mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
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 tip’s 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(),
|
||
})
|