diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index 8a6e036e21..a2437561fb 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -394,7 +394,35 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void { isESM = false; } +/** + * Module loader for bundled egg apps. Called with the raw `importModule()` + * filepath (posix-normalized) before `importResolve`, so bundled apps can + * serve modules that no longer exist on disk. Return `undefined` to fall + * through to the standard import path. + */ +export type BundleModuleLoader = (filepath: string) => unknown; + +/** + * Register a bundle module loader. Uses globalThis so that bundled and + * external copies of @eggjs/utils share the same loader. + */ +export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void { + (globalThis as any).__EGG_BUNDLE_MODULE_LOADER__ = loader; + if (loader) isESM = false; +} + export async function importModule(filepath: string, options?: ImportModuleOptions): Promise { + const _bundleModuleLoader: BundleModuleLoader | undefined = (globalThis as any).__EGG_BUNDLE_MODULE_LOADER__; + if (_bundleModuleLoader) { + const hit = _bundleModuleLoader(filepath.replaceAll('\\', '/')); + 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..88b91efdbe --- /dev/null +++ b/packages/utils/test/bundle-import.test.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; + +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('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); + }); + + it('short-circuits importResolve so bundled paths need not exist 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); + }); +});