Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update esm loader hooks API #1457

Merged
merged 36 commits into from Oct 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2108b58
Initial commit
jonaskello Sep 12, 2021
f8ef837
Merge branch 'main' into resolver-hooks-update
jonaskello Sep 12, 2021
073bbfc
Update hooks
jonaskello Sep 12, 2021
765932f
Merge branch 'resolver-hooks-update' of github.com:jonaskello/ts-node…
jonaskello Sep 12, 2021
786ec34
wip impl of load
jonaskello Sep 12, 2021
14220ac
Expose old hooks for backward compat
jonaskello Sep 12, 2021
cd44ef3
Some logging
jonaskello Sep 13, 2021
245242b
Add raw copy of default get format
jonaskello Sep 17, 2021
0b0d50c
Adapt defaultGetFormat() from node source
jonaskello Sep 17, 2021
5534722
Fix defaultTransformSource
jonaskello Sep 17, 2021
1030783
Add missing newline
jonaskello Sep 17, 2021
9f28485
Fix require
jonaskello Sep 17, 2021
662b4b8
Check node version to avoid deprecation warning
jonaskello Sep 17, 2021
469437b
Remove load from old hooks
jonaskello Sep 17, 2021
b102ee6
Add some comments
jonaskello Sep 18, 2021
2044208
Use versionGte
jonaskello Sep 18, 2021
b2fffe7
Remove logging
jonaskello Sep 18, 2021
d41204c
Refine comments
jonaskello Sep 18, 2021
da34bf7
Wording
jonaskello Sep 18, 2021
a7e3475
Use format hint if available
jonaskello Sep 18, 2021
e228e08
One more comment
jonaskello Sep 18, 2021
f4dee40
Nitpicky changes to comments
cspotcode Sep 18, 2021
0025586
Update index.ts
cspotcode Sep 18, 2021
6ffe9ea
lint-fix
cspotcode Sep 18, 2021
521a1e2
attempt at downloading node nightly in tests
cspotcode Sep 18, 2021
8b94f1b
fix
cspotcode Sep 18, 2021
10e5580
fix
cspotcode Sep 18, 2021
77acae6
Windows install of node nightly
cspotcode Sep 18, 2021
8e40254
update version checks to be ready for node backporting
cspotcode Sep 23, 2021
d3ed4a1
Add guards for undefined source
jonaskello Sep 25, 2021
544584a
More error info
jonaskello Sep 25, 2021
a23bc47
Skip source transform for builtin and commonjs
jonaskello Sep 25, 2021
63c0618
Update transpile-only.mjs
cspotcode Oct 6, 2021
ca9cde9
Merge remote-tracking branch 'origin/main' into resolver-hooks-update
cspotcode Oct 10, 2021
e576772
Tweak `createEsmHooks` type
cspotcode Oct 10, 2021
8b328d3
fix test to accomodate new api
cspotcode Oct 10, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/continuous-integration.yml
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]
flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
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 @@ -112,14 +112,43 @@ jobs:
typescript: next
typescriptFlag: next
downgradeNpm: true
# Node nightly
- flavor: 11
node: nightly
nodeFlag: nightly
typescript: latest
typescriptFlag: latest
downgradeNpm: true
steps:
# checkout code
- uses: actions/checkout@v2
# install node
- name: Use Node.js ${{ matrix.node }}
if: matrix.node != 'nightly'
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Use Node.js 16, will be subsequently overridden by download of nightly
if: matrix.node == 'nightly'
uses: actions/setup-node@v1
with:
node-version: 16
- name: Download Node.js nightly
if: matrix.node == 'nightly' && matrix.os == 'ubuntu'
run: |
export N_PREFIX=$(pwd)/n
npm install -g n
n nightly
sudo cp "${N_PREFIX}/bin/node" "$(which node)"
node --version
- name: Download Node.js nightly
if: matrix.node == 'nightly' && matrix.os == 'windows'
run: |
$version = (Invoke-WebRequest https://nodejs.org/download/nightly/index.json | ConvertFrom-json)[0].version
$url = "https://nodejs.org/download/nightly/$version/win-x64/node.exe"
$targetPath = (Get-Command node.exe).Source
Invoke-WebRequest -Uri $url -OutFile $targetPath
node --version
# lint, build, test
# Downgrade from npm 7 to 6 because 7 still seems buggy to me
- if: ${{ matrix.downgradeNpm }}
Expand Down
83 changes: 83 additions & 0 deletions dist-raw/node-esm-default-get-format.js
@@ -0,0 +1,83 @@
// Copied from https://raw.githubusercontent.com/nodejs/node/v15.3.0/lib/internal/modules/esm/get_format.js
// Then modified to suite our needs.
// Formatting is intentionally bad to keep the diff as small as possible, to make it easier to merge
// upstream changes and understand our modifications.

'use strict';
const {
RegExpPrototypeExec,
StringPrototypeStartsWith,
} = require('./node-primordials');
const { extname } = require('path');
const { getOptionValue } = require('./node-options');

const experimentalJsonModules = getOptionValue('--experimental-json-modules');
const experimentalSpeciferResolution =
getOptionValue('--experimental-specifier-resolution');
const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
const { getPackageType } = require('./node-esm-resolve-implementation.js').createResolve({tsExtensions: [], jsExtensions: []});
const { URL, fileURLToPath } = require('url');
const { ERR_UNKNOWN_FILE_EXTENSION } = require('./node-errors').codes;

const extensionFormatMap = {
'__proto__': null,
'.cjs': 'commonjs',
'.js': 'module',
'.mjs': 'module'
};

const legacyExtensionFormatMap = {
'__proto__': null,
'.cjs': 'commonjs',
'.js': 'commonjs',
'.json': 'commonjs',
'.mjs': 'module',
'.node': 'commonjs'
};

if (experimentalWasmModules)
extensionFormatMap['.wasm'] = legacyExtensionFormatMap['.wasm'] = 'wasm';

if (experimentalJsonModules)
extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json';

function defaultGetFormat(url, context, defaultGetFormatUnused) {
if (StringPrototypeStartsWith(url, 'node:')) {
return { format: 'builtin' };
}
const parsed = new URL(url);
if (parsed.protocol === 'data:') {
const [ , mime ] = RegExpPrototypeExec(
/^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/,
parsed.pathname,
) || [ null, null, null ];
const format = ({
'__proto__': null,
'text/javascript': 'module',
'application/json': experimentalJsonModules ? 'json' : null,
'application/wasm': experimentalWasmModules ? 'wasm' : null
})[mime] || null;
return { format };
} else if (parsed.protocol === 'file:') {
const ext = extname(parsed.pathname);
let format;
if (ext === '.js') {
format = getPackageType(parsed.href) === 'module' ? 'module' : 'commonjs';
} else {
format = extensionFormatMap[ext];
}
if (!format) {
if (experimentalSpeciferResolution === 'node') {
process.emitWarning(
'The Node.js specifier resolution in ESM is experimental.',
'ExperimentalWarning');
format = legacyExtensionFormatMap[ext];
} else {
throw new ERR_UNKNOWN_FILE_EXTENSION(ext, fileURLToPath(url));
}
}
return { format: format || null };
}
return { format: null };
}
exports.defaultGetFormat = defaultGetFormat;
1 change: 1 addition & 0 deletions esm.mjs
Expand Up @@ -6,6 +6,7 @@ const require = createRequire(fileURLToPath(import.meta.url));
const esm = require('./dist/esm');
export const {
resolve,
load,
getFormat,
transformSource,
} = esm.registerAndCreateEsmHooks();
1 change: 1 addition & 0 deletions esm/transpile-only.mjs
Expand Up @@ -6,6 +6,7 @@ const require = createRequire(fileURLToPath(import.meta.url));
const esm = require('../dist/esm');
export const {
resolve,
load,
getFormat,
transformSource,
} = esm.registerAndCreateEsmHooks({ transpileOnly: true });
7 changes: 3 additions & 4 deletions package.json
Expand Up @@ -70,10 +70,9 @@
"pre-debug": "npm run build-tsc && npm run build-pack",
"coverage-report": "nyc report --reporter=lcov",
"prepare": "npm run clean && npm run build-nopack",
"api-extractor": "api-extractor run --local --verbose"
},
"engines": {
"node": ">=12.0.0"
"api-extractor": "api-extractor run --local --verbose",
"esm-usage-example": "npm run build-tsc && cd esm-usage-example && node --experimental-specifier-resolution node --loader ../esm.mjs ./index",
"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"
},
"repository": {
"type": "git",
Expand Down
92 changes: 90 additions & 2 deletions src/esm.ts
@@ -1,4 +1,10 @@
import { getExtensions, register, RegisterOptions, Service } from './index';
import {
register,
getExtensions,
RegisterOptions,
Service,
versionGteLt,
} from './index';
import {
parse as parseUrl,
format as formatUrl,
Expand All @@ -12,9 +18,24 @@ import { normalizeSlashes } from './util';
const {
createResolve,
} = require('../dist-raw/node-esm-resolve-implementation');
const { defaultGetFormat } = require('../dist-raw/node-esm-default-get-format');

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

// NOTE ABOUT MULTIPLE EXPERIMENTAL LOADER APIS
//
// At the time of writing, this file implements 2x different loader APIs.
// Node made a breaking change to the loader API in https://github.com/nodejs/node/pull/37468
//
// We check the node version number and export either the *old* or the *new* API.
//
// Today, we are implementing the *new* API on top of our implementation of the *old* API,
// which relies on copy-pasted code from the *old* hooks implementation in node.
//
// In the future, we will likely invert this: we will copy-paste the *new* API implementation
// from node, build our implementation of the *new* API on top of it, and implement the *old*
// hooks API as a shim to the *new* API.

/** @internal */
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
// Automatically performs registration just like `-r ts-node/register`
Expand All @@ -32,7 +53,24 @@ export function createEsmHooks(tsNodeService: Service) {
preferTsExts: tsNodeService.options.preferTsExts,
});

return { resolve, getFormat, transformSource };
// The hooks API changed in node version X so we need to check for backwards compatibility.
// TODO: When the new API is backported to v12, v14, v16, update these version checks accordingly.
const newHooksAPI =
versionGteLt(process.versions.node, '17.0.0') ||
versionGteLt(process.versions.node, '16.999.999', '17.0.0') ||
versionGteLt(process.versions.node, '14.999.999', '15.0.0') ||
versionGteLt(process.versions.node, '12.999.999', '13.0.0');

// Explicit return type to avoid TS's non-ideal inferred type
const hooksAPI: {
resolve: typeof resolve;
getFormat: typeof getFormat | undefined;
transformSource: typeof transformSource | undefined;
load: typeof load | undefined;
} = newHooksAPI
? { resolve, load, getFormat: undefined, transformSource: undefined }
: { resolve, getFormat, transformSource, load: undefined };
return hooksAPI;

function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
Expand Down Expand Up @@ -76,6 +114,52 @@ 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: Format | null | undefined },
defaultLoad: typeof load
): Promise<{ format: Format; source: string | Buffer | undefined }> {
// If we get a format hint from resolve() on the context then use it
// otherwise call the old getFormat() hook using node's old built-in defaultGetFormat() that ships with ts-node
const format =
context.format ??
(await getFormat(url, context, defaultGetFormat)).format;

let source = undefined;
if (format !== 'builtin' && format !== 'commonjs') {
// Call the new defaultLoad() to get the source
const { source: rawSource } = await defaultLoad(
url,
{ format },
defaultLoad
);

if (rawSource === undefined || rawSource === null) {
throw new Error(
`Failed to load raw source: Format was '${format}' and url was '${url}''.`
);
}

// Emulate node's built-in old defaultTransformSource() so we can re-use the old transformSource() hook
const defaultTransformSource: typeof transformSource = async (
source,
_context,
_defaultTransformSource
) => ({ source });

// Call the old hook
const { source: transformedSource } = await transformSource(
rawSource,
{ url, format },
defaultTransformSource
);
source = transformedSource;
}

return { format, source };
}

