Skip to content

Monorepo topology

Status
Production
Verified
against master

flags.games is one Bun workspace with five apps and four internal packages. Two Cloudflare Workers live in the same repository but sit outside the workspace list, so Bun and Turbo never see them.

The root package.json declares the boundary:

package.json
"workspaces": [
"apps/*",
"packages/*"
],

Everything that ships to players flows from @flags/shared. The web app pulls in all four packages; the game server pulls in only @flags/shared; the docs site pulls in @flags/shared and @flags/flavitars so its simulators run production code.

flags.games_email (React Email templates) and flags.games_mobile (Capacitor shell) are workspaces with no internal dependencies. @flags/fingerprint and @flags/flavitars have no internal dependencies either.

FolderPackage nameRoleBuild script
apps/webflags.games_webSvelteKit app, API routes, Convex backend (src/convex)bun scripts/build.ts
apps/serverflags.games_serverBun HTTP + WebSocket game server, SQLitetsc --noEmit
apps/docsflags.games_docsThis site (Astro + Starlight)astro check && astro build
apps/emailflags.games_emailReact Email templatesemail export --outDir dist --dir emails
apps/mobileflags.games_mobileCapacitor remote shell for https://flags.gamesnone
packages/shared@flags/sharedCountry data, game logic, schemas, constantstsc
packages/audio@flags/audio (exports . and ./lite)Tone.js rich audio and a raw Web Audio lite entrytsup
packages/fingerprint@flags/fingerprintBrowser signal collection and scoringtsc
packages/flavitars-sdk@flags/flavitarsDeterministic avatar SVG renderertsc
workers/staticstatic-worker (not a workspace)Analytics proxy on static.flags.gamesno-op
workers/assetsassets-worker (not a workspace)SvelteKit immutable chunks from R2 on assets.flags.gamesno-op

workers/lib holds plain JS helpers shared by both Workers (mime-by-extension.js, r2-client-build-keys.js, read-capped-body.js).

