// CJS module loader for the runtime worker. require() is synchronous: // every loadModule/resolve syscall goes through the sync mailbox (Atomics.wait). // Envelope + error mapping are the C2/C3 grafts recorded in sketches/arena-1/SYNTHESIS.md. // loadCjsByPath is also the backend of the runtime hook's cjs() — the ESM facade // modules served from the SW virtual origin evaluate CJS through the same loader // and the same cache, so a module never evaluates twice per worker. // // The pieces below (wrapper, extensions, resolveFilename) are also node:module's // Module._* surface (src/runtime/module.ts): real hook points, not a shadow API, so // a guest's require.extensions-style patch actually changes what require() does. import type { FsResult, SyscallSpec } from "../shared/protocol"; import type { HttpRuntime } from "./http"; import type { Loop } from "./loop"; import type { Timers } from "./timers"; import type { GuestProcess } from "./process"; import type { WorkerThreads } from "./worker_threads"; import type { ChildProcesses } from "./child_process"; import { loadBuiltin } from "./builtins"; import { loaderError, requireNotFound } from "./errors"; import { esmCycle, esmEvaluating, requireEsm } from "./esm-sync"; import { unsupported } from "./refuse"; import { SCHEME_ONLY_BUILTINS, isBuiltinId, type BuiltinId } from "../shared/builtin-ids"; export interface ModuleCacheEntry { exports: unknown; loaded: boolean; } /** The require()-visible module object (Node's `module`): the require cache entry * IS the module instance, so `module.exports = x` from inside a cycle is what a * later require() of the same path reads back. */ export interface ModuleLike extends ModuleCacheEntry { id: string; filename: string; children: unknown[]; paths: string[]; /** Node's module.require/_compile: real methods on every module object (not just * `new Module()` instances), so a guest's own Module._extensions handler — which * expects to call `mod._compile(content, filename)` the way Node's own default * handlers do — works on whatever require() handed it. */ require(specifier: string): unknown; _compile(content: string, filename: string): unknown; } export interface RequireContext { call(op: K, args: SyscallSpec[K]["args"]): Promise; callSync(op: K, args: SyscallSpec[K]["args"]): SyscallSpec[K]["ret"]; /** A sync syscall that hands back the kernel's result instead of throwing. */ trySync(op: K, args: SyscallSpec[K]["args"]): FsResult; /** An async syscall whose result `done` receives as a poll-phase loop callback. */ submit(op: K, args: SyscallSpec[K]["args"], done: (result: FsResult) => void): void; cache: Map; dirnameOf(path: string): string; /** Process cwd — relative builtin paths resolve against it (join is lexical). */ readonly cwd: string; resolvePath(p: string): string; /** The process-wide node:http module (one per worker: it owns the port dispatch). */ readonly http: HttpRuntime["module"]; /** The guest timer functions (the same objects as the globals). */ readonly timers: Timers; readonly process: GuestProcess; /** The worker's event loop, for builtins whose work completes outside a syscall. */ readonly loop: Loop; /** This worker's node:worker_threads state (it owns the thread's parent port). */ readonly workerThreads: WorkerThreads; /** This worker's children (node:child_process); their output comes back to it. */ readonly childProcess: ChildProcesses; } /** Compile a CJS module body the way Node wraps it: the wrapper opens on the body's * first line, so stack traces report the guest's own line numbers. `new Function` * would prepend two header lines and shift every frame by two. Entry-script * execution (worker_threads eval, the process entry) calls this directly with its * own extra `process` parameter; it is not part of Module.wrap's contract below. */ export function compileCjs(text: string, path: string, params: string[]): (...args: unknown[]) => unknown { const indirectEval = eval; // A hashbang is only legal as a script's first characters; inside the wrapper it is // not, so it goes, keeping the line (a package's bin entries start with one). const body = text.startsWith("#!") ? text.replace(/^#![^\n]*/, "") : text; return indirectEval(`(function (${params.join(", ")}) {${body}\n})\n//# sourceURL=${path}`); } /** Module.wrapper / Module.wrap: the require() path's own compile template, kept * mutable and separate from compileCjs's generic one (above) so a guest patching * it — a require hook rewriting the wrapper to inject something — really changes * what require() compiles. Same five params and order as Node. */ export const wrapper: [string, string] = [ "(function (exports, require, module, __filename, __dirname) { ", "\n});", ]; export function wrap(script: string): string { return `${wrapper[0]}${script}${wrapper[1]}`; } function compileWrapped(ctx: RequireContext, mod: ModuleLike, text: string, filename: string): unknown { const indirectEval = eval; const body = text.startsWith("#!") ? text.replace(/^#![^\n]*/, "") : text; const fn = indirectEval(`${wrap(body)}\n//# sourceURL=${filename}`) as (...args: unknown[]) => unknown; return fn.call(mod.exports, mod.exports, makeRequire(ctx, filename), mod, filename, ctx.dirnameOf(filename)); } /** Module._extensions: a compile step keyed by file extension, consulted by * module.load(). This is the same object `require("module")._extensions` hands * back (src/runtime/module.ts), so a guest's require hook (ts-node/tsx-style) * that assigns `Module._extensions['.js'] = ...` really changes what runs. An * extension not in the table falls back to `.js`, as Node's own lookup does. */ export type ExtensionHandler = (ctx: RequireContext, mod: ModuleLike, filename: string, parent: string | undefined) => void; function loadSource(ctx: RequireContext, filename: string): { text: string; format: "cjs" | "json" | "esm" } { const { source, format } = ctx.callSync("loadModule", { path: filename }); return { text: new TextDecoder().decode(source), format }; } export const extensions: Record = { ".js": (ctx, mod, filename, parent) => { const { text, format } = loadSource(ctx, filename); if (format === "esm") mod.exports = requireEsm(ctx, filename, parent); else compileWrapped(ctx, mod, text, filename); }, ".cjs": (ctx, mod, filename) => { const { text } = loadSource(ctx, filename); compileWrapped(ctx, mod, text, filename); }, ".json": (ctx, mod, filename) => { const { text } = loadSource(ctx, filename); mod.exports = JSON.parse(text); }, ".node": () => { throw unsupported("module", "require(*.node)", "native addons have no equivalent inside a worker"); }, }; function extnameOf(filename: string): string { const base = filename.slice(filename.lastIndexOf("/") + 1); const dot = base.lastIndexOf("."); return dot <= 0 ? "" : base.slice(dot); } /** Node's Module._nodeModulePaths: purely lexical (every ancestor's node_modules, * skipping one already named node_modules), no fs check — the resolver does that * when a require() actually walks it. */ export function nodeModulePaths(from: string): string[] { const paths: string[] = []; let dir = from; for (;;) { if (!dir.endsWith("/node_modules")) paths.push(dir === "/" ? "/node_modules" : `${dir}/node_modules`); const slash = dir.lastIndexOf("/"); if (slash <= 0) break; dir = dir.slice(0, slash); } return paths; } /** Node's `module` object, which is also its require-cache entry. */ export function newModule(ctx: RequireContext, path: string): ModuleLike { const mod: ModuleLike = { exports: {}, id: path, filename: path, loaded: false, children: [], paths: nodeModulePaths(dirnameLexical(path)), require: (specifier) => makeRequire(ctx, mod.filename)(specifier), _compile: (content, filename) => compileWrapped(ctx, mod, content, filename), }; return mod; } function dirnameLexical(path: string): string { const slash = path.lastIndexOf("/"); return slash <= 0 ? "/" : path.slice(0, slash); } /** Resolve→load→eval for a KNOWN path, shared by require() and the cjs() runtime * hook. The cache entry is installed before loading so a require() cycle observes * partial exports instead of recursing. Dispatches through `extensions` (above), * so a patched handler for a known extension is what actually compiles the file. */ export function loadCjsByPath(ctx: RequireContext, path: string, parent?: string): unknown { // A module still loading is a require() cycle: it answers its exports so far, // except an ES module, which has none until it finishes. const cached = ctx.cache.get(path); if (cached) { if (!cached.loaded && esmEvaluating(path)) throw esmCycle(path); return cached.exports; } const mod = newModule(ctx, path); ctx.cache.set(path, mod); try { const ext = extnameOf(path); const handler = extensions[ext] ?? extensions[".js"]!; handler(ctx, mod, path, parent); } catch (e) { ctx.cache.delete(path); // as Node does, a module that threw loads afresh next time throw e; } mod.loaded = true; return mod.exports; } /** Module._resolveFilename's default behavior: the specifier resolver, returning * either a builtin's request string back unchanged (Node echoes it: `fs` stays * `fs`, `node:fs` stays `node:fs`) or a file path. */ function defaultResolveFilename(ctx: RequireContext, request: string, parentFilename: string | undefined): string { const resolved = ctx.callSync("resolve", { specifier: request, parent: ctx.dirnameOf(parentFilename ?? ctx.cwd), kind: "require", }); if (resolved.kind === "builtin") return request; if (resolved.kind === "missing") { if (resolved.code === "ERR_MODULE_NOT_FOUND") throw requireNotFound(request, parentFilename ? [parentFilename] : []); throw loaderError(resolved.code, request, parentFilename); } return resolved.path; } export type ResolveFilenameFn = (ctx: RequireContext, request: string, parentFilename: string | undefined, isMain: boolean) => string; let resolveFilenameOverride: ResolveFilenameFn | null = null; /** Module._resolveFilename's current implementation — `defaultResolveFilename` * unless a guest (module.ts's Module._resolveFilename setter) installed its own, * in which case require() itself resolves through it too. */ export function resolveFilename(ctx: RequireContext, request: string, parentFilename: string | undefined, isMain = false): string { return (resolveFilenameOverride ?? defaultResolveFilename)(ctx, request, parentFilename, isMain); } export function setResolveFilenameHook(fn: ResolveFilenameFn | null): void { resolveFilenameOverride = fn; } /** A snapshot of whichever implementation is live right now (default or a guest's * override) — for `Module._resolveFilename`'s getter. Snapshotting matters: it * must NOT re-check the override on every call, or a guest that reads this to * save "the original" and later restores it (`Module._resolveFilename = original`) * would hand its own restored value back to itself as the override and recurse * forever. A snapshot is forever "whatever was running at read time", exactly * what "the original" means to a caller doing a save/patch/restore. */ export function currentResolveFilename(): ResolveFilenameFn { return resolveFilenameOverride ?? defaultResolveFilename; } /** Whether `name` (as `Module._resolveFilename`/`resolve()` handed back) names a * builtin — `node:fs` or bare `fs`, the two forms Node accepts — and the bare id * if so. Shared by require(), require.resolve and Module._load. */ export function builtinIdOf(name: string): BuiltinId | null { const id = name.replace(/^node:/, ""); if (!isBuiltinId(id)) return null; if (!name.startsWith("node:") && SCHEME_ONLY_BUILTINS.has(id)) return null; return id; } /** Node's require.cache IS Module._cache — the exact same object, not a lookalike * (a guest does `delete require.cache[id]` and `Module._cache[id]` interchangeably). * One Proxy per worker, built lazily so modules.ts never needs ctx until a require() * actually happens; module.ts (node:module) hands back this same instance. */ let cacheViewSingleton: Record | null = null; export function cacheView(ctx: RequireContext): Record { cacheViewSingleton ??= new Proxy({} as Record, { get: (_t, key) => (typeof key === "string" ? ctx.cache.get(key) : undefined), set: (_t, key, value) => { if (typeof key === "string") ctx.cache.set(key, value as ModuleLike); return true; }, has: (_t, key) => typeof key === "string" && ctx.cache.has(key), deleteProperty: (_t, key) => { if (typeof key === "string") ctx.cache.delete(key); return true; }, ownKeys: () => [...ctx.cache.keys()], getOwnPropertyDescriptor: (_t, key) => (typeof key === "string" && ctx.cache.has(key) ? { enumerable: true, configurable: true, value: ctx.cache.get(key) } : undefined), }); return cacheViewSingleton; } export function makeRequire(ctx: RequireContext, filename: string): (specifier: string) => unknown { const require = (specifier: string): unknown => { const resolved = resolveFilename(ctx, specifier, filename, false); const builtinId = builtinIdOf(resolved); if (builtinId !== null) return loadBuiltin(builtinId, ctx); return loadCjsByPath(ctx, resolved, filename); }; (require as { resolve?: unknown }).resolve = (specifier: string): string => resolveFilename(ctx, specifier, filename, false); (require as { cache?: unknown }).cache = cacheView(ctx); return require; }