Skip to content

Commit 89f88eb

Browse files
Pokutecspotcode
andauthored
Import assertion support (#1559)
* json / wasm import assertion support. * Addressing review feedback * reformat * fix mistakes in test * add node 17 stable to test matrix Co-authored-by: Andrew Bradley <[email protected]> Co-authored-by: Andrew Bradley <[email protected]>
1 parent a817289 commit 89f88eb

16 files changed

+152
-8
lines changed

.github/workflows/continuous-integration.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
matrix:
4949
os: [ubuntu, windows]
5050
# Don't forget to add all new flavors to this list!
51-
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
51+
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
5252
include:
5353
# Node 12.15
5454
# TODO Add comments about why we test 12.15; I think git blame says it's because of an ESM behavioral change that happened at 12.16
@@ -119,8 +119,15 @@ jobs:
119119
typescript: next
120120
typescriptFlag: next
121121
downgradeNpm: true
122-
# Node nightly
122+
# Node 17
123123
- flavor: 12
124+
node: 17
125+
nodeFlag: 17
126+
typescript: latest
127+
typescriptFlag: latest
128+
downgradeNpm: true
129+
# Node nightly
130+
- flavor: 13
124131
node: nightly
125132
nodeFlag: nightly
126133
typescript: latest

dist-raw/node-options.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ function parseArgv(argv) {
3434
'--es-module-specifier-resolution': '--experimental-specifier-resolution',
3535
'--experimental-policy': String,
3636
'--conditions': [String],
37-
'--pending-deprecation': Boolean
37+
'--pending-deprecation': Boolean,
38+
'--experimental-json-modules': Boolean,
39+
'--experimental-wasm-modules': Boolean,
3840
}, {
3941
argv,
4042
permissive: true

src/esm.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,19 @@ export interface NodeLoaderHooksAPI2 {
6262
export namespace NodeLoaderHooksAPI2 {
6363
export type ResolveHook = (
6464
specifier: string,
65-
context: { parentURL: string },
65+
context: {
66+
conditions?: NodeImportConditions;
67+
importAssertions?: NodeImportAssertions;
68+
parentURL: string;
69+
},
6670
defaultResolve: ResolveHook
6771
) => Promise<{ url: string }>;
6872
export type LoadHook = (
6973
url: string,
70-
context: { format: NodeLoaderHooksFormat | null | undefined },
74+
context: {
75+
format: NodeLoaderHooksFormat | null | undefined;
76+
importAssertions?: NodeImportAssertions;
77+
},
7178
defaultLoad: NodeLoaderHooksAPI2['load']
7279
) => Promise<{
7380
format: NodeLoaderHooksFormat;
@@ -83,6 +90,11 @@ export type NodeLoaderHooksFormat =
8390
| 'module'
8491
| 'wasm';
8592

93+
export type NodeImportConditions = unknown;
94+
export interface NodeImportAssertions {
95+
type?: 'json';
96+
}
97+
8698
/** @internal */
8799
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
88100
// Automatically performs registration just like `-r ts-node/register`
@@ -159,7 +171,10 @@ export function createEsmHooks(tsNodeService: Service) {
159171
// `load` from new loader hook API (See description at the top of this file)
160172
async function load(
161173
url: string,
162-
context: { format: NodeLoaderHooksFormat | null | undefined },
174+
context: {
175+
format: NodeLoaderHooksFormat | null | undefined;
176+
importAssertions?: NodeImportAssertions;
177+
},
163178
defaultLoad: typeof load
164179
): Promise<{
165180
format: NodeLoaderHooksFormat;
@@ -176,7 +191,10 @@ export function createEsmHooks(tsNodeService: Service) {
176191
// Call the new defaultLoad() to get the source
177192
const { source: rawSource } = await defaultLoad(
178193
url,
179-
{ format },
194+
{
195+
...context,
196+
format,
197+
},
180198
defaultLoad
181199
);
182200

src/test/esm-loader.spec.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@
55
import { context } from './testlib';
66
import semver = require('semver');
77
import {
8+
CMD_ESM_LOADER_WITHOUT_PROJECT,
89
contextTsNodeUnderTest,
910
EXPERIMENTAL_MODULES_FLAG,
1011
resetNodeEnvironment,
1112
TEST_DIR,
1213
} from './helpers';
1314
import { createExec } from './exec-helpers';
14-
import { join } from 'path';
15+
import { join, resolve } from 'path';
1516
import * as expect from 'expect';
1617
import type { NodeLoaderHooksAPI2 } from '../';
1718

1819
const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0');
20+
const nodeSupportsImportAssertions = semver.gte(process.version, '17.1.0');
1921

2022
const test = context(contextTsNodeUnderTest);
2123

@@ -71,3 +73,55 @@ test.suite('hooks', (_test) => {
7173
});
7274
}
7375
});
76+
77+
if (nodeSupportsImportAssertions) {
78+
test.suite('Supports import assertions', (test) => {
79+
test('Can import JSON using the appropriate flag and assertion', async (t) => {
80+
const { err, stdout } = await exec(
81+
`${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`,
82+
{
83+
cwd: resolve(TEST_DIR, 'esm-import-assertions'),
84+
}
85+
);
86+
expect(err).toBe(null);
87+
expect(stdout.trim()).toBe(
88+
'A fuchsia car has 2 seats and the doors are open.\nDone!'
89+
);
90+
});
91+
});
92+
93+
test.suite("Catch unexpected changes to node's loader context", (test) => {
94+
/*
95+
* This does not test ts-node.
96+
* Rather, it is meant to alert us to potentially breaking changes in node's
97+
* loader API. If node starts returning more or less properties on `context`
98+
* objects, we want to know, because it may indicate that our loader code
99+
* should be updated to accomodate the new properties, either by proxying them,
100+
* modifying them, or suppressing them.
101+
*/
102+
test('Ensure context passed to loader by node has only expected properties', async (t) => {
103+
const { stdout, stderr } = await exec(
104+
`node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs`
105+
);
106+
const rows = stdout.split('\n').filter((v) => v[0] === '{');
107+
expect(rows.length).toBe(14);
108+
rows.forEach((row) => {
109+
const json = JSON.parse(row) as {
110+
resolveContextKeys?: string[];
111+
loadContextKeys?: string;
112+
};
113+
if (json.resolveContextKeys) {
114+
expect(json.resolveContextKeys).toEqual([
115+
'conditions',
116+
'importAssertions',
117+
'parentURL',
118+
]);
119+
} else if (json.loadContextKeys) {
120+
expect(json.loadContextKeys).toEqual(['format', 'importAssertions']);
121+
} else {
122+
throw new Error('Unexpected stdout in test.');
123+
}
124+
});
125+
});
126+
});
127+
}

tests/esm-import-assertions/car.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"color": "fuchsia",
3+
"doors": "open",
4+
"seats": 2
5+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import carData from './car.json' assert { type: 'json' };
2+
3+
if (carData.color !== 'fuchsia') throw new Error('failed to import json');
4+
5+
const { default: dynamicCarData } = await import('./car.json', {
6+
assert: { type: 'json' },
7+
});
8+
9+
if (dynamicCarData.doors !== 'open')
10+
throw new Error('failed to dynamically import json');
11+
12+
console.log(
13+
`A ${carData.color} car has ${carData.seats} seats and the doors are ${dynamicCarData.doors}.`
14+
);
15+
16+
// Test that omitting the assertion causes node to throw an error
17+
await import('./car.json').then(
18+
() => {
19+
throw new Error('should have thrown');
20+
},
21+
(error: any) => {
22+
if (error.code !== 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
23+
throw error;
24+
}
25+
/* error is expected */
26+
}
27+
);
28+
console.log('Done!');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"module": "ESNext",
4+
"target": "ESNext",
5+
"resolveJsonModule": true,
6+
"allowJs": true,
7+
"moduleResolution": "node",
8+
"allowSyntheticDefaultImports": true
9+
}
10+
}

tests/esm-loader-context/index.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as moduleA from './moduleA.mjs';
2+
import * as moduleB from './moduleB.mjs' assert { foo: 'bar' };
3+
import * as jsonModule from './jsonModuleA.json' assert { type: 'json' };
4+
5+
await import('./moduleC.mjs');
6+
await import('./moduleD.mjs', { foo: 'bar' });
7+
await import('./jsonModuleB.json', { assert: { type: 'json' } });
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

tests/esm-loader-context/loader.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function resolve(specifier, context, defaultResolve) {
2+
console.log(JSON.stringify({ resolveContextKeys: Object.keys(context) }));
3+
return defaultResolve(specifier, context);
4+
}
5+
export function load(url, context, defaultLoad) {
6+
console.log(JSON.stringify({ loadContextKeys: Object.keys(context) }));
7+
return defaultLoad(url, context);
8+
}

tests/esm-loader-context/moduleA.mjs

Whitespace-only changes.

tests/esm-loader-context/moduleB.mjs

Whitespace-only changes.

tests/esm-loader-context/moduleC.mjs

Whitespace-only changes.

tests/esm-loader-context/moduleD.mjs

Whitespace-only changes.

0 commit comments

Comments
 (0)