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$