mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add /server/status endpoint
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-1
@@ -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<App>()
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 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
|
||||
|
||||
+4
-2
@@ -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<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): 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<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): 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<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData;
|
||||
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
|
||||
declare abstract class Body {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
||||
get body(): ReadableStream | null;
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
@@ -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<number> {
|
||||
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
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user