|
| 1 | +# Fix js-exec failing on both Bun and Node.js |
| 2 | + |
| 3 | +Fixes #159 |
| 4 | + |
| 5 | +## Problem |
| 6 | + |
| 7 | +`js-exec` commands hang for exactly `maxJsTimeoutMs` (default 10s) and exit with code 124 on every invocation, regardless of input complexity. Even `js-exec -c "console.log(42)"` fails. |
| 8 | + |
| 9 | +**Affected runtimes**: Bun (all versions), Node.js LTS (intermittently, due to bug #1) |
| 10 | + |
| 11 | +## Root Cause |
| 12 | + |
| 13 | +Two independent bugs compound to break js-exec: |
| 14 | + |
| 15 | +### Bug 1: Worker URL resolves to the wrong file after esbuild bundling |
| 16 | + |
| 17 | +`js-exec.ts` references the worker via: |
| 18 | + |
| 19 | +```ts |
| 20 | +const workerPath = fileURLToPath(new URL("./worker.js", import.meta.url)); |
| 21 | +``` |
| 22 | + |
| 23 | +After esbuild bundles `js-exec.ts` into `dist/bin/chunks/js-exec-XXXX.js`, the relative `./worker.js` resolves to `dist/bin/chunks/worker.js` at runtime -- which is the **Python worker** (copied there by `build:worker`). The js-exec worker exists as `dist/bin/chunks/js-exec-worker.js` but is never loaded. |
| 24 | + |
| 25 | +The Python worker expects `workerData` (passed at `new Worker(path, { workerData })` constructor time). The js-exec protocol sends input via `postMessage()`. Since `workerData` is undefined, the Python worker does nothing and hangs until the timeout fires. |
| 26 | + |
| 27 | +**Diagram of the name collision**: |
| 28 | + |
| 29 | +``` |
| 30 | +build:worker script copies: |
| 31 | + python3/worker.js --> dist/bin/chunks/worker.js <-- Python protocol |
| 32 | + js-exec/worker.js --> dist/bin/chunks/js-exec-worker.js <-- js-exec protocol |
| 33 | +
|
| 34 | +js-exec.ts references: |
| 35 | + new URL("./worker.js", import.meta.url) |
| 36 | + --> resolves to: dist/bin/chunks/worker.js (Python!) |
| 37 | +``` |
| 38 | + |
| 39 | +### Bug 2: Static import of `stripTypeScriptTypes` crashes Bun workers |
| 40 | + |
| 41 | +`worker.ts` line 12: |
| 42 | + |
| 43 | +```ts |
| 44 | +import { stripTypeScriptTypes } from "node:module"; |
| 45 | +``` |
| 46 | + |
| 47 | +`stripTypeScriptTypes` is a Node.js 23.2+ experimental API. Bun's `node:module` does not export this symbol. Since this is a static ESM named import, it causes a link-time error that crashes the worker thread before any code runs: |
| 48 | + |
| 49 | +``` |
| 50 | +SyntaxError: Export named 'stripTypeScriptTypes' not found in module 'node:module'. |
| 51 | +``` |
| 52 | + |
| 53 | +## Fix |
| 54 | + |
| 55 | +### Change 1: Rename worker output to avoid name collision (`js-exec.ts` + `package.json`) |
| 56 | + |
| 57 | +```diff |
| 58 | +- const workerPath = fileURLToPath(new URL("./worker.js", import.meta.url)); |
| 59 | ++ const workerPath = fileURLToPath(new URL("./js-exec-worker.js", import.meta.url)); |
| 60 | +``` |
| 61 | + |
| 62 | +The `build:worker` script already produces `js-exec-worker.js` in the chunks directories. This change makes the source reference match. |
| 63 | + |
| 64 | +The build script is also updated to output esbuild to `js-exec-worker.js` directly (instead of `worker.js` with a rename), keeping the source and build output consistent. |
| 65 | + |
| 66 | +### Change 2: Dynamic import with fallback (`worker.ts`) |
| 67 | + |
| 68 | +```diff |
| 69 | +- import { stripTypeScriptTypes } from "node:module"; |
| 70 | ++ let stripTypeScriptTypes: (code: string) => string; |
| 71 | ++ try { |
| 72 | ++ const nodeModule = await import("node:module"); |
| 73 | ++ stripTypeScriptTypes = |
| 74 | ++ (nodeModule as any).stripTypeScriptTypes ?? ((code: string) => code); |
| 75 | ++ } catch { |
| 76 | ++ stripTypeScriptTypes = (code: string) => code; |
| 77 | ++ } |
| 78 | +``` |
| 79 | + |
| 80 | +When `stripTypeScriptTypes` is unavailable (Bun, older Node.js), the fallback returns the source code unmodified. TypeScript type stripping (`.ts`/`.mts` files, `--strip-types` flag) becomes unavailable, but plain JavaScript execution works normally -- which is the common case. |
| 81 | + |
| 82 | +## Verification |
| 83 | + |
| 84 | +All tests run against the built `dist/` output (not source), matching what npm consumers receive. |
| 85 | + |
| 86 | +### Node.js v25.4.0 |
| 87 | + |
| 88 | +| Test | Result | |
| 89 | +|------|--------| |
| 90 | +| `js-exec -c "console.log(42)"` | exit=0, stdout=`"42"` | |
| 91 | +| `require("fs").readFileSync` | exit=0, stdout=`"{\"k\":1}"` | |
| 92 | +| `[1,2,3].reduce((a,b)=>a+b)` | exit=0, stdout=`"6"` | |
| 93 | +| `.ts` file auto-detection | exit=0, stdout=`"42"` (stripTypeScriptTypes works) | |
| 94 | + |
| 95 | +### Bun 1.3.11 |
| 96 | + |
| 97 | +| Test | Result | |
| 98 | +|------|--------| |
| 99 | +| `js-exec -c "console.log(42)"` | exit=0, stdout=`"42"` | |
| 100 | +| `require("fs").readFileSync` | exit=0, stdout=`"{\"k\":1}"` | |
| 101 | +| `[1,2,3].reduce((a,b)=>a+b)` | exit=0, stdout=`"6"` | |
| 102 | +| `fs.writeFileSync` cross-call persistence | exit=0, stdout=`"hi"` | |
| 103 | + |
| 104 | +**Before this fix**: 0% success rate (exit=124 timeout on every call). |
| 105 | +**After this fix**: 100% success rate on both runtimes. |
| 106 | + |
| 107 | +## Files Changed |
| 108 | + |
| 109 | +| File | Lines | Description | |
| 110 | +|------|-------|-------------| |
| 111 | +| `src/commands/js-exec/js-exec.ts` | +1/-1 | Worker URL: `./worker.js` -> `./js-exec-worker.js` | |
| 112 | +| `src/commands/js-exec/worker.ts` | +12/-1 | Static import -> dynamic import with fallback | |
| 113 | +| `package.json` | +1/-1 | `build:worker`: esbuild output to `js-exec-worker.js` | |
| 114 | +| `.gitignore` | +1 | Add generated `js-exec-worker.js` | |
| 115 | + |
| 116 | +## Notes |
| 117 | + |
| 118 | +- The fix is backward compatible: on Node.js 23.2+, `stripTypeScriptTypes` is loaded normally via the dynamic import path |
| 119 | +- No new dependencies added |
| 120 | +- The Python worker (`worker.js`) path and behavior are unchanged |
| 121 | +- On Bun, `.ts`/`.mts` execution will show the raw TypeScript source (type annotations are not stripped). This could be documented or addressed separately with a Bun-native TS strip implementation |
0 commit comments