mono updates

This commit is contained in:
Devin Zuczek
2026-08-15 16:51:50 -04:00
parent 66c09806f9
commit 8e0f090d18
15 changed files with 185 additions and 28 deletions
+12 -2
View File
@@ -1,9 +1,19 @@
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
# Base domain all service hosts are derived from, e.g. accounts.<domain>. Used by
# `just dev` too, so a locally-run worker hands out the same addresses it would deployed.
RECFLARE_DOMAIN=rec.example.com
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
# worker's directory name. Defaults to the directory name when unset.
# worker's directory name. Defaults to the directory name when unset. Use "@" to
# put a worker on the APEX of the domain rather than a subdomain.
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
#
# The combined `mono` worker (an alternative to deploying the services separately:
# it mounts most of them in one deployable and routes on the first path segment, so
# every address is https://<domain>/rooms, https://<domain>/auth, …) belongs on the
# apex, and won't hand out the right addresses anywhere else. It ships only when you
# ask for it — `just deploy-mono`, never `just deploy` — since it's an alternative to
# the split set, not part of it:
# RECFLARE_SUBDOMAINS='{"mono":"@"}'
# Id of the shared `recflare` D1 database (create it manually with
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
+11
View File
@@ -71,6 +71,17 @@ preview:
deploy *args:
bun turbo deploy "$@"
# Deploy the combined `mono` worker: every service in ONE Worker, routed on the first
# path segment (https://<domain>/rooms). It's an alternative to the split deployment
# above — for debugging, or for running the whole server as a single service — so it has
# its own command and `just deploy` leaves it alone. Put it on the apex of your domain
# with RECFLARE_SUBDOMAINS='{"mono":"@"}'; see .env.example.
[group('2. local dev')]
[positional-arguments]
[no-cd]
deploy-mono *args:
bun turbo -F mono deploy:mono "$@"
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
[group('2. local dev')]
+1
View File
@@ -9,6 +9,7 @@
"check:lint": "run-oxlint",
"check:types": "run-tsc",
"check:workers-types": "run-wrangler-types --check",
"deploy:mono": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"test": "run-vitest"
+13
View File
@@ -15,6 +15,19 @@ import type { NotificationsHub } from '../../notify/src/notifications-hub'
* each app's narrower `Env`, so the sub-apps type-check unchanged.
*/
export type Env = SharedHonoEnv & {
/**
* Base domain this worker answers on, e.g. `rec.example.com` — injected from
* `RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, with a
* placeholder default in `wrangler.jsonc` for tests and an unconfigured checkout.
*
* Read by the mounted `ns` app to build the service-discovery document — the thing a
* client is pointed at — so it has to name the host that actually reaches this worker:
* the tunnel/LAN hostname when running it locally, and the apex of the domain when
* deployed (`RECFLARE_SUBDOMAINS='{"mono":"@"}'`, `just deploy-mono`). Every service
* mounted here is served from a PATH on that one host, so the document says
* `https://<domain>/rooms` and nothing else would answer there.
*/
DOMAIN: string
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
JWT_SECRET: SecretsStoreSecret
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
+39 -13
View File
@@ -3,22 +3,31 @@
*
* Mounts each RecFlare worker inside a single deployable Worker WITHOUT modifying the
* originals: every app is imported by relative path and bundled by esbuild at build
* time. Production routing mirrors the split deployment — requests are dispatched on
* the request's subdomain (`accounts.<domain>` -> the `accounts` app), so the sub-app
* paths (and therefore the client contract) are untouched.
* time. A request selects its service two ways, and the sub-app paths (and therefore the
* client contract) are untouched either way.
*
* Local dev has no subdomain, so the first path segment selects the service and is
* stripped before the request is forwarded, e.g.
* http://localhost:8787/accounts/ -> accounts app sees /
* http://localhost:8787/match/player/login -> match app sees /player/login
* http://localhost:8787/api/api/config/v2 -> api app sees /api/config/v2
* By PATH — how this worker is meant to be deployed, at the apex of `DOMAIN`, and the
* only way that works in local dev, which has no subdomain. The first path segment names
* the service and is stripped before the request is forwarded, e.g.
* https://<domain>/accounts/ -> accounts app sees /
* https://<domain>/match/player/login -> match app sees /player/login
* https://<domain>/api/api/config/v2 -> api app sees /api/config/v2
*
* By SUBDOMAIN — `accounts.<domain>` -> the `accounts` app, with the path forwarded
* unchanged. That mirrors the split deployment, so a client (or a stray DNS record) still
* pointed at the per-service hosts keeps working if they're routed here.
*
* A request with no path (just `/`) that selects no service serves the `ns` discovery
* document, so a bare hit to the facade root returns the service map to bootstrap from.
* The document is built in the PATH style (`https://<domain>/rooms`, every service on
* this one host) — see ENDPOINT_STYLE below — so deploy this worker at the apex of
* `DOMAIN` and point the client at nothing else.
*
* NOT mounted here: `www`, `img`, `econ`. Each binds a static `assets` directory and
* Cloudflare allows only one static-assets binding per Worker. Resolve that (serve
* their static trees from R2, or keep those three as their own Workers) before adding.
* The discovery document still puts them on this host, since a single-service run is the
* whole point of this worker — so until they're mounted, their paths 404 here.
*/
import accounts from '../../accounts/src/accounts.app'
import api from '../../api/src/api.app'
@@ -67,16 +76,24 @@ const services = {
type ServiceName = keyof typeof services
/**
* This worker is one host, so its discovery document has to name one host: every service
* is advertised as `https://<domain>/<name>`, never `https://<name>.<domain>`. Handed to
* the mounted `ns` app, which defaults to the per-host document the split deployment wants.
*/
const ENDPOINT_STYLE = 'path'
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
const url = new URL(request.url)
// Production: dispatch on the leftmost DNS label — accounts.<domain> -> accounts.
// The path is forwarded unchanged so the client contract is identical.
// Dispatch on the leftmost DNS label — accounts.<domain> -> accounts. The path is
// forwarded unchanged so the client contract is identical to the split deployment.
const sub = url.hostname.split('.')[0]
if (sub in services) return { name: sub as ServiceName, request }
// Local dev (no service subdomain): the first path segment selects the service and
// is stripped before forwarding — /match/player/login -> match app sees /player/login.
// Apex (and local dev): the first path segment selects the service and is stripped
// before forwarding — /match/player/login -> match app sees /player/login. This is
// what the discovery document advertises; see ENDPOINT_STYLE.
const [, first, ...rest] = url.pathname.split('/')
if (first !== undefined && first in services) {
url.pathname = `/${rest.join('/')}`
@@ -103,11 +120,20 @@ export default {
{ status: 404 }
)
}
// `ns` is the one mounted app whose answer depends on this worker's own shape: the
// addresses it hands out have to be paths on this host. Passed as a var — the same
// way a deploy would — so the app itself stays free of any knowledge of mono.
if (resolved.name === 'ns') return ns.fetch(resolved.request, { ...env, ENDPOINT_STYLE }, ctx)
return services[resolved.name].fetch(resolved.request, env, ctx)
},
// Only `match` runs a cron in the split deployment; this worker owns its presence sweep.
scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> | void {
scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> | void {
return matchScheduled(controller, env, ctx)
},
} satisfies ExportedHandler<Env>
+19 -2
View File
@@ -9,6 +9,9 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
// Must match the DOMAIN var default in apps/mono/wrangler.jsonc.
const TEST_DOMAIN = 'rec.example.com'
// The facade's job is routing, not business logic, so one request that reaches a
// mounted app through the path prefix is enough to prove the wiring. `api` serves a
// static game-config with no auth/DB, so it's a clean target. The api worker namespaces
@@ -24,8 +27,22 @@ describe('mono routing', () => {
test('root path (no service, no prefix) serves the ns discovery document', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
// The ns worker serves the service-discovery document.
expect(await res.json()).toHaveProperty('Auth')
// The ns worker serves the service-discovery document. This worker is one host, so
// every service in it is a path on the base domain (the DOMAIN var default in
// wrangler.jsonc) — no per-service subdomains anywhere in the document.
const doc = (await res.json()) as Record<string, string>
expect(doc).toMatchObject({
Auth: `https://${TEST_DOMAIN}/auth`,
Rooms: `https://${TEST_DOMAIN}/rooms`,
Matchmaking: `https://${TEST_DOMAIN}/match`,
})
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
})
test('the ns service prefix serves that same document', async () => {
const res = await exports.default.fetch(`${ORIGIN}/ns/`)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ Rooms: `https://${TEST_DOMAIN}/rooms` })
})
test('unknown service prefix returns the facade 404', async () => {
+6 -1
View File
@@ -76,6 +76,11 @@
"vars": {
"NAME": "mono", // logging tag; split workers derive this per-app
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Base domain the discovery document is built from; replaced with RECFLARE_DOMAIN by
// both `just dev` and `just deploy-mono`. It must name the host that actually reaches
// this worker, which serves every service it mounts from a path on that ONE host — so
// deployed, it belongs on the APEX of that domain (see src/context.ts).
"DOMAIN": "rec.example.com"
}
}
+11 -3
View File
@@ -7,11 +7,19 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
Notifications, …).
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
`rec.example.com` in `wrangler.jsonc` for local dev.
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected from
`RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, and
defaults to `rec.example.com` in `wrangler.jsonc` when that isn't set.
## Updating endpoints
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
- To add or rename a service host, edit the map in `src/endpoints.ts`.
## ENDPOINT_STYLE
With `ENDPOINT_STYLE=path`, every service is advertised as `https://<domain>/<slug>`
instead of `https://<slug>.<domain>`. That's for the combined `mono` worker alone —
it's a single Worker that routes on the first path segment, so one host serves the
lot. Unset (the split deployment, where each service is its own Worker on its own
host) gives the subdomain document.
+8
View File
@@ -1,5 +1,6 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
import type { EndpointStyle } from './endpoints'
export type Env = SharedHonoEnv & {
/**
@@ -8,6 +9,13 @@ export type Env = SharedHonoEnv & {
* for local dev and tests.
*/
DOMAIN: string
/**
* `path` to serve every service from a path on `DOMAIN` (`https://<domain>/rooms`)
* instead of from its own subdomain. Set only by the combined `mono` worker, which is
* one Worker routing on that first path segment; anything else (the split deployment)
* leaves it unset and gets the per-service hosts.
*/
ENDPOINT_STYLE?: EndpointStyle
}
/** Variables can be extended */
+18 -2
View File
@@ -44,9 +44,25 @@ const SERVICE_SUBDOMAINS = {
WWW: 'www',
} as const
/**
* Where the services live, relative to the base domain:
*
* `subdomain` — one host each, `https://rooms.<domain>`. The split deployment, and the
* default, since that's what every worker in `apps/` is deployed as.
* `path` — one host, first path segment names the service: `https://<domain>/rooms`.
* Only the combined `mono` worker, which is a single Worker routing on that segment.
*/
export type EndpointStyle = 'subdomain' | 'path'
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
export function buildEndpoints(domain: string): Record<string, string> {
export function buildEndpoints(
domain: string,
style: EndpointStyle = 'subdomain'
): Record<string, string> {
return Object.fromEntries(
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
label,
style === 'path' ? `https://${domain}/${sub}` : `https://${sub}.${domain}`,
])
)
}
+4 -2
View File
@@ -28,7 +28,9 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
// Endpoints document, derived from the deploy-time base domain.
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
// Endpoints document, derived from the deploy-time base domain. ENDPOINT_STYLE is set
// only by the combined `mono` worker, to advertise the services on paths of that one
// domain rather than on a host each; unset (the split deployment) means subdomains.
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.ENDPOINT_STYLE)))
export default app
+14
View File
@@ -18,6 +18,20 @@ describe('ns endpoints', () => {
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
})
test('the path style puts every service on the base domain', async () => {
const doc = buildEndpoints(TEST_DOMAIN, 'path')
expect(doc.Rooms).toBe(`https://${TEST_DOMAIN}/rooms`)
expect(doc.Matchmaking).toBe(`https://${TEST_DOMAIN}/match`)
expect(doc.Images).toBe(`https://${TEST_DOMAIN}/img`)
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
})
test('the default style gives every service its own host', async () => {
const doc = buildEndpoints(TEST_DOMAIN)
expect(doc.Rooms).toBe(`https://rooms.${TEST_DOMAIN}`)
expect(doc.Images).toBe(`https://img.${TEST_DOMAIN}`)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
+7 -2
View File
@@ -16,7 +16,9 @@ recflare_load_env
# custom domain via `--domain`. This keeps the real domain out of versioned files
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
# (a JSON object, e.g. {"playersettings":"settings"}).
# (a JSON object, e.g. {"playersettings":"settings"}); an override of "@" (or "")
# puts the worker on the APEX of the domain instead of a subdomain, which is what
# the combined `mono` worker wants — it serves every service from a path on one host.
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
exit 1
@@ -29,7 +31,10 @@ SUBDOMAINS_JSON=${RECFLARE_SUBDOMAINS:-}
DIR=$(basename "$PWD")
# Per-app subdomain override, falling back to the worker's directory name.
SUBDOMAIN=$(printf '%s' "$SUBDOMAINS_JSON" | jq -r --arg d "$DIR" '.[$d] // $d')
HOST="$SUBDOMAIN.$DOMAIN"
case "$SUBDOMAIN" in
"" | "@") HOST="$DOMAIN" ;;
*) HOST="$SUBDOMAIN.$DOMAIN" ;;
esac
# Splice deploy-time resource ids into a generated config. The committed
# wrangler.jsonc carries "local" placeholders so it needs no per-developer edits;
+12 -1
View File
@@ -14,6 +14,15 @@ NAME=$(jq -r '.name' package.json)
recflare_load_env
EXTRA_VARS=$(recflare_vars)
# The base domain isn't a tuning knob (recflare_vars skips the deploy inputs), so pass it
# the same way a deploy does. It matters locally for the workers that hand out addresses —
# `ns`, and the combined `mono` worker, whose discovery document IS the thing you point a
# client at. Unset, the worker keeps the placeholder default in its wrangler.jsonc; running
# mono behind a tunnel, set RECFLARE_DOMAIN to the hostname that reaches it and the document
# it serves names that host instead of a domain nothing here answers on.
DOMAIN_VAR=""
[ -z "${RECFLARE_DOMAIN:-}" ] || DOMAIN_VAR="--var DOMAIN:$RECFLARE_DOMAIN"
# Give each worker a stable, unique dev port so `turbo dev` can run them all in
# parallel without colliding on wrangler's default 8787 (and its 9229 inspector
# port). The offset is the worker's alphabetical position among its siblings, so
@@ -27,9 +36,11 @@ OFFSET=$(
PORT=$((8787 + OFFSET - 1))
INSPECTOR_PORT=$((9229 + OFFSET - 1))
# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split on.
# $EXTRA_VARS and $DOMAIN_VAR are intentionally unquoted — they're flag lists to word-split
# on, and neither a knob value nor a domain contains whitespace.
exec wrangler dev \
--var NAME:"$NAME" \
$DOMAIN_VAR \
$EXTRA_VARS \
--port "$PORT" \
--inspector-port "$INSPECTOR_PORT" \
+10
View File
@@ -41,6 +41,16 @@
"env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
"outputLogs": "new-only"
},
// The combined `mono` worker's deploy (see apps/mono). Deliberately NOT called
// `deploy`: mono is a debugging deployable that runs every service in one Worker,
// an ALTERNATIVE to the split set rather than a member of it, so `just deploy`
// must not sweep it up. Ship it on demand with `just deploy-mono`.
"deploy:mono": {
"cache": false,
"dependsOn": ["build", "topo"],
"env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
"outputLogs": "new-only"
},
// Apply D1 migrations. Only workers that own migrations define a `migrate`
// script, so `turbo migrate` runs just those. No build needed.
"migrate": {