type Format = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm';
async function getFormat(
url: string,
Expand Down Expand Up @@ -129,6 +213,10 @@ export function createEsmHooks(tsNodeService: Service) {
context: { url: string; format: Format },
defaultTransformSource: typeof transformSource
): Promise<{ source: string | Buffer }> {
if (source === null || source === undefined) {
throw new Error('No source');
}

const defer = () =>
defaultTransformSource(source, context, defaultTransformSource);

Expand Down
39 changes: 26 additions & 13 deletions src/index.ts
Expand Up @@ -47,18 +47,31 @@ export type {
const engineSupportsPackageTypeField =
parseInt(process.versions.node.split('.')[0], 10) >= 12;

function versionGte(version: string, requirement: string) {
const [major, minor, patch, extra] = version
.split(/[\.-]/)
.map((s) => parseInt(s, 10));
const [reqMajor, reqMinor, reqPatch] = requirement
.split('.')
.map((s) => parseInt(s, 10));
return (
major > reqMajor ||
(major === reqMajor &&
(minor > reqMinor || (minor === reqMinor && patch >= reqPatch)))
);
/** @internal */
export function versionGteLt(
version: string,
gteRequirement: string,
ltRequirement?: string
) {
const [major, minor, patch, extra] = parse(version);
const [gteMajor, gteMinor, gtePatch] = parse(gteRequirement);
const isGte =
major > gteMajor ||
(major === gteMajor &&
(minor > gteMinor || (minor === gteMinor && patch >= gtePatch)));
let isLt = true;
if (ltRequirement) {
const [ltMajor, ltMinor, ltPatch] = parse(ltRequirement);
isLt =
major < ltMajor ||
(major === ltMajor &&
(minor < ltMinor || (minor === ltMinor && patch < ltPatch)));
}
return isGte && isLt;

function parse(requirement: string) {
return requirement.split(/[\.-]/).map((s) => parseInt(s, 10));
}
}

/**
Expand Down Expand Up @@ -570,7 +583,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
);
}
// Top-level await was added in TS 3.8
const tsVersionSupportsTla = versionGte(ts.version, '3.8.0');
const tsVersionSupportsTla = versionGteLt(ts.version, '3.8.0');
if (options.experimentalReplAwait === true && !tsVersionSupportsTla) {
throw new Error(
'Experimental REPL await is not compatible with TypeScript versions older than 3.8'
Expand Down
2 changes: 1 addition & 1 deletion tests/esm-custom-loader/loader.mjs
Expand Up @@ -11,6 +11,6 @@ const tsNodeInstance = register({
},
});

export const { resolve, getFormat, transformSource } = createEsmHooks(
export const { resolve, getFormat, transformSource, load } = createEsmHooks(
tsNodeInstance
);