diff --git a/packages/typings/src/global.ts b/packages/typings/src/global.ts index 594e2f9677..2f2dcc9dfc 100644 --- a/packages/typings/src/global.ts +++ b/packages/typings/src/global.ts @@ -5,6 +5,14 @@ declare global { var __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined; // eslint-disable-next-line no-var var __EGG_MODULE_IMPORTER__: ModuleImporter | undefined; + /** + * Synchronous require bound to the bundle output directory, installed by the + * snapshot restore main function. The snapshot prelude / lazy mechanism uses it + * to pull in modules through `require()` (Node 22+ can `require()` ESM) because a + * deserialized snapshot process has no dynamic `import()` callback. + */ + // eslint-disable-next-line no-var + var __RUNTIME_REQUIRE: ((id: string) => unknown) | undefined; } export {}; diff --git a/tools/egg-bin/package.json b/tools/egg-bin/package.json index 5532d4577f..cdb0984612 100644 --- a/tools/egg-bin/package.json +++ b/tools/egg-bin/package.json @@ -28,10 +28,12 @@ "exports": { ".": "./src/index.ts", "./baseCommand": "./src/baseCommand.ts", + "./bundleOptions": "./src/bundleOptions.ts", "./commands/bundle": "./src/commands/bundle.ts", "./commands/cov": "./src/commands/cov.ts", "./commands/dev": "./src/commands/dev.ts", "./commands/manifest": "./src/commands/manifest.ts", + "./commands/snapshot": "./src/commands/snapshot.ts", "./commands/test": "./src/commands/test.ts", "./types": "./src/types.ts", "./utils": "./src/utils.ts", @@ -42,10 +44,12 @@ "exports": { ".": "./dist/index.js", "./baseCommand": "./dist/baseCommand.js", + "./bundleOptions": "./dist/bundleOptions.js", "./commands/bundle": "./dist/commands/bundle.js", "./commands/cov": "./dist/commands/cov.js", "./commands/dev": "./dist/commands/dev.js", "./commands/manifest": "./dist/commands/manifest.js", + "./commands/snapshot": "./dist/commands/snapshot.js", "./commands/test": "./dist/commands/test.js", "./types": "./dist/types.js", "./utils": "./dist/utils.js", diff --git a/tools/egg-bin/src/bundleOptions.ts b/tools/egg-bin/src/bundleOptions.ts new file mode 100644 index 0000000000..c3c3b79c42 --- /dev/null +++ b/tools/egg-bin/src/bundleOptions.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Shared bundle-option parsing used by both `egg-bin bundle` and +// `egg-bin snapshot build`, so the two commands cannot drift. + +export const bundleModes = ['production', 'development'] as const; +export type BundleMode = (typeof bundleModes)[number]; + +export function getBundleMode(mode: string): BundleMode { + if (mode === 'production' || mode === 'development') { + return mode; + } + throw new Error(`Unsupported bundle mode: ${mode}`); +} + +export function parsePackAliases(values: readonly string[], baseDir: string): Record | undefined { + if (values.length === 0) return undefined; + + const alias: Record = {}; + for (const value of values) { + const separator = value.indexOf('='); + if (separator <= 0 || separator === value.length - 1) { + throw new Error(`Invalid --pack-alias value: ${value}. Expected =.`); + } + + const specifier = value.slice(0, separator); + const target = value.slice(separator + 1); + alias[specifier] = target.startsWith('.') ? path.resolve(baseDir, target) : target; + } + + return alias; +} + +export async function getBundleFrameworkSpecifier(baseDir: string, framework?: string): Promise { + if (framework) return framework; + + const pkgPath = path.join(baseDir, 'package.json'); + const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as { + egg?: { + framework?: unknown; + }; + }; + return typeof pkg.egg?.framework === 'string' && pkg.egg.framework ? pkg.egg.framework : 'egg'; +} diff --git a/tools/egg-bin/src/commands/bundle.ts b/tools/egg-bin/src/commands/bundle.ts index 05632728af..feee5b1ceb 100644 --- a/tools/egg-bin/src/commands/bundle.ts +++ b/tools/egg-bin/src/commands/bundle.ts @@ -1,51 +1,12 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import { debuglog } from 'node:util'; import { Flags } from '@oclif/core'; import { BaseCommand } from '../baseCommand.ts'; +import { bundleModes, getBundleFrameworkSpecifier, getBundleMode, parsePackAliases } from '../bundleOptions.ts'; const debug = debuglog('egg/bin/commands/bundle'); -const bundleModes = ['production', 'development'] as const; -type BundleMode = (typeof bundleModes)[number]; - -function getBundleMode(mode: string): BundleMode { - if (mode === 'production' || mode === 'development') { - return mode; - } - throw new Error(`Unsupported bundle mode: ${mode}`); -} - -function parsePackAliases(values: readonly string[], baseDir: string): Record | undefined { - if (values.length === 0) return undefined; - - const alias: Record = {}; - for (const value of values) { - const separator = value.indexOf('='); - if (separator <= 0 || separator === value.length - 1) { - throw new Error(`Invalid --pack-alias value: ${value}. Expected =.`); - } - - const specifier = value.slice(0, separator); - const target = value.slice(separator + 1); - alias[specifier] = target.startsWith('.') ? path.resolve(baseDir, target) : target; - } - - return alias; -} - -async function getBundleFrameworkSpecifier(baseDir: string, framework?: string): Promise { - if (framework) return framework; - - const pkgPath = path.join(baseDir, 'package.json'); - const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as { - egg?: { - framework?: unknown; - }; - }; - return typeof pkg.egg?.framework === 'string' && pkg.egg.framework ? pkg.egg.framework : 'egg'; -} export default class Bundle extends BaseCommand { static override description = 'Bundle an egg app into a deployable artifact using @eggjs/egg-bundler'; diff --git a/tools/egg-bin/src/commands/snapshot.ts b/tools/egg-bin/src/commands/snapshot.ts new file mode 100644 index 0000000000..d743e5b516 --- /dev/null +++ b/tools/egg-bin/src/commands/snapshot.ts @@ -0,0 +1,211 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +import { Args, Flags } from '@oclif/core'; + +import { BaseCommand } from '../baseCommand.ts'; +import { bundleModes, getBundleFrameworkSpecifier, getBundleMode, parsePackAliases } from '../bundleOptions.ts'; + +const debug = debuglog('egg/bin/commands/snapshot'); + +/** + * Build a V8 startup snapshot of a bundled egg app. + * + * `snapshot build` bundles the app in snapshot mode (single self-contained + * worker.js plus prelude) and then wraps + * `node --snapshot-blob --build-snapshot worker.js` to produce the blob. + * + * Booting from the blob is a production-runtime concern and lives in + * `@eggjs/scripts`: `egg-scripts start --snapshot-blob `. + */ +export default class Snapshot extends BaseCommand { + static override description = 'Build a V8 startup snapshot of a bundled egg app'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> build', + '<%= config.bin %> <%= command.id %> build --output ./dist-bundle --blob ./dist-bundle/snapshot.blob', + '<%= config.bin %> <%= command.id %> build --skip-bundle', + ]; + + static override args = { + action: Args.string({ + required: true, + description: 'Action to perform', + options: ['build'], + }), + }; + + static override flags = { + output: Flags.string({ + char: 'o', + description: 'bundle output directory (also where worker.js lives)', + default: './dist-bundle', + }), + blob: Flags.string({ + description: 'snapshot blob path (defaults to /snapshot.blob)', + }), + framework: Flags.string({ + char: 'f', + description: 'framework package specifier (build only)', + }), + mode: Flags.string({ + description: 'bundle build mode (build only)', + options: [...bundleModes], + default: 'production', + }), + 'force-external': Flags.string({ + description: 'package name to always mark as external, repeatable (build only)', + multiple: true, + default: [], + }), + 'inline-external': Flags.string({ + description: 'package name to force-inline even if auto-detected as external, repeatable (build only)', + multiple: true, + default: [], + }), + 'pack-alias': Flags.string({ + description: '@utoo/pack resolve alias in = form (build only)', + multiple: true, + default: [], + }), + 'skip-bundle': Flags.boolean({ + description: 'skip bundling and build the snapshot from an existing worker.js (build only)', + default: false, + }), + }; + + public async run(): Promise { + const { action } = this.args; + switch (action) { + case 'build': + await this.runBuild(); + break; + } + } + + #resolveOutputDir(): string { + const { flags } = this; + return path.isAbsolute(flags.output) ? flags.output : path.join(flags.base, flags.output); + } + + #resolveBlobPath(outputDir: string): string { + const { flags } = this; + if (!flags.blob) return path.join(outputDir, 'snapshot.blob'); + return path.isAbsolute(flags.blob) ? flags.blob : path.join(flags.base, flags.blob); + } + + private async runBuild(): Promise { + const { flags } = this; + const outputDir = this.#resolveOutputDir(); + const blobPath = this.#resolveBlobPath(outputDir); + const workerPath = path.join(outputDir, 'worker.js'); + + if (!flags['skip-bundle']) { + const { bundle } = await import('@eggjs/egg-bundler'); + const packAlias = parsePackAliases(flags['pack-alias'], flags.base); + debug('snapshot build: bundling baseDir=%s output=%s', flags.base, outputDir); + const result = await bundle({ + baseDir: flags.base, + outputDir, + framework: await getBundleFrameworkSpecifier(flags.base, flags.framework), + mode: getBundleMode(flags.mode), + snapshot: true, + externals: { + force: flags['force-external'], + inline: flags['inline-external'], + }, + ...(packAlias ? { pack: { resolve: { alias: packAlias } } } : {}), + }); + this.log(`bundled (snapshot mode) to ${result.outputDir} (${result.files.length} files)`); + } + + // Wrap: node --snapshot-blob --build-snapshot worker.js + // EGG_BUNDLE_SNAPSHOT=build switches the generated entry into snapshot-build + // mode (load metadata, run snapshotWillSerialize hooks, register the + // deserialize main function). + await this.#spawnNode(['--snapshot-blob', blobPath, '--build-snapshot', workerPath], { + EGG_BUNDLE_SNAPSHOT: 'build', + }); + + // In dry-run nothing was spawned, so do not claim a blob was produced. + if (flags['dry-run']) return; + + // node can exit 0 from --build-snapshot without producing a blob (e.g. the + // entry threw after the will-serialize hooks and never reached + // setDeserializeMainFunction). Verify the blob exists so a missing blob fails + // loudly here rather than as a confusing error in a later `snapshot start`. + try { + await fs.access(blobPath); + } catch { + throw new Error(`snapshot build finished but no blob was written at ${blobPath}`); + } + this.log(`snapshot blob written to ${blobPath}`); + } + + async #spawnNode(nodeArgs: readonly string[], extraEnv: NodeJS.ProcessEnv = {}): Promise { + // Run the self-contained bundle with a clean env: start from process.env, NOT + // this.env. BaseCommand.#afterInit injects NODE_OPTIONS=--loader ts-node/esm + // (plus ts-node/register, tsconfig-paths) into this.env for TypeScript apps; + // applying that to `node --build-snapshot worker.js` would pull a non-bundled + // loader into the snapshot build. process.env never carries that injection. + const env = { ...process.env, ...extraEnv }; + const args = [...this.globalExecArgv, ...nodeArgs]; + const fullCommand = `${process.execPath} ${args.join(' ')}`; + if (this.flags['dry-run']) { + this.log('dry run: $ %s', fullCommand); + return; + } + + debug('spawn: %s', fullCommand); + // Use spawn (not fork) so no IPC channel is created — an IPC handle is a + // non-serializable libuv resource that would break `--build-snapshot`. + const proc = spawn(process.execPath, args, { + stdio: 'inherit', + env, + cwd: this.flags.base, + }); + + // spawn (unlike fork + BaseCommand.graceful) does not forward termination + // signals, so forward them ourselves so Ctrl-C during a build cleanly tears + // down the child instead of orphaning it. + let terminatingSignal: NodeJS.Signals | undefined; + const forwardSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGQUIT']; + const onSignal = (signal: NodeJS.Signals) => { + terminatingSignal = signal; + proc.kill(signal); + }; + for (const signal of forwardSignals) { + process.on(signal, onSignal); + } + + try { + await new Promise((resolve, reject) => { + proc.once('error', reject); + proc.once('exit', (code, signal) => { + // A signal-kill (code === null) is a failure unless we initiated the + // shutdown — otherwise a crashed --build-snapshot (e.g. SIGSEGV while + // serializing a non-serializable binding) would masquerade as success. + if (code === 0 || terminatingSignal) { + resolve(); + } else if (code !== null) { + reject(new Error(`${fullCommand} exited with code ${code}`)); + } else { + reject(new Error(`${fullCommand} was killed by signal ${signal}`)); + } + }); + }); + } finally { + for (const signal of forwardSignals) { + process.removeListener(signal, onSignal); + } + // Re-raise the signal on ourselves so the parent exits with the correct + // signal status (so chained shells / `&&` see the abort), now that our + // listeners are removed and the child has terminated. + if (terminatingSignal) { + process.kill(process.pid, terminatingSignal); + } + } + } +} diff --git a/tools/egg-bin/test/bundleOptions.test.ts b/tools/egg-bin/test/bundleOptions.test.ts new file mode 100644 index 0000000000..50fa19dbeb --- /dev/null +++ b/tools/egg-bin/test/bundleOptions.test.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { getBundleFrameworkSpecifier, getBundleMode, parsePackAliases } from '../src/bundleOptions.ts'; +import { getFixtures } from './helper.ts'; + +describe('test/bundleOptions.test.ts', () => { + describe('getBundleMode', () => { + it('accepts production and development', () => { + expect(getBundleMode('production')).toBe('production'); + expect(getBundleMode('development')).toBe('development'); + }); + + it('throws on an unsupported mode', () => { + expect(() => getBundleMode('staging')).toThrow('Unsupported bundle mode: staging'); + }); + }); + + describe('parsePackAliases', () => { + it('returns undefined for an empty list', () => { + expect(parsePackAliases([], '/base')).toBeUndefined(); + }); + + it('resolves dot-relative targets from baseDir and keeps bare specifiers', () => { + expect(parsePackAliases(['a=./x/y.js', 'b=pkg'], '/base')).toEqual({ + a: path.resolve('/base', './x/y.js'), + b: 'pkg', + }); + }); + + it('throws on a malformed alias', () => { + expect(() => parsePackAliases(['noequals'], '/base')).toThrow('Invalid --pack-alias value: noequals'); + expect(() => parsePackAliases(['=missingspec'], '/base')).toThrow('Invalid --pack-alias value'); + expect(() => parsePackAliases(['trailing='], '/base')).toThrow('Invalid --pack-alias value'); + }); + }); + + describe('getBundleFrameworkSpecifier', () => { + it('returns the explicit framework as-is', async () => { + await expect(getBundleFrameworkSpecifier('/base', '@my-org/framework')).resolves.toBe('@my-org/framework'); + }); + + it('reads egg.framework from package.json when no explicit value is given', async () => { + const baseDir = getFixtures('demo-app'); + await expect(getBundleFrameworkSpecifier(baseDir)).resolves.toBe('aliyun-egg'); + }); + }); +}); diff --git a/tools/egg-bin/test/commands/snapshot.test.ts b/tools/egg-bin/test/commands/snapshot.test.ts new file mode 100644 index 0000000000..ee330656a0 --- /dev/null +++ b/tools/egg-bin/test/commands/snapshot.test.ts @@ -0,0 +1,127 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import Snapshot from '../../src/commands/snapshot.ts'; +import { getFixtures } from '../helper.ts'; + +const bundleMock = vi.hoisted(() => vi.fn()); +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock('@eggjs/egg-bundler', () => ({ + bundle: bundleMock, +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: spawnMock }; +}); + +describe('test/commands/snapshot.test.ts', () => { + const baseDir = getFixtures('demo-app'); + + beforeEach(() => { + bundleMock.mockReset(); + bundleMock.mockResolvedValue({ + outputDir: path.join(baseDir, 'dist-bundle'), + manifestPath: path.join(baseDir, '.egg/manifest.json'), + files: ['worker.js'], + }); + // The blob is produced by the (mocked) spawned node, so pretend it exists. + vi.spyOn(fsp, 'access').mockResolvedValue(undefined); + spawnMock.mockReset(); + // Fake child process that exits cleanly on the next microtask. + spawnMock.mockImplementation(() => { + const handlers: Record void> = {}; + const child = { + once(event: string, cb: (arg?: unknown) => void) { + handlers[event] = cb; + if (event === 'exit') queueMicrotask(() => cb(0)); + return child; + }, + }; + return child; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function spawnArgs() { + expect(spawnMock).toHaveBeenCalledTimes(1); + const [bin, args, options] = spawnMock.mock.calls[0] as [string, string[], { env: NodeJS.ProcessEnv }]; + return { bin, args, options }; + } + + it('build bundles in snapshot mode then spawns node --build-snapshot', async () => { + await Snapshot.run(['build', '--base', baseDir]); + + expect(bundleMock).toHaveBeenCalledTimes(1); + expect(bundleMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseDir, + outputDir: path.join(baseDir, 'dist-bundle'), + framework: 'aliyun-egg', + mode: 'production', + snapshot: true, + externals: { force: [], inline: [] }, + }), + ); + + const { bin, args, options } = spawnArgs(); + expect(bin).toBe(process.execPath); + expect(args).toEqual( + expect.arrayContaining([ + '--snapshot-blob', + path.join(baseDir, 'dist-bundle', 'snapshot.blob'), + '--build-snapshot', + path.join(baseDir, 'dist-bundle', 'worker.js'), + ]), + ); + expect(options.env.EGG_BUNDLE_SNAPSHOT).toBe('build'); + }); + + it('build --skip-bundle does not bundle, only spawns the blob build', async () => { + await Snapshot.run(['build', '--base', baseDir, '--skip-bundle']); + + expect(bundleMock).not.toHaveBeenCalled(); + const { args } = spawnArgs(); + expect(args).toContain('--build-snapshot'); + }); + + it('build honours a custom --blob path', async () => { + await Snapshot.run(['build', '--base', baseDir, '--skip-bundle', '--blob', 'out/app.blob']); + + const { args } = spawnArgs(); + expect(args).toContain(path.join(baseDir, 'out', 'app.blob')); + }); + + it('build --dry-run neither bundles-spawn nor spawns node', async () => { + await Snapshot.run(['build', '--base', baseDir, '--skip-bundle', '--dry-run']); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('build rejects when the spawned node exits non-zero', async () => { + spawnMock.mockImplementation(() => { + const handlers: Record void> = {}; + const child = { + once(event: string, cb: (...a: unknown[]) => void) { + handlers[event] = cb; + if (event === 'exit') queueMicrotask(() => cb(1, null)); + return child; + }, + }; + return child; + }); + + await expect(Snapshot.run(['build', '--base', baseDir, '--skip-bundle'])).rejects.toThrow('exited with code 1'); + }); + + it('build rejects when the blob is missing after a successful build', async () => { + (fsp.access as unknown as ReturnType).mockRejectedValue(new Error('ENOENT')); + + await expect(Snapshot.run(['build', '--base', baseDir, '--skip-bundle'])).rejects.toThrow('no blob was written'); + }); +}); diff --git a/tools/egg-bundler/src/index.ts b/tools/egg-bundler/src/index.ts index e1af17a1b0..d63fb9f8f9 100644 --- a/tools/egg-bundler/src/index.ts +++ b/tools/egg-bundler/src/index.ts @@ -2,6 +2,7 @@ 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 { PackRunner, type BuildFunc, @@ -32,7 +33,8 @@ export interface BundlerPackConfig { * Emit a single self-contained worker.js (all modules inlined, zero sibling-chunk * require). This is the default (`true`) and is required for V8 startup snapshots, * which forbid user-land require of sibling chunks. Set to `false` to fall back to - * the legacy multi-chunk standalone output. + * the legacy multi-chunk standalone output. Enabling {@link BundlerConfig.snapshot} + * forces this on regardless of an explicit `false`. */ readonly singleFile?: boolean; } @@ -61,6 +63,15 @@ export interface BundlerConfig { readonly pack?: BundlerPackConfig; /** Runtime asset copy tuning. */ readonly runtimeAssets?: BundlerRuntimeAssetsConfig; + /** + * Build a V8 startup snapshot-ready artifact. When `true` the bundler emits a + * single self-contained worker.js (implies {@link BundlerPackConfig.singleFile}) + * and prepends a runtime prelude before the bundle IIFE so it runs before any + * module loads. The generated entry additionally honours the `EGG_BUNDLE_SNAPSHOT` + * env var at runtime to switch between normal start, snapshot build, and snapshot + * restore. Defaults to `false`. + */ + readonly snapshot?: boolean; } export interface BundleResult { diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index bd87dcf464..546693a5b5 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -10,6 +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'; const debug = debuglog('egg/bundler/bundler'); @@ -326,6 +327,7 @@ export class Bundler { externals, pack, runtimeAssets, + snapshot = false, } = this.#config; const absBaseDir = path.resolve(baseDir); @@ -336,6 +338,14 @@ export class Bundler { const mergedPack = mergePackConfig(moduleConfig?.pack, pack); const mergedRuntimeAssets = mergeRuntimeAssetsConfig(moduleConfig?.runtimeAssets, runtimeAssets); + // Single-file output is the PackRunner default. Snapshot artifacts must be a + // single self-contained worker.js (a V8 startup snapshot forbids user-land + // require of sibling chunks), so snapshot mode forces it on even when the app + // explicitly opted out via pack.singleFile === false. Otherwise honour the + // app's pack.singleFile (undefined keeps PackRunner's default). + const singleFile = snapshot ? true : mergedPack?.singleFile; + debug('snapshot=%s singleFile=%o', snapshot, singleFile); + const manifestLoader = new ManifestLoader({ baseDir: absBaseDir, manifestPath, @@ -378,7 +388,7 @@ export class Bundler { mode, buildFunc: mergedPack?.buildFunc, resolve: mergedPack?.resolve, - singleFile: mergedPack?.singleFile, + singleFile, }); const packResult = await wrapStep('pack build', () => packRunner.run()); debug('pack produced %d files', packResult.files.length); @@ -392,6 +402,15 @@ 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). + if (snapshot) { + const prependedEntries = await wrapStep('prepend snapshot prelude', () => + this.#prependSnapshotPrelude(absOutputDir, ['worker']), + ); + debug('prepended snapshot prelude to %d entry file(s)', prependedEntries.length); + } + const copiedRuntimeAssets = await wrapStep('runtime asset copy', () => this.#copyRuntimeAssets(absBaseDir, absOutputDir, manifestLoader, mergedRuntimeAssets), ); @@ -428,6 +447,34 @@ 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`); + 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) { + await fs.writeFile(filepath, next); + prepended.push(rel); + } + } + return prepended; + } + async #copyRuntimeAssets( baseDir: string, outputDir: string, diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index f791adb8ef..55c11951cd 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -257,6 +257,7 @@ for (const [key, spec] of __EXTERNAL_SPECS) { return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit /* eslint-disable */ import path from 'node:path'; +import v8 from 'node:v8'; import { ManifestLoaderFS, ManifestStore } from '@eggjs/core'; import type {} from '@eggjs/typings/global'; @@ -343,17 +344,100 @@ globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { return __getBundleMap(filepath); }; -startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single', loaderFS: __loaderFS }).then((app) => { - const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; - app.listen(port, () => { +const __startOptions = { baseDir: __outputDir, framework: __framework, mode: 'single' as const, loaderFS: __loaderFS }; +// Resolve the listen port. Treat an explicit numeric port of 0 (bind a random +// free port) as a real value, so only an unset PORT / config falls through to +// the default — a plain \`||\` chain would wrongly map 0 to 7001. +const __resolvePort = (app: any) => { + const envPort = process.env.PORT; + if (envPort !== undefined && envPort !== '') return envPort; + return app.config.cluster?.listen?.port ?? 7001; +}; + +if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') { + // ── snapshot build mode ───────────────────────────────────────────────── + // Runs under \`node --snapshot-blob --build-snapshot worker.js\`. + // Load all metadata with snapshot:true (the lifecycle stops at configWillLoad, + // no servers/timers/connections), run the snapshotWillSerialize hooks to release + // non-serializable resources, then register the deserialize main function that + // V8 invokes when restoring from the blob. + startEgg({ ...__startOptions, snapshot: true }).then(async (app) => { + if (app.agent) { + await app.agent.triggerSnapshotWillSerialize(); + } + await app.triggerSnapshotWillSerialize(); + + v8.startupSnapshot.setDeserializeMainFunction(() => { + // ── snapshot restore main ────────────────────────────────────────── + // V8 runs this callback synchronously right after deserialization, before + // the Node ESM loader is ready, so all real work is deferred to the next + // tick via setImmediate. + setImmediate(() => { + // A restored snapshot process has no dynamic import() callback + // (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Route every egg loader import + // through require() instead — Node 22+ supports synchronous require() of + // ESM. __EGG_MODULE_IMPORTER__ is the egg loader hook that takes + // precedence over import(), so the loader never reaches the missing + // dynamic import() callback. + // + // Use process.getBuiltinModule (Node >= 22.3) rather than a static + // \`require('node:module')\`/\`import\`: a bare require is traced/rewritten by + // @utoo/pack inside the single-file bundle, and a build-time import binding + // is frozen into the snapshot — getBuiltinModule fetches a fresh, working + // builtin at deserialize time without the bundler ever seeing a specifier. + // Fall back to an eval'd require for Node < 22.3 (the eval keeps the + // specifier opaque to @utoo/pack, same as the static-require concern above). + const { createRequire } = + typeof process.getBuiltinModule === 'function' + ? process.getBuiltinModule('node:module') + : (0, eval)('require')('node:module'); + const __req = createRequire(__outputDir + '/'); + globalThis.__RUNTIME_REQUIRE = (id: string) => __req(id); + globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp); + + (async () => { + if (app.agent) { + await app.agent.triggerSnapshotDidDeserialize(); + } + await app.triggerSnapshotDidDeserialize(); + const port = __resolvePort(app); + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s (restored from snapshot)', port); + // When launched by \`egg-scripts start --snapshot-blob\` (daemon mode), the + // parent waits for an egg-ready IPC message before backgrounding. There is + // no egg-cluster master here, so the snapshot process reports readiness + // itself over the IPC channel when one is present. + if (process.connected && typeof process.send === 'function') { + process.send({ action: 'egg-ready', data: { address: String(port) } }); + } + }); + })().catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to restore snapshot:', err); + process.exit(1); + }); + }); + }); + }).catch((err) => { // eslint-disable-next-line no-console - console.log('[egg-bundler] server listening on port %s', port); + console.error('[egg-bundler] failed to build snapshot:', err); + process.exit(1); }); -}).catch((err) => { - // eslint-disable-next-line no-console - console.error('[egg-bundler] failed to start bundled app:', err); - process.exit(1); -}); +} else { + // ── normal mode ───────────────────────────────────────────────────────── + startEgg(__startOptions).then((app) => { + const port = __resolvePort(app); + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s', port); + }); + }).catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to start bundled app:', err); + process.exit(1); + }); +} `; } diff --git a/tools/egg-bundler/src/lib/prelude.ts b/tools/egg-bundler/src/lib/prelude.ts new file mode 100644 index 0000000000..7c6de253ea --- /dev/null +++ b/tools/egg-bundler/src/lib/prelude.ts @@ -0,0 +1,73 @@ +/** + * Snapshot prelude generation. + * + * 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. + */ + +/** + * 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'; + +/** + * 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. + */ +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'); +} + +/** + * Prepend the snapshot prelude to `source`, preserving a leading shebang and a + * leading `"use strict"` / `'use strict'` directive so neither is demoted out of + * the directive prologue. Idempotent: if the prelude marker is already present the + * source is returned unchanged. + */ +export function prependSnapshotPrelude(source: string): 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, + // silently skipping prelude injection. + if (source.slice(0, 1024).includes(SNAPSHOT_PRELUDE_MARKER)) { + return source; + } + + const prelude = renderSnapshotPrelude(); + const lines = source.split('\n'); + let insertAt = 0; + + // Keep a shebang on the very first line. + if (lines[0]?.startsWith('#!')) { + insertAt = 1; + } + // Keep a leading "use strict" directive ahead of the prelude. + const directive = lines[insertAt]?.trim(); + if (directive === '"use strict";' || directive === "'use strict';") { + insertAt += 1; + } + + if (insertAt === 0) { + return prelude + source; + } + const head = lines.slice(0, insertAt).join('\n'); + const tail = lines.slice(insertAt).join('\n'); + return `${head}\n${prelude}${tail}`; +} diff --git a/tools/egg-bundler/test/Bundler.test.ts b/tools/egg-bundler/test/Bundler.test.ts index 429f4b2d9b..e132366edc 100644 --- a/tools/egg-bundler/test/Bundler.test.ts +++ b/tools/egg-bundler/test/Bundler.test.ts @@ -58,6 +58,7 @@ vi.mock('../src/lib/EntryGenerator.ts', () => ({ })); import { bundle } from '../src/index.ts'; +import { SNAPSHOT_PRELUDE_MARKER } from '../src/lib/prelude.ts'; describe('Bundler', () => { let tmpApp: string; @@ -105,6 +106,103 @@ describe('Bundler', () => { }); }); + it('emits single-file output and prepends the snapshot prelude in snapshot mode', async () => { + let packConfig: { output?: { type?: string }; entry?: unknown[] } | undefined; + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { + buildFunc: async (wrapped) => { + packConfig = wrapped.config as typeof packConfig; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '((__UTOOPACK__)=>{})([]);\n'); + }, + }, + }); + + // snapshot implies single-file: `export` output + per-entry library. + expect(packConfig?.output?.type).toBe('export'); + expect(packConfig?.entry).toContainEqual(expect.objectContaining({ library: { name: 'app' } })); + + // prelude prepended before the bundle IIFE. + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).toContain(SNAPSHOT_PRELUDE_MARKER); + expect(worker.indexOf(SNAPSHOT_PRELUDE_MARKER)).toBeLessThan(worker.indexOf('__UTOOPACK__')); + }); + + it('skips the prelude when snapshot is disabled (single-file default unchanged)', async () => { + let outputType: string | undefined; + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + buildFunc: async (wrapped) => { + outputType = (wrapped.config as { output?: { type?: string } }).output?.type; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '((__UTOOPACK__)=>{})([]);\n'); + }, + }, + }); + + // single-file is the upstream default, so output stays `export` without snapshot. + expect(outputType).toBe('export'); + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).not.toContain(SNAPSHOT_PRELUDE_MARKER); + }); + + it('honours pack.singleFile=false without snapshot (standalone, no prelude)', async () => { + let outputType: string | undefined; + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + singleFile: false, + buildFunc: async (wrapped) => { + outputType = (wrapped.config as { output?: { type?: string } }).output?.type; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// standalone\n'); + }, + }, + }); + + expect(outputType).toBe('standalone'); + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).not.toContain(SNAPSHOT_PRELUDE_MARKER); + }); + + it('fails fast in snapshot mode when the pack build produced no worker.js', async () => { + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { + // buildFunc intentionally writes nothing, simulating a broken pack output. + buildFunc: async () => {}, + }, + }), + ).rejects.toThrow(/expected bundle entry "worker\.js" was not found/); + }); + + it('forces single-file output in snapshot mode even when pack.singleFile=false', async () => { + let outputType: string | undefined; + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + snapshot: true, + pack: { + singleFile: false, + buildFunc: async (wrapped) => { + outputType = (wrapped.config as { output?: { type?: string } }).output?.type; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '((__UTOOPACK__)=>{})([]);\n'); + }, + }, + }); + + // snapshot must override an explicit opt-out: V8 snapshots require single-file. + expect(outputType).toBe('export'); + const worker = await fs.readFile(path.join(tmpOutput, 'worker.js'), 'utf8'); + expect(worker).toContain(SNAPSHOT_PRELUDE_MARKER); + }); + it.each([ ['absolute path', () => path.join(tmpApp, 'node_modules/custom-egg')], ['relative path', () => './custom-egg'], diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index fb698c2eba..b6cf40e26e 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -280,8 +280,24 @@ describe('EntryGenerator', () => { expect(worker).toContain('__setBundleMap(__framework, __frameworkModule)'); expect(worker).not.toContain('__frameworkImport'); expect(worker).toContain( - "startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single', loaderFS: __loaderFS })", + "const __startOptions = { baseDir: __outputDir, framework: __framework, mode: 'single' as const, loaderFS: __loaderFS }", ); + expect(worker).toContain('startEgg(__startOptions)'); + // 3-mode snapshot dispatch (normal / snapshot-build / restore-main) + expect(worker).toContain("import v8 from 'node:v8'"); + expect(worker).toContain("if (process.env.EGG_BUNDLE_SNAPSHOT === 'build')"); + expect(worker).toContain('startEgg({ ...__startOptions, snapshot: true })'); + expect(worker).toContain('app.triggerSnapshotWillSerialize()'); + expect(worker).toContain('v8.startupSnapshot.setDeserializeMainFunction(() =>'); + // restore main must defer (ESM loader not ready) and route imports via require() + expect(worker).toContain('setImmediate(() =>'); + expect(worker).toContain("process.getBuiltinModule('node:module')"); + expect(worker).toContain("(0, eval)('require')('node:module')"); + expect(worker).toContain('globalThis.__RUNTIME_REQUIRE ='); + expect(worker).toContain('globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp)'); + expect(worker).toContain('app.triggerSnapshotDidDeserialize()'); + // daemon readiness over IPC for `egg-scripts start --snapshot-blob` + expect(worker).toContain("process.send({ action: 'egg-ready'"); }); it('builds a BUNDLE_MAP keyed by relKey, output absolute, original app absolute, and resolveCache aliases', async () => { @@ -485,8 +501,9 @@ export async function startEgg(options) { expect(extractImports(worker).length).toBe(0); expect(worker).toContain( - "startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single', loaderFS: __loaderFS })", + "const __startOptions = { baseDir: __outputDir, framework: __framework, mode: 'single' as const, loaderFS: __loaderFS }", ); + expect(worker).toContain('startEgg(__startOptions)'); expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain('ManifestStore.setBundleStore'); }); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 5e769911a2..50d0e5cbfb 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -1,6 +1,7 @@ // ⚠️ auto-generated by @eggjs/egg-bundler — do not edit /* eslint-disable */ import path from 'node:path'; +import v8 from 'node:v8'; import { ManifestLoaderFS, ManifestStore } from '@eggjs/core'; import type {} from '@eggjs/typings/global'; @@ -127,14 +128,97 @@ globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { return __getBundleMap(filepath); }; -startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single', loaderFS: __loaderFS }).then((app) => { - const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; - app.listen(port, () => { +const __startOptions = { baseDir: __outputDir, framework: __framework, mode: 'single' as const, loaderFS: __loaderFS }; +// Resolve the listen port. Treat an explicit numeric port of 0 (bind a random +// free port) as a real value, so only an unset PORT / config falls through to +// the default — a plain `||` chain would wrongly map 0 to 7001. +const __resolvePort = (app: any) => { + const envPort = process.env.PORT; + if (envPort !== undefined && envPort !== '') return envPort; + return app.config.cluster?.listen?.port ?? 7001; +}; + +if (process.env.EGG_BUNDLE_SNAPSHOT === 'build') { + // ── snapshot build mode ───────────────────────────────────────────────── + // Runs under `node --snapshot-blob --build-snapshot worker.js`. + // Load all metadata with snapshot:true (the lifecycle stops at configWillLoad, + // no servers/timers/connections), run the snapshotWillSerialize hooks to release + // non-serializable resources, then register the deserialize main function that + // V8 invokes when restoring from the blob. + startEgg({ ...__startOptions, snapshot: true }).then(async (app) => { + if (app.agent) { + await app.agent.triggerSnapshotWillSerialize(); + } + await app.triggerSnapshotWillSerialize(); + + v8.startupSnapshot.setDeserializeMainFunction(() => { + // ── snapshot restore main ────────────────────────────────────────── + // V8 runs this callback synchronously right after deserialization, before + // the Node ESM loader is ready, so all real work is deferred to the next + // tick via setImmediate. + setImmediate(() => { + // A restored snapshot process has no dynamic import() callback + // (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Route every egg loader import + // through require() instead — Node 22+ supports synchronous require() of + // ESM. __EGG_MODULE_IMPORTER__ is the egg loader hook that takes + // precedence over import(), so the loader never reaches the missing + // dynamic import() callback. + // + // Use process.getBuiltinModule (Node >= 22.3) rather than a static + // `require('node:module')`/`import`: a bare require is traced/rewritten by + // @utoo/pack inside the single-file bundle, and a build-time import binding + // is frozen into the snapshot — getBuiltinModule fetches a fresh, working + // builtin at deserialize time without the bundler ever seeing a specifier. + // Fall back to an eval'd require for Node < 22.3 (the eval keeps the + // specifier opaque to @utoo/pack, same as the static-require concern above). + const { createRequire } = + typeof process.getBuiltinModule === 'function' + ? process.getBuiltinModule('node:module') + : (0, eval)('require')('node:module'); + const __req = createRequire(__outputDir + '/'); + globalThis.__RUNTIME_REQUIRE = (id: string) => __req(id); + globalThis.__EGG_MODULE_IMPORTER__ = async (fp: string) => __req(fp); + + (async () => { + if (app.agent) { + await app.agent.triggerSnapshotDidDeserialize(); + } + await app.triggerSnapshotDidDeserialize(); + const port = __resolvePort(app); + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s (restored from snapshot)', port); + // When launched by `egg-scripts start --snapshot-blob` (daemon mode), the + // parent waits for an egg-ready IPC message before backgrounding. There is + // no egg-cluster master here, so the snapshot process reports readiness + // itself over the IPC channel when one is present. + if (process.connected && typeof process.send === 'function') { + process.send({ action: 'egg-ready', data: { address: String(port) } }); + } + }); + })().catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to restore snapshot:', err); + process.exit(1); + }); + }); + }); + }).catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to build snapshot:', err); + process.exit(1); + }); +} else { + // ── normal mode ───────────────────────────────────────────────────────── + startEgg(__startOptions).then((app) => { + const port = __resolvePort(app); + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s', port); + }); + }).catch((err) => { // eslint-disable-next-line no-console - console.log('[egg-bundler] server listening on port %s', port); + console.error('[egg-bundler] failed to start bundled app:', err); + process.exit(1); }); -}).catch((err) => { - // eslint-disable-next-line no-console - console.error('[egg-bundler] failed to start bundled app:', err); - process.exit(1); -}); +} diff --git a/tools/egg-bundler/test/prelude.test.ts b/tools/egg-bundler/test/prelude.test.ts new file mode 100644 index 0000000000..3f4f7b1e71 --- /dev/null +++ b/tools/egg-bundler/test/prelude.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { prependSnapshotPrelude, renderSnapshotPrelude, SNAPSHOT_PRELUDE_MARKER } from '../src/lib/prelude.ts'; + +describe('snapshot prelude', () => { + it('renders a marked placeholder block', () => { + const prelude = renderSnapshotPrelude(); + expect(prelude).toContain(SNAPSHOT_PRELUDE_MARKER); + expect(prelude).toContain('eggBundlerSnapshotPrelude'); + // ends with a newline so it cleanly precedes the bundle IIFE + expect(prelude.endsWith('\n')).toBe(true); + }); + + it('prepends the prelude before the bundle IIFE', () => { + const bundle = '((__UTOOPACK__)=>{/* modules */})([]);\n'; + const out = prependSnapshotPrelude(bundle); + expect(out.startsWith(renderSnapshotPrelude())).toBe(true); + expect(out).toContain(bundle); + // prelude precedes the IIFE + expect(out.indexOf(SNAPSHOT_PRELUDE_MARKER)).toBeLessThan(out.indexOf('__UTOOPACK__')); + }); + + it('is idempotent: a second prepend does not duplicate the prelude', () => { + const bundle = '((__UTOOPACK__)=>{})([]);\n'; + const once = prependSnapshotPrelude(bundle); + const twice = prependSnapshotPrelude(once); + expect(twice).toBe(once); + const occurrences = twice.split(SNAPSHOT_PRELUDE_MARKER).length - 1; + expect(occurrences).toBe(1); + }); + + it('keeps a leading shebang on the first line', () => { + const bundle = '#!/usr/bin/env node\n((__UTOOPACK__)=>{})([]);\n'; + const out = prependSnapshotPrelude(bundle); + expect(out.startsWith('#!/usr/bin/env node\n')).toBe(true); + expect(out.indexOf(SNAPSHOT_PRELUDE_MARKER)).toBeGreaterThan(0); + expect(out.indexOf(SNAPSHOT_PRELUDE_MARKER)).toBeLessThan(out.indexOf('__UTOOPACK__')); + }); + + it('keeps a leading "use strict" directive ahead of the prelude', () => { + const bundle = '"use strict";\n((__UTOOPACK__)=>{})([]);\n'; + const out = prependSnapshotPrelude(bundle); + expect(out.startsWith('"use strict";\n')).toBe(true); + const marker = out.indexOf(SNAPSHOT_PRELUDE_MARKER); + expect(marker).toBeGreaterThan('"use strict";'.length); + expect(marker).toBeLessThan(out.indexOf('__UTOOPACK__')); + }); +}); diff --git a/tools/scripts/src/commands/start.ts b/tools/scripts/src/commands/start.ts index 2ca048a683..493b925340 100644 --- a/tools/scripts/src/commands/start.ts +++ b/tools/scripts/src/commands/start.ts @@ -72,6 +72,11 @@ export default class Start extends BaseCommand { description: 'customize node command path', default: 'node', }), + 'snapshot-blob': Flags.string({ + description: + 'boot from a V8 startup snapshot blob (built by `egg-bin snapshot build`) via `node --snapshot-blob`, ' + + 'instead of launching the egg cluster', + }), require: Flags.string({ summary: 'require the given module', char: 'r', @@ -126,14 +131,11 @@ export default class Start extends BaseCommand { } await this.initBaseInfo(baseDir); - flags.framework = await this.getFrameworkPath({ - framework: flags.framework, - baseDir, - }); - - const frameworkName = await this.getFrameworkName(flags.framework); - - flags.title = flags.title || `egg-server-${this.pkg.name}`; + // The shared startup option/env pipeline below runs for BOTH the cluster and + // the snapshot (`--snapshot-blob`) launch paths; only the final argv differs, + // so snapshot boots honor the same runtime contract (eggScriptsConfig.require, + // node-options--*, sourcemap, PATH, EGG_TS_ENABLE, …) as a normal start. + flags.title = flags.title || `egg-server-${this.pkg?.name ?? 'snapshot'}`; flags.stdout = flags.stdout || path.join(logDir, 'master-stdout.log'); flags.stderr = flags.stderr || path.join(logDir, 'master-stderr.log'); @@ -232,7 +234,7 @@ export default class Start extends BaseCommand { flags.port = parseInt(process.env.PORT); } - debug('flags: %o, framework: %o, baseDir: %o, execArgv: %o', flags, frameworkName, baseDir, execArgv); + debug('flags: %o, baseDir: %o, execArgv: %o', flags, baseDir, execArgv); const command = flags.node; const options: SpawnOptions = { @@ -242,27 +244,59 @@ export default class Start extends BaseCommand { cwd: baseDir, }; - this.log('Starting %s application at %s', frameworkName, baseDir); - - // remove unused properties from stringify, alias had been remove by `removeAlias` - const ignoreKeys = ['env', 'daemon', 'stdout', 'stderr', 'timeout', 'ignore-stderr', 'node']; - const clusterOptions = stringify( - { - ...flags, - baseDir, - }, - ignoreKeys, - ); - // Note: `spawn` is not like `fork`, had to pass `execArgv` yourself - const serverBin = await this.getServerBin(); - const eggArgs = [...execArgv, serverBin, clusterOptions, `--title=${flags.title}`]; + // The final argv is the only thing that differs between the two launch modes. + let eggArgs: string[]; + let displayName: string; + if (flags['snapshot-blob']) { + // Snapshot boot: a single self-contained `node --snapshot-blob ` + // process (no egg-cluster, no framework resolution). The snapshot entry + // reads the listen port from PORT env, and `--title` is appended only so + // `egg-scripts stop` can grep the process (the snapshot main ignores it). + const blobFlag = flags['snapshot-blob']; + const blob = path.isAbsolute(blobFlag) ? blobFlag : path.join(baseDir, blobFlag); + if (flags.port !== undefined) { + this.env.PORT = String(flags.port); + } + eggArgs = [...execArgv, '--snapshot-blob', blob, `--title=${flags.title}`]; + displayName = 'snapshot'; + this.log('Starting egg snapshot at %s', blob); + } else { + flags.framework = await this.getFrameworkPath({ framework: flags.framework, baseDir }); + const frameworkName = await this.getFrameworkName(flags.framework); + this.log('Starting %s application at %s', frameworkName, baseDir); + // remove unused properties from stringify, alias had been remove by `removeAlias` + const ignoreKeys = ['env', 'daemon', 'stdout', 'stderr', 'timeout', 'ignore-stderr', 'node', 'snapshot-blob']; + const clusterOptions = stringify({ ...flags, baseDir }, ignoreKeys); + // Note: `spawn` is not like `fork`, had to pass `execArgv` yourself + const serverBin = await this.getServerBin(); + eggArgs = [...execArgv, serverBin, clusterOptions, `--title=${flags.title}`]; + displayName = frameworkName; + } + const spawnScript = `${command} ${eggArgs.map((a) => `'${a}'`).join(' ')}`; this.log('Spawn %o', spawnScript); + await this.spawnServer(command, eggArgs, options, logDir, displayName); + } + + /** + * Shared spawn + daemon/foreground lifecycle for both the cluster and snapshot + * launch paths. In daemon mode it waits for an `egg-ready` IPC message via + * {@link checkStatus}; in foreground mode it forwards termination signals to the + * child and mirrors its exit code. + */ + protected async spawnServer( + command: string, + eggArgs: string[], + options: SpawnOptions, + logDir: string, + displayName: string, + ): Promise { + const { flags } = this; // whether run in the background. if (flags.daemon) { this.log(`Save log file to ${logDir}`); - const [stdout, stderr] = await Promise.all([getRotateLog(flags.stdout), getRotateLog(flags.stderr)]); + const [stdout, stderr] = await Promise.all([getRotateLog(flags.stdout!), getRotateLog(flags.stderr!)]); options.stdio = ['ignore', stdout, stderr, 'ipc']; options.detached = true; const child = (this.#child = spawn(command, eggArgs, options)); @@ -271,7 +305,7 @@ export default class Start extends BaseCommand { // https://github.com/eggjs/cluster/blob/master/src/master.ts#L119 if (msg && msg.action === 'egg-ready') { this.isReady = true; - this.log('%s started on %s', frameworkName, msg.data.address); + this.log('%s started on %s', displayName, msg.data.address); child.unref(); child.disconnect(); } diff --git a/tools/scripts/src/commands/stop.ts b/tools/scripts/src/commands/stop.ts index 4e86c66cb4..9169ac8b04 100644 --- a/tools/scripts/src/commands/stop.ts +++ b/tools/scripts/src/commands/stop.ts @@ -8,6 +8,28 @@ import { isWindows, findNodeProcess, type NodeProcess, kill } from '../helper.ts const debug = debuglog('egg/scripts/commands/stop'); +function escapeRegExp(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Match a snapshot-started server process (`egg-scripts start --snapshot-blob`). + * + * - must carry `--snapshot-blob` but NOT `--build-snapshot` (so an in-progress + * `egg-bin snapshot build` is never killed); + * - must carry a `--title=` token (egg-scripts start always appends one), so a bare + * `node --snapshot-blob` is not matched when no title is given; + * - when a title is given, match it at argv boundaries (so `egg-server-foo` does not + * match `egg-server-foo-bar`). + */ +function isSnapshotServer(cmd: string, title?: string): boolean { + if (!cmd.includes('--snapshot-blob') || cmd.includes('--build-snapshot')) return false; + if (title) { + return new RegExp(`(?:^|\\s)--title=${escapeRegExp(title)}(?:\\s|$)`).test(cmd); + } + return /(?:^|\s)--title=\S/.test(cmd); +} + const osRelated = { titleTemplate: isWindows ? '\\"title\\":\\"%s\\"' : '"title":"%s"', // node_modules/@eggjs/cluster/dist/app_worker.js @@ -46,9 +68,15 @@ export default class Stop extends BaseCommand { // node ~/eggjs/scripts/scripts/start-cluster.cjs {"title":"egg-server","workers":4,"port":7001,"baseDir":"~/eggjs/test/showcase","framework":"~/eggjs/test/showcase/node_modules/egg"} let processList = await this.findNodeProcesses((item) => { const cmd = item.cmd; - const matched = flags.title + // A snapshot boot (`egg-scripts start --snapshot-blob`) is a single + // self-contained `node --snapshot-blob --title=` process, + // matched precisely by isSnapshotServer (excludes `--build-snapshot` builds + // and matches the title at argv boundaries). + const clusterMatched = flags.title ? cmd.includes('start-cluster') && cmd.includes(format(osRelated.titleTemplate, flags.title)) : cmd.includes('start-cluster'); + const snapshotMatched = isSnapshotServer(cmd, flags.title); + const matched = clusterMatched || snapshotMatched; if (matched) { debug('find master process: %o', item); } diff --git a/tools/scripts/test/snapshot-start.test.ts b/tools/scripts/test/snapshot-start.test.ts new file mode 100644 index 0000000000..eb25bd2d2b --- /dev/null +++ b/tools/scripts/test/snapshot-start.test.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import Start from '../src/commands/start.ts'; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: spawnMock }; +}); + +const __dirname = import.meta.dirname; + +describe('test/snapshot-start.test.ts', () => { + const baseDir = path.join(__dirname, 'fixtures/example'); + let homeDir: string; + + beforeEach(async () => { + // Mock HOME (node-homedir honors MOCK_HOME_DIR) so the shared start pipeline's + // `mkdir(<HOME>/logs/alinode)` does not touch the real home directory. + homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-snapshot-start-')); + process.env.MOCK_HOME_DIR = homeDir; + spawnMock.mockReset(); + spawnMock.mockImplementation(() => ({ + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + unref: vi.fn(), + disconnect: vi.fn(), + kill: vi.fn(), + pid: 4242, + })); + }); + + afterEach(async () => { + for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) { + process.removeAllListeners(signal); + } + delete process.env.MOCK_HOME_DIR; + vi.restoreAllMocks(); + await fs.rm(homeDir, { recursive: true, force: true }); + }); + + function spawnArgs() { + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [string, string[], { env: NodeJS.ProcessEnv }]; + return { command, args, options }; + } + + it('boots from the blob via `node --snapshot-blob` instead of the cluster bin', async () => { + await Start.run(['--snapshot-blob', './snapshot.blob', baseDir]); + + const { command, args, options } = spawnArgs(); + expect(command).toBe('node'); + // self-contained snapshot boot — no egg-cluster server bin + expect(args.join(' ')).not.toContain('start-cluster'); + expect(args).toEqual(expect.arrayContaining(['--snapshot-blob', path.join(baseDir, 'snapshot.blob')])); + // --title=... appended for `egg-scripts stop` grep + expect(args.some((a) => a.startsWith('--title=egg-server-'))).toBe(true); + expect(options.env.NODE_ENV).toBe('production'); + }); + + it('passes --port through as PORT env for the snapshot entry to read', async () => { + await Start.run(['--snapshot-blob', '/abs/app.blob', '--port', '8080', baseDir]); + + const { args, options } = spawnArgs(); + expect(args).toContain('--snapshot-blob'); + expect(args).toContain('/abs/app.blob'); + expect(options.env.PORT).toBe('8080'); + }); +}); diff --git a/tools/scripts/test/snapshot-stop.test.ts b/tools/scripts/test/snapshot-stop.test.ts new file mode 100644 index 0000000000..22fccb20ad --- /dev/null +++ b/tools/scripts/test/snapshot-stop.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NodeProcess } from '../src/helper.ts'; + +const killMock = vi.hoisted(() => vi.fn()); +const processList = vi.hoisted(() => ({ value: [] as NodeProcess[] })); + +vi.mock('../src/helper.ts', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/helper.ts')>(); + return { + ...actual, + findNodeProcess: vi.fn(async (filter: (item: NodeProcess) => boolean) => processList.value.filter(filter)), + kill: killMock, + }; +}); + +import Stop from '../src/commands/stop.ts'; + +describe('test/snapshot-stop.test.ts', () => { + beforeEach(() => { + killMock.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('stops a `--snapshot-blob` process matched by --title', async () => { + processList.value = [ + { + pid: 4242, + cmd: 'node --no-deprecation --trace-warnings --snapshot-blob /app/snapshot.blob --title=egg-server-foo', + }, + { pid: 1, cmd: 'node unrelated.js' }, + ]; + + await Stop.run(['--title', 'egg-server-foo', '--timeout', '10']); + + expect(killMock).toHaveBeenCalledTimes(1); + expect(killMock.mock.calls[0][0]).toEqual([4242]); + }); + + it('does not stop a snapshot process whose title does not match', async () => { + processList.value = [ + { + pid: 4242, + cmd: 'node --snapshot-blob /app/snapshot.blob --title=egg-server-other', + }, + ]; + + await Stop.run(['--title', 'egg-server-foo', '--timeout', '10']); + + expect(killMock).not.toHaveBeenCalled(); + }); + + it('stops any snapshot-started server when no --title is given', async () => { + processList.value = [{ pid: 7, cmd: 'node --snapshot-blob /app/snapshot.blob --title=egg-server-foo' }]; + + await Stop.run(['--timeout', '10']); + + expect(killMock).toHaveBeenCalledTimes(1); + expect(killMock.mock.calls[0][0]).toEqual([7]); + }); + + it('never kills an in-progress `egg-bin snapshot build`', async () => { + processList.value = [ + { + pid: 99, + cmd: 'node --snapshot-blob /app/snapshot.blob --build-snapshot /app/worker.js --title=egg-server-foo', + }, + ]; + + // even without --title (broadest match), a build must not be targeted + await Stop.run(['--timeout', '10']); + expect(killMock).not.toHaveBeenCalled(); + }); + + it('does not kill a bare `node --snapshot-blob` without a --title token when no --title given', async () => { + processList.value = [{ pid: 5, cmd: 'node --snapshot-blob /app/snapshot.blob' }]; + + await Stop.run(['--timeout', '10']); + expect(killMock).not.toHaveBeenCalled(); + }); + + it('matches the title at argv boundaries (foo does not match foo-bar)', async () => { + processList.value = [{ pid: 8, cmd: 'node --snapshot-blob /app/snapshot.blob --title=egg-server-foo-bar' }]; + + await Stop.run(['--title', 'egg-server-foo', '--timeout', '10']); + expect(killMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/scripts/test/start-unit.test.ts b/tools/scripts/test/start-unit.test.ts new file mode 100644 index 0000000000..f82bcaf22e --- /dev/null +++ b/tools/scripts/test/start-unit.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import Start from '../src/commands/start.ts'; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: spawnMock }; +}); + +// Subclass to stub the cluster framework/server-bin resolution so the test does +// not depend on a real egg install under the fixture. +class TestStart extends (Start as any) { + async getFrameworkPath() { + return '/fake/framework'; + } + async getServerBin() { + return '/fake/scripts/start-cluster.cjs'; + } +} + +const __dirname = import.meta.dirname; + +describe('test/start-unit.test.ts', () => { + const baseDir = path.join(__dirname, 'fixtures/example'); + let homeDir: string; + + beforeEach(async () => { + homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-start-unit-')); + process.env.MOCK_HOME_DIR = homeDir; + spawnMock.mockReset(); + }); + + afterEach(async () => { + for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) { + process.removeAllListeners(signal); + } + delete process.env.MOCK_HOME_DIR; + vi.restoreAllMocks(); + await fs.rm(homeDir, { recursive: true, force: true }); + }); + + it('cluster mode spawns the start-cluster server bin with cluster options', async () => { + spawnMock.mockImplementation(() => ({ + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + unref: vi.fn(), + disconnect: vi.fn(), + kill: vi.fn(), + pid: 1111, + })); + + await TestStart.run(['--workers=1', baseDir]); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [string, string[], { env: NodeJS.ProcessEnv }]; + expect(command).toBe('node'); + expect(args).toContain('/fake/scripts/start-cluster.cjs'); + expect(args.some((a) => a.startsWith('--title=egg-server-'))).toBe(true); + // cluster options JSON carries baseDir + framework + expect(args.some((a) => a.includes('"baseDir"') && a.includes('/fake/framework'))).toBe(true); + // not a snapshot boot + expect(args).not.toContain('--snapshot-blob'); + expect(options.env.NODE_ENV).toBe('production'); + }); + + it('daemon mode backgrounds once the child reports egg-ready over IPC', async () => { + const unref = vi.fn(); + const disconnect = vi.fn(); + spawnMock.mockImplementation(() => { + const child: any = { + once: vi.fn().mockReturnThis(), + on: vi.fn((event: string, cb: (msg: unknown) => void) => { + // emit egg-ready synchronously so checkStatus() resolves immediately + if (event === 'message') cb({ action: 'egg-ready', data: { address: '127.0.0.1:7001' } }); + return child; + }), + unref, + disconnect, + kill: vi.fn(), + pid: 2222, + }; + return child; + }); + + await TestStart.run(['--daemon', '--workers=1', baseDir]); + + expect(spawnMock).toHaveBeenCalledTimes(1); + // egg-ready handler unref/disconnects the daemonized child + expect(unref).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +});