From 7e08b585ace9d19b500bef654b8976805fd52187 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 8 Sep 2026 19:32:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(bankai):=20bankai=20v10=20=E2=80=94=20the?= =?UTF-8?q?=20isomorphic=20compiler=20&=20server=20on=20Vite=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command, zero config, and only the client ever bundles: v8 server code is plain ESM that Node runs as-authored, so there is no server build to rot (the lesson of v9). - start: Vite middleware mode + HMR with per-request streaming SSR via ssrLoadModule; a virtual client entry generates the browser glue so the user writes exactly one isomorphic module (plan decision D5). - build: client bundle via Vite 8/Rolldown, manifest-derived route assets in dist/bankai.json, service worker built with the precache list injected (choo-service-worker convention, manifest edition), brotli+gzip precompression of every text asset. - serve: immutable caching + precompressed negotiation for hashed assets, 103 Early Hints (res.writeEarlyHints) with the route's assets before every page, streaming SSR, and a window.initialState tail with script-breakout-safe serialization and choo internals filtered out. - inspect: raw/gzip/brotli size report. Integration tests drive the real counter example through build and serve, asserting the 103 interim response at the HTTP level. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd --- package.json | 2 +- packages/bankai/cli.js | 59 ++++++++++++ packages/bankai/index.js | 6 ++ packages/bankai/lib/app.js | 23 +++++ packages/bankai/lib/build.js | 114 ++++++++++++++++++++++ packages/bankai/lib/dev.js | 59 ++++++++++++ packages/bankai/lib/document.js | 55 +++++++++++ packages/bankai/lib/inspect.js | 56 +++++++++++ packages/bankai/lib/plugin.js | 28 ++++++ packages/bankai/lib/serve.js | 116 +++++++++++++++++++++++ packages/bankai/package.json | 32 +++++++ packages/bankai/test/build-serve.test.js | 112 ++++++++++++++++++++++ packages/bankai/test/document.test.js | 55 +++++++++++ 13 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 packages/bankai/cli.js create mode 100644 packages/bankai/index.js create mode 100644 packages/bankai/lib/app.js create mode 100644 packages/bankai/lib/build.js create mode 100644 packages/bankai/lib/dev.js create mode 100644 packages/bankai/lib/document.js create mode 100644 packages/bankai/lib/inspect.js create mode 100644 packages/bankai/lib/plugin.js create mode 100644 packages/bankai/lib/serve.js create mode 100644 packages/bankai/package.json create mode 100644 packages/bankai/test/build-serve.test.js create mode 100644 packages/bankai/test/document.test.js diff --git a/package.json b/package.json index dae178d..a24ae41 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "node": ">=24" }, "scripts": { - "test": "node --test packages/core/test/ packages/html/test/ packages/component/test/ packages/devtools/test/ packages/migrate/test/", + "test": "node --test packages/core/test/ packages/html/test/ packages/component/test/ packages/devtools/test/ packages/migrate/test/ packages/bankai/test/", "test:e2e": "node --test test/e2e/", "bench": "node bench/render.js", "bench:browser": "node bench/real-browser.js" diff --git a/packages/bankai/cli.js b/packages/bankai/cli.js new file mode 100644 index 0000000..0d624b0 --- /dev/null +++ b/packages/bankai/cli.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// bankai [entry] [options] +// +// bankai start dev server: Vite + HMR + streaming SSR +// bankai build production build (client bundle, manifest, +// service worker, precompression) +// bankai serve production server for a built app: static +// assets + 103 Early Hints + streaming SSR +// bankai inspect size report for the built output +// +// options: --port --out --title + +import { parseArgs } from 'node:util' + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + port: { type: 'string' }, + out: { type: 'string', default: 'dist' }, + title: { type: 'string', default: 'choo' } + } +}) + +const [command, entry] = positionals +const port = values.port ? Number(values.port) : 8080 + +try { + if (command === 'start') { + requireEntry(entry) + const { default: dev } = await import('./lib/dev.js') + const { origin } = await dev({ entry, port, title: values.title }) + console.log(`bankai: dev server streaming on ${origin}`) + } else if (command === 'build') { + requireEntry(entry) + const { default: build } = await import('./lib/build.js') + const { outDir, compressed } = await build({ entry, outDir: values.out, title: values.title }) + console.log(`bankai: built to ${outDir} (${compressed} asset(s) precompressed) — bankai serve to run it`) + } else if (command === 'serve') { + const { default: serve } = await import('./lib/serve.js') + const { origin } = await serve({ outDir: values.out, port }) + console.log(`bankai: production server on ${origin}`) + } else if (command === 'inspect') { + const { default: inspect } = await import('./lib/inspect.js') + await inspect({ outDir: values.out }) + } else { + console.log('usage: bankai [entry] [--port n] [--out dir] [--title text]') + process.exit(command ? 1 : 0) + } +} catch (err) { + console.error('bankai:', err.message) + process.exit(1) +} + +function requireEntry (value) { + if (!value) { + console.error('bankai: an entry module is required, e.g. bankai start app.js') + process.exit(1) + } +} diff --git a/packages/bankai/index.js b/packages/bankai/index.js new file mode 100644 index 0000000..b7f5999 --- /dev/null +++ b/packages/bankai/index.js @@ -0,0 +1,6 @@ +export { default as dev } from './lib/dev.js' +export { default as build } from './lib/build.js' +export { default as serve } from './lib/serve.js' +export { default as inspect } from './lib/inspect.js' +export { documentHead, documentTail, serializeState, assetLinks } from './lib/document.js' +export { resolveApp } from './lib/app.js' diff --git a/packages/bankai/lib/app.js b/packages/bankai/lib/app.js new file mode 100644 index 0000000..5b48872 --- /dev/null +++ b/packages/bankai/lib/app.js @@ -0,0 +1,23 @@ +// The isomorphic entry contract (plan decision D5): the entry module's +// default export is either a choo app instance (typically +// `export default app.mount('body')` — mount() on the server records the +// selector and returns the app) or a factory returning one (preferred: +// fresh state per request). One module, three consumers: browser mount, +// server render, build metadata. + +export function resolveApp (mod, entry) { + const value = mod && mod.default + if (!value) { + throw new Error(`bankai: ${entry} has no default export — export your choo app (or a function returning one)`) + } + // a factory is a plain function; an app instance has .mount + const app = typeof value === 'function' && !value.mount ? value() : value + if (typeof app.toStream !== 'function') { + throw new Error(`bankai: the default export of ${entry} is not a choo v8 app (missing toStream)`) + } + return app +} + +export function selectorOf (app) { + return app.selector || 'body' +} diff --git a/packages/bankai/lib/build.js b/packages/bankai/lib/build.js new file mode 100644 index 0000000..c469013 --- /dev/null +++ b/packages/bankai/lib/build.js @@ -0,0 +1,114 @@ +// bankai build — the production build. Only the CLIENT is bundled: the +// server side of a choo v8 app is plain ESM that Node runs as-authored, +// so there is no server build to rot. Outputs: +// dist/assets/* hashed client chunks + css +// dist/.vite/manifest.json Vite's build manifest +// dist/bankai.json bankai's route/asset metadata for serving +// dist/sw.js service worker (if /sw.js exists), +// with the precached asset list defined in +// *.br / *.gz precompressed siblings for every text asset + +import { readFile, writeFile, readdir, stat, access } from 'node:fs/promises' +import { join, dirname, resolve } from 'node:path' +import { brotliCompress, gzip, constants } from 'node:zlib' +import { promisify } from 'node:util' + +import bankaiPlugin, { CLIENT_ID } from './plugin.js' + +const brotli = promisify(brotliCompress) +const gzipP = promisify(gzip) + +export default async function buildApp ({ entry, outDir = 'dist', title = 'choo' }) { + const { build } = await import('vite') + entry = resolve(entry) + outDir = resolve(outDir) + + await build({ + appType: 'custom', + plugins: [bankaiPlugin(entry)], + logLevel: 'warn', + build: { + outDir, + emptyOutDir: true, + manifest: true, + rollupOptions: { input: { main: CLIENT_ID } } + } + }) + + // derive the route-independent asset set from Vite's manifest + const manifest = JSON.parse(await readFile(join(outDir, '.vite', 'manifest.json'), 'utf8')) + const entryChunk = Object.values(manifest).find((m) => m.isEntry) + const modulepreload = (entryChunk.imports || []).map((key) => '/' + manifest[key].file) + const assets = { + scripts: ['/' + entryChunk.file], + modulepreload, + css: collectCss(manifest, entryChunk) + } + + const meta = { title, entry, assets, builtAt: new Date().toISOString() } + await writeFile(join(outDir, 'bankai.json'), JSON.stringify(meta, null, 2)) + + // service worker: /sw.js, built standalone with the asset + // list injected — the choo-service-worker convention, manifest edition + const swSource = join(dirname(entry), 'sw.js') + if (await exists(swSource)) { + const precache = ['/', ...assets.scripts, ...assets.modulepreload, ...assets.css] + await build({ + appType: 'custom', + logLevel: 'warn', + define: { __BANKAI_ASSETS__: JSON.stringify(precache) }, + build: { + outDir, + emptyOutDir: false, + rollupOptions: { + input: { sw: swSource }, + output: { entryFileNames: 'sw.js' } + } + } + }) + } + + // precompress text assets so the server never compresses at request time + const compressed = await precompress(outDir) + + return { outDir, meta, compressed } +} + +function collectCss (manifest, entryChunk) { + const css = new Set(entryChunk.css || []) + for (const key of entryChunk.imports || []) { + for (const file of manifest[key].css || []) css.add(file) + } + return [...css].map((file) => '/' + file) +} + +const COMPRESSIBLE = /\.(js|mjs|css|html|json|svg|txt|map)$/ + +async function precompress (dir) { + let count = 0 + for (const name of await readdir(dir)) { + const path = join(dir, name) + const info = await stat(path) + if (info.isDirectory()) { + count += await precompress(path) + continue + } + if (!COMPRESSIBLE.test(name) || info.size < 1024) continue + const data = await readFile(path) + await writeFile(path + '.br', await brotli(data, { + params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY } + })) + await writeFile(path + '.gz', await gzipP(data, { level: constants.Z_BEST_COMPRESSION })) + count++ + } + return count +} + +async function exists (path) { + try { + await access(path) + return true + } catch (e) { + return false + } +} diff --git a/packages/bankai/lib/dev.js b/packages/bankai/lib/dev.js new file mode 100644 index 0000000..6ea53d4 --- /dev/null +++ b/packages/bankai/lib/dev.js @@ -0,0 +1,59 @@ +// bankai start — the dev server: Vite middleware mode for the module +// graph and HMR, per-request streaming SSR of the same entry through +// vite.ssrLoadModule so server code shares Vite's transforms and cache. + +import { createServer } from 'node:http' + +import bankaiPlugin, { CLIENT_URL } from './plugin.js' +import { resolveApp } from './app.js' +import { documentHead, documentTail } from './document.js' + +export default async function dev ({ entry, port = 8080, title = 'choo' }) { + const { createServer: createViteServer } = await import('vite') + + const vite = await createViteServer({ + appType: 'custom', + server: { middlewareMode: true }, + plugins: [bankaiPlugin(entry)], + logLevel: 'warn' + }) + + const server = createServer((req, res) => { + vite.middlewares(req, res, async () => { + // not an asset Vite knows: this is a page navigation — SSR it + try { + const mod = await vite.ssrLoadModule(entry) + const app = resolveApp(mod, entry) + const state = {} + + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.write(documentHead({ + title, + scripts: ['/@vite/client', CLIENT_URL] + })) + + for await (const chunk of app.toStream(req.url, state)) { + res.write(chunk) + } + res.end(documentTail(state)) + } catch (err) { + vite.ssrFixStacktrace(err) + console.error(err) + if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' }) + res.end('bankai dev error:\n\n' + (err.stack || err.message)) + } + }) + }) + + await new Promise((resolve) => server.listen(port, resolve)) + return { + server, + vite, + port: server.address().port, + origin: `http://localhost:${server.address().port}`, + close: async () => { + await vite.close() + await new Promise((resolve) => server.close(resolve)) + } + } +} diff --git a/packages/bankai/lib/document.js b/packages/bankai/lib/document.js new file mode 100644 index 0000000..bb9bdc1 --- /dev/null +++ b/packages/bankai/lib/document.js @@ -0,0 +1,55 @@ +// The HTML document around the app's streamed body. bankai owns the +// (charset, viewport, title, asset links) and the tail +// (window.initialState + ); the app's view owns . +// Page scripts live in (type=module defers itself); the one +// exception is the initialState script, which must be inline and is +// emitted after the body — inline scripts execute during parse, module +// scripts only after it, so the order still holds. + +export function documentHead ({ title, css = [], modulepreload = [], scripts = [] }) { + let head = '\n\n\n\n\n' + if (title) head += `${escapeHtml(title)}\n` + for (const href of css) { + head += `\n` + } + for (const href of modulepreload) { + head += `\n` + } + for (const src of scripts) { + head += `\n` + } + head += '\n' + return head +} + +export function documentTail (state) { + return `\n\n\n` +} + +// choo internals that every boot recomputes \u2014 no point shipping them +const RECOMPUTED = new Set(['events', 'cache', 'prefetch']) + +// JSON that is safe to embed in an inline ' or +// '