diff --git a/src/cjs/api/register.ts b/src/cjs/api/register.ts index 8c08646b4..e4d30e7d7 100644 --- a/src/cjs/api/register.ts +++ b/src/cjs/api/register.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { loadTsconfig } from '../../utils/tsconfig.js'; import type { RequiredProperty } from '../../types.js'; import { urlSearchParamsStringify } from '../../utils/url-search-params-stringify.js'; +import { fileUrlPrefix } from '../../utils/path-utils.js'; import type { LoaderState } from './types.js'; import { createExtensions } from './module-extensions.js'; import { createResolveFilename } from './module-resolve-filename.js'; @@ -22,7 +23,7 @@ const resolveContext = ( } if ( - (typeof fromFile === 'string' && fromFile.startsWith('file://')) + (typeof fromFile === 'string' && fromFile.startsWith(fileUrlPrefix)) || fromFile instanceof URL ) { fromFile = fileURLToPath(fromFile); diff --git a/src/esm/api/scoped-import.ts b/src/esm/api/scoped-import.ts index efe205cf5..6482c9f77 100644 --- a/src/esm/api/scoped-import.ts +++ b/src/esm/api/scoped-import.ts @@ -1,44 +1,33 @@ import { pathToFileURL } from 'node:url'; - -const resolveSpecifier = ( - specifier: string, - fromFile: string, - namespace: string, -) => { - const base = ( - fromFile.startsWith('file://') - ? fromFile - : pathToFileURL(fromFile) - ); - const resolvedUrl = new URL(specifier, base); - - /** - * A namespace query is added so we get our own module cache - * - * I considered using an import attribute for this, but it doesn't seem to - * make the request unique so it gets cached. - */ - resolvedUrl.searchParams.set('tsx-namespace', namespace); - - return resolvedUrl.toString(); -}; +import type { TsxRequest } from '../types.js'; +import { fileUrlPrefix } from '../../utils/path-utils.js'; export type ScopedImport = ( specifier: string, - parentURL: string, + parent: string, ) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any export const createScopedImport = ( namespace: string, ): ScopedImport => ( specifier, - parentURL, + parent, ) => { - if (!parentURL) { + if (!parent) { throw new Error('The current file path (import.meta.url) must be provided in the second argument of tsImport()'); } + const parentURL = ( + parent.startsWith(fileUrlPrefix) + ? parent + : pathToFileURL(parent).toString() + ); + return import( - resolveSpecifier(specifier, parentURL, namespace) + `tsx://${JSON.stringify({ + specifier, + parentURL, + namespace, + } satisfies TsxRequest)}` ); }; diff --git a/src/esm/api/ts-import.ts b/src/esm/api/ts-import.ts index 02268f4c7..255cd02bf 100644 --- a/src/esm/api/ts-import.ts +++ b/src/esm/api/ts-import.ts @@ -1,4 +1,6 @@ import { register as cjsRegister } from '../../cjs/api/index.js'; +import { isFeatureSupported, esmLoadReadFile } from '../../utils/node-features.js'; +import { isBarePackageNamePattern, cjsExtensionPattern } from '../../utils/path-utils.js'; import { register, type TsconfigOptions } from './register.js'; type Options = { @@ -6,6 +8,7 @@ type Options = { onImport?: (url: string) => void; tsconfig?: TsconfigOptions; }; + const tsImport = ( specifier: string, options: string | Options, @@ -22,10 +25,26 @@ const tsImport = ( const namespace = Date.now().toString(); // Keep registered for hanging require() calls - cjsRegister({ + const cjs = cjsRegister({ namespace, }); + /** + * In Node v18, the loader doesn't support reading the CommonJS from + * a data URL, so it can't actually relay the namespace. This is a workaround + * to preemptively determine whether the file is a CommonJS file, and shortcut + * to using the CommonJS loader instead of going through the ESM loader first + */ + if ( + !isFeatureSupported(esmLoadReadFile) + && ( + !isBarePackageNamePattern.test(specifier) + && cjsExtensionPattern.test(specifier) + ) + ) { + return Promise.resolve(cjs.require(specifier, parentURL)); + } + /** * We don't want to unregister this after load since there can be child import() calls * that need TS support diff --git a/src/esm/hook/load.ts b/src/esm/hook/load.ts index de7b46ccc..0df3a6044 100644 --- a/src/esm/hook/load.ts +++ b/src/esm/hook/load.ts @@ -9,7 +9,7 @@ import { isFeatureSupported, importAttributes, esmLoadReadFile } from '../../uti import { parent } from '../../utils/ipc/client.js'; import type { Message } from '../types.js'; import { fileMatcher } from '../../utils/tsconfig.js'; -import { isJsonPattern, tsExtensionsPattern } from '../../utils/path-utils.js'; +import { isJsonPattern, tsExtensionsPattern, fileUrlPrefix } from '../../utils/path-utils.js'; import { isESM } from '../../utils/es-module-lexer.js'; import { getNamespace } from './utils.js'; import { data } from './initialize.js'; @@ -63,7 +63,7 @@ export const load: LoadHook = async ( } const loaded = await nextLoad(url, context); - const filePath = url.startsWith('file://') ? fileURLToPath(url) : url; + const filePath = url.startsWith(fileUrlPrefix) ? fileURLToPath(url) : url; if ( loaded.format === 'commonjs' diff --git a/src/esm/hook/resolve.ts b/src/esm/hook/resolve.ts index 0bf02f4f2..2470ce3ff 100644 --- a/src/esm/hook/resolve.ts +++ b/src/esm/hook/resolve.ts @@ -16,6 +16,7 @@ import { isDirectoryPattern, isBarePackageNamePattern, } from '../../utils/path-utils.js'; +import type { TsxRequest } from '../types.js'; import { getFormatFromFileUrl, namespaceQuery, @@ -205,6 +206,8 @@ const resolveTsPaths: ResolveHook = async ( return resolveDirectory(specifier, context, nextResolve); }; +const tsxProtocol = 'tsx://'; + export const resolve: ResolveHook = async ( specifier, context, @@ -214,13 +217,33 @@ export const resolve: ResolveHook = async ( return nextResolve(specifier, context); } - const requestNamespace = getNamespace(specifier) ?? ( + let requestNamespace = getNamespace(specifier) ?? ( // Inherit namespace from parent context.parentURL && getNamespace(context.parentURL) ); - if (data.namespace && data.namespace !== requestNamespace) { - return nextResolve(specifier, context); + if (data.namespace) { + let tsImportRequest: TsxRequest | undefined; + + // Initial request from tsImport() + if (specifier.startsWith(tsxProtocol)) { + try { + tsImportRequest = JSON.parse(specifier.slice(tsxProtocol.length)); + } catch {} + + if (tsImportRequest?.namespace) { + requestNamespace = tsImportRequest.namespace; + } + } + + if (data.namespace !== requestNamespace) { + return nextResolve(specifier, context); + } + + if (tsImportRequest) { + specifier = tsImportRequest.specifier; + context.parentURL = tsImportRequest.parentURL; + } } const [cleanSpecifier, query] = specifier.split('?'); diff --git a/src/esm/types.ts b/src/esm/types.ts index ee1372704..e97ee7b0c 100644 --- a/src/esm/types.ts +++ b/src/esm/types.ts @@ -4,3 +4,9 @@ export type Message = { type: 'load'; url: string; }; + +export type TsxRequest = { + namespace: string; + parentURL: string; + specifier: string; +}; diff --git a/src/utils/path-utils.ts b/src/utils/path-utils.ts index 055c5e130..373c8dfd1 100644 --- a/src/utils/path-utils.ts +++ b/src/utils/path-utils.ts @@ -48,6 +48,8 @@ export const fileUrlPrefix = 'file://'; export const tsExtensionsPattern = /\.([cm]?ts|[tj]sx)($|\?)/; +export const cjsExtensionPattern = /[/\\].+\.(?:cts|cjs)(?:$|\?)/; + export const isJsonPattern = /\.json($|\?)/; export const isDirectoryPattern = /\/(?:$|\?)/; diff --git a/tests/specs/api.ts b/tests/specs/api.ts index 23b000a5e..8a6d0d17a 100644 --- a/tests/specs/api.ts +++ b/tests/specs/api.ts @@ -50,13 +50,23 @@ const tsFiles = { 'bar.ts': 'export type A = 1; export { bar } from "pkg"', 'async.ts': 'export default "async"', 'json.json': JSON.stringify({ json: 'json' }), - 'node_modules/pkg': { - 'package.json': createPackageJson({ - name: 'pkg', - type: 'module', - exports: './pkg.js', - }), - 'pkg.js': 'import "node:process"; export const bar = "bar";', + node_modules: { + pkg: { + 'package.json': createPackageJson({ + name: 'pkg', + type: 'module', + exports: './pkg.js', + }), + 'pkg.js': 'import "node:process"; export const bar = "bar";', + }, + '@a/b.cjs': { + 'package.json': createPackageJson({ + name: '@a/b.cjs', + type: 'module', + exports: './pkg.js', + }), + 'pkg.js': 'import "node:process"; export const bar = "bar";', + }, }, 'tsconfig.json': createTsconfig({ compilerOptions: { @@ -662,8 +672,13 @@ export default testSuite(({ describe }, node: NodeApis) => { // Loads cts vis CJS namespace even if there are no exports await tsImport('./cjs/exports-no.cts', import.meta.url).catch((error) => console.log(error.constructor.name)) - const cjsExport = await tsImport('./cjs/exports-yes.cts', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); - console.log(cjsExport); + const cts = await tsImport('./cjs/exports-yes.cts', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); + console.log(cts); + + const cjs = await tsImport('./cjs/reexport.cjs?query', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); + console.log(cjs); + + await tsImport('@a/b.cjs', import.meta.url); const { message: message2 } = await tsImport('./file.ts?with-query', import.meta.url); console.log(message2); @@ -682,11 +697,15 @@ export default testSuite(({ describe }, node: NodeApis) => { nodeOptions: [], }); - if (node.supports.cjsInterop) { - expect(stdout).toMatch(/Fails as expected 1\nfoo bar json file\.ts\?tsx-namespace=\d+\ncts loaded\ncjsReexport esm syntax\nfoo bar json file\.ts\?with-query=&tsx-namespace=\d+\nFails as expected 2/); - } else { - expect(stdout).toMatch(/Fails as expected 1\nfoo bar json file\.ts\?tsx-namespace=\d+\nSyntaxError\nSyntaxError\nfoo bar json file\.ts\?with-query=&tsx-namespace=\d+\nFails as expected 2/); - } + expect(stdout).toMatch(new RegExp([ + 'Fails as expected 1', + String.raw`foo bar json file\.ts\?tsx-namespace=\d+`, + 'cts loaded', + 'cjsReexport esm syntax', + 'cjsReexport esm syntax', + String.raw`foo bar json file\.ts\?with-query&tsx-namespace=\d+`, + 'Fails as expected 2', + ].join(String.raw`\n`))); }); test('commonjs', async () => { @@ -706,12 +725,14 @@ export default testSuite(({ describe }, node: NodeApis) => { const { message: message2 } = await tsImport('./file.ts?with-query', __filename); console.log(message2); - const cts = await tsImport('./cjs/exports-yes.cts', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); + const cts = await tsImport('./cjs/exports-yes.cts?query', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); console.log(cts); - const cjs = await tsImport('./cjs/reexport.cjs', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); + const cjs = await tsImport('./cjs/reexport.cjs?query', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name); console.log(cjs); + await tsImport('@a/b.cjs', __filename); + // Global not polluted await import('./file.ts?nocache').catch((error) => { console.log('Fails as expected 2'); @@ -725,25 +746,15 @@ export default testSuite(({ describe }, node: NodeApis) => { nodePath: node.path, nodeOptions: [], }); - if (node.supports.cjsInterop) { - expect(stdout).toMatch(new RegExp( - `${String.raw`Fails as expected 1\n` - + String.raw`foo bar json file\.ts\?tsx-namespace=\d+\n` - + String.raw`foo bar json file\.ts\?with-query=&tsx-namespace=\d+\n` - + String.raw`cjsReexport esm syntax\n` - + String.raw`cjsReexport esm syntax\n` - }Fails as expected 2`, - )); - } else { - expect(stdout).toMatch(new RegExp( - `${String.raw`Fails as expected 1\n` - + String.raw`foo bar json file\.ts\?tsx-namespace=\d+\n` - + String.raw`foo bar json file\.ts\?with-query=&tsx-namespace=\d+\n` - + String.raw`SyntaxError\n` - + String.raw`Error\n` - }Fails as expected 2`, - )); - } + + expect(stdout).toMatch(new RegExp([ + 'Fails as expected 1', + String.raw`foo bar json file\.ts\?tsx-namespace=\d+`, + String.raw`foo bar json file\.ts\?with-query&tsx-namespace=\d+`, + 'cjsReexport esm syntax', + 'cjsReexport esm syntax', + 'Fails as expected 2', + ].join(String.raw`\n`))); }); test('mts from commonjs', async () => {