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
+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)