@drupflare/burrow

中文文档
1.1.1发布于 2026/09/25MITTypeScriptES Module构建来源已验证近 30 天下载 2350

Execute arbitrary WebAssembly on Cloudflare Workers

原始使用文档
文档目录14 节
  1. 📋 Table of Contents
  2. 🎯 Why Burrow
  3. 📥 Install
  4. 🚀 Quick Start
  5. 🔁 Execution Paths
  6. 🔗 Dynamic Libraries
  7. 📦 Declaring a Runtime
  8. 🧮 Memory Budget
  9. ⚡ Parallel Lanes
  10. 🩺 Doctor
  11. 🔒 Security
  12. 🧭 Subpath Exports
  13. 🧪 Testing
  14. 📄 License

@drupflare/burrow

Execute arbitrary WebAssembly on Cloudflare Workers.

📋 Table of Contents

🎯 Why Burrow

Cloudflare Workers forbids WebAssembly code generation at request time. A Worker cannot call new WebAssembly.Module, WebAssembly.compile, eval or new Function, so it cannot run code it did not know about at deploy time.

burrow runs it anyway, by not compiling it. An interpreter compiled to wasm ships in the bundle, and a guest module arriving with a request is data to that interpreter. Nothing is generated, so nothing is blocked.

The same property makes dynamic linking work. Loading a -s SIDE_MODULE build normally means producing a funcref for every address-taken symbol, which is code generation. To an interpreter a function pointer is an index into its own table, so that step does not exist.

burrow ships no language runtime. The consumer provides one; burrow handles acquisition, residency and safety.

📥 Install

bun add @drupflare/burrow

The types expect the Workers globals, from @cloudflare/workers-types or wrangler types.

🚀 Quick Start

import wasm3 from '@drupflare/burrow/vendor/wasm3.wasm';
import { createInterpreter } from '@drupflare/burrow/interpret';

export default {
  async fetch(request: Request) {
    const vm = await createInterpreter({ module: wasm3 });
    const guest = vm.load(new Uint8Array(await request.arrayBuffer()));
    return Response.json({ answer: guest.call('main') });
  }
};

Host functions are plain JavaScript, which is legal here because the guest's call_indirect never touches a real funcref table:

const guest = vm.load(bytes, {
  imports: {
    env: {
      now: { signature: 'i()', fn: () => Date.now() | 0 },
      log: { signature: 'v(ii)', fn: (ptr, len) => console.log(guest.readText(ptr, len)) }
    }
  }
});

🔁 Execution Paths

Three paths, chosen by one question: do you know this module at deploy time?

PathForSpeed
interpretbytes the deployment never sawsee the ratio below
importmodules that shipped in the bundle1.00x, native
publishbytes that should become native, in the background1.00x once live

interpret is the default because it is the only one that always works. import is not better, only narrower.

The interpreted ratio is set by the guest, not by burrow:

ratio = 1 + D_cy x G, where G is guest instructions retired per native cycle.

The interpreter's overhead is additive and nearly constant, so what decides the ratio is how much the guest already stalls. A pointer chase over a 64 MiB working set measures 1.01x; the same chase over 16 KiB measures 4.41x; a per-byte transform measures 20x.

Match the work to the path. Memory-bound and latency-bound work interprets for almost nothing. Compute-dense work with a small working set belongs in the bundle, or on the native path via publish. ADVANCED_USAGE.md carries the measured curve and the by-shape table.

🔗 Dynamic Libraries

import { createLinker } from '@drupflare/burrow/dylink';

const linker = createLinker(vm);
const lib = linker.load(soBytes, { name: 'ext' });

lib.call('ext_run', 20, 22);
lib.readText(lib.address('greeting')!);

Linked against a host runtime, an extension shares the host's heap, table and symbols, which is what a real extension ABI needs:

const host = vm.load(mainModuleBytes);
const ext = createLinker(vm, { host }).load(extensionBytes);

