@drupflare/cartridge

中文文档
0.3.1发布于 2026/10/01MITTypeScriptES Module构建来源已验证近 30 天下载 2201

Run a blocking wasm interpreter inside a Cloudflare Durable Object

原始使用文档
文档目录17 节
  1. 📋 Table of Contents
  2. 🎯 Why Cartridge
  3. 📥 Install
  4. 🚀 Quick Start
  5. 🧰 What Is Here
  6. 🧭 Subpath Exports
  7. ✨ Ergonomics
  8. 🔔 Error Handling
  9. 🚧 Reentrancy Gate
  10. 🎭 Interrupt Mask
  11. 💾 Filesystem
  12. 🩺 Supervisor
  13. 📡 Tail Worker
  14. 🏗 Two Layers
  15. 🧪 Testing
  16. 🔗 Related Repositories
  17. 📄 License

🎮 cartridge

Run a blocking wasm interpreter inside a Cloudflare Durable Object

Build Interpreters Prettier codecov License

cartridge runs a blocking wasm interpreter inside a Cloudflare Durable Object. Pass createCartridge() a wasm module and call run(); the reentrancy gate, the interrupt mask and the filesystem are wired for you. Every piece stays exported for callers that need them directly.

The Workers runtime is asynchronous and a wasm interpreter is not. Two overlapping invocations share one mutable machine — globals, open file descriptors, session state — and interleave into plausible wrong output rather than failing.

Nothing here is specific to PHP. Real builds run through tests/interpreters/ on every push: PHP 8.3 (php-wasm), Lua 5.4 (wasmoon), CPython 3 (Pyodide), QuickJS (quickjs-emscripten), CRuby 3.4 (ruby.wasm, over WASI rather than emscripten) and Java (bytebox, compiled ahead of time rather than interpreted). See ADVANCED_USAGE.md for the per-language status and a recipe for each.


📋 Table of Contents


🎯 Why Cartridge

A wasm interpreter is a single mutable machine. Globals, the autoloader cache, the container pointer, open file descriptors and session state are all process-wide. Two overlapping invocations do not fail loudly. They interleave and produce plausible wrong output.

Cloudflare's runtime is the opposite shape. It is event-driven and it will happily deliver a second request while the first is parked. Durable Objects narrow that, but do not close it.

So the host has to supply four things the interpreter cannot supply for itself: serialisation, a way to hold off an interrupt across a host call, a filesystem that does not cost a full inflate at boot, and a repair path that does not run inside the thing it repairs.

Each is silent when it is missing. An unmasked callback surfaces as SuspendError from wherever the opcode counter happened to land, months later and under load.


📥 Install

bun add @drupflare/cartridge

Two runtime dependencies, fflate and fzstd, both synchronous decompressors. A lazy mount inflates inside a synchronous open() from wasm and a pre-compressed interpreter inflates at module scope; neither point can await DecompressionStream, which is also limited to gzip and deflate.


🚀 Quick Start

import { createCartridge } from '@drupflare/cartridge';

const cartridge = createCartridge({
  // whatever your build's factory is; forward the io it gives you
  instantiate: (io) => initInterpreter({ print: io.print, printErr: io.printErr }),
  // files every run should see, written once at boot
  files: { 'lib/helper.txt': 'shared by every run' }
});

const result = await cartridge.run('print("hello")');
console.log(result.stdoutText); // "hello\n"
console.log(result.status); // 0

That is the default path. Every entry is serialised, every print runs masked, the script is in the filesystem before callMain is entered, and a leaked mask depth is reported by name instead of poisoning the next run.

What your build has to satisfy is two members:

interface Interpreter {
  FS: MountFS; // emscripten's FS: mkdir, writeFile, and optionally utime
  // may answer a promise, for an adapter with work to finish after main returns
  callMain(argv: string[]): number | void | Promise<number | void>;
}

