Skip to content

Commit 485cec8

Browse files
committed
refactor: repro tsx CJS node_modules resolution bug on Node 24+
Replace statuses/JSON repro with superagent-based HTTP fetch so the test exercises tsx's CJS require() hook — the actual failure surface on Node 24 where tsx wrongly rewrites intra-node_modules relative paths (.js → .jsx/.ts etc.). - swap statuses → superagent (CJS pkg with internal require() calls) - src/status.ts: fetchJson() wraps superagent GET - src/status.test.ts: offline local http server, two JSON round-trips - add tsx-pr (file:../tsx-fix) — local build of PR #803 fix branch - add test:pr script; drop @types/statuses - update README to document new bug, fix, and all four test variants Refs privatenumber/tsx#800, privatenumber/tsx#803
1 parent ac5b865 commit 485cec8

5 files changed

Lines changed: 1206 additions & 54 deletions

File tree

README.md

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,78 @@
1-
# tsx JSON bug reproduction
1+
# tsx CJS node_modules resolution bug reproduction
22

3-
Minimal repo that proves a regression introduced in **tsx v4.21.1** where `.json` files
4-
are incorrectly transformed as JavaScript, producing the error:
3+
Minimal repo that proves a regression where tsx on **Node 24+** incorrectly applies
4+
TypeScript extension resolution (`.js → .ts/.tsx/.jsx`) to `require()` calls made
5+
*inside* `node_modules`, breaking CJS packages such as **superagent**.
56

67
```
7-
SyntaxError: …/statuses/codes.json: Unexpected token 'v', "var _00="C"... is not valid JSON
8+
Error: Cannot find module '.../mime-db/index.jsx'
9+
at Object.<anonymous> (.../superagent/lib/node/index.js:...)
810
```
911

12+
Tracked in: [privatenumber/tsx#800](https://github.com/privatenumber/tsx/issues/800)
13+
Fixed by: [privatenumber/tsx#803](https://github.com/privatenumber/tsx/pull/803) (linked locally as `tsx-pr`)
14+
1015
## What's in here
1116

12-
| File | Purpose |
13-
| -------------------- | -------------------------------------------------------- |
14-
| `src/status.ts` | Imports `statuses` (which loads `codes.json` internally) |
15-
| `src/status.test.ts` | Three `node:test` assertions that exercise `status.ts` |
17+
| File | Purpose |
18+
| ---------------------- | -------------------------------------------------------------------- |
19+
| `src/status.ts` | Uses **superagent** (a CJS package) to `GET` a URL and return JSON |
20+
| `src/status.test.ts` | Two `node:test` assertions — spins up a local HTTP server, no network needed |
21+
| `../tsx-fix/` | Local checkout of the PR branch (`imevanc/tsx-fix@fix/node-modules-extension-rewrite`), built and linked as `tsx-pr` |
1622

1723
## Reproduce
1824

1925
```bash
2026
npm install
2127

22-
# ✅ Passes with tsx@4.21.0
28+
# ✅ Passes tsx@4.21.0 (unaffected, before the regression window)
2329
npm run test:good
2430

25-
# ❌ Fails with tsx@4.21.1
31+
# ❌ Fails tsx@4.21.1 (regression introduced here)
2632
npm run test:bad
33+
34+
# ❌ Fails — tsx@latest (still unfixed in the published release)
35+
npm run test:latest
36+
37+
# ✅ Passes — tsx PR #803 (local build of the fix branch)
38+
npm run test:pr
2739
```
2840

29-
Both scripts run the **identical** test command, only the tsx version differs:
41+
All scripts run the **same** test command, only the tsx version differs:
3042

3143
```
32-
node --test-reporter spec --import=tsx[-good|-bad]/esm --test 'src/**/*.test.ts'
44+
node --test-reporter spec --import=tsx[-good|-bad|-latest|-pr]/esm --test 'src/**/*.test.ts'
3345
```
3446

35-
The error only occurs with Node version 24, if I run `nvm use 22` before `npm run test:bad`, the tests pass just fine.
47+
> **Note:** The failure only manifests on **Node 24+**. Running `nvm use 22` before
48+
> `npm run test:bad` makes the tests pass because on Node 22 CJS `require()` calls
49+
> never go through the ESM hooks.
50+
51+
## Why it breaks
52+
53+
On Node 24, `module.registerHooks()` intercepts CJS `require()` calls through ESM
54+
sync hooks. When a CJS file inside `node_modules` does `require('./relative/path')`,
55+
tsx's resolver applies `mapTsExtensions()`, probing `.ts/.tsx/.jsx` candidates.
56+
`nextResolve` for each probe re-enters the hooks recursively and fails for every
57+
extension — including the original `.js` — because Node's native CJS resolver can't
58+
resolve these paths in the recursive hook context.
59+
60+
## The fix (PR #803)
3661

37-
## Why it breaks (this section is AI generate, so it might not be the actual reason)
62+
In `createResolveSync`, early-return to Node's native resolver when:
3863

39-
`statuses` ships a plain JSON file (`node_modules/statuses/codes.json`).
40-
Starting in 4.21.1, tsx's ESM loader began applying its esbuild transform pipeline
41-
to `.json` files. esbuild compiles JSON into a JS module (`var _00 = …`), but Node's
42-
`--experimental-json-modules` / import assertions path then tries to re-parse the
43-
_compiled output_ as raw JSON — and fails.
64+
1. The hook context is a CJS `require()` call,
65+
2. the parent file is inside `node_modules`, and
66+
3. the specifier is a relative file path.
67+
68+
This prevents tsx from ever trying to rewrite extensions for intra-`node_modules`
69+
requires, which matches the behaviour on Node 22.
70+
71+
## Building `tsx-pr` locally
72+
73+
```bash
74+
cd ../tsx-fix # the cloned PR branch
75+
pnpm install
76+
pnpm build
77+
# then back in this repo: npm install (picks up file:../tsx-fix)
78+
```

0 commit comments

Comments
 (0)