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:
@@ -98,12 +98,12 @@ 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/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. |
|
||||
| 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`). 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. |
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
Generated
+590
-6
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user