Gaps
This is the list of what CellB does not do yet, or does differently from celld, as of the current code. Each entry says what you see. When a gap closes, its entry goes, in the same commit.
Recorded celld behaviours CellB does not reproduce
Each is a knownGap scenario in conformance/scenarios.ts: the Node suite expects it to differ
from the celld golden, and turns red when it stops differing (conformance.md).
| Scenario | What differs |
|---|---|
wd-sql-default | CellB and celld stop at the same assertion, sql.ingest()’s syntax error, which CellB words as celld does (sql-ingest agrees), but celld’s report of it carries celld’s own stack frames. |
wd-actor-fetch-rejection | No global addEventListener('unhandledrejection') in Node; celld’s own assert rethrows its rejected: … [at …] error. |
wd-websocket-hibernation | The other way round: celld fails this workerd test (got ); CellB passes it, as workerd does. |
wd-websocket-constructor-gaps | The host’s own classes: Node’s WebSocket takes 'BLOB' as 'blob', and Chromium’s CloseEvent keeps lone surrogates in a close reason. |
wd-worker-loader-gaps | WorkerCode.compatibilityFlags; abortIsolateDynamic* do not build; getDurableObjectClass(name, { props }) ignores its props; facetUafRegression needs gc(). celld refuses allowExperimental, so most of these exports fail on celld as well. |
miniflare2-details-relative-url | A stub’s fetch() of a relative URL: celld passes it to the object, which fails on new URL(); CellB rejects at the stub, as workerd does. |
Stubs pass by reference, as on workerd, except between nodes
- Functions,
RpcTargets and stubs pass by reference through a stub (crossinsrc/core/handles.ts) and into or out of a loaded Worker’s isolate, in itsenv,props, arguments and results (src/isolate/protocol.ts). celld refuses them; these cases are judged against workerd (conformance.md). - A call on a stub returns an RpcPromise, as on workerd:
stub.a().b()andawait stub.a().x.ywork beforea()settles. Across an isolate’s portbis sent at once, naminga’s pending answer; in one realm, and to an object on another node,bruns oncea()settles. It isinstanceof Promisebut not a native promise, and it cannot be called itself (stub.getFn()()fails: await the function first). Only the first call on a loaded Worker’s entrypoint result is pipelined across the isolate; a call on that call’s result waits for it. - A stub into an isolate holds its value until released.
stub[Symbol.dispose]()or garbage collection of the stub releases it; then the value’s ownSymbol.disposeruns. A stub used afterSymbol.disposerejects withA stub was used after it was disposed. - Stubs do not cross to another node. An object owned by another tab, worker or peer is called
over cells-http or celld’s tunnel with V8-serialized arguments and results: a function,
RpcTargetor stub in either is refused withDataCloneError: Functions, RpcTargets and stubs cannot be sent to another node: they pass by reference only within one runtime.(toAnotherNodeinsrc/core/handles.ts). - Only
ctx.exports.X({ props })stubs and class handles can be stored in Durable Object storage (as V8 host objects,[name, props], restored against the Worker’sctx.exports). celld cannot read such a value; other stubs andRpcTargets are refused withDataCloneError. - A fetch into a loaded Worker crosses as HTTP/1.1, so hop-by-hop request headers (
Connection,Upgrade,TE,Keep-Alive) do not reach it.
Firefox and WebKit
pnpm e2e:firefox runs the browser suite in Firefox; pnpm e2e stays on Chromium. (pnpm e2e:webkit
is wired, but WebKit is not run: see the last item.)
Module ServiceWorkers, OPFS synchronous access handles (the sahpool VFS), Web Locks and module
Dedicated Workers all work in Firefox 157, and the whole suite passes there. What differs
(browser-host.md):
- Firefox has no
Request.body; CellB gives it one, read whole.createCellRuntimeinstalls arequest.bodythat reads the body witharrayBuffer()on its first read, so Worker code gets aReadableStream, but the whole body is in memory before the first chunk. Firefox stores aReadableStreamgiven as a request body as the text[object ReadableStream], so Worker code that forwards one (new Request(url, { body: request.body }),fetch(url, { body: … })) sends that text. Useawait request.arrayBuffer(), or the request itself, as the body. - WebKit is not run, and is not a target for now: Playwright’s WebKit needs the system
libavif16(libavif.so.16), which this host lacks.
Other gaps
- Configuration CellB does not support is reported in
createProjectRuntime(...).unsupported, not silently ignored:migrations.renamed_classes,durable_objects.bindings[].script_name, service bindings to other scripts, the Workers AI binding (ai) when the host names no OpenAI-compatible endpoint for it, cron triggers without ascheduled()handler, and compatibility flags it does not know. Renaming a Flue agent renames its class, so it needsrenamed_classes. Browser tabs log it (cellb: project … shard …: unsupported: …). process.envholds the project’s textvarsin a browser only. workerd fills it under nodejs_compat from compatibility date 2025-04-01; a browser host does, but a Node host shares its realprocesswith the Worker, so there the embedder sets what the code reads.waitUntilofcloudflare:workersruns in the runtime’s background untildrain(), not tied to the event that called it (nothing cancels an event’s work after its response here).node:cryptoin a browser has no signature checks.createPublicKeyreturns aKeyObject(type and DER/PEM export) andverify/createVerifythrow: a synchronous check needs a pure-JS implementation.node:module’srequireserves only the built-ins CellB polyfills.- Workers AI is an OpenAI-compatible endpoint.
env.AI.run()serves text generation and embeddings through the host’saiendpoint, with Workers AI model ids mapped to its models; image, speech and classification models throw, andoptions.gateway(AI Gateway) is ignored. envandexportsofcloudflare:workersare empty in a Worker loaded into a site project’s realm. Every project gets its own, and a loaded Worker gets its own (itsWorkerCode.env, and stubs of its own entrypoints) on Node, in a browser bundle’s realm, and in an isolate (isolateScript). On an in-browser site withoutisolateScriptthe project and its loaded Workers share the realm’s module registry: itscloudflare:workersentry answers by the module that asks (a loaded Worker’s proxy is a module under<site>loaded/, found on the stack), and gives loaded code empty objects; anything else that reads the entry gets the project’s.cloudflare:emailonly loads. It exists so code that imports it at top level (the Agents SDK) starts, on Node and in browser bundles; building anEmailMessagethrowscloudflare:email is not supported by CellB. An in-browser site provides it too.node:fsandnode:osin a browser have no host behind them. With nodejs_compat a browser bundle getsnode:fswhose calls fail withENOENT(existsSyncis false) andnode:oswith fixed answers (EOLis"\n", no CPUs, no memory); they let libraries that probe for files load. Other Node.js modules without a browser polyfill (node:child_process,node:readline, …) load as stubs whose exports throw when called, as wrangler’s nodejs_compat stubs do on Cloudflare.AsyncLocalStoragein a browser carries its stores only through code it can see. Thenode:async_hookspolyfill keeps eachrun()to its synchronous part and makes continuations carry their stores (Promise.prototype.then, timers,queueMicrotask); a nativeawaitbypassesthen, so with nodejs_compatawaitis lowered to generators: bybundleWorkerForBrowser(esbuild) in a bundle, bycreateProjectSite(lowerAwait, after webrun-modules) in a site’s modules. Both then answer as on Node (test/async-context-worker.ts, 16 objects at once). Not lowered: async generators’ own awaits, async functions that usesuperornew.target(on a site; esbuild lowers those too), and top-level await; code there resumes in no store after anawait, rather than in another event’s. In a browser with TC39 AsyncContext (Stage 2, shipped by none yet) the polyfill is a layer overAsyncContext.VariableandSnapshotinstead, sites lower nothing (bundleWorkerForBrowsertakeslowerAwait: false), and the engine carries the stores through native awaits as workerd and celld do;enterWith()throws there (test/p18 runs it on an AsyncContext made of Node’s own). Event listeners (addEventListener, MessagePortonmessage) do not carry stores, on Node either.- An object’s events start in the async context the runtime was made in. A call to an object
is a new event, as on Cloudflare: the callee (and its constructor) does not see the caller’s
AsyncLocalStoragestores. Where Cloudflare has none, CellB has the stores that were current whencreateCellRuntimeran, so a host can scope a whole runtime (test/p09 attributes each scenario’s logs this way). On Node this needsprocess.getBuiltinModule(Node 22.3 and later); in a browser it goes through CellB’snode:async_hookspolyfill. - A browser runtime is one thread, and OPFS writes are its cost. Its cells interleave, and
each commit’s SQLite I/O is synchronous on that thread. A Flue conversation turn (a prompt, a tool
call, two model turns) makes 21 commits (155 when commits ended with each microtask); in this
repository’s CI-like container an OPFS write costs about 0.75 ms whatever its size, and those
writes are about 80% of the worker’s time. For parallel threads, spread the project over
several workers (
startProjectShards): 32 Flue conversations take 16 s on one worker and 11 s on four there, where four workers writing OPFS at once slow each other’s writes. WithjoinProjectShards, the tabs of an origin share the shards (an even split, moved as tabs come and go); placement of cells within the shards stays by hash. Opening a cell reserves room in the OPFS pool first and the pool grows on demand; a database or journal that SQLite creates mid-event (SAH pool is full) can still fail if a burst outruns the background growth. - The
WebSocketglobal of Worker code is workerd’s, built over the host’s. Its prototype is the server-side socket’s (serializeAttachment,deserializeAttachment: partyserver and the Agents SDK reach attachments through it), it has theREADY_STATE_*statics, andinstanceofcovers both ends of aWebSocketPairand client sockets.new WebSocket(url)returns the host’s own client socket, so a class that extends the global gets a host socket, not an instance of itself. ItsRequestglobal carriesUpgradeandSec-WebSocket-*into copies of a request, which a browser’snew Request()drops; the host’s own requests count as instances of it. - The upstream Worker Loader export
inheritGlobalOutboundis left out of the vendored run.WorkerCode.globalOutbounditself is implemented (nullblocksfetch; a Fetcher redirects it, in the host’s realm and in an isolate, where the request crosses back to the host), matches celld in theruntime-apiproject, and passesoverrideGlobalOutboundinwd-worker-loader.inheritGlobalOutboundstays out for good: it needs the test Worker’s ownglobalOutbound, a.wd-testsetting with no wrangler equivalent, so on celld and CellB it would fetchhttps://example.comfor real. - An OPFS fleet bucket has one writer at a time. Its conditional writes are checked, then written; two tabs using it at once can both win.
Set-Cookiecannot reach a page through a ServiceWorker, and a browserResponsedrops it.- The input gate does not hold timers, as on celld. A timer set before
blockConcurrencyWhile()fires while its callback runs; workerd holds it until the callback ends.wd-actor-block-concurrency-timerrecords celld doing the same as CellB, and celld is the judge, so this stays (following workerd would need ajudge: "workerd"reason of the stub kind). workerd’s input gate also defers outboundfetchcompletions while storage is in flight; CellB does not, and what celld does there is not recorded. - celld examples not run:
python(Pyodide Python Workers: CellB runs no Python),wasm(needs the Rust toolchain to build),containerandsandbox(Docker or Podman; acontainersconfig is reported inunsupported).piandopencodeneed a model on the network as celld ships them;examples/agentsruns both, adapted to take their model from an endpoint in their vars, against a fake OpenAI-compatible model. - The LTX store’s room check is a hint. Before a compaction it compares the cell’s size with
navigator.storage.estimate(), which overstates the room in a Playwright (off-the-record) context, where OPFS lives in memory with its own limit. A write past the limit still fails cleanly (storage quota exceeded writing …), with nothing left half-written. - The LTX log of fleet buckets keeps each cell’s pages in memory.
ltxLog(src/bucket/ltx-log.ts, behindcelldBucketandfilesApiLtxLog) holds the pages it last wrote per cell;bucket: { ltx }reads a cell page by page instead. - The workspace runs CellB from its TypeScript sources, the published package from
dist/(publishConfig.exports).scripts/pack-check.mjs, in CI, checks the built package; a change that only breaks the build shows there, not in the workspace’s tests.
Out of scope, on purpose
- Limits per object (CPU time, memory, storage size).
- Opaque-origin iframes for untrusted code. A loaded Worker is isolated from the project’s realm, not from the origin.
- celld’s log tier and capacity hand-off (
/peer/handoff). A CellB node acknowledges a write only once it is in the bucket and never accepts cells it was not asked for. - Python Workers.