Skip to content

Commit

Permalink
Import assertion support (#1559)
Browse files Browse the repository at this point in the history
* 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 <abradley@brightcove.com>
Co-authored-by: Andrew Bradley <cspotcode@gmail.com>
  • Loading branch information
3 people committed Jan 21, 2022
1 parent a817289 commit 89f88eb
Show file tree
Hide file tree
Showing 16 changed files with 152 additions and 8 deletions.
11 changes: 9 additions & 2 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
matrix:
os: [ubuntu, windows]
# Don't forget to add all new flavors to this list!
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
include:
# Node 12.15
# 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
Expand Down Expand Up @@ -119,8 +119,15 @@ jobs:
typescript: next
typescriptFlag: next
downgradeNpm: true
# Node nightly
# Node 17
- flavor: 12
node: 17
nodeFlag: 17
typescript: latest
typescriptFlag: latest
downgradeNpm: true
# Node nightly
- flavor: 13
node: nightly
nodeFlag: nightly
typescript: latest
Expand Down
4 changes: 3 additions & 1 deletion dist-raw/node-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function parseArgv(argv) {
'--es-module-specifier-resolution': '--experimental-specifier-resolution',
'--experimental-policy': String,
'--conditions': [String],
'--pending-deprecation': Boolean
'--pending-deprecation': Boolean,
'--experimental-json-modules': Boolean,
'--experimental-wasm-modules': Boolean,
}, {
argv,
permissive: true
Expand Down
26 changes: 22 additions & 4 deletions src/esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,19 @@ export interface NodeLoaderHooksAPI2 {
export namespace NodeLoaderHooksAPI2 {
export type ResolveHook = (
specifier: string,
context: { parentURL: string },
context: {
conditions?: NodeImportConditions;
importAssertions?: NodeImportAssertions;
parentURL: string;
},
defaultResolve: ResolveHook
) => Promise<{ url: string }>;
export type LoadHook = (
url: string,
context: { format: NodeLoaderHooksFormat | null | undefined },
context: {
format: NodeLoaderHooksFormat | null | undefined;
importAssertions?: NodeImportAssertions;
},
defaultLoad: NodeLoaderHooksAPI2['load']
) => Promise<{
format: NodeLoaderHooksFormat;
Expand All @@ -83,6 +90,11 @@ export type NodeLoaderHooksFormat =
| 'module'
| 'wasm';

export type NodeImportConditions = unknown;
export interface NodeImportAssertions {
type?: 'json';
}

/** @internal */
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
// Automatically performs registration just like `-r ts-node/register`
Expand Down Expand Up @@ -159,7 +171,10 @@ export function createEsmHooks(tsNodeService: Service) {
// `load` from new loader hook API (See description at the top of this file)
async function load(
url: string,
context: { format: NodeLoaderHooksFormat | null | undefined },
context: {
format: NodeLoaderHooksFormat | null | undefined;
importAssertions?: NodeImportAssertions;
},
defaultLoad: typeof load
): Promise<{
format: NodeLoaderHooksFormat;
Expand All @@ -176,7 +191,10 @@ export function createEsmHooks(tsNodeService: Service) {
// Call the new defaultLoad() to get the source
const { source: rawSource } = await defaultLoad(
url,
{ format },
{
...context,
format,
},
defaultLoad
);

Expand Down
56 changes: 55 additions & 1 deletion src/test/esm-loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
import { context } from './testlib';
import semver = require('semver');
import {
CMD_ESM_LOADER_WITHOUT_PROJECT,
contextTsNodeUnderTest,
EXPERIMENTAL_MODULES_FLAG,
resetNodeEnvironment,
TEST_DIR,
} from './helpers';
import { createExec } from './exec-helpers';
import { join } from 'path';
import { join, resolve } from 'path';
import * as expect from 'expect';
import type { NodeLoaderHooksAPI2 } from '../';

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

const test = context(contextTsNodeUnderTest);

Expand Down Expand Up @@ -71,3 +73,55 @@ test.suite('hooks', (_test) => {
});
}
});

if (nodeSupportsImportAssertions) {
test.suite('Supports import assertions', (test) => {
test('Can import JSON using the appropriate flag and assertion', async (t) => {
const { err, stdout } = await exec(
`${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`,
{
cwd: resolve(TEST_DIR, 'esm-import-assertions'),
}
);
expect(err).toBe(null);
expect(stdout.trim()).toBe(
'A fuchsia car has 2 seats and the doors are open.\nDone!'
);
});
});

test.suite("Catch unexpected changes to node's loader context", (test) => {
/*
* This does not test ts-node.
* Rather, it is meant to alert us to potentially breaking changes in node's
* loader API. If node starts returning more or less properties on `context`
* objects, we want to know, because it may indicate that our loader code
* should be updated to accomodate the new properties, either by proxying them,
* modifying them, or suppressing them.
*/
test('Ensure context passed to loader by node has only expected properties', async (t) => {
const { stdout, stderr } = await exec(
`node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs`
);
const rows = stdout.split('\n').filter((v) => v[0] === '{');
expect(rows.length).toBe(14);
rows.forEach((row) => {
const json = JSON.parse(row) as {
resolveContextKeys?: string[];
loadContextKeys?: string;
};
if (json.resolveContextKeys) {
expect(json.resolveContextKeys).toEqual([
'conditions',
'importAssertions',
'parentURL',
]);
} else if (json.loadContextKeys) {
expect(json.loadContextKeys).toEqual(['format', 'importAssertions']);
} else {
throw new Error('Unexpected stdout in test.');
}
});
});
});
}
5 changes: 5 additions & 0 deletions tests/esm-import-assertions/car.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"color": "fuchsia",
"doors": "open",
"seats": 2
}
28 changes: 28 additions & 0 deletions tests/esm-import-assertions/importJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import carData from './car.json' assert { type: 'json' };

if (carData.color !== 'fuchsia') throw new Error('failed to import json');

const { default: dynamicCarData } = await import('./car.json', {
assert: { type: 'json' },
});

if (dynamicCarData.doors !== 'open')
throw new Error('failed to dynamically import json');

console.log(
`A ${carData.color} car has ${carData.seats} seats and the doors are ${dynamicCarData.doors}.`
);

// Test that omitting the assertion causes node to throw an error
await import('./car.json').then(
() => {
throw new Error('should have thrown');
},
(error: any) => {
if (error.code !== 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
throw error;
}
/* error is expected */
}
);
console.log('Done!');
3 changes: 3 additions & 0 deletions tests/esm-import-assertions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
10 changes: 10 additions & 0 deletions tests/esm-import-assertions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
}
}
7 changes: 7 additions & 0 deletions tests/esm-loader-context/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as moduleA from './moduleA.mjs';
import * as moduleB from './moduleB.mjs' assert { foo: 'bar' };
import * as jsonModule from './jsonModuleA.json' assert { type: 'json' };

await import('./moduleC.mjs');
await import('./moduleD.mjs', { foo: 'bar' });
await import('./jsonModuleB.json', { assert: { type: 'json' } });
1 change: 1 addition & 0 deletions tests/esm-loader-context/jsonModuleA.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions tests/esm-loader-context/jsonModuleB.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
8 changes: 8 additions & 0 deletions tests/esm-loader-context/loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function resolve(specifier, context, defaultResolve) {
console.log(JSON.stringify({ resolveContextKeys: Object.keys(context) }));
return defaultResolve(specifier, context);
}
export function load(url, context, defaultLoad) {
console.log(JSON.stringify({ loadContextKeys: Object.keys(context) }));
return defaultLoad(url, context);
}
Empty file.
Empty file.
Empty file.
Empty file.

0 comments on commit 89f88eb

Please sign in to comment.