A full-page light terminal as the homepage, backed by a real shell (zsh + fzf + eza, running inside a disposable isolated container) — not a command simulation. Visual style follows jyy's homepage: one 80-column rounded frame on #fafafa, Maple Mono, CRT scanlines + vignette + smudge, minimal purple/blue/gray prompt — except this shell is real.
- Full-page terminal: xterm.js ↔ WebSocket ↔ node-pty ↔ containerized zsh. Terminal shortcuts work as-is: Ctrl-R (fzf history search), Tab (fzf-tab completion), Ctrl-A/E line editing, and the rest of your muscle memory (Ctrl+P excepted — the page keeps it for the palette).
open <path>: opens documents in-page. Markdown renders with GFM, mermaid diagrams, KaTeX math, and syntax-highlighted code; anything else shows as<pre>text. Bareopenlaunches an fzf fuzzy picker with bat previews. Esc closes and returns focus to the terminal.cat/ll/ etc.: the container ships bat, eza, fd, fzf, with aliases matching a daily-driver setup. bat auto-switches its syntax theme with the terminal background (Coldark-Cold on the light page, Coldark-Dark in dark mode).demo: AIGC-style typewriter tour of the basics.Spacepauses/resumes,qor Ctrl-C quits.theme dark/theme light: swaps the page and terminal palette live, persists the choice inlocalStorage(defaults to the OS preference). Also in the command palette.?cmd=<line>deep link: runs one command line once the shell is online — e.g./?cmd=open%20hello.md. Length-capped, newline-stripped, and typed into the same jailed shell as ordinary keystrokes, so it grants no extra authority.status: prints live shell-server stats (sessions, uptime, image) into the terminal via a JSON control frame; the shell stays stateless.- Ctrl+P / Ctrl+Shift+P / ⌘P / ⌘⇧P: VS Code-style command palette — fuzzy-search commands and
content/files, shows recently opened; Enter routes markdown to the viewer, commands go to the real shell. Bound at the page level, so it works without focusing the terminal first. The page keeps Ctrl+P for the palette, so shell previous-history lives on ↑ / Ctrl-R (inside the fzf picker, navigate with the arrows or Ctrl-J/K). - Welcome screen: the motd doubles as the site's hero — a one-line tagline and a quickstart (
open hello.md,demo,theme dark,status) with the key hints, printed once per shell in English. - When the backend is unreachable an offline overlay appears (startup hints + retry button), with exponential-backoff reconnects; reloading reattaches to a still-live session.
- Concurrency ladder: one persistent box container hosts every session as
an isolated bwrap jail (cheap
docker exec+ uid lease instead of a fulldocker run), and a warm pool pre-spawns jails so creates land in ~20ms; when capacity is full visitors queue in a waiting room (live position, fairness keyed by a per-browser token); per-IP floors throttle create spam, auth-failure lockout and connection caps; grace/idle timeouts tighten under saturation;/api/metricsexposes the counters. Pool shells count insideMAX_SESSIONS, so the worst-case host load is unchanged. WithBROKER_REDIS_URLset, several hosts share one waiting room through redis — a saturated host's queued visitors are granted on whichever host drains next (see Broker mode below). - Demo replay fallback: when the queue runs deep or admission rejects, the overlay plays
content/replay.cast(asciinema v2, served from the content jail) into a small read-only terminal so waiting visitors see what the shell does; the cast is optional and deployments without one just skip it.
Browser (GitHub Pages, static) apps/web React 19 + Vite + @xterm/xterm
│ WS ws(s)://…/ws JSON control frames + raw binary I/O frames
│ HTTP /api/file /api/files viewer content + file listing (same jail check)
└─ OSC 7770 `open` in the shell → in-page viewer
│
Host apps/shell-server Fastify + @fastify/websocket + node-pty (Node 22)
└─ docker exec per session one persistent box container (homepage-shell-box)
│
Jail (per session) bwrap own pid/ipc/mount ns, uid tulipjN, throwaway
home+tmp, rlimits; zsh + fzf + eza + bat inside
Sandboxing: sessions share ONE persistent, --read-only, --network none box
container and isolate through per-session bwrap jails (container/jail/jail-login):
each session gets its own pid/ipc/mount namespaces, a uid leased from the
tulipjN range (10000–10031), a throwaway home + /tmp wiped before every
spawn, per-jail rlimits (512MB address space, 64 procs, 256 fds), and the
content directory re-bound read-only. bwrap drops every capability before the
shell execs. Path validation for open (jail + size cap) happens twice — in
the shell function and at the HTTP layer — so a forged OSC gains nothing.
Sessions are bound to a per-session secret issued at create; the id alone
grants nothing.
Prerequisites: Node ≥ 22 (see .nvmrc) and Docker.
docker compose build shell # build the container image (a few minutes)
npm install
npm run dev:server # shell-server on :8787
npm run dev:web # web on :5173, /ws and /api proxied to 8787Open http://localhost:5173/homepage/.
Build notes:
- The image build runs with
network: host(seedocker-compose.yml): the default bridge network uses the Docker daemon's DNS, which is unreachable on some restricted networks; host networking inherits the host resolver. Runtime containers are unaffected (they run--network none). - apt sources are swapped at build time by the linuxmirrors script (
ARG UBUNTU_MIRROR=mirrors.aliyun.com, over plain http since apt verifies via GPG-signed Release files). The script rewrites noble's deb822ubuntu.sources— mirror domain, security suite, and all components (main restricted universe multiverse; the base image shipsmainonly) — in one maintained place instead of hand-rolled sed rules. Override the mirror with--build-arg UBUNTU_MIRROR=…if you build elsewhere. - Package and binary installation lives in
container/scripts/(apt-mirror.sh,install-utils.sh), not inline in the Dockerfile — thin declarative layers, reviewable shell, and version pins (FZF_VERSION,EZA_VERSION) passed in as build args.
Configuration lives in .env.example (shell-server env: port, bind host, WS_TOKEN, session limits, idle reaping, etc.).
The frontend deploys to GitHub Pages (base /homepage/):
-
Push to
main; GitHub Actions (.github/workflows/deploy.yml) builds and publishes. -
In the repo's Settings → Secrets and variables → Actions → Variables, set the build vars:
Variable Purpose VITE_WS_URLShell backend WebSocket URL (e.g. wss://…/ws); required unless only the broker is used.VITE_API_URLMatching HTTP base (e.g. https://…) for/api/file*and/api/replay.VITE_WS_TOKENSame value as the server's WS_TOKEN— private mode only; it ships in the bundle, so never for public sites.VITE_BROKER_URLBase URL of a broker-enabled host ( https://…); enables the multi-host pre-flight. Optional — see below.
The backend is a host process (it needs docker run), so Pages cannot host it — run it on a Docker-capable machine behind a wss-capable reverse proxy. It has two auth modes:
- Public mode (default):
WS_TOKENunset. Every visitor gets a jailed shell. The server binds127.0.0.1and refuses to start on any widerWS_HOSTwithout a token — put a local reverse proxy in front for TLS and public reachability. - Private mode:
WS_TOKENset. Clients must send the token as the first WebSocket message (the legacy?token=query form is gone — it leaked the token into request logs); without it they see the offline overlay. Build the frontend withVITE_WS_TOKENset to the same value. Caveat: that token is baked into the shipped JS bundle, so private mode only makes sense with private frontend hosting.
Without VITE_WS_URL the site still deploys and just shows the offline overlay.
One host is a ceiling (MAX_SESSIONS per machine). To run several hosts behind
one public origin, point them all at a shared redis with BROKER_REDIS_URL —
each instance then joins the broker:
- Capacity stays per-host. Redis holds only the control plane: the global
waiting-room queue (
hp:queue), admission tickets (hp:ticket:<t>), visitor budgets (hp:visitor:<id>) and a presence heartbeat (hp:hosts, 5s tick, a host reads as gone after 15s). Session data-plane traffic stays client↔host WebSocket direct; session secrets never touch redis. - Any host drains when it has room. A saturated host answers client
preflights with a waiting-room ticket; whichever host finds local headroom
next grants the ticket against its own capacity, stamping the ticket with
its
HOST_PUBLIC_URL(or none — clients then stay on the same public origin, which is what you want when a reverse proxy fronts all hosts). - The control plane is plain HTTP:
POST /api/admit→{granted, ticket, endpoint}or{queued, ticket, position}; the client then connects to the endpoint and opens its WS withcreate{ticket}, which the host redeems.GET /api/queue/:ticketpolls position/grant;GET /api/hostsis an ops view of who is alive and how loaded. The two ops endpoints (/api/hosts,/api/metrics) are not public surface: they requireAuthorization: Bearer <OPS_TOKEN>, falling back toWS_TOKENwhenOPS_TOKENis unset. SetCORS_ORIGINSto the site's origin when you front the broker publicly — the API tightens from reflect-any to the allowlist the moment the variable is set. - Old clients keep working unchanged: a WS
createwithout a ticket falls back to the answering host's own hard cap and local waiting room. - Dead hosts self-heal: their heartbeat ages out (≤20s), queue tickets expire, and leaked visitor-budget counters expire within an hour.
Locally, the two-host shape is:
docker run -d --rm -p 6399:6379 redis:7-alpine
BROKER_REDIS_URL=redis://127.0.0.1:6399 HOST_ID=a WS_PORT=8787 npm run start -w apps/shell-server
BROKER_REDIS_URL=redis://127.0.0.1:6399 HOST_ID=b WS_PORT=8788 npm run start -w apps/shell-serverThe frontend's broker pre-flight (VITE_BROKER_URL) points at any
broker-enabled host; visitors then land on whichever host admits them,
and a page reload reattaches on the granting host directly. Without it,
direct-WS visitors still queue — just per-host.
Markdown files under content/ are what open and the command palette can open. They are mounted read-only at runtime, so editing content needs no image rebuild.
Public-facing auth/TLS termination (reverse proxy territory), tmux session persistence, deep mobile adaptation. (Warm pools and the single-box bwrap jail architecture shipped — see the concurrency ladder in Features.)