mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
experimental api docs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user