- app/[lang]/* — one folder per tool, /[lang] path-prefix locale
- components/ — shared widgets
- lib/ — helpers (apiUrl / flag / format_result)
- 11 Zustand stores (auth / settings / sessions / etc.)
A cube-tools site with 24+ tool pages — one Next.js 16 codebase deployed two ways (self-hosted VM + Vercel edge, split-horizon DNS). 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.
One line: everything sits on one self-hosted VM — nginx reverse-proxies systemd Next standalone (:3002) + Hono API (:3001); Hono talks to PG over a local socket. The other line hits Vercel edge running the same Next code; the API call still resolves to api.cuberoot.me. static.cuberoot.me is a dedicated subdomain serving /tools and /stats for the Vercel function fallback.
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 → proxy_pass :3002 (一条线路:systemd Next standalone)
│ ↘ Vercel edge (另一条线路:同份 Next 代码)
▼
Next App Router → SSR shell stream → 客户端 hydrate
│
▼
client → 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.mewww.cuberoot.me | Split-horizon DNS — one line → self-hosted VM nginx → systemd Next standalone :3002; the other → Vercel Hobby edge (same code, push-to-deploy) | Primary site, Next.js 16 App Router |
api.cuberoot.me | Cloud VM nginx → :3001 | Hono API + 24h proxy_cache |
next.cuberoot.me | Same systemd cuberoot-next :3002 (alias) | Staging alias / direct to self-hosted Next, bypassing DNS routing |
static.cuberoot.me | Same nginx, dedicated vhost serving only /tools/ + /stats/ (CORS:*) | Static-asset origin for Vercel function fallback |
cuberoot.me/blog/blog.cuberoot.me | Dual via split-horizon DNS: same-VM nginx alias / GH Pages | WordPress static archive (frozen 2026-05) |
Each pick has alternatives. The table below lists what was chosen, what wasn't, and why.
| Topic | Picked | Not picked | Why |
|---|---|---|---|
| UI library | React 19 | Vue / Svelte / Solid | Widest ecosystem; cubing.js / sr-puzzlegen samples are React; team familiarity. |
| Framework | Next.js 16 (App Router) | Remix / TanStack Start / 纯 Vite SPA | App Router + RSC + server streaming in one; Turbopack dev/build; one codebase deploys to both systemd standalone and Vercel. Cut over from React Router SPA in Phase 4 (2026-05). |
| Bundler | Turbopack | Webpack / Vite | Bundled with Next.js 16, drives both dev incremental compile and prod build; first cold compile 30-90s, then sub-second 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/postcss + a single @import "tailwindcss" in app/globals.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. |
| Hosting | 自有 VM nginx + Vercel (DNS 分线路) | 单 Vercel / 单 nginx | Split-horizon DNS, same Next.js codebase on both. One line → self-hosted VM nginx → systemd Next standalone (reverse-proxy :3002, deploy_next.yml CI auto scp + atomic swap); the other line → Vercel Hobby edge (push-to-GitHub auto-deploy). Backend Hono+PG stays on the same VM; Vercel side hits it via api.cuberoot.me. |
| 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. lib/api-base.ts uses import.meta.env.DEV: dev → next.config.ts rewrites() proxy to api.cuberoot.me, prod → direct 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.
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 Next, some hit cache at nginx, some pierce all the way through.
nginx reverse-proxies Next standalone (:3002), Next App Router server-renders LandingPage and streams HTML back; client hydrates. 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.