Skip to content

Commit 8b4be1d

Browse files
committed
fix: ts-node --esm on Node v20
When running `ts-node --esm`, a child process is spawned with the `child-loader.mjs` loader, `dist/child/child-entrypoint.js` main, and `argv[2]` set to the base64 encoded compressed configuration payload. `child-loader.mjs` imports and re-exports the functions defined in `src/child/child-loader.ts`. These are initially set to empty loader hooks which call the next hook in line until they are defined by calling `lateBindHooks()`. `child-entrypoint.ts` reads the config payload from argv, and bootstraps the registration process, which then calls `lateBindHooks()`. Presumably, the reason for this hand-off is because `--loader` hooks do not have access to `process.argv`. Unfortunately, in node 20, they don't have access to anything else, either, so calling `lateBindHooks` is effectively a no-op; the `child-loader.ts` where the hooks end up getting bound is not the same one that is being used as the actual loader. To solve this, the following changes are added: 1. An `isLoaderThread` flag is added to the BootstrapState. If this flag is set, then no further processing is performed beyond binding the loader hooks. 2. `callInChild` adds the config payload to _both_ the argv and the loader URL as a query param. 3. In the `child-loader.mjs` loader, only on node v20 and higher, the config payload is read from `import.meta.url`, and `bootstrap` is called, setting the `isLoaderThread` flag. I'm not super enthusiastic about this implementation. It definitely feels like there's a refactoring opportunity to clean it up, as it adds some copypasta between child-entrypoint.ts and child-loader.mjs. A further improvement would be to remove the late-binding handoff complexity entirely, and _always_ pass the config payload on the loader URL rather than on process.argv.
1 parent 71e319c commit 8b4be1d

File tree

6 files changed

+50
-22
lines changed

6 files changed

+50
-22
lines changed

child-loader.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { fileURLToPath } from 'url';
21
import { createRequire } from 'module';
2+
import { fileURLToPath } from 'url';
3+
34
const require = createRequire(fileURLToPath(import.meta.url));
45

56
// TODO why use require() here? I think we can just `import`
67
/** @type {import('./dist/child-loader')} */
78
const childLoader = require('./dist/child/child-loader');
8-
export const { resolve, load, getFormat, transformSource } = childLoader;
9+
export const { resolve, load, getFormat, transformSource, bindFromLoaderThread } = childLoader;
10+
11+
bindFromLoaderThread(import.meta.url);

esm.mjs

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,7 @@
11
import { fileURLToPath } from 'url';
22
import { createRequire } from 'module';
3-
import { versionGteLt } from './dist/util.js';
43
const require = createRequire(fileURLToPath(import.meta.url));
54

65
/** @type {import('./dist/esm')} */
76
const esm = require('./dist/esm');
8-
export const { resolve, load, getFormat, transformSource } = esm.registerAndCreateEsmHooks();
9-
10-
// Affordance for node 20, where load() happens in an isolated thread
11-
const offThreadLoader = versionGteLt(process.versions.node, '20.0.0');
12-
export const globalPreload = () => {
13-
if (!offThreadLoader) {
14-
return '';
15-
}
16-
const self = fileURLToPath(import.meta.url);
17-
return `
18-
const { createRequire } = getBuiltin('module');
19-
const require = createRequire(${JSON.stringify(self)});
20-
require('./dist/esm').registerAndCreateEsmHooks();
21-
`;
22-
};
7+
export const { resolve, load, getFormat, transformSource, globalPreload } = esm.registerAndCreateEsmHooks();

src/bin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface BootstrapState {
7171
parseArgvResult: ReturnType<typeof parseArgv>;
7272
phase2Result?: ReturnType<typeof phase2>;
7373
phase3Result?: ReturnType<typeof phase3>;
74+
isLoaderThread?: boolean;
7475
}
7576

7677
/** @internal */
@@ -441,7 +442,7 @@ function getEntryPointInfo(state: BootstrapState) {
441442
}
442443

