From 8e0f090d1861e6c0fc2bed60b38c3dceffb2f467 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 15 Aug 2026 16:51:50 -0400 Subject: [PATCH] mono updates --- .env.example | 14 ++++- Justfile | 11 ++++ apps/mono/package.json | 1 + apps/mono/src/context.ts | 13 +++++ apps/mono/src/mono.app.ts | 52 ++++++++++++++----- .../mono/src/test/integration/routing.test.ts | 21 +++++++- apps/mono/wrangler.jsonc | 7 ++- apps/ns/README.md | 14 +++-- apps/ns/src/context.ts | 8 +++ apps/ns/src/endpoints.ts | 20 ++++++- apps/ns/src/ns.app.ts | 6 ++- apps/ns/src/test/integration/api.test.ts | 14 +++++ packages/tools/bin/run-wrangler-deploy | 9 +++- packages/tools/bin/run-wrangler-dev | 13 ++++- turbo.jsonc | 10 ++++ 15 files changed, 185 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index d154577..dbf1280 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,19 @@ -# Base domain all service hosts are derived from, e.g. accounts.. +# Base domain all service hosts are derived from, e.g. accounts.. 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:///rooms, https:///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. diff --git a/Justfile b/Justfile index 8488ba9..1f6d691 100644 --- a/Justfile +++ b/Justfile @@ -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:///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')] diff --git a/apps/mono/package.json b/apps/mono/package.json index 2b6f3cb..58e1fd4 100644 --- a/apps/mono/package.json +++ b/apps/mono/package.json @@ -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" diff --git a/apps/mono/src/context.ts b/apps/mono/src/context.ts index bc0a700..4b73367 100644 --- a/apps/mono/src/context.ts +++ b/apps/mono/src/context.ts @@ -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:///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 diff --git a/apps/mono/src/mono.app.ts b/apps/mono/src/mono.app.ts index 0b43388..fcc8a58 100644 --- a/apps/mono/src/mono.app.ts +++ b/apps/mono/src/mono.app.ts @@ -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.` -> 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:///accounts/ -> accounts app sees / + * https:///match/player/login -> match app sees /player/login + * https:///api/api/config/v2 -> api app sees /api/config/v2 + * + * By SUBDOMAIN — `accounts.` -> 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:///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:///`, never `https://.`. 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. -> accounts. - // The path is forwarded unchanged so the client contract is identical. + // Dispatch on the leftmost DNS label — accounts. -> 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 { + scheduled( + controller: ScheduledController, + env: Env, + ctx: ExecutionContext + ): Promise | void { return matchScheduled(controller, env, ctx) }, } satisfies ExportedHandler diff --git a/apps/mono/src/test/integration/routing.test.ts b/apps/mono/src/test/integration/routing.test.ts index 9e05483..549067d 100644 --- a/apps/mono/src/test/integration/routing.test.ts +++ b/apps/mono/src/test/integration/routing.test.ts @@ -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 + 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 () => { diff --git a/apps/mono/wrangler.jsonc b/apps/mono/wrangler.jsonc index 4cbfda5..0c09ea4 100644 --- a/apps/mono/wrangler.jsonc +++ b/apps/mono/wrangler.jsonc @@ -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" } } diff --git a/apps/ns/README.md b/apps/ns/README.md index 6aa2f8e..b029757 100644 --- a/apps/ns/README.md +++ b/apps/ns/README.md @@ -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:///` +instead of `https://.`. 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. diff --git a/apps/ns/src/context.ts b/apps/ns/src/context.ts index 3174ff3..0000e59 100644 --- a/apps/ns/src/context.ts +++ b/apps/ns/src/context.ts @@ -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:///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 */ diff --git a/apps/ns/src/endpoints.ts b/apps/ns/src/endpoints.ts index 5d66976..df5a22f 100644 --- a/apps/ns/src/endpoints.ts +++ b/apps/ns/src/endpoints.ts @@ -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.`. 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:///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 { +export function buildEndpoints( + domain: string, + style: EndpointStyle = 'subdomain' +): Record { 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}`, + ]) ) } diff --git a/apps/ns/src/ns.app.ts b/apps/ns/src/ns.app.ts index 259c069..e4a09ea 100644 --- a/apps/ns/src/ns.app.ts +++ b/apps/ns/src/ns.app.ts @@ -28,7 +28,9 @@ const app = new Hono() .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 diff --git a/apps/ns/src/test/integration/api.test.ts b/apps/ns/src/test/integration/api.test.ts index b1cb750..12b1ba3 100644 --- a/apps/ns/src/test/integration/api.test.ts +++ b/apps/ns/src/test/integration/api.test.ts @@ -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) diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index 1315f03..8943b0f 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -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; diff --git a/packages/tools/bin/run-wrangler-dev b/packages/tools/bin/run-wrangler-dev index a94d935..3f44d07 100755 --- a/packages/tools/bin/run-wrangler-dev +++ b/packages/tools/bin/run-wrangler-dev @@ -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" \ diff --git a/turbo.jsonc b/turbo.jsonc index d356679..d8be24c 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -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": {