From d6458411544702dcd389d018e692b7c4efc8be4c Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 4 Nov 2026 13:03:35 -0500 Subject: [PATCH] Serve ES modules to require() as synchronously evaluable functions require() of an ES module has to evaluate it on the guest's own thread without the browser's module linker, which only runs asynchronously. The loadEsmSync syscall hands the runtime what it needs for one module in one round trip: the source as a function, its export plan, its import bindings, and every request already resolved with the import conditions, as Node resolves inside a required graph. The function form comes from es-module-lexer alone. Import and export-from statements, export keywords and export lists are blanked, and import.meta, import() and `export default` are replaced, all at the same width, so every line and column after the first is the source's own and stack frames point at the guest's code. Import bindings become closure variables the runtime assigns, which V8 resolves statically; the sketch measured a `with` scope at 37x to 100x slower in hot code. Results are cached by stat version like the served module bytes, and a TypeScript module or dependency waits for the strip engine as loadModule does. kernel.js grows by 3,946 bytes min (1,213 brotli). tests/function-form.spec.ts checks positions over every fixture module and @emnapi/core 2.0.0-beta.2, read from the tarball cache, whose exact export plan it asserts. --- packages/kernel/src/kernel/function-form.ts | 165 ++++++++++++++++++++ packages/kernel/src/kernel/main.ts | 10 ++ packages/kernel/src/kernel/transform.ts | 52 +++--- packages/kernel/src/shared/protocol.ts | 26 ++- packages/kernel/tests/function-form.spec.ts | 121 ++++++++++++++ packages/kernel/tests/tarball-cache.ts | 30 +++- 6 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 packages/kernel/src/kernel/function-form.ts create mode 100644 packages/kernel/tests/function-form.spec.ts diff --git a/packages/kernel/src/kernel/function-form.ts b/packages/kernel/src/kernel/function-form.ts new file mode 100644 index 0000000..fcc1063 --- /dev/null +++ b/packages/kernel/src/kernel/function-form.ts @@ -0,0 +1,165 @@ +// An ES module's source as a function the runtime can evaluate synchronously, for +// require(esm) (sketches/require-esm.md). Driven by es-module-lexer alone: import and +// `export … from` statements, `export` keywords and export lists are blanked to the +// same width, so every token after line 1 keeps its line and column. The output: +// (function (__nib_meta, __nib_def, __nib_bind, __nibI) { let a,b;__nib_bind((g)=>{a=g(0);b=g(1);}); +// return function () {"use strict";__nib_def({"x":()=>x}); +// }; }) +// Import bindings are closure variables the runtime assigns (g(i) is binding i's +// value), so V8 resolves them statically. __nib_def receives a getter per local +// export, which is what makes a namespace read live and subject to the TDZ. +import { parse as lexParse } from "es-module-lexer"; +import type { EsmBinding, EsmExport } from "../shared/protocol"; + +export interface FunctionForm { + code: string; + /** Distinct static specifiers in source order; bindings and exports index them. */ + requests: string[]; + bindings: EsmBinding[]; + exports: EsmExport[]; +} + +export type Edit = { start: number; end: number; replacement: string }; + +/** The default export of `export default `: "export default" and + * "const __nib_d=" are both 14 characters. */ +export const DEFAULT_LOCAL = "__nib_d"; + +/** `replacement` over [start, end), padded with the span's own line breaks and spaces + * so the text after it keeps its position. */ +function sameWidth(text: string, start: number, end: number, replacement: string): Edit { + return { start, end, replacement: replacement + text.slice(start + replacement.length, end).replace(/[^\n\r]/g, " ") }; +} + +const blank = (text: string, start: number, end: number): Edit => sameWidth(text, start, end, ""); + +const IDENT = "[A-Za-z_$][\\w$]*"; +const NAMED = new RegExp(`^(?:"([^"]*)"|'([^']*)'|(${IDENT}))(?:\\s+as\\s+(${IDENT}))?$`); + +/** The bindings of one import clause: the text from `import` to the specifier. */ +function parseClause(clause: string): { local: string; imported: string | null }[] { + let s = clause.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, " ").trim().replace(/^import\b/, "").replace(/\bfrom\s*$/, "").trim(); + const out: { local: string; imported: string | null }[] = []; + const def = new RegExp(`^(${IDENT})\\s*(,|$)`).exec(s); + if (def) { + out.push({ local: def[1]!, imported: "default" }); + s = s.slice(def[0].length).trim(); + } + const star = new RegExp(`^\\*\\s*as\\s+(${IDENT})$`).exec(s); + if (star) { + out.push({ local: star[1]!, imported: null }); + return out; + } + const list = /^\{([\s\S]*)\}$/.exec(s); + if (list) { + for (const part of list[1]!.split(",")) { + const p = part.trim(); + if (p === "") continue; + const m = NAMED.exec(p); + if (!m) throw new SyntaxError(`unsupported import clause: ${clause}`); + const imported = m[1] ?? m[2] ?? m[3]!; + out.push({ local: m[4] ?? imported, imported }); + } + } else if (s !== "") throw new SyntaxError(`unsupported import clause: ${clause}`); + return out; +} + +/** End (exclusive) of the `{ … }` list after `from`, skipping quoted names. */ +function braceEnd(text: string, from: number): number { + for (let i = text.indexOf("{", from) + 1; i > 0 && i < text.length; i++) { + const c = text[i]; + if (c === '"' || c === "'") i = text.indexOf(c, i + 1); + else if (c === "}") return i + 1; + } + throw new SyntaxError("unterminated export list"); +} + +/** Throws when the lexer rejects the source or a clause is beyond the grammar here. */ +export function toFunctionForm(text: string, path: string): FunctionForm { + const [imports, exports] = lexParse(text); + const edits: Edit[] = []; + const requests: string[] = []; + const requestIndex = new Map(); + const requestOf = (i: number): number => { + const spec = imports[i]!.specifier!; + let n = requestIndex.get(spec); + if (n === undefined) { + n = requests.length; + requestIndex.set(spec, n); + requests.push(spec); + } + return n; + }; + const bindings: (EsmBinding & { local: string })[] = []; + + imports.forEach((imp, i) => { + if (imp.type === "dynamic") { + edits.push(sameWidth(text, imp.importStart, imp.dynamicStart, "__nibI")); + } else if (imp.type === "import-meta") { + edits.push(sameWidth(text, imp.start, imp.end, "__nib_meta")); + } else { + const request = requestOf(i); + edits.push(blank(text, imp.importStart, imp.importEnd)); + if (text.startsWith("import", imp.importStart)) { + for (const b of parseClause(text.slice(imp.importStart, imp.start - 1))) bindings.push({ ...b, request }); + } + } + }); + + const plan: EsmExport[] = []; + const blanked = new Set(); + const blankOnce = (start: number, end: () => number): void => { + if (blanked.has(start)) return; + blanked.add(start); + edits.push(blank(text, start, end())); + }; + for (const ex of exports) { + if (ex.type === "reexport-all") { + plan.push({ kind: "star", request: requestOf(ex.importIndex) }); + } else if (ex.type === "reexport") { + plan.push({ kind: "reexport", name: ex.name, request: requestOf(ex.importIndex), imported: ex.importName }); + // `export { imported as x }` is a statement of its own; the `from` form was + // blanked with its import record. + if (imports[ex.importIndex]!.importStart !== ex.exportStart) blankOnce(ex.exportStart, () => braceEnd(text, ex.exportStart)); + } else if (text.slice(ex.exportStart + 6).trimStart().startsWith("{")) { + plan.push({ kind: "local", name: ex.name, local: ex.localName! }); + blankOnce(ex.exportStart, () => braceEnd(text, ex.exportStart)); + } else if (ex.name === "default" && text.startsWith("default", ex.start)) { + const declaration = ex.localName !== undefined && ex.localName !== "default" && /^(?:async\s+function\b|function\b|class\b)/.test(text.slice(ex.end).trimStart()); + if (declaration) { + edits.push(blank(text, ex.exportStart, ex.end)); + plan.push({ kind: "local", name: "default", local: ex.localName! }); + } else { + edits.push(sameWidth(text, ex.exportStart, ex.end, `const ${DEFAULT_LOCAL}=`)); + plan.push({ kind: "local", name: "default", local: DEFAULT_LOCAL }); + } + } else { + plan.push({ kind: "local", name: ex.name, local: ex.localName! }); + blankOnce(ex.exportStart, () => ex.exportStart + 6); + } + } + + let body = applyEdits(text, edits); + if (body.startsWith("#!")) body = `//${body.slice(2)}`; + const locals = plan.filter((e) => e.kind === "local"); + const def = `__nib_def({${locals.map((e) => `${JSON.stringify(e.name)}:()=>${e.local}`).join(",")}});`; + const declare = bindings.length === 0 ? "" + : `let ${bindings.map((b) => b.local).join(",")};__nib_bind((g)=>{${bindings.map((b, i) => `${b.local}=g(${i});`).join("")}});`; + const code = `(function (__nib_meta, __nib_def, __nib_bind, __nibI) { ${declare}return function () {"use strict";${def}${body}\n}; })\n//# sourceURL=file://${path}`; + return { code, requests, bindings: bindings.map(({ request, imported }) => ({ request, imported })), exports: plan }; +} + +/** One forward pass over non-overlapping edits: each byte of the source is copied + * once, where re-slicing the whole string per edit costs O(length × edits). */ +export function applyEdits(text: string, edits: Edit[]): string { + if (edits.length === 0) return text; + edits.sort((a, b) => a.start - b.start); + const parts: string[] = []; + let at = 0; + for (const e of edits) { + parts.push(text.slice(at, e.start), e.replacement); + at = e.end; + } + parts.push(text.slice(at)); + return parts.join(""); +} diff --git a/packages/kernel/src/kernel/main.ts b/packages/kernel/src/kernel/main.ts index de886c2..7b63410 100644 --- a/packages/kernel/src/kernel/main.ts +++ b/packages/kernel/src/kernel/main.ts @@ -252,6 +252,10 @@ function detectModuleSyntax(path: string): boolean { /** Whether serving `req` now would consult a TypeScript engine still starting. */ function awaitsEngine(req: Req): boolean { if (req.op === "loadModule") return awaitsStrip((req.args as SyscallSpec["loadModule"]["args"]).path); + if (req.op === "loadEsmSync") { + const { path } = req.args as SyscallSpec["loadEsmSync"]["args"]; + return fs.stat(path, true).ok && sources.esmSync(path) === null; + } // module.stripTypeScriptTypes has no path to gate on (awaitsStrip's .tsx/node_modules // rules are about which files strip) — it always wants the engine, just like a .ts load. if (req.op === "stripTypes") return !stripSettled(); @@ -375,6 +379,12 @@ function serve(req: Req, proc: Proc, thread: Thread = proc.main): Res | null { } break; } + case "loadEsmSync": { + const a = req.args as SyscallSpec["loadEsmSync"]["args"]; + const st = fs.stat(a.path, true); + result = st.ok ? { ok: true, val: sources.esmSync(a.path)! } : st; + break; + } case "stripTypes": { const a = req.args as SyscallSpec["stripTypes"]["args"]; result = { ok: true, val: stripCode(a.code) }; diff --git a/packages/kernel/src/kernel/transform.ts b/packages/kernel/src/kernel/transform.ts index 9d769ec..5c368d9 100644 --- a/packages/kernel/src/kernel/transform.ts +++ b/packages/kernel/src/kernel/transform.ts @@ -19,12 +19,13 @@ // dropped the meta rewrite; exposed by the TS slice's stripped sources, fixed here. import { parse as lexParse } from "es-module-lexer"; import { parse as cjsLexParse } from "cjs-module-lexer"; -import type { FsResult, ResolvedModule, StatRecord } from "../shared/protocol"; +import type { EsmSyncModule, FsResult, ResolvedModule, StatRecord } from "../shared/protocol"; import type { ModuleKind } from "../shared/vurls"; import { builtinUrl, cjsUrl, vfsUrl, type HostToken } from "../shared/vurls"; import { BUILTIN_EXPORT_NAMES } from "../shared/builtin-names"; import type { BuiltinId } from "../shared/builtin-ids"; import { awaitsStrip, ensureStrip, isTypeScriptPath, prepareTypeScript } from "./strip"; +import { applyEdits, toFunctionForm, type Edit } from "./function-form"; /** Everything the transformer needs from the kernel, injected to keep the * dependency one-way (transform ← main, never the reverse). */ @@ -40,8 +41,6 @@ export interface SourceDeps { typeless(path: string): string | null; } -type Edit = { start: number; end: number; replacement: string }; - const dec = new TextDecoder(); const enc = new TextEncoder(); @@ -240,28 +239,45 @@ export function rewriteCjsDynamicImports(path: string, text: string): string { return applyEdits(text, dynamicImportEdits(imports, path)); } -function applyEdits(text: string, edits: Edit[]): string { - // One forward pass over non-overlapping edits: each byte of the source is copied - // once, where re-slicing the whole string per edit costs O(length × edits). - if (edits.length === 0) return text; - edits.sort((a, b) => a.start - b.start); - const parts: string[] = []; - let at = 0; - for (const e of edits) { - parts.push(text.slice(at, e.start), e.replacement); - at = e.end; - } - parts.push(text.slice(at)); - return parts.join(""); -} - export interface SourceServer { /** Produce the module bytes for one SW fetch. Rejects → the SW answers 404. */ serve(kind: ModuleKind, path: string): Promise; + /** An ES module in function form for require(), or null while it or a request's + * format waits on the TypeScript engine. */ + esmSync(path: string): EsmSyncModule | null; } +const syncCache = new Map(); + export function createSourceServer(deps: SourceDeps): SourceServer { return { + esmSync(path) { + const v = versionOf(deps, path); + const hit = syncCache.get(path); + if (hit && hit.v === v) return hit.mod; + if (awaitsStrip(path)) return null; + const read = deps.read(path); + if (!read.ok) throw new Error(`read failed for ${path}: ${read.err.code}`); + let text = dec.decode(read.val); + if (isTypeScriptPath(path)) text = asServed(prepareTypeScript(path, text)); + let form; + try { + form = toFunctionForm(text, path); + } catch { + // Unlexable source: compiling it as it is reports the engine's own SyntaxError. + form = { code: `(function () { return function () {${text}\n}; })`, requests: [], bindings: [], exports: [] }; + } + const requests: EsmSyncModule["requests"] = []; + for (const specifier of form.requests) { + const r = deps.resolve(specifier, parentDirOf(path), "import"); + const resolved: ResolvedModule = r.ok ? r.val : { kind: "missing", code: "ERR_MODULE_NOT_FOUND" }; + if (resolved.kind === "file" && awaitsStrip(resolved.path)) return null; + requests.push({ specifier, resolved }); + } + const mod = { code: enc.encode(form.code), requests, bindings: form.bindings, exports: form.exports }; + syncCache.set(path, { v, mod }); + return mod; + }, async serve(kind, path) { const key = `${kind}\u0000${path}`; if (kind === "builtin") { diff --git a/packages/kernel/src/shared/protocol.ts b/packages/kernel/src/shared/protocol.ts index 3b5fb40..a7326d7 100644 --- a/packages/kernel/src/shared/protocol.ts +++ b/packages/kernel/src/shared/protocol.ts @@ -11,7 +11,7 @@ export type { InstallReport, KeepReport, ProcessRuntime, Signal, Substitution, W export type SyscallName = | "open" | "read" | "write" | "close" | "stat" | "fstat" | "mkdir" | "readdir" | "unlink" | "rmdir" | "rename" | "symlink" | "readlink" | "realpath" - | "resolve" | "loadModule" | "stripTypes" | "exit" | "listen" | "unlisten" + | "resolve" | "loadModule" | "loadEsmSync" | "stripTypes" | "exit" | "listen" | "unlisten" | "kill" | "sigaction" | "ttySetRaw" | "ttyGetSize" | "newThread" | "spawn" | "watch" | "unwatch" | "ftruncate" | "utimes" | "futimes" | "npm" | "npmLookup"; @@ -57,6 +57,29 @@ export type ResolvedModule = | { kind: "file"; path: string; format: "cjs" | "json" | "esm" } | { kind: "missing"; code: "ERR_MODULE_NOT_FOUND" | "ERR_UNKNOWN_BUILTIN_MODULE" | "ERR_PACKAGE_PATH_NOT_EXPORTED" | "ERR_PACKAGE_IMPORT_NOT_DEFINED" | "ERR_INVALID_PACKAGE_CONFIG" | "ERR_INVALID_PACKAGE_TARGET" | "ERR_UNSUPPORTED_DIR_IMPORT" | "ERR_DLOPEN_FAILED" }; +/** One export of an ES module in function form: its own binding (`local` is the + * identifier in the code), a re-export of a request's export (`imported` null for + * `export * as name`), or `export * from` a request. */ +export type EsmExport = + | { kind: "local"; name: string; local: string } + | { kind: "reexport"; name: string; request: number; imported: string | null } + | { kind: "star"; request: number }; + +/** The value the code's i-th import binding takes: a request's export, or its + * namespace when `imported` is null. */ +export interface EsmBinding { request: number; imported: string | null } + +/** An ES module made synchronously evaluable for require() (kernel esm-sync.ts). */ +export interface EsmSyncModule { + /** `(function (__nib_meta, __nib_def, __nib_bind, __nibI) { … })`: calling it + * declares the import bindings and returns the module body as a function. */ + code: Uint8Array; + /** Each module request in source order, resolved with the import conditions. */ + requests: { specifier: string; resolved: ResolvedModule }[]; + bindings: EsmBinding[]; + exports: EsmExport[]; +} + export interface SyscallSpec { open: { args: { path: string; flags: OpenFlags; mode: number }; ret: { fd: number } }; read: { args: { fd: number; len: number; pos: number | null }; ret: { bytes: Uint8Array; eof: boolean } }; @@ -84,6 +107,7 @@ export interface SyscallSpec { futimes: { args: { fd: number; mtimeMs: number }; ret: null }; resolve: { args: { specifier: string; parent: string; kind: "require" | "import" }; ret: ResolvedModule }; loadModule:{ args: { path: string }; ret: { source: Uint8Array; format: "cjs" | "json" | "esm" } }; + loadEsmSync:{ args: { path: string }; ret: EsmSyncModule }; // module.stripTypeScriptTypes: the same engine loadModule uses for a .ts path, run // on a caller-supplied string instead of a file (no node_modules/.tsx loader rules — // those are about *which files* strip, not what the engine does to a given string). diff --git a/packages/kernel/tests/function-form.spec.ts b/packages/kernel/tests/function-form.spec.ts new file mode 100644 index 0000000..c13b9db --- /dev/null +++ b/packages/kernel/tests/function-form.spec.ts @@ -0,0 +1,121 @@ +// The kernel's function form of an ES module (src/kernel/function-form.ts), checked in +// Node: positions survive the rewrite, and the export plan is exactly the module's. +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { gunzipSync } from "node:zlib"; +import { test, expect } from "@playwright/test"; +import { toFunctionForm } from "../src/kernel/function-form"; +import { tarball } from "./tarball-cache"; + +const GUESTS = "tests/fixtures/guests/require-esm"; +const LOCK = JSON.parse(readFileSync("tests/fixtures/require-esm-emnapi/package-lock.json", "utf8")); + +/** A file of an npm tarball, its bytes checked against the fixture lockfile. */ +async function packageFile(pkg: string, file: string): Promise { + const entry = LOCK.packages[`node_modules/${pkg}`]; + const tgz = await tarball(entry.resolved); + expect(`sha512-${createHash("sha512").update(tgz).digest("base64")}`).toBe(entry.integrity); + const tar = gunzipSync(tgz); + for (let at = 0; at + 512 <= tar.length;) { + const field = (from: number, len: number): string => tar.subarray(at + from, at + from + len).toString("latin1").replace(/\0.*$/s, ""); + const size = parseInt(field(124, 12), 8); + const prefix = field(345, 155); + if (`${prefix ? `${prefix}/` : ""}${field(0, 100)}` === `package/${file}`) return tar.subarray(at + 512, at + 512 + size).toString("utf8"); + at += 512 + Math.ceil(size / 512) * 512; + } + throw new Error(`${pkg} has no ${file}`); +} + +/** Every line after the first keeps its length, and each column either holds the + * source's character, a blank, or part of a same-width replacement. */ +function expectPositionsKept(source: string, code: string): void { + const src = source.split("\n"); + const out = code.split("\n"); + expect(out.length).toBe(src.length + 2); + for (let i = 1; i < src.length; i++) { + expect(out[i]!.length, `line ${i + 1}`).toBe(src[i]!.length); + const replaced = new Set(); + for (const m of out[i]!.matchAll(/__nibI|__nib_meta|const __nib_d=/g)) for (let c = m.index; c < m.index + m[0].length; c++) replaced.add(c); + for (let c = 0; c < src[i]!.length; c++) { + if (out[i]![c] !== src[i]![c] && out[i]![c] !== " " && !replaced.has(c)) throw new Error(`line ${i + 1} column ${c + 1}: ${JSON.stringify(out[i])}`); + } + } +} + +test("every line and column after the first survives, for each fixture module", () => { + const modules = readdirSync(GUESTS).filter((f) => f.endsWith(".mjs")); + expect(modules.length).toBeGreaterThan(30); + for (const f of modules) { + const text = readFileSync(`${GUESTS}/${f}`, "utf8"); + expectPositionsKept(text, toFunctionForm(text, `/app/${f}`).code); + } +}); + +test("same-width rewrites keep positions on the lines they touch", () => { + const text = [ + "#!/usr/bin/env node", + 'import a, { b as c, "d-e" as de } from "./x.mjs";', + "export default (1);", + "export { c as f } ;export * as g from 'y';", + "const u = import.meta.url, p = import ( './z.mjs' );", + "export const h = 1, k = 2; export { h as \"module.exports\" };", + "export * from \"./w.mjs\";", + ].join("\n"); + const form = toFunctionForm(text, "/app/m.mjs"); + expectPositionsKept(text, form.code); + expect(form.code).toBe([ + '(function (__nib_meta, __nib_def, __nib_bind, __nibI) { let a,c,de;__nib_bind((g)=>{a=g(0);c=g(1);de=g(2);});return function () {"use strict";__nib_def({"default":()=>__nib_d,"h":()=>h,"k":()=>k,"module.exports":()=>h});///usr/bin/env node', + " ;", + "const __nib_d= (1);", + " ; ;", + "const u = __nib_meta .url, p = __nibI ( './z.mjs' );", + " const h = 1, k = 2; ;", + " ;", + "}; })", + "//# sourceURL=file:///app/m.mjs", + ].join("\n")); + expect({ requests: form.requests, bindings: form.bindings, exports: form.exports }).toEqual({ + requests: ["./x.mjs", "y", "./w.mjs"], + bindings: [{ request: 0, imported: "default" }, { request: 0, imported: "b" }, { request: 0, imported: "d-e" }], + exports: [ + { kind: "local", name: "default", local: "__nib_d" }, + { kind: "reexport", name: "f", request: 0, imported: "b" }, + { kind: "reexport", name: "g", request: 1, imported: null }, + { kind: "local", name: "h", local: "h" }, + { kind: "local", name: "k", local: "k" }, + { kind: "local", name: "module.exports", local: "h" }, + { kind: "star", request: 2 }, + ], + }); +}); + +test("default declarations keep their own names; anonymous ones bind the default local", () => { + const plan = (text: string) => toFunctionForm(text, "/app/m.mjs").exports; + expect(plan("export default function hello() {}")).toEqual([{ kind: "local", name: "default", local: "hello" }]); + expect(plan("export default async function hello() {}")).toEqual([{ kind: "local", name: "default", local: "hello" }]); + expect(plan("export default class K {}")).toEqual([{ kind: "local", name: "default", local: "K" }]); + expect(plan("export default function () {}")).toEqual([{ kind: "local", name: "default", local: "__nib_d" }]); + expect(plan("export default class {}")).toEqual([{ kind: "local", name: "default", local: "__nib_d" }]); + expect(plan("const x = 1; export { x as default };")).toEqual([{ kind: "local", name: "default", local: "x" }]); +}); + +test("@emnapi/core 2.0.0-beta.2: positions and its exact export plan", async () => { + const text = await packageFile("@emnapi/core", "dist/emnapi-core.js"); + const form = toFunctionForm(text, "/app/node_modules/@emnapi/core/dist/emnapi-core.js"); + expectPositionsKept(text, form.code); + const local = (name: string) => ({ kind: "local", name, local: name }); + expect({ requests: form.requests, bindings: form.bindings, exports: form.exports }).toEqual({ + requests: ["@emnapi/wasi-threads"], + bindings: [ + { request: 0, imported: "ThreadManager" }, + { request: 0, imported: "WASIThreads" }, + { request: 0, imported: "ThreadMessageHandler" }, + ], + exports: [ + { kind: "star", request: 0 }, + ...["MessageHandler", "createAddonModule", "createNapiModule", "instantiateAddon", "instantiateAddonSync", + "instantiateNapiModule", "instantiateNapiModuleSync", "loadAddon", "loadAddonSync", "loadNapiModule", + "loadNapiModuleSync", "version"].map(local), + ], + }); +}); diff --git a/packages/kernel/tests/tarball-cache.ts b/packages/kernel/tests/tarball-cache.ts index 6b7247f..45b8551 100644 --- a/packages/kernel/tests/tarball-cache.ts +++ b/packages/kernel/tests/tarball-cache.ts @@ -12,6 +12,28 @@ import type { BrowserContext } from "@playwright/test"; const CACHE = fileURLToPath(new URL("fixtures/tarballs", import.meta.url)); const CORS = { "access-control-allow-origin": "*" }; +const cachePath = (url: string): string => join(CACHE, decodeURIComponent(new URL(url).pathname)); + +function store(file: string, body: Buffer): void { + mkdirSync(dirname(file), { recursive: true }); + // Parallel workers may fetch the same tarball: write aside, then rename into place. + const partial = `${file}.${process.pid}.partial`; + writeFileSync(partial, body); + renameSync(partial, file); +} + +/** One tarball's bytes for a spec that reads a package outside the browser. */ +export async function tarball(url: string): Promise { + const file = cachePath(url); + if (!existsSync(file)) { + if (process.env.TARBALLS_OFFLINE === "1") throw new Error(`${url} is not cached`); + const res = await fetch(url); + if (!res.ok) throw new Error(`${url}: ${res.status}`); + store(file, Buffer.from(await res.arrayBuffer())); + } + return readFileSync(file); +} + export async function cacheTarballs(context: BrowserContext): Promise { // Registered first, so every other route (a spec's own registry answers) runs before it. if (process.env.TARBALLS_OFFLINE === "1") { @@ -20,16 +42,12 @@ export async function cacheTarballs(context: BrowserContext): Promise { await context.route(/^https:\/\/registry\.npmjs\.org\/.+\/-\/[^/]+\.tgz$/, async (route) => { const req = route.request(); if (req.method() !== "GET") return route.fallback(); - const file = join(CACHE, decodeURIComponent(new URL(req.url()).pathname)); + const file = cachePath(req.url()); if (!existsSync(file)) { if (process.env.TARBALLS_OFFLINE === "1") return route.abort("internetdisconnected"); const res = await route.fetch(); if (res.status() !== 200) return route.fulfill({ response: res }); - mkdirSync(dirname(file), { recursive: true }); - // Parallel workers may fetch the same tarball: write aside, then rename into place. - const partial = `${file}.${process.pid}.partial`; - writeFileSync(partial, await res.body()); - renameSync(partial, file); + store(file, await res.body()); } return route.fulfill({ status: 200, headers: CORS, contentType: "application/octet-stream", body: readFileSync(file) }); }); -- knotmirror 0.0.1