experimental api docs

This commit is contained in:
Devin Zuczek
2026-07-20 18:41:00 -04:00
parent 30bb6a131c
commit 7429ba4536
6 changed files with 2653 additions and 50 deletions
+81
View File
@@ -0,0 +1,81 @@
import type { Env } from './context'
/**
* Aggregated API docs, served on www at `/docs`.
*
* www is already a backend-for-frontend that reaches the other workers server-side
* (see upstream.ts), so it can serve every worker's OpenAPI spec same-origin — the
* browser only ever talks to www, and there's no cross-origin/CORS problem even though
* the specs live on separate subdomains. The Scalar UI (a self-hosted asset, see the
* vite plugin in vite.config.ts) fetches each spec from `/docs/openapi/{service}.json`,
* which this module proxies to `https://{service}.<DOMAIN>/openapi.json`.
*/
/**
* The workers whose `/openapi.json` we aggregate. Single source of truth: the docs
* page's Scalar sources and the `/docs/openapi/:service` proxy allowlist are both built
* from this, so they can never drift. Add a worker here once it serves `/openapi.json`.
*/
export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> = [
{ slug: 'auth', title: 'auth — authentication & tokens' },
{ slug: 'accounts', title: 'accounts — profiles & lookups' },
{ slug: 'match', title: 'match — matchmaking & presence' },
{ slug: 'econ', title: 'econ — avatar & economy' },
]
/** Path (served as a static asset) of the self-hosted Scalar standalone bundle. */
const SCALAR_ASSET = '/docs/scalar.standalone.js'
/**
* The upstream `/openapi.json` URL for a service, derived from the shared base domain
* the same way upstream.ts derives the auth/accounts hosts.
*/
export function specUpstream(env: Env, slug: string): string {
return `https://${slug}.${env.DOMAIN}/openapi.json`
}
/**
* Proxy a documented worker's `/openapi.json` back to the browser, same-origin. Returns
* null for a service that isn't in the allowlist so the caller can 404 — this keeps the
* route from being turned into an open proxy to `https://<anything>.<DOMAIN>`.
*/
export async function fetchSpec(env: Env, slug: string): Promise<Response | null> {
if (!DOCUMENTED_SERVICES.some((s) => s.slug === slug)) return null
const upstream = await fetch(specUpstream(env, slug))
// Re-wrap so we control the content type and don't forward upstream headers verbatim.
return new Response(upstream.body, {
status: upstream.status,
headers: { 'content-type': 'application/json; charset=utf-8' },
})
}
/**
* The `/docs` HTML page. Mounts the self-hosted Scalar UI with one source per
* documented service (a dropdown to switch between them). Built from
* DOCUMENTED_SERVICES so it stays in sync with the proxy.
*/
export function docsPage(): string {
const sources = DOCUMENTED_SERVICES.map((s) => ({
url: `/docs/openapi/${s.slug}.json`,
title: s.title,
slug: s.slug,
}))
// The config is inlined as JSON — the slugs/titles are static constants, not user
// input, so there's nothing to escape here.
const config = JSON.stringify({ sources })
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>recflare API docs</title>
</head>
<body>
<div id="app"></div>
<script src="${SCALAR_ASSET}"></script>
<script>
Scalar.createApiReference('#app', ${config})
</script>
</body>
</html>`
}
+19
View File
@@ -46,3 +46,22 @@ it('rejects an unauthenticated coach message', async () => {
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
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)
expect(res.headers.get('content-type')).toContain('text/html')
const html = await res.text()
// Mounts the self-hosted Scalar bundle (not a CDN) and lists every service's spec.
expect(html).toContain('/docs/scalar.standalone.js')
for (const slug of ['auth', 'accounts', 'match', 'econ']) {
expect(html).toContain(`/docs/openapi/${slug}.json`)
}
})
it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
// An un-allowlisted service is rejected before any upstream fetch, so this can't be
// turned into a proxy to `https://<anything>.<DOMAIN>`.
const res = await SELF.fetch('https://example.com/docs/openapi/evil.json')
expect(res.status).toBe(404)
})
+22 -1
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withOnError } from '@repo/hono-helpers'
import { docsPage, fetchSpec } from './docs'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import type { Context } from 'hono'
@@ -253,7 +254,11 @@ const app = new Hono<App>()
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { delivered?: number }
return c.json({ success: true, starts_in_minutes: startsIn, connections: result.delivered ?? 0 })
return c.json({
success: true,
starts_in_minutes: startsIn,
connections: result.delivered ?? 0,
})
})
// Send a coach/system message to every online player. Like maintenance, this
@@ -279,6 +284,22 @@ const app = new Hono<App>()
return c.json({ success: true, sent: result.sent ?? 0 })
})
// ---- Aggregated API docs ------------------------------------------------
// `/docs` serves the self-hosted Scalar UI; `/docs/openapi/:service.json` proxies
// each worker's spec same-origin (see docs.ts). The Scalar bundle itself
// (`/docs/scalar.standalone.js`) is a static asset emitted by the vite build, so it
// falls through to the ASSETS catch-all below.
.get('/docs', (c) => c.html(docsPage()))
.get('/docs/openapi/:service', async (c) => {
// Scalar requests `auth.json`; strip the suffix to get the service slug. The
// param is a single path segment, and fetchSpec allowlists it (so this can't be
// coerced into an open proxy).
const slug = c.req.param('service').replace(/\.json$/, '')
const spec = await fetchSpec(c.env, slug)
if (spec === null) return c.notFound()
return spec
})
// ---- Static SPA ---------------------------------------------------------
// Everything else is served from the built client assets. With
// `not_found_handling: single-page-application`, unknown routes return