ext.call('ext_install'); // allocates from the host heap and calls back into it

Admission reads the library's declared demand rather than its size, because a library's .bss occupies no bytes in the file:

const { memorySize, tableSize, needed } = linker.inspect(soBytes);

Verified against zlib 1.3.2 built as an ordinary -s SIDE_MODULE: the interpreted library compresses, and node's own zlib reads the result back.

Loading at request time buys reach rather than speed. Anything on a hot path belongs in the bundle. ADVANCED_USAGE.md has the cost model and the work shapes that suit it.

📦 Declaring a Runtime

import { Burrow, defineRuntime } from '@drupflare/burrow';

const php = defineRuntime({
  name: 'php',
  load: () => import('./runtimes/php.js'),
  instantiate: async ({ loaded, io, lines }) => {
    const mod = await loaded.PHPFactory({ stdout: lines(io.print) });
    return { FS: mod.FS, callMain: (argv) => mod.callMain(argv) };
  },
  memory: { initial: 96 * 1024 * 1024, peak: 116 * 1024 * 1024 }
});

const burrow = new Burrow({ runtimes: [php] });

await using sh = await burrow.session('php');
await sh.evalText('<?php echo phpversion();');

load must be a thunk around a literal specifier, because esbuild cannot follow a computed one.

🧮 Memory Budget

Budget tracks linear memory per resident runtime against the isolate cap, refuses a boot that would not fit, and evicts least-recently-used residents first. Declared figures are corrected from observation after each run.

A lease is the interlock: nothing leased is ever evicted, and a boot that cannot fit throws BudgetError rather than letting the isolate run out.

⚡ Parallel Lanes

One isolate has one thread. @drupflare/burrow/parallel spreads one job across Durable Objects of the same Worker, each on its own execution context, and gathers the results in order.

import { defineLane, LanePool } from '@drupflare/burrow/parallel';
import wasm3 from '@drupflare/burrow/vendor/wasm3.wasm';

export const BurrowLane = defineLane({
  interpreter: wasm3,
  tasks: { checksum: (input) => input.reduce((a, b) => (a + b) >>> 0, 0) }
});

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const pool = new LanePool(env.BURROW_LANES, { size: 16 });
    const sums = await pool.map({ task: 'checksum' }, [new Uint8Array([1, 2]), 'text']);
    return Response.json(sums.map((r) => r.number()));
  }
};
"durable_objects": { "bindings": [{ "name": "BURROW_LANES", "class_name": "BurrowLane" }] },
"migrations": [{ "tag": "burrow-lanes", "new_sqlite_classes": ["BurrowLane"] }]

A slice can be guest wasm, a named task from your bundle, or a runtime evaluation. The pool retries failures on spare lanes and hedges slow slices, and accepts exactly one result per slice. Measured through the packed package on deployed Workers, a warm 16-lane pool ran an interpreted guest job that takes 3-4 s on one lane 7.8-8.5x faster; shorter jobs scale less, because every slice pays a fixed dispatch cost.

spawn and scope give thread-like handles, atomic and mutex coordinate lanes, and channel streams messages between them. ADVANCED_USAGE.md covers the semantics and the measurements.

🩺 Doctor

bunx burrow doctor ./runtimes/php.js

Scans a runtime for patterns that are fatal on Workers: eval, new Function, request-time wasm compilation, browser-only glue, and an unguarded self.location read. It reports findings and never a clean bill of health, because a source scan cannot prove the absence of a JIT. Exit code 0 means nothing known-fatal was seen.

🔒 Security

burrow runs code the deployment never saw. Treat a guest as untrusted unless you built it.

What the platform already handles. A Worker isolate is ephemeral and has no filesystem, no subprocesses and no syscalls, so the persistence-shaped threats do not apply: there is nowhere for a miner to keep running, nothing for a trojan to install into, and no host to pivot to. A hostile guest gets one request.

