mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
cdn openapi
This commit is contained in:
+29
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 client’s 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 subroom’s `DataBlob` (see the `rooms`',
|
||||
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
|
||||
'slashes.',
|
||||
'',
|
||||
'A room’s 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 invention’s',
|
||||
'`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 don’t match” failure, not a download',
|
||||
'error.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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 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(),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Generated
+15
@@ -215,12 +215,27 @@ importers:
|
||||
'@repo/jwt':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/jwt
|
||||
'@standard-community/standard-json':
|
||||
specifier: 0.3.5
|
||||
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||
'@standard-community/standard-openapi':
|
||||
specifier: 0.2.9
|
||||
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
|
||||
hono:
|
||||
specifier: 4.12.27
|
||||
version: 4.12.27
|
||||
hono-openapi:
|
||||
specifier: 1.3.1
|
||||
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
|
||||
openapi-types:
|
||||
specifier: 12.1.3
|
||||
version: 12.1.3
|
||||
workers-tagged-logger:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@cloudflare/vitest-pool-workers':
|
||||
specifier: 0.16.20
|
||||
|
||||
Reference in New Issue
Block a user