From 87925463de13d169e7fbb06a5ab60aa657f0e4b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:36:49 +0000 Subject: [PATCH 1/2] cellb: lower await on in-browser sites, so AsyncLocalStorage keeps concurrent stores A site's modules (createProjectSite with nodeCompat) now get their async functions lowered to generators after webrun-modules compiles them (lowerAwait, on acorn), as bundleWorkerForBrowser does with esbuild. The browser host then installs the polyfill's continuations for a site too: 16 objects answering at once each keep their own store, as on Node and in a bundle. Left native: async generators, async functions using super or new.target, top-level await; they see no store after an await rather than another event's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXJQA3LbggJHi5qZBpUKe8 --- packages/cellb/docs/browser-host.md | 18 +- packages/cellb/docs/gaps.md | 13 +- .../cellb/e2e-browser/site-projects.spec.ts | 33 ++++ packages/cellb/package.json | 23 +-- packages/cellb/src/node-compat/async_hooks.ts | 11 +- packages/cellb/src/project/browser-host.ts | 8 +- packages/cellb/src/project/lower-await.ts | 164 ++++++++++++++++++ packages/cellb/src/project/site.ts | 25 ++- packages/cellb/test/p17-lower-await.test.ts | 103 +++++++++++ packages/flue-example/README.md | 3 +- pnpm-lock.yaml | 3 + 11 files changed, 367 insertions(+), 37 deletions(-) create mode 100644 packages/cellb/src/project/lower-await.ts create mode 100644 packages/cellb/test/p17-lower-await.test.ts diff --git a/packages/cellb/docs/browser-host.md b/packages/cellb/docs/browser-host.md index 2457b0b..6dcd36f 100644 --- a/packages/cellb/docs/browser-host.md +++ b/packages/cellb/docs/browser-host.md @@ -22,9 +22,9 @@ 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 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 +61,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 +275,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..204310e 100644 --- a/packages/cellb/docs/gaps.md +++ b/packages/cellb/docs/gaps.md @@ -89,12 +89,13 @@ 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. 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/node-compat/async_hooks.ts b/packages/cellb/src/node-compat/async_hooks.ts index b627b94..723fbd5 100644 --- a/packages/cellb/src/node-compat/async_hooks.ts +++ b/packages/cellb/src/node-compat/async_hooks.ts @@ -8,15 +8,16 @@ * 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. */ type Frame = ReadonlyMap, unknown>; diff --git a/packages/cellb/src/project/browser-host.ts b/packages/cellb/src/project/browser-host.ts index 2cb2fc4..04dbc9f 100644 --- a/packages/cellb/src/project/browser-host.ts +++ b/packages/cellb/src/project/browser-host.ts @@ -105,10 +105,10 @@ 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. 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..0ee0d72 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, @@ -10,6 +10,7 @@ import { type WorkerCode, } from "../bindings/worker-loader.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,8 +31,25 @@ 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 lowers the modules' awaits to generators so + * that AsyncLocalStorage stores follow them. It must match the project's flag: the browser host + * makes continuations carry stores for a nodejs_compat project, which awaits that are not + * lowered would not see. + */ nodeCompat?: boolean; /** * The `node:*` registry entries of the realm that runs the code (`nodeRegistryEntries`): a @@ -58,6 +76,9 @@ export function createProjectSite(opts: { scheduler: SCHEDULER_EXPRESSION, ...(opts.nodeCompat ? { Buffer: `${host}("Buffer")`, process: `${host}("process")` } : {}), }; + // nodejs_compat: awaits lowered to generators, so that AsyncLocalStorage stores follow them + // (the browser host installs the polyfill's continuations for such a project). + const transform = opts.nodeCompat ? loweringTransform() : undefined; const modules = newModuleServer({ cache: new MemFilesApi(), project: opts.projectFiles, @@ -65,6 +86,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 +106,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/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 From 0bd01ff14f6c4f983cd592a9b543ddfa2ee9e937 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:19:35 +0000 Subject: [PATCH 2/2] cellb: node:async_hooks on native AsyncContext when the realm has it; lowering otherwise In a realm with TC39 AsyncContext, AsyncLocalStorage is a layer over AsyncContext.Variable and Snapshot: the engine carries stores through native awaits, as in workerd, celld and Node, and installAsyncContext() patches nothing. asyncRoot() starts events in an AsyncContext.Snapshot. enterWith() has no AsyncContext counterpart and throws there. Without it the polyfill simulates AsyncContext as before: patched continuations plus lowered awaits. createProjectSite lowers by default only when the browser lacks AsyncContext (lowerAwait overrides); bundleWorkerForBrowser takes lowerAwait: false for such browsers. test/p18 runs the backend on an AsyncContext made of Node's engine-backed AsyncLocalStorage, with native awaits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SXJQA3LbggJHi5qZBpUKe8 --- packages/cellb/docs/browser-host.md | 3 +- packages/cellb/docs/gaps.md | 6 +- packages/cellb/src/core/async-context.ts | 41 ++++- packages/cellb/src/node-compat/async_hooks.ts | 56 +++++- packages/cellb/src/project/browser-bundle.ts | 13 +- packages/cellb/src/project/browser-host.ts | 7 +- packages/cellb/src/project/site.ts | 21 ++- .../test/p18-native-async-context.test.ts | 159 ++++++++++++++++++ 8 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 packages/cellb/test/p18-native-async-context.test.ts diff --git a/packages/cellb/docs/browser-host.md b/packages/cellb/docs/browser-host.md index 6dcd36f..8cc384d 100644 --- a/packages/cellb/docs/browser-host.md +++ b/packages/cellb/docs/browser-host.md @@ -24,7 +24,8 @@ and WebKit. - **`AsyncLocalStorage` needs help.** Browsers have no async context yet: for a nodejs_compat 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)). + 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. diff --git a/packages/cellb/docs/gaps.md b/packages/cellb/docs/gaps.md index 204310e..34c637c 100644 --- a/packages/cellb/docs/gaps.md +++ b/packages/cellb/docs/gaps.md @@ -94,7 +94,11 @@ Dedicated Workers all work in Firefox 157, and the whole suite passes there. Wha 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. Event listeners (`addEventListener`, MessagePort + `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 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 723fbd5..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; @@ -19,8 +25,24 @@ * 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"); @@ -59,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 @@ -85,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); @@ -101,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); } @@ -114,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 04dbc9f..639d194 100644 --- a/packages/cellb/src/project/browser-host.ts +++ b/packages/cellb/src/project/browser-host.ts @@ -105,9 +105,10 @@ async function importWorkerCode( const nodeCompat = flags.some((f) => f.startsWith("nodejs_compat")); if (nodeCompat) { installNodeGlobals(); - // node:async_hooks: continuations carry their AsyncLocalStorage stores. 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. + // 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 = { diff --git a/packages/cellb/src/project/site.ts b/packages/cellb/src/project/site.ts index 0ee0d72..963899a 100644 --- a/packages/cellb/src/project/site.ts +++ b/packages/cellb/src/project/site.ts @@ -9,6 +9,7 @@ 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"; @@ -45,12 +46,17 @@ function loweringTransform(): Transform { export function createProjectSite(opts: { projectFiles: FilesApi; /** - * nodejs_compat: serves the `node:*` modules, and lowers the modules' awaits to generators so - * that AsyncLocalStorage stores follow them. It must match the project's flag: the browser host - * makes continuations carry stores for a nodejs_compat project, which awaits that are not - * lowered would not see. + * 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 @@ -76,9 +82,10 @@ export function createProjectSite(opts: { scheduler: SCHEDULER_EXPRESSION, ...(opts.nodeCompat ? { Buffer: `${host}("Buffer")`, process: `${host}("process")` } : {}), }; - // nodejs_compat: awaits lowered to generators, so that AsyncLocalStorage stores follow them - // (the browser host installs the polyfill's continuations for such a project). - const transform = opts.nodeCompat ? loweringTransform() : undefined; + // 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, 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/, + ); + }); +});