which is what emscripten produces for a main()-having program built with -sINVOKE_RUN=0 and -sEXPORTED_RUNTIME_METHODS=callMain,FS. Most published builds do not do that: wasmoon, Pyodide, quickjs-emscripten, ruby.wasm, php-wasm and bytebox were all checked and none of the six exports callMain, so writing it as a two-line adapter is the normal case rather than the fallback. createMemoryFS() supplies the other half when a build has no filesystem either, and FS is three method signatures rather than emscripten's object, so a WASI preopen satisfies it too. See the recipes in ADVANCED_USAGE.md, each labelled with what was actually run.


🧰 What Is Here

ModuleWhat it does
lazy-fs.tsMEMFS mount that inflates a file on first open, from layered per-file packs
supervisor.tstripwires, the health ledger, the circuit breaker, the repair ladder
cartridge.tscreateCartridge(): the high-level default, and RunResult
mount.tsthe eager streaming mount, the driver mount, mountRecord(), createMemoryFS()
tail-worker.tssummarises a Tail Worker's events into something a human reads
mask.tsthe refcounted interrupt mask, plus the VM slice counters
inflate.tssynchronous zstd: content size, inflateZstd, a wasm-backed decoder
util.tsfromUtf8/toUtf8, encodeJson/decodeJson, toBytes, splitLines
serialize.tsGate: one interpreter, one invocation at a time
errors.tsthe five-name error vocabulary, each with a stable dotted code
worker-shim.tsmakes the emscripten ENVIRONMENT=worker build instantiate on workerd

🧭 Subpath Exports

One package, sideEffects declared honestly, and a subpath per concern so a bundler can drop what you do not use.

ImportWhat is in it
@drupflare/cartridgeeverything below except the shim
@drupflare/cartridge/cartridgecreateCartridge(), RunResult, Interpreter
@drupflare/cartridge/gateGate, doGate, GateStats
@drupflare/cartridge/maskcreateMask, the singleton, vmFromBinary, the slice counters
@drupflare/cartridge/fsboth mounts, mountRecord, createMemoryFS, both pack-index shapes
@drupflare/cartridge/inflatesynchronous zstd, for a pre-compressed module inflated at boot
@drupflare/cartridge/supervisortripwires, CircuitBreaker, the ledger DDL
@drupflare/cartridge/tailthe Tail Worker handler and its pure reducers
@drupflare/cartridge/utilthe byte, string and JSON conveniences
@drupflare/cartridge/errorsthe error vocabulary
@drupflare/cartridge/shimside-effectful; import for effect before the glue evaluates

[!WARNING] sideEffects is an array rather than false. worker-shim.ts patches globalThis at import time; declaring the package side-effect-free would let a bundler tree-shake it away, which deletes the Asyncify stub and restores an uncatchable request kill. Declaring true would disable shaking for the whole package. The array is the only honest answer, and tests/exports.spec.ts pins it.


✨ Ergonomics

Never build a TextEncoder to use this package. Every payload takes string | Uint8Array; everything that hands bytes back also hands back decoded text.

import { createCartridge, fromUtf8, toUtf8 } from '@drupflare/cartridge';

const cartridge = createCartridge({ instantiate });

// a string or bytes, both fine
await cartridge.run('print(1)');
await cartridge.run(fromUtf8('print(1)'));

// structured input, no encoder at the call site
await cartridge.writeJson('input.json', { items: [1, 2, 3] });

const result = await cartridge.run('read input.json and print a total');
result.stdout; // Uint8Array, the primitive
result.stdoutText; // string, what you wanted
result.json<{ total: number }>(); // parsed
result.lines(); // ['{"total":6}']
result.lastLine(); // the last line, no trailing empty element
result.assertOk(); // throws InterpreterError naming the status and stderr

And the raw path is still there:

// the module itself, instantiated if needed, handed over inside the gate
const raw = await cartridge.interpreter();

