Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/typings/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
4 changes: 4 additions & 0 deletions tools/egg-bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions tools/egg-bin/src/bundleOptions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined {
if (values.length === 0) return undefined;

const alias: Record<string, string> = {};
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 <specifier>=<target>.`);
}

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<string> {
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';
}
41 changes: 1 addition & 40 deletions tools/egg-bin/src/commands/bundle.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined {
if (values.length === 0) return undefined;

const alias: Record<string, string> = {};
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 <specifier>=<target>.`);
}

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<string> {
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<typeof Bundle> {
static override description = 'Bundle an egg app into a deployable artifact using @eggjs/egg-bundler';
Expand Down
211 changes: 211 additions & 0 deletions tools/egg-bin/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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 <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 <blob>`.
*/
export default class Snapshot<T extends typeof Snapshot> extends BaseCommand<T> {
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 <output>/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 <specifier>=<target> 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<void> {
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<void> {
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 <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<void> {
// 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<void>((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);
}
}
}
}
Loading
Loading