An internal handbook·2026.05

How CubeRoot is built

A cube-tools site with 24+ tool pages, running on a single cloud VM. Frontend is a React 19 SPA, backend is Hono + PostgreSQL, and the WCA statistics pipeline runs separately on a weekly cadence. This page walks every layer — from a mouse click to a DOM update, and everything in between.

5
packages
14
modules
80+
WCA stat pages
41
alg sets
1
cloud VM
01

The whole system in one picture

Everything sits on one cloud VM. nginx serves both the static SPA and reverse-proxies the Hono API; Hono talks to PG over a local socket. GitHub Pages is a fallback mirror — used only when cuberoot.me itself is down.

GitHub ActionsCI · 周更 · deployWCA Public Dump每周 · .sql + .tsvUser BrowserChrome / Safari / Edgecuberoot.meCloud VMone box, three servicesnginx :443Hono API :3001PostgreSQL 13 :5432GH Pagesfallback mirrorcuberoot.me (CNAME)HTTPS301scp · sshpull weekly
01
Edge
TLS termination + static + reverse proxy
nginx · CloudFlare DNS · Let's Encrypt
02
Frontend
One SPA, 24+ tool pages, React Router
React 19 · Vite 8 · TypeScript · cubing.js · Tailwind 4 (base layer)
03
API
Small, light Hono, run under pm2
Hono · Node 22 · pm2
04
Storage
recon · alg library · training data · WCA stats derivatives
PostgreSQL 13 · pg_dump nightly
02

Monorepo: five packages

core/ is a pnpm + Turbo monorepo with five packages, each owning one slice. CI only re-runs packages that actually changed — cache hits make most builds sub-second.

sharedtypes onlyclientReact 19 + ViteserverHono + PGstats-buildCLI · 独立client / server 都依赖 shared (纯类型) · stats-build 独立 CLI
@cuberoot/client~120k LOC
React SPA — the whole frontend
  • pages/ — one folder per tool
  • components/ — shared widgets
  • utils/ — helpers (apiUrl / flag / format_result)
  • stores/ — 11 Zustand stores (auth / settings / sessions / etc.)
@cuberoot/server~8k LOC
Hono API + PG access
  • WCA OAuth + sessions
  • recon / alg / training-data CRUD
  • CORS allowlist
@cuberoot/shared~1k LOC
Shared types
  • Pure TS types, zero runtime
  • Must not import client utils
  • One source for both ends
@cuberoot/stats-build~5k LOC
WCA stats standalone pipeline
  • 80+ SQL-driven stats
  • Weekly CI, ~2h end to end
  • TS rewrite of jonatanklosko/wca_statistics
03

From click to DOM

A typical read (say, opening /recon/abc) runs end-to-end in under 50ms. With an nginx proxy_cache hit it drops below 10ms. Each hop's latency is plotted below.

click0 msJS handler~2 msfetch()~5 msnginx~15 msHono~18 msPG query~25 msJSON → DOM~40 ms典型读请求 · 端到端 < 50ms · 缓存命中 < 10ms
Browser
  │  GET cuberoot.me/recon/abc
  ▼
nginx :443    →  try_files → index.html  (SPA fallback)
  │
  ▼
