diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index aa1411135c..45cdeb199b 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -10,6 +10,30 @@ import { ImportResolveError } from './error/index.ts'; const debug = debuglog('egg/utils/import'); +type NativeDynamicImport = (specifier: string) => Promise; + +let nativeDynamicImport: NativeDynamicImport | undefined; + +/* v8 ignore next -- covered by the spawned Node fixture; Vitest cannot instrument this opaque import. */ +function getNativeDynamicImport(): NativeDynamicImport { + if (!nativeDynamicImport) { + // Keep the fallback dynamic import opaque to bundlers. Turbopack rewrites + // dynamic import expressions with non-static specifiers, which breaks the + // bundled runtime when the bundle map intentionally falls through to Node. + try { + // oxlint-disable-next-line typescript-eslint/no-implied-eval + nativeDynamicImport = new Function('specifier', 'return import(specifier);') as NativeDynamicImport; + } catch (err) { + const error = new Error( + 'Native dynamic import fallback for bundled module loader misses requires code generation from strings.', + ); + (error as Error & { cause?: unknown }).cause = err; + throw error; + } + } + return nativeDynamicImport; +} + export interface ImportResolveOptions { paths?: string[]; } @@ -454,7 +478,12 @@ export async function importModule(filepath: string, options?: ImportModuleOptio // esm const fileUrl = pathToFileURL(moduleFilePath).toString(); debug('[importModule:start] await import fileUrl: %s, isESM: %s', fileUrl, isESM); - obj = await import(fileUrl); + /* v8 ignore if -- covered by the spawned Node fixture; Vitest cannot instrument this opaque import. */ + if (_bundleModuleLoader) { + obj = await getNativeDynamicImport()(fileUrl); + } else { + obj = await import(fileUrl); + } debug('[importModule:success] await import %o', fileUrl); // { // default: { foo: 'bar', one: 1 }, diff --git a/packages/utils/test/bundle-import.test.ts b/packages/utils/test/bundle-import.test.ts index 8ce53344a3..bca31a3c3d 100644 --- a/packages/utils/test/bundle-import.test.ts +++ b/packages/utils/test/bundle-import.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; import path from 'node:path'; +import coffee from 'coffee'; import { afterEach, describe, it } from 'vitest'; import { importModule, setBundleModuleLoader } from '../src/import.ts'; @@ -62,12 +64,58 @@ describe('test/bundle-import.test.ts', () => { assert.deepEqual(result.default, { fn: 'bundled' }); }); - it('falls through to normal import when loader returns undefined', async () => { - setBundleModuleLoader(() => undefined); + it('falls through to native dynamic import when loader returns undefined', async () => { + await coffee + .spawn(process.execPath, ['--experimental-strip-types', getFilepath('bundle-native-fallback/run.mjs')]) + .expect('stdout', /bar/) + .expect('code', 0) + .end(); + }); - const result = await importModule(getFilepath('esm')); - assert.ok(result); - assert.equal(result.default.foo, 'bar'); + it('does not create the native dynamic import helper at module load time', async () => { + const importUrl = new URL('../src/import.ts', import.meta.url).href; + + await coffee + .spawn(process.execPath, [ + '--experimental-strip-types', + '--disallow-code-generation-from-strings', + '--input-type=module', + '--eval', + `await import(${JSON.stringify(importUrl)});`, + ]) + .expect('code', 0) + .end(); + }); + + it('reports hardened runtime failures when bundled fallback needs code generation', async () => { + const importUrl = new URL('../src/import.ts', import.meta.url).href; + const esmFilepath = getFilepath('esm'); + + await coffee + .spawn(process.execPath, [ + '--experimental-strip-types', + '--disallow-code-generation-from-strings', + '--input-type=module', + '--eval', + [ + `const { importModule, setBundleModuleLoader } = await import(${JSON.stringify(importUrl)});`, + 'setBundleModuleLoader(() => undefined);', + `await importModule(${JSON.stringify(esmFilepath)});`, + ].join('\n'), + ]) + .expect('stderr', /Native dynamic import fallback for bundled module loader misses requires code generation/) + .expect('code', 1) + .end(); + }); + + it('hides dynamic import fallback from bundled expression transforms', async () => { + const source = await fs.readFile(new URL('../src/import.ts', import.meta.url), 'utf8'); + + assert.match(source, /new Function\('specifier', 'return import\(specifier\);'\)/); + assert.match( + source, + /\/\* v8 ignore if[^\n]*\*\/\r?\n\s+if \(_bundleModuleLoader\) \{\r?\n\s+obj = await getNativeDynamicImport\(\)\(fileUrl\);/, + ); }); it('serves virtual specifiers from the loader without requiring them on disk', async () => { diff --git a/packages/utils/test/fixtures/bundle-native-fallback/run.mjs b/packages/utils/test/fixtures/bundle-native-fallback/run.mjs new file mode 100644 index 0000000000..a1278a804a --- /dev/null +++ b/packages/utils/test/fixtures/bundle-native-fallback/run.mjs @@ -0,0 +1,20 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { importModule, setBundleModuleLoader } from '../../../src/import.ts'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const seen = []; + +setBundleModuleLoader((filepath) => { + seen.push(filepath); + return undefined; +}); + +const result = await importModule(path.resolve(dirname, '../esm')); + +if (!seen.some((filepath) => filepath.endsWith('/fixtures/esm'))) { + throw new Error(`bundle loader miss was not observed: ${JSON.stringify(seen)}`); +} + +console.log(result.default.foo);