// or run arbitrary work against it, still serialised against every other entry
await cartridge.withInterpreter((module) => module.FS.writeFile('/tmp/x', 'by hand'));

Naming is fixed so it is learnable once: to*/from* for codecs (toUtf8, fromUtf8), *Json for serialise/parse (writeJson, encodeJson, decodeJson), *Text for decoded fields (stdoutText), .json() / .lines() on a result.


🔔 Error Handling

Five names, one base, and every one carries a stable dotted code. Match on the code, not on the message text.

ClassDefault codeRaised when
CartridgeErrorcartridge.errorthe base; also the JSON codec refusals
GateErrorgate.errorthe gate was misused, or observed two runs at once
MaskViolationErrormask.violationan unbalanced mask, or a suspension attempted while masked
MountErrormount.errora pack was unreachable, or the FS refused a write
BudgetErrorbudget.exceededa slice, a resident-bytes ceiling or an interrupt allowance
InterpreterErrorinterpreter.errorinstantiation, no entry point, a nonzero exit, a leaked mask
import { InterpreterError, isCartridgeError } from '@drupflare/cartridge';

try {
  (await cartridge.run(script)).assertOk();
} catch (error) {
  if (error instanceof InterpreterError && error.code === 'interpreter.nonzero_exit') {
    // the script failed, which is different from the interpreter failing
  } else if (isCartridgeError(error)) {
    // something in the cartridge layer; error.code says which seam
  }
}

The codes use the same dotted shape as supervisor.ts's Finding.code, so a thrown error and a recorded finding about the same defect correlate without a translation table.


🚧 Reentrancy Gate

Gate serialises entry into the interpreter: one invocation at a time, queued in FIFO order.

Every host call is synchronous today, so nothing overlaps yet. One suspending call — JSPI, an awaited query, an outbound fetch — is enough for a second request to enter while the first is parked mid-request.

Two separate mechanisms exist:

MechanismWhere it worksWhy not only this one
Gatea plain Worker, a Durable Object, wrangler devportable, and what the tests drive
ctx.blockConcurrencyWhile()a Durable Object onlystronger — the runtime stops delivering events — but it caps at 30 s and cannot be nested

doGate(gate, ctx) is the combination, and the order matters: the gate is entered first and the block is taken inside it, never the other way round; blockConcurrencyWhile cannot nest. Pass a ctx to createCartridge() and you get that automatically.

[!CAUTION] Acquiring the gate inside a call that already holds it hangs forever, with no error. alarm() is not gated as a whole while fetch() is, so before adding a gated entry point, check whether you are already inside one.


🎭 Interrupt Mask

mask.ts is the piece with the least obvious reason to exist. _zend_wasm_slice_arm(period) fires the VM interrupt on an opcode counter, not a seam, and JSPI cannot suspend a stack with a JS frame in it — SuspendError: trying to suspend JS frames. Several host calls put exactly that frame under the interpreter's stack: the SQL bridge, the codec inside it, the logger, every capability call, every lazy-FS inflateSync, and every print callback.

So the interrupt has to be held off for the duration of the host call and released after it. The mask is refcounted, since those calls nest, and it exposes counters so a leak is visible rather than inferred:

import { createMask } from '@drupflare/cartridge/mask';

const mask = createMask();

const rows = mask.withMask(() => {
  // any host call that puts a JS frame under the interpreter goes here
  return [];
});

const { enters, nested, maxDepth, deferred, violations } = mask.stats();

Four properties, each of which is a way this goes silently wrong without it:

  1. Refcounted. The cases nest — a host call can trigger a lazy-FS read — and a non-counting mask unmasks at the inner exit.
  2. A pending flag that fires on unmask. zend_wasm_tick_fired() skips EG(vm_interrupt) entirely while masked; it does not defer it, so the boundary is lost unless the host remembers it.
  3. The budget re-checked on unmask, not only at the poll site. One inflate member is ~1 ms of local ratio, so a masked window can overrun a slice on its own.
  4. A dev assertion that suspension never happens above depth 0. MaskViolationError is thrown rather than swallowed.