React SPA   →  fetch(apiUrl('/v1/recon/abc'))
                          │
                          ▼
                nginx :443 (api.cuberoot.me)
                  │  proxy_cache /v1/wca/* (24h)
                  ▼
                Hono :3001    →  pg pool  →  PostgreSQL :5432
                  │
                  ▼
                JSON  →  React state  →  DOM
04

14 modules, three flavors

Not every route was built from scratch. own = designed and built here; port = someone else's React/HTML rewritten in-repo; fork = upstream assets hosted as-is. Click a card to visit the module.

own · 8port · 3fork · 3
05

WCA stats: a separate weekly pipeline

Stats data is fully decoupled from the main site. GitHub Actions pulls the WCA public dump weekly, runs 80+ SQL-driven statistics on the runner, produces JSON + TSVs, scp's them to the VM, \copys them into PG, Hono reads them out, and nginx caches 24h on top.

WCA dump每周公开MySQL本机 :3306stats-build80+ SQL · 1 TSJSON + TSVartifacts/scp → VM~6 MBPG / API / UInginx cache 24h三处必须同步:builder.ts (写 TSV) · stats.yml (scp 清单) · load.sql (\copy 引)
06

Deploy: five hosts, one machine

HostBacked byRole
cuberoot.meCloud nginxPrimary site, SPA entry
www.cuberoot.meSame VM, 301apex / www mutual redirect
api.cuberoot.meSame VM, nginx → :3001Hono API + 24h proxy_cache
cuberoot.me/blog/
blog.cuberoot.me
Dual: nginx alias / GH PagesWordPress static archive (frozen 2026-05); main path serves CN, subdomain serves oversea
07

Why these picks

Each pick has alternatives. The table below lists what was chosen, what wasn't, and why.

TopicPickedNot pickedWhy
FrameworkReact 19Vue / SvelteWidest ecosystem; cubing.js / sr-puzzlegen samples are React; team familiarity.
BundlerVite 8Webpack / TurbopackSub-1s dev start; instant HMR; native ESM; tsc -b 12s incremental.
Styling手写语义化 CSS + Tailwind 4 base纯 Tailwind / CSS-in-JSPer-page hand-written semantic CSS is the primary style layer (compare.css / stack_landing.css etc., page-prefixed names like .compare-card). Tailwind 4 is wired via @tailwindcss/vite + a single @import "tailwindcss" in src/index.css — it supplies preflight + a utility namespace as the base layer, but className="flex p-4" is not the idiom. Theme tokens use shadcn naming + CSS custom properties.
API serverHonoExpress / FastifyTS-first; declarative routing; ~5MB deps vs Express noisy stack.
DatabasePostgreSQL 13MariaDB / MongoDBMigrated from MariaDB 2026-05. jsonb, window functions, partial indexes — a tier above MariaDB.
Monorepopnpm + Turbonpm / yarn workspacesFour core workspaces (client / server / shared / stats-build), one pnpm-lock. Hard-linked node_modules saves disk; Turbo runs only changed packages. The underlying registry is still npm (registry.npmjs.org) — pnpm is just a faster client.
State mgmtZustandRedux Toolkit / Jotai / Context11 stores (6 global + 5 page-local). No Provider — create() returns a hook, components select slices. auth syncs across tabs via the storage event; settings/sessions persist to localStorage via middleware. ~1 KB bundle cost.
Static host云服务器 nginxVercel / NetlifySame VM hosts nginx + API + DB; localhost hops; Vercel SaaS is unreachable from China.
Theme tokensshadcn 命名 + hex + color-mixoklch / Material 3 / Radix ColorsDark/light across 8 pages. Naming follows shadcn (OSS standard, friendly to AI code-gen); hex values (surveyed 30+ big-co incl. Anthropic console — zero use oklch as primary brand tokens); derivations via color-mix(in srgb) aligning with Anthropic CDS (644 production uses).
08

Engineering details worth knowing

SharedArrayBuffer · COOP/COEP

/scramble/solver and /scramble/analyzer run cubeopt-wasm and require SharedArrayBuffer. Only those two routes get nginx-injected COOP=same-origin + COEP=require-corp for cross-origin isolation. Every other page stays clean — login callbacks unaffected.

apiUrl() 是唯一的 fetch 入口

Client never hardcodes origin. utils/api_base.ts uses import.meta.env.DEV: dev → Vite proxy, prod → api.cuberoot.me. hostname checks get fooled by Tailscale / LAN IP — banned.

cubing.js + sr-puzzlegen + visualcube 三件套

cubing.js for animation (TwistyPlayer) and 3x3/4x4 solvers. sr-puzzlegen for sq1 / megaminx / pyraminx / skewb SVGs. visualcube for NxN state images (F2L / OLL / PLL / ZBLL). Three libs, three lanes — hand-written cube SVG is banned.

i18n — 两种 pattern 并存

Long blocks → t() + en.json/zh.json; inline strings → isZh ? 'X' : 'Y' ternary. LangToggle sits top-right on every page. Chinese comp names live in a separate comp_names_zh.json.

Theme — dark / light / system 三态

shadcn-style tokens (--background --foreground --muted-foreground --accent --signal-*) live in :root, light defaults + @media (prefers-color-scheme: dark) + html[data-theme] dual override. Derivations always go through color-mix(in srgb, var(--base) X%, transparent) so changing one base ripples to all. ThemeToggle sits top-right and cycles system → light → dark, persists to localStorage.theme, applied via bootstrapTheme() at startup. 8 pages support switching (3 dual-theme + 4 dark-locked + 1 light-locked); legacy pages still use the old --bg-primary --text-primary tokens untouched.

WCA 统计的脆弱三角

Adding a stat table needs three coordinated edits: stats-build/src/bin/*.ts (writes TSV), .github/workflows/stats.yml (scp manifest), ops/sql/load.sql (\copy reference). Miss one and the server table silently empties — nginx still caches 24h. The only safety net: a 30-second grep dry-run across all three.

fork / port / own 三种治理

fork (csTimer / Solver / Alg Trainers) = upstream assets hosted as-is, only the outer shell is ours. port (Calc / Battle / Mosaic) = someone else's React / HTML, rewritten in this repo. own (the other 11) = designed and built here. Touching a fork or port? Check upstream first.

状态管理 — Zustand 11 个 store

Global stores live in src/stores/: auth_store (WCA OAuth user), settingsStore (theme / lang, persisted), sessionStore (active solve session, persisted), statsStore (WCA stats query), trainerStore (drill state, persisted), recon_store (recon cache). Page-local stores live next to their pages (battle / calc / mosaic / viz). One pattern throughout: create() returns a hook — no Provider, no reducer. auth listens to the window 'storage' event for cross-tab sync.

npm registry — 我们用 pnpm 但拉的是 npm

pnpm install still fetches tarballs from registry.npmjs.org. yarn / pnpm / bun are different clients of the same registry, all sharing the package.json + semver + lockfile protocol that npm defined. We pick pnpm for hard-linked store (disk savings), Turbo-cache friendliness, and good workspaces — but the moat (4M+ packages, hundreds of billions of weekly downloads) is at npm's end.

09

Dev HMR: one JS, three entries, all hot-reload

In dev, the PC visits http://localhost:5173/ (direct, fast, plain HTTP), same-WiFi phones visit https://alienware.tail171d80.ts.net/ (public via Tailscale Funnel), and cellular/off-network phones visit https://dev.cuberoot.me/ (self-hosted VM nginx + frp reverse tunnel back to the PC). All three entries pull the same /@vite/client JS, yet each needs a different scheme (ws/wss) and port to reach the same HMR server. Here's how Vite makes one JS satisfy all three.

Laptophttp://localhost:5173/port = "5173" · httpPhone · 同 WiFihttps://alienware.tail171d80.ts.net/port = "" · httpsPhone · 蜂窝/外网https://dev.cuberoot.me/port = "" · https/@vite/client — 同一份 JS, 三边都拉这一份socketProtocol = null || (protocol === "https:" ? "wss" : "ws")hmrPort = nullsocketHost = hostname + ":" + (hmrPort || URL.port) + "/"短路求值:null 为假 → fallback 到 page URL 自身的值→ Laptop 推导出ws://localhost:5173/port "5173" 直接代入→ WiFi 推导出wss://alienware...ts.net:/尾巴 ":" 规范化, wss 默认 443→ 蜂窝 推导出wss://dev.cuberoot.me:/同款规则, wss 默认 443direct TCPTailscale Funneledge :443 → 127.0.0.1:5173nginx + frp 反向隧道VPS :443 → frp :7100 → PC :5173Vite HTTP + WS127.0.0.1:5173 · 单端口共享
// ❌ BEFORE  — vite.config.ts 写了:  hmr: { clientPort: 443, protocol: 'wss' }
//    Vite 把字面量烤进 /@vite/client, 两端都被强制走 wss://...:443/
const socketProtocol = "wss" || (importMetaUrl.protocol === "https:" ? "wss" : "ws");
const hmrPort = 443;
const socketHost = `${importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}/`;
// localhost 拿到:  wss://localhost:443/        ← 本机 443 没监听, HMR 死

// ✅ AFTER  — 删掉整段 hmr 配置
//    Vite 注入 null, 客户端 || 短路回退到 page URL 自身的值
const socketProtocol = null || (importMetaUrl.protocol === "https:" ? "wss" : "ws");
const hmrPort = null;
const socketHost = `${importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}/`;
// localhost 拿到:  ws://localhost:5173/        ← page 是 http:5173, ws 也是 ws:5173
// Funnel   拿到:  wss://alienware...ts.net:/  ← URL 末尾的孤 ":" 被规范化, 走 wss 默认 443

The trap: a hardcoded clientPort enforces one entry on everyone

Vite bakes the HMR config into /@vite/client at dev-server startup — once baked, every browser receives the same file. So clientPort: 443 made the Funnel entry work AND made the localhost entry try a non-existent wss://localhost:443. Same trap for everyone.

The fix: delete the override, let the page URL speak

After deleting the hmr block, Vite injects __HMR_PORT__ / __HMR_PROTOCOL__ as null. The client falls through null || X to importMetaUrl.port / protocol — the URL of the JS itself. Each entry computes its own correct values, no server-side conditional needed.

The hidden trick: URL.port returns '' for default ports

This is a quiet URL-spec detail: for https://host/, new URL(...).port returns an empty string, not "443". So the phone derives wss://host:/ — a stray trailing ":" and nothing after. Browsers normalize that away and fall back to the scheme default 443 — which happens to be exactly Funnel's public port. Things line up for free.

Why a single reverse-proxy port carries HTTP and WS together

Vite's dev server is one http.Server handling both regular HTTP and Upgrade: websocket on port 5173. So whether the route is Funnel's :443 → :5173 or dev.cuberoot.me's nginx :443 → frp :7100 → :5173, a single mapping carries both the static HTML/JS and the WS upgrade — one port is enough. HMR doesn't need a separate rule. Prerequisite: nginx must forward the Upgrade/Connection headers (proxy_set_header Upgrade $http_upgrade; Connection $connection_upgrade;), otherwise the default HTTP/1.0 proxy mode silently strips them — see the map at the top of ops/nginx/www.cuberoot.me.conf.

10

A request walks the stack: click a tab to highlight

Section 03 sketches the "ideal read" timeline, but real URLs don't all walk the full path. Click the four tabs below to see which stages each pattern lights up — some never boot the SPA, some hit cache at nginx, some pierce all the way through.

  1. 01
    Browser
    fetch / nav
  2. 02
    cuberoot.me nginx
    static + try_files
  3. 03
    SPA boot
    React + Router
    RETURN
  4. 04
    apiUrl() fetch
    utils/api_base.ts
  5. 05
    api.cuberoot.me nginx
    proxy_cache 24h
  6. 06
    Hono server
    pm2 · :3001
  7. 07
    PostgreSQL
    :5432
~200ms 首次 · 完全不打 API

Pure SPA load: nginx try_files returns index.html, browser runs SPA, React Router renders LandingPage. Zero API calls.

11

Mobile: one SPA, two webview shells

2026-05 wrapped the SPA with Capacitor 8 — same dist/ hosted inside iOS WKWebView + Android WebView, side-loaded to personal devices, not published to either store. CI runs two runners in parallel: ubuntu builds APK via gradle (~3.5min), macOS builds IPA via xcodebuild (~3min), auto-triggered on push to main when client/shared/visualcube change. iOS uses Sideloadly + free Apple ID for 7-day signing (no $99/yr), Android installs directly with no expiry.

SPA dist (Vite build)pnpm exec cap syncCapacitor 8 套壳appId me.cuberoot.app · webDir distubuntu-latest runnergradle assembleDebug · ~3.5minmacos-latest runnerxcodebuild archive · ~3mincuberoot.apk~40MB · debug signedcuberoot-unsigned.ipa~34MB · 待 Sideloadly 签Android 直装允许未知来源 · 数据持久iPhone · Sideloadly 自签免费 Apple ID · 7 天证书

But the app isn't just "the site, again, inside a webview" — its origin is capacitor://localhost (iOS) / https://localhost (Android), not cuberoot.me. CORS, routing, static assets, and OAuth each need their own fallback. Here's how web and app actually differ at runtime:

AspectWebApp
originhttps://cuberoot.mecapacitor://localhost / https://localhost
API callsfetch api.cuberoot.me, 3-entry CORS allowlistadd capacitor + localhost origins · CapacitorHttp bypasses webview CORS
/stats/* /tools/*served by nginxnot bundled (17MB too heavy); fetch wrapper rewrites to cuberoot.me
back buttonbrowser ◁ uses history.back@capacitor/app intercepts backButton → React Router navigate(-1) (webView.canGoBack ignores pushState)
WCA OAuthredirect_uri = https://cuberoot.me/auth/callbackcustom-scheme deep link me.cuberoot.app://auth-callback · @capacitor/browser opens + appUrlOpen catches token
update pathnginx deploy = instantpush → CI build → manual reinstall (artifact retained 14d)

Install steps + known webview limitations (cstimer iframe / SAB / WebCodecs) live in core/packages/client/MOBILE.md.

12

Timeline: the past year's key changes

The project was born on 2025-12-13 — a single empty index.html. Five months and 2300+ commits later: the list view tells the story through 14 major changes; the calendar view shows every "non-trivial" commit by date (feat/refactor/perf/i18n, capped at 3 per day) so you can see which days were heads-down coding and which were just polish.