Skip to content

Commit

Permalink
fix: isolated cts import in Node v18 (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber committed Jul 3, 2024
1 parent a74aa58 commit 042be03
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 69 deletions.
3 changes: 2 additions & 1 deletion src/cjs/api/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
43 changes: 16 additions & 27 deletions src/esm/api/scoped-import.ts
Original file line number Diff line number Diff line change
@@ -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<any>; // 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)}`
);
};
21 changes: 20 additions & 1 deletion src/esm/api/ts-import.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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 = {
parentURL: string;
onImport?: (url: string) => void;
tsconfig?: TsconfigOptions;
};

const tsImport = (
specifier: string,
options: string | Options,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/esm/hook/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down
29 changes: 26 additions & 3 deletions src/esm/hook/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isDirectoryPattern,
isBarePackageNamePattern,
} from '../../utils/path-utils.js';
import type { TsxRequest } from '../types.js';
import {
getFormatFromFileUrl,
namespaceQuery,
Expand Down Expand Up @@ -205,6 +206,8 @@ const resolveTsPaths: ResolveHook = async (
return resolveDirectory(specifier, context, nextResolve);
};

const tsxProtocol = 'tsx://';

export const resolve: ResolveHook = async (
specifier,
context,
Expand All @@ -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('?');
Expand Down
6 changes: 6 additions & 0 deletions src/esm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ export type Message = {
type: 'load';
url: string;
};

export type TsxRequest = {
namespace: string;
parentURL: string;
specifier: string;
};
2 changes: 2 additions & 0 deletions src/utils/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /\/(?:$|\?)/;
Expand Down
81 changes: 46 additions & 35 deletions tests/specs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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');
Expand All @@ -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 () => {
Expand Down

0 comments on commit 042be03

Please sign in to comment.