Skip to content

Commit 68f72f3

Browse files
committed
fix: preserve entrypoint with TypeScript preload hooks
- Fixes the tsx 4.21.1 silent-exit regression when a TypeScript preload registers an async loader hook before tsx runs - Covers TypeScript preloads via `NODE_OPTIONS` and direct `--import`, across `.ts`/`.mts` hooks, the `--import=<specifier>` form, and tsx referenced as a package name, file URL, or absolute path - Keeps the sync `module.registerHooks()` path for normal startup and sync-only preload composition - User-visible behavior change: affected TypeScript-preload setups now run the entrypoint instead of exiting with no output ## Problem TypeScript preloads can call `module.register()` before tsx initializes. On newer Node versions, tsx then preferred `module.registerHooks()`, and the entrypoint could exit with code 0 without evaluating. This PR intentionally scopes the async fallback to TypeScript preloads. A broader "any external `--import`" gate also fixes JavaScript `.mjs` async-loader preloads, but it regresses sync `registerHooks()` preload composition with `ERR_REQUIRE_CYCLE_MODULE`. Node does not expose a public signal for whether an earlier preload installed async hooks, so the JavaScript/MJS async-preload class should be handled as a separate follow-up design. ## Changes - Skip automatic tsx registration on Node's internal loader thread while preserving user Worker preloads - Detect TypeScript preloads in `NODE_OPTIONS` and `process.execArgv`, handling both `--import <specifier>` and `--import=<specifier>` forms and tsx referenced by package name, file URL, or absolute path - Fall back to async registration only for that TypeScript-preload startup shape - Add regression coverage with `.ts` and `.mts` hooks; entrypoint uses TypeScript-only syntax (`enum`) so the assertion proves tsx actually transformed
1 parent 69455cf commit 68f72f3

3 files changed

Lines changed: 176 additions & 8 deletions

File tree

