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