443444
function phase4(payload: BootstrapState) {
444-
const { isInChildProcess, tsNodeScript } = payload;
445+
const { isInChildProcess, tsNodeScript, isLoaderThread } = payload;
445446
const { version, showConfig, restArgs, code, print, argv } = payload.parseArgvResult;
446447
const { cwd } = payload.phase2Result!;
447448
const { preloadedConfig } = payload.phase3Result!;
@@ -522,8 +523,12 @@ function phase4(payload: BootstrapState) {
522523

523524
if (replStuff) replStuff.state.path = join(cwd, REPL_FILENAME(service.ts.version));
524525

525-
if (isInChildProcess)
526+
if (isInChildProcess) {
526527
(require('./child/child-loader') as typeof import('./child/child-loader')).lateBindHooks(createEsmHooks(service));
528+
// we should not do anything else at this point in the loader thread,
529+
// let the entrypoint run the actual program.
530+
if (isLoaderThread) return;
531+
}
527532

528533
// Bind REPL service to ts-node compiler service (chicken-and-egg problem)
529534
replStuff?.repl.setService(service);

src/child/child-loader.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import type { NodeLoaderHooksAPI1, NodeLoaderHooksAPI2 } from '..';
22
import { filterHooksByAPIVersion } from '../esm';
3+
import { URL } from 'url';
4+
import { bootstrap } from '../bin';
5+
import { versionGteLt } from '../util';
6+
import { argPrefix, decompress } from './argv-payload';
7+
8+
// On node v20, we cannot lateBind the hooks from outside the loader thread
9+
// so it has to be done in the loader thread.
10+
export function bindFromLoaderThread(loaderURL: string) {
11+
// If we aren't in a loader thread, then skip this step.
12+
if (!versionGteLt(process.versions.node, '20.0.0')) return;
13+
14+
const url = new URL(loaderURL);
15+
const base64Payload = url.searchParams.get(argPrefix);
16+
if (!base64Payload) throw new Error('unexpected loader url');
17+
const state = decompress(base64Payload);
18+
state.isInChildProcess = true;
19+
state.isLoaderThread = true;
20+
bootstrap(state);
21+
}
322

423
let hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2;
524

src/child/spawn-child.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ import { argPrefix, compress } from './argv-payload';
1010
* the child process.
1111
*/
1212
export function callInChild(state: BootstrapState) {
13+
const loaderURL = pathToFileURL(require.resolve('../../child-loader.mjs'));
14+
const compressedState = compress(state);
15+
loaderURL.searchParams.set(argPrefix, compressedState);
16+
1317
const child = spawn(
1418
process.execPath,
1519
[
1620
'--require',
1721
require.resolve('./child-require.js'),
1822
'--loader',
1923
// Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/`
20-
pathToFileURL(require.resolve('../../child-loader.mjs')).toString(),
24+
loaderURL.toString(),
2125
require.resolve('./child-entrypoint.js'),
22-
`${argPrefix}${compress(state)}`,
26+
`${argPrefix}${compressedState}`,
2327
...state.parseArgvResult.restArgs,
2428
],
2529
{

src/esm.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export namespace NodeLoaderHooksAPI1 {
4343
export interface NodeLoaderHooksAPI2 {
4444
resolve: NodeLoaderHooksAPI2.ResolveHook;
4545
load: NodeLoaderHooksAPI2.LoadHook;
46+
globalPreload?: NodeLoaderHooksAPI2.GlobalPreload;
4647
}
4748
export namespace NodeLoaderHooksAPI2 {
4849
export type ResolveHook = (
@@ -74,6 +75,7 @@ export namespace NodeLoaderHooksAPI2 {
7475
export interface NodeImportAssertions {
7576
type?: 'json';
7677
}
78+
export type GlobalPreload = () => string;
7779
}
7880

7981
export type NodeLoaderHooksFormat = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm';
@@ -111,14 +113,24 @@ export function createEsmHooks(tsNodeService: Service) {
111113
const nodeResolveImplementation = tsNodeService.getNodeEsmResolver();
112114
const nodeGetFormatImplementation = tsNodeService.getNodeEsmGetFormat();
113115
const extensions = tsNodeService.extensions;
116+
const useLoaderThread = versionGteLt(process.versions.node, '20.0.0');
114117

115118
const hooksAPI = filterHooksByAPIVersion({
116119
resolve,
117120
load,
118121
getFormat,
119122
transformSource,
123+
globalPreload: useLoaderThread ? globalPreload : undefined,
120124
});
121125

126+
function globalPreload() {
127+
return `
128+
const { createRequire } = getBuiltin('module');
129+
const require = createRequire(${JSON.stringify(__filename)});
130+
require('./index').register();
131+
`;
132+
}
133+
122134
function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
123135
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
124136
const { protocol } = parsed;

0 commit comments

Comments
 (0)