From c8c2e1b915df5ea4385d2a7e7c6d81069c92c1bf Mon Sep 17 00:00:00 2001 From: killagu Date: Sat, 27 Jun 2026 00:31:14 +0800 Subject: [PATCH] feat(bundler): lazy-external network stack for V8 snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the snapshot prelude skeleton from #5998 with the lazy-external / native-binding stub mechanism, so a snapshot bundle stays serializable. The Node network stack (http/https/http2/tls/dns) produces native bindings (HTTPParser, nghttp2 settingsBuffer, tls SecureContext, dns ChannelWrap) that V8 cannot serialize, and WebAssembly is disabled under --build-snapshot. They must not load while the snapshot is built, then load for real at restore. prelude.ts (renderSnapshotPrelude now carries the lazy id set): - deletes Node's lazy web globals with `delete` (redefining the accessor would trigger the undici load we avoid); - installs `__LAZY_EXT` + `__makeLazyExt`. The factory returns a Proxy that is a stub at build time (hardcoded http METHODS/STATUS_CODES/maxHeaderSize so a library's top-level `[...http.METHODS]` does not force a load) and forwards to the real module via `globalThis.__RUNTIME_REQUIRE` once the generated restore entry installs it. Structural traps delegate to the target to keep Proxy invariants. - new `injectExternalRequireLazyHook` and `resolveSnapshotLazyModules`. Bundler (snapshot mode): resolves the lazy id set (network-stack default + app `egg.snapshot.lazyModules` from package.json), keeps those ids external so @utoo/pack emits externalRequire, injects the lazy dispatch into externalRequire, then prepends the prelude carrying that id set. Third-party business deps (leoric/@elastic/...) are out of scope — apps can add them via egg.snapshot.lazyModules but stub-completeness is a separate RFC. Tests: lazy-module resolution, filled prelude, externalRequire injection, vm-based runtime proxy behavior (build stub vs restore), a Bundler wiring test, and a real @utoo/pack build proving http is a stub at build and the real module after __RUNTIME_REQUIRE. #5998's prelude/Bundler tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/egg-bundler/src/index.ts | 10 +- tools/egg-bundler/src/lib/Bundler.ts | 137 ++++-- tools/egg-bundler/src/lib/prelude.ts | 396 +++++++++++++++++- .../test/snapshot-lazy-bundler.test.ts | 213 ++++++++++ .../test/snapshot-lazy-external.test.ts | 238 +++++++++++ .../test/snapshot-lazy.realbuild.test.ts | 159 +++++++ 6 files changed, 1103 insertions(+), 50 deletions(-) create mode 100644 tools/egg-bundler/test/snapshot-lazy-bundler.test.ts create mode 100644 tools/egg-bundler/test/snapshot-lazy-external.test.ts create mode 100644 tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts diff --git a/tools/egg-bundler/src/index.ts b/tools/egg-bundler/src/index.ts index d63fb9f8f9..faba0f227a 100644 --- a/tools/egg-bundler/src/index.ts +++ b/tools/egg-bundler/src/index.ts @@ -2,7 +2,15 @@ export { Bundler } from './lib/Bundler.ts'; export { EntryGenerator, type EntryGeneratorOptions, type GeneratedEntries } from './lib/EntryGenerator.ts'; export { ExternalsResolver, type ExternalsConfig, type ExternalsResolverOptions } from './lib/ExternalsResolver.ts'; export { ManifestLoader, type ManifestLoaderOptions } from './lib/ManifestLoader.ts'; -export { renderSnapshotPrelude, prependSnapshotPrelude, SNAPSHOT_PRELUDE_MARKER } from './lib/prelude.ts'; +export { + renderSnapshotPrelude, + prependSnapshotPrelude, + injectExternalRequireLazyHook, + resolveSnapshotLazyModules, + SNAPSHOT_PRELUDE_MARKER, + DEFAULT_SNAPSHOT_LAZY_MODULES, + type ExternalRequireInjectionResult, +} from './lib/prelude.ts'; export { PackRunner, type BuildFunc, diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 546693a5b5..a711190ca3 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -10,7 +10,7 @@ import { ExternalsResolver } from './ExternalsResolver.ts'; import { assertFrameworkPackageSpecifier } from './frameworkSpecifier.ts'; import { ManifestLoader } from './ManifestLoader.ts'; import { PackRunner } from './PackRunner.ts'; -import { prependSnapshotPrelude } from './prelude.ts'; +import { injectExternalRequireLazyHook, prependSnapshotPrelude, resolveSnapshotLazyModules } from './prelude.ts'; const debug = debuglog('egg/bundler/bundler'); @@ -346,6 +346,13 @@ export class Bundler { const singleFile = snapshot ? true : mergedPack?.singleFile; debug('snapshot=%s singleFile=%o', snapshot, singleFile); + // The Node network-stack ids (plus the app's egg.snapshot.lazyModules) that must + // stay external so @utoo/pack emits an externalRequire call the prelude can + // intercept, and that the prelude's __LAZY_EXT set names. + const snapshotLazyModules = snapshot + ? await wrapStep('resolve snapshot lazy modules', () => resolveSnapshotLazyModules(absBaseDir)) + : []; + const manifestLoader = new ManifestLoader({ baseDir: absBaseDir, manifestPath, @@ -359,8 +366,15 @@ export class Bundler { force: externals?.force, inline: externals?.inline, }); - const externalsMap = await wrapStep('externals resolve', () => externalsResolver.resolve()); - debug('externals resolved: %d packages', Object.keys(externalsMap).length); + const resolvedExternals = await wrapStep('externals resolve', () => externalsResolver.resolve()); + debug('externals resolved: %d packages', Object.keys(resolvedExternals).length); + + // Keep the lazy network-stack ids external so @utoo/pack does not inline them + // (an inlined http/tls/dns would load — and fail to serialize — at snapshot + // build time). A builtin id maps to itself for the runtime require(). + const externalsMap: Record = snapshot + ? { ...resolvedExternals, ...Object.fromEntries(snapshotLazyModules.map((id) => [id, id])) } + : resolvedExternals; const entryGen = new EntryGenerator({ baseDir: absBaseDir, @@ -402,13 +416,18 @@ export class Bundler { patchResult.deletedMapCount, ); - // In snapshot mode prepend the snapshot prelude to each entry's worker.js so it - // runs before the bundle IIFE (and therefore before any bundled module loads). + // In snapshot mode inject the lazy-external dispatch into @utoo/pack's + // externalRequire and prepend the prelude to each entry's worker.js so it runs + // before the bundle IIFE (and therefore before any bundled module loads). if (snapshot) { - const prependedEntries = await wrapStep('prepend snapshot prelude', () => - this.#prependSnapshotPrelude(absOutputDir, ['worker']), + const applied = await wrapStep('apply snapshot prelude', () => + this.#applySnapshotPrelude(absOutputDir, ['worker'], snapshotLazyModules, patchResult.outputFiles), + ); + debug( + 'snapshot: prepended prelude to %d entry file(s), injected lazy hook into %d externalRequire(s)', + applied.prependedEntries.length, + applied.injectedCount, ); - debug('prepended snapshot prelude to %d entry file(s)', prependedEntries.length); } const copiedRuntimeAssets = await wrapStep('runtime asset copy', () => @@ -447,32 +466,90 @@ export class Bundler { }; } - async #prependSnapshotPrelude(outputDir: string, entryNames: readonly string[]): Promise { - const prepended: string[] = []; - for (const name of entryNames) { - const rel = this.#sanitizeOutputRelativePath(`${name}.js`); + /** + * Make the bundled output V8-snapshot-eligible: + * 1. Inject the lazy dispatch at the start of every `externalRequire` body (in any + * emitted .js) so a require of a lazy module id routes to the prelude's + * `__makeLazyExt` instead of loading the real (non-serializable) module. + * 2. Prepend the prelude (carrying the resolved lazy id set) to each entry's + * worker.js so it runs before the bundle IIFE. + */ + async #applySnapshotPrelude( + outputDir: string, + entryNames: readonly string[], + lazyModules: readonly string[], + outputFiles: readonly string[], + ): Promise<{ prependedEntries: readonly string[]; injectedCount: number }> { + const entrySet = new Set(entryNames.map((name) => this.#sanitizeOutputRelativePath(`${name}.js`))); + const seenEntries = new Set(); + const prependedEntries: string[] = []; + let injectedCount = 0; + let sawExternalRequire = false; + let sawInjectedLazyHook = false; + + // Single pass over the emitted .js files: inject the lazy hook into every file + // carrying externalRequire (single-file mode keeps it in worker.js; the loop + // stays robust if it ever moves) and, in the same read/write, prepend the + // prelude to entry files so worker.js is only touched once. + for (const rawRel of outputFiles) { + const rel = this.#sanitizeOutputRelativePath(rawRel); + if (!rel.endsWith('.js')) continue; + const isEntry = entrySet.has(rel); + const filepath = path.join(outputDir, rel); - // The entry file is required output; a missing worker.js means the pack - // build did not emit what we expect. Fail fast with a clear error instead - // of silently skipping the prelude (which would surface later as an - // obscure snapshot-build failure). - const content = await fs.readFile(filepath, 'utf8').catch((err: NodeJS.ErrnoException) => { - // Only a missing file means "broken pack output"; preserve the original - // error for permission / other I/O failures so they stay diagnosable. - if (err.code === 'ENOENT') { - throw new Error(`snapshot prelude: expected bundle entry "${rel}" was not found at ${filepath}`, { - cause: err, - }); - } - throw err; - }); - const next = prependSnapshotPrelude(content); - if (next !== content) { + const original = await fs.readFile(filepath, 'utf8'); + // Detect the @utoo/pack helper *definition* independently of our injection + // regex, so a helper that is emitted but not patched (e.g. a codegen format + // our injection regex no longer matches) becomes a loud build error below + // rather than a silent snapshot that loads the network stack at build time. + if (/function\s+externalRequire\b/.test(original)) sawExternalRequire = true; + // A file already carrying the dispatch was patched on a previous run; its + // injectedCount is 0 (idempotent) but it must NOT count as "unpatched". + if (original.includes('globalThis.__makeLazyExt(')) sawInjectedLazyHook = true; + const hooked = injectExternalRequireLazyHook(original); + injectedCount += hooked.injected; + + let next = hooked.content; + if (isEntry) { + next = prependSnapshotPrelude(next, lazyModules); + seenEntries.add(rel); + prependedEntries.push(rel); + } + if (next !== original) { await fs.writeFile(filepath, next); - prepended.push(rel); } } - return prepended; + + // The entry file is required output; a missing worker.js (absent from the pack + // output enumeration) means the build did not emit what we expect. Fail fast + // with a clear error instead of silently skipping the prelude (which would + // surface later as an obscure snapshot-build failure). + for (const rel of entrySet) { + if (!seenEntries.has(rel)) { + throw new Error( + `snapshot prelude: expected bundle entry "${rel}" was not found at ${path.join(outputDir, rel)}`, + ); + } + } + + if (injectedCount === 0) { + if (sawExternalRequire && !sawInjectedLazyHook) { + // The helper was emitted but our injection regex matched nothing: the lazy + // dispatch is absent, so the snapshot build would load the network stack and + // fail to serialize. Fail loud instead of producing a broken blob. + throw new Error( + 'snapshot prelude: an externalRequire helper was emitted but the lazy hook could not be ' + + 'injected (its signature did not match). The bundle would load the network stack at ' + + 'snapshot-build time.', + ); + } + // Otherwise benign: a bundle that never requires an external has nothing to + // defer. A real app that touches the network stack always produces an + // externalRequire, so this only fires for trivial/synthetic bundles. + debug('snapshot: no externalRequire helper emitted — lazy hook injected nowhere'); + } + + return { prependedEntries, injectedCount }; } async #copyRuntimeAssets( diff --git a/tools/egg-bundler/src/lib/prelude.ts b/tools/egg-bundler/src/lib/prelude.ts index 7c6de253ea..85616a02f1 100644 --- a/tools/egg-bundler/src/lib/prelude.ts +++ b/tools/egg-bundler/src/lib/prelude.ts @@ -3,36 +3,352 @@ * * In snapshot mode the bundler prepends this prelude to the emitted single-file * `worker.js`, BEFORE the bundle IIFE (`((__UTOOPACK__)=>{...})([...modules])`), - * so it runs before any bundled module is evaluated. PR3 fills the body with the - * lazy-require / native-binding stub mechanism that converges non-serializable - * native bindings for V8 startup snapshots. For now it is an intentional no-op - * skeleton plus a placeholder hook so the wiring (generate → prepend → detect) is - * in place and testable. + * so it runs before any bundled module is evaluated. It installs the + * lazy-external / native-binding stub mechanism that keeps a V8 startup snapshot + * serializable: the Node network stack (http/https/http2/tls/dns) is NOT loaded + * while the snapshot is built (its native bindings — HTTPParser, nghttp2 + * settingsBuffer, tls SecureContext, dns ChannelWrap — cannot be serialized, and + * `WebAssembly` is disabled under `--build-snapshot`), then forwarded to the real + * module at restore time via `globalThis.__RUNTIME_REQUIRE` (installed by the + * generated snapshot-restore entry). */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +const debug = debuglog('egg/bundler/snapshot-prelude'); + /** * Marker comment embedded in the prelude. Used to detect an already-prepended * prelude so {@link prependSnapshotPrelude} stays idempotent across re-runs. */ export const SNAPSHOT_PRELUDE_MARKER = '@eggjs/egg-bundler:snapshot-prelude'; +/** + * Node built-in network modules that produce non-serializable native bindings when + * loaded inside a V8 startup snapshot builder: + * + * - `node:http` / `node:https` create an `HTTPParser` (llhttp) C++ global handle. + * - `node:http2` creates `HTTPParser` + an `nghttp2` `settingsBuffer` Uint32Array. + * - `node:tls` creates a `SecureContext`. + * - `node:dns` creates a `ChannelWrap`. + * + * Egg's loader phase touches the HTTP/TLS/DNS stack (HttpClient, agents, etc.), so + * these are kept as lazy externals: build time returns a stub Proxy; restore time + * forwards to the real module via `globalThis.__RUNTIME_REQUIRE`. + */ +export const DEFAULT_SNAPSHOT_LAZY_MODULES: readonly string[] = [ + 'http', + 'https', + 'http2', + 'node:http', + 'node:https', + 'node:http2', + 'tls', + 'node:tls', + 'dns', + 'node:dns', +]; + +/** + * Node's lazy web globals. Accessing any of them lazily initializes undici, whose + * llhttp parser allocates a `WebAssembly` instance + `HTTPParser` binding that the + * snapshot builder cannot serialize. They are removed with `delete` before any + * bundled module runs. `Object.defineProperty` is NOT usable here: redefining the + * lazy accessor triggers the very undici load we are avoiding. + */ +const WEB_GLOBALS: readonly string[] = [ + 'fetch', + 'Headers', + 'Request', + 'Response', + 'FormData', + 'WebSocket', + 'EventSource', + 'MessageEvent', + 'CloseEvent', + 'File', + 'Blob', +]; + +/** `http.METHODS` — hardcoded so a library's top-level `[...http.METHODS]` does not force a build-time load. */ +const HTTP_METHODS: readonly string[] = [ + 'ACL', + 'BIND', + 'CHECKOUT', + 'CONNECT', + 'COPY', + 'DELETE', + 'GET', + 'HEAD', + 'LINK', + 'LOCK', + 'M-SEARCH', + 'MERGE', + 'MKACTIVITY', + 'MKCALENDAR', + 'MKCOL', + 'MOVE', + 'NOTIFY', + 'OPTIONS', + 'PATCH', + 'POST', + 'PROPFIND', + 'PROPPATCH', + 'PURGE', + 'PUT', + 'QUERY', + 'REBIND', + 'REPORT', + 'SEARCH', + 'SOURCE', + 'SUBSCRIBE', + 'TRACE', + 'UNBIND', + 'UNLINK', + 'UNLOCK', + 'UNSUBSCRIBE', +]; + +/** `http.STATUS_CODES` — hardcoded for the same reason as METHODS. */ +const HTTP_STATUS_CODES: Readonly> = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a Teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '509': 'Bandwidth Limit Exceeded', + '510': 'Not Extended', + '511': 'Network Authentication Required', +}; + +const HTTP_MAX_HEADER_SIZE = 16384; + +interface AppPackageJson { + readonly egg?: { + readonly snapshot?: { + readonly lazyModules?: unknown; + }; + }; +} + +/** + * Read `egg.snapshot.lazyModules` from the application `package.json` and merge it + * (deduplicated, order-preserving) onto {@link DEFAULT_SNAPSHOT_LAZY_MODULES}. + * Returns the default list unchanged when the field is absent or malformed. + */ +export async function resolveSnapshotLazyModules(baseDir: string): Promise { + const merged = [...DEFAULT_SNAPSHOT_LAZY_MODULES]; + const seen = new Set(merged); + + let pkg: AppPackageJson | undefined; + try { + const raw = await fs.readFile(path.join(baseDir, 'package.json'), 'utf8'); + pkg = JSON.parse(raw) as AppPackageJson; + } catch (err) { + debug('no readable package.json at %s: %s', baseDir, err instanceof Error ? err.message : err); + return merged; + } + + const extra = pkg?.egg?.snapshot?.lazyModules; + if (!Array.isArray(extra)) return merged; + + for (const entry of extra) { + if (typeof entry !== 'string' || entry.length === 0 || seen.has(entry)) continue; + seen.add(entry); + merged.push(entry); + } + debug('resolved %d snapshot lazy modules', merged.length); + return merged; +} + /** * Render the snapshot prelude source. The result is plain CommonJS (the bundle * output package.json is `{ "type": "commonjs" }`) and must be safe to execute at * the very top of the worker file, before the bundle IIFE. + * + * It (1) deletes Node's lazy web globals and (2) installs `globalThis.__LAZY_EXT` + * (the set of lazy module ids) + `globalThis.__makeLazyExt` (the build-time stub / + * restore-time forwarder) that the patched `externalRequire` (see + * {@link injectExternalRequireLazyHook}) consults for every external require. */ -export function renderSnapshotPrelude(): string { - return [ - `// ⚠️ auto-generated by @eggjs/egg-bundler — snapshot prelude (do not edit)`, - `// marker: ${SNAPSHOT_PRELUDE_MARKER}`, - `// Runs before the bundle IIFE so it executes before any bundled module loads.`, - `// PR3 installs the lazy-require / native-binding stub mechanism here; this is`, - `// currently an intentional no-op placeholder.`, - `(function eggBundlerSnapshotPrelude() {`, - ` // PR3: install lazy-require / native-binding stubs before module evaluation.`, - `})();`, - ``, - ].join('\n'); +export function renderSnapshotPrelude(lazyModules: readonly string[] = DEFAULT_SNAPSHOT_LAZY_MODULES): string { + const lazyJson = JSON.stringify([...lazyModules]); + const webJson = JSON.stringify([...WEB_GLOBALS]); + const methodsJson = JSON.stringify([...HTTP_METHODS]); + const statusJson = JSON.stringify(HTTP_STATUS_CODES); + + return `// ⚠️ auto-generated by @eggjs/egg-bundler — snapshot prelude (do not edit) +// marker: ${SNAPSHOT_PRELUDE_MARKER} +// Runs before the bundle IIFE so it executes before any bundled module loads. +/* eslint-disable */ +(function eggBundlerSnapshotPrelude() { + 'use strict'; + // Drop Node's lazy web globals before any bundled module touches them. These + // getters lazily initialize undici, whose llhttp HTTPParser / WebAssembly cannot + // be V8-snapshot-serialized. MUST use \`delete\`: redefining the lazy accessor + // would trigger the very load we are avoiding. + var __WEB_GLOBALS = ${webJson}; + for (var __i = 0; __i < __WEB_GLOBALS.length; __i++) { + try { delete globalThis[__WEB_GLOBALS[__i]]; } catch (e) {} + } + + if (globalThis.__LAZY_EXT) return; + + // Set of module ids treated as lazy externals. The patched externalRequire + // forwards these here instead of requiring the real module at build time. + globalThis.__LAZY_EXT = new Set(${lazyJson}); + + // Hardcoded http constants so top-level \`[...http.METHODS]\` or + // \`Object.keys(http.STATUS_CODES)\` in a bundled library does not force a + // build-time load of the real (non-serializable) http module. + var __HTTP_METHODS = ${methodsJson}; + var __HTTP_STATUS_CODES = ${statusJson}; + var __HTTP_MAX_HEADER_SIZE = ${HTTP_MAX_HEADER_SIZE}; + + globalThis.__makeLazyExt = function (id, thunk) { + var isHttp = id === 'http' || id === 'node:http' || id === 'https' || id === 'node:https'; + // Build time: globalThis.__RUNTIME_REQUIRE is unset -> returns undefined, the + // real module is never loaded. Restore time: __RUNTIME_REQUIRE (installed by the + // generated snapshot-restore entry) really requires the module, so + // http.createServer is the genuine builtin and the app truly listens. + var realModule = function () { + var rt = globalThis.__RUNTIME_REQUIRE; + return rt ? rt(id) : undefined; + }; + var buildConst = function (prop) { + if (!isHttp) return undefined; + if (prop === 'METHODS') return __HTTP_METHODS; + if (prop === 'STATUS_CODES') return __HTTP_STATUS_CODES; + if (prop === 'maxHeaderSize') return __HTTP_MAX_HEADER_SIZE; + return undefined; + }; + var proxy = new Proxy(function () {}, { + get: function (target, prop) { + if (prop === 'default') return proxy; + var real = realModule(); + if (real !== undefined && real !== null) { + return real[prop]; + } + // --- build time only below --- + if (typeof prop === 'string') { + var c = buildConst(prop); + if (c !== undefined) return c; + } + if (prop === '__esModule') return undefined; + if (typeof prop === 'symbol') return undefined; + // Never expose a build-time \`then\`: returning the callable proxy would make + // the stub a thenable, so \`await require(id)\` / Promise.resolve(stub) hangs + // (apply never resolves). At restore the real module forwards \`then\` above. + if (prop === 'then') return undefined; + // Satisfy Proxy invariants: the function target's own non-configurable + // props (prototype/length/name) must be reported faithfully. + if (prop === 'prototype' || prop === 'name' || prop === 'length') { + return Reflect.get(target, prop); + } + return proxy; // chainable build-time stub + }, + apply: function (target, thisArg, args) { + var real = realModule(); + if (typeof real === 'function') return Reflect.apply(real, thisArg, args); + return undefined; + }, + construct: function (target, args) { + var real = realModule(); + if (typeof real === 'function') return Reflect.construct(real, args); + return proxy; // build time: keep \`new Stub().method()\` chainable + }, + has: function (target, prop) { + var real = realModule(); + if (real !== undefined && real !== null) return prop in real; + return true; + }, + // Structural traps. At restore time reflect the REAL module's keys so + // Object.keys / spread / destructuring-rest over e.g. require('http') see the + // genuine exports; at build time fall back to the target. Either way the + // function target's own non-configurable keys (prototype) stay reported so the + // Proxy invariants hold. Real descriptors are forced configurable to avoid the + // "report a non-configurable prop absent from target" invariant violation. + ownKeys: function (target) { + var real = realModule(); + if (real === undefined || real === null) return Reflect.ownKeys(target); + var keys = Reflect.ownKeys(real); + var targetKeys = Reflect.ownKeys(target); + for (var i = 0; i < targetKeys.length; i++) { + if (keys.indexOf(targetKeys[i]) === -1) keys.push(targetKeys[i]); + } + return keys; + }, + getOwnPropertyDescriptor: function (target, prop) { + var targetDesc = Reflect.getOwnPropertyDescriptor(target, prop); + if (targetDesc && !targetDesc.configurable) return targetDesc; + var real = realModule(); + if (real === undefined || real === null) return targetDesc; + var desc = Reflect.getOwnPropertyDescriptor(real, prop); + if (desc) desc.configurable = true; + return desc; + }, + }); + return proxy; + }; +})(); +`; } /** @@ -41,7 +357,10 @@ export function renderSnapshotPrelude(): string { * the directive prologue. Idempotent: if the prelude marker is already present the * source is returned unchanged. */ -export function prependSnapshotPrelude(source: string): string { +export function prependSnapshotPrelude( + source: string, + lazyModules: readonly string[] = DEFAULT_SNAPSHOT_LAZY_MODULES, +): string { // Only look for the marker in the file head (the prelude is short and always // sits at the very top). A whole-file `includes` would false-positive if any // bundled application/dependency code happened to contain the marker string, @@ -50,7 +369,7 @@ export function prependSnapshotPrelude(source: string): string { return source; } - const prelude = renderSnapshotPrelude(); + const prelude = renderSnapshotPrelude(lazyModules); const lines = source.split('\n'); let insertAt = 0; @@ -71,3 +390,42 @@ export function prependSnapshotPrelude(source: string): string { const tail = lines.slice(insertAt).join('\n'); return `${head}\n${prelude}${tail}`; } + +/** + * The source `@utoo/pack` (Turbopack) emits for its external-require helper. The + * lazy hook is injected right after the opening brace, using the helper's own + * parameter names so it stays correct even if Turbopack renames them. + */ +const EXTERNAL_REQUIRE_SIGNATURE = + /function\s+externalRequire\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*([A-Za-z_$][\w$]*)\s*(?:,[^)]*)?\)\s*\{/g; + +export interface ExternalRequireInjectionResult { + readonly content: string; + readonly injected: number; +} + +/** + * Inject the lazy dispatch at the start of every `externalRequire` body: + * + * ```js + * if (globalThis.__LAZY_EXT && globalThis.__LAZY_EXT.has(id)) return globalThis.__makeLazyExt(id, thunk); + * ``` + * + * so a require of a lazy module id is rerouted to {@link renderSnapshotPrelude}'s + * `__makeLazyExt` instead of loading the real (non-serializable) module. + */ +export function injectExternalRequireLazyHook(content: string): ExternalRequireInjectionResult { + // Idempotent: a re-run over already-injected output must not double-inject the + // dispatch. `__makeLazyExt(` only appears as the call site we inject (the prelude + // *assigns* `__makeLazyExt =`, which has no `(`), so its presence means this file + // was already processed. + if (content.includes('globalThis.__makeLazyExt(')) { + return { content, injected: 0 }; + } + let injected = 0; + const next = content.replace(EXTERNAL_REQUIRE_SIGNATURE, (match, idParam: string, thunkParam: string) => { + injected++; + return `${match} if (globalThis.__LAZY_EXT && globalThis.__LAZY_EXT.has(${idParam})) return globalThis.__makeLazyExt(${idParam}, ${thunkParam});`; + }); + return { content: next, injected }; +} diff --git a/tools/egg-bundler/test/snapshot-lazy-bundler.test.ts b/tools/egg-bundler/test/snapshot-lazy-bundler.test.ts new file mode 100644 index 0000000000..7bf4c7ce1d --- /dev/null +++ b/tools/egg-bundler/test/snapshot-lazy-bundler.test.ts @@ -0,0 +1,213 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SNAPSHOT_PRELUDE_MARKER } from '../src/lib/prelude.ts'; + +const mocks = vi.hoisted(() => ({ + manifestLoad: vi.fn(async () => undefined), + externalsResolve: vi.fn(async () => ({}) as Record), + entryGenerate: vi.fn(async () => ({ workerEntry: '', entryDir: '' })), +})); + +const MANIFEST = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, +}; + +vi.mock('../src/lib/ManifestLoader.ts', () => ({ + ManifestLoader: vi.fn().mockImplementation(function () { + return { + load: mocks.manifestLoad, + get manifest() { + return MANIFEST; + }, + get store() { + return {}; + }, + getAllDiscoveredFiles: () => [], + getTeggDecoratedFiles: () => [], + }; + }), +})); + +vi.mock('../src/lib/ExternalsResolver.ts', () => ({ + ExternalsResolver: vi.fn().mockImplementation(function () { + return { resolve: mocks.externalsResolve }; + }), +})); + +vi.mock('../src/lib/EntryGenerator.ts', () => ({ + EntryGenerator: vi.fn().mockImplementation(function () { + return { generate: mocks.entryGenerate }; + }), +})); + +import { bundle } from '../src/index.ts'; + +// Synthetic single-file output shaped like @utoo/pack's: bundle IIFE plus an +// externalRequire helper the lazy hook must land in. +const SYNTHETIC_WORKER = [ + '"use strict";', + 'function externalRequire(id, thunk, esm = false) {', + ' return thunk();', + '}', + '((__UTOOPACK__)=>{ /* modules */ })([]);', + '', +].join('\n'); + +describe('Bundler snapshot lazy-external wiring', () => { + let tmpApp: string; + let tmpOutput: string; + + beforeEach(async () => { + mocks.manifestLoad.mockClear(); + mocks.externalsResolve.mockClear(); + mocks.externalsResolve.mockResolvedValue({}); + mocks.entryGenerate.mockClear(); + + tmpApp = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-snaplazy-app-')); + tmpOutput = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-snaplazy-out-')); + const entryDir = path.join(tmpApp, '.egg-bundle', 'entries'); + mocks.entryGenerate.mockResolvedValue({ + workerEntry: path.join(entryDir, 'worker.entry.ts'), + entryDir, + }); + }); + + afterEach(async () => { + await fs.rm(tmpApp, { recursive: true, force: true }); + await fs.rm(tmpOutput, { recursive: true, force: true }); + }); + + async function writePkg(extra?: Record) { + await fs.writeFile(path.join(tmpApp, 'package.json'), JSON.stringify({ name: 'snaplazy-app', ...extra })); + } + + function buildFuncWriting(content: string) { + return async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), content); + }; + } + + it('injects the lazy hook into externalRequire and keeps the prelude marker', async () => { + await writePkg(); + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { buildFunc: buildFuncWriting(SYNTHETIC_WORKER) }, + }); + + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).toContain(SNAPSHOT_PRELUDE_MARKER); + expect(worker).toContain( + 'if (globalThis.__LAZY_EXT && globalThis.__LAZY_EXT.has(id)) return globalThis.__makeLazyExt(id, thunk);', + ); + // prelude (with __LAZY_EXT) precedes the bundle IIFE — fail closed if either marker is absent + const lazyExtIndex = worker.indexOf('globalThis.__LAZY_EXT = new Set('); + const bundleIndex = worker.indexOf('__UTOOPACK__'); + expect(lazyExtIndex).toBeGreaterThanOrEqual(0); + expect(bundleIndex).toBeGreaterThanOrEqual(0); + expect(lazyExtIndex).toBeLessThan(bundleIndex); + }); + + it('does not throw on a re-run over already-patched output (idempotent)', async () => { + await writePkg(); + // Output that already carries the prelude marker + an injected dispatch, as a + // prior snapshot run would leave it: injectedCount is 0 but it is NOT unpatched. + const alreadyPatched = [ + '"use strict";', + '// marker: @eggjs/egg-bundler:snapshot-prelude', + 'function externalRequire(id, thunk, esm = false) {', + ' if (globalThis.__LAZY_EXT && globalThis.__LAZY_EXT.has(id)) return globalThis.__makeLazyExt(id, thunk);', + ' return thunk();', + '}', + '((__UTOOPACK__)=>{})([]);', + '', + ].join('\n'); + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { buildFunc: buildFuncWriting(alreadyPatched) }, + }), + ).resolves.toBeDefined(); + }); + + it('keeps the default network-stack ids external in the bundle manifest', async () => { + await writePkg(); + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { buildFunc: buildFuncWriting(SYNTHETIC_WORKER) }, + }); + + const manifest = JSON.parse(await fs.readFile(path.join(tmpOutput, 'bundle-manifest.json'), 'utf8')); + for (const id of ['http', 'https', 'http2', 'node:http', 'node:tls', 'node:dns']) { + expect(manifest.externals).toContain(id); + } + }); + + it('merges app egg.snapshot.lazyModules into __LAZY_EXT and externals', async () => { + await writePkg({ egg: { snapshot: { lazyModules: ['leoric', '@elastic/elasticsearch'] } } }); + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { buildFunc: buildFuncWriting(SYNTHETIC_WORKER) }, + }); + + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).toContain('"leoric"'); + expect(worker).toContain('"@elastic/elasticsearch"'); + + const manifest = JSON.parse(await fs.readFile(path.join(tmpOutput, 'bundle-manifest.json'), 'utf8')); + expect(manifest.externals).toContain('leoric'); + expect(manifest.externals).toContain('@elastic/elasticsearch'); + }); + + it('does not lazy-externalize or inject when snapshot mode is off', async () => { + await writePkg(); + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { singleFile: true, buildFunc: buildFuncWriting(SYNTHETIC_WORKER) }, + }); + + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).toBe(SYNTHETIC_WORKER); + const manifest = JSON.parse(await fs.readFile(path.join(tmpOutput, 'bundle-manifest.json'), 'utf8')); + expect(manifest.externals).not.toContain('http'); + }); + + it('fails loud when externalRequire is emitted but its signature does not match', async () => { + await writePkg(); + // A helper present (so sawExternalRequire is true) but with a one-param shape the + // injection regex (which needs id + thunk) cannot match -> must throw, not produce + // a silently-broken snapshot. + const unpatchable = '"use strict";\nfunction externalRequire(id) { return id; }\n((__UTOOPACK__)=>{})([]);\n'; + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { buildFunc: buildFuncWriting(unpatchable) }, + }), + ).rejects.toThrow(/lazy hook could not be injected/); + }); +}); diff --git a/tools/egg-bundler/test/snapshot-lazy-external.test.ts b/tools/egg-bundler/test/snapshot-lazy-external.test.ts new file mode 100644 index 0000000000..2acd067ab1 --- /dev/null +++ b/tools/egg-bundler/test/snapshot-lazy-external.test.ts @@ -0,0 +1,238 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_SNAPSHOT_LAZY_MODULES, + injectExternalRequireLazyHook, + renderSnapshotPrelude, + resolveSnapshotLazyModules, +} from '../src/lib/prelude.ts'; + +describe('snapshot lazy-external', () => { + describe('resolveSnapshotLazyModules', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-lazy-')); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it('returns the default network-stack list when package.json is absent', async () => { + const result = await resolveSnapshotLazyModules(tmp); + expect(result).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]); + expect(result).toContain('http'); + expect(result).toContain('node:http2'); + expect(result).toContain('node:dns'); + }); + + it('returns the default list when package.json has no egg.snapshot.lazyModules', async () => { + await fs.writeFile(path.join(tmp, 'package.json'), JSON.stringify({ name: 'app', egg: {} })); + expect(await resolveSnapshotLazyModules(tmp)).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]); + }); + + it('appends app egg.snapshot.lazyModules onto the default list, deduped and order-preserving', async () => { + await fs.writeFile( + path.join(tmp, 'package.json'), + JSON.stringify({ + name: 'app', + egg: { snapshot: { lazyModules: ['leoric', 'http', '@elastic/elasticsearch', 'leoric'] } }, + }), + ); + const result = await resolveSnapshotLazyModules(tmp); + expect(result.slice(0, DEFAULT_SNAPSHOT_LAZY_MODULES.length)).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]); + expect(result.filter((m) => m === 'http')).toHaveLength(1); + expect(result.slice(DEFAULT_SNAPSHOT_LAZY_MODULES.length)).toEqual(['leoric', '@elastic/elasticsearch']); + }); + + it('ignores a malformed (non-array) lazyModules field', async () => { + await fs.writeFile( + path.join(tmp, 'package.json'), + JSON.stringify({ egg: { snapshot: { lazyModules: 'http' } } }), + ); + expect(await resolveSnapshotLazyModules(tmp)).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]); + }); + + it('skips non-string / empty entries inside lazyModules', async () => { + await fs.writeFile( + path.join(tmp, 'package.json'), + JSON.stringify({ egg: { snapshot: { lazyModules: ['ok', '', 42, null, 'ok2'] } } }), + ); + const result = await resolveSnapshotLazyModules(tmp); + expect(result.slice(DEFAULT_SNAPSHOT_LAZY_MODULES.length)).toEqual(['ok', 'ok2']); + }); + + it('returns the default list when package.json is invalid JSON', async () => { + await fs.writeFile(path.join(tmp, 'package.json'), '{ not json'); + expect(await resolveSnapshotLazyModules(tmp)).toEqual([...DEFAULT_SNAPSHOT_LAZY_MODULES]); + }); + }); + + describe('renderSnapshotPrelude (lazy body)', () => { + it('deletes Node lazy web globals with delete (not defineProperty)', () => { + const prelude = renderSnapshotPrelude(); + expect(prelude).toContain('delete globalThis['); + expect(prelude).not.toContain('Object.defineProperty'); + for (const g of ['fetch', 'Headers', 'Request', 'Response', 'FormData', 'WebSocket', 'File', 'Blob']) { + expect(prelude).toContain(JSON.stringify(g)); + } + }); + + it('installs __LAZY_EXT with every lazy id and the __makeLazyExt factory', () => { + const prelude = renderSnapshotPrelude(['http', 'node:http', 'custom-pkg']); + expect(prelude).toContain('globalThis.__LAZY_EXT = new Set('); + expect(prelude).toContain('globalThis.__makeLazyExt = function'); + expect(prelude).toContain('"custom-pkg"'); + expect(prelude).toContain('globalThis.__RUNTIME_REQUIRE'); + }); + + it('hardcodes http METHODS / STATUS_CODES / maxHeaderSize', () => { + const prelude = renderSnapshotPrelude(); + expect(prelude).toContain('"GET"'); + expect(prelude).toContain('"POST"'); + expect(prelude).toContain('"200"'); + expect(prelude).toContain('16384'); + }); + }); + + describe('injectExternalRequireLazyHook', () => { + it('injects the lazy dispatch using the helper parameter names', () => { + const src = 'function externalRequire(id, thunk, esm = false) { return thunk(); }'; + const { content, injected } = injectExternalRequireLazyHook(src); + expect(injected).toBe(1); + expect(content).toContain( + 'if (globalThis.__LAZY_EXT && globalThis.__LAZY_EXT.has(id)) return globalThis.__makeLazyExt(id, thunk);', + ); + expect(content.indexOf('__makeLazyExt')).toBeLessThan(content.indexOf('return thunk()')); + }); + + it('respects renamed parameters', () => { + const src = 'function externalRequire(a,b,c=false){return b();}'; + const { content, injected } = injectExternalRequireLazyHook(src); + expect(injected).toBe(1); + expect(content).toContain('globalThis.__LAZY_EXT.has(a)) return globalThis.__makeLazyExt(a, b);'); + }); + + it('injects into every externalRequire occurrence', () => { + const src = + 'function externalRequire(id, thunk){return id;}\nfunction externalRequire(id2, thunk2, esm=false){return id2;}'; + const { injected } = injectExternalRequireLazyHook(src); + expect(injected).toBe(2); + }); + + it('returns the input untouched when there is no externalRequire', () => { + const src = 'function notExternal(id, thunk) { return thunk(); }'; + const { content, injected } = injectExternalRequireLazyHook(src); + expect(injected).toBe(0); + expect(content).toBe(src); + }); + + it('is idempotent: re-injecting already-injected output is a no-op', () => { + const src = 'function externalRequire(id, thunk, esm = false) { return thunk(); }'; + const once = injectExternalRequireLazyHook(src); + expect(once.injected).toBe(1); + const twice = injectExternalRequireLazyHook(once.content); + expect(twice.injected).toBe(0); + expect(twice.content).toBe(once.content); + // exactly one dispatch, no duplication + expect(twice.content.split('__makeLazyExt(').length - 1).toBe(1); + }); + }); + + describe('runtime __makeLazyExt behavior (prelude evaluated in a vm)', () => { + function makeContext(lazy: readonly string[]) { + const sandbox: Record = {}; + vm.createContext(sandbox); + vm.runInContext(renderSnapshotPrelude(lazy), sandbox); + const makeLazyExt = sandbox.__makeLazyExt as (id: string, thunk: unknown) => unknown; + const lazySet = sandbox.__LAZY_EXT as Set; + return { sandbox, makeLazyExt, lazySet }; + } + + it('exposes __LAZY_EXT as a Set of the lazy ids', () => { + const { lazySet } = makeContext(['http', 'node:http']); + expect(lazySet.has('http')).toBe(true); + expect(lazySet.has('node:http')).toBe(true); + expect(lazySet.has('tls')).toBe(false); + }); + + it('build time: never loads the real module and returns hardcoded http constants', () => { + const { makeLazyExt } = makeContext(['http']); + const http = makeLazyExt('http', null) as Record; + const methods = [...(http.METHODS as string[])]; + expect(methods).toContain('GET'); + expect(methods).toContain('POST'); + expect((http.STATUS_CODES as Record)['404']).toBe('Not Found'); + expect(http.maxHeaderSize).toBe(16384); + }); + + it('build time: default resolves to the proxy itself and unknown props are chainable stubs', () => { + const { makeLazyExt } = makeContext(['http']); + const http = makeLazyExt('http', null) as Record; + expect(http.default).toBe(http); + const deep = (http.Server as Record).prototype; + expect(deep).toBeDefined(); + }); + + it('build time: calling / constructing a stub does not throw and never loads the module', () => { + const { makeLazyExt, sandbox } = makeContext(['dns']); + const dns = makeLazyExt('dns', null) as { lookup: (...a: unknown[]) => unknown }; + expect(() => dns.lookup('example.com')).not.toThrow(); + expect(sandbox.__RUNTIME_REQUIRE).toBeUndefined(); + }); + + it('build time: a constructed stub stays chainable (new Agent().method())', () => { + const { makeLazyExt } = makeContext(['http']); + const http = makeLazyExt('http', null) as { Agent: new () => { addRequest: (...a: unknown[]) => unknown } }; + // new http.Agent().addRequest(...) must not hit undefined at build time + expect(() => new http.Agent().addRequest({}, {})).not.toThrow(); + }); + + it('build time: the stub is not thenable, so awaiting it does not hang', async () => { + const { makeLazyExt } = makeContext(['http']); + const http = makeLazyExt('http', null) as Record; + expect(http.then).toBeUndefined(); + // Promise.resolve checks thenability; a non-thenable stub resolves to itself. + await expect(Promise.resolve(http)).resolves.toBe(http); + }); + + it('restore time: forwards every access to the real module via __RUNTIME_REQUIRE', () => { + const { makeLazyExt, sandbox } = makeContext(['http']); + const realHttp = { createServer: () => 'real-server', METHODS: ['REALGET'] }; + sandbox.__RUNTIME_REQUIRE = (id: string) => (id === 'http' ? realHttp : undefined); + const http = makeLazyExt('http', null) as typeof realHttp; + expect(http.createServer()).toBe('real-server'); + expect(http.METHODS).toEqual(['REALGET']); + }); + + it('restore time: structural traps reflect the real module exports', () => { + const { makeLazyExt, sandbox } = makeContext(['http']); + const realHttp = { createServer: () => 'srv', METHODS: ['GET'] }; + sandbox.__RUNTIME_REQUIRE = (id: string) => (id === 'http' ? realHttp : undefined); + const http = makeLazyExt('http', null) as Record; + + // Object.keys / getOwnPropertyDescriptor must see the real exports, not just + // the dummy function target. + expect(Object.keys(http)).toEqual(expect.arrayContaining(['createServer', 'METHODS'])); + expect(Object.getOwnPropertyDescriptor(http, 'createServer')).toBeDefined(); + // destructuring-rest iterates own enumerable keys -> must work over real http + const { createServer, ...rest } = http as { createServer: unknown; METHODS: unknown }; + expect(typeof createServer).toBe('function'); + expect(rest.METHODS).toEqual(['GET']); + }); + + it('build time: structural reflection does not throw and exposes nothing real', () => { + const { makeLazyExt } = makeContext(['tls']); + const tls = makeLazyExt('tls', null) as object; + expect(() => Object.keys(tls)).not.toThrow(); + expect(Object.keys(tls)).toEqual([]); // no real module loaded -> no exports + expect(() => Object.getOwnPropertyDescriptor(tls, 'prototype')).not.toThrow(); + expect('prototype' in tls).toBe(true); + }); + }); +}); diff --git a/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts b/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts new file mode 100644 index 0000000000..acccd647d0 --- /dev/null +++ b/tools/egg-bundler/test/snapshot-lazy.realbuild.test.ts @@ -0,0 +1,159 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const execFileAsync = promisify(execFile); + +// REAL @utoo/pack build regression test for the snapshot lazy-external mechanism. +// +// The mechanism hinges on @utoo/pack emitting a `function externalRequire(id, thunk, +// ...)` helper for external module ids — a property of @utoo/pack's codegen a mock +// cannot guard. This runs a real build over an entry that requires `node:http`, then +// proves: +// 1. the prelude is prepended and the lazy hook is injected into the real +// externalRequire body; +// 2. at BUILD context (no __RUNTIME_REQUIRE) http is a stub — http.METHODS is the +// hardcoded constant and http.createServer() no-ops, so the real +// (non-serializable) http module is never loaded; +// 3. at RESTORE context (__RUNTIME_REQUIRE installed) the same proxy forwards to +// the real http — http.createServer() returns a real Server. + +const mocks = vi.hoisted(() => ({ + workerEntry: '', + entryDir: '', +})); + +vi.mock('../src/lib/ManifestLoader.ts', () => ({ + ManifestLoader: vi.fn().mockImplementation(function () { + return { + load: async () => undefined, + get manifest() { + return { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + }; + }, + get store() { + return {}; + }, + getAllDiscoveredFiles: () => [], + getTeggDecoratedFiles: () => [], + }; + }), +})); + +vi.mock('../src/lib/ExternalsResolver.ts', () => ({ + ExternalsResolver: vi.fn().mockImplementation(function () { + return { resolve: async () => ({}) }; + }), +})); + +vi.mock('../src/lib/EntryGenerator.ts', () => ({ + EntryGenerator: vi.fn().mockImplementation(function () { + return { + generate: async () => ({ workerEntry: mocks.workerEntry, entryDir: mocks.entryDir }), + }; + }), +})); + +import { bundle } from '../src/index.ts'; +import { SNAPSHOT_PRELUDE_MARKER } from '../src/lib/prelude.ts'; + +describe('snapshot lazy-external — real @utoo/pack build', () => { + let baseDir: string; + + beforeEach(async () => { + // realpath: on macOS os.tmpdir() is a /var -> /private/var symlink and Turbopack's + // path math rejects the mismatch. + baseDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-snaplazy-rb-'))); + }); + + afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it('lazy-externalizes node:http: stub at build, real module after __RUNTIME_REQUIRE', async () => { + await fs.writeFile(path.join(baseDir, 'package.json'), JSON.stringify({ name: 'snaplazy-rb-app' })); + + const entryDir = path.join(baseDir, '.egg-bundle', 'entries'); + await fs.mkdir(entryDir, { recursive: true }); + const entry = path.join(entryDir, 'worker.entry.ts'); + // CommonJS require mirrors how egg's real http consumers (urllib/undici, node + // internals) load the network stack: externalRequire returns the live lazy proxy + // directly, with no ESM interop namespace copy in between. + await fs.writeFile( + entry, + [ + '// @ts-nocheck', + 'const http = require("node:http");', + 'const g = globalThis;', + 'const probe = {', + ' methodsHasGet: Array.isArray(http.METHODS) && http.METHODS.includes("GET"),', + ' maxHeaderSize: http.maxHeaderSize,', + ' createServerCall: typeof http.createServer(),', + ' restored: !!g.__RUNTIME_REQUIRE,', + '};', + 'process.stdout.write(JSON.stringify(probe));', + '', + ].join('\n'), + ); + mocks.workerEntry = entry; + mocks.entryDir = entryDir; + + const outputDir = path.join(baseDir, 'dist'); + // top-level snapshot:true forces single-file + prepend + lazy-external. + await bundle({ baseDir, outputDir, snapshot: true }); + + const workerPath = path.join(outputDir, 'worker.js'); + const worker = await fs.readFile(workerPath, 'utf8'); + + expect(worker).toContain(SNAPSHOT_PRELUDE_MARKER); + // @utoo/pack really emitted externalRequire and the hook landed inside it. + expect(worker).toMatch(/function\s+externalRequire\s*\(/); + expect(worker).toMatch(/globalThis\.__LAZY_EXT\.has\([A-Za-z_$][\w$]*\)\) return globalThis\.__makeLazyExt\(/); + + // The injected + prepended source is still valid JS. + await execFileAsync(process.execPath, ['--check', workerPath]); + + // BUILD context: run worker.js directly. http must be the stub. + const built = await execFileAsync(process.execPath, [workerPath], { cwd: outputDir }); + expect(JSON.parse(built.stdout)).toEqual({ + methodsHasGet: true, // hardcoded METHODS, real http never loaded + maxHeaderSize: 16384, // hardcoded constant + createServerCall: 'undefined', // apply trap no-ops at build time + restored: false, + }); + + // RESTORE context: install __RUNTIME_REQUIRE before the worker runs, exactly as + // the generated snapshot deserialize main would. The same proxy forwards to real http. + const runner = path.join(outputDir, 'restore-runner.cjs'); + await fs.writeFile( + runner, + [ + 'const { createRequire } = require("node:module");', + 'globalThis.__RUNTIME_REQUIRE = createRequire(__filename);', + 'require("./worker.js");', + '', + ].join('\n'), + ); + const restored = await execFileAsync(process.execPath, [runner], { cwd: outputDir }); + const restoreProbe = JSON.parse(restored.stdout); + expect(restoreProbe.restored).toBe(true); + expect(restoreProbe.methodsHasGet).toBe(true); // real http.METHODS also has GET + expect(restoreProbe.createServerCall).toBe('object'); // real http.createServer() -> Server + }, 60_000); +});