src/esm/api/register.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,76 @@ export type Register = {
3737

3838
let cjsInteropApplied = false;
3939

40+
const collectImportSpecifiers = (
41+
argv: string[],
42+
) => {
43+
const imports: string[] = [];
44+
45+
for (let index = 0; index < argv.length; index += 1) {
46+
const argument = argv[index]!;
47+
if (argument === '--import') {
48+
const specifier = argv[index + 1];
49+
if (specifier) {
50+
imports.push(specifier);
51+
}
52+
index += 1;
53+
} else if (argument.startsWith('--import=')) {
54+
imports.push(argument.slice('--import='.length));
55+
}
56+
}
57+
58+
return imports;
59+
};
60+
61+
const collectNodeOptionsImportSpecifiers = () => [
62+
...(process.env.NODE_OPTIONS ?? '').matchAll(/(?:^|\s)--import(?:=|\s+)(\S+)/g),
63+
].map(([, specifier]) => specifier!);
64+
65+
const tsxImportUrls = [
66+
// pkgroll emits this register chunk in dist/, next to loader.mjs.
67+
new URL('loader.mjs', import.meta.url).toString(),
68+
new URL('esm/index.mjs', import.meta.url).toString(),
69+
];
70+
71+
const tsxImportSpecifiers = new Set([
72+
'tsx',
73+
'tsx/esm',
74+
...tsxImportUrls,
75+
...tsxImportUrls.map(url => decodeURI(new URL(url).pathname)),
76+
]);
77+
78+
const isTsxImport = (
79+
specifier: string,
80+
) => tsxImportSpecifiers.has(specifier);
81+
82+
const isTypeScriptImport = (
83+
specifier: string,
84+
) => /\.(?:[cm]?ts|tsx)(?:[?#].*)?$/.test(specifier);
85+
86+
const hasCliTypeScriptPreload = () => {
87+
const imports = collectImportSpecifiers(process.execArgv);
88+
const tsxImportIndex = imports.findIndex(isTsxImport);
89+
return tsxImportIndex > 0 && imports.slice(0, tsxImportIndex).some(isTypeScriptImport);
90+
};
91+
92+
const hasTypeScriptPreloadedImport = (
93+
collectNodeOptionsImportSpecifiers().some(isTypeScriptImport)
94+
|| hasCliTypeScriptPreload()
95+
);
96+
97+
const supportsRegisterHooks = (
98+
typeof module.registerHooks === 'function'
99+
&& isFeatureSupported(moduleRegisterHooksCjsReload)
100+
// Node does not expose whether a preload installed async hooks. The
101+
// failing shape needs a TypeScript preload before tsx, so keep the
102+
// async path scoped there to preserve sync registerHooks composition.
103+
// https://github.com/nodejs/node/blob/v26.0.0/doc/api/module.md#asynchronous-customization-hooks
104+
&& !hasTypeScriptPreloadedImport
105+
);
106+
40107
export const register: Register = (
41108
options,
42109
) => {
43-
const supportsRegisterHooks = (
44-
typeof module.registerHooks === 'function'
45-
&& isFeatureSupported(moduleRegisterHooksCjsReload)
46-
);
47-
48110
if (!module.register && !supportsRegisterHooks) {
49111
throw new Error(`This version of Node.js (${process.version}) does not support module.register(). Please upgrade to Node v18.19 or v20.6 and above.`);
50112
}

src/esm/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
import { isMainThread } from 'node:worker_threads';
1+
// Namespace import avoids a load-time SyntaxError on Node < 22.14, which
2+
// doesn't export isInternalThread.
3+
// https://github.com/nodejs/node/pull/56469
4+
import * as workerThreads from 'node:worker_threads';
25
import { isFeatureSupported, moduleRegister, moduleRegisterHooksCjsReload } from '../utils/node-features.js';
36
import { register } from './api/index.js';
47

58
// Loaded via --import flag
69
if (
7-
isFeatureSupported(moduleRegisterHooksCjsReload)
10+
(
11+
isFeatureSupported(moduleRegisterHooksCjsReload)
12+
// Keep user Worker preloads active; only skip Node's internal loader
13+
// thread, where async module.register() preloads can evaluate tsx itself.
14+
// https://github.com/nodejs/node/blob/v26.0.0/doc/api/worker_threads.md#worker_threadsisinternalthread
15+
&& !workerThreads.isInternalThread
16+
)
817
|| (
918
isFeatureSupported(moduleRegister)
10-
&& isMainThread
19+
&& workerThreads.isMainThread
1120
)
1221
) {
1322
register();

tests/specs/version-sensitive.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,103 @@ export const versionSensitiveTests = (node: NodeApis) => describe('Version-sensi
136136
);
137137
});
138138

139+
test('runs when a TypeScript pre-import registers an async loader', async () => {
140+
await using fixture = await createFixture({
141+
'package.json': createPackageJson({ type: 'module' }),
142+
'hook.ts': `
143+
import { register } from 'node:module';
144+
145+
register('fixture-loader/hook.mjs', import.meta.url);
146+
`,
147+
// enum forces tsx to actually transform the entrypoint; Node native
148+
// type-stripping rejects it, so the assertion proves the loader ran.
149+
'main.ts': 'enum Mode { Run = "run" } console.log("entrypoint:" + Mode.Run);',
150+
'node_modules/fixture-loader/hook.mjs': `
151+
export const resolve = async (specifier, context, nextResolve) => (
152+
nextResolve(specifier, context)
153+
);
154+
`,
155+
});
156+
157+
const { exitCode, stdout } = await node.tsx(['main.ts'], {
158+
cwd: fixture.path,
159+
env: {
160+
NODE_OPTIONS: `--import ${pathToFileURL(fixture.getPath('hook.ts')).toString()}`,
161+
},
162+
});
163+
164+
expect(exitCode).toBe(0);
165+
expect(stdout).toBe('entrypoint:run');
166+
});
167+
168+
test('runs when a direct TypeScript pre-import registers an async loader', async () => {
169+
await using fixture = await createFixture({
170+
'package.json': createPackageJson({ type: 'module' }),
171+
// .mts hook exercises the non-.ts branch of isTypeScriptImport,
172+
// and the --import=<specifier> form below exercises the equals-form
173+
// branch of collectImportSpecifiers.
174+
'hook.mts': `
175+
import { register } from 'node:module';
176+
177+
register('fixture-loader/hook.mjs', import.meta.url);
178+
`,
179+
'main.ts': 'enum Mode { Run = "run" } console.log("entrypoint:" + Mode.Run);',
180+
'node_modules/fixture-loader/hook.mjs': `
181+
export const resolve = async (specifier, context, nextResolve) => (
182+
nextResolve(specifier, context)
183+
);
184+
`,
185+
});
186+
187+
const process = await execaNode('main.ts', {
188+
cwd: fixture.path,
189+
nodePath: node.path,
190+
nodeOptions: [
191+
'--import=./hook.mts',
192+
'--import',
193+
tsxEsmPath,
194+
],
195+
reject: false,
196+
});
197+
198+
expect(process.exitCode).toBe(0);
199+
expect(process.stdout).toBe('entrypoint:run');
200+
});
201+
202+
test('runs when a direct TypeScript pre-import precedes tsx as an absolute path', async () => {
203+
await using fixture = await createFixture({
204+
'package.json': createPackageJson({ type: 'module' }),
205+
'hook.ts': `
206+
import { register } from 'node:module';
207+
208+
register('fixture-loader/hook.mjs', import.meta.url);
209+
`,
210+
// enum forces tsx to actually transform the entrypoint; Node native
211+
// type-stripping rejects it, so the assertion proves the loader ran.
212+
'main.ts': 'enum Mode { Run = "run" } console.log("entrypoint:" + Mode.Run);',
213+
'node_modules/fixture-loader/hook.mjs': `
214+
export const resolve = async (specifier, context, nextResolve) => (
215+
nextResolve(specifier, context)
216+
);
217+
`,
218+
});
219+
220+
const process = await execaNode('main.ts', {
221+
cwd: fixture.path,
222+
nodePath: node.path,
223+
nodeOptions: [
224+
'--import',
225+
'./hook.ts',
226+
'--import',
227+
decodeURI(new URL(tsxEsmPath).pathname),
228+
],
229+
reject: false,
230+
});
231+
232+
expect(process.exitCode).toBe(0);
233+
expect(process.stdout).toBe('entrypoint:run');
234+
});
235+
139236
test('sync ESM hook preserves CJS JSON require', async () => {
140237
await using fixture = await createFixture({
141238
'package.json': createPackageJson({ type: 'commonjs' }),

0 commit comments

Comments
 (0)