TargetHosted onHow it deploys
apps/webVercelapps/web/vercel.json: installCommand runs bun install --frozen-lockfile at the repo root, buildCommand runs bun run build:web. One cron: /api/revalidate at 0 0 * * *.
apps/web/src/convexConvexManual bun run deploy:convex (cd apps/web && bunx convex deploy). The schedule generator reads prod archives from https://convex.flags.games.
apps/serverHostinger VPS, Coolify app for api.flags.gamesCoolify builds apps/server/Dockerfile (oven/bun:1.2.19, PORT=8080, CMD ["bun", "run", "src/app.ts"]).
workers/static, workers/assetsCloudflare Workers.github/workflows/deploy-workers.yml on pushes to master that touch workers/**, or bun run deploy:worker locally.
apps/docsVercel project flags-games-docsbunx turbo run build --filter=flags.games_docs.

The web build uploads hashed client chunks to R2 before Vercel finishes, so the assets Worker can serve them:

apps/web/scripts/build.ts
run("bunx", ["svelte-kit", "sync"]);
run("bun", ["scripts/copy-sentry-replay-worker.ts"]);
process.env = withNodeHeapLimit();
run("bunx", ["vite", "build"]);
if (shouldUploadClientBuild()) {
run("bun", ["scripts/r2/upload-client-build.ts"]);
} else {
console.log(
"Skipping R2 client build upload (preview/local or PUBLIC_SVELTEKIT_ASSETS_ORIGIN unset)."
);
}
run("bun", ["scripts/patch-vercel-headers.ts"]);

The assets Worker binds the same bucket and serves only the immutable prefix:

workers/assets/wrangler.toml
# SvelteKit client build: R2 keys `assets/_app/immutable/*` in bucket `flags` (default).
[[r2_buckets]]
binding = "WEB_ASSETS"
bucket_name = "flags"

The static Worker has no R2 binding. It routes /phi to PostHog, /vemetric to Vemetric, and POST /sentry to Sentry, and returns 404 for everything else (workers/static/src/index.js).

CommandWhat runsPorts
bun devturbo dev: every workspace with a dev script (web, server, docs, email, flavitars watch)web 3000, server 3001, docs 4321, email 3030
bun run dev:webturbo dev --filter=flags.games_web --filter=flags.games_server --filter=@flags/flavitars3000, 3001
bun run dev:emailReact Email preview only3030
bun run dev:worker / dev:worker:assetswrangler dev in workers/static / workers/assets8787 / 8788
bun run dev:convexbunx convex dev from apps/webConvex cloud dev deployment

dev:web includes the game server despite its name. The server’s dev script runs from the repo root with bun --env-file=apps/server/.env --watch apps/server/src/app.ts.

turbo.json
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"dependsOn": ["^build"],
"outputs": ["dist/**", ".svelte-kit/**"]
},
"flags.games_web#typecheck": {
"dependsOn": ["^build", "^typecheck"],
"outputs": []
},
"@flags/flavitars#dev": {
"dependsOn": ["build"],
"cache": false,
"persistent": true
}

^build means “build my dependencies first”. For bun run build:web (turbo run build --filter=flags.games_web), Turbo walks the graph above and produces this order:

Only @flags/audio waits on @flags/shared. fingerprint and flavitars could start in wave 1; Turbo schedules them as soon as a worker is free. The critical path is shared→audio→web\text{shared} \rightarrow \text{audio} \rightarrow \text{web}, and the web step (Vite build plus R2 upload) dominates it.

The per-task env lists on flags.games_web#build and flags.games_server#build feed Turbo’s cache hash. envMode: "loose" still passes the full process environment to tasks; the root AGENTS.md records that strict mode caused spurious EADDRINUSE on Bun.serve under turbo dev on Windows.

Built packages set "main": "./dist/index.js", but most runtime code imports source paths directly, for example @flags/shared/src/data/countries. The Bun server runs TypeScript source without a build, and the Docker image copies packages/shared as source. @flags/shared has no exports map, so any file under src/ is importable.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Shared-package blast radius. A change to packages/shared reaches the web app, the server, and the docs site in one commit, but they deploy on three different schedules (Vercel on push, Coolify on push or redeploy, Convex by hand). A WebSocket schema change can be live on the server before the web client that speaks it. Keep schema changes additive across one deploy cycle.
  • Server image skips tests. The Dockerfile comment says tests run in server-ci.yml, not during the image build. A Coolify redeploy from a branch that failed CI still builds.
  • Chunk upload before deploy. With R2_UPLOAD_CLIENT_BUILD_REQUIRED=1, missing R2 credentials make upload-client-build.ts exit 1 and the Vercel build fails. Without the flag, the script logs a warning and returns, while HTML built with PUBLIC_SVELTEKIT_ASSETS_ORIGIN set still points chunk URLs at assets.flags.games. That combination ships pages whose chunks 404.
  • Workers drift silently. The Workers are not in Turbo’s graph, so bun run build and typecheck:web do not cover them. Their only automated gate is the deploy workflow itself.
  • Local ports. resolveFlagsApiBaseUrl() in apps/web/src/lib/env/game-server-urls.ts falls back to http://<page hostname>:3001/api in the browser. A LAN phone hitting http://192.168.x.x:3000 reaches the server on the same host, which is what makes the session_token cookie flow work in dev.
ChoiceAlternativeWhy this one
Convex source inside apps/web/src/convexA separate apps/convex workspaceThe web app imports generated api types directly through $lib/convex-browser; one tree avoids a second copy of _generated.
Bun server on a VPSServerless functionsRooms, timers, heartbeats, and SQLite need a long-lived process.
Workers outside workspacesAdd workers/* to the root listWrangler and its dependency tree stay out of the root lockfile and the server Docker install.
Source imports from @flags/shared/src/...Consume only dist/The server and docs run TypeScript without a prior package build. The cost is that tsc output and source can diverge if someone imports dist directly.
One R2 bucket (flags) for flags, audio, OG images, and client chunksA bucket per asset classOne credential set and one upload helper. Key prefixes (images/flags/, audio/, assets/_app/immutable/) keep the classes apart.

Non-goals: running Workers under turbo dev, publishing any @flags/* package to npm, or deploying the game server from Turbo.