refactor(vite)!: make @vue-tui/vite dev-only; build apps with tsdown (#247)

`vite build` is a browser-first bundler, so producing a runnable Node program
from it meant constantly overriding its defaults (platform, externalization,
inlineDynamicImports, module-preload). Building a Node app is a Node bundler's
job — so split vue-tui's two jobs across two tools:

- @vue-tui/vite is now DEV-ONLY (in-terminal dev server + HMR). Removed
  buildConfigPlugin and the externalize predicate: deleted src/build.ts,
  src/external.ts (+ external.spec.ts) and test/build.sequential.test.ts.
  vueTui() returns just the dev + dev-vmod plugins, and entry normalization is
  dev-only; dev.spec.ts updated.
- Production builds move to tsdown + unplugin-vue: a plain tsdown.config.ts that
  bundles the whole app into one self-contained Node file (dist/*.mjs) runnable
  with no node_modules. platform:node keeps builtins external and emits a real
  createRequire for CJS deps; deps.alwaysBundle inlines everything else.
- Migrated all five examples to tsdown (including flappy-bird, which hand-rolled
  a vite lib-mode self-contained build), and rewrote the examples smoke suite to
  build via tsdown and launch each self-contained bundle from an empty sandbox.
- Added tsdown + unplugin-vue-jsx to the catalog; updated the root and
  @vue-tui/vite READMEs.

BREAKING CHANGE: @vue-tui/vite no longer configures `vite build`. Build the app
with tsdown instead (see the @vue-tui/vite README, examples/*, and the starter).
This commit is contained in:
Yunfei He
2026-07-06 00:29:28 +08:00
committed by GitHub
parent e5015b0a80
commit 0a774ddc8c
24 changed files with 799 additions and 383 deletions
+2 -2
View File
@@ -99,9 +99,9 @@ createApp(App).mount();
## Packages
| Package | Description |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`@vue-tui/runtime`](https://www.npmjs.com/package/@vue-tui/runtime) | The core framework — Vue 3 renderer for the terminal with components (`Box`, `Text`, `Static`, etc.), composables (`useInput`, `useFocus`, `useApp`, etc.), and yoga-based flexbox layout. _API stabilizing._ |
| [`@vue-tui/vite`](https://www.npmjs.com/package/@vue-tui/vite) | Vite plugin — add `vueTui()` to `vite.config.ts` for an in-process terminal dev server with HMR (`npm run dev`) plus a production build (`vite build`). _Experimental; may change._ |
| [`@vue-tui/vite`](https://www.npmjs.com/package/@vue-tui/vite) | Vite plugin — add `vueTui()` to `vite.config.ts` for an in-process terminal dev server with HMR (`npm run dev`). Dev only; the production build is a plain `tsdown` config that bundles the app into one self-contained Node file (see the starter and `examples/*/tsdown.config.ts`). _Experimental; may change._ |
| [`@vue-tui/testing`](https://www.npmjs.com/package/@vue-tui/testing) | Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
| [`@vue-tui/components`](https://www.npmjs.com/package/@vue-tui/components) | High-level components built on the runtime primitives — currently `<Spinner>` (animated loading), with more to come. |
+4 -2
View File
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite build && node dist/main.js"
"build": "tsdown",
"preview": "tsdown && node dist/main.mjs"
},
"dependencies": {
"@vue-tui/runtime": "workspace:*",
@@ -16,6 +16,8 @@
"@types/node": "catalog:",
"@vitejs/plugin-vue-jsx": "^5",
"@vue-tui/vite": "workspace:*",
"tsdown": "catalog:",
"unplugin-vue-jsx": "catalog:",
"vite": "catalog:"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "tsdown";
import VueJsx from "unplugin-vue-jsx/rolldown";
// This example authors in JSX (.tsx), so the build uses unplugin-vue-jsx (Vue's JSX transform)
// instead of unplugin-vue. Self-contained Node build → dist/main.mjs. See
// examples/basic-template/tsdown.config.ts for the platform:node + deps.alwaysBundle rationale.
export default defineConfig({
entry: ["src/main.tsx"],
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [VueJsx()],
});
+4 -2
View File
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite build && node dist/main.js"
"build": "tsdown",
"preview": "tsdown && node dist/main.mjs"
},
"dependencies": {
"@vue-tui/runtime": "workspace:*",
@@ -16,6 +16,8 @@
"@types/node": "catalog:",
"@vitejs/plugin-vue": "^6",
"@vue-tui/vite": "workspace:*",
"tsdown": "catalog:",
"unplugin-vue": "catalog:",
"vite": "catalog:"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown";
// Production build: bundle the whole app into one self-contained Node file (dist/main.mjs) that
// `node` runs with no node_modules present. `platform: "node"` keeps Node builtins external and
// emits a real createRequire for CJS deps; `deps.alwaysBundle` inlines everything else (tsdown
// externalizes declared deps by default, the library behavior); `onlyBundle: false` silences the
// resulting "you are bundling dependencies" hint (intentional for an app). Dev/HMR is separate —
// it runs through vite + @vue-tui/vite (see vite.config.ts).
export default defineConfig({
entry: ["src/main.ts"],
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [Vue()],
});
+4 -2
View File
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite build && node dist/main.js"
"build": "tsdown",
"preview": "tsdown && node dist/main.mjs"
},
"dependencies": {
"@vue-tui/runtime": "workspace:*",
@@ -17,6 +17,8 @@
"@types/node": "catalog:",
"@vitejs/plugin-vue": "^6",
"@vue-tui/vite": "workspace:*",
"tsdown": "catalog:",
"unplugin-vue": "catalog:",
"vite": "catalog:"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown";
// Self-contained Node build → dist/main.mjs. See examples/basic-template/tsdown.config.ts for the
// rationale behind platform:node + deps.alwaysBundle + onlyBundle:false.
export default defineConfig({
entry: ["src/main.ts"],
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [Vue()],
});
+4 -4
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "vp build",
"preview": "vp build && node dist/game.mjs"
"build": "tsdown",
"preview": "tsdown && node dist/game.mjs"
},
"dependencies": {
"@vue-tui/runtime": "workspace:*",
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@vitejs/plugin-vue": "^6",
"vite": "catalog:"
"tsdown": "catalog:",
"unplugin-vue": "catalog:"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown";
// Self-contained Node build → dist/game.mjs, runnable with no node_modules present. See
// examples/basic-template/tsdown.config.ts for the platform:node + deps.alwaysBundle rationale.
export default defineConfig({
entry: { game: "src/main.ts" },
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [Vue()],
});
-34
View File
@@ -1,34 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath } from "node:url";
import { isBuiltin } from "node:module";
const here = fileURLToPath(new URL(".", import.meta.url));
// Build the example as a SELF-CONTAINED Node ESM bundle: a single dist/game.mjs that `node` runs
// with no node_modules present (the stepping stone toward a distributable binary). Bundle EVERYTHING
// that can be bundled (vue, chalk, @vue-tui/runtime, yoga's base64-inlined wasm, the SFC) and
// externalize ONLY Node's own builtins — isBuiltin() matches both "node:fs" and bare "fs". A
// builtins-only rule has no relative/absolute path heuristics, so the Windows path footgun behind
// vue-tui#209 can't exist here. `platform: "node"` makes rolldown emit a real
// createRequire(import.meta.url) for a CJS dep's require() instead of a stub that throws at startup
// (stack-utils does `require("module").builtinModules` at module load — the #212 fault class).
export default defineConfig({
plugins: [vue()],
build: {
target: "node22",
outDir: "dist",
emptyOutDir: true,
minify: false,
lib: {
entry: `${here}src/main.ts`,
formats: ["es"],
fileName: () => "game.mjs",
},
// Vite 8 is Rolldown-powered: rolldownOptions is the field (rollupOptions is the deprecated alias).
rolldownOptions: {
platform: "node",
external: (id) => isBuiltin(id),
},
},
});
+4 -2
View File
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite build && node dist/main.js"
"build": "tsdown",
"preview": "tsdown && node dist/main.mjs"
},
"dependencies": {
"@vue-tui/components": "workspace:*",
@@ -17,6 +17,8 @@
"@types/node": "catalog:",
"@vitejs/plugin-vue": "^6",
"@vue-tui/vite": "workspace:*",
"tsdown": "catalog:",
"unplugin-vue": "catalog:",
"vite": "catalog:"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown";
// Self-contained Node build → dist/main.mjs. See examples/basic-template/tsdown.config.ts for the
// rationale behind platform:node + deps.alwaysBundle + onlyBundle:false.
export default defineConfig({
entry: ["src/main.ts"],
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [Vue()],
});
@@ -3,34 +3,30 @@ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, afterEach } from "vite-plus/test";
import { exampleDir, launch, viteBin, type Launched } from "./helpers/run-example.ts";
import { exampleDir, launch, tsdownBin, viteBin, type Launched } from "./helpers/run-example.ts";
// End-to-end smoke test for the shipped examples (#212). The 0.1.0 crash —
// `Calling \`require\` for "node:module" in an environment that doesn't expose \`require\`` — came
// from the old @vue-tui/cli's bundledDev step, which folded CJS into a single ESM bundle. The
// @vue-tui/vite plugin (#215) deleted that path: dev runs in-process through Vite's SSR module
// runner and the production build externalizes every bare dep. This guards that the shipped
// examples still launch and paint, so a regression that reintroduces a module-system crash fails
// CI on every change.
// End-to-end smoke test for the shipped examples (#212). vue-tui splits its two jobs across two
// tools: DEV runs in-process through Vite + @vue-tui/vite (HMR); the production BUILD is a plain
// tsdown config that bundles the whole app into ONE self-contained Node file (dist/*.mjs) which
// `node` runs with NO node_modules present. The 0.1.0 crash — `Calling \`require\` for "node:module"
// in an environment that doesn't expose \`require\`` — came from folding a CJS dep's require() into
// an ESM bundle; tsdown's `platform: "node"` emits a real createRequire instead of that throwing
// shim. This suite guards that the shipped examples still launch and paint on both paths, so a
// regression that reintroduces a module-system crash fails CI on every change.
//
// Why a real PTY: a TUI gates its full paint on an interactive TTY (`interactive = !isInCi && isTTY`),
// so a piped/non-TTY child renders nothing — a non-PTY smoke test would be a false negative. Each
// runnable example is launched under a pseudo-terminal and we wait for its title to paint.
//
// What each path actually guards (be precise, don't oversell):
// - dev (`vite`): the in-process dev server boots and paints. In THIS monorepo the dev path
// BUNDLES @vue-tui/runtime (the workspace symlink's real path is outside node_modules, so Vite's
// SSR runner re-executes it), so it can't reproduce #212's externalized-load crash — it guards
// the dev plugin itself (client-compile, CLI-shortcut neutralization, HMR bridge, blank paint).
// - build (`node dist/main.js`): the bundle externalizes @vue-tui/runtime and Node loads it via
// native ESM. This is the externalized launch guard; if a regression let a CJS `require` survive
// into the ESM bundle (the #212 fault class), the shim throws at startup and this goes red
// (verified by injecting a bare `require()` into an entry — it reproduces #212 exactly).
//
// Coverage boundary: the externalized *dev* path a published `npm install` takes (runtime resolved
// through the SSR runner's externalize/conditions, not bundled) cannot be reproduced from an
// in-repo example because the workspace symlink forces bundling. It is NOT covered here; guarding it
// would need a packed-install fixture and belongs with @vue-tui/vite's own suite.
// What each path guards (be precise, don't oversell):
// - dev (`vite`): the in-process dev server boots and paints — the dev plugin itself
// (client-compile, CLI-shortcut neutralization, HMR bridge, blank paint). In THIS monorepo the
// dev path bundles @vue-tui/runtime (the workspace symlink's real path is outside node_modules),
// so it can't reproduce #212's module-system crash — that's the build path's job.
// - build (`node dist/*.mjs` from an EMPTY sandbox): the tsdown bundle is self-contained, so this
// is the standalone-launch guard. A dep that failed to bundle is ERR_MODULE_NOT_FOUND in the
// empty sandbox; a CJS require that survived into the ESM bundle (the #212 fault class) throws
// the shim at startup — both are launch failures, so this goes red fast.
// Both the template and JSX apps title themselves "vue-tui basic (…)". Letters-only this is
// "vuetuibasic", which the wrap-robust matcher in run-example.ts finds regardless of where the
@@ -39,34 +35,26 @@ import { exampleDir, launch, viteBin, type Launched } from "./helpers/run-exampl
// token (the test then fails via timeout), so that prop is load-bearing here, not cosmetic.
const TITLE_TOKEN = "vue-tui basic";
// The two "hello world" apps are deterministic and key-free, so they get the full dev + build paint
// check. coding-agent uses the same @vue-tui/vite build but needs a live LLM key to RUN, so it gets
// a build-only guard below. flappy-bird is absent from THIS pair because it doesn't use @vue-tui/vite
// — it's the SELF-CONTAINED example (raw vite, everything bundled into one dist/game.mjs), guarded by
// its own dedicated test at the bottom of this file instead of this plugin-shaped dev/build pair.
const RUNNABLE = [
{ name: "basic-template", dir: exampleDir("basic-template") },
{ name: "basic-jsx", dir: exampleDir("basic-jsx") },
] as const;
// The fingerprint #212 leaves in a built bundle: rolldown couldn't externalize a CJS `require`, so
// it emitted the runtime shim that throws on call. Asserting the bundle is free of this is a fast,
// deterministic #212 guard that needs no PTY and no API key — usable even for examples we can't run.
const CJS_REQUIRE_SHIM = /doesn't expose the `require` function|Calling `require` for/;
// Build an example and assert the bundle carries no #212 shim — the single home for that invariant,
// shared by the runnable apps (before they're launched) and the build-only coding-agent guard.
// `vite build` needs no TTY; a plain child process is enough. Bounded so a wedged build can't hang
// the worker (execFileSync blocks synchronously, so vitest's testTimeout can't preempt it).
function buildAndExpectNoCjsRequire(dir: string): void {
execFileSync("node", [viteBin(dir), "build"], {
// Build an example with its tsdown config and assert the bundle carries no #212 shim — the single
// home for that invariant, shared by the runnable apps (before they launch) and the build-only
// coding-agent guard. tsdown needs no TTY; execFileSync blocks synchronously (vitest's testTimeout
// can't preempt it), so it's bounded. Returns the bundle path.
function buildSelfContained(dir: string, outName: string): string {
execFileSync("node", [tsdownBin(dir)], {
cwd: dir,
stdio: "pipe",
timeout: 60000,
killSignal: "SIGKILL",
env: { ...process.env, CI: "false" },
});
expect(readFileSync(path.join(dir, "dist", "main.js"), "utf8")).not.toMatch(CJS_REQUIRE_SHIM);
const bundle = path.join(dir, "dist", outName);
expect(readFileSync(bundle, "utf8")).not.toMatch(CJS_REQUIRE_SHIM);
return bundle;
}
let running: Launched | undefined;
@@ -75,6 +63,29 @@ afterEach(() => {
running = undefined;
});
// Launch a self-contained bundle from a fresh dir holding ONLY that file and NO node_modules — the
// property that actually matters (the single file runs standalone). Running from the example's own
// dir couldn't catch a re-externalized dep (still present in its node_modules); an empty sandbox can.
async function expectSelfContainedPaints(bundle: string, token: string): Promise<void> {
const sandbox = mkdtempSync(path.join(tmpdir(), "vue-tui-selfcontained-"));
try {
const name = path.basename(bundle);
copyFileSync(bundle, path.join(sandbox, name));
running = launch("node", [name], sandbox);
await running.waitForRenderOrCrash(token);
} finally {
running?.kill();
running = undefined;
rmSync(sandbox, { recursive: true, force: true });
}
}
// The two deterministic, key-free "hello world" apps get the full dev + self-contained-build check.
const RUNNABLE = [
{ name: "basic-template", dir: exampleDir("basic-template") },
{ name: "basic-jsx", dir: exampleDir("basic-jsx") },
] as const;
for (const ex of RUNNABLE) {
test(`${ex.name}: dev server (vite) launches and paints a frame`, async () => {
running = launch("node", [viteBin(ex.dir)], ex.dir);
@@ -82,47 +93,20 @@ for (const ex of RUNNABLE) {
expect(running.output()).not.toMatch(CJS_REQUIRE_SHIM);
});
test(`${ex.name}: production build runs (node dist/main.js) and paints a frame`, async () => {
buildAndExpectNoCjsRequire(ex.dir);
running = launch("node", ["dist/main.js"], ex.dir);
await running.waitForRenderOrCrash(TITLE_TOKEN);
test(`${ex.name}: self-contained build (dist/main.mjs) runs with no node_modules`, async () => {
const bundle = buildSelfContained(ex.dir, "main.mjs");
await expectSelfContainedPaints(bundle, TITLE_TOKEN);
});
}
// coding-agent shares the @vue-tui/vite build path but needs an API key to run, so we can't paint
// it in CI. The build itself is key-free, so we still lock the #212 invariant where it matters.
test("coding-agent: production build succeeds with no bundled CJS require (#212)", () => {
buildAndExpectNoCjsRequire(exampleDir("coding-agent"));
// coding-agent shares the same tsdown build but needs a live LLM key to RUN, so we can't paint it in
// CI. The build itself is key-free, so we still lock the #212 invariant where it matters.
test("coding-agent: self-contained build succeeds with no bundled CJS require (#212)", () => {
buildSelfContained(exampleDir("coding-agent"), "main.mjs");
});
// flappy-bird builds a SELF-CONTAINED dist/game.mjs (everything bundled but Node builtins; the
// stepping stone toward a distributable binary). Guard the property that actually matters — the
// single file runs with NO node_modules — by building it, copying ONLY game.mjs into a fresh temp
// dir, and launching it there. Running from the example's own dir (like the apps above) couldn't
// catch a regression that re-externalized a dep: those deps are still present in node_modules. Here
// a re-externalized dep is ERR_MODULE_NOT_FOUND in the empty sandbox, and dropping platform:"node"
// brings back the throwing require shim — both are launch-failure signatures, so this goes red fast.
// flappy-bird builds a self-contained dist/game.mjs and runs standalone, same as the pair above.
test("flappy-bird: self-contained game.mjs runs with no node_modules", async () => {
const dir = exampleDir("flappy-bird");
execFileSync("node", [viteBin(dir), "build"], {
cwd: dir,
stdio: "pipe",
timeout: 60000,
killSignal: "SIGKILL",
env: { ...process.env, CI: "false" },
});
const bundlePath = path.join(dir, "dist", "game.mjs");
expect(readFileSync(bundlePath, "utf8")).not.toMatch(CJS_REQUIRE_SHIM);
// Isolate the bundle from the workspace: a dir with the single file and no node_modules at all.
const sandbox = mkdtempSync(path.join(tmpdir(), "flappy-selfcontained-"));
try {
copyFileSync(bundlePath, path.join(sandbox, "game.mjs"));
running = launch("node", ["game.mjs"], sandbox);
await running.waitForRenderOrCrash("press space to start");
} finally {
running?.kill();
running = undefined;
rmSync(sandbox, { recursive: true, force: true });
}
const bundle = buildSelfContained(exampleDir("flappy-bird"), "game.mjs");
await expectSelfContainedPaints(bundle, "press space to start");
});
@@ -25,6 +25,16 @@ export const viteBin = (cwd: string): string => {
return path.join(path.dirname(pkgPath), rel);
};
// Resolve an example's local tsdown CLI the same way — production builds go through tsdown (a
// self-contained Node bundle), not `vite build`.
export const tsdownBin = (cwd: string): string => {
const pkgPath = require.resolve("tsdown/package.json", { paths: [cwd] });
const pkg = require(pkgPath) as { bin?: string | Record<string, string> };
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.tsdown;
if (!rel) throw new Error(`could not locate tsdown's CLI bin from ${pkgPath}`);
return path.join(path.dirname(pkgPath), rel);
};
// Launch-failure signatures, so a broken example fails fast with a useful message instead of
// burning the whole render timeout. Two families:
// - module-system crashes (#212's `Calling \`require\` ... doesn't expose the \`require\``, plus the
+23 -23
View File
@@ -1,7 +1,7 @@
# @vue-tui/vite
Vite plugin for [vue-tui](https://github.com/vuejs-ai/vue-tui): an in-process terminal dev server
with HMR, plus a production build, for Vue apps that render to the terminal via `@vue-tui/runtime`.
with HMR, for Vue apps that render to the terminal via `@vue-tui/runtime`.
## Install
@@ -12,8 +12,8 @@ npm install -D @vue-tui/vite @vitejs/plugin-vue
## Usage
`vueTui()` adds the terminal dev server (HMR) and the production build. Bring your own SFC/JSX
compiler alongside it — `@vitejs/plugin-vue` for SFCs (or `@vitejs/plugin-vue-jsx` for JSX):
`vueTui()` adds the terminal dev server (HMR). Bring your own SFC/JSX compiler alongside it —
`@vitejs/plugin-vue` for SFCs (or `@vitejs/plugin-vue-jsx` for JSX):
```ts
// vite.config.ts
@@ -28,7 +28,6 @@ export default defineConfig({
- `vite` (dev) — boots the app in-process through Vite's SSR module runner and renders it to the
terminal, with state-preserving HMR.
- `vite build` — bundles a single Node entry (`dist/main.js`).
### Options
@@ -48,33 +47,34 @@ export default defineConfig({
});
```
## Build output
## Production build
By default the production build **externalizes** bare dependencies (`vue`, `@vue-tui/runtime`, …) —
Node resolves them from `node_modules` at runtime. This is the right shape for a library, or an app
shipped alongside its `node_modules`.
Distribution shape is yours to choose: to produce a **self-contained** single file (everything
bundled but Node builtins — e.g. toward a standalone binary), set your own build options in
`vite.config.ts` and the plugin yields to them:
`vueTui()` is **dev only** — it does not touch the production build. `vite build` is browser-first
and the wrong tool for a Node program, so build with [`tsdown`](https://tsdown.dev) instead: it
bundles the whole app into one self-contained Node file that runs with no `node_modules` present.
```ts
import vue from "@vitejs/plugin-vue";
import { isBuiltin } from "node:module";
// tsdown.config.ts
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown"; // or unplugin-vue-jsx/rolldown for a .tsx entry
export default defineConfig({
plugins: [vue(), vueTui()],
build: {
// Vite 8 is Rolldown-powered: the field is `rolldownOptions` (`rollupOptions` is the alias).
rolldownOptions: {
external: (id) => isBuiltin(id), // only Node builtins stay external
platform: "node", // real createRequire for any CJS dependency
output: { inlineDynamicImports: true }, // fold into one file
},
},
entry: ["src/main.ts"],
platform: "node", // keep Node builtins external; real createRequire for CJS deps
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false }, // inline every dep into the one file
plugins: [Vue()],
});
```
```sh
npm install -D tsdown unplugin-vue
tsdown # → dist/main.mjs, self-contained
```
See the [starter](https://github.com/vuejs-ai/vue-tui-starter) and this repo's `examples/` for
complete setups.
## License
MIT
-50
View File
@@ -1,50 +0,0 @@
import type { Plugin } from "vite";
import { isExternalId } from "./external.ts";
// Production build path: `vite build` → a Node entry. The dev plugins are apply: "serve" and this
// one is apply: "build", so they coexist in the vueTui() array and Vite applies the right set per
// mode.
//
// Vite 8 is Rolldown-powered: the build field is `rolldownOptions`; `rollupOptions` is a deprecated
// back-compat alias. We emit `rolldownOptions`, and detect a consumer's external under EITHER name
// (the alias proxy is only wired up later during config resolution, not in this config() hook).
//
// Distribution shape is the APP AUTHOR's call, not ours. By DEFAULT we externalize bare deps (Node
// resolves vue/@vue-tui/runtime/… from node_modules at runtime — the library / app-shipped-with-
// node_modules shape). But if the consumer sets their own external in vite.config.ts — e.g. to
// bundle everything into one self-contained file for a binary:
// rolldownOptions: { external: (id) => isBuiltin(id), platform: "node",
// output: { inlineDynamicImports: true } }
// — we YIELD to it. Vite merges a plugin's config() OVER the user config, so without this guard our
// predicate would silently clobber theirs (the consumer couldn't change the build shape).
export function buildConfigPlugin(opts: { entry?: string }): Plugin {
const entry = opts.entry ?? "src/main.ts";
return {
name: "vue-tui:build",
apply: "build",
config(userConfig) {
const userBuild = userConfig?.build;
const consumerSetExternal =
userBuild?.rolldownOptions?.external !== undefined ||
userBuild?.rollupOptions?.external !== undefined;
return {
build: {
// Node runs the output directly — keep modern syntax (top-level await, etc.) instead of
// down-leveling for browsers.
target: "esnext",
// The module-preload polyfill is a browser-only helper; it's meaningless for a Node entry.
modulePreload: false,
rolldownOptions: {
// Name the entry directly so the build does not look for an index.html.
input: entry,
// DEFAULT ONLY: externalize bare deps; relative/virtual/SFC ids stay bundled. Omitted
// when the consumer set their own external, so theirs takes effect instead of this.
...(consumerSetExternal ? {} : { external: (id: string) => isExternalId(id) }),
// Emit `<name>.js` (e.g. main.js) rather than a hashed asset name.
output: { entryFileNames: "[name].js" },
},
},
};
},
};
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Plugin } from "vite";
export const DEV_VMOD_ID = "virtual:vue-tui/dev";
// Rollup convention: a "\0"-prefixed id marks a virtual module so no other plugin /
// the filesystem tries to resolve it. Kept in the bundle by isExternalId().
// the filesystem tries to resolve it.
export const RESOLVED_DEV_VMOD_ID = "\0" + DEV_VMOD_ID;
// The snippet is TRANSFORMED by Vite (so its import.meta.hot is live, unlike the
+10 -26
View File
@@ -37,42 +37,30 @@ test("strips the query suffix before matching the entry", () => {
expect(out?.code).toBe(`${injectPrefix}export const x = 1;`);
});
// vueTui() normalizes the `entry` option so dev (which matches the absolute module id via
// endsWith) and build (which feeds rollupOptions.input) agree. Rooted forms — a leading "/"
// (root-relative / POSIX-absolute / UNC) or a Windows drive-letter — pass through unchanged;
// relative forms ("./src/x") get a leading slash for dev and the bare form for build. Each case
// below previously broke ONE side ("./" missed dev injection -> no HMR/overlay; a POSIX/UNC
// absolute had its slash stripped -> build UNRESOLVED_ENTRY), so assert dev AND build for all.
// vueTui() normalizes the dev `entry` so the dev plugin (which matches the absolute module id via
// endsWith) injects the HMR snippet on it. Rooted forms — a leading "/" (root-relative /
// POSIX-absolute / UNC) or a Windows drive-letter — pass through unchanged; relative forms
// ("./src/x") get a leading slash. The "./" case previously missed the module id (no HMR/overlay),
// so this pins the normalization across all forms. (The production build is tsdown's job now, not
// vueTui's, so there's no build-input side to assert here anymore.)
const ENTRY_CASES = [
{
name: "'./'-relative",
entry: "./src/app.ts",
id: "/Users/proj/src/app.ts",
buildInput: "src/app.ts",
},
{
name: "Windows drive-letter",
entry: "C:/proj/src/main.ts",
id: "C:/proj/src/main.ts",
buildInput: "C:/proj/src/main.ts",
},
{ name: "'./'-relative", entry: "./src/app.ts", id: "/Users/proj/src/app.ts" },
{ name: "Windows drive-letter", entry: "C:/proj/src/main.ts", id: "C:/proj/src/main.ts" },
{
name: "Windows UNC",
entry: "\\\\server\\share\\src\\main.ts",
id: "//server/share/src/main.ts",
buildInput: "//server/share/src/main.ts",
},
{
name: "POSIX-absolute",
entry: "/Users/proj/app/src/main.ts",
id: "/Users/proj/app/src/main.ts",
buildInput: "/Users/proj/app/src/main.ts",
},
];
test.each(ENTRY_CASES)(
"vueTui handles a $name entry: dev injects on the module id, build gets the right input",
({ entry, id, buildInput }) => {
"vueTui normalizes a $name entry so dev injects on the module id",
({ entry, id }) => {
const plugins = vueTui({ entry });
const dev = plugins.find((p) => p.name === "vue-tui:dev") as unknown as {
transform: TransformFn;
@@ -80,9 +68,5 @@ test.each(ENTRY_CASES)(
expect(dev.transform("export const x = 1;", id)?.code).toBe(
`${injectPrefix}export const x = 1;`,
);
const build = plugins.find((p) => p.name === "vue-tui:build") as unknown as {
config: () => { build: { rolldownOptions: { input: string } } };
};
expect(build.config().build.rolldownOptions.input).toBe(buildInput);
},
);
-22
View File
@@ -1,22 +0,0 @@
import { test, expect } from "vite-plus/test";
import { isExternalId } from "./external.ts";
test("bare imports are external; relative/virtual/\\0 stay bundled", () => {
expect(isExternalId("@vue-tui/runtime")).toBe(true);
expect(isExternalId("node:fs")).toBe(true);
expect(isExternalId("./app.vue")).toBe(false);
expect(isExternalId("/abs/x")).toBe(false);
expect(isExternalId("\0virtual:vue-tui/dev")).toBe(false);
expect(isExternalId("virtual:vue-tui/dev")).toBe(false);
});
// Regression for vue-tui#209: @vitejs/plugin-vue resolves the SFC to an ABSOLUTE
// path before this predicate runs. On Windows that's a drive-letter / UNC path that a
// POSIX-only `/`-prefix check misses, so the .vue file got externalized and the built
// `node dist/main.js` crashed with ERR_MODULE_NOT_FOUND. Absolute paths (both schemes)
// must stay bundled. Covered from Linux/macOS CI via literal win32 path strings.
test("windows-absolute SFC paths stay bundled (vue-tui#209)", () => {
expect(isExternalId("D:\\app\\src\\App.vue")).toBe(false); // drive-letter backslash
expect(isExternalId("D:/app/src/App.vue")).toBe(false); // drive-letter forward slash
expect(isExternalId("\\\\server\\share\\App.vue")).toBe(false); // UNC path
});
-18
View File
@@ -1,18 +0,0 @@
import { posix, win32 } from "node:path";
// Externalize bare imports (resolved from node_modules at runtime by Node) but KEEP
// in the bundle: relative imports, ABSOLUTE paths, Rollup virtual ids ("\0..."), and
// our "virtual:" ids (no on-disk file — externalizing them would crash at runtime).
//
// @vitejs/plugin-vue resolves the SFC to an ABSOLUTE path before this runs, so absolute
// paths must count as internal. We test BOTH posix.isAbsolute AND win32.isAbsolute, not a
// bare `/`-prefix check: on Windows the SFC resolves to a drive-letter path like
// `D:\app\src\App.vue` (or `D:/…`, or a `\\server\share` UNC path) that a POSIX-only `/`
// check misses — which left the .vue file external and crashed `node dist/main.js` with
// ERR_MODULE_NOT_FOUND on Windows (vue-tui#209, ported from the now-removed CLI's fix).
export function isExternalId(id: string): boolean {
if (id.startsWith("\0") || id.startsWith("virtual:")) return false;
if (id.startsWith(".")) return false;
if (posix.isAbsolute(id) || win32.isAbsolute(id)) return false;
return true;
}
+12 -17
View File
@@ -1,38 +1,33 @@
import type { Plugin } from "vite";
import { devVmodPlugin } from "./dev-vmod.ts";
import { devPlugin } from "./dev.ts";
import { buildConfigPlugin } from "./build.ts";
export interface VueTuiOptions {
entry?: string;
}
export function vueTui(options: VueTuiOptions = {}): Plugin[] {
// devPlugin (apply:"serve") and buildConfigPlugin (apply:"build") never run together — Vite
// picks the right set per mode. normalizeEntry() (below) derives the entry string each needs.
// vueTui() is a DEV-only toolkit: an in-terminal dev server with HMR. It does NOT touch the
// production build — `vite build` is browser-first and the wrong tool for a Node program. Bundle
// the app into a self-contained Node file with tsdown + unplugin-vue instead (see the
// vue-tui-starter template and examples/*/tsdown.config.ts).
//
// Bring your own SFC/JSX compiler alongside vueTui() — `[vue(), vueTui()]` for SFCs, or
// `[vueJsx(), vueTui()]` for JSX. devPlugin's configResolved finds whichever is present (by
// plugin name) and force-client-compiles it, so it emits CLIENT render functions for the
// terminal renderer even in Vite's SSR dev environment. vueTui deliberately does NOT bundle
// @vitejs/plugin-vue: the app's authoring format is the consumer's choice, kept explicit.
const { dev, build } = normalizeEntry(options.entry);
return [devPlugin({ entry: dev }), buildConfigPlugin({ entry: build }), devVmodPlugin()];
return [devPlugin({ entry: normalizeDevEntry(options.entry) }), devVmodPlugin()];
}
// Reconcile the entry for dev (matched against the absolute module id via endsWith) and build
// (fed to rolldownOptions.input). Anything already ROOTED passes through unchanged — a leading "/"
// (root-relative "/src/main.ts", a POSIX-absolute "/Users/x/…", or a UNC "//server/share/…") or a
// Windows drive-letter "C:/x": dev's endsWith matches the module id, and build accepts a "/"-input
// as root-relative and an absolute path as-is. Only the RELATIVE forms ("src/main.ts",
// "./src/main.ts") get a leading slash added for dev and the bare form for build. Backslashes are
// normalized to "/" first. (Stripping the slash off a POSIX/UNC absolute broke `vite build` with
// UNRESOLVED_ENTRY while dev still worked.)
function normalizeEntry(entry?: string): { dev: string; build: string } {
// Normalize the dev entry (matched against the absolute module id via endsWith). Anything already
// ROOTED passes through unchanged — a leading "/" (root-relative "/src/main.ts", a POSIX-absolute
// "/Users/x/…", or a UNC "//server/share/…") or a Windows drive-letter "C:/x". Only the RELATIVE
// forms ("src/main.ts", "./src/main.ts") get a leading slash added. Backslashes are normalized first.
function normalizeDevEntry(entry?: string): string {
const e = (entry ?? "src/main.ts").replace(/\\/g, "/");
if (e.startsWith("/") || /^[a-zA-Z]:\//.test(e)) return { dev: e, build: e };
const bare = e.replace(/^(?:\.\/)+/, "");
return { dev: `/${bare}`, build: bare };
if (e.startsWith("/") || /^[a-zA-Z]:\//.test(e)) return e;
return `/${e.replace(/^(?:\.\/)+/, "")}`;
}
export default vueTui;
@@ -1,92 +0,0 @@
// SEQUENTIAL: writes/removes the fixture's dist/ directory on the real fs, so concurrent
// builds racing on dist/ are pinned to a *.sequential.test.ts file.
//
// Uses a DEDICATED `build` fixture (a copy of `basic`): dev.sequential MUTATES
// fixtures/basic/src/app.vue (its hot-swap test swaps LABEL-A out), and file-parallelism
// (fileParallelism: true) would otherwise let that edit land in this build's output mid-run
// and break the toContain("LABEL-A") assertion. A private fixture removes the shared file.
//
// NOTE: We pass configFile: false and provide vueTui() plugins inline rather than loading the
// fixture's vite.config.ts. rolldown v0.2.1 (used by vite-plus-core) has a bug where bundling a
// config file that combines transform.define with a plugin transform returning { code, map: null }
// throws "TypeError: Cannot convert undefined or null to object" in the bundleConfigFile WASM
// binding. Bypassing config-file loading sidesteps the bug while still exercising the real build:
// buildConfigPlugin (apply: "build") sets rolldownOptions.input + the externalize predicate.
import { test, expect, afterEach } from "vite-plus/test";
import { fileURLToPath } from "node:url";
import { existsSync, rmSync } from "node:fs";
import { isBuiltin } from "node:module";
import { build, type Rollup } from "vite";
import vue from "@vitejs/plugin-vue";
import { vueTui } from "../src/index.ts";
const root = fileURLToPath(new URL("./fixtures/build", import.meta.url));
const dist = `${root}/dist`;
afterEach(() => {
rmSync(dist, { recursive: true, force: true });
});
test("vite build emits a single self-contained Node entry with deps externalized", async () => {
rmSync(dist, { recursive: true, force: true });
const output = await build({
root,
configFile: false,
plugins: [vue(), vueTui()],
logLevel: "silent",
});
// A single, named input resolves to one RollupOutput (not a watcher).
const result = (Array.isArray(output) ? output[0] : output) as Rollup.RollupOutput;
const entryChunk = result.output.find(
(c): c is Rollup.OutputChunk => c.type === "chunk" && c.isEntry,
);
expect(entryChunk?.fileName).toBe("main.js");
expect(existsSync(`${dist}/main.js`)).toBe(true);
const code = entryChunk!.code;
// Bare deps stay external bare imports for Node to resolve at runtime (not inlined).
expect(code).toMatch(/from\s*["']@vue-tui\/runtime["']/);
expect([...entryChunk!.imports]).toContain("@vue-tui/runtime");
// The relative app.vue id was bundled in, so its rendered content is present in the entry.
expect(code).toContain("LABEL-A");
});
test("a consumer's own rolldownOptions.external overrides the plugin default (self-contained build)", async () => {
rmSync(dist, { recursive: true, force: true });
// The consumer keeps vueTui() (for dev/HMR) but asks for a SELF-CONTAINED build in their own
// config: externalize only Node builtins, bundle everything else into one file. The plugin must
// YIELD its default externalize-deps predicate to this — distribution shape is the app author's
// call, and Vite merges plugin config() over user config, so without the yield theirs is clobbered.
// Vite 8 field is rolldownOptions (rollupOptions is the deprecated alias).
const output = await build({
root,
configFile: false,
plugins: [vue(), vueTui()],
logLevel: "silent",
build: {
rolldownOptions: {
external: (id: string) => isBuiltin(id),
platform: "node",
output: { inlineDynamicImports: true },
},
},
});
const result = (Array.isArray(output) ? output[0] : output) as Rollup.RollupOutput;
const entryChunk = result.output.find(
(c): c is Rollup.OutputChunk => c.type === "chunk" && c.isEntry,
)!;
// The consumer's external won: deps are bundled IN, not left external.
expect([...entryChunk.imports]).not.toContain("@vue-tui/runtime");
expect([...entryChunk.imports]).not.toContain("vue");
// Every surviving external import is a Node builtin (the consumer's isBuiltin predicate took effect).
for (const imp of entryChunk.imports) expect(isBuiltin(imp)).toBe(true);
// platform:"node" gave the bundle a real require, so no throwing CJS-require stub survived.
expect(entryChunk.code).not.toMatch(
/doesn't expose the `require` function|Calling `require` for/,
);
expect(entryChunk.code).toContain("LABEL-A");
});
+590 -6
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -25,6 +25,8 @@ catalog:
tsx: ^4.22.0
string-width: ^8.0.0
unplugin-vue: ^7.2.0
unplugin-vue-jsx: ^0.10.0
tsdown: ^0.22.3
vue-tsc: ^3.3.4
overrides:
# Pin vite's version spec tree-wide (incl. third-party peer ranges) to the catalog version.