Skip to content

Commit 042be03

Browse files
authoredJul 3, 2024··
fix: isolated cts import in Node v18 (#61)
1 parent a74aa58 commit 042be03

File tree

8 files changed

+120
-69
lines changed

8 files changed

+120
-69
lines changed
 

‎src/cjs/api/register.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
44
import { loadTsconfig } from '../../utils/tsconfig.js';
55
import type { RequiredProperty } from '../../types.js';
66
import { urlSearchParamsStringify } from '../../utils/url-search-params-stringify.js';
7+
import { fileUrlPrefix } from '../../utils/path-utils.js';
78
import type { LoaderState } from './types.js';
89
import { createExtensions } from './module-extensions.js';
910
import { createResolveFilename } from './module-resolve-filename.js';
@@ -22,7 +23,7 @@ const resolveContext = (
2223
}
2324

2425
if (
25-
(typeof fromFile === 'string' && fromFile.startsWith('file://'))
26+
(typeof fromFile === 'string' && fromFile.startsWith(fileUrlPrefix))
2627
|| fromFile instanceof URL
2728
) {
2829
fromFile = fileURLToPath(fromFile);

‎src/esm/api/scoped-import.ts

+16-27
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,33 @@
11
import { pathToFileURL } from 'node:url';
2-
3-
const resolveSpecifier = (
4-
specifier: string,
5-
fromFile: string,
6-
namespace: string,
7-
) => {
8-
const base = (
9-
fromFile.startsWith('file://')
10-
? fromFile
11-
: pathToFileURL(fromFile)
12-
);
13-
const resolvedUrl = new URL(specifier, base);
14-
15-
/**
16-
* A namespace query is added so we get our own module cache
17-
*
18-
* I considered using an import attribute for this, but it doesn't seem to
19-
* make the request unique so it gets cached.
20-
*/
21-
resolvedUrl.searchParams.set('tsx-namespace', namespace);
22-
23-
return resolvedUrl.toString();
24-
};
2+
import type { TsxRequest } from '../types.js';
3+
import { fileUrlPrefix } from '../../utils/path-utils.js';
254

265
export type ScopedImport = (
276
specifier: string,
28-
parentURL: string,
7+
parent: string,
298
) => Promise<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
309

3110
export const createScopedImport = (
3211
namespace: string,
3312
): ScopedImport => (
3413
specifier,
35-
parentURL,
14+
parent,
3615
) => {
37-
if (!parentURL) {
16+
if (!parent) {
3817
throw new Error('The current file path (import.meta.url) must be provided in the second argument of tsImport()');
3918
}
4019

20+
const parentURL = (
21+
parent.startsWith(fileUrlPrefix)
22+
? parent
23+
: pathToFileURL(parent).toString()
24+
);
25+
4126
return import(
42-
resolveSpecifier(specifier, parentURL, namespace)
27+
`tsx://${JSON.stringify({
28+
specifier,
29+
parentURL,
30+
namespace,
31+
} satisfies TsxRequest)}`
4332
);
4433
};

‎src/esm/api/ts-import.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { register as cjsRegister } from '../../cjs/api/index.js';
2+
import { isFeatureSupported, esmLoadReadFile } from '../../utils/node-features.js';
3+
import { isBarePackageNamePattern, cjsExtensionPattern } from '../../utils/path-utils.js';
24
import { register, type TsconfigOptions } from './register.js';
35

46
type Options = {
57
parentURL: string;
68
onImport?: (url: string) => void;
79
tsconfig?: TsconfigOptions;
810
};
11+
912
const tsImport = (
1013
specifier: string,
1114
options: string | Options,
@@ -22,10 +25,26 @@ const tsImport = (
2225
const namespace = Date.now().toString();
2326

2427
// Keep registered for hanging require() calls
25-
cjsRegister({
28+
const cjs = cjsRegister({
2629
namespace,
2730
});
2831

32+
/**
33+
* In Node v18, the loader doesn't support reading the CommonJS from
34+
* a data URL, so it can't actually relay the namespace. This is a workaround
35+
* to preemptively determine whether the file is a CommonJS file, and shortcut
36+
* to using the CommonJS loader instead of going through the ESM loader first
37+
*/
38+
if (
39+
!isFeatureSupported(esmLoadReadFile)
40+
&& (
41+
!isBarePackageNamePattern.test(specifier)
42+
&& cjsExtensionPattern.test(specifier)
43+
)
44+
) {
45+
return Promise.resolve(cjs.require(specifier, parentURL));
46+
}
47+
2948
/**
3049
* We don't want to unregister this after load since there can be child import() calls
3150
* that need TS support

‎src/esm/hook/load.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isFeatureSupported, importAttributes, esmLoadReadFile } from '../../uti
99
import { parent } from '../../utils/ipc/client.js';
1010
import type { Message } from '../types.js';
1111
import { fileMatcher } from '../../utils/tsconfig.js';
12-
import { isJsonPattern, tsExtensionsPattern } from '../../utils/path-utils.js';
12+
import { isJsonPattern, tsExtensionsPattern, fileUrlPrefix } from '../../utils/path-utils.js';
1313
import { isESM } from '../../utils/es-module-lexer.js';
1414
import { getNamespace } from './utils.js';
1515
import { data } from './initialize.js';
@@ -63,7 +63,7 @@ export const load: LoadHook = async (
6363
}
6464

6565
const loaded = await nextLoad(url, context);
66-
const filePath = url.startsWith('file://') ? fileURLToPath(url) : url;
66+
const filePath = url.startsWith(fileUrlPrefix) ? fileURLToPath(url) : url;
6767

6868
if (
6969
loaded.format === 'commonjs'

‎src/esm/hook/resolve.ts

+26-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
isDirectoryPattern,
1717
isBarePackageNamePattern,
1818
} from '../../utils/path-utils.js';
19+
import type { TsxRequest } from '../types.js';
1920
import {
2021
getFormatFromFileUrl,
2122
namespaceQuery,
@@ -205,6 +206,8 @@ const resolveTsPaths: ResolveHook = async (
205206
return resolveDirectory(specifier, context, nextResolve);
206207
};
207208

209+
const tsxProtocol = 'tsx://';
210+
208211
export const resolve: ResolveHook = async (
209212
specifier,
210213
context,
@@ -214,13 +217,33 @@ export const resolve: ResolveHook = async (
214217
return nextResolve(specifier, context);
215218
}
216219

217-
const requestNamespace = getNamespace(specifier) ?? (
220+
let requestNamespace = getNamespace(specifier) ?? (
218221
// Inherit namespace from parent
219222
context.parentURL && getNamespace(context.parentURL)
220223
);
221224

222-
if (data.namespace && data.namespace !== requestNamespace) {
223-
return nextResolve(specifier, context);
225+
if (data.namespace) {
226+
let tsImportRequest: TsxRequest | undefined;
227+
228+
// Initial request from tsImport()
229+
if (specifier.startsWith(tsxProtocol)) {
230+
try {
231+
tsImportRequest = JSON.parse(specifier.slice(tsxProtocol.length));
232+
} catch {}
233+
234+
if (tsImportRequest?.namespace) {
235+
requestNamespace = tsImportRequest.namespace;
236+
}
237+
}
238+
239+
if (data.namespace !== requestNamespace) {
240+
return nextResolve(specifier, context);
241+
}
242+
243+
if (tsImportRequest) {
244+
specifier = tsImportRequest.specifier;
245+
context.parentURL = tsImportRequest.parentURL;
246+
}
224247
}
225248

226249
const [cleanSpecifier, query] = specifier.split('?');

‎src/esm/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ export type Message = {
44
type: 'load';
55
url: string;
66
};
7+
8+
export type TsxRequest = {
9+
namespace: string;
10+
parentURL: string;
11+
specifier: string;
12+
};

‎src/utils/path-utils.ts

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export const fileUrlPrefix = 'file://';
4848

4949
export const tsExtensionsPattern = /\.([cm]?ts|[tj]sx)($|\?)/;
5050

51+
export const cjsExtensionPattern = /[/\\].+\.(?:cts|cjs)(?:$|\?)/;
52+
5153
export const isJsonPattern = /\.json($|\?)/;
5254

5355
export const isDirectoryPattern = /\/(?:$|\?)/;

‎tests/specs/api.ts

+46-35
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,23 @@ const tsFiles = {
5050
'bar.ts': 'export type A = 1; export { bar } from "pkg"',
5151
'async.ts': 'export default "async"',
5252
'json.json': JSON.stringify({ json: 'json' }),
53-
'node_modules/pkg': {
54-
'package.json': createPackageJson({
55-
name: 'pkg',
56-
type: 'module',
57-
exports: './pkg.js',
58-
}),
59-
'pkg.js': 'import "node:process"; export const bar = "bar";',
53+
node_modules: {
54+
pkg: {
55+
'package.json': createPackageJson({
56+
name: 'pkg',
57+
type: 'module',
58+
exports: './pkg.js',
59+
}),
60+
'pkg.js': 'import "node:process"; export const bar = "bar";',
61+
},
62+
'@a/b.cjs': {
63+
'package.json': createPackageJson({
64+
name: '@a/b.cjs',
65+
type: 'module',
66+
exports: './pkg.js',
67+
}),
68+
'pkg.js': 'import "node:process"; export const bar = "bar";',
69+
},
6070
},
6171
'tsconfig.json': createTsconfig({
6272
compilerOptions: {
@@ -662,8 +672,13 @@ export default testSuite(({ describe }, node: NodeApis) => {
662672
// Loads cts vis CJS namespace even if there are no exports
663673
await tsImport('./cjs/exports-no.cts', import.meta.url).catch((error) => console.log(error.constructor.name))
664674
665-
const cjsExport = await tsImport('./cjs/exports-yes.cts', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
666-
console.log(cjsExport);
675+
const cts = await tsImport('./cjs/exports-yes.cts', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
676+
console.log(cts);
677+
678+
const cjs = await tsImport('./cjs/reexport.cjs?query', import.meta.url).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
679+
console.log(cjs);
680+
681+
await tsImport('@a/b.cjs', import.meta.url);
667682
668683
const { message: message2 } = await tsImport('./file.ts?with-query', import.meta.url);
669684
console.log(message2);
@@ -682,11 +697,15 @@ export default testSuite(({ describe }, node: NodeApis) => {
682697
nodeOptions: [],
683698
});
684699

685-
if (node.supports.cjsInterop) {
686-
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/);
687-
} else {
688-
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/);
689-
}
700+
expect(stdout).toMatch(new RegExp([
701+
'Fails as expected 1',
702+
String.raw`foo bar json file\.ts\?tsx-namespace=\d+`,
703+
'cts loaded',
704+
'cjsReexport esm syntax',
705+
'cjsReexport esm syntax',
706+
String.raw`foo bar json file\.ts\?with-query&tsx-namespace=\d+`,
707+
'Fails as expected 2',
708+
].join(String.raw`\n`)));
690709
});
691710

692711
test('commonjs', async () => {
@@ -706,12 +725,14 @@ export default testSuite(({ describe }, node: NodeApis) => {
706725
const { message: message2 } = await tsImport('./file.ts?with-query', __filename);
707726
console.log(message2);
708727
709-
const cts = await tsImport('./cjs/exports-yes.cts', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
728+
const cts = await tsImport('./cjs/exports-yes.cts?query', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
710729
console.log(cts);
711730
712-
const cjs = await tsImport('./cjs/reexport.cjs', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
731+
const cjs = await tsImport('./cjs/reexport.cjs?query', __filename).then(({ cjsReexport, esmSyntax }) => \`\${cjsReexport} \${esmSyntax}\`, err => err.constructor.name);
713732
console.log(cjs);
714733
734+
await tsImport('@a/b.cjs', __filename);
735+
715736
// Global not polluted
716737
await import('./file.ts?nocache').catch((error) => {
717738
console.log('Fails as expected 2');
@@ -725,25 +746,15 @@ export default testSuite(({ describe }, node: NodeApis) => {
725746
nodePath: node.path,
726747
nodeOptions: [],
727748
});
728-
if (node.supports.cjsInterop) {
729-
expect(stdout).toMatch(new RegExp(
730-
`${String.raw`Fails as expected 1\n`
731-
+ String.raw`foo bar json file\.ts\?tsx-namespace=\d+\n`
732-
+ String.raw`foo bar json file\.ts\?with-query=&tsx-namespace=\d+\n`
733-
+ String.raw`cjsReexport esm syntax\n`
734-
+ String.raw`cjsReexport esm syntax\n`
735-
}Fails as expected 2`,
736-
));
737-
} else {
738-
expect(stdout).toMatch(new RegExp(
739-
`${String.raw`Fails as expected 1\n`
740-
+ String.raw`foo bar json file\.ts\?tsx-namespace=\d+\n`
741-
+ String.raw`foo bar json file\.ts\?with-query=&tsx-namespace=\d+\n`
742-
+ String.raw`SyntaxError\n`
743-
+ String.raw`Error\n`
744-
}Fails as expected 2`,
745-
));
746-
}
749+
750+
expect(stdout).toMatch(new RegExp([
751+
'Fails as expected 1',
752+
String.raw`foo bar json file\.ts\?tsx-namespace=\d+`,
753+
String.raw`foo bar json file\.ts\?with-query&tsx-namespace=\d+`,
754+
'cjsReexport esm syntax',
755+
'cjsReexport esm syntax',
756+
'Fails as expected 2',
757+
].join(String.raw`\n`)));
747758
});
748759

749760
test('mts from commonjs', async () => {

0 commit comments

Comments
 (0)