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
+1
View File
@@ -17,6 +17,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@scalar/api-reference": "1.63.0",
"hono": "4.12.27",
"react": "19.2.7",
"react-dom": "19.2.7",
+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
+33 -1
View File
@@ -1,7 +1,39 @@
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { cloudflare } from '@cloudflare/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
/**
* Self-host the Scalar API-reference UI used by the `/docs` page (see src/docs.ts),
* instead of loading it from a CDN. Scalar's standalone browser bundle is emitted into
* the client build at `docs/scalar.standalone.js`, so it's served as a same-origin
* static asset pinned to the installed @scalar/api-reference version.
*
* The package doesn't export the standalone subpath, so resolve the package entry and
* reach its sibling `browser/standalone.js`. Only the client build serves browser
* assets, so skip the worker build's bundle.
*/
function scalarStandalone(): Plugin {
const require = createRequire(import.meta.url)
return {
name: 'scalar-standalone',
async generateBundle() {
if (this.environment.name !== 'client') return
const entry = require.resolve('@scalar/api-reference') // dist/index.js
const standalone = resolve(dirname(entry), 'browser/standalone.js')
this.emitFile({
type: 'asset',
fileName: 'docs/scalar.standalone.js',
source: await readFile(standalone),
})
},
}
}
export default defineConfig({
plugins: [react(), cloudflare()],
plugins: [react(), cloudflare(), scalarStandalone()],
})