What is left is the capabilities you hand over. The guest can reach exactly as far as the host functions you supply. An import that fetches reaches the network; one that reads a binding reads your data; one that takes a pointer and a length can be handed any pointer. Supply the narrowest imports that do the job, validate arguments inside them rather than trusting the guest, and grant nothing you would not grant a browser extension.

const vm = await createInterpreter({ module: wasm3, maxMemoryBytes: 64 * 1024 * 1024 });

const guest = vm.load(untrustedBytes, {
  imports: {
    env: {
      // no fetch, no bindings, no clock the caller did not ask for
      log: {
        signature: 'v(ii)',
        // cap the length here rather than trusting the one the guest passed
        fn: (ptr, len) => console.log(guest.readText(ptr, Math.min(len, 4096)))
      }
    }
  }
});

Set maxMemoryBytes for any guest you did not build. Without it a guest can grow memory until the isolate dies, and an isolate OOM fails every request on that isolate rather than one.

Dynamic libraries linked into a host are not sandboxed from it. They share one memory and one table, so a library can read and write everything the host holds and call anything the host can. That is what an extension ABI is, and it is also what a hostile library wants, so it has to be asked for:

createLinker(vm, { host, allowHostAccess: true }); // only for libraries you would run in-process
createLinker(vm); // user-supplied: each library gets an address space of its own

Without a host, libraries resolve no symbols across each other and cannot reach the host's heap.

Guest memory is isolated; guest CPU is not. Every guest access is bounds-checked, so a guest cannot read the interpreter or another guest. Nothing here stops a guest spinning, so the Worker CPU limit is what ends a runaway loop. Do not run untrusted guests inside a Durable Object you need to stay responsive.

Isolates are shared across requests. Anything a previous request left in an interpreter's memory is readable by the next guest loaded into it. Create a fresh interpreter per tenant, or per request where the data warrants it.

burrow doctor reports findings and never a clean bill of health, so it is a lint rather than a gate. ADVANCED_USAGE.md has the threat model in full.

🧭 Subpath Exports

ExportContents
.everything except ./parallel
./interpretcreateInterpreter, WasmInterpreter
./dylinkcreateLinker, readDylink, Library
./registryBurrow, leases
./sessionSession and the eval surface
./budgetBudget
./runtimedefineRuntime, RuntimeSpec
./adaptlines, memoryFS, mkdirp
./doctorinspectSource, inspectWasm
./publishpublishVersion
./probeprobe
./parallelLanePool, defineLane, LaneTask
./errorsevery error type and its code

The interpreter binary is an asset rather than a module, and it is imported by path:

import wasm3 from '@drupflare/burrow/vendor/wasm3.wasm';

🧪 Testing

bun run typecheck
bun run test          # gate: unit and node
bun run test:runtimes # real builds installed from npm
bun run format:check

Every error carries a stable dotted code. Match on that, never on a message.

📄 License

MIT

版本与发布记录

@drupflare/burrow 共发布 3 个版本,首次发布于 2026/09/18;过去 12 个月发布了 3 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.1.1latest2026/09/251 个月前
1.1.02026/09/241 个月前
1.0.02026/09/181 个月前

关于 @drupflare/burrow 的常见问题

@drupflare/burrow 是什么?

Execute arbitrary WebAssembly on Cloudflare Workers

如何安装 @drupflare/burrow?

在项目目录运行 npm install @drupflare/burrow,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 burrow,可以用 npx burrow 直接运行。

@drupflare/burrow 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@drupflare/burrow 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@drupflare/burrow 可以商用吗?

@drupflare/burrow 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@drupflare/burrow 的下载量有多少?

根据 npm 官方下载统计,@drupflare/burrow 在 2026/10/13 – 2026/11/11 这 30 天内被下载 2,350 次,日均约 78 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@drupflare/burrow 还在维护吗?

最新版本 1.1.1 发布于 2026/09/25(1 个月前);至今共发布 3 个版本,其中过去 12 个月发布了 3 个;首次发布于 2026/09/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。