cdn openapi

This commit is contained in:
Devin Zuczek
2026-07-24 21:48:10 -04:00
parent 460839458c
commit a5136d2bfa
7 changed files with 362 additions and 20 deletions
+29 -1
View File
@@ -1,6 +1,34 @@
# cdn
A Cloudflare Workers application using Hono
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
bucket, plus the one bundled config file the loading screen reads.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
served as `application/octet-stream`; the worker never interprets what it hands back.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
images are served by `img`.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
the docs page www serves at `/docs`.
**The spec is descriptive, not enforced** — same rationale as the `img`/`auth` workers: a
reverse-engineered protocol, lenient handlers, no runtime validation. A test asserts
every route appears in the spec, so adding one without documenting it fails.
## Conditional and range requests
Every asset route honours `If-None-Match` (→ 304) and a single `Range` (→ 206). The range
support is not an optimization: large-file downloaders fetch in chunks, and answering 200
where a 206 is expected corrupts the reassembled file — which surfaces as an anti-cheat
"Signatures don't match" failure rather than a download error.
## Development
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+138 -18
View File
@@ -1,17 +1,26 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
import {
assetResponses,
CONDITIONAL_HEADERS,
json,
keyParam,
LoadingScreenTip,
ServiceStatus,
} from './openapi'
import type { Context } from 'hono'
import type { App, Env } from './context'
/**
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
* are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
* yet and are stubbed.
* are served bare. Everything but the liveness probe and the bundled tip data is
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
*/
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
@@ -60,16 +69,16 @@ async function serveAsset(c: Context<App>, key: string) {
// Range honored → 206 Partial Content with Content-Range.
if (object.range && c.req.header('range')) {
const r = object.range
let offset: number
let length: number
if ('suffix' in r) {
length = r.suffix
offset = object.size - length
} else {
offset = r.offset ?? 0
length = r.length ?? object.size - offset
}
// R2 hands back the RESOLVED range, and the object it returns carries all three
// keys with the inapplicable ones set to undefined — so `'suffix' in r` is true
// even for an offset/length range and cannot discriminate between the two forms.
// (It read as a suffix range every time, making offset/length NaN and the
// Content-Range header garbage.) Read the values, not the keys. A `bytes=-N`
// request already comes back resolved to a concrete offset/length; the suffix
// fallback below is only there in case that ever stops being true.
const r = object.range as { offset?: number; length?: number; suffix?: number }
const length = r.length ?? r.suffix ?? object.size - (r.offset ?? 0)
const offset = r.offset ?? object.size - length
headers.set('content-length', String(length))
headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`)
return new Response(object.body, { status: 206, headers })
@@ -92,25 +101,136 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'cdn', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') },
}),
(c) => c.json({ service: 'cdn', status: 'ok' })
)
// Loading-screen tips, bundled here as static JSON.
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
.get(
'/config/LoadingScreenTipData',
describeRoute({
tags: ['Config'],
summary: 'Loading-screen tips',
description: [
'The tips the client cycles through on a loading screen. A bundled static file',
'(`static/loading-screen-tip-data.json`), captured from the real service and served',
'verbatim — nothing here is editable at runtime, and every client gets the same list',
'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`',
'fields are the clients own filters, applied client-side.',
].join(' '),
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
}),
(c) => c.json(loadingScreenTipData)
)
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// 404 when missing.
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
.get(
'/sigs/:sigName',
describeRoute({
tags: ['Assets'],
summary: 'Serve a signature blob',
description: [
'Streams the object stored under `sigs/<sigName>`. These are the anti-cheat signature',
'blobs the client fetches at startup; nothing here inspects or validates them.',
].join(' '),
parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS],
responses: assetResponses('The signature blob'),
}),
(c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)
)
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
// load the room. Streamed from R2 under `room/`. The name may contain slashes
// (uploads are foldered by date, e.g. `2026-02-03/<uuid>`), so match the rest of
// the path.
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
.get(
'/room/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve room build data',
description: [
'Streams the object stored under `room/<dataBlob>` — the saved scene the client',
'downloads to load a room. The name comes from a subrooms `DataBlob` (see the `rooms`',
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
'slashes.',
'',
'A rooms IMAGE also lives under this prefix, stored by its bare `ImageName` — the',
'same route serves both.',
].join('\n'),
parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The room data'),
}),
(c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)
)
// Invention data by name. The client fetches this for an invention's
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
// the upload stored it under, so the rest of the path is matched as-is.
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
.get(
'/invention/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve invention data',
description: [
'Streams the object stored under `invention/<dataBlob>` — the data the client',
'downloads to spawn an invention. The name comes from an inventions',
'`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,',
'and it keeps the `.inv` extension the upload stored it under.',
].join(' '),
parameters: [
keyParam('dataBlob', 'The blob name, including `.inv`.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The invention data'),
}),
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare cdn',
version: '1.0.0',
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
'R2 bucket, plus the one bundled config file the loading screen reads.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
'',
'Every asset route supports conditional GETs (`If-None-Match` → 304) and single',
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
'which surfaces as an anti-cheat “Signatures dont match” failure, not a download',
'error.',
].join('\n'),
},
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+114
View File
@@ -0,0 +1,114 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the cdn worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/img workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*
* Most of this worker's surface is opaque BYTES, not JSON, so those responses are
* described with a binary content type rather than a zod schema (the same way the `img`
* worker describes image bytes).
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* A binary asset response. Everything streamed out of the bucket is served as
* `application/octet-stream` regardless of what it actually is — the client downloads
* these blobs, it never sniffs their type.
*/
export function assetBytes(description: string): OpenAPIV3_1.ResponseObject {
return {
description,
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
}
}
/** The shared responses of every asset route: the byte-serving ones plus its failures. */
export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject {
return {
200: assetBytes(description),
206: assetBytes('A byte range, when the request carried a `Range` header'),
304: { description: '`If-None-Match` matched the stored etag (no body)' },
400: { description: 'The key contains `..` (no body)' },
404: { description: 'No such object in the bucket' },
}
}
/** The `Range` / `If-None-Match` headers every asset route honours. */
export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
{
name: 'Range',
in: 'header',
required: false,
description:
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.',
schema: { type: 'string', example: 'bytes=0-1023' },
},
{
name: 'If-None-Match',
in: 'header',
required: false,
description: 'The etag of a previously fetched copy; a match answers 304 with no body.',
schema: { type: 'string' },
},
]
/** A path parameter naming an object in the bucket. */
export function keyParam(
name: string,
description: string,
slashes: boolean
): OpenAPIV3_1.ParameterObject {
return {
name,
in: 'path',
required: true,
description: slashes ? `${description} May contain slashes.` : description,
schema: { type: 'string' },
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('cdn'),
status: z.literal('ok'),
})
/**
* One loading-screen tip. `Context`/`InputType`/`Visibility` are client-side enums that
* decide where a tip may appear, and `PlatformMask` is a bit field of the platforms it
* shows on — every tip in the bundled set is left at whatever the 2019 capture had.
*/
export const LoadingScreenTip = z.object({
Name: z.string().describe('A GUID (no dashes) — the tips id, not a display name'),
Title: z.string(),
Message: z.string(),
RoomNames: z
.array(z.string())
.describe('Rooms to restrict the tip to; empty everywhere in the bundled set'),
Context: z.int(),
InputType: z.int(),
Visibility: z.int(),
AllowCycling: z.boolean(),
RestrictToNewUsers: z.boolean(),
ImageName: z.string().describe('An image key the client resolves against the img worker'),
PlatformMask: z.int().describe('Bit field of the platforms the tip shows on'),
CreatedAt: z.string(),
})
+59
View File
@@ -52,6 +52,27 @@ describe('cdn endpoints', () => {
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([12, 13, 14]))
})
// The other two Range forms. Both resolve to a concrete offset/length inside R2, so
// they exercise the same Content-Range math as the closed range above — which read
// every range as a suffix range and emitted `bytes NaN-NaN/6` until it was fixed.
test('GET /sigs/:sigName honors open-ended and suffix Range requests', async () => {
await env.CDN_ASSETS.put('sigs/ranged2', new Uint8Array([10, 11, 12, 13, 14, 15]))
const fetchRange = (range: string) =>
exports.default.fetch(`${ORIGIN}/sigs/ranged2`, { headers: { Range: range } })
// `bytes=4-` — from an offset to the end.
const open = await fetchRange('bytes=4-')
expect(open.status).toBe(206)
expect(open.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await open.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
// `bytes=-2` — the last N bytes.
const suffix = await fetchRange('bytes=-2')
expect(suffix.status).toBe(206)
expect(suffix.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
})
test('GET /room/:dataBlob streams the room blob from R2', async () => {
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
@@ -79,4 +100,42 @@ describe('cdn endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+1
View File
@@ -25,6 +25,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }>
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
{ slug: 'chat', title: 'chat — threads & messages' },
{ slug: 'img', title: 'img — image serving & resizing' },
{ slug: 'cdn', title: 'cdn — binary asset delivery' },
{ slug: 'storage', title: 'storage — uploads to the CDN bucket' },
{ slug: 'playersettings', title: 'playersettings — per-player settings' },
{ slug: 'api', title: 'api — everything else' },