From 4744fd9b3d79332139673146bf038b3093ca2b8c Mon Sep 17 00:00:00 2001 From: ThangYoga Date: Wed, 1 Jul 2026 23:57:46 +0700 Subject: [PATCH] fix(tui): map .js imports to .ts sources in esbuild bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig uses moduleResolution: nodenext, which requires every relative import to spell the .js extension (even when the file on disk is .ts or .tsx). esbuild does not auto-rewrite .js -> .ts for imports that already carry an explicit extension — it only walks resolveExtensions for extensionless imports. Without a resolver plugin, every `import x from "../lib/foo.js"` fails to resolve even though foo.ts is right there, and `hermes --tui` rebuilds fail on the first launch with "Could not resolve ../lib/foo.js". Add a small esbuild plugin that maps .js -> .tsx/.ts/.jsx/.js on disk and lets esbuild pick up the match. --- ui-tui/scripts/build.mjs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/ui-tui/scripts/build.mjs b/ui-tui/scripts/build.mjs index f9494ca43014..d77dfdabf93c 100644 --- a/ui-tui/scripts/build.mjs +++ b/ui-tui/scripts/build.mjs @@ -2,7 +2,7 @@ // Bundles src/entry.tsx into a single self-contained dist/entry.js. // No runtime node_modules needed. import { build } from 'esbuild' -import { readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' @@ -26,6 +26,36 @@ const stubDevtools = { } } +// `tsconfig.json` uses `moduleResolution: "nodenext"`, which requires every +// relative import to spell the `.js` extension (even when the file on disk +// is `.ts`/`.tsx`). esbuild, by default, does NOT rewrite `.js` -> `.ts` for +// imports that already carry an explicit extension — it only walks +// `resolveExtensions` for extensionless imports. Without this plugin every +// `import x from '../lib/foo.js'` would fail to resolve, even though +// `foo.ts` is sitting right there. Map `.js` -> `.tsx`/`.ts`/`.jsx` on disk +// and let esbuild pick up the match. We deliberately do NOT include `.js` +// in the lookup list — if a `.ts/.tsx/.jsx` rewrite isn't available the +// resolution is left to esbuild's default path (which handles real `.js` +// files in `node_modules/` and elsewhere). All returned paths are absolute +// (esbuild rejects relative paths from resolve plugins). +const jsToTsPlugin = { + name: 'js-to-ts', + setup(b) { + b.onResolve({ filter: /\.js$/ }, args => { + if (args.namespace === 'stub-devtools') return null + const base = args.path.replace(/\.js$/, '') + const dir = args.resolveDir + for (const ext of ['.tsx', '.ts', '.jsx']) { + const candidate = resolve(dir, base + ext) + if (existsSync(candidate) && statSync(candidate).isFile()) { + return { path: candidate } + } + } + return null + }) + } +} + await build({ entryPoints: [resolve(root, 'src/entry.tsx')], bundle: true, @@ -44,7 +74,7 @@ await build({ // circular async chain that hung the TUI at startup with only ANSI // reset bytes on screen (#31227). alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') }, - plugins: [stubDevtools], + plugins: [stubDevtools, jsToTsPlugin], // Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles // don't get a `require` binding automatically, so we inject one. banner: {