From aebd4ca630c489fd33bc7a48e72912a1b11c6a12 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 20 Jul 2026 18:10:39 -0400 Subject: [PATCH] use path prefixes to make it easy --- apps/mono/src/mono.app.ts | 33 +++++++++++-------- .../mono/src/test/integration/routing.test.ts | 29 ++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 apps/mono/src/test/integration/routing.test.ts diff --git a/apps/mono/src/mono.app.ts b/apps/mono/src/mono.app.ts index ce7bdfb..58ab316 100644 --- a/apps/mono/src/mono.app.ts +++ b/apps/mono/src/mono.app.ts @@ -7,9 +7,11 @@ * the request's subdomain (`accounts.` -> 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. -> 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 { - const name = resolveService(request) - if (name === undefined) { + const resolved = resolve(request) + if (resolved === undefined) { return Response.json( { error: 'unknown_service', - hint: 'Route by subdomain (.). In local dev set the X-Recflare-Service header or ?__svc= query.', + hint: 'Route by subdomain (.), or in local dev prefix the path with the service name (//...).', 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. diff --git a/apps/mono/src/test/integration/routing.test.ts b/apps/mono/src/test/integration/routing.test.ts new file mode 100644 index 0000000..a866f4e --- /dev/null +++ b/apps/mono/src/test/integration/routing.test.ts @@ -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' }) + }) +})