Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support .mts .cts #1564

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions dist-raw/node-cjs-loader-utils.js
Expand Up @@ -23,6 +23,8 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) {
const tsNodeClassification = service.moduleTypeClassifier.classifyModule(normalizeSlashes(filename));
if(tsNodeClassification.moduleType === 'cjs') return;

// TODO modify to ignore package.json when file extension is ESM-only

// Function require shouldn't be used in ES modules.
if (tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) {
const parentPath = module.parent && module.parent.filename;
Expand Down
14 changes: 11 additions & 3 deletions dist-raw/node-esm-resolve-implementation.js
Expand Up @@ -330,11 +330,19 @@ function resolveExtensions(search) {
* TS's resolver can resolve foo.js to foo.ts, by replacing .js extension with several source extensions.
* IMPORTANT: preserve ordering according to preferTsExts; this affects resolution behavior!
*/
const replacementExtensions = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext));
const replacementExtensionsForJs = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext));
const replacementExtensionsForMjs = extensions.filter(ext => ['.mjs', '.mts'].includes(ext));
const replacementExtensionsForCjs = extensions.filter(ext => ['.cjs', '.cts'].includes(ext));

function resolveReplacementExtensions(search) {
if (search.pathname.match(/\.js$/)) {
const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3);
const lastDotIndex = search.pathname.lastIndexOf('.');
const ext = search.pathname.slice(lastDotIndex);
if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex);
const replacementExtensions =
ext === '.js' ? replacementExtensionsForJs
: ext === '.mjs' ? replacementExtensionsForMjs
: replacementExtensionsForCjs;
for (let i = 0; i < replacementExtensions.length; i++) {
const extension = replacementExtensions[i];
const guess = new URL(search.toString());
Expand Down
23 changes: 18 additions & 5 deletions src/esm.ts
Expand Up @@ -96,7 +96,7 @@ export function createEsmHooks(tsNodeService: Service) {

// Custom implementation that considers additional file extensions and automatically adds file extensions
const nodeResolveImplementation = createResolve({
...getExtensions(tsNodeService.config),
...tsNodeService.extensions,
preferTsExts: tsNodeService.options.preferTsExts,
});

Expand All @@ -112,7 +112,6 @@ export function createEsmHooks(tsNodeService: Service) {
const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI
? { resolve, load, getFormat: undefined, transformSource: undefined }
: { resolve, getFormat, transformSource, load: undefined };
return hooksAPI;

function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
Expand Down Expand Up @@ -205,6 +204,16 @@ export function createEsmHooks(tsNodeService: Service) {
return { format, source };
}

// Mapping from extensions understood by tsc to the equivalent for node,
// as far as getFormat is concerned.
const nodeEquivalentExtensions = new Map<string, string>([
['.ts', '.js'],
['.tsx', '.js'],
['.jsx', '.js'],
['.mts', '.mjs'],
['.cts', '.cjs']
]);

async function getFormat(
url: string,
context: {},
Expand All @@ -227,11 +236,13 @@ export function createEsmHooks(tsNodeService: Service) {

const nativePath = fileURLToPath(url);

// If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js
// If file has extension not understood by node, then ask node how it would treat the emitted extension.
// E.g. .mts compiles to .mjs, so ask node how to classify an .mjs file.
const ext = extname(nativePath);
let nodeSays: { format: NodeLoaderHooksFormat };
if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
const nodeEquivalentExt = nodeEquivalentExtensions.get(ext);
if (nodeEquivalentExt && !tsNodeService.ignored(nativePath)) {
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt)));
} else {
nodeSays = await defer();
}
Expand Down Expand Up @@ -283,4 +294,6 @@ export function createEsmHooks(tsNodeService: Service) {

return { source: emittedJs };
}

return hooksAPI;
}
42 changes: 35 additions & 7 deletions src/index.ts
Expand Up @@ -479,6 +479,8 @@ export interface Service {
installSourceMapSupport(): void;
/** @internal */
enableExperimentalEsmLoaderInterop(): void;
/** @internal */
extensions: Extensions;
}

/**
Expand All @@ -499,14 +501,24 @@ export interface DiagnosticFilter {
}

/** @internal */
export function getExtensions(config: _ts.ParsedCommandLine) {
export type Extensions = ReturnType<typeof getExtensions>;

/** @internal */
export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) {
// TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions
const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0');
const tsExtensions = ['.ts'];
const jsExtensions = [];

if(tsSupportsMtsCtsExts) tsExtensions.push('.mts', '.cts');

// Enable additional extensions when JSX or `allowJs` is enabled.
if (config.options.jsx) tsExtensions.push('.tsx');
if (config.options.allowJs) jsExtensions.push('.js');
if (config.options.jsx && config.options.allowJs) jsExtensions.push('.jsx');
if (config.options.allowJs) {
jsExtensions.push('.js', '.mjs', '.cjs');
if (config.options.jsx) jsExtensions.push('.jsx');
if (tsSupportsMtsCtsExts) tsExtensions.push('.mjs', '.cjs');
}
return { tsExtensions, jsExtensions };
}

Expand All @@ -529,7 +541,7 @@ export function register(
}

const originalJsHandler = require.extensions['.js'];
const { tsExtensions, jsExtensions } = getExtensions(service.config);
const { tsExtensions, jsExtensions } = service.extensions;
const extensions = [...tsExtensions, ...jsExtensions];

// Expose registered instance globally.
Expand Down Expand Up @@ -1296,7 +1308,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
let active = true;
const enabled = (enabled?: boolean) =>
enabled === undefined ? active : (active = !!enabled);
const extensions = getExtensions(config);
const extensions = getExtensions(config, ts.version);
const ignored = (fileName: string) => {
if (!active) return true;
const ext = extname(fileName);
Expand Down Expand Up @@ -1333,6 +1345,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
addDiagnosticFilter,
installSourceMapSupport,
enableExperimentalEsmLoaderInterop,
extensions,
};
}

