The Abalone game and the accounts behind it: a pnpm + Turborepo monorepo of Cloudflare Workers, wired to deploy from GitHub Actions. The API half is a Hono worker on D1 with username and password sign-in, profile pictures on R2, and a deploy pipeline that grows by one line per new app.
apps/game |
the Abalone game — React 19 on the @repo/nativ PWA shell, TanStack Start routing, served by a Worker |
apps/backend |
Hono on a Worker. D1 + Drizzle, the layered http/services structure, better-auth sessions, R2 avatars |
packages/nativ |
the PWA shell framework: app config, generated router, native-feeling components, hooks, service worker |
packages/shared |
the kernel the apps import — tryCatch, logging, the Hono endpoint factory |
packages/env-validation |
schema-checked env with one registry per app |
packages/dev |
the dev runner: port clearing, LAN log sink, local cron setup |
.github/ |
CI, the deploy pipeline, and deploy-units.jsonc — what deploys and in what order |
.claude/, .cursor/ |
agent skills, routing hooks, and the verify gate |
Apps consume packages through workspace exports (@repo/nativ, @repo/shared,
…) and never reach past a package's public entry points. Every package here has
a consumer; nothing is kept on spec.
The game calls the backend for one thing: who you are. Sign-in, sign-out and the
profile picture go over the API; everything about actually playing runs on the
device, so offline play works signed out and with the network off. The one thing
that crosses the two workspaces at build time is a type, the RPC routes
interface, which is why apps/game depends on @repo/backend and imports
nothing from it at runtime.
- Node 22+ (
.nvmrc) - pnpm 9.15.4 (
corepack enable) - A Cloudflare account, for deploying
pnpm installThen give each app its env — the .env.example files are the documented
contract, and check:env fails the dev server and the deploy if a required
value is missing:
for app in apps/*/; do cp "$app/env/.env.example" "$app/env/.env"; doneThe defaults already point at the local ports, so nothing needs editing to run the two apps side by side.
Create the local database and apply migrations:
pnpm migrate:localpnpm devGame on http://localhost:6161, backend on http://localhost:8181. Both bind
0.0.0.0, so another device on the same network can reach them — useful for
testing the PWA on a phone.
pnpm build |
build every app |
pnpm typecheck |
project references + the two standalone projects |
pnpm biome:check / pnpm biome:fix |
lint and format |
pnpm test |
Vitest across the workspace (backend runs on a real local D1) |
pnpm test:e2e |
Playwright smoke test against a running app |
pnpm logs |
the LAN log sink — device consoles forwarded to your terminal |
pnpm tail |
live Worker logs from the deployed backend |
Anything that edits source is done when typecheck, Biome, and the build all pass; that gate is also what CI runs.
pnpm test:e2e runs each on chromium and on the webkit iPhone 13 profile. CI does not run them yet.
| Flow | Spec |
|---|---|
| Online game: invite, accept, both players see each move | e2e/online-game.spec.ts |
Online game: a resignation shows "by resignation" to both players and the game lands in the host's /online/history |
e2e/online-game.spec.ts |
| Bot game played to the end | e2e/bot-game.spec.ts |
| Pass and play ending on a resignation | e2e/local-game.spec.ts |
| Invite link used and expired | e2e/invite-link.spec.ts |
apps/backend/src/http/routes/profile.routes.ts is the shape every route
follows, and it shows the whole contract in one file: rate limit first on the
chain, then requireAuth(), then handlers that only describe the success path,
with a service doing the work and ok() on the way out. There is no try/catch,
because a service throw is the global catcher's job.
src/services/profile.service.ts is its domain half, and
profile.routes.test.ts drives the real worker against a real local D1.
To add a domain, copy those three, then mount it with one .route() line in
src/http/routes/index.ts.
Sign-in is a username and a password. No email, no OAuth, no verification.
better-auth's username plugin owns the handle, and the email column it insists
on is derived server-side from that handle and never shown. Sessions are bearer
tokens in localStorage rather than cookies. The game and the API now share an
origin, the API answering under /api/v1 on every domain the game is served
from, but the tokens stay: they were chosen when the two were different origins,
where a cross-origin cookie is a fight with Safari that an installed PWA keeps
losing, and nothing about them broke.
Profile pictures live in R2 under a content-addressed key, avatars/<sha256>.webp,
so a picture's URL never changes and its Cache-Control can say immutable. The
object carries that header itself, which keeps the read path off the Worker
entirely: browsers and the Cloudflare edge answer it, and R2 is only touched on a
cold miss. Uploads are resized to a 256px square in the browser before they are
sent, so no image library ships to the Worker.
Dev stays entirely local. The R2 binding points at a bucket miniflare invents on
the spot, so pnpm dev needs no network and no Cloudflare account — but wrangler
emulates the binding, not public-bucket HTTP access, so that bucket has no address
an <img> can reach. GET /api/v1/avatars/* stands in for the custom domain and
serves the headers the object itself carries, so the caching behaviour under test
is the one that ships. It is gated on the same config-derived signal as CORS, an
all-localhost FRONTEND_URLS, so against an https frontend it answers 404 and the
production read path is the only one there is.
The pipeline is driven by .github/deploy-units.jsonc. An app deploys only if
it is listed there and has a wrangler.toml; apps inside one unit deploy in
order and stop on the first failure, and units run in parallel. Only apps
changed in the push actually deploy.
Adding an app is a wrangler.toml plus one line in that manifest — no workflow
edits.
Two units, both shipping. backend and game are separate entries, because
the game is a static PWA that only reaches the API at runtime, so neither has to
land before the other.
One target, selected explicitly. main goes to production and there is no
staging branch. The backend still carries an [env.production] block, because
wrangler dev and the deploy need different bindings: the top level names the
local database and bucket, and the deploy passes --env production to every
wrangler call that reads the file. Bindings do not inherit across a named
environment, so that block repeats the full set rather than patching the one
above. The game has no environments at all, since its config comes from the Vite
build rather than from wrangler.toml.
Run a manual workflow dispatch to force-redeploy every unit. That is what to do
after rotating secrets, which change no files, so --affected finds nothing.
Setup. CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID go in the repo's
production GitHub environment, along with the values both apps declare:
| Key | Value |
|---|---|
BETTER_AUTH_SECRET |
16 characters or more, from openssl rand -hex 32 |
BETTER_AUTH_URL |
https://abalone.tudu.dev |
FRONTEND_URLS |
https://abalone.tudu.dev,https://babaluje.tudu.dev — every origin the game answers on, comma-separated |
AVATAR_PUBLIC_URL |
https://cdn.abalone.tudu.dev |
Then create the database and the bucket. Both already exist on this account and
apps/backend/wrangler.toml carries the database's id; on a fresh account,
paste the new id over it:
pnpm exec wrangler d1 create abalone-backend-dbpnpm exec wrangler r2 bucket create abalone-avatarsThe game's domains are the dashboard's. Attach abalone.tudu.dev and
babaluje.tudu.dev to the abalone-game Worker — the game answers on both,
which is why FRONTEND_URLS is a list — and cdn.abalone.tudu.dev to the
abalone-avatars bucket under Settings, Public access, Custom domain. There is
no wrangler equivalent for the bucket one. The API needs no domain of its own:
apps/backend/wrangler.toml routes abalone.tudu.dev/api/* and
babaluje.tudu.dev/api/* to abalone-backend, and a route wins over the game's
custom domain on the same hostname, so the game calls /api/v1 on its own
origin. The game reads no API url in production; VITE_BACKEND_URL is for dev.
Do all of that before merging. A missing environment value is invisible on the
PR, because CI seeds .env from .env.example and deliberately does not run
check:env; it fails at deploy instead.
The deploy writes env/.env from env/schema.ts, which is the allowlist, so
only declared keys are ever written, and then uploads it onto the Worker as
secrets. Nothing is configured in the Cloudflare dashboard except the game's
domains. A database-backed Worker deploys as upload, then migrate, then promote,
so the schema is in place before the new code goes live. Destructive DDL is
blocked on PRs unless it carries an explicit acknowledgment.
Rehearse any app's deploy without touching Cloudflare:
DRY_RUN=1 bash .github/scripts/deploy/app.sh apps/gameThe project name appears in package.json and in both wrangler.toml files
(worker and D1 names). The game's icons under apps/game/public/ are named for
the game, not the project.
MIT — see LICENSE.