// bankai serve — the production server. Static assets from dist // (precompressed variants negotiated, hashed assets cached forever); // prerendered routes served as static HTML; everything else is a page: // 103 Early Hints from the build manifest, then streaming SSR of the // entry module — imported natively, because the server side never // needed a build. `h2: true` serves HTTP/2 with a generated local cert // (allowHTTP1 for tools) — browsers only act on Early Hints over h2/h3, // so production deployments want this or a fronting h2 proxy. import http from 'node:http' import http2 from 'node:http2' import { execFile } from 'node:child_process' import { createReadStream } from 'node:fs' import { readFile, stat, mkdir } from 'node:fs/promises' import { join, normalize, extname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { resolveApp } from './app.js' import { documentHead, documentTail, assetLinks } from './document.js' const execFileP = promisify(execFile) const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.mp4': 'video/mp4', '.ico': 'image/x-icon' } export default async function serve ({ outDir = 'dist', port = 8080, h2 = false }) { outDir = resolve(outDir) const meta = JSON.parse(await readFile(join(outDir, 'bankai.json'), 'utf8')) const mod = await import(pathToFileURL(meta.entry)) const links = assetLinks(meta.assets) async function handler (req, res) { const url = new URL(req.url, 'http://localhost') const pathname = decodeURIComponent(url.pathname) // static asset? const file = normalize(join(outDir, pathname)) if (file.startsWith(outDir) && extname(pathname)) { if (await isFile(file)) return sendFile(req, res, file, pathname) if (MIME[extname(pathname)]) { // a missing asset is a miss, not a page res.writeHead(404, { 'content-type': 'text/plain' }) return res.end('not found: ' + pathname) } } // prerendered page? const prerendered = normalize(join(outDir, pathname, 'index.html')) if (prerendered.startsWith(outDir) && await isFile(prerendered)) { if (links.length && res.writeEarlyHints) res.writeEarlyHints({ link: links }) return sendFile(req, res, prerendered, pathname, { link: links.join(', ') }) } // live page: hints first, then stream try { const app = resolveApp(mod, meta.entry) const state = {} if (links.length && res.writeEarlyHints) { res.writeEarlyHints({ link: links }) } // first chunk before the head: stores + the first render slice have // run by then, so state.title is populated const reader = app.toStream(req.url, state).getReader() const first = await reader.read() res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', link: links.join(', ') }) res.write(documentHead({ title: state.title || meta.title, ...meta.assets })) if (!first.done) res.write(first.value) for (;;) { const { done, value } = await reader.read() if (done) break res.write(value) } res.end(documentTail(state)) } catch (err) { console.error(err) if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' }) res.end('bankai: render error') } } let server if (h2) { const { key, cert } = await localCert(outDir) server = http2.createSecureServer({ key, cert, allowHTTP1: true }, handler) } else { server = http.createServer(handler) } await new Promise((resolveListen) => server.listen(port, resolveListen)) return { server, port: server.address().port, origin: `${h2 ? 'https' : 'http'}://localhost:${server.address().port}`, close: () => new Promise((resolveClose) => server.close(resolveClose)) } } // self-signed localhost cert, generated once via openssl and cached // alongside the build (never commit dist) async function localCert (outDir) { const dir = join(outDir, '.bankai-cert') const keyPath = join(dir, 'key.pem') const certPath = join(dir, 'cert.pem') if (!(await isFile(keyPath)) || !(await isFile(certPath))) { await mkdir(dir, { recursive: true }) await execFileP('openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '365', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', '-keyout', keyPath, '-out', certPath ]) } return { key: await readFile(keyPath), cert: await readFile(certPath) } } async function isFile (path) { try { return (await stat(path)).isFile() } catch (e) { return false } } async function sendFile (req, res, file, pathname, extraHeaders = {}) { const headers = { ...extraHeaders, 'content-type': MIME[extname(file)] || 'application/octet-stream', // hashed build assets are immutable; everything else revalidates 'cache-control': pathname.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache', vary: 'accept-encoding' } // negotiate precompressed siblings written at build time const accepted = String(req.headers['accept-encoding'] || '') for (const [encoding, ext] of [['br', '.br'], ['gzip', '.gz']]) { if (accepted.includes(encoding) && await isFile(file + ext)) { headers['content-encoding'] = encoding res.writeHead(200, headers) createReadStream(file + ext).pipe(res) return } } res.writeHead(200, headers) createReadStream(file).pipe(res) }