Skip to content

Commit a979dd6

Browse files
Update esm loader hooks API (#1457)
* Initial commit * Update hooks * wip impl of load * Expose old hooks for backward compat * Some logging * Add raw copy of default get format * Adapt defaultGetFormat() from node source * Fix defaultTransformSource * Add missing newline * Fix require * Check node version to avoid deprecation warning * Remove load from old hooks * Add some comments * Use versionGte * Remove logging * Refine comments * Wording * Use format hint if available * One more comment * Nitpicky changes to comments * Update index.ts * lint-fix * attempt at downloading node nightly in tests * fix * fix * Windows install of node nightly * update version checks to be ready for node backporting * Add guards for undefined source * More error info * Skip source transform for builtin and commonjs * Update transpile-only.mjs * Tweak `createEsmHooks` type * fix test to accomodate new api Co-authored-by: Andrew Bradley <[email protected]>
1 parent 4a0db31 commit a979dd6

File tree

8 files changed

+235
-21
lines changed

8 files changed

+235
-21
lines changed

.github/workflows/continuous-integration.yml

+30-1
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]
51+
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
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
@@ -112,14 +112,43 @@ jobs:
112112
typescript: next
113113
typescriptFlag: next
114114
downgradeNpm: true
115+
# Node nightly
116+
- flavor: 11
117+
node: nightly
118+
nodeFlag: nightly
119+
typescript: latest
120+
typescriptFlag: latest
121+
downgradeNpm: true
115122
steps:
116123
# checkout code
117124
- uses: actions/checkout@v2
118125
# install node
119126
- name: Use Node.js ${{ matrix.node }}
127+
if: matrix.node != 'nightly'
120128
uses: actions/setup-node@v1
121129
with:
122130
node-version: ${{ matrix.node }}
131+
- name: Use Node.js 16, will be subsequently overridden by download of nightly
132+
if: matrix.node == 'nightly'
133+
uses: actions/setup-node@v1
134+
with:
135+
node-version: 16
136+
- name: Download Node.js nightly
137+
if: matrix.node == 'nightly' && matrix.os == 'ubuntu'
138+
run: |
139+
export N_PREFIX=$(pwd)/n
140+
npm install -g n
141+
n nightly
142+
sudo cp "${N_PREFIX}/bin/node" "$(which node)"
143+
node --version
144+
- name: Download Node.js nightly
145+
if: matrix.node == 'nightly' && matrix.os == 'windows'
146+
run: |
147+
$version = (Invoke-WebRequest https://nodejs.org/download/nightly/index.json | ConvertFrom-json)[0].version
148+
$url = "https://nodejs.org/download/nightly/$version/win-x64/node.exe"
149+
$targetPath = (Get-Command node.exe).Source
150+
Invoke-WebRequest -Uri $url -OutFile $targetPath
151+
node --version
123152
# lint, build, test
124153
# Downgrade from npm 7 to 6 because 7 still seems buggy to me
125154
- if: ${{ matrix.downgradeNpm }}
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copied from https://raw.githubusercontent.com/nodejs/node/v15.3.0/lib/internal/modules/esm/get_format.js
2+
// Then modified to suite our needs.
3+
// Formatting is intentionally bad to keep the diff as small as possible, to make it easier to merge
4+
// upstream changes and understand our modifications.
5+
6+
'use strict';
7+
const {
8+
RegExpPrototypeExec,
9+
StringPrototypeStartsWith,
10+
} = require('./node-primordials');
11+
const { extname } = require('path');
12+
const { getOptionValue } = require('./node-options');
13+
14+
const experimentalJsonModules = getOptionValue('--experimental-json-modules');
15+
const experimentalSpeciferResolution =
16+
getOptionValue('--experimental-specifier-resolution');
17+
const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
18+
const { getPackageType } = require('./node-esm-resolve-implementation.js').createResolve({tsExtensions: [], jsExtensions: []});
19+
const { URL, fileURLToPath } = require('url');
20+
const { ERR_UNKNOWN_FILE_EXTENSION } = require('./node-errors').codes;
21+
22+
const extensionFormatMap = {
23+
'__proto__': null,
24+
'.cjs': 'commonjs',
25+
'.js': 'module',
26+
'.mjs': 'module'
27+
};
28+
29+
const legacyExtensionFormatMap = {
30+
'__proto__': null,
31+
'.cjs': 'commonjs',
32+
'.js': 'commonjs',
33+
'.json': 'commonjs',
34+
'.mjs': 'module',
35+
'.node': 'commonjs'
36+
};
37+
38+
if (experimentalWasmModules)
39+
extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm';
40+
41+
if (experimentalJsonModules)
42+
extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json';
43+
44+
function defaultGetFormat(url, context, defaultGetFormatUnused) {
45+
if (StringPrototypeStartsWith(url, 'node:')) {
46+
return { format: 'builtin' };
47+
}
48+
const parsed = new URL(url);
49+
if (parsed.protocol === 'data:') {
50+
const [ , mime ] = RegExpPrototypeExec(
51+
/^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/,
52+
parsed.pathname,
53+
) || [ null, null, null ];
54+
const format = ({
55+
'__proto__': null,
56+
'text/javascript': 'module',
57+
'application/json': experimentalJsonModules ? 'json' : null,
58+
'application/wasm': experimentalWasmModules ? 'wasm' : null
59+
})[mime] || null;
60+
return { format };
61+
} else if (parsed.protocol === 'file:') {
62+
const ext = extname(parsed.pathname);
63+
let format;
64+
if (ext === '.js') {
65+
format = getPackageType(parsed.href) === 'module' ? 'module' : 'commonjs';
66+
} else {
67+
format = extensionFormatMap[ext];
68+
}
69+
if (!format) {
70+
if (experimentalSpeciferResolution === 'node') {
71+
process.emitWarning(
72+
'The Node.js specifier resolution in ESM is experimental.',
73+
'ExperimentalWarning');
74+
format = legacyExtensionFormatMap[ext];
75+
} else {
76+
throw new ERR_UNKNOWN_FILE_EXTENSION(ext, fileURLToPath(url));
77+
}
78+
}
79+
return { format: format || null };
80+
}
81+
return { format: null };
82+
}
83+
exports.defaultGetFormat = defaultGetFormat;

esm.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const require = createRequire(fileURLToPath(import.meta.url));
66
const esm = require('./dist/esm');
77
export const {
88
resolve,
9+
load,
910
getFormat,
1011
transformSource,
1112
} = esm.registerAndCreateEsmHooks();

esm/transpile-only.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const require = createRequire(fileURLToPath(import.meta.url));
66
const esm = require('../dist/esm');
77
export const {
88
resolve,
9+
load,
910
getFormat,
1011
transformSource,
1112
} = esm.registerAndCreateEsmHooks({ transpileOnly: true });

package.json

+3-4
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,9 @@
7070
"pre-debug": "npm run build-tsc && npm run build-pack",
7171
"coverage-report": "nyc report --reporter=lcov",
7272
"prepare": "npm run clean && npm run build-nopack",
73-
"api-extractor": "api-extractor run --local --verbose"
74-
},
75-
"engines": {
76-
"node": ">=12.0.0"
73+
"api-extractor": "api-extractor run --local --verbose",
74+
"esm-usage-example": "npm run build-tsc && cd esm-usage-example && node --experimental-specifier-resolution node --loader ../esm.mjs ./index",
75+
"esm-usage-example2": "npm run build-tsc && cd tests && TS_NODE_PROJECT=./module-types/override-to-cjs/tsconfig.json node --loader ../esm.mjs ./module-types/override-to-cjs/test.cjs"
7776
},
7877
"repository": {
7978
"type": "git",

src/esm.ts

+90-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { getExtensions, register, RegisterOptions, Service } from './index';
1+
import {
2+
register,
3+
getExtensions,
4+
RegisterOptions,
5+
Service,
6+
versionGteLt,
7+
} from './index';
28
import {
39
parse as parseUrl,
410
format as formatUrl,
@@ -12,9 +18,24 @@ import { normalizeSlashes } from './util';
1218
const {
1319
createResolve,
1420
} = require('../dist-raw/node-esm-resolve-implementation');
21+
const { defaultGetFormat } = require('../dist-raw/node-esm-default-get-format');
1522

1623
// Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts
1724

25+
// NOTE ABOUT MULTIPLE EXPERIMENTAL LOADER APIS
26+
//
27+
// At the time of writing, this file implements 2x different loader APIs.
28+
// Node made a breaking change to the loader API in https://github.com/nodejs/node/pull/37468
29+
//
30+
// We check the node version number and export either the *old* or the *new* API.
31+
//
32+
// Today, we are implementing the *new* API on top of our implementation of the *old* API,
33+
// which relies on copy-pasted code from the *old* hooks implementation in node.
34+
//
35+
// In the future, we will likely invert this: we will copy-paste the *new* API implementation
36+
// from node, build our implementation of the *new* API on top of it, and implement the *old*
37+
// hooks API as a shim to the *new* API.
38+
1839
/** @internal */
1940
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
2041
// Automatically performs registration just like `-r ts-node/register`
@@ -32,7 +53,24 @@ export function createEsmHooks(tsNodeService: Service) {
3253
preferTsExts: tsNodeService.options.preferTsExts,
3354
});
3455

35-
return { resolve, getFormat, transformSource };
56+
// The hooks API changed in node version X so we need to check for backwards compatibility.
57+
// TODO: When the new API is backported to v12, v14, v16, update these version checks accordingly.
58+
const newHooksAPI =
59+
versionGteLt(process.versions.node, '17.0.0') ||
60+
versionGteLt(process.versions.node, '16.999.999', '17.0.0') ||
61+
versionGteLt(process.versions.node, '14.999.999', '15.0.0') ||
62+
versionGteLt(process.versions.node, '12.999.999', '13.0.0');
63+
64+
// Explicit return type to avoid TS's non-ideal inferred type
65+
const hooksAPI: {
66+
resolve: typeof resolve;
67+
getFormat: typeof getFormat | undefined;
68+
transformSource: typeof transformSource | undefined;
69+
load: typeof load | undefined;
70+
} = newHooksAPI
71+
? { resolve, load, getFormat: undefined, transformSource: undefined }
72+
: { resolve, getFormat, transformSource, load: undefined };
73+
return hooksAPI;
3674

3775
function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
3876
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
@@ -76,6 +114,52 @@ export function createEsmHooks(tsNodeService: Service) {
76114
);
77115
}
78116

