Files
recflare/apps/cdn/src/cdn.app.ts
T
Devin Zuczek a5136d2bfa cdn openapi
2026-07-24 21:48:10 -04:00

237 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
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. 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. */
function parseRange(header: string | undefined): R2Range | undefined {
if (!header) return undefined
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
if (!m) return undefined
const start = m[1]
const end = m[2]
if (start === '' && end !== '') return { suffix: Number(end) } // last N bytes
if (start !== '') {
return end !== ''
? { offset: Number(start), length: Number(end) - Number(start) + 1 }
: { offset: Number(start) }
}
return undefined
}
/**
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
* honoring Range requests. 404s when the file is missing.
* Supports conditional GET and byte-range requests (206) — large-file
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
* reassembled file (e.g. EAC "Signatures don't match").
*/
async function serveAsset(c: Context<App>, key: string) {
if (key.includes('..')) return c.body(null, 400)
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
const range = parseRange(c.req.header('range'))
const object = await (c.env as Env).CDN_ASSETS.get(key, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
...(range ? { range } : {}),
})
if (!object) return c.notFound()
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)
headers.set('content-type', 'application/octet-stream')
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', 'public, max-age=3600')
// Precondition matched (If-None-Match) → R2 returns no body.
if (!('body' in object)) return new Response(null, { status: 304, headers })
// Range honored → 206 Partial Content with Content-Range.
if (object.range && c.req.header('range')) {
// 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 })
}
return new Response(object.body, { headers })
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.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',
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',
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{.+}',
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{.+}',
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