From cf0502628525b64f1b7d089092b198c1edfbbb47 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 1 Aug 2026 17:15:11 -0400 Subject: [PATCH] fix service subdomains env var not working --- apps/ns/README.md | 15 +++++-- apps/ns/src/context.ts | 8 ++++ apps/ns/src/endpoints.ts | 51 +++++++++++++++++++++--- apps/ns/src/ns.app.ts | 2 +- apps/ns/src/test/integration/api.test.ts | 13 ++++++ apps/ns/wrangler.jsonc | 3 +- 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/ns/README.md b/apps/ns/README.md index 6aa2f8e..48a7046 100644 --- a/apps/ns/README.md +++ b/apps/ns/README.md @@ -7,11 +7,18 @@ 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`, with the `SUBDOMAINS` var applied +on top. Both vars are injected at deploy time from `RECFLARE_DOMAIN` and +`RECFLARE_SUBDOMAINS` (see `run-wrangler-deploy`) and default to +`rec.example.com` / `{}` in `wrangler.jsonc` for local dev. ## 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`. +- To point one service at a different host, add it to `RECFLARE_SUBDOMAINS` (in + `.env`) and redeploy. It's keyed by the service's _default_ subdomain — the + same object `run-wrangler-deploy` reads to pick a worker's host, so an entry + moves the deployed worker and the advertised host together. An entry for a + service with no worker (e.g. `{"moderation":"api"}`) is a pure client-side + redirect onto a host another worker already serves. +- To add or rename a service, edit the map in `src/endpoints.ts`. diff --git a/apps/ns/src/context.ts b/apps/ns/src/context.ts index 3174ff3..3c9e487 100644 --- a/apps/ns/src/context.ts +++ b/apps/ns/src/context.ts @@ -8,6 +8,14 @@ export type Env = SharedHonoEnv & { * for local dev and tests. */ DOMAIN: string + + /** + * Per-service subdomain overrides as a raw JSON object keyed by default subdomain, + * e.g. `{"moderation":"api"}`. The operator's `RECFLARE_SUBDOMAINS`, injected at deploy + * time via `--var SUBDOMAINS`; defaults to `{}` in `wrangler.jsonc`. See + * `parseOverrides` in `endpoints.ts`. + */ + SUBDOMAINS: string } /** Variables can be extended */ diff --git a/apps/ns/src/endpoints.ts b/apps/ns/src/endpoints.ts index 5d66976..7008a0a 100644 --- a/apps/ns/src/endpoints.ts +++ b/apps/ns/src/endpoints.ts @@ -1,9 +1,11 @@ /** - * Service-discovery map: service label → subdomain. The game client fetches the + * Service-discovery map: service label → default subdomain. The game client fetches the * generated `{ label: "https://." }` document from `/`. * * The base domain is injected at deploy time via the `DOMAIN` var (see - * `run-wrangler-deploy`), so the real domain never lives in a versioned file. + * `run-wrangler-deploy`), so the real domain never lives in a versioned file. The + * subdomains here are defaults — an operator redirects any of them from `.env`, see + * `applyOverrides` below. */ const SERVICE_SUBDOMAINS = { Accounts: 'accounts', @@ -44,9 +46,48 @@ const SERVICE_SUBDOMAINS = { WWW: 'www', } as const -/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */ -export function buildEndpoints(domain: string): Record { +/** + * Parses the `SUBDOMAINS` var — the operator's `RECFLARE_SUBDOMAINS` object, injected at + * deploy time by `run-wrangler-deploy`. + * + * It is keyed by the DEFAULT subdomain above, not by the service label, because the deploy + * script reads the very same object keyed by a worker's directory name — and every worker's + * directory name is its default subdomain. So one `.env` entry moves both sides at once: + * `{"playersettings":"settings"}` both deploys the `playersettings` worker onto + * `settings.` and advertises that host to the client. Entries naming a service with + * no worker of its own are pure client-side redirects — `{"moderation":"api"}` points the + * client's Moderation calls at the `api` worker, which is where the + * `/api/PlayerReporting/…` routes actually live. + * + * A malformed value is ignored rather than thrown: this document is the first thing the + * client fetches, so a typo in `.env` should cost one redirect, not every service host. + */ +function parseOverrides(subdomains: string | undefined): Record { + if (!subdomains) return {} + let parsed: unknown + try { + parsed = JSON.parse(subdomains) + } catch { + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} return Object.fromEntries( - Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`]) + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '' + ) + ) +} + +/** + * Builds the endpoints document for `domain`, e.g. `rec.example.com`, applying any + * subdomain overrides from `subdomains` (the raw `SUBDOMAINS` var JSON). + */ +export function buildEndpoints(domain: string, subdomains?: string): Record { + const overrides = parseOverrides(subdomains) + return Object.fromEntries( + Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [ + label, + `https://${overrides[sub] ?? sub}.${domain}`, + ]) ) } diff --git a/apps/ns/src/ns.app.ts b/apps/ns/src/ns.app.ts index 259c069..bd5bb24 100644 --- a/apps/ns/src/ns.app.ts +++ b/apps/ns/src/ns.app.ts @@ -29,6 +29,6 @@ const app = new Hono() .notFound(withNotFound()) // Endpoints document, derived from the deploy-time base domain. - .get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN))) + .get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS))) 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..a9abe8b 100644 --- a/apps/ns/src/test/integration/api.test.ts +++ b/apps/ns/src/test/integration/api.test.ts @@ -18,6 +18,19 @@ describe('ns endpoints', () => { expect(body).toEqual(buildEndpoints(TEST_DOMAIN)) }) + test('a subdomain override redirects that service only', () => { + const endpoints = buildEndpoints(TEST_DOMAIN, '{"moderation":"api"}') + expect(endpoints.Moderation).toBe(`https://api.${TEST_DOMAIN}`) + expect(endpoints.API).toBe(`https://api.${TEST_DOMAIN}`) + expect(endpoints.Accounts).toBe(`https://accounts.${TEST_DOMAIN}`) + }) + + test('a malformed override object is ignored', () => { + for (const bad of ['', '{', 'null', '[]', '{"moderation":42}', '{"moderation":""}']) { + expect(buildEndpoints(TEST_DOMAIN, bad)).toEqual(buildEndpoints(TEST_DOMAIN)) + } + }) + test('unknown path returns 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) diff --git a/apps/ns/wrangler.jsonc b/apps/ns/wrangler.jsonc index 92605e6..1f12a2d 100644 --- a/apps/ns/wrangler.jsonc +++ b/apps/ns/wrangler.jsonc @@ -15,6 +15,7 @@ "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment - "DOMAIN": "rec.example.com" // base domain; overridden during deployment + "DOMAIN": "rec.example.com", // base domain; overridden during deployment + "SUBDOMAINS": "{}" // per-service subdomain overrides; overridden during deployment } }