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) });
});