diff --git a/lib/compiler/plugins/plugin-metadata-generator.ts b/lib/compiler/plugins/plugin-metadata-generator.ts index 156f843da..7b5c4684a 100644 --- a/lib/compiler/plugins/plugin-metadata-generator.ts +++ b/lib/compiler/plugins/plugin-metadata-generator.ts @@ -8,6 +8,127 @@ import { TypeCheckerHost } from '../swc/type-checker-host'; import { TypeScriptBinaryLoader } from '../typescript-loader'; import { PluginMetadataPrinter } from './plugin-metadata-printer'; +/** + * Returns `true` when the consuming project uses an ESM-style module + * resolution strategy (`node16` / `nodenext`). Under those resolution + * modes, dynamic `import()` specifiers MUST include the file extension + * (typically `.js`) for the runtime resolver to find the module. Without + * the extension, executing the generated metadata file fails with + * `ERR_MODULE_NOT_FOUND`, and TypeScript reports a diagnostic. + */ +export function requiresExplicitImportExtensions( + options: ts.CompilerOptions, + tsBinary: typeof ts, +): boolean { + const moduleResolution = options.moduleResolution; + return ( + moduleResolution === tsBinary.ModuleResolutionKind.Node16 || + moduleResolution === tsBinary.ModuleResolutionKind.NodeNext + ); +} + +const RELATIVE_PATH_RE = /^\.\.?\//; +// Any common JS/TS-style extension that the user could have authored or +// that our rewrite would have already produced. Prevents double-appending. +const HAS_KNOWN_EXTENSION_RE = /\.(m?js|c?js|m?ts|c?ts|json|node)$/i; + +/** + * Returns the same import path with `.js` appended when (and only when) + * the path is relative and does not already end in a recognized + * extension. Bare specifiers (e.g. `@nestjs/common`) and absolute paths + * are returned unchanged because the caller's resolver handles them. + */ +export function appendJsExtensionIfMissing(importPath: string): string { + if (!RELATIVE_PATH_RE.test(importPath)) { + return importPath; + } + if (HAS_KNOWN_EXTENSION_RE.test(importPath)) { + return importPath; + } + return `${importPath}.js`; +} + +/** + * Rewrites a single `await import("...")`-style string by appending the + * `.js` extension to the inner specifier when it is a relative path + * without an extension. Used to patch the visitor-supplied `typeImports` + * map values. + */ +export function rewriteAsyncImportString(target: string): string { + return target.replace( + /import\((['"])((?:\\\1|(?!\1).)*)\1\)/g, + (match, quote, specifier) => + `import(${quote}${appendJsExtensionIfMissing(specifier)}${quote})`, + ); +} + +/** + * Walks the given `ts.CallExpression` tree and rewrites every dynamic + * `import("...")` whose specifier is a relative path missing an + * extension. Returns a new node when changes are required, or the input + * node unchanged otherwise. + */ +export function rewriteImportExpressionForNodeNext( + expression: ts.CallExpression, + tsBinary: typeof ts, +): ts.CallExpression { + const visit = (node: ts.Node): ts.Node => { + if ( + tsBinary.isCallExpression(node) && + node.expression.kind === tsBinary.SyntaxKind.ImportKeyword && + node.arguments.length > 0 && + tsBinary.isStringLiteralLike(node.arguments[0]) + ) { + const original = (node.arguments[0] as ts.StringLiteralLike).text; + const rewritten = appendJsExtensionIfMissing(original); + if (rewritten !== original) { + const updatedArgs = [ + tsBinary.factory.createStringLiteral(rewritten), + ...node.arguments.slice(1), + ]; + return tsBinary.factory.updateCallExpression( + node, + node.expression, + node.typeArguments, + updatedArgs, + ); + } + } + return tsBinary.visitEachChild(node, visit, undefined as any); + }; + return visit(expression) as ts.CallExpression; +} + +/** + * Recursively walks the collected plugin metadata, rewriting every + * dynamic `import("./relative")` call expression to include the `.js` + * extension required by node16 / nodenext module resolution. + */ +export function rewriteCollectedMetadataForNodeNext( + metadata: Record< + string, + Record> + >, + tsBinary: typeof ts, +): void { + for (const visitorKey of Object.keys(metadata)) { + const sections = metadata[visitorKey]; + for (const sectionKey of Object.keys(sections)) { + const tuples = sections[sectionKey]; + if (!Array.isArray(tuples)) { + continue; + } + for (let i = 0; i < tuples.length; i++) { + const [importExpr, meta] = tuples[i]; + tuples[i] = [ + rewriteImportExpressionForNodeNext(importExpr, tsBinary), + meta, + ]; + } + } + } +} + export interface PluginMetadataGenerateOptions { /** * The visitors to use to generate the metadata. @@ -150,6 +271,26 @@ export class PluginMetadataGenerator { ...visitor.typeImports, }; }); + + // Under `node16` / `nodenext` module resolution, dynamic `import()` + // specifiers must include explicit file extensions. The visitors emit + // bare relative specifiers (e.g. `import("./hello.dto")`), which are + // valid under classic / node10 resolution but break compilation and + // runtime under nodenext. When the consuming project uses an ESM-style + // resolver, rewrite both the metadata import call expressions and the + // typeImports map values to include the `.js` extension. See #3364. + if ( + requiresExplicitImportExtensions( + programRef.getCompilerOptions(), + this.tsBinary, + ) + ) { + rewriteCollectedMetadataForNodeNext(collectedMetadata, this.tsBinary); + for (const key of Object.keys(typeImports)) { + typeImports[key] = rewriteAsyncImportString(typeImports[key]); + } + } + this.pluginMetadataPrinter.print( collectedMetadata, typeImports, diff --git a/test/lib/compiler/plugins/plugin-metadata-generator.spec.ts b/test/lib/compiler/plugins/plugin-metadata-generator.spec.ts new file mode 100644 index 000000000..6857accb2 --- /dev/null +++ b/test/lib/compiler/plugins/plugin-metadata-generator.spec.ts @@ -0,0 +1,203 @@ +import * as ts from 'typescript'; +import { + appendJsExtensionIfMissing, + requiresExplicitImportExtensions, + rewriteAsyncImportString, + rewriteCollectedMetadataForNodeNext, + rewriteImportExpressionForNodeNext, +} from '../../../../lib/compiler/plugins/plugin-metadata-generator'; + +function createImportCall(specifier: string): ts.CallExpression { + return ts.factory.createCallExpression( + ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as unknown as ts.Expression, + undefined, + [ts.factory.createStringLiteral(specifier)], + ); +} + +function printNode(node: ts.Node): string { + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const sourceFile = ts.createSourceFile( + 'tmp.ts', + '', + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); +} + +describe('PluginMetadataGenerator (#3364: nodenext import extensions)', () => { + describe('requiresExplicitImportExtensions', () => { + it('returns true for Node16 module resolution', () => { + expect( + requiresExplicitImportExtensions( + { moduleResolution: ts.ModuleResolutionKind.Node16 }, + ts, + ), + ).toBe(true); + }); + + it('returns true for NodeNext module resolution', () => { + expect( + requiresExplicitImportExtensions( + { moduleResolution: ts.ModuleResolutionKind.NodeNext }, + ts, + ), + ).toBe(true); + }); + + it('returns false for Node10 / classic module resolution', () => { + expect( + requiresExplicitImportExtensions( + { moduleResolution: ts.ModuleResolutionKind.Node10 }, + ts, + ), + ).toBe(false); + expect( + requiresExplicitImportExtensions( + { moduleResolution: ts.ModuleResolutionKind.Classic }, + ts, + ), + ).toBe(false); + }); + + it('returns false when moduleResolution is undefined', () => { + expect(requiresExplicitImportExtensions({}, ts)).toBe(false); + }); + + it('returns false for the Bundler resolver (extensions not required)', () => { + expect( + requiresExplicitImportExtensions( + { moduleResolution: ts.ModuleResolutionKind.Bundler }, + ts, + ), + ).toBe(false); + }); + }); + + describe('appendJsExtensionIfMissing', () => { + it('appends `.js` to relative paths without an extension', () => { + expect(appendJsExtensionIfMissing('./hello.dto')).toBe('./hello.dto.js'); + expect(appendJsExtensionIfMissing('../shared/user')).toBe( + '../shared/user.js', + ); + }); + + it('leaves bare specifiers unchanged', () => { + expect(appendJsExtensionIfMissing('@nestjs/common')).toBe( + '@nestjs/common', + ); + expect(appendJsExtensionIfMissing('rxjs')).toBe('rxjs'); + }); + + it('does not double-append when an extension is already present', () => { + expect(appendJsExtensionIfMissing('./hello.dto.js')).toBe( + './hello.dto.js', + ); + expect(appendJsExtensionIfMissing('./hello.dto.mjs')).toBe( + './hello.dto.mjs', + ); + expect(appendJsExtensionIfMissing('./hello.dto.cjs')).toBe( + './hello.dto.cjs', + ); + expect(appendJsExtensionIfMissing('./data.json')).toBe('./data.json'); + }); + + it('leaves absolute paths unchanged', () => { + expect(appendJsExtensionIfMissing('/usr/lib/foo')).toBe('/usr/lib/foo'); + }); + }); + + describe('rewriteAsyncImportString', () => { + it('rewrites a single dynamic import inside an `await import(...)` string', () => { + expect(rewriteAsyncImportString('await import("./hello.dto")')).toBe( + 'await import("./hello.dto.js")', + ); + }); + + it('rewrites the dynamic import in a `(await import(...)).Foo` expression', () => { + expect( + rewriteAsyncImportString('(await import("./user.dto")).UserDto'), + ).toBe('(await import("./user.dto.js")).UserDto'); + }); + + it('leaves bare-specifier dynamic imports alone', () => { + expect( + rewriteAsyncImportString('await import("@nestjs/common")'), + ).toBe('await import("@nestjs/common")'); + }); + + it('does not re-append the extension on a previously-rewritten string', () => { + const once = rewriteAsyncImportString('await import("./hello.dto")'); + const twice = rewriteAsyncImportString(once); + expect(twice).toBe('await import("./hello.dto.js")'); + }); + + it('handles single-quoted import specifiers', () => { + expect(rewriteAsyncImportString("await import('./foo')")).toBe( + "await import('./foo.js')", + ); + }); + }); + + describe('rewriteImportExpressionForNodeNext', () => { + it('rewrites a top-level `import("./relative")` call expression', () => { + const call = createImportCall('./hello.dto'); + const rewritten = rewriteImportExpressionForNodeNext(call, ts); + expect(printNode(rewritten)).toBe('import("./hello.dto.js")'); + }); + + it('leaves bare-specifier `import("rxjs")` calls unchanged', () => { + const call = createImportCall('rxjs'); + const rewritten = rewriteImportExpressionForNodeNext(call, ts); + expect(printNode(rewritten)).toBe('import("rxjs")'); + }); + + it('does not modify a call that already includes a `.js` extension', () => { + const call = createImportCall('./foo.js'); + const rewritten = rewriteImportExpressionForNodeNext(call, ts); + expect(printNode(rewritten)).toBe('import("./foo.js")'); + }); + }); + + describe('rewriteCollectedMetadataForNodeNext', () => { + it('rewrites import call expressions across the entire metadata tree', () => { + const fakeObjectLiteral = ts.factory.createObjectLiteralExpression([]); + const metadata: Record< + string, + Record< + string, + Array<[ts.CallExpression, Record]> + > + > = { + '@nestjs/swagger': { + models: [ + [createImportCall('./hello.dto'), {}], + [createImportCall('../shared/user.dto'), {}], + [createImportCall('@nestjs/common'), {}], + ], + controllers: [[createImportCall('./app.controller'), {}]], + }, + }; + + // Use the fake object literal somewhere so TS doesn't drop it; not used + // by the rewriter but mirrors the real-world metadata shape. + void fakeObjectLiteral; + + rewriteCollectedMetadataForNodeNext(metadata as any, ts); + + const printed = metadata['@nestjs/swagger'].models.map(([imp]) => + printNode(imp), + ); + expect(printed).toEqual([ + 'import("./hello.dto.js")', + 'import("../shared/user.dto.js")', + 'import("@nestjs/common")', + ]); + expect( + printNode(metadata['@nestjs/swagger'].controllers[0][0]), + ).toBe('import("./app.controller.js")'); + }); + }); +});