×
visitor@homepage80×24Starting xterm.js
visitor@homepage$ render header.md Home | Posts | Pages | About ———————————————————————————————————————————————————————————————————————————————— This shell is powered by a real Linux kernel running in your browser. Hint: type vim header.md. ———————————————————————————————————————————————————————————————————————————————— visitor@homepage$ render posts/2026-08-01-terminal-like-blog ╭──────────────────────────────╮ Terminal-like Blog ╰──────────────────────────────╯ Terminal-like Blog This blog is a terminal. The homepage is rendered to ANSI at build time and displayed by render. Below the surface, a real Linux kernel runs in your browser tab. Things to try - ls, cat index.md, pipes, redirection — real Linux, not a reimplementation - render posts lists a folder; render posts/<file>.md reads a post - , Ctrl+C, line editing — BusyBox ash Architecture Three layers, each doing what it is best at: - Next.js static export — plain HTML and JS; no server. - xterm.js — terminal rendering and input, wired to the guest's serial console. - v86 — an x86 PC emulator in WebAssembly, running Linux with a BusyBox userspace. Pipes, redirection, history and signals belong to Linux, not the UI. Content pipeline 1. Markdown under content/ is rendered to ANSI at build time (truecolor SGR, OSC 8 hyperlinks, Unicode tables and boxes). 2. Sources and pre-rendered output (.term/rendered/*) enter the guest over v86's 9P filesystem, mounted at the home directory. Nothing is baked into the kernel or the snapshot. 3. In the guest, render cats the pre-rendered file. ls, cat and friends work on the same tree. URLs are commands: /posts/foo.md runs render posts/foo.md. Clicking a link pushes browser history and asks the running VM to render the new route — no reload; the session survives. The guest system The kernel is Linux 6.6.1, 32-bit x86, built from allnoconfig plus a ~30-line allowlist: serial console, initramfs, procfs, virtio PCI, 9P filesystem. No USB, no GPU, no sound, no highmem. mitigations=off — the emulator is the sandbox. The initramfs is embedded into the bzImage, so the whole OS is one file. Sources are pinned by SHA-256 and the build is reproducible (SOURCE_DATE_EPOCH=0). The userspace is a static BusyBox — ash with line editing, history and tab completion, plus vi and the usual coreutils — and a tiny C user-shell that drops to uid 1000 and execs ash. /init mounts /proc and the 9P share, then execs the shell. The snapshot The browser never boots Linux; it restores a memory snapshot — zero to a shell prompt in well under a second of CPU time. - pnpm build:snapshot boots the kernel in Node under v86, waits for the shell prompt, stops the VM, and saves the 16 MiB RAM state. - The state is verified by restoring it in a fresh emulator and round-tripping a serial echo, then compressed with zstd level 19: state.bin.zst, ~2.6 MB. - The kernel runs with init_on_free=1, so freed boot memory is zeroed; v86 omits zero pages from snapshots, cutting ~1 MiB from the compressed state. - A snapshot is valid only for its exact v86 build and machine configuration; pnpm verify:runtime checks the published file set. The boot preview Restoring is fast, but the download still takes a second or two, and staring at a black box is bad. So the page shows the finished screen before the VM exists: - At build time, renderRoutePreview reconstructs the exact screen the guest will print: prompts, render echoes, rendered content — byte for byte. - pnpm test:preview boots the real snapshot in Node and asserts the preview matches the guest's serial output exactly. Drift fails the build. - Without JS, the preview is inlined as HTML: the site stays fully readable and navigable. With JS, the same ANSI is written into xterm itself. The handoff The preview is painted by xterm — the same renderer the live session uses — so the takeover changes no pixels: - Boot output is buffered, never written; no kernel echo flashes by. - At the guest's ready marker (an OSC 777 sequence), the buffered bytes are dropped: the preview already shows that exact screen, down to the trailing prompt. The visible change is only the dimmed screen lighting up and the cursor starting to blink. - If you clicked a link before ready, the buffer is replayed from the guest's clear-screen sequence in one write — an atomic switch to the new route. - Input is genuinely disabled while booting: the keyboard handler attaches only once the shell is listening. Performance work Cold start is the whole battle: a WASM runtime, an emulator, and a VM image all want to be downloaded first. Streaming WASM compilation. v86.wasm compiles with WebAssembly.instantiateStreaming, overlapping download with compilation. Preloads that actually coalesce. Runtime assets get preload hints in the SSR'd head. Sharing one request with the real fetches turned out to be subtle: v86's XHR loader can never coalesce with a preload, so the snapshot is fetched explicitly, and Chrome only merges as=fetch preloads with crossorigin="use-credentials" plus credentials: "include". Found by diffing headless-Chrome network traces, not by reading documentation. A handoff with zero repaint. The preview-in-xterm design means the ready transition changes no pixels. Guest output is batched too: bytes accumulate and flush on a microtask instead of one write per byte. Measured, not guessed. A/B tested under throttled network and CPU in headless Chrome. On a fast desktop the remaining bottleneck is parsing libv86.mjs; on slow networks, transfer size dominates — the next big win is gzip/brotli on the runtime, worth ~45% of time-to-shell on a throttled connection. Why Mostly because it is fun. But there is something satisfying about a blog where the static site generator and the runtime are the same artifact — where viewing a post means a real shell runs render on a real file, and the page you landed on was already showing exactly what that shell would say. visitor@homepage$