117+
// `load` from new loader hook API (See description at the top of this file)
118+
async function load(
119+
url: string,
120+
context: { format: Format | null | undefined },
121+
defaultLoad: typeof load
122+
): Promise<{ format: Format; source: string | Buffer | undefined }> {
123+
// If we get a format hint from resolve() on the context then use it
124+
// otherwise call the old getFormat() hook using node's old built-in defaultGetFormat() that ships with ts-node
125+
const format =
126+
context.format ??
127+
(await getFormat(url, context, defaultGetFormat)).format;
128+
129+
let source = undefined;
130+
if (format !== 'builtin' && format !== 'commonjs') {
131+
// Call the new defaultLoad() to get the source
132+
const { source: rawSource } = await defaultLoad(
133+
url,
134+
{ format },
135+
defaultLoad
136+
);
137+
138+
if (rawSource === undefined || rawSource === null) {
139+
throw new Error(
140+
`Failed to load raw source: Format was '${format}' and url was '${url}''.`
141+
);
142+
}
143+
144+
// Emulate node's built-in old defaultTransformSource() so we can re-use the old transformSource() hook
145+
const defaultTransformSource: typeof transformSource = async (
146+
source,
147+
_context,
148+
_defaultTransformSource
149+
) => ({ source });
150+
151+
// Call the old hook
152+
const { source: transformedSource } = await transformSource(
153+
rawSource,
154+
{ url, format },
155+
defaultTransformSource
156+
);
157+
source = transformedSource;
158+
}
159+
160+
return { format, source };
161+
}
162+
79163
type Format = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm';
80164
async function getFormat(
81165
url: string,
@@ -129,6 +213,10 @@ export function createEsmHooks(tsNodeService: Service) {
129213
context: { url: string; format: Format },
130214
defaultTransformSource: typeof transformSource
131215
): Promise<{ source: string | Buffer }> {
216+
if (source === null || source === undefined) {
217+
throw new Error('No source');
218+
}
219+
132220
const defer = () =>
133221
defaultTransformSource(source, context, defaultTransformSource);
134222

src/index.ts

+26-13
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,31 @@ export type {
4747
const engineSupportsPackageTypeField =
4848
parseInt(process.versions.node.split('.')[0], 10) >= 12;
4949

50-
function versionGte(version: string, requirement: string) {
51-
const [major, minor, patch, extra] = version
52-
.split(/[\.-]/)
53-
.map((s) => parseInt(s, 10));
54-
const [reqMajor, reqMinor, reqPatch] = requirement
55-
.split('.')
56-
.map((s) => parseInt(s, 10));
57-
return (
58-
major > reqMajor ||
59-
(major === reqMajor &&
60-
(minor > reqMinor || (minor === reqMinor && patch >= reqPatch)))
61-
);
50+
/** @internal */
51+
export function versionGteLt(
52+
version: string,
53+
gteRequirement: string,
54+
ltRequirement?: string
55+
) {
56+
const [major, minor, patch, extra] = parse(version);
57+
const [gteMajor, gteMinor, gtePatch] = parse(gteRequirement);
58+
const isGte =
59+
major > gteMajor ||
60+
(major === gteMajor &&
61+
(minor > gteMinor || (minor === gteMinor && patch >= gtePatch)));
62+
let isLt = true;
63+
if (ltRequirement) {
64+
const [ltMajor, ltMinor, ltPatch] = parse(ltRequirement);
65+
isLt =
66+
major < ltMajor ||
67+
(major === ltMajor &&
68+
(minor < ltMinor || (minor === ltMinor && patch < ltPatch)));
69+
}
70+
return isGte && isLt;
71+
72+
function parse(requirement: string) {
73+
return requirement.split(/[\.-]/).map((s) => parseInt(s, 10));
74+
}
6275
}
6376

6477
/**
@@ -570,7 +583,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
570583
);
571584
}
572585
// Top-level await was added in TS 3.8
573-
const tsVersionSupportsTla = versionGte(ts.version, '3.8.0');
586+
const tsVersionSupportsTla = versionGteLt(ts.version, '3.8.0');
574587
if (options.experimentalReplAwait === true && !tsVersionSupportsTla) {
575588
throw new Error(
576589
'Experimental REPL await is not compatible with TypeScript versions older than 3.8'

tests/esm-custom-loader/loader.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ const tsNodeInstance = register({
1111
},
1212
});
1313

14-
export const { resolve, getFormat, transformSource } = createEsmHooks(
14+
export const { resolve, getFormat, transformSource, load } = createEsmHooks(
1515
tsNodeInstance
1616
);

0 commit comments

Comments
 (0)