use path prefixes to make it easy

This commit is contained in:
Devin Zuczek
2026-07-20 18:10:39 -04:00
parent 86edf0ba66
commit aebd4ca630
2 changed files with 49 additions and 13 deletions
+20 -13
View File
@@ -7,9 +7,11 @@
* the request's subdomain (`accounts.<domain>` -> the `accounts` app), so the sub-app
* paths (and therefore the client contract) are untouched.
*
* Local dev has no subdomain, so pick a service explicitly with the
* `X-Recflare-Service` header or an `?__svc=` query param, e.g.
* curl -H 'X-Recflare-Service: accounts' http://localhost:8787/health
* 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
*
* 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
@@ -62,34 +64,39 @@ const services = {
type ServiceName = keyof typeof services
function resolveService(request: Request): ServiceName | undefined {
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
const url = new URL(request.url)
// Local-dev / explicit override (localhost has no service subdomain).
const override = request.headers.get('x-recflare-service') ?? url.searchParams.get('__svc')
if (override !== null && override in services) return override as ServiceName
// Production: dispatch on the leftmost DNS label — accounts.<domain> -> accounts.
// The path is forwarded unchanged so the client contract is identical.
const sub = url.hostname.split('.')[0]
if (sub in services) return sub as ServiceName
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.
const [, first, ...rest] = url.pathname.split('/')
if (first !== undefined && first in services) {
url.pathname = `/${rest.join('/')}`
return { name: first as ServiceName, request: new Request(url, request) }
}
return undefined
}
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise<Response> {
const name = resolveService(request)
if (name === undefined) {
const resolved = resolve(request)
if (resolved === undefined) {
return Response.json(
{
error: 'unknown_service',
hint: 'Route by subdomain (<service>.<domain>). In local dev set the X-Recflare-Service header or ?__svc= query.',
hint: 'Route by subdomain (<service>.<domain>), or in local dev prefix the path with the service name (/<service>/...).',
services: Object.keys(services),
},
{ status: 404 }
)
}
return services[name].fetch(request, env, 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.
@@ -0,0 +1,29 @@
import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://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
// its own routes under `/api`, hence the `/api` prefix (service) + `/api/...` (real path).
describe('mono routing', () => {
test('path prefix routes to the api worker (gameconfigs)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/api/gameconfigs/v1/all`)
expect(res.status).toBe(200)
// Reached the api app's real handler, not the facade's 404.
expect(res.headers.get('content-type')).toContain('application/json')
})
test('unknown service prefix returns the facade 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope/whatever`)
expect(res.status).toBe(404)
expect(await res.json()).toMatchObject({ error: 'unknown_service' })
})
})