createCartridge() gives each cartridge a fresh mask rather than the module singleton: two interpreters in one isolate are two C-side counters, and sharing one host counter would make both depths wrong. The singleton stays exported for the single-interpreter case the worker uses.

[!NOTE] The depth the host tracks is the host depth. The C handler masks itself for the duration of its own yield, so zend_wasm_slice_stat(4) reads 1 inside a yield and that is correct, not a violation. Never assert on it.


💾 Filesystem

Three mounts, for three different sizes of problem.

FunctionForCost
mountRecord()a handful of files you have in handnone; a plain Record<string, string | bytes>
mountDrupalStreaming()a whole tree, materialised at bootone inflate of everything
mountDrupalLazy()a whole tree, inflated per file on first open()one resident compressed blob + an LRU

mountRecord() is the one a first use wants, and createCartridge({ files }) calls it for you. A pack is the right answer for an 11,421-file CMS tree and the wrong answer for the three files an interpreter needs to run one script.

When the build has no filesystem of its own — quickjs-emscripten exports none, and a TeaVM or WASI target has no emscripten FS either — createMemoryFS() is a MountFS over a Map, with read() and readText() so the adapter never builds a TextDecoder. MountFS.utime is optional for the same reason: wasmoon's Lua build ships a real emscripten FS and no utime at all, and only mountDrupalStreaming() ever calls it.

The lazy mount attacks cold start, measured at 3,754 ms of cpuTime and 3,066 ms of that was the mount. Layers merge in order, so a driver or a patch can shadow a base pack without either being rebuilt, and InflateStats reports what was actually inflated — which is the measurement that says whether laziness paid.

[!IMPORTANT] StreamPackEntry and PerFilePackEntry are not one type and must not be merged. o means different things: the streaming index offsets into the INFLATED concatenation core.bin.gz produces, the per-file index offsets into the COMPRESSED blob and needs c beside l. One interface covering both would give o two meanings and make c optional for materialise(), which computes blob.subarray(e.o, e.o + e.c) and would get NaN.

Two further behaviours:

  • Layers merge BEFORE node creation, so a later layer overrides an earlier one on the same path.
  • Heap restore depends on the open file-descriptor table, at the same fd numbers. Inode alignment does not matter: that was measured and falsified (shifting every inode by 1 and by 500 both restored byte-identically). Dropping /dev/urandom's fd alone throws; dropping sqlite's fds gives a locking-protocol error after an 80-120 second stall, which on an edge runtime is a hung request, not an error. If you touch the mount or the snapshot path, that is the invariant.

An R2-backed layer costs zero subrequests, making R2 the right store for a mutable layer.


🩺 Supervisor

supervisor.ts holds the half of a health layer that must be JavaScript: a repair path must not depend on the subsystem it repairs.

The interpreter cannot observe a JS throw out of a wasm import — measured twice, @ and a catch are both useless and the whole invocation dies — cannot observe its own isolate being killed, and cannot be trusted to fix itself once poisoned. So detection and repair for those classes live out here.

Every tripwire corresponds to a defect this project has already shipped and then found. That is the selection criterion, and each one names its incident. A check nobody has seen fire is decoration.

The breaker decays rather than looping: the same code N times inside a window escalates one rung on the ladder, a clean interval decays one rung, and bounded attempts halt with a named reason.

import {
  CircuitBreaker,
  quarantineDecision,
  runHostTripwires
} from '@drupflare/cartridge/supervisor';

const findings = runHostTripwires({ status: 200, bytes: 0, path: '/' });
const { quarantine, reason } = quarantineDecision(findings);
// quarantine === true, reason === 'render.empty: 200 with 0 bytes'

Quarantine beats wrong output. A 503 with Retry-After is a better answer than a 0-byte 200, and this project has shipped the 0-byte 200 — then cached it, then served it from the edge.


