From f185ef97dfac0579b961ba7d349b74a66ef3ff26 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 5 Aug 2026 18:42:19 -0400 Subject: [PATCH] add /server/status endpoint --- apps/www/package.json | 1 + apps/www/src/context.ts | 6 ++++ apps/www/src/test/integration/api.test.ts | 34 +++++++++++++++++++++++ apps/www/src/www.app.ts | 23 ++++++++++++++- apps/www/worker-configuration.d.ts | 6 ++-- apps/www/wrangler.jsonc | 17 ++++++++++-- packages/domain/src/presence-db.ts | 15 ++++++++++ pnpm-lock.yaml | 3 ++ 8 files changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/www/package.json b/apps/www/package.json index 7a869a1..6648e6e 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@scalar/api-reference": "1.63.0", "hono": "4.12.27", diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index a43d885..d4fe3b2 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & { DOMAIN: string /** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */ ASSETS: Fetcher + /** + * The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it + * is the live presence head-count behind `/server-status`. Every table it can see is + * owned (and migrated) by another worker. + */ + DB: D1Database /** * Service binding to the `auth` worker — how the BFF reaches it, so the browser's real * IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`). diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index b8f6ff9..4bb5217 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1,6 +1,8 @@ import { adminSecretsStore, env, SELF } from 'cloudflare:test' import { beforeAll, expect, it } from 'vitest' +import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db' + import { DOCUMENTED_SERVICES } from '../../docs' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' import { turnstileKeys } from '../../turnstile' @@ -22,6 +24,9 @@ const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA' beforeAll(async () => { await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY) await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY) + // `presence` is owned (and migrated) by other workers — www only reads it — so the + // table has to be created here for the head-count behind /server-status. + for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Web signup is open, but only behind the Turnstile check. These pin the closed door: @@ -223,6 +228,35 @@ it('carries the browser IP across to auth instead of losing it to the edge', asy expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull() }) +// The public status snapshot. Two things are pinned: it needs no auth and no origin (a +// status page or Discord bot fetches it from anywhere), and its player count is LIVE +// presence — a row whose TTL has run out is a player who crashed or hard-quit, and +// counting them would leave the number permanently inflated between sweeps. +it('serves a public head-count of the players actually online', async () => { + const now = Math.floor(Date.now() / 1000) + const write = (accountId: number, expiresAt: number) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, roomInstance: null, expiresAt })) + .run() + + // Empty table: online, nobody playing. + let res = await SELF.fetch('https://example.com/server-status') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'online', players: 0 }) + + await write(1, now + PRESENCE_TTL_SECONDS) // in a lobby — still online + await write(2, now + PRESENCE_TTL_SECONDS) + await write(3, now - 1) // stopped heartbeating, not yet swept + + res = await SELF.fetch('https://example.com/server-status', { + headers: { origin: 'https://s.example' }, + }) + expect(res.status).toBe(200) + // Readable from any origin — it's meant to be embedded elsewhere. + expect(res.headers.get('access-control-allow-origin')).toBe('*') + expect(await res.json()).toEqual({ status: 'online', players: 2 }) +}) + it('serves the aggregated docs page with a source per documented service', async () => { const res = await SELF.fetch('https://example.com/docs') expect(res.status).toBe(200) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index e474cee..5d32a20 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { logger, withOnError } from '@repo/hono-helpers' +import { countOnlinePlayers } from '@repo/domain/src/presence-db' +import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers' import { authUnreachable } from './auth-messages' import { docsPage, fetchSpec } from './docs' @@ -70,6 +71,26 @@ const app = new Hono() }) }) + // ---- Server status ------------------------------------------------------ + + // A public, unauthenticated snapshot of the server — what a status page, a Discord + // bot or the homepage can poll without a token. CORS is open on this one route (the + // rest of www is same-origin) so a page hosted anywhere can read it. + // + // `status` is a stub: this handler only runs when the worker is up, so there is no + // state in which it answers anything but "online". It's here so callers can key off + // a field rather than off HTTP 200, and so a real health signal can replace the + // constant without changing the payload's shape. + .get('/server-status', withDefaultCors(), async (c) => { + return c.json({ + status: 'online', + // One presence row per account, expired rows excluded — see countOnlinePlayers. + // Players sitting in the lobby count as online, same as anywhere else we read + // presence. + players: await countOnlinePlayers(c.env.DB), + }) + }) + // ---- Signup ------------------------------------------------------------- // Create an account from the website, behind a Turnstile bot check. The check is what diff --git a/apps/www/worker-configuration.d.ts b/apps/www/worker-configuration.d.ts index b267e04..3187584 100644 --- a/apps/www/worker-configuration.d.ts +++ b/apps/www/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat +// Runtime types generated with workerd@1.20260625.1 2026-06-16 nodejs_compat // Begin runtime types /*! ***************************************************************************** Copyright (c) Cloudflare. All rights reserved. @@ -420,6 +420,7 @@ interface TestController { interface ExecutionContext { waitUntil(promise: Promise): void; passThroughOnException(): void; + readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; readonly access?: CloudflareAccessContext; @@ -526,6 +527,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u } interface DurableObjectState { waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; readonly props: Props; readonly id: DurableObjectId; readonly storage: DurableObjectStorage; @@ -1643,7 +1645,7 @@ declare class Headers { value: string ]>; } -type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; declare abstract class Body { /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ get body(): ReadableStream | null; diff --git a/apps/www/wrangler.jsonc b/apps/www/wrangler.jsonc index 6c3a650..3a1f1b0 100644 --- a/apps/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -5,6 +5,18 @@ "compatibility_date": "2026-06-16", "compatibility_flags": ["nodejs_compat"], "routes": [], + // The shared `recflare` D1 — read-only here, and only for the public + // `/server-status` head-count (see src/www.app.ts). The schema is owned by the + // workers that write it (presence lives in apps/rooms/migrations), so no + // migrations_dir: www never migrates. The "local" placeholder is spliced with + // RECFLARE_D1 at deploy time, as it is for every other worker. + "d1_databases": [ + { + "binding": "DB", + "database_name": "recflare", + "database_id": "local" + } + ], // React SPA client build (Vite emits it to dist/www/client). Static assets are // served directly; any non-asset request falls through to the Worker, which // serves API routes and returns index.html for client-side routes (SPA). @@ -18,7 +30,8 @@ // SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers // send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker, // so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's - // routes (`/api/*`, `/docs*` and `/privacy`). `/docs/scalar.standalone.js` is + // routes (`/api/*`, `/docs*`, `/privacy` and `/server-status`). + // `/docs/scalar.standalone.js` is // deliberately excluded so it's served directly as the static asset it is. // // `/privacy` is here for the same reason `/docs` is, and it matters more: the Meta @@ -27,7 +40,7 @@ "assets": { "binding": "ASSETS", "not_found_handling": "single-page-application", - "run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"] + "run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy", "/server-status"] }, // The Turnstile keypair guarding web signup, out of the same account-level Secrets // Store every other worker binds for JWT_SECRET — values live there, never in this diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 83dd165..f712073 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -152,6 +152,21 @@ export async function countPlayersInInstance( return row?.n ?? 0 } +/** + * How many players are online right now, anywhere — one row per account, so this is + * the player count a status page means. Counts unexpired presence only: rows outlive + * the player by up to the TTL until the sweep purges them, and reads elsewhere ignore + * them the same way. Lobby (null-instance) presence IS counted — those players are + * signed in and playing, they're just not in a room. + */ +export async function countOnlinePlayers(db: D1Database, now = nowSeconds()): Promise { + const row = await db + .prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1') + .bind(now) + .first<{ n: number }>() + return row?.n ?? 0 +} + /** * Live head-count per ROOM, keyed by room id — the players standing in any of a * room's instances right now. One grouped query rather than a count per room, so diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a165bf4..531ccc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: apps/www: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers