Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions script/text-import-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
await esbuildBuild({
Expand All @@ -80,6 +70,9 @@ async function prebundleTs(sourcePath: string, outPath: string): Promise<void> {
jsx: "automatic",
external: ["node:*"],
banner: { js: REQUIRE_BANNER },
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
minify: false,
write: true,
});
Expand Down
16 changes: 16 additions & 0 deletions src/lib/init/ui/ink-ui-sidecar.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("./ink-app.js")> {
return import(inkAppPath as string) as Promise<typeof import("./ink-app.js")>;
}
58 changes: 16 additions & 42 deletions src/lib/init/ui/ink-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -213,22 +186,23 @@ function openFreshTtyForInk(): ReadStream | null {
export async function createInkUI(
opts: CreateInkUIOptions = {}
): Promise<InkUI> {
// 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,
Expand Down
Loading