diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 4bd5898c5..f650e6c4d 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -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 @@ -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 diff --git a/dist-raw/node-options.js b/dist-raw/node-options.js index 0602a4769..22722755d 100644 --- a/dist-raw/node-options.js +++ b/dist-raw/node-options.js @@ -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 diff --git a/src/esm.ts b/src/esm.ts index c83fd22c4..48dd13f1d 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -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; @@ -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` @@ -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; @@ -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 ); diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 6c3c1c51a..0de0482c0 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -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); @@ -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.'); + } + }); + }); + }); +} diff --git a/tests/esm-import-assertions/car.json b/tests/esm-import-assertions/car.json new file mode 100644 index 000000000..a63dfe91d --- /dev/null +++ b/tests/esm-import-assertions/car.json @@ -0,0 +1,5 @@ +{ + "color": "fuchsia", + "doors": "open", + "seats": 2 +} diff --git a/tests/esm-import-assertions/importJson.ts b/tests/esm-import-assertions/importJson.ts new file mode 100644 index 000000000..b4446aa71 --- /dev/null +++ b/tests/esm-import-assertions/importJson.ts @@ -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!'); diff --git a/tests/esm-import-assertions/package.json b/tests/esm-import-assertions/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-import-assertions/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-import-assertions/tsconfig.json b/tests/esm-import-assertions/tsconfig.json new file mode 100644 index 000000000..d626b9278 --- /dev/null +++ b/tests/esm-import-assertions/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + } +} diff --git a/tests/esm-loader-context/index.mjs b/tests/esm-loader-context/index.mjs new file mode 100644 index 000000000..3def33246 --- /dev/null +++ b/tests/esm-loader-context/index.mjs @@ -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' } }); diff --git a/tests/esm-loader-context/jsonModuleA.json b/tests/esm-loader-context/jsonModuleA.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/esm-loader-context/jsonModuleA.json @@ -0,0 +1 @@ +{} diff --git a/tests/esm-loader-context/jsonModuleB.json b/tests/esm-loader-context/jsonModuleB.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/esm-loader-context/jsonModuleB.json @@ -0,0 +1 @@ +{} diff --git a/tests/esm-loader-context/loader.mjs b/tests/esm-loader-context/loader.mjs new file mode 100644 index 000000000..d500b5a79 --- /dev/null +++ b/tests/esm-loader-context/loader.mjs @@ -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); +} diff --git a/tests/esm-loader-context/moduleA.mjs b/tests/esm-loader-context/moduleA.mjs new file mode 100644 index 000000000..e69de29bb diff --git a/tests/esm-loader-context/moduleB.mjs b/tests/esm-loader-context/moduleB.mjs new file mode 100644 index 000000000..e69de29bb diff --git a/tests/esm-loader-context/moduleC.mjs b/tests/esm-loader-context/moduleC.mjs new file mode 100644 index 000000000..e69de29bb diff --git a/tests/esm-loader-context/moduleD.mjs b/tests/esm-loader-context/moduleD.mjs new file mode 100644 index 000000000..e69de29bb