Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions lib/compiler/plugins/plugin-metadata-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<[ts.CallExpression, DeepPluginMeta]>>
>,
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.
Expand Down Expand Up @@ -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,
Expand Down
203 changes: 203 additions & 0 deletions test/lib/compiler/plugins/plugin-metadata-generator.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>]>
>
> = {
'@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")');
});
});
});