feat(examples): build flappy-bird as a self-contained single file (#223)
flappy-bird's end goal is a distributable binary; as a stepping stone it
builds a single dist/game.mjs that `node` runs on its own, with no
node_modules present. It's the example that demonstrates that ideal.
Bundle everything that CAN be bundled — vue, chalk, @vue-tui/runtime, yoga's
base64-inlined wasm, the SFC — and externalize ONLY Node builtins:
- The external predicate is `(id) => isBuiltin(id)`, inline in vite.config.ts
(no separate file for a one-liner). It has no relative/absolute path
heuristics, so the Windows path footgun behind vue-tui#209 can't exist here.
- rollupOptions.platform = "node" so rolldown emits 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).
Guard the property that actually matters in examples-smoke: build flappy-bird,
copy ONLY game.mjs into a fresh temp dir with no node_modules, and launch it
there — it must paint. Verified red->green: dropping platform:"node" brings
back the throwing require shim (build-shim check fails) and re-externalizing a
dep is ERR_MODULE_NOT_FOUND in the empty sandbox.
Closes #209. Supersedes #210 (which targeted the now-removed @vue-tui/cli).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { isBuiltin } from "node:module";
|
||||||
|
|
||||||
const here = fileURLToPath(new URL(".", import.meta.url));
|
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
// Build the example as a Node ESM bundle. SFCs are compiled by
|
// Build the example as a SELF-CONTAINED Node ESM bundle: a single dist/game.mjs that `node` runs
|
||||||
// @vitejs/plugin-vue; runtime deps (vue, @vue-tui/runtime, chalk, …) stay
|
// with no node_modules present (the stepping stone toward a distributable binary). Bundle EVERYTHING
|
||||||
// external so Node resolves them from node_modules at startup.
|
// 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({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
build: {
|
build: {
|
||||||
@@ -19,10 +25,10 @@ export default defineConfig({
|
|||||||
formats: ["es"],
|
formats: ["es"],
|
||||||
fileName: () => "game.mjs",
|
fileName: () => "game.mjs",
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
// Vite 8 is Rolldown-powered: rolldownOptions is the field (rollupOptions is the deprecated alias).
|
||||||
// Bundle anything resolved as a relative / absolute path (the .vue
|
rolldownOptions: {
|
||||||
// file + its `?vue` virtual modules); externalize bare specifiers.
|
platform: "node",
|
||||||
external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("\0"),
|
external: (id) => isBuiltin(id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect, afterEach } from "vite-plus/test";
|
import { test, expect, afterEach } from "vite-plus/test";
|
||||||
import { exampleDir, launch, viteBin, type Launched } from "./helpers/run-example.ts";
|
import { exampleDir, launch, viteBin, type Launched } from "./helpers/run-example.ts";
|
||||||
@@ -40,8 +41,9 @@ const TITLE_TOKEN = "vue-tui basic";
|
|||||||
|
|
||||||
// The two "hello world" apps are deterministic and key-free, so they get the full dev + build paint
|
// 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
|
// 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 intentionally absent: it doesn't use @vue-tui/vite at all
|
// a build-only guard below. flappy-bird is absent from THIS pair because it doesn't use @vue-tui/vite
|
||||||
// (it builds via `vp build` to dist/game.mjs), so it's outside the dev/build path #212 is about.
|
// — 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 = [
|
const RUNNABLE = [
|
||||||
{ name: "basic-template", dir: exampleDir("basic-template") },
|
{ name: "basic-template", dir: exampleDir("basic-template") },
|
||||||
{ name: "basic-jsx", dir: exampleDir("basic-jsx") },
|
{ name: "basic-jsx", dir: exampleDir("basic-jsx") },
|
||||||
@@ -92,3 +94,35 @@ for (const ex of RUNNABLE) {
|
|||||||
test("coding-agent: production build succeeds with no bundled CJS require (#212)", () => {
|
test("coding-agent: production build succeeds with no bundled CJS require (#212)", () => {
|
||||||
buildAndExpectNoCjsRequire(exampleDir("coding-agent"));
|
buildAndExpectNoCjsRequire(exampleDir("coding-agent"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user