diff --git a/packages/cellb/docs/browser-host.md b/packages/cellb/docs/browser-host.md index 2457b0b..8cc384d 100644 --- a/packages/cellb/docs/browser-host.md +++ b/packages/cellb/docs/browser-host.md @@ -22,9 +22,10 @@ and WebKit. rollback journal keeps its pool slot between commits instead of being created and deleted on each one. The database file is the same as with the default `DELETE` (only WAL marks it). - **`AsyncLocalStorage` needs help.** Browsers have no async context yet: for a nodejs_compat - bundle, `browserProjectRuntime` makes promise and timer continuations carry their stores, and - `bundleWorkerForBrowser` lowers `await` so that it goes through them. An in-browser site keeps - run-at-a-time stores ([gaps.md](gaps.md)). + project, `browserProjectRuntime` makes promise and timer continuations carry their stores, and + the code's `await` is lowered so that it goes through them: by `bundleWorkerForBrowser` in a + bundle, by `createProjectSite` in a site's modules ([gaps.md](gaps.md)). A browser with TC39 + AsyncContext needs neither: `node:async_hooks` then runs on it, and a site lowers nothing. - **A ServiceWorker cannot spawn workers** and is stopped when idle, dropping every port it held. It can route requests, hold Web Locks and pass MessagePorts, so it routes and a page owns the workers. @@ -61,8 +62,9 @@ code and builds the runtime from the wrangler configuration. The code is either: `@statewalker/webrun-site-host`, which compiles the project's sources on request (`@statewalker/webrun-modules`). Loaded Workers' code is uploaded to it and imported from URLs. The project's `cloudflare:workers` has its own `env` and `exports` (Workers loaded into its - realm get empty ones; in isolates, their own), and `cloudflare:email` loads; `await` is not - lowered, so `AsyncLocalStorage` keeps run-at-a-time stores there (see below). + realm get empty ones; in isolates, their own), and `cloudflare:email` loads. With `nodeCompat` + the site lowers `await` in the modules it serves, so `AsyncLocalStorage` answers as in a bundle; + the site's `nodeCompat` must match the project's flag (see below). A loaded Worker's `cloudflare:workers` has its own `env` and `exports` (its `WorkerCode.env`, and stubs of its own entrypoints), except on a site without `isolateScript`. @@ -274,9 +276,10 @@ tabs or workers with no server. Its conditional writes are atomic only within on outran the pool's growth (`browserProjectRuntime` starts with 64 files). With `sahpoolOpener`, pass a larger `initialCapacity` for workloads that open many cells together. - **`AsyncLocalStorage.getStore()` returns another cell's store, or `undefined` after an - `await`**, in a browser. The code was not bundled by `bundleWorkerForBrowser` with nodejs_compat: - an in-browser site keeps run-at-a-time stores (shared between concurrent runs), and a bundle of - your own whose `await` is not lowered loses its store at the first `await`. + `await`**, in a browser. The code's `await` was not lowered: a bundle of your own, not made by + `bundleWorkerForBrowser` with nodejs_compat, or a site made by `createProjectSite` without + `nodeCompat` for a nodejs_compat project. Code in an async generator, or (on a site) in an async + method that uses `super`, sees no store after its `await`s. - **Every S3 request fails with a CORS error** in the console, or `PUT` succeeds but ownership never works. The bucket lacks a CORS rule, or does not expose `etag`: conditional writes need it. - **`SignatureDoesNotMatch` through a proxy.** `signAs` is missing or differs from the host the proxy diff --git a/packages/cellb/docs/gaps.md b/packages/cellb/docs/gaps.md index 3fd975d..34c637c 100644 --- a/packages/cellb/docs/gaps.md +++ b/packages/cellb/docs/gaps.md @@ -89,12 +89,17 @@ Dedicated Workers all work in Firefox 157, and the whole suite passes there. Wha - **`AsyncLocalStorage` in a browser carries its stores only through code it can see.** The `node:async_hooks` polyfill keeps each `run()` to its synchronous part and makes continuations carry their stores (`Promise.prototype.then`, timers, `queueMicrotask`); a native `await` bypasses - `then`, so `bundleWorkerForBrowser` lowers `await` to generators in nodejs_compat bundles. A - bundle then answers as on Node (test/async-context-worker.ts, 16 objects at once). An in-browser - site's modules are compiled without lowering, so there the polyfill keeps its run-at-a-time - stores: a `run()`'s store stays set until its promise settles, which is right for one run at a - time and shared between concurrent ones (two cells answering at once can read each other's). - Event listeners (`addEventListener`, MessagePort `onmessage`) do not carry stores, on Node either. + `then`, so with nodejs_compat `await` is lowered to generators: by `bundleWorkerForBrowser` + (esbuild) in a bundle, by `createProjectSite` (`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 use `super` or `new.target` (on a + site; esbuild lowers those too), and top-level await; code there resumes in no store after an + `await`, rather than in another event's. In a browser with TC39 AsyncContext (Stage 2, shipped + by none yet) the polyfill is a layer over `AsyncContext.Variable` and `Snapshot` instead, sites + lower nothing (`bundleWorkerForBrowser` takes `lowerAwait: 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`, MessagePort + `onmessage`) 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 `AsyncLocalStorage` stores. Where Cloudflare has none, cellb has the stores that were current diff --git a/packages/cellb/e2e-browser/site-projects.spec.ts b/packages/cellb/e2e-browser/site-projects.spec.ts index 37d7763..04db20a 100644 --- a/packages/cellb/e2e-browser/site-projects.spec.ts +++ b/packages/cellb/e2e-browser/site-projects.spec.ts @@ -8,6 +8,11 @@ import { goldenDir, scenarios, } from "../conformance/scenarios.ts"; +import { + ASYNC_CONTEXT_CONFIG, + ASYNC_CONTEXT_WORKER, + expectedSeen, +} from "../test/async-context-worker.ts"; import { startReferenceServer } from "../test/ws-reference-server.ts"; // The celld projects served by an IN-BROWSER HTTP SERVER: the page mounts a @@ -127,3 +132,31 @@ for (const noIsolate of [false, true]) noIsolate ? { env: [], exports: [] } : { env: ["NAME"], exports: ["Echo"] }, ); }); + +// node:async_hooks on a site: its modules' awaits are lowered as they are served, so 16 objects at +// once keep their own stores, as in a bundle (agents-sdk-host.spec) and on Node (test/p13). +test("AsyncLocalStorage on a site keeps each concurrent object's store, as on Node", async ({ + page, +}) => { + const b64 = (s: string) => Buffer.from(s).toString("base64"); + await page.goto("/site-projects/"); + await page.waitForFunction(() => document.body.dataset.ready === "true"); + const r = await page.evaluate((job) => (window as any).__runOnSite(job), { + name: "site-als", + scenario: "", + config: ASYNC_CONTEXT_CONFIG, + files: [ + ["/index.js", b64(ASYNC_CONTEXT_WORKER)], + ["/wrangler.json", b64(JSON.stringify(ASYNC_CONTEXT_CONFIG))], + ], + assets: [], + paths: ["/", "/"], + }); + expect(r.ok, r.error).toBe(true); + for (const answer of r.answers) { + const run = JSON.parse(answer); + expect(run.outside).toBeNull(); + for (const [name, seen] of Object.entries(run.results)) + expect(seen).toEqual(expectedSeen(name)); + } +}); diff --git a/packages/cellb/package.json b/packages/cellb/package.json index 7377d84..90e0b3d 100644 --- a/packages/cellb/package.json +++ b/packages/cellb/package.json @@ -34,19 +34,20 @@ "@noble/hashes": "^1.8.0", "@sqlite.org/sqlite-wasm": "^3.53.4-build1", "@statewalker/webrun-files": "^0.10.0", + "@statewalker/webrun-http-browser": "catalog:", "@statewalker/webrun-http-streams": "^0.2.2", "@statewalker/webrun-rpc": "^0.4.0", - "@statewalker/webrun-streams": "^0.2.0", - "worker-timers": "^8.0.0", - "sqlite-vec": "^0.1.7", "@statewalker/webrun-site-builder": "catalog:", "@statewalker/webrun-site-host": "catalog:", - "@statewalker/webrun-http-browser": "catalog:", + "@statewalker/webrun-streams": "^0.2.0", + "acorn": "^8.19.0", + "assert": "^2.1.0", + "buffer": "^6.0.3", "events": "^3.3.0", "path-browserify": "^1.0.1", + "sqlite-vec": "^0.1.7", "util": "^0.12.5", - "buffer": "^6.0.3", - "assert": "^2.1.0" + "worker-timers": "^8.0.0" }, "devDependencies": { "@playwright/test": "^1.63.0", @@ -56,16 +57,16 @@ "@statewalker/webrun-http-browser": "^0.6.0", "@statewalker/webrun-modules": "catalog:", "@types/node": "catalog:", + "@types/ws": "catalog:", "esbuild": "^0.28.0", + "html-rewriter-wasm": "^0.4.1", + "miniflare": "4.20260730.0", + "sqlite-vec-wasm-demo": "0.1.6", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", "ws": "catalog:", - "@types/ws": "catalog:", - "zod": "^3.25.0", - "sqlite-vec-wasm-demo": "0.1.6", - "html-rewriter-wasm": "^0.4.1", - "miniflare": "4.20260730.0" + "zod": "^3.25.0" }, "private": true } diff --git a/packages/cellb/src/core/async-context.ts b/packages/cellb/src/core/async-context.ts index e710964..af9c6d8 100644 --- a/packages/cellb/src/core/async-context.ts +++ b/packages/cellb/src/core/async-context.ts @@ -3,12 +3,42 @@ export type AsyncRoot = (fn: () => R) => R; const POLYFILL_STATE = Symbol.for("cellb.asyncContext.state"); +// biome-ignore lint/suspicious/noExplicitAny: any function, called with its own arguments +type Fn = (...args: any[]) => R; + +/** TC39 AsyncContext (Stage 2), the part cellb uses. */ +export interface NativeAsyncContext { + Variable: new (options?: { + name?: string; + defaultValue?: T; + }) => { + get(): T | undefined; + run(value: T, fn: Fn, ...args: unknown[]): R; + }; + Snapshot: { + new (): { run(fn: Fn, ...args: unknown[]): R }; + wrap(fn: F): F; + }; +} + +/** + * The engine's AsyncContext, when the realm has one: async context then follows every `await` + * natively, as in workerd, celld and Node, and code needs no lowering. + */ +export function nativeAsyncContext(): NativeAsyncContext | undefined { + const ac = (globalThis as { AsyncContext?: Partial }).AsyncContext; + return typeof ac?.Variable === "function" && typeof ac.Snapshot === "function" + ? (ac as NativeAsyncContext) + : undefined; +} + /** * The async context current now, to start events in: on Cloudflare a call to a Durable Object is * a new event, which sees no `AsyncLocalStorage` store its caller set. On Node it is every store - * (`AsyncLocalStorage.snapshot()`); in a browser, the `node:async_hooks` polyfill's frame (a - * realm's polyfill copies share it), or its root frame if the polyfill loads later. Elsewhere - * events run in their caller's context, as before. + * (`AsyncLocalStorage.snapshot()`); in a browser with AsyncContext, an `AsyncContext.Snapshot`; + * in other browsers, the `node:async_hooks` polyfill's frame (a realm's polyfill copies share + * it), or its root frame if the polyfill loads later. Elsewhere events run in their caller's + * context, as before. */ export function asyncRoot(): AsyncRoot { const hooks = ( @@ -28,6 +58,11 @@ export function asyncRoot(): AsyncRoot { // AsyncContextFrame) stays its own instead of reaching the next event started in the snapshot. if (snapshot && Resource) return (fn) => snapshot(() => new Resource("cellb.event").runInAsyncScope(fn)); + const native = nativeAsyncContext(); + if (native) { + const captured = new native.Snapshot(); + return (fn) => captured.run(fn); + } type State = { root: unknown; current: unknown }; const realm = globalThis as Record; const captured = realm[POLYFILL_STATE]?.current; diff --git a/packages/cellb/src/node-compat/async_hooks.ts b/packages/cellb/src/node-compat/async_hooks.ts index b627b94..6d606d5 100644 --- a/packages/cellb/src/node-compat/async_hooks.ts +++ b/packages/cellb/src/node-compat/async_hooks.ts @@ -1,6 +1,12 @@ /** - * `node:async_hooks` AsyncLocalStorage for browsers (nodejs_compat). Browsers have no async - * context propagation yet (TC39 AsyncContext), so this is the polyfill's design: + * `node:async_hooks` AsyncLocalStorage for browsers (nodejs_compat). + * + * In a realm with TC39 AsyncContext (Stage 2; no browser ships it yet), each AsyncLocalStorage + * is an `AsyncContext.Variable`: the engine carries it through every `await`, as workerd, celld + * and Node do, and `installAsyncContext()` patches nothing. Code needs no lowering there (lowered + * code works too). `enterWith()` has no AsyncContext counterpart and throws. + * + * Elsewhere this module simulates it: * * - a context is an immutable frame (store per AsyncLocalStorage); `run()` makes its frame * current for the synchronous part of the call only; @@ -8,18 +14,35 @@ * were scheduled: `Promise.prototype.then` (so `catch`, `finally` and `await` on a thenable), * `setTimeout`, `setInterval` and `queueMicrotask`; * - a native `await` does not call `then`, so code that relies on propagation is compiled with - * `async`/`await` lowered to generators (`bundleWorkerForBrowser` does it with nodejs_compat): - * each `await` then goes through `Promise.prototype.then`. + * `async`/`await` lowered to generators (with nodejs_compat, `bundleWorkerForBrowser` does it + * with esbuild, an in-browser site with `lowerAwait`): each `await` then goes through + * `Promise.prototype.then`. * * Code that is not lowered (cellb itself) resumes after a native `await` in the root frame: it * neither sees nor leaks another event's stores. * - * Without `installAsyncContext()` (an in-browser site, whose modules are compiled without - * lowering), `run()` keeps its store set until its promise settles instead: one run at a time - * sees its store after `await`, concurrent runs share whichever was set last. + * Without `installAsyncContext()` (code compiled without lowering), `run()` keeps its store set + * until its promise settles instead: one run at a time sees its store after `await`, concurrent + * runs share whichever was set last. */ +import { type NativeAsyncContext, nativeAsyncContext } from "../core/async-context.js"; + type Frame = ReadonlyMap, unknown>; +const native = nativeAsyncContext(); +type NativeVariable = InstanceType; +const variables = new WeakMap, NativeVariable>(); +/** Stores whose `disable()` holds until their next `run()` (native backend). */ +const disabled = new WeakSet>(); +const variableOf = (als: AsyncLocalStorage, ac: NativeAsyncContext) => { + let v = variables.get(als); + if (!v) { + v = new ac.Variable(); + variables.set(als, v); + } + return v; +}; + // One state per realm: a browser bundle carries its own copy of this module (node:async_hooks // bundled in), and its stores must be the frames that cellb's patched `then` restores. const STATE = Symbol.for("cellb.asyncContext.state"); @@ -58,9 +81,15 @@ function bindToCurrent unknown>(fn: F): F { export class AsyncLocalStorage { getStore(): T | undefined { - return state.current.get(this as AsyncLocalStorage) as T | undefined; + const self = this as AsyncLocalStorage; + if (native) return disabled.has(self) ? undefined : (variableOf(self, native).get() as T); + return state.current.get(self) as T | undefined; } run(store: T, fn: (...args: unknown[]) => R, ...args: unknown[]): R { + if (native) { + disabled.delete(this as AsyncLocalStorage); + return variableOf(this as AsyncLocalStorage, native).run(store, fn, ...args); + } const frame = new Map(state.current).set(this as AsyncLocalStorage, store); if (state.propagate) return runIn(frame, fn, undefined, args); // Without installAsyncContext() (code whose `await` is not lowered): the store stays set @@ -84,14 +113,28 @@ export class AsyncLocalStorage { } /** The store for the rest of the current synchronous part and what it schedules. */ enterWith(store: T): void { + if (native) + throw new Error( + "AsyncLocalStorage.enterWith() is not available with AsyncContext: use run() instead", + ); state.current = new Map(state.current).set(this as AsyncLocalStorage, store); } exit(fn: (...args: unknown[]) => R, ...args: unknown[]): R { + if (native) + return variableOf(this as AsyncLocalStorage, native).run( + undefined, + fn, + ...args, + ); const frame = new Map(state.current); frame.delete(this as AsyncLocalStorage); return runIn(frame, fn, undefined, args); } disable(): void { + if (native) { + disabled.add(this as AsyncLocalStorage); + return; + } if (!state.current.has(this as AsyncLocalStorage)) return; const frame = new Map(state.current); frame.delete(this as AsyncLocalStorage); @@ -100,10 +143,15 @@ export class AsyncLocalStorage { /** `fn` bound to the current stores. */ // biome-ignore lint/suspicious/noExplicitAny: any function, called with its own arguments static bind unknown>(fn: F): F { + if (native) return native.Snapshot.wrap(fn); return bindToCurrent(fn); } /** Runs a function later in the stores current now. */ static snapshot(): (fn: (...args: unknown[]) => R, ...args: unknown[]) => R { + if (native) { + const snapshot = new native.Snapshot(); + return (fn, ...args) => snapshot.run(fn, ...args); + } const frame = state.current; return (fn, ...args) => runIn(frame, fn, undefined, args); } @@ -113,9 +161,10 @@ const INSTALLED = Symbol.for("cellb.asyncContext"); /** * Makes continuations carry the current stores, in the realm of `g` (a browser worker that runs - * Worker code). Idempotent. + * Worker code). Idempotent. With AsyncContext the engine carries them: nothing to install. */ export function installAsyncContext(g: typeof globalThis = globalThis): void { + if (native) return; const proto = g.Promise.prototype as Promise & { [INSTALLED]?: true }; if (proto[INSTALLED]) return; Object.defineProperty(proto, INSTALLED, { value: true }); diff --git a/packages/cellb/src/project/browser-bundle.ts b/packages/cellb/src/project/browser-bundle.ts index 5abcf16..21a2ad0 100644 --- a/packages/cellb/src/project/browser-bundle.ts +++ b/packages/cellb/src/project/browser-bundle.ts @@ -53,7 +53,16 @@ const bareBuiltin = new Set(builtinModules.filter((m) => !m.startsWith("_") && ! /** Bundles the Worker module at `entry` into one ES module for `browserProjectRuntime`. */ export async function bundleWorkerForBrowser( entry: string, - opts: { nodeCompat?: boolean } = {}, + opts: { + nodeCompat?: boolean; + /** + * Lower `await` to generators (default: with `nodeCompat`), so that the node:async_hooks + * polyfill carries AsyncLocalStorage stores through it. `false` only for browsers with TC39 + * AsyncContext, whose engine carries them through native awaits; a lowered bundle runs there + * too. + */ + lowerAwait?: boolean; + } = {}, ): Promise { const out = await build({ entryPoints: [entry], @@ -67,7 +76,7 @@ export async function bundleWorkerForBrowser( keepNames: true, // nodejs_compat: `await` lowered to generators, so that each one resumes through // Promise.prototype.then and carries its AsyncLocalStorage stores (node-compat/async_hooks.ts). - ...(opts.nodeCompat + ...((opts.lowerAwait ?? opts.nodeCompat) ? { supported: { "async-await": false, "async-generator": false, "for-await": false } } : {}), logLevel: "silent", diff --git a/packages/cellb/src/project/browser-host.ts b/packages/cellb/src/project/browser-host.ts index 2cb2fc4..639d194 100644 --- a/packages/cellb/src/project/browser-host.ts +++ b/packages/cellb/src/project/browser-host.ts @@ -105,10 +105,11 @@ async function importWorkerCode( const nodeCompat = flags.some((f) => f.startsWith("nodejs_compat")); if (nodeCompat) { installNodeGlobals(); - // node:async_hooks: continuations carry their AsyncLocalStorage stores. Only for a bundle: - // bundleWorkerForBrowser lowers its awaits so that they go through them; a site's modules - // keep native awaits and the polyfill's run-at-a-time stores. - if ("bundle" in code) installAsyncContext(); + // node:async_hooks: continuations carry their AsyncLocalStorage stores (with AsyncContext + // the engine does, and this installs nothing). The code's awaits are lowered so that they go + // through them: by bundleWorkerForBrowser for a bundle, by createProjectSite (nodeCompat) for + // a site's modules. + installAsyncContext(); } const globals = { ...workersGlobals, diff --git a/packages/cellb/src/project/lower-await.ts b/packages/cellb/src/project/lower-await.ts new file mode 100644 index 0000000..14968dc --- /dev/null +++ b/packages/cellb/src/project/lower-await.ts @@ -0,0 +1,164 @@ +/** + * `async`/`await` lowered to generators, for the modules an in-browser site serves (what + * esbuild's `supported: { "async-await": false }` does for `bundleWorkerForBrowser`): each `await` + * then resumes through `Promise.prototype.then`, which cellb's `node:async_hooks` polyfill makes + * carry the AsyncLocalStorage stores (see `installAsyncContext`). + * + * async function f(a) { BODY } → function f(a) { return __cellbAsync(function* () { BODY }.apply(this, arguments)); } + * async (a) => EXPR → (a) => __cellbAsync(function* () { return EXPR; }.call(this)) + * await X → (yield X) + * for await (L of R) BODY → a loop over `yield it.next()`, closing the iterator on exit + * + * Left as they are: async generators (their awaits stay native, as their bodies), functions that + * use `super` or `new.target` (a generator nested in them cannot), and top-level await. Their + * awaits resume in the polyfill's root frame: they see no store rather than another event's. + */ +import { type Node, parse } from "acorn"; + +// biome-ignore lint/suspicious/noExplicitAny: walking acorn's ESTree nodes by their properties +type AnyNode = Node & Record; + +const ASYNC_RUNNER = + 'function __cellbAsync(g){return new Promise(function(res,rej){function step(m,v){var r;try{r=g[m](v)}catch(e){rej(e);return}if(r.done)res(r.value);else Promise.resolve(r.value).then(function(x){step("next",x)},function(e){step("throw",e)})}step("next")})}'; +const ASYNC_ITERATOR = + "function __cellbIter(o){var m=o[Symbol.asyncIterator];if(m)return m.call(o);var it=o[Symbol.iterator](),w=function(r){return Promise.resolve(r.value).then(function(x){return{value:x,done:r.done}})};return{next:function(v){return w(it.next(v))},return:it.return&&function(v){return w(it.return(v))}}}"; + +const isFunction = (n: AnyNode) => + n.type === "FunctionDeclaration" || + n.type === "FunctionExpression" || + n.type === "ArrowFunctionExpression"; + +function childrenOf(node: AnyNode): AnyNode[] { + const out: AnyNode[] = []; + for (const key in node) { + if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") + continue; + const v = node[key]; + if (Array.isArray(v)) { + for (const c of v) if (c && typeof c.type === "string") out.push(c); + } else if (v && typeof v.type === "string") out.push(v); + } + return out.sort((a, b) => a.start - b.start); +} + +/** `super` or `new.target` in `fn`'s own body (not in the functions it declares). */ +function usesSuperOrNewTarget(fn: AnyNode): boolean { + let found = false; + const visit = (n: AnyNode) => { + if (found) return; + if (n.type === "Super" || n.type === "MetaProperty") { + found = true; + return; + } + for (const c of childrenOf(n)) + if (!(isFunction(c) && c.type !== "ArrowFunctionExpression") && c.type !== "ClassBody") + visit(c); + }; + visit(fn.body); + return found; +} + +const lowered = (n: AnyNode) => + isFunction(n) && n.async && !n.generator && !usesSuperOrNewTarget(n); + +/** `code` with its async functions lowered; `code` itself when it has none or does not parse. */ +export function lowerAwait(code: string): string { + if (!/\basync\b/.test(code)) return code; + let program: AnyNode; + try { + program = parse(code, { + ecmaVersion: "latest", + sourceType: "module", + allowHashBang: true, + }) as AnyNode; + } catch { + return code; + } + let changed = false; + let iterUsed = false; + let temp = 0; + + // `inLowered`: whether `await` here belongs to a function being lowered. + const render = (node: AnyNode, inLowered: boolean): string => { + if (isFunction(node)) return renderFunction(node); + if (inLowered && node.type === "AwaitExpression") + return `(yield ${render(node.argument, inLowered)})`; + if (inLowered && node.type === "ForOfStatement" && node.await) + return renderForAwait(node, "", inLowered); + if (inLowered && node.type === "LabeledStatement") { + let labels = ""; + let body = node; + while (body.type === "LabeledStatement") { + labels += `${body.label.name}: `; + body = body.body; + } + if (body.type === "ForOfStatement" && body.await) + return renderForAwait(body, labels, inLowered); + } + let text = generic(node, inLowered); + // `async` of a lowered method sits before its key (`static async [k]() {}`). + if ( + (node.type === "MethodDefinition" || (node.type === "Property" && node.method)) && + lowered(node.value) + ) { + const head = node.key.start - node.start - (node.computed ? 1 : 0); + const prefix = text.slice(0, head).replace(/\basync\b\s*/, ""); + text = prefix + text.slice(head); + } + return text; + }; + + const generic = ( + node: AnyNode, + inLowered: boolean, + wrap?: (child: AnyNode, text: string) => string, + ): string => { + let out = ""; + let at = node.start; + for (const c of childrenOf(node)) { + if (c.start < at) continue; // the same node twice (a shorthand property's key and value) + out += code.slice(at, c.start); + const text = render(c, inLowered); + out += wrap ? wrap(c, text) : text; + at = c.end; + } + return out + code.slice(at, node.end); + }; + + const renderFunction = (fn: AnyNode): string => { + if (!lowered(fn)) return generic(fn, false); + changed = true; + const arrow = fn.type === "ArrowFunctionExpression"; + const text = generic(fn, true, (child, body) => { + if (child !== fn.body) return body; + // An arrow's `arguments` is its enclosing function's: pass them on only when it reads them + // (a module has none). + const call = !arrow || /\barguments\b/.test(body) ? ".apply(this, arguments)" : ".call(this)"; + if (fn.expression) return `__cellbAsync(function* () { return ${body}; }${call})`; + return `{ return __cellbAsync(function* () ${body}${call}); }`; + }); + return text.replace(/^async\b\s*/, ""); + }; + + const renderForAwait = (loop: AnyNode, labels: string, inLowered: boolean): string => { + iterUsed = true; + const n = temp++; + const it = `__cellbIt${n}`; + const step = `__cellbStep${n}`; + const left = render(loop.left, inLowered); + const assign = + loop.left.type === "VariableDeclaration" + ? `${left} = ${step}.value;` + : `(${left} = ${step}.value);`; + return ( + `{ const ${it} = __cellbIter(${render(loop.right, inLowered)}); let ${step}; ` + + `try { ${labels}for (;;) { ${step} = undefined; ${step} = yield ${it}.next(); ` + + `if (${step}.done) break; { ${assign} ${render(loop.body, inLowered)} } } } ` + + `finally { if (${step} && !${step}.done && ${it}.return) yield ${it}.return(); } }` + ); + }; + + const out = render(program, false); + if (!changed) return code; + return `${out}\n${ASYNC_RUNNER}\n${iterUsed ? `${ASYNC_ITERATOR}\n` : ""}`; +} diff --git a/packages/cellb/src/project/site.ts b/packages/cellb/src/project/site.ts index cc66a0b..963899a 100644 --- a/packages/cellb/src/project/site.ts +++ b/packages/cellb/src/project/site.ts @@ -1,6 +1,6 @@ import { type FilesApi, readText } from "@statewalker/webrun-files"; import { MemFilesApi } from "@statewalker/webrun-files-mem"; -import { newModuleServer } from "@statewalker/webrun-modules"; +import { newDefaultTransform, newModuleServer, type Transform } from "@statewalker/webrun-modules"; import { SiteBuilder, type SiteHandler } from "@statewalker/webrun-site-builder"; import { type LogSink, @@ -9,7 +9,9 @@ import { outboundFetch, type WorkerCode, } from "../bindings/worker-loader.js"; +import { nativeAsyncContext } from "../core/async-context.js"; import { NODE_POLYFILL_FILES, NODE_POLYFILL_PACKAGES } from "../node-compat/modules.js"; +import { lowerAwait } from "./lower-await.js"; import { SCHEDULER_EXPRESSION } from "./scheduler.js"; import { WORKERS_GLOBAL_NAMES } from "./workers-global-names.js"; @@ -30,9 +32,31 @@ import { WORKERS_GLOBAL_NAMES } from "./workers-global-names.js"; */ export const LOADED_CONSOLES = "__cellsLoadedConsoles"; +/** webrun-modules' transform, then `lowerAwait` on its output. */ +function loweringTransform(): Transform { + const base = newDefaultTransform(true); + return { + async transform(file, rewrite) { + const r = await base.transform(file, rewrite); + return { ...r, code: lowerAwait(r.code) }; + }, + }; +} + export function createProjectSite(opts: { projectFiles: FilesApi; + /** + * nodejs_compat: serves the `node:*` modules, and (see `lowerAwait`) lowers the modules' awaits + * to generators so that AsyncLocalStorage stores follow them. It must match the project's flag: + * without AsyncContext, the browser host makes continuations carry stores for a nodejs_compat + * project, which awaits that are not lowered would not see. + */ nodeCompat?: boolean; + /** + * Lower `await` in the modules served. Default: with `nodeCompat`, unless this browser has + * AsyncContext (the engine then carries the stores through native awaits, as workerd does). + */ + lowerAwait?: boolean; /** * The `node:*` registry entries of the realm that runs the code (`nodeRegistryEntries`): a * namespace import (`import * as assert from "node:assert"`) exports the keys the server @@ -58,6 +82,10 @@ export function createProjectSite(opts: { scheduler: SCHEDULER_EXPRESSION, ...(opts.nodeCompat ? { Buffer: `${host}("Buffer")`, process: `${host}("process")` } : {}), }; + // nodejs_compat without AsyncContext: awaits lowered to generators, so that AsyncLocalStorage + // stores follow them (the browser host installs the polyfill's continuations for such a project). + const lower = opts.lowerAwait ?? (!!opts.nodeCompat && !nativeAsyncContext()); + const transform = lower ? loweringTransform() : undefined; const modules = newModuleServer({ cache: new MemFilesApi(), project: opts.projectFiles, @@ -65,6 +93,7 @@ export function createProjectSite(opts: { target: "browser", provided, globals, + transform, }); // One module server per loaded Worker: its globals proxy (one per project root in // webrun-modules) can then bind `console` to that Worker's own console (Tail Workers). @@ -84,6 +113,7 @@ export function createProjectSite(opts: { basePath: `/loaded/${encodeURIComponent(id)}/`, target: "browser", provided, + transform, globals: { ...globals, console: `(globalThis.${LOADED_CONSOLES}?.[${JSON.stringify(id)}] ?? globalThis.console)`, diff --git a/packages/cellb/test/p17-lower-await.test.ts b/packages/cellb/test/p17-lower-await.test.ts new file mode 100644 index 0000000..f43ec3e --- /dev/null +++ b/packages/cellb/test/p17-lower-await.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { AsyncLocalStorage, installAsyncContext } from "../src/node-compat/async_hooks.js"; +import { lowerAwait } from "../src/project/lower-await.js"; + +// An in-browser site's modules are compiled by webrun-modules; cellb lowers their awaits to +// generators so that cellb's node:async_hooks polyfill can carry AsyncLocalStorage stores +// through them, as bundleWorkerForBrowser does with esbuild. + +/** Runs lowered `code` (a function body that returns a promise) with `scope` in reach. */ +function run(code: string, scope: Record = {}): Promise { + const lowered = lowerAwait(`async function __main() {\n${code}\n}`); + const names = Object.keys(scope); + return new Function(...names, `${lowered}\nreturn __main();`)(...Object.values(scope)); +} + +describe("lowerAwait", () => { + it("leaves code without async functions as it is", () => { + const code = "const a = 1; function f() { return a; } // async is only a word here"; + expect(lowerAwait(code)).toBe(code); + expect(lowerAwait("export const x = (")).toBe("export const x = ("); // does not parse + }); + + it("removes every await from lowered functions", () => { + const out = lowerAwait( + "export default { async fetch(r) { return await r; } };\n" + + "class A { static async [k]() { await 1; } async m() { for await (const x of y) await x; } }\n" + + "const f = async (x) => await x, g = async x => ({ x: await x });\n" + + "export async function h() { const o = { a, async b() { await a; } }; return o; }", + ); + expect(out).not.toMatch(/\bawait\b/); + expect(out.match(/\basync\b/g)).toBeNull(); + }); + + it("keeps the meaning: values, errors, this, arguments, order", async () => { + expect( + await run(` + const log = []; + const obj = { + n: 2, + async twice(a) { await null; return this.n * a * arguments.length; }, + }; + async function fails() { await null; throw new Error("boom"); } + const arrow = async (x) => (await x) + 1; + const exprObj = async () => ({ k: await 1 }); + let caught; + try { await fails(); } catch (e) { caught = e.message; } + const p = (async () => { log.push("a"); await null; log.push("c"); })(); + log.push("b"); + await p; + return [await obj.twice(5), caught, await arrow(Promise.resolve(1)), await exprObj(), log]; + `), + ).toEqual([10, "boom", 2, { k: 1 }, ["a", "b", "c"]]); + }); + + it("lowers for await: async and sync iterables, break, labels, closing the iterator", async () => { + expect( + await run(` + const seen = []; + let closed = false; + async function* gen() { try { yield 1; await null; yield 2; yield 3; } finally { closed = true; } } + for await (const x of gen()) { if (x === 2) break; seen.push(x); } + for await (const x of [Promise.resolve("p"), "q"]) seen.push(x); + let y; + outer: for await (y of gen()) { for (const z of [1, 2]) { if (z === 2) continue outer; seen.push(y * 10 + z); } } + for await (const [a, { b }] of [[1, { b: 2 }]]) seen.push(a + b); + return [seen, closed, y]; + `), + ).toEqual([[1, "p", "q", 11, 21, 31, 3], true, 3]); + }); + + it("leaves async functions that use super, and async generators, as they are", () => { + const code = + "class B extends A { async m() { await super.m(); } }\nasync function* g() { await 1; yield 2; }"; + expect(lowerAwait(code)).toBe(code); + }); + + it("with the polyfill's continuations, concurrent runs keep their own stores", async () => { + installAsyncContext(); + const als = new AsyncLocalStorage(); + const results = await run( + ` + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + async function* ticks() { yield 1; await sleep(1); yield 2; } + async function probe(name) { + return als.run(name, async () => { + const seen = []; + await null; + seen.push(als.getStore()); + await sleep(Math.random() * 5); + seen.push(als.getStore()); + for await (const _ of ticks()) seen.push(als.getStore()); + seen.push(await (async () => { await sleep(1); return als.getStore(); })()); + return seen; + }); + } + return Promise.all(["a", "b", "c", "d"].map(probe)); + `, + { als }, + ); + expect(results).toEqual(["a", "b", "c", "d"].map((n) => [n, n, n, n, n])); + expect(als.getStore()).toBeUndefined(); + }); +}); diff --git a/packages/cellb/test/p18-native-async-context.test.ts b/packages/cellb/test/p18-native-async-context.test.ts new file mode 100644 index 0000000..f9a35cd --- /dev/null +++ b/packages/cellb/test/p18-native-async-context.test.ts @@ -0,0 +1,159 @@ +import { AsyncLocalStorage as NodeAls } from "node:async_hooks"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MemFilesApi } from "@statewalker/webrun-files-mem"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +// In a realm with TC39 AsyncContext, cellb's node:async_hooks is a thin layer over it and +// nothing is lowered or patched: the engine carries the stores through native awaits, as in +// workerd, celld and Node. No browser ships AsyncContext yet, so this test gives the realm one +// built on Node's own (engine-backed) AsyncLocalStorage, and runs code with native awaits. + +// biome-ignore lint/suspicious/noExplicitAny: any function, called with its own arguments +type Fn = (...args: any[]) => any; +class Variable { + #als = new NodeAls(); + get(): T | undefined { + return this.#als.getStore(); + } + run(value: T, fn: Fn, ...args: unknown[]): R { + return this.#als.run(value, fn, ...args); + } +} +class Snapshot { + #run = NodeAls.snapshot(); + run(fn: Fn, ...args: unknown[]): R { + return this.#run(fn, ...args); + } + static wrap(fn: F): F { + const s = new Snapshot(); + return function (this: unknown, ...args: unknown[]) { + return s.run(() => fn.apply(this, args)); + } as F; + } +} + +const g = globalThis as { AsyncContext?: unknown }; +let hooks: typeof import("../src/node-compat/async_hooks.js"); +const thenBefore = Promise.prototype.then; +beforeAll(async () => { + g.AsyncContext = { Variable, Snapshot }; + hooks = await import("../src/node-compat/async_hooks.js"); // picks its backend at load +}); +afterAll(() => { + delete g.AsyncContext; +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("node:async_hooks over native AsyncContext", () => { + it("installs nothing: Promise.prototype.then stays the engine's", () => { + hooks.installAsyncContext(); + expect(Promise.prototype.then).toBe(thenBefore); + }); + + it("concurrent runs keep their own stores across native awaits", async () => { + const als = new hooks.AsyncLocalStorage(); + async function* ticks() { + yield 1; + await sleep(1); + yield 2; + } + const probe = (name: string) => + als.run(name, async () => { + const seen = [als.getStore()]; + await null; + seen.push(als.getStore()); + await sleep(Math.random() * 5); + seen.push(als.getStore()); + for await (const _ of ticks()) seen.push(als.getStore()); + seen.push(await als.run(`${name}/inner`, async () => (await sleep(1), als.getStore()))); + seen.push(als.exit(() => als.getStore()) ?? "none"); + seen.push( + await new Promise((r) => setTimeout(() => r(als.getStore()))), + ); + return seen; + }); + const names = ["a", "b", "c", "d"]; + expect(await Promise.all(names.map(probe))).toEqual( + names.map((n) => [n, n, n, n, n, `${n}/inner`, "none", n]), + ); + expect(als.getStore()).toBeUndefined(); + }); + + it("snapshot, bind, disable; enterWith is refused", async () => { + const als = new hooks.AsyncLocalStorage(); + const [later, bound] = als.run("x", () => [ + hooks.AsyncLocalStorage.snapshot(), + hooks.AsyncLocalStorage.bind(() => als.getStore()), + ]); + expect(later(() => als.getStore())).toBe("x"); + expect(bound()).toBe("x"); + als.run("y", () => { + als.disable(); + expect(als.getStore()).toBeUndefined(); + }); + expect(als.run("z", () => als.getStore())).toBe("z"); // run() lifts disable() + expect(() => als.enterWith("w")).toThrow(/not available with AsyncContext/); + }); + + it("lowered code runs on it too", async () => { + const { lowerAwait } = await import("../src/project/lower-await.js"); + const als = new hooks.AsyncLocalStorage(); + const code = lowerAwait( + "async function probe(n) { return als.run(n, async () => { await sleep(Math.random() * 5); return als.getStore(); }); }", + ); + const probe = new Function("als", "sleep", `${code}\nreturn probe;`)(als, sleep); + expect(await Promise.all(["a", "b"].map(probe))).toEqual(["a", "b"]); + }); + + it("asyncRoot starts events in the AsyncContext snapshot it was made in", async () => { + const { asyncRoot } = await import("../src/core/async-context.js"); + const als = new hooks.AsyncLocalStorage(); + // Take the browser path: no process.getBuiltinModule there. + const spy = vi.spyOn(process, "getBuiltinModule").mockReturnValue(undefined as never); + try { + const root = asyncRoot(); + const scoped = als.run("runtime", () => asyncRoot()); + expect(als.run("caller", () => root(() => als.getStore()))).toBeUndefined(); + expect(als.run("caller", () => scoped(() => als.getStore()))).toBe("runtime"); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("lowering follows the realm", () => { + // webrun-site-builder routes with URLPattern: Node 24 and browsers have it, Node 22 does not. + it.skipIf(typeof (globalThis as { URLPattern?: unknown }).URLPattern === "undefined")( + "a site lowers nothing when the browser has AsyncContext, unless asked to", + async () => { + const { createProjectSite } = await import("../src/project/site.js"); + const projectFiles = new MemFilesApi(); + const src = "export default { async fetch() { await null; return new Response('ok'); } };"; + await projectFiles.write("/index.js", [new TextEncoder().encode(src)]); + const served = async (lowerAwait?: boolean) => { + const site = createProjectSite({ projectFiles, nodeCompat: true, lowerAwait }); + const res = await site(new Request("http://site.test/modules/~/index.js")); + return res.text(); + }; + expect(await served()).toMatch(/await null/); + expect(await served(true)).not.toMatch(/\bawait\b/); + }, + ); + + it("bundleWorkerForBrowser lowers by default with nodejs_compat; lowerAwait: false keeps await", async () => { + const { bundleWorkerForBrowser } = await import("../src/project/browser-bundle.js"); + const dir = mkdtempSync(join(tmpdir(), "cellb-lower-")); + writeFileSync( + join(dir, "index.js"), + "export default { async fetch() { await null; return new Response('ok'); } };", + ); + const entry = join(dir, "index.js"); + expect(await bundleWorkerForBrowser(entry, { nodeCompat: true })).not.toMatch(/\bawait\b/); + expect(await bundleWorkerForBrowser(entry, { nodeCompat: true, lowerAwait: false })).toMatch( + /await null/, + ); + }); +}); diff --git a/packages/flue-example/README.md b/packages/flue-example/README.md index c652057..d79d864 100644 --- a/packages/flue-example/README.md +++ b/packages/flue-example/README.md @@ -128,8 +128,7 @@ CORS preflights, since the worker's origin is the Vite server's) and a fresh OPF - **`run vite build first (pnpm test does)`** is what `vitest run` alone says without `dist/`. - **The browser test's Chromium** is `PW_CHROMIUM`, else Playwright's own, else the one `@sparticuz/chromium` unpacks (as in `packages/cellb`). -- What cellb still lacks for Agents SDK apps (`AsyncLocalStorage` isolation in browsers, - `env` on an in-browser site) is in `../cellb/docs/gaps.md`. +- What cellb still lacks for Agents SDK apps is in `../cellb/docs/gaps.md`. ### Dependencies diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a831188..16b9cf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@statewalker/webrun-streams': specifier: ^0.2.0 version: 0.2.2 + acorn: + specifier: ^8.19.0 + version: 8.19.0 assert: specifier: ^2.1.0 version: 2.1.0