/** * Combined ("facade") worker. * * 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. * * 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 * * 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. * * 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. */ import { withDatabase } from '@repo/domain' import accounts from '../../accounts/src/accounts.app' import api from '../../api/src/api.app' import auth from '../../auth/src/auth.app' import cdn from '../../cdn/src/cdn.app' import chat from '../../chat/src/chat.app' import clubs from '../../clubs/src/clubs.app' import commerce from '../../commerce/src/commerce.app' import { app as match, scheduled as matchScheduled } from '../../match/src/match.app' import notify from '../../notify/src/notify.app' import ns from '../../ns/src/ns.app' import playersettings from '../../playersettings/src/playersettings.app' import { app as rooms, scheduled as roomsScheduled } from '../../rooms/src/rooms.app' import storage from '../../storage/src/storage.app' import type { Env } from './context' // The Notifications Durable Object is defined in `notify`; re-export it so this worker // owns the class its wrangler.jsonc binds and migrates (bound in-process, no script_name). export { NotificationsHub } from '../../notify/src/notifications-hub' /** Anything that can handle a fetch with this worker's (superset) Env. */ type Mounted = { fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise } /** * Mount an app on this worker's Env. Each app declares only the bindings it reads, and * this Env is the union of them, so the app's narrower Env is not checked against this one * here — as it never was through Hono's `fetch`, which accepts any env. `withDatabase` * (@repo/domain) types a wrapped app's `fetch` by its own Env, which is what needs the cast. */ const mount = (app: { fetch(request: Request, env: never, ctx: ExecutionContext): Response | Promise }): Mounted => app as Mounted /** * Subdomain -> mounted app. Keys must match the `` in `.` from the * `ns` service-discovery document so production host-based routing lines up. */ const services = { accounts: mount(accounts), api: mount(api), auth: mount(auth), cdn: mount(cdn), chat: mount(chat), clubs: mount(clubs), commerce: mount(commerce), match: mount(match), notify: mount(notify), ns: mount(ns), playersettings: mount(playersettings), rooms: mount(rooms), storage: mount(storage), } satisfies Record type ServiceName = keyof typeof services 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. 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. 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) } } // No service selected and no path (just `/`): serve the `ns` discovery document so a // bare hit to the facade returns the service map, like the apex/ns host. if (url.pathname === '/') return { name: 'ns', request } return undefined } export default withDatabase({ fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise { const resolved = resolve(request) if (resolved === undefined) { return Response.json( { error: 'unknown_service', hint: 'Route by subdomain (.), or in local dev prefix the path with the service name (//...).', services: Object.keys(services), }, { status: 404 } ) } return services[resolved.name].fetch(resolved.request, env, ctx) }, // `match` (presence sweep) and `rooms` (stale ballots, expired invites) each run a // `*/5 * * * *` cron in the split deployment; this worker's one trigger fires both. // Each schedules its work on `ctx.waitUntil`, so the runtime waits for both. scheduled( controller: ScheduledController, env: Env, ctx: ExecutionContext ): Promise | void { matchScheduled(controller, env, ctx) roomsScheduled(controller, env, ctx) }, })