diff --git a/packages/egg/test/cluster1/app_worker.test.ts b/packages/egg/test/cluster1/app_worker.test.ts index aa6445c1cd..1c036ba825 100644 --- a/packages/egg/test/cluster1/app_worker.test.ts +++ b/packages/egg/test/cluster1/app_worker.test.ts @@ -21,7 +21,7 @@ describe('test/cluster1/app_worker.test.ts', () => { beforeAll(async () => { app = cluster('apps/app-server'); await app.ready(); - }); + }, 60000); afterAll(() => app.close()); // FIXME: unsable @@ -148,33 +148,28 @@ function rawRequest(port: number, path: string) { socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`); }); - function resolveOnce() { - if (!settled) { - settled = true; - resolve(response); - } - } - - function rejectOnce(err: Error) { - if (!settled) { - settled = true; - reject(err); - } - } + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + socket.setTimeout(0); + callback(); + }; + socket.setTimeout(5000, () => + settle(() => { + socket.destroy(); + reject(new Error(`rawRequest timeout after 5s, partial response: ${response}`)); + }), + ); socket.setEncoding('utf8'); - socket.setTimeout(5000); socket.on('data', (chunk) => { response += chunk; }); - socket.on('timeout', () => { - socket.destroy(new Error('Timed out waiting for raw HTTP response')); - }); - socket.on('end', resolveOnce); - socket.on('error', rejectOnce); + socket.on('error', (err) => settle(() => reject(err))); + socket.on('end', () => settle(() => resolve(response))); socket.on('close', (hadError) => { if (!hadError) { - resolveOnce(); + settle(() => resolve(response)); } }); }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 9bcc0d82c8..5912c87588 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -35,7 +35,9 @@ "scripts": { "typecheck": "tsgo --noEmit" }, - "dependencies": {}, + "dependencies": { + "@eggjs/typings": "workspace:*" + }, "devDependencies": { "coffee": "catalog:", "mm": "catalog:", diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index 8a6e036e21..aa1411135c 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { debuglog } from 'node:util'; +import type { BundleModuleLoader } from '@eggjs/typings'; + import { ImportResolveError } from './error/index.ts'; const debug = debuglog('egg/utils/import'); @@ -394,7 +396,44 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void { isESM = false; } +export type { BundleModuleLoader } from '@eggjs/typings'; + +type BundleModuleGlobalThis = typeof globalThis & { + __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined; +}; + +const bundleModuleGlobalThis = globalThis as BundleModuleGlobalThis; + +function normalizeBundleModulePath(filepath: string): string { + return filepath.split(path.win32.sep).join(path.posix.sep); +} + +/** + * Register a bundle module loader. Uses globalThis so that bundled and + * external copies of @eggjs/utils share the same loader. + * + * The loader receives a POSIX-normalized filepath or virtual specifier before + * normal resolution runs. Return `undefined` to fall through to the default + * import path. Non-undefined hits use the same default unwrapping semantics as + * normal imports, including `importDefaultOnly` and double-default `__esModule` + * compatibility. + */ +export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void { + bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader; +} + export async function importModule(filepath: string, options?: ImportModuleOptions): Promise { + const _bundleModuleLoader = bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__; + if (_bundleModuleLoader) { + const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath)); + if (hit !== undefined) { + let obj = hit as any; + if (obj?.default?.__esModule === true && 'default' in obj.default) obj = obj.default; + if (options?.importDefaultOnly && obj && typeof obj === 'object' && 'default' in obj) obj = obj.default; + return obj; + } + } + const moduleFilePath = importResolve(filepath, options); if (_snapshotModuleLoader) { diff --git a/packages/utils/test/__snapshots__/index.test.ts.snap b/packages/utils/test/__snapshots__/index.test.ts.snap index a7d626f650..40c81c80d6 100644 --- a/packages/utils/test/__snapshots__/index.test.ts.snap +++ b/packages/utils/test/__snapshots__/index.test.ts.snap @@ -19,6 +19,7 @@ exports[`test/index.test.ts > export all > should keep checking 1`] = ` "importResolve", "isESM", "isSupportTypeScript", + "setBundleModuleLoader", "setSnapshotModuleLoader", ] `; diff --git a/packages/utils/test/bundle-import.test.ts b/packages/utils/test/bundle-import.test.ts new file mode 100644 index 0000000000..8ce53344a3 --- /dev/null +++ b/packages/utils/test/bundle-import.test.ts @@ -0,0 +1,90 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { afterEach, describe, it } from 'vitest'; + +import { importModule, setBundleModuleLoader } from '../src/import.ts'; +import { getFilepath } from './helper.ts'; + +describe('test/bundle-import.test.ts', () => { + afterEach(() => { + setBundleModuleLoader(undefined); + }); + + it('returns the real module when no bundle loader is registered', async () => { + const result = await importModule(getFilepath('esm')); + assert.ok(result); + assert.equal(typeof result, 'object'); + }); + + it('intercepts importModule with the registered loader', async () => { + const seen: string[] = []; + const fakeModule = { default: { hello: 'bundle' }, other: 'stuff' }; + setBundleModuleLoader((p) => { + seen.push(p); + if (p.endsWith('/fixtures/esm')) return fakeModule; + }); + + const result = await importModule(getFilepath('esm')); + assert.deepEqual(result, fakeModule); + assert.ok(seen.some((p) => p.endsWith('/fixtures/esm'))); + }); + + it('honors importDefaultOnly when the bundle hit has a default key', async () => { + setBundleModuleLoader(() => ({ default: { greet: 'hi' }, other: 'x' })); + + const result = await importModule(getFilepath('esm'), { importDefaultOnly: true }); + assert.deepEqual(result, { greet: 'hi' }); + }); + + it('keeps non-default bundle hits when importDefaultOnly is enabled', async () => { + const fakeModule = { named: 'bundle' }; + setBundleModuleLoader(() => fakeModule); + + const result = await importModule(getFilepath('esm'), { importDefaultOnly: true }); + assert.deepEqual(result, fakeModule); + }); + + it('keeps null bundle hits when importDefaultOnly is enabled', async () => { + setBundleModuleLoader(() => null); + + const result = await importModule(getFilepath('esm'), { importDefaultOnly: true }); + assert.equal(result, null); + }); + + it('unwraps __esModule double-default shape', async () => { + setBundleModuleLoader(() => ({ + default: { __esModule: true, default: { fn: 'bundled' } }, + })); + + const result = await importModule(getFilepath('esm')); + assert.equal(result.__esModule, true); + assert.deepEqual(result.default, { fn: 'bundled' }); + }); + + it('falls through to normal import when loader returns undefined', async () => { + setBundleModuleLoader(() => undefined); + + const result = await importModule(getFilepath('esm')); + assert.ok(result); + assert.equal(result.default.foo, 'bar'); + }); + + it('serves virtual specifiers from the loader without requiring them on disk', async () => { + const fakeModule = { virtual: true }; + setBundleModuleLoader((p) => (p === 'virtual/not-on-disk' ? fakeModule : undefined)); + + const result = await importModule('virtual/not-on-disk'); + assert.deepEqual(result, fakeModule); + }); + + it('normalizes Windows-style bundle paths before loader lookup', async () => { + const fakeModule = { windows: true }; + const filepath = getFilepath('esm').split(path.posix.sep).join(path.win32.sep); + + setBundleModuleLoader((p) => (p.endsWith('/fixtures/esm') ? fakeModule : undefined)); + + const result = await importModule(filepath); + assert.deepEqual(result, fakeModule); + }); +}); diff --git a/packages/utils/test/fixtures/esm/es-module-default.js b/packages/utils/test/fixtures/esm/es-module-default.js new file mode 100644 index 0000000000..e42922cbe1 --- /dev/null +++ b/packages/utils/test/fixtures/esm/es-module-default.js @@ -0,0 +1,7 @@ +export default { + __esModule: true, + default: { + foo: 'bar', + one: 1, + }, +}; diff --git a/packages/utils/test/import.test.ts b/packages/utils/test/import.test.ts index a664539171..48e717abe9 100644 --- a/packages/utils/test/import.test.ts +++ b/packages/utils/test/import.test.ts @@ -327,6 +327,15 @@ describe('test/import.test.ts', () => { assert.deepEqual(Object.keys(obj), ['foo', 'one']); assert.equal(obj.foo, 'bar'); assert.equal(obj.one, 1); + + obj = await importModule(getFilepath('esm/es-module-default.js')); + assert.deepEqual(Object.keys(obj), ['__esModule', 'default']); + assert.deepEqual(obj.default, { foo: 'bar', one: 1 }); + + obj = await importModule(getFilepath('esm/es-module-default.js'), { + importDefaultOnly: true, + }); + assert.deepEqual(obj, { foo: 'bar', one: 1 }); }); it('should work on tshy without dist', async () => {