|
1 | | -# tsx JSON bug reproduction |
| 1 | +# tsx CJS node_modules resolution bug reproduction |
2 | 2 |
|
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**. |
5 | 6 |
|
6 | 7 | ``` |
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:...) |
8 | 10 | ``` |
9 | 11 |
|
| 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 | + |
10 | 15 | ## What's in here |
11 | 16 |
|
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` | |
16 | 22 |
|
17 | 23 | ## Reproduce |
18 | 24 |
|
19 | 25 | ```bash |
20 | 26 | npm install |
21 | 27 |
|
22 | | -# ✅ Passes with tsx@4.21.0 |
| 28 | +# ✅ Passes — tsx@4.21.0 (unaffected, before the regression window) |
23 | 29 | npm run test:good |
24 | 30 |
|
25 | | -# ❌ Fails with tsx@4.21.1 |
| 31 | +# ❌ Fails — tsx@4.21.1 (regression introduced here) |
26 | 32 | 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 |
27 | 39 | ``` |
28 | 40 |
|
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: |
30 | 42 |
|
31 | 43 | ``` |
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' |
33 | 45 | ``` |
34 | 46 |
|
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) |
36 | 61 |
|
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: |
38 | 63 |
|
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