📡 Tail Worker

wrangler tail reports CPU only while an operator is attached. A Tail Worker receives the same trace events from inside the platform, continuously and with no operator present.

It also carries the canary. The CPU-attribution result the whole slicing design rests on is undocumented behaviour: work parked in one Durable Object invocation and resumed in another is charged to the resuming invocation. If Cloudflare changes that, nothing in the product fails loudly — renders just stop fitting. So evaluateCanary() re-checks the invariant, with thresholds as ratios rather than milliseconds. Absolute cpuTime varies by colo (a render that was 46 ms on one deploy was 75 ms on another).


🏗 Two Layers

The sibling library edgeport uses a two-layer rule: a private src/core/ is the only code importing the platform primitive, and every public module builds on it. That rule does not transfer here.

cartridge imports nothing from the platform. grep -rn "from 'cloudflare" src/ returns nothing. The primitive it is about — an emscripten Module with an FS — is instantiated by the consumer and handed in through instantiate, and the Durable Object state arrives as an optional BlockingContext with one method on it. So the invariant the two-layer split exists to protect is already satisfied by there being no platform import to contain.

What does transfer, and is applied: subpath exports from one package, an honest sideEffects declaration, a uniform error vocabulary, ergonomics with an escape hatch, and recipes each backed by a green spec.


🧪 Testing

bun run typecheck
bun run test # in workerd
bun run test:coverage
bun run test:interpreters # 40 assertions against real wasm builds, in node

382 passing, 99% of statements. The gate runs under @cloudflare/vitest-pool-workers, so it executes inside workerd rather than Node, and coverage uses provider: 'istanbul' rather than v8. That is a runtime fact, not a preference: the v8 provider reads coverage off the Node inspector, and these tests run inside workerd's isolate, so it attributes zero while every test passes.

Four brackets, and each one is honest about what it can prove:

BracketWhereProves
behaviour over a fakemost specsthe contract, without a 40 MB binary in the way
the real platform primitivemount.spec.ts (real gzip, real Response, Fetcher)the inflate-and-write path end to end
source assertionslazy-fs.spec.ts, part of mask.spec.tsinvariants no fake can reach into
a real wasm interpretertests/interpreters/ (PHP, Lua, CPython, QuickJS, Ruby, Java)that a real build satisfies { FS, callMain }

That last lane is the only thing that makes ADVANCED_USAGE.md say Verified. Its builds are pinned devDependencies so renovate bumps them, and e2e.yml runs it on every push with a guard that fails if zero tests ran or any was skipped: a silently skipped lane reads as a pass.


RepositoryWhat it is
drupflare/workerthe consumer: Drupal 11 on Cloudflare Workers
drupflare/durabledbimports /gate and /mask for its SQL bridge
drupflare/phasmbuilds the interpreter, including the VM interrupt patch the mask drives
drupflare/stream-httpthe PHP-side https:// stream wrapper, over an injected fetch
drupflare/untarltar and tar.gz extraction with no Node APIs

📄 License

MIT (c) Gregory Mitchell 2026. See LICENSE.

版本与发布记录

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

版本发布日期距今
0.3.1latest2026/10/011 个月前
0.3.02026/09/301 个月前
0.2.02026/08/292 个月前
0.1.32026/08/222 个月前
0.1.22026/08/152 个月前
0.1.12026/08/152 个月前

关于 @drupflare/cartridge 的常见问题

@drupflare/cartridge 是什么?

Run a blocking wasm interpreter inside a Cloudflare Durable Object

如何安装 @drupflare/cartridge?

在项目目录运行 npm install @drupflare/cartridge,或使用 pnpm add / yarn add / bun add。

@drupflare/cartridge 支持 TypeScript 吗?

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

@drupflare/cartridge 支持 ESM 还是 CommonJS?

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

@drupflare/cartridge 可以商用吗?

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

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

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

@drupflare/cartridge 还在维护吗?

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