diff --git a/script/text-import-plugin.ts b/script/text-import-plugin.ts index c99bdc421..455edf697 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -26,13 +26,6 @@ * like `ink-frame.tsx` and third-party packages like `ink`, * `react`) so the file is fully self-contained. * - * Some of the inlined packages (e.g. `signal-exit`, used by Ink) - * are CJS modules that call `require("assert")` etc. esbuild - * wraps these in `__require` shims that throw "Dynamic require - * is not supported" at runtime. The banner injects a real - * `require` function via `createRequire` so CJS dependencies - * resolve Node builtins correctly inside the ESM bundle. - * * Non-TypeScript files (plain `.js`) are copied verbatim. * * Used by `script/build.ts` (single-file executable) and @@ -54,20 +47,17 @@ const TS_EXTENSIONS = new Set([".ts", ".tsx", ".jsx"]); /** * Banner injected into the pre-bundled sidecar JS. Provides a real - * `require` function so esbuild's CJS-wrapping `__require` shims - * can resolve Node.js builtins (`assert`, `events`, etc.) at runtime. - * Without this, `signal-exit` and other CJS deps of Ink throw - * "Dynamic require of 'assert' is not supported". + * `require` function so esbuild's CJS-wrapping `__require` shims can + * resolve Node.js builtins (`assert`, `events`, etc.) at runtime. */ const REQUIRE_BANNER = 'import { createRequire as ___cr } from "node:module";' + " var require = ___cr(import.meta.url);"; /** - * Pre-bundle a TypeScript/TSX source file into self-contained JS. + * Pre-bundle a TypeScript/TSX source file into a self-contained JS module. * All dependencies (local modules AND npm packages) are inlined; - * only `node:*` builtins are external since Bun resolves them - * natively inside `/$bunfs/`. + * only `node:*` builtins are external since Bun resolves them natively. */ async function prebundleTs(sourcePath: string, outPath: string): Promise { await esbuildBuild({ @@ -80,6 +70,9 @@ async function prebundleTs(sourcePath: string, outPath: string): Promise { jsx: "automatic", external: ["node:*"], banner: { js: REQUIRE_BANNER }, + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, minify: false, write: true, }); diff --git a/src/lib/init/ui/ink-ui-sidecar.ts b/src/lib/init/ui/ink-ui-sidecar.ts new file mode 100644 index 000000000..26d058a07 --- /dev/null +++ b/src/lib/init/ui/ink-ui-sidecar.ts @@ -0,0 +1,16 @@ +/** + * Compiled-binary sidecar loader for the Ink App. + * + * Only imported in the compiled Bun binary (when import.meta.url + * contains /$bunfs/). In dev mode this module is never loaded, so the + * `with { type: "file" }` static import never runs and the Bun module + * cache for ink-app.tsx is never poisoned — which would cause any + * subsequent import() of that path to return the path string instead + * of the module. + */ +// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun +import inkAppPath from "./ink-app.tsx" with { type: "file" }; + +export function loadInkApp(): Promise { + return import(inkAppPath as string) as Promise; +} diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 237ab8c69..52c2178d8 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -161,33 +161,6 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { return "success"; } -/** - * Embed the Ink App sidecar as a Bun-compile file resource. - * - * `with { type: "file" }` tells Bun.compile to embed the file into - * the binary's virtual filesystem (`/$bunfs/root/`) and replace the - * import with the embedded path string at runtime. The - * `text-import-plugin` in `script/build.ts` intercepts this during - * esbuild: it pre-bundles the .tsx source into self-contained JS - * (stripping TypeScript, inlining local deps and npm packages, - * injecting a `createRequire` banner for CJS deps), then marks the - * import external so Bun.compile picks up the resulting .js file. - * - * Why pre-bundle? Bun's `/$bunfs/` virtual FS uses a JavaScript - * parser, not TypeScript — raw .tsx fails on `import { type Foo }`. - * The `/$bunfs/` environment also has no `node_modules`, so all - * deps (ink, react, local modules) must be inlined. - * - * The npm/Node distribution never reaches `createInkUI()` (the - * factory routes there only on the Bun binary because Ink uses - * top-level await that esbuild can't emit in our CJS bundle), so - * the embedded file is unused on Node. We still produce it because - * the static import is unconditional; the bundle.ts cleanup step - * `unlink`s the unused sidecar after bundling. - */ -// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun -import inkAppPath from "./ink-app.tsx" with { type: "file" }; - /** * Open a fresh `/dev/tty` `ReadStream` for Ink to consume. Returns * `null` when `/dev/tty` isn't available (non-TTY environment, or @@ -213,22 +186,23 @@ function openFreshTtyForInk(): ReadStream | null { export async function createInkUI( opts: CreateInkUIOptions = {} ): Promise { - // Import the Ink App sidecar from the embedded file. The - // `with { type: "file" }` import above gives us the virtual path - // (e.g. `/$bunfs/root/ink-app-xxx.js`). The text-import-plugin - // pre-bundles the .tsx source into self-contained JS at build - // time, so the embedded file includes ink, react, and all local - // deps — no external resolution needed at runtime. - // - // NOTE: Do NOT append a query string (e.g. `?bridge=1`) to the - // path. Bun's `/$bunfs/` virtual filesystem does not support - // query strings — the path lookup fails with ENOENT. + // In the compiled binary: sidecar holds the `with { type: "file" }` + // import; detected via /$bunfs/ in import.meta.url. // - // `mountApp()` lives inside the sidecar so it uses the same - // ink/react instances as the App's hooks. Importing ink/react - // separately in this module would create a second copy of React, - // causing "Invalid hook call" errors at runtime. - const app = (await import(inkAppPath)) as typeof import("./ink-app.js"); + // In dev mode: use a variable import path so esbuild cannot statically + // analyse it and bundle ink-app.tsx into bin.js. A literal + // `import("./ink-app.js")` would cause esbuild to inline ink-app.tsx + // — pulling in ink's CJS modules — and Bun.compile then injects + // __promiseAll at wrong positions inside those modules. The variable + // breaks esbuild's static analysis while still resolving correctly at + // dev-mode runtime. The sidecar is never loaded in dev mode so Bun's + // module cache for ink-app.tsx is never poisoned. + const devPath = /* @__PURE__ */ (() => "./ink-app.js")(); + const app = ( + import.meta.url.includes("/$bunfs/") + ? await (await import("./ink-ui-sidecar.js")).loadInkApp() + : ((await import(devPath)) as typeof import("./ink-app.js")) + ) as typeof import("./ink-app.js"); const store = new WizardStore({ cliVersion: CLI_VERSION,