Expand Down Expand Up @@ -1368,14 +1381,29 @@ function registerExtensions(
service: Service,
originalJsHandler: (m: NodeModule, filename: string) => any
) {
const exts = new Set(extensions);
// Only way to transform .mts and .cts is via the .js extension.
// Can't register those extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs
if(exts.has('.mts') || exts.has('.cts')) exts.add('.js');
// Filter extensions which should not be added to `require.extensions`
// They may still be handled via the `.js` extension handler.
exts.delete('.mts');
exts.delete('.cts');
exts.delete('.mjs');
exts.delete('.cjs');

// TODO do we care about overriding moduleType for mjs? No, I don't think so.
// Could conditionally register `.mjs` extension when moduleType overrides are configured,
// since that is the only situation where we want to avoid node throwing an error.

// Register new extensions.
for (const ext of extensions) {
for (const ext of exts) {
registerExtension(ext, service, originalJsHandler);
}

if (preferTsExts) {
const preferredExtensions = new Set([
...extensions,
...exts,
...Object.keys(require.extensions),
]);

Expand Down
24 changes: 24 additions & 0 deletions src/test/index.spec.ts
Expand Up @@ -183,6 +183,30 @@ test.suite('ts-node', (test) => {
expect(err).toBe(null);
expect(stdout).toBe('hello world\n');
});

test('should support cts when module = CommonJS', async () => {
const { err, stdout } = await exec(
[
CMD_TS_NODE_WITH_PROJECT_FLAG,
'-O "{\\"module\\":"CommonJS"}"',
'-pe "import { main } from \'./ts45-ext/ext-cts/index\';main()"',
].join(' ')
);
expect(err).toBe(null);
expect(stdout).toBe('hello world\n');
});

test('should support cts when module = ESNext', async () => {
const { err, stdout } = await exec(
[
CMD_TS_NODE_WITH_PROJECT_FLAG,
'-O "{\\"module\\":"ESNext"}"',
'-pe "import { main } from \'./ts45-ext/ext-mts/index\';main()"',
].join(' ')
);
expect(err).toBe(null);
expect(stdout).toBe('hello world\n');
});
}

test('should eval code', async () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/ts45-ext/ext-cts/index.cts
@@ -0,0 +1,3 @@
export function main() {
return 'hello world';
}
5 changes: 5 additions & 0 deletions tests/ts45-ext/ext-cts/tsconfig.json
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"module": "CommonJS"
}
}
3 changes: 3 additions & 0 deletions tests/ts45-ext/ext-mts/index.mts
@@ -0,0 +1,3 @@
export function main() {
return 'hello world';
}
5 changes: 5 additions & 0 deletions tests/ts45-ext/ext-mts/tsconfig.json
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"module": "ESNext"
}
}