From 65e75138e48e967289e394757dd74ea8f328f8e0 Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 2 May 2026 11:44:03 +0800 Subject: [PATCH] fix(bundler): use metadataOnly for manifest generation --- .../src/scripts/generate-manifest.mjs | 18 +++- tools/egg-bundler/test/ManifestLoader.test.ts | 2 +- .../test/generate-manifest.test.ts | 86 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tools/egg-bundler/test/generate-manifest.test.ts diff --git a/tools/egg-bundler/src/scripts/generate-manifest.mjs b/tools/egg-bundler/src/scripts/generate-manifest.mjs index 9e1db95131..4221c4e399 100644 --- a/tools/egg-bundler/src/scripts/generate-manifest.mjs +++ b/tools/egg-bundler/src/scripts/generate-manifest.mjs @@ -16,6 +16,19 @@ async function readOptions() { return JSON.parse(raw); } +async function flushWritable(stream) { + if (stream.destroyed || stream.writableEnded) return; + await new Promise((resolve, reject) => { + stream.write('', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + async function main() { debug('argv: %o', process.argv); const options = await readOptions(); @@ -46,6 +59,7 @@ async function main() { framework: options.framework, env: options.env, mode: 'single', + metadataOnly: true, }); const manifest = app.loader.generateManifest(); @@ -59,7 +73,9 @@ async function main() { console.log('[bundler-manifest] fileDiscovery: %d', fileDiscoveryCount); console.log('[bundler-manifest] extensions: %d', extensionCount); - await app.close(); + // This runs in a dedicated child process; exit after flushing so real app close hooks cannot affect bundling. + await Promise.all([flushWritable(process.stdout), flushWritable(process.stderr)]); + process.exit(0); } main().catch((err) => { diff --git a/tools/egg-bundler/test/ManifestLoader.test.ts b/tools/egg-bundler/test/ManifestLoader.test.ts index 16556a2e6b..69a7c0febf 100644 --- a/tools/egg-bundler/test/ManifestLoader.test.ts +++ b/tools/egg-bundler/test/ManifestLoader.test.ts @@ -382,7 +382,7 @@ describe('ManifestLoader', () => { expect(written).toEqual(expected); expect(loaded).toEqual(expected); expect(loader.store.data).toBe(loaded); - await expect(fsp.readFile(path.join(appDir, 'closed.txt'), 'utf-8')).resolves.toBe('true'); + await expect(fsp.readFile(path.join(appDir, 'closed.txt'), 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fsp.readFile(path.join(appDir, 'framework-entry.txt'), 'utf-8')).resolves.toBe( pathToFileURL(path.join(frameworkDir, 'src/index.js')).href, ); diff --git a/tools/egg-bundler/test/generate-manifest.test.ts b/tools/egg-bundler/test/generate-manifest.test.ts new file mode 100644 index 0000000000..21e3fbbe50 --- /dev/null +++ b/tools/egg-bundler/test/generate-manifest.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { execaNode } from 'execa'; +import { afterEach, describe, expect, it } from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = path.join(__dirname, '..', 'src/scripts/generate-manifest.mjs'); + +async function createReproApp(): Promise { + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-manifest-')); + await fs.writeFile( + path.join(baseDir, 'framework.mjs'), + ` +import fs from 'node:fs'; +import path from 'node:path'; + +export async function start(options) { + fs.writeFileSync(path.join(options.baseDir, 'start-options.json'), JSON.stringify(options, null, 2)); + setInterval(() => {}, 1000); + return { + loader: { + generateManifest() { + return { + version: 1, + generatedAt: new Date().toISOString(), + invalidation: { + lockfileFingerprint: 'minimal-repro', + configFingerprint: 'minimal-repro', + serverEnv: options.env ?? 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + }; + }, + }, + async close() { + fs.writeFileSync(path.join(options.baseDir, 'app-close-called'), '1'); + fs.writeFileSync(path.join(options.baseDir, 'before-close-called'), '1'); + throw new Error('can not get proto for clazz ChangesStreamService'); + }, + }; +} +`, + ); + return baseDir; +} + +describe('generate-manifest subprocess', () => { + let tmpApp: string | undefined; + + afterEach(async () => { + if (tmpApp) { + await fs.rm(tmpApp, { recursive: true, force: true }); + tmpApp = undefined; + } + }); + + it('starts in metadataOnly mode and exits without app.close side effects', async () => { + tmpApp = await createReproApp(); + const payload = { + baseDir: tmpApp, + frameworkEntry: pathToFileURL(path.join(tmpApp, 'framework.mjs')).href, + env: 'prod', + }; + + await execaNode(SCRIPT_PATH, [], { + input: JSON.stringify(payload), + stdin: 'pipe', + timeout: 5_000, + }); + + const startOptions = JSON.parse(await fs.readFile(path.join(tmpApp, 'start-options.json'), 'utf8')) as { + metadataOnly?: boolean; + }; + expect(startOptions.metadataOnly).toBe(true); + await expect(fs.stat(path.join(tmpApp, '.egg/manifest.json'))).resolves.toBeTruthy(); + await expect(fs.stat(path.join(tmpApp, 'app-close-called'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(path.join(tmpApp, 'before-close-called'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); +});