- pages/ — one folder per tool
- components/ — shared widgets
- utils/ — helpers (apiUrl / flag / format_result)
- stores/ — 11 Zustand stores (auth / settings / sessions / etc.)
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.
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.
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.
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.
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 → DOMNot 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.
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.
| Host | Backed by | Role |
|---|---|---|
cuberoot.me | Cloud nginx | Primary site, SPA entry |
www.cuberoot.me | Same VM, 301 | apex / www mutual redirect |
api.cuberoot.me | Same VM, nginx → :3001 | Hono API + 24h proxy_cache |
cuberoot.me/blog/blog.cuberoot.me | Dual: nginx alias / GH Pages | WordPress static archive (frozen 2026-05); main path serves CN, subdomain serves oversea |
Each pick has alternatives. The table below lists what was chosen, what wasn't, and why.
| Topic | Picked | Not picked | Why |
|---|---|---|---|
| Framework | React 19 | Vue / Svelte | Widest ecosystem; cubing.js / sr-puzzlegen samples are React; team familiarity. |
| Bundler | Vite 8 | Webpack / Turbopack | Sub-1s dev start; instant HMR; native ESM; tsc -b 12s incremental. |
| Styling | 手写语义化 CSS + Tailwind 4 base | 纯 Tailwind / CSS-in-JS | Per-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 server | Hono | Express / Fastify | TS-first; declarative routing; ~5MB deps vs Express noisy stack. |
| Database | PostgreSQL 13 | MariaDB / MongoDB | Migrated from MariaDB 2026-05. jsonb, window functions, partial indexes — a tier above MariaDB. |
| Monorepo | pnpm + Turbo | npm / yarn workspaces | Four 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 mgmt | Zustand | Redux Toolkit / Jotai / Context | 11 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 | 云服务器 nginx | Vercel / Netlify | Same VM hosts nginx + API + DB; localhost hops; Vercel SaaS is unreachable from China. |
| Theme tokens | shadcn 命名 + hex + color-mix | oklch / Material 3 / Radix Colors | Dark/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). |
/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.
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 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.
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.
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.
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 (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.
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.
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.
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.
// ❌ 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 默认 443Vite 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.
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.
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.
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.
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.
Pure SPA load: nginx try_files returns index.html, browser runs SPA, React Router renders LandingPage. Zero API calls.
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.
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:
| Aspect | Web | App |
|---|---|---|
| origin | https://cuberoot.me | capacitor://localhost / https://localhost |
| API calls | fetch api.cuberoot.me, 3-entry CORS allowlist | add capacitor + localhost origins · CapacitorHttp bypasses webview CORS |
| /stats/* /tools/* | served by nginx | not bundled (17MB too heavy); fetch wrapper rewrites to cuberoot.me |
| back button | browser ◁ uses history.back | @capacitor/app intercepts backButton → React Router navigate(-1) (webView.canGoBack ignores pushState) |
| WCA OAuth | redirect_uri = https://cuberoot.me/auth/callback | custom-scheme deep link me.cuberoot.app://auth-callback · @capacitor/browser opens + appUrlOpen catches token |
| update path | nginx deploy = instant | push → CI build → manual reinstall (artifact retained 14d) |
Install steps + known webview limitations (cstimer iframe / SAB / WebCodecs) live in core/packages/client/MOBILE.md.
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.