Skip to content

Commit b94f46f

Browse files
fix: support data URLs in tsImport
1 parent be1315e commit b94f46f

7 files changed

Lines changed: 233 additions & 14 deletions

File tree

notes/node/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Node runtime and loader implementation references. These notes record Node behav
88
| --- | --- |
99
| [module-hooks.md](./module-hooks.md) | Async `module.register()` and sync `module.registerHooks()` |
1010
| [module-resolution.md](./module-resolution.md) | ESM root exports, CommonJS directory mains, and ESM error decoration |
11+
| [data-url-modules.md](./data-url-modules.md) | `data:` module payload and metadata behavior |
1112
| [cjs-loader.md](./cjs-loader.md) | CommonJS resolution, cache identity, extensions, and ESM error decoration |
1213
| [cjs-esm-interop.md](./cjs-esm-interop.md) | Node's CJS-to-ESM and ESM-to-CJS interoperability |
1314
| [type-stripping.md](./type-stripping.md) | Node's native TypeScript type-stripping runtime |

notes/node/data-url-modules.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Data URL modules
2+
3+
Node loads `data:` modules directly from their URL payload. Supported ESM media types include `text/javascript`, `application/json`, and `application/wasm` ([Node ESM documentation](https://github.com/nodejs/node/blob/v22.9.0/doc/api/esm.md#L219-L241)).
4+
5+
## Query and fragment handling
6+
7+
Before Node 22.9.0, the ESM loader parsed a data module from `url.pathname`, so URL query and fragment metadata were outside the module body ([v20.20.2 loader](https://github.com/nodejs/node/blob/v20.20.2/lib/internal/modules/esm/load.js#L31-L73)). Node 22.9.0 adopted the Fetch-standard data-URL processor ([backport](https://github.com/nodejs/node/commit/40ba89e4524b6593badbdfa9e716c0523a0fa234)); it serializes the URL with only the fragment excluded, then decodes the remaining body ([v22.9.0 processor](https://github.com/nodejs/node/blob/v22.9.0/lib/internal/data_url.js#L35-L96)).
8+
9+
Appending a query to a base64 data URL therefore changes its encoded body on Node 22.9.0 and newer. For example, `data:text/javascript;base64,Y29uc29sZS5sb2coMSk=?namespace=private` is not valid base64. A fragment leaves the payload unchanged because the processor excludes it.
10+
11+
## Loader implication
12+
13+
Loader metadata that must survive into imports from a `data:` module belongs in its fragment, not a query parameter. A `data:` module can import absolute URLs, so preserving the metadata is necessary for a hook to continue handling a TypeScript child module. Relative imports remain unsupported because `data:` is not a special URL scheme.

notes/tsx/module-resolution.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Dependency TypeScript parents remain TypeScript-first; consumer `allowJs` never
5353

5454
Dependency classification must inspect `new URL(parentURL).pathname`, not a substring of the serialized URL ([ESM resolver](../../src/esm/hook/resolve.ts)). URL queries and fragments are not filesystem location. A local parent URL containing `?source=/node_modules/` must retain local tsconfig path behavior, while a real dependency pathname must not receive the consumer's aliases.
5555

56+
## Namespaced URLs
57+
58+
`tsImport()` isolates each registration with a namespace. File URLs carry that namespace in their query, while `data:` URLs carry it in a trailing fragment parameter so Node does not include the metadata in the data payload. Data URLs bypass file-oriented resolver logic because their payload can contain query and directory-like text. The trailing marker is removed only when it matches the active registration, leaving user fragments opaque. It lets absolute TypeScript imports from a `data:` module retain the registration namespace ([Node contract](../node/data-url-modules.md), [resolver](../../src/esm/hook/resolve.ts), [lookup](../../src/esm/hook/utils.ts)).
59+
5660
## Required test matrix
5761

5862
Before changing resolver code, cover each policy row through every execution boundary:
@@ -65,6 +69,7 @@ Before changing resolver code, cover each policy row through every execution bou
6569
6. Parent URL classification with no query, a `/node_modules/` query, and a real `node_modules` pathname.
6670
7. Dependency subpaths with conditional root exports, package imports, scoped nested mains, an exact main before extension fallbacks, a missing main that falls through to index, a main directory, and an all-miss native-error comparison.
6771
8. A relative dependency directory beneath root exports, so the package-subpath boundary cannot block ordinary index fallback.
72+
9. A namespaced `tsImport()` graph containing base64 and raw `data:` modules, an opaque user fragment, and an absolute TypeScript child across async and sync hooks.
6873

6974
## Non-goals
7075

src/esm/hook/load.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
getQueryWithoutParameters,
2727
namespaceQuery,
2828
getNamespace,
29+
isDataUrl,
2930
moduleSourceByUrl,
3031
} from './utils.js';
3132
import type { Data } from './initialize.js';
@@ -173,9 +174,20 @@ const notifyLoad = (
173174
const filePath = url.startsWith(fileUrlPrefix)
174175
? getFileLoadContext(url).filePath
175176
: undefined;
176-
parsedUrl.searchParams.delete('tsx-namespace');
177-
parsedUrl.searchParams.delete(commonJsExportPreparseSearchParameter);
178-
parsedUrl.searchParams.delete(commonJsVirtualQuerySearchParameter);
177+
if (isDataUrl(url)) {
178+
if (hookData.namespace) {
179+
const namespaceFragment = `${namespaceQuery}${hookData.namespace}`;
180+
if (parsedUrl.hash === `#${namespaceFragment}`) {
181+
parsedUrl.hash = '';
182+
} else if (parsedUrl.hash.endsWith(`&${namespaceFragment}`)) {
183+
parsedUrl.hash = parsedUrl.hash.slice(0, -namespaceFragment.length - 1);
184+
}
185+
}
186+
} else {
187+
parsedUrl.searchParams.delete('tsx-namespace');
188+
parsedUrl.searchParams.delete(commonJsExportPreparseSearchParameter);
189+
parsedUrl.searchParams.delete(commonJsVirtualQuerySearchParameter);
190+
}
179191
if (filePath) {
180192
parsedUrl.pathname = new URL(pathToFileURL(filePath)).pathname;
181193
}

src/esm/hook/resolve.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
commonJsVirtualQuerySearchParameter,
3535
getQueryWithoutParameters,
3636
getNamespace,
37+
isDataUrl,
3738
parentImportsCommonJsExports,
3839
} from './utils.js';
3940
import type { Data } from './initialize.js';
@@ -799,6 +800,17 @@ const addQuery = (
799800
return `${urlWithoutFragment}${urlWithoutFragment.includes('?') ? '&' : '?'}${query}${fragment}`;
800801
};
801802

803+
const addNamespace = (
804+
url: string,
805+
namespace: string,
806+
) => {
807+
if (!isDataUrl(url)) {
808+
return addQuery(url, `${namespaceQuery}${namespace}`);
809+
}
810+
811+
return `${url}${url.includes('#') ? '&' : '#'}${namespaceQuery}${namespace}`;
812+
};
813+
802814
const mergeUrlMetadata = (
803815
url: string,
804816
metadata: string,
@@ -862,10 +874,10 @@ export const createResolve = (
862874
return nextResolve(specifier, context);
863875
}
864876

865-
let requestNamespace = getNamespace(specifier) ?? (
866-
// Inherit namespace from parent
867-
context.parentURL && getNamespace(context.parentURL)
868-
);
877+
const parentNamespace = context.parentURL && getNamespace(context.parentURL);
878+
let requestNamespace = isDataUrl(specifier)
879+
? parentNamespace
880+
: getNamespace(specifier) ?? parentNamespace;
869881

870882
if (hookData.namespace) {
871883
let tsImportRequest: TsxRequest | undefined;
@@ -891,6 +903,17 @@ export const createResolve = (
891903
}
892904
}
893905

906+
if (isDataUrl(specifier)) {
907+
const resolved = await nextResolve(specifier, context);
908+
if (!hookData.namespace) {
909+
return resolved;
910+
}
911+
return {
912+
...resolved,
913+
url: addNamespace(resolved.url, hookData.namespace),
914+
};
915+
}
916+
894917
const metadataIndex = getSpecifierMetadataIndex(
895918
specifier,
896919
isCommonJsRequireContext(context),
@@ -963,7 +986,7 @@ export const createResolve = (
963986
requestNamespace
964987
&& getNamespace(resolved.url) === undefined
965988
) {
966-
resolved.url = addQuery(resolved.url, `${namespaceQuery}${requestNamespace}`);
989+
resolved.url = addNamespace(resolved.url, requestNamespace);
967990
}
968991

969992
if (shouldLoadForCommonJsExportPreparse) {
@@ -1020,10 +1043,10 @@ export const createResolveSync = (
10201043
return nextResolve(specifier, context);
10211044
}
10221045

1023-
let requestNamespace = getNamespace(specifier) ?? (
1024-
// Inherit namespace from parent
1025-
context.parentURL && getNamespace(context.parentURL)
1026-
);
1046+
const parentNamespace = context.parentURL && getNamespace(context.parentURL);
1047+
let requestNamespace = isDataUrl(specifier)
1048+
? parentNamespace
1049+
: getNamespace(specifier) ?? parentNamespace;
10271050

10281051
if (hookData.namespace) {
10291052
let tsImportRequest: TsxRequest | undefined;
@@ -1049,6 +1072,17 @@ export const createResolveSync = (
10491072
}
10501073
}
10511074

1075+
if (isDataUrl(specifier)) {
1076+
const resolved = nextResolve(specifier, context);
1077+
if (!hookData.namespace) {
1078+
return resolved;
1079+
}
1080+
return {
1081+
...resolved,
1082+
url: addNamespace(resolved.url, hookData.namespace),
1083+
};
1084+
}
1085+
10521086
const metadataIndex = getSpecifierMetadataIndex(
10531087
specifier,
10541088
isCommonJsRequireContext(context),
@@ -1105,7 +1139,7 @@ export const createResolveSync = (
11051139
requestNamespace
11061140
&& getNamespace(resolved.url) === undefined
11071141
) {
1108-
resolved.url = addQuery(resolved.url, `${namespaceQuery}${requestNamespace}`);
1142+
resolved.url = addNamespace(resolved.url, requestNamespace);
11091143
}
11101144

11111145
resolved.url = preserveCommonJsQueryIdentity(

src/esm/hook/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export const commonJsExportPreparseQuery = `${commonJsExportPreparseSearchParame
4040
export const commonJsVirtualQuerySearchParameter = 'tsx-commonjs-virtual-query';
4141
export const moduleSourceByUrl = new Map<string, string>();
4242

43+
const dataUrlPattern = /^data:/i;
44+
45+
export const isDataUrl = (
46+
url: string,
47+
) => dataUrlPattern.test(url);
48+
4349
type CommonJsImportBinding = 'named' | 'namespace' | undefined;
4450

4551
export const getQueryWithoutParameters = (
@@ -137,6 +143,20 @@ export const getNamespace = (
137143
) => {
138144
const queryIndex = url.indexOf('?');
139145
const fragmentIndex = url.indexOf('#');
146+
if (isDataUrl(url)) {
147+
if (fragmentIndex === -1) {
148+
return;
149+
}
150+
151+
// Data URL fragments are user content; tsx appends its marker last.
152+
const fragment = url.slice(fragmentIndex + 1);
153+
const parameterIndex = fragment.lastIndexOf('&');
154+
const parameter = fragment.slice(parameterIndex + 1);
155+
return parameter.startsWith(namespaceQuery)
156+
? parameter.slice(namespaceQuery.length)
157+
: undefined;
158+
}
159+
140160
if (
141161
queryIndex === -1
142162
|| (fragmentIndex !== -1 && fragmentIndex < queryIndex)

tests/specs/version-sensitive.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,140 @@ export const versionSensitiveTests = (node: NodeApis) => describe('Version-sensi
13991399
expect(tsxProcess.stdout).toBe('literal-question');
14001400
});
14011401

1402+
// https://github.com/privatenumber/tsx/issues/750
1403+
test('tsImport imports data URLs', async () => {
1404+
if (!node.supports.moduleRegister) {
1405+
return;
1406+
}
1407+
1408+
await using fixture = await createFixture({
1409+
'package.json': createPackageJson({ type: 'module' }),
1410+
'import.mjs': `
1411+
import { tsImport } from ${JSON.stringify(tsxEsmApiPath)};
1412+
1413+
await tsImport('./data-import.ts', import.meta.url);
1414+
`,
1415+
'data-import.ts': `
1416+
const childUrl = new URL('./data-child.ts', import.meta.url);
1417+
const source = 'import ' + JSON.stringify(childUrl) + '; console.log("data URL");';
1418+
await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
1419+
1420+
console.log('after import');
1421+
`,
1422+
'data-child.ts': 'enum Data { Url = "child" } console.log(Data.Url);',
1423+
});
1424+
1425+
const process = await execaNode(fixture.getPath('import.mjs'), [], {
1426+
nodePath: node.path,
1427+
reject: false,
1428+
});
1429+
1430+
expect(process.exitCode).toBe(0);
1431+
expect(process.stderr).toBe('');
1432+
expect(process.stdout).toBe('child\ndata URL\nafter import');
1433+
});
1434+
1435+
test('tsImport treats uppercase data URL queries as source', async () => {
1436+
if (!node.supports.moduleRegister) {
1437+
return;
1438+
}
1439+
1440+
await using fixture = await createFixture({
1441+
'package.json': createPackageJson({ type: 'module' }),
1442+
'import.mjs': `
1443+
import { tsImport } from ${JSON.stringify(tsxEsmApiPath)};
1444+
1445+
await tsImport('./data-import.ts', import.meta.url);
1446+
`,
1447+
'data-import.ts': `
1448+
const childUrl = new URL('./data-child.ts', import.meta.url);
1449+
const source = 'import ' + JSON.stringify(childUrl) + '; //?tsx-namespace=payload';
1450+
await import('DATA:text/javascript,' + source);
1451+
1452+
console.log('after import');
1453+
`,
1454+
'data-child.ts': 'enum Data { Url = "child" } console.log(Data.Url);',
1455+
});
1456+
1457+
const process = await execaNode(fixture.getPath('import.mjs'), [], {
1458+
nodePath: node.path,
1459+
reject: false,
1460+
});
1461+
1462+
expect(process.exitCode).toBe(0);
1463+
expect(process.stderr).toBe('');
1464+
expect(process.stdout).toBe('child\nafter import');
1465+
});
1466+
1467+
test('tsImport isolates data URLs with user namespace fragments', async () => {
1468+
if (!node.supports.moduleRegister) {
1469+
return;
1470+
}
1471+
1472+
await using fixture = await createFixture({
1473+
'package.json': createPackageJson({ type: 'module' }),
1474+
'import.mjs': `
1475+
import { tsImport } from ${JSON.stringify(tsxEsmApiPath)};
1476+
1477+
await tsImport('./data-import.ts', import.meta.url);
1478+
await tsImport('./data-import.ts', import.meta.url);
1479+
`,
1480+
'data-import.ts': 'await import(\'data:text/javascript,globalThis.dataUrlLoads = (globalThis.dataUrlLoads || 0) + 1; console.log(globalThis.dataUrlLoads)#tsx-namespace=foreign\');',
1481+
});
1482+
1483+
const process = await execaNode(fixture.getPath('import.mjs'), [], {
1484+
nodePath: node.path,
1485+
reject: false,
1486+
});
1487+
1488+
expect(process.exitCode).toBe(0);
1489+
expect(process.stderr).toBe('');
1490+
expect(process.stdout).toBe('1\n2');
1491+
});
1492+
1493+
test('tsImport preserves data URL fragments in onImport', async () => {
1494+
if (!node.supports.moduleRegister) {
1495+
return;
1496+
}
1497+
1498+
await using fixture = await createFixture({
1499+
'package.json': createPackageJson({ type: 'module' }),
1500+
'import.mjs': `
1501+
import { setTimeout } from 'node:timers/promises';
1502+
import { tsImport } from ${JSON.stringify(tsxEsmApiPath)};
1503+
1504+
const importedUrls = [];
1505+
await tsImport('./data-import.ts', {
1506+
parentURL: import.meta.url,
1507+
onImport(url) {
1508+
importedUrls.push(url);
1509+
},
1510+
});
1511+
await setTimeout(100);
1512+
1513+
const dataUrlHashes = importedUrls
1514+
.filter(url => url.startsWith('data:'))
1515+
.map(url => new URL(url).hash);
1516+
console.log(JSON.stringify(dataUrlHashes));
1517+
`,
1518+
'data-import.ts': `
1519+
await import('data:text/javascript,console.log("no fragment")');
1520+
await import('data:text/javascript,console.log("user fragment")#user&&fragment&tsx-namespace=foreign');
1521+
`,
1522+
});
1523+
1524+
const process = await execaNode(fixture.getPath('import.mjs'), [], {
1525+
nodePath: node.path,
1526+
reject: false,
1527+
});
1528+
1529+
expect(process.exitCode).toBe(0);
1530+
expect(process.stderr).toBe('');
1531+
expect(process.stdout).toBe('no fragment\nuser fragment\n["","#user&&fragment&tsx-namespace=foreign"]');
1532+
}, {
1533+
retry: 3,
1534+
});
1535+
14021536
test('tsImport keeps CommonJS-classified TypeScript loads isolated when calls share a timestamp', async () => {
14031537
if (!node.supports.esmLoadReadFile) {
14041538
return;
@@ -1959,6 +2093,6 @@ export const versionSensitiveTests = (node: NodeApis) => describe('Version-sensi
19592093
expect(tsxProcess.stdout).toMatch('# pass 1\n');
19602094
}
19612095
expect(tsxProcess.exitCode).toBe(0);
1962-
}, 10_000);
2096+
}, 20_000);
19632097
}
19642098
});

0 commit comments

Comments
 (0)