Skip to content

Latest commit

 

History

History
2571 lines (2228 loc) · 154 KB

README.md

File metadata and controls

2571 lines (2228 loc) · 154 KB

Auto-generated API documentation — implementation plan

OpenAPI 3.1 for the HTTP adapter, AsyncAPI 3.0 for the WebSocket adapter, across bun-common, bun-nest and the existing bun-jobs prior art.

Status (2026-11-10): complete. (g), the optional swagger bridge (§11), is dropped by the user's decision of 2026-11-10, as §3.8.2's open question allowed: @nestjs/swagger on BunHttpAdapter covers the DTO path (phase (d)), and bun-common's own generator documents Standard Schema routes; (b1) merged as bun-validate's V3a (#482), and (f) as #541 and #542, both in §11's table. Started 2026-10-24 — (b0) merged, #527 (the vendored spec types at 1.1.0: StandardTypedV1, StandardJSONSchemaV1, StandardSchemaV1.Options, in bun-common's lib/types/standardSchema.ts). (b) merged 2026-10-25, #529, and shipped before (a) as the standalone converter (the maintainer's decision of 2026-10-24): toJsonSchema, SchemaDirection, ConvertContext, JsonSchemaConverter, JsonSchemaLike, registerJsonSchemaConverter, zodConverter, arktypeConverter, yupConverter, bunJobsConverter, valibotConverter, withJsonSchema, liftComponents, ConversionWarning (all approved names), in bun-common's new lib/docs/ module — no __schemas (V1's contracts and V12's listRoutes supersede it), no document generator, no strict mode and no ConfigError (those are (a)'s), and the converter never throws for a content problem ({} plus a warning). §4.2/§4.3 carry the response-direction correction: responses convert with input except under V13's responseOutput: "parsed". (a) merged 2026-10-25, #531 (the maintainer's decisions of 2026-10-25, all final): ConfigError (new in bun-common, lib/errors.ts — extends Error, optional details record; bun-jobs keeps its own, a JobsError, and its spec/security.ts shim translates, with an instanceof test), spec/refs.ts and spec/security.ts moved to lib/docs/ (bun-jobs keeps two-line re-export/translation shims; OpenApiSecurityScheme re-declared in bun-common, aliased in bun-jobs), RouteDoc/RouteResponseDoc (plus request.cookies, §4.11 of the bun-validate plan), .describe() (a BunRouter method beside setName, hand-written — not in the generated overloads; the WeakMap keyed on the terminal callback), ListedRoute gains routeIndex, generateOpenApi + OpenApiOptions/OpenApiResult/DocWarning/OpenApiDocument over V12's listRoutes (§3.3's DiscoveredRoute is subsumed — filter and operationId take a ListedRoute), toOpenApiPath ported with §10.2's rules (optional params document as two paths by default, an OpenApiOptions.optionalParams flag — name approved 2026-10-25 — falls back to a warning), the OAS 3.1 meta-schema matrix ({none, zod, valibot, arktype, yup, mixed} × {none, bearer, apiKey} × {strict, lenient}, emitting openapi: "3.1.2" — the version facts re-checked against spec.openapis.org 2026-10-25: 3.1.2 is the latest 3.1.x; 3.2.x exists and is out of scope), the golden fixture, and the type tests. The root lib/index.ts re-exports the (a) surface as types only (§3.1). §6.1's "port toOpenApiPath verbatim" is corrected: its structure was ported, not the throw sites — in bun-common the guards are warnings (strict escalates), per §10.2. (c) merged 2026-10-25, #532 (the maintainer's decisions of 2026-10-25, all final — names: docsRouter, DocsRouterOptions, DocsCdnOptions, docsPageHeaders, swaggerUiPage, asyncApiUiPage, resolveDocsCdn, ResolvedDocsCdn, PinnedAsset, AssetIntegrity, assetUrl, and bun-common's own DOCS_CDN pins copy — with one mid-build change, also 2026-10-25: §7.1's ui/uiPath/asyncapiUiPath became a single viewer option of per-spec { name, ...options } declarations, five viewers pinned — Scalar the OpenAPI default, the official component the AsyncAPI one; see §7.1's as-built note). The ./lib/docs/types browser entry landed with it (deferred here from (a)), carrying OpenApiDocument + JsonSchemaLike only. (d) merged 2026-10-26, #533 — @nestjs/swagger verified end-to-end against BunHttpAdapter, no hole found, so no ./lib/swagger helper. (a2) merged 2026-10-26, #534: the registrar as defineRoutes(mount, build) (the rename to the typed-client plan's §8.5 shape; names defineRoutes, RoutesOf, RouteDef approved 2026-10-25/26), a helper over .describe() at bun-common's root — every definition's property name is its route's operationId, and RoutesOf (a pure type) reads the return into the flat route map, for inference (TC's D3), not instead of generation. (b2) merged 2026-10-26, #535 (the maintainer's decisions of 2026-10-26, all final — names DtoClass, ClassValidatorAdapter, ClassDtoOptions, useClassDtos, fromDto, vendor string "class-validator"): class DTOs in bun-common's lib/classDto.ts, the libraries injected (useClassDtos) and held as devDependencies only; every schema position widened (the six ValidationSchemas keys, ResponseSpec, RouteDoc.request.*, RouteResponseDoc.schema); the unregistered-adapter case a deliberate ConfigError naming useClassDtos; documents through a registered "class-validator" converter and a WeakMap back to the class; async-only, never where a synchronous schema is required; bun-nest deliberately untouched. §3.6's header block carries the measured corrections (the whitelist note was wrong; toJsonSchema returns object). (e) merged 2026-10-26, #539 (the maintainer's decisions of 2026-10-26, all final — names generateAsyncApi, AsyncApiOptions, AsyncApiResult, AsyncApiDocument, WsChannelDoc, WsMessageDoc, describeChannel, wsRoutes(), gatewayAsyncApi in bun-nest's lib/gateway-docs.ts, the extensions x-bun-common-schema (on parameters — AsyncAPI 3.0 parameters carry no schema keyword), x-bun-common-sse, x-bun-common-sse-fallback, x-bun-common-subprotocol, x-bun-common-binary, x-bun-common-topics): the AsyncAPI 3.0 generator. wsRoutes()/describeChannel() on BunWebSocket (the read-only listing never exposes the mutable maps; describeChannel enriches a bare ws() route's stub — §5.1's re-scoping, below); ContractMeta gained an optional channel field and ChannelMeta an optional request (the upgrade's schemas ride the contract meta — the generator's only reach), channelContract() passing both through; generateAsyncApi(source: BunRouter | BunWebSocket, options) in lib/docs/asyncapi.ts (channels keyed on the contract's transport name, so a future broker transport maps without redesign; a channel's message payloads documented as the { type, id?, data } frame envelope, bun-jobs' eventEnvelope precedent; replies as operation.reply; the implicit error frame marked as the framework's; an SSE GET — a contract body of kind "sse" — one channel with an http GET binding; strict/lenient as generateOpenApi, a duplicate channel id always a ConfigError); docsRouter's structural AsyncApiDocumentLike replaced by ./types' AsyncApiDocument (a deliberate, announced tightening — the real type satisfies the structural one); bun-nest's gatewayAsyncApi(app) discovering gateways with DiscoveryService (built on the app's ModulesContainer — the workspace's Nest 12.2.0 does not auto-register the DiscoveryModule, and resolving an unregistered token through app.get is avoided deliberately) + MetadataScanner, the socket.io EVENT envelope the documented payload with @ValidateMessage's schema at data[1] (recorded under the new "bun:validate:message" metadata key, two lines in wsValidation.ts), the ack as the reply's ACK packet, the @Emits map the send operations, the CONNECT/DISCONNECT/ERROR frames and the exception event fixed on every channel, a wildcard gateway route warning as unmappable. Emitting asyncapi: "3.0.0" — §9.1's 3.1.0 question decided for 3.0.0, matching bun-jobs. Every document validates against the official @asyncapi/specs 3.0.0 schema (ajv's draft-07 build, the embedded draft-07 $schema definition deleted first — bun-jobs' own workarounds). The examples: examples/bun-common/13-api-docs/asyncapi-channels.ts, examples/bun-nest/09-asyncapi/gateway-docs.ts; the playground's docs router serves the chat channel's AsyncAPI document. Tag groups merged 2026-11-03, #629 (the maintainer's decisions of 2026-11-03): OpenApiOptions.tagGroups, readonly { name; tags }[], emitted as the document's top-level x-tagGroups (Redoc's and Scalar's shape) — sections over the tags of .describe({ tags }). Chosen in the build and approved by the maintainer on 2026-11-03, with the names tagGroups, "Other", groupBySection and DocSection: a used tag no group names goes in a trailing "Other" group (Redoc hides a tag in no group); a group's unused tag is left out with a warning, a group left empty too; a tag named twice stays in its first group, with a warning; two groups with one name are always a ConfigError; an untagged operation is warned about (Redoc does not show it); with groups, tags follows the groups' order. bun-api-docs-ui's viewer shows the groups as top-level sidebar sections with their tags nested (groupBySection, DocSection in its httpModel); bun-client's generator ignores the extension (tested). defaultTags merged 2026-11-03, #632 (name approved by the maintainer): OpenApiOptions.defaultTags(route) supplies tags to an operation whose own .describe() declares none (own tags, [] included, win); the tags it supplies count as used (tags, descriptions, tagGroups). The playground uses it for the jobs API's routes, in place of copying tags from api.openapi() afterwards. x-tagGroups in bun-jobs' own document (createJobsApi) is deferred (2026-11-03). The example: examples/bun-common/13-api-docs/sections.ts. Every claim about existing code cites a file; every claim marked measured was produced by running code against this checkout (Bun 1.4.3-canary.1, 2026-09-22). Claims marked assumed were not executed.


1. Executive summary

bun-jobs already ships a complete OpenAPI 3.1 + AsyncAPI 3.0 generator (packages/bun-jobs/lib/api/spec/, ~1,800 lines) validated against both official meta-schemas. It works because bun-jobs declares its routes through defineRoute() — a closed set of 60-odd definitions carrying operationId, summary, tags, responses, errors and schemas built with its own s.* DSL. None of that exists for an arbitrary BunRouter.

The whole plan turns on three decisions.

Decision 1 — the metadata carrier is a chained .describe(), keyed on the handler function

Route documentation must attach after registration, not as a new argument position. CLAUDE.md records three hard constraints on the generated verb overloads; a new positional argument would break the third ("preceding positions are typed RouterHandler, not a union") and very likely the second. BunRouter already has the exact mechanism needed: #lastRoute, used by setName() (BunRouter.ts:4110). .describe(meta) mirrors it one-for-one.

The metadata store must be keyed on the route's terminal callback function, not on the Route object. Measured: mounting a sub-router creates a new Route and a new callbacks array (mergeRoute → getFormattedSetRouteOption, BunRouter.ts:759-800), but the callback function identities survive:

Route object identity preserved: false
callbacks array identity preserved: false
terminal callback identity preserved: true

A WeakMap<Function, RouteDoc> therefore survives use(subRouter) with no change to mergeRoute. Zero cost when unused: an empty WeakMap and one branch in setRoute.

Decision 2 — request schemas come from BunValidate, but only after the middleware carries them at runtime

validate({ query, body }) returns a bare closure (BunValidate.ts:middleware()). The schemas live in the BunValidate instance's private #options; the returned function advertises them only as the phantom __shape type (ValidatorMiddleware<TShape>, BunValidate.ts). At runtime they are gone.

The fix is three lines: attach the schemas to the returned handler as a non-enumerable property. Measured — the property survives registration with function identity intact:

callback[0] carries __schemas: true   same fn identity: true

This is the single highest-leverage change in the plan. Every zod/valibot/ arktype/yup schema a user already wrote for validation becomes documentation.

Superseded (2026-10-24, the maintainer's decision): no __schemas ever landed. The contracts of bun-validate V1 (getContract on the middleware) and V12's listRoutes already carry the schemas at runtime, so the middleware needs nothing new; (b) was built against them.

Decision 3 — read ~standard.jsonSchema first; vendor converters are a structural fallback

The premise most of this plan was drafted under is out of date. Standard Schema gained an official JSON Schema companion interface: @standard-schema/spec 1.1.0 (published 2025-12-15) added StandardJSONSchemaV1, a sibling of StandardSchemaV1 sharing the ~standard property:

readonly jsonSchema: {
  readonly input:  (options: { target: Target; libraryOptions?: … }) => Record<string, unknown>;
  readonly output: (options: { target: Target; libraryOptions?: … }) => Record<string, unknown>;
};
// Target = "draft-2020-12" | "draft-07" | "openapi-3.0" | (string & {})

It is a sibling, not an extension, so detection is a runtime feature check, not a version check: typeof s["~standard"].jsonSchema?.output === "function".

Measured on this checkout:

Vendor Version ~standard.jsonSchema? Fallback needed
zod 4.6.5 yes — {input, output} no
arktype 2.2.3 yes — {input, output} no
valibot 1.5.0 (core) no @valibot/to-json-schema's toStandardJsonSchema, which adds one
yup 1.7.1 no (~standard present, vendor: "yup", no jsonSchema) our own describe() mapper
superstruct 2.0.2 no ~standard at all none possible — explicit JSON Schema only

So the design is two layers, and the first one is vendor-agnostic:

  1. ~standard.jsonSchema when present — no vendor knowledge, no imports, works for every library that adopts the companion spec (per standardschema.dev/json-schema: zod ≥4.2, arktype ≥2.1.28, valibot ≥1.2 via the companion package, VineJS, Sury, stnl, GraphQL Standard Schema).
  2. A vendor registry keyed on ~standard.vendor for everything else. Its converters are structural, exactly like the logging adapters (CLAUDE.md: "Adapters are structural — nothing imports those libraries.") — measured, zod's .toJSONSchema() and arktype's .toJsonSchema() are instance methods and yup's .describe() is too, so the fallbacks for older versions need no imports either.

Either way bun-common gains no dependency, no optional peer, and no declaration that imports an undeclared package.

Decision 4 — responses ride on the validator, not on .describe()

This was forced by a spike, and it resolves what would otherwise be two competing places to declare a response.

.describe() is chained after the verb call, so the handler has already been checked by the time it runs. Spiked (spike/chained.ts): even given the most generous possible builder — get() returning a Builder<H> that carries the handler's type — a subsequent .describe({ responses }) produces no error for a handler that returns the wrong shape. Enforcement is impossible by construction. The same spike shows the converse: once responses are declared before the handler, a wrong body is caught.

So the two mechanisms divide cleanly, and the split is a rule, not a preference:

Mechanism Owns Reaches the type system?
.describe({ … }) prose: summary, description, tags, operationId, deprecated, hidden, security, extensions no — and never needs to
validate(schemas, { responses }) anything that must be enforced: request schemas (already) and now response status + shape yes — it is the one shape-carrying position

A route that wants documented responses with no runtime or compile-time checking uses .describe({ responses }), which stays in the API for exactly that case (a route with no validator, a legacy handler, a hand-written fragment). When both are present the validator wins and .describe()'s responses is merged in for documentation only, with a warning if they disagree. §3.4 states the rule.

Three results, all spiked against the real BunRouter (a full copy of packages/bun-common with the changes applied — CLAUDE.md records that the per-position-shape idea worked in isolation and collapsed in the real class, so isolation was not an option):

  • Narrowing res through the validator works, including for mounted sub-routers, and the error messages are clean (§3.7).
  • The patched library typechecks with zero changes outside four files — the whole cost is one protected setStatusRaw and one cast (§3.7).
  • It does not disturb the generated verb overloads: no argument position changes, and MountedHandler reads the responses out of the TShape it already receives, so generate-verb-overloads.ts emits identical text.

What this does not solve

Response bodies are still invisible to inference — nothing derives a schema from res.json(x). Responses are declared, by hand, once. What changed is that the declaration is now enforced at compile time and optionally at runtime, instead of being documentation that silently rots.

Enforcement is partial, with a known boundary: res.status(c).json(b) is caught, res.send() and res.jsonp() are not, and passing res to a helper typed as a plain BunResponse escapes it entirely. §3.7 has the measured table. Partial with stated limits is the claim; parity with a framework that owns its response type is not.


2. What metadata exists today vs what must be added

2.1 bun-common — BunRouter (packages/bun-common/lib/BunRouter.ts)

routes() returns @routejs/router Route objects (node_modules/@routejs/router/src/route.mjs) tagged by setRoute (BunRouter.ts:678-751). Measured on a router with a mount, a wildcard and a regex-constrained param:

0 {"params":[],"isEndpoint":false,"routerGroupId":0,"cbs":1}                                  // use(mw)
1 {"method":"GET","path":"/users","isEndpoint":true,"cbs":2}                                  // validate + handler
2 {"method":"GET","path":"/files/*","params":[0],"wildcardNames":["name"],"isEndpoint":true}
3 {"method":"GET","path":"/re/:id(\\d+)","params":["id"],"isEndpoint":true}
4 {"method":"GET","path":"/users/:id/posts/:postId","group":"/users/:id","routerGroupId":1}
5 {"method":"POST","path":"/users/:id/posts","group":"/users/:id","routerGroupId":1,"cbs":2}
Metadata Exists? Where
HTTP method yes Route.method (string | string[] | null)
Path literal yes, verbatim Route.path — including (\d+) constraints
Mount prefix yes Route.group; Route.path is already the joined absolute path
Param names yes Route.params — "id" for :id, 0 for * and bare (regex)
Express-5 wildcard names yes RouteWithGroup.wildcardNames (BunRouter.ts:283) — *name is normalised to * and the name kept here
Handler vs middleware yes RouteWithGroup.isEndpoint (BunRouter.ts:277), the sole source of truth (getMatchedLayers comment, BunRouter.ts:4455-4462)
Which mount a route came from yes RouteWithGroup.routerGroupId
A concrete example path yes Route.compilePathRegexpToPath(params) — undeclared in @routejs/router's index.d.ts, a cast is required. Measured: /files/*+{0:"x/y"} → /files/x/y, /re/:id(\d+)+{id:"1"} → /re/1, all self-matching
Route name yes Route.name, set by setName(). Preserved across mount flattening (measured) — but names are unique-checked, so double-mounting a named route throws. Not usable as a doc key
Summary / description / tags / operationId / deprecation no must be added
Response schemas and statuses no must be added
Request schemas at runtime, no present as types only (ValidatorMiddleware.__shape); must be added
Security applied by middleware no undiscoverable — see §10
Per-route metadata slot no must be added

2.2 bun-common — BunValidate (packages/bun-common/lib/BunValidate.ts)

Metadata Exists? Where
The four targets (params/query/body/headers) yes ValidationSchemas
The schema objects, on the instance yes BunValidate.schemas getter
The schema objects, on the middleware no middleware() returns a bare closure; __shape is a phantom
replace: false (validated values not written back) yes, on the instance matters: a replace: false validator documents the request, not the handler's view
hooks.normalize / hooks.transform yes, on the instance opaque functions — a transform's output type is invisible at runtime (see §10)

2.3 bun-common — BunWebSocket (packages/bun-common/lib/BunWebSocket.ts)

Metadata Exists? Where
Registered route paths yes, but private #_routeHandlers: Map<path, handlers[]> (BunWebSocket.ts:458) — no public accessor; must be added
Path params of a WS route yes, at match time ws.data.route / ws.data.params (WebSocketClientData, BunWebSocket.ts:67)
Bound port yes get port() (:853), getServer() (:861)
Transport settings yes idleTimeout, maxPayloadLength, perMessageDeflate (:480-482)
Lifecycle events fixed, not user-declared open/message/close/ping/pong/drain
Named events / message types no message is (ws, message: string | Buffer). Nothing above bytes
Topics / rooms / publish / subscribe no a grep for publish|subscribe|topic|room in BunWebSocket.ts returns zero hits. Bun's native ServerWebSocket.publish/subscribe leak through WebSocketClient, but this class neither declares, tracks nor documents them
Direction (send vs receive) no —
Subprotocol only if an onUpgrade hook sets the header —

2.4 bun-nest

Metadata Exists? Where
Controller routes in the router table yes registerVerb (BunHttpAdapter.ts:1432) delegates to this.instance[verb](path, ...callbacks), so Nest routes land in the same BunRouter.routes() table. Assumed end-to-end (not executed): whether the path already carries setGlobalPrefix
@Get/@Body/@Param/@Query + DTO classes yes, in Nest's own Reflect metadata not reachable from the router callback (Nest wraps handlers in a RouterProxy closure)
Nest middleware yes createMiddlewareFactory (:3138) uses useMethod, so isEndpoint stays false and it is never mistaken for a handler
@SubscribeMessage event names yes, in @nestjs/websockets metadata MESSAGE_METADATA = 'message' on the method descriptor; read by gateway-metadata-explorer.js, never by bun-nest (a grep for Reflect.getMetadata in packages/bun-nest/lib/ returns zero hits)
WS channel path yes, derived resolveRoute (BunWebSocketAdapter.ts:557) concatenates GATEWAY_OPTIONS.path + .namespace into a URL path
WS wire format yes, fixed socket.io-shaped MessageEventTypes enum (BunWebSocketAdapter.ts:134), frames {type, namespace, id?, data:[event, ...args]}
Ack contract per handler yes isAckHandledManually from hasAckDecorator (gateway-metadata-explorer.js:36)
Outbound event names no runtime strings passed to client.emit() or returned as WsResponse.event
Payload schemas only what a DTO class carries needs @nestjs/swagger's @ApiProperty or a Standard Schema pipe

2.5 Gap summary

Package Biggest gap Cost to close
bun-common HTTP no per-route doc slot; validator schemas invisible at runtime small: a WeakMap + .describe() + a non-enumerable property (~250 lines)
bun-common responses nothing declares a status or a body shape, anywhere medium, and spiked: responses on the validator, TResponses on BunResponse, TRes threaded through TypedRouteHandler. Four files, one protected setStatusRaw, one cast — §3.7
bun-common class DTOs toStandardSchema exists but nothing accepts a class small: one wrapper plus an injected adapter — §3.6. Blocked for consumers on stage-3 decorators (risk 1e)
bun-common WS no public route list; no message concept at all small for channels (one accessor), large for messages — needs a new declaration API
bun-nest HTTP nothing; @nestjs/swagger already covers it see §8.6 — verify + document + example
bun-nest validation lib/decorators.ts is one re-export line; no decorator infrastructure at all medium: an interceptor on interceptors.ts's pattern, plus the swagger bridge — §3.8. Gated on (d)
bun-nest WS metadata exists but nothing reads it medium: a metadata explorer + an AsyncAPI mapper

3. Public API design

New module: packages/bun-common/lib/docs/. Nothing in lib/index.ts changes behaviour; the whole feature is inert until .describe() or generateOpenApi() is called.

3.1 Zero cost when unused

Three mechanisms, all measured-cheap:

  1. .describe() writes to a module-level WeakMap. Never called → an empty WeakMap allocated once at module load. No per-route allocation, no branch in handle(), no change to the matched-pipeline cache.
  2. The validator's __schemas property is a single non-enumerable Object.defineProperty in middleware(). It adds one property write per validator constructed (not per request) and is invisible to Object.keys, JSON.stringify and spread.
  3. generateOpenApi() lives in lib/docs/, reachable only through @kingsleyweb/bun-common/lib/docs. lib/index.ts re-exports types only (export type { RouteDoc, OpenApiOptions }), so a consumer who never imports the subpath never loads the generator, the converters or the $ref walker.

3.2 Declaring documentation

/** What a route contributes to the generated document. Every field optional. */
export interface RouteDoc {
  /**
   * One-line summary, rendered as the operation's title.
   * Default: none — the operation gets no `summary`.
   */
  summary?: string;
  /**
   * Longer CommonMark description. Default: none.
   */
  description?: string;
  /**
   * Tags the operation is grouped under. Default: `[]`, which means the
   * operation is untagged and viewers list it under "default".
   */
  tags?: readonly string[];
  /**
   * The operation id. Must be unique across the document.
   * Default: derived from method + path (see {@link OpenApiOptions.operationId}).
   */
  operationId?: string;
  /**
   * Marks the operation deprecated. Default: `false`.
   */
  deprecated?: boolean;
  /**
   * Leaves this route out of every generated document, including the
   * `paths` entry. Default: `false`. Use for health checks, the docs routes
   * themselves, and anything whose path OpenAPI cannot express.
   */
  hidden?: boolean;
  /**
   * Request schemas, when the route has no `validate()` middleware or when
   * the validator's schemas describe less than the route accepts. A target
   * given here **replaces** whatever was discovered for that target.
   * Default: discovered from the pipeline's validators.
   */
  request?: {
    /** Path parameters. Keys must be names the path declares. */
    params?: StandardSchemaV1 | JsonSchemaLike;
    /** Query string. */
    query?: StandardSchemaV1 | JsonSchemaLike;
    /** Request body, for the media types in {@link RouteDoc.requestMediaTypes}. */
    body?: StandardSchemaV1 | JsonSchemaLike;
    /** Request headers. Emitted as `in: "header"` parameters. */
    headers?: StandardSchemaV1 | JsonSchemaLike;
    /**
     * Request cookies. Emitted as `in: "cookie"` parameters.
     * (Added 2026-10-25 per bun-validate §4.11: AD's `RouteDoc.request`
     * gains `cookies`.)
     */
    cookies?: StandardSchemaV1 | JsonSchemaLike;
  };
  /**
   * Media types the body is accepted as. Default: `["application/json"]`
   * when a body schema exists, otherwise no `requestBody` is emitted.
   */
  requestMediaTypes?: readonly string[];
  /**
   * Whether a body must be sent. Default: `true` when a body schema exists.
   */
  requestRequired?: boolean;
  /**
   * Declared responses by status, **for documentation only**. `null` means
   * "no body" (204). A bare schema is shorthand for
   * `{ schema, description: <reason phrase> }`.
   *
   * This never reaches the type system and is never checked at runtime — it
   * cannot be, because `.describe()` runs after the handler was already
   * type-checked (§3.4, spiked). To *enforce* responses, declare them on the
   * validator instead: `validate(schemas, { responses })` (§3.5). Use this
   * form for a route with no validator, or for a status the handler never
   * produces itself (a 401 added by middleware).
   *
   * Default: `{}` — the generator then emits a single
   * `default: { description: "Unspecified" }`, which is valid but useless,
   * and `strict` mode (see {@link OpenApiOptions.strict}) rejects it.
   */
  responses?: Readonly<Record<number | "default", RouteResponseDoc | null>>;
  /**
   * Security requirements for this operation, overriding the document-wide
   * one. `[]` means "explicitly public". Default: inherit the document's.
   */
  security?: readonly Record<string, readonly string[]>[];
  /**
   * Vendor extensions merged into the operation object verbatim. Keys must
   * start with `x-`. Default: none.
   */
  extensions?: Readonly<Record<`x-${string}`, unknown>>;
}

/** One declared response. */
export interface RouteResponseDoc {
  /** Required by OpenAPI. Default: the status's reason phrase. */
  description?: string;
  /** The body schema, any Standard Schema or a raw JSON Schema. */
  schema?: StandardSchemaV1 | JsonSchemaLike;
  /** Media type. Default: `"application/json"`. */
  mediaType?: string;
  /** Response headers, by name. Default: none. */
  headers?: Readonly<Record<string, { description?: string; schema?: JsonSchemaLike }>>;
  /** Examples, by name, merged into the media-type object. Default: none. */
  examples?: Readonly<Record<string, { summary?: string; value: unknown }>>;
}

BunRouter gains exactly one method — the shape of setName:

/**
 * Documents the route registered by the immediately preceding verb call.
 *
 * Mirrors {@link setName}: it reads the same `#lastRoute`, so it must follow
 * a verb/`all` registration directly. Documentation is stored against the
 * route's **terminal callback**, which survives `use(subRouter)` flattening,
 * so a sub-router's routes keep their docs when mounted.
 *
 * Calling it twice for one route merges, last write winning per key.
 *
 * @throws TypeError when the previous registration was not a route handler
 *   (`use`, `group`, `domain`, a mount) — middleware cannot be documented.
 */
describe(doc: RouteDoc): this;

Usage — before:

const router = new BunRouter();
router.get("/users/:id", validate({ params: IdParams }), (req, res) =>
  res.json(getUser(req.params.id)),
);

after (the validator is unchanged and still does the validating):

router
  .get("/users/:id", validate({ params: IdParams }), (req, res) =>
    res.json(getUser(req.params.id)),
  )
  .describe({
    summary: "Read one user",
    tags: ["Users"],
    responses: { 200: { schema: UserSchema }, 404: { schema: ProblemSchema } },
  });

IdParams is not restated: it is discovered from the validator.

A route with no validator — declare the request inline:

router.get("/search", handler).describe({
  summary: "Search",
  request: { query: SearchQuery },          // documents only; nothing validates it
  responses: { 200: { schema: ResultsSchema } },
});

The asymmetry is deliberate and must be stated in the README: a request schema given in .describe() documents without enforcing. bun-jobs has the same idea under a different name (s.documented, schema/builder.ts) and the same justification.

Excluding a route:

router.get("/healthz", handler).describe({ hidden: true });

or globally, OpenApiOptions.filter.

3.3 Document-level options

/** Options for {@link generateOpenApi}. */
export interface OpenApiOptions {
  /** `info.title`. Required — OpenAPI has no sensible default. */
  title: string;
  /** `info.version`. Required. */
  version: string;
  /** `info.description`, CommonMark. Default: none. */
  description?: string;
  /** The rest of `info` (`contact`, `license`, `termsOfService`, `summary`). */
  info?: Omit<OpenApiInfo, "title" | "version" | "description">;
  /**
   * `servers`. Default: `[{ url: "/" }]`, so paths are relative to wherever
   * the document is served from.
   */
  servers?: readonly { url: string; description?: string; variables?: unknown }[];
  /** `components.securitySchemes`. Default: none declared. */
  securitySchemes?: Readonly<Record<string, OpenApiSecurityScheme>>;
  /**
   * The document-wide `security` requirement. Default: with schemes declared,
   * "any one of them"; with none, absent.
   */
  security?: readonly Record<string, readonly string[]>[];
  /**
   * Tag descriptions, by tag name. A tag a route uses but this does not name
   * still appears, without a description. Default: `{}`.
   */
  tags?: Readonly<Record<string, { description?: string; externalDocs?: unknown }>>;
  /**
   * Decides which routes are documented, on top of `hidden`. Return `false`
   * to drop one. Default: every non-hidden endpoint route.
   */
  filter?: (route: DiscoveredRoute) => boolean;
  /**
   * Names an operation that declared no `operationId`. Default:
   * `camelCase(method + path)` with params as `By<Name>` —
   * `GET /users/:id/posts` → `getUsersByIdPosts`. Collisions are a
   * `ConfigError`, never a silent rename.
   */
  operationId?: (route: DiscoveredRoute) => string;
  /**
   * How hard to be about incomplete documentation.
   * - `"lenient"` (default) — emit what is known, collect the rest in
   *   {@link OpenApiResult.warnings}.
   * - `"strict"` — throw `ConfigError` on an undocumentable path, a route
   *   with no declared responses, or a schema no converter handled.
   */
  strict?: "lenient" | "strict";
  /**
   * Standard Schema → JSON Schema converters, tried before the built-in
   * registry and keyed by `~standard.vendor`. Default: `{}`.
   */
  converters?: Readonly<Record<string, JsonSchemaConverter>>;
  /**
   * Names a schema so it is emitted once under `components.schemas` and
   * referenced with `$ref` everywhere. Return `undefined` to inline.
   * Default: a schema's own `$id`/`title`, else inline (see §4.4).
   */
  componentName?: (schema: StandardSchemaV1 | JsonSchemaLike) => string | undefined;
}

/** A route as the generator sees it, before it becomes an operation.
 *
 * **Subsumed (2026-10-25, AD (a) as built):** V12's `ListedRoute`
 * (`lib/routeModel.ts`) already carries everything here — the method, the
 * path, the params, the described contract (the `discovered` half), and the
 * replay — so `filter` and `operationId` take a `ListedRoute` and no
 * `DiscoveredRoute` exists. What it deliberately does not carry: `template`
 * (the generator derives templates with `toOpenApiPath`, §10.2's rules) and
 * `doc` (looked up in the `.describe()` WeakMap by the route's terminal
 * callback — `router.routes()[routeIndex].callbacks.at(-1)`; `routeIndex`
 * was added to `ListedRoute` for exactly this).
 */
export interface DiscoveredRoute {
  /** The HTTP method, upper-case. `"ALL"` for an `all()` route. */
  method: string;
  /** The Express-syntax path, absolute (mounts already joined). */
  path: string;
  /** The OpenAPI path template, e.g. `/users/{id}`. `undefined` when the path cannot be expressed. */
  template?: string;
  /** Path parameter names, in order. */
  params: readonly string[];
  /** Why `template` is `undefined`, when it is. */
  undocumentable?: string;
  /** The documentation attached with `.describe()`, if any. */
  doc: RouteDoc;
  /** Request schemas discovered from validators in this route's pipeline. */
  discovered: Partial<Record<ValidationTarget, StandardSchemaV1>>;
  /** The `Route` this came from, for a caller that needs more. */
  route: Route;
}

/** What {@link generateOpenApi} returns. */
export interface OpenApiResult {
  /** The document. Fresh objects; the caller owns it. */
  document: OpenApiDocument;
  /** Everything `lenient` mode swallowed: undocumentable paths, missing responses, unconverted schemas. */
  warnings: readonly DocWarning[];
}

/**
 * Builds the OpenAPI 3.1 document for a router's routes.
 *
 * Reads `router.routes()`, replays `router.getMatchedLayers()` on a path
 * synthesised from each route, and merges what it finds with `.describe()`.
 */
export function generateOpenApi(
  router: BunRouter,
  options: OpenApiOptions,
): OpenApiResult;

3.4 Does .describe() violate the typed-overload constraints?

No, and this is the reason for its shape. CLAUDE.md's three constraints are about the argument positions of a verb call:

Constraint Affected by .describe()?
Generated per verb, inside the class, ahead of the existing overloads No — .describe() is a separate method with one overload, like setName. The generated block is untouched, and generate-verb-overloads.ts --check stays green
Exactly one position carries a shape — the validator immediately before the handler No — no position changes. The validator remains the one shape carrier
Preceding positions typed RouterHandler, not a union No — nothing is added to any position

The alternative designs all do violate them, and are rejected:

  • A trailing options object (router.get(path, handler, { summary })) — adds a position whose type is not RouterHandler. Per CLAUDE.md's measured note, as soon as a shape position receives a non-validator the overload is discarded and the call falls through to (path, ...callbacks), degrading the handler to any. Fatal.
  • A leading options object (router.get({ path, summary }, handler)) — TPath no longer infers from a string literal, so req.params collapses. Fatal.
  • A defineRoute-style registrar (bun-jobs' approach) — perfectly sound and additive: a routes(defs) helper that builds the pipeline and calls .describe() itself. Worth shipping as an ergonomic layer in phase (a2), but it cannot be the only mechanism, because it does not document the routers people have already written.

.describe() returns this, so it chains, and it throws for middleware for the same reason setName does.

One rule about responses, since it can now be declared in two places. RouteDoc.responses (§3.2) is documentation only — it never reaches the type system, for the reason Decision 4 spiked. validate(schemas, { responses }) (§3.5) is the enforced declaration. When a route has both, the generator takes the validator's as the source of truth, merges .describe()'s description/headers/examples onto it, and emits a warning if the two declare different statuses. A route should normally use one or the other:

Situation Use
Route has a validator, or you want any checking at all validate(..., { responses })
Route has no validator and you only want the document right .describe({ responses })
Documented status the handler never produces (e.g. a 401 added by middleware) .describe({ responses }) — the validator's map types res, so putting an unreachable status there would let a handler send it

That last row is the reason .describe({ responses }) is not deleted.

3.5 Runtime response validation

Prior art, reused rather than reinvented: bun-jobs' responseMismatch (packages/bun-jobs/lib/api/routes/define.ts) validates a handler's result against the declared schema for its status, and on a mismatch logs — logger.error("jobs api response did not match its schema", { operationId, status, mismatch }) — rather than failing the request. That is the right default and the right semantics, and the message shape should match.

/** Options for {@link BunValidateOptions.responses} checking. */
export type ResponseValidationMode =
  /** Never check. The default. */
  | "off"
  /** Check and log at `error` level; the response is sent unchanged. */
  | "log"
  /** Check and throw, entering the error pipeline. For tests and CI. */
  | "throw";

/** Response schemas by status. `null` declares a status that carries no body. */
export type ResponseSchemas = Readonly<Record<number, StandardSchemaV1 | null>>;

interface BunValidateOptions</* … existing params … */> {
  /**
   * Response schemas by status — what the route is allowed to answer with.
   *
   * They type the following handler's `res` (see §3.7) and, when
   * {@link validateResponses} is on, are checked at runtime. A status of
   * `null` declares a body-less response (204, 304).
   *
   * Declaring these does not make the route send them; it constrains what it
   * *may* send. A declared status the handler never produces is not an error
   * — exhaustiveness is not checkable (measured).
   */
  responses?: ResponseSchemas;
  /**
   * Whether to check a response against {@link responses} at runtime.
   * Defaults to `"off"`. A sensible production setting is
   * `process.env.NODE_ENV === "production" ? "off" : "log"`.
   */
  validateResponses?: ResponseValidationMode;
  /**
   * What to do when the status sent is not among {@link responses} at all.
   * Defaults to `"report"`, which treats it as a mismatch (`status <n> is not
   * declared`, bun-jobs' wording). `"allow"` passes it through unchecked —
   * for a route whose error statuses come from middleware it does not own.
   */
  undeclaredStatus?: "report" | "allow";
}

Where it hooks. Not in json() — that would miss send() and fire before the status is final. The check belongs at response finalisation, the one place every path converges, and it reads BunResponse's existing #sentBody/getSentBody rather than intercepting a setter. Coverage by path:

Path Checked? Why
json() / jsonp() yes #sentBody holds the pre-serialisation value — the object, not the string
send(object) yes same slot
send(string) where the schema is a string schema yes the schema is run against the string
send(string) where the schema is an object schema reported as a mismatch correct: the route declared JSON and sent text
end() with no body checked against null/absent a body-less status declared null passes; one declaring a schema mismatches
204 / 304 yes, expects no body falls out of the above
redirect() skipped the status is a redirect and the body is generated; never a declared response
sendFile() / a stream / StreamableFile skipped, with one warning per route on the first occurrence the body is never materialised; validating it would mean buffering it, which is the one thing a stream exists to avoid
res.headersSent already true skipped nothing to check

Cost. Zero when "off" — one property read on a branch that is already taken at finalisation. When on, it is one Standard Schema validate() per response, i.e. the same order as validating a request body, plus a WeakMap lookup for the route's map. That is why the default is off and the recommendation is dev-only; it is not free and the plan should not pretend it is.

On a mismatch, "log" emits through the router's resolved Logger at error with { path, method, status, mismatch }, where mismatch is bun-jobs' flattened "<path>: <message>; …" string. It never alters the response — a response already being written cannot be retracted, and turning a working 200 into a 500 because a schema is stale is a worse failure than the drift it is reporting.


3.6 Class DTOs (class-validator / class-transformer)

As built (2026-10-26, phase (b2)) — the maintainer's decisions of 2026-10-26, all final: names DtoClass, ClassValidatorAdapter, ClassDtoOptions, useClassDtos, fromDto, vendor string "class-validator", in bun-common's lib/classDto.ts (root-exported). Corrections to the sketch below, each measured on class-validator 0.15.1 / class-transformer 0.5.1 / class-validator-jsonschema 5.2.0 under Bun 1.4.4:

  • ValidationSchemas has six keys, not four (V2 added cookies/signedCookies); all six widened, as did ResponseSpec's schema positions (the status shorthand, content maps, headers/cookies) — (b1) having merged long before made that mandatory — and ValidatedShapeFor/InferValidatedShape learn DtoClass<T> → output T, input unknown. A declared status's res.json() takes the instance.
  • The adapter is { plainToInstance, validate } alone: getMetadataStorage is dropped — the JSON-Schema producer is injected whole as ClassDtoOptions.toJsonSchema. Its return is typed object, not JsonSchemaLike: targetConstructorToSchema's SchemaObject has no index signature, so the sketch's signature would have forced every consumer to cast.
  • The whitelist note in the sketch is wrong as measured: whitelist: true strips undeclared properties from the instance (an extra key does not survive validate(instance, { whitelist: true })). The default stays { whitelist: true, forbidNonWhitelisted: false }; validate: { whitelist: false } keeps extras.
  • A class in a schema slot with no adapter registered did not die in checkContractDef (BunValidate builds its contract unchecked) but at request time (TypeError: undefined is not an object); it is now a deliberate ConfigError naming useClassDtos, thrown when the validator (or the document) is built.
  • Test isolation is an internal, unexported reset: resetClassDtos() in lib/classDto.ts (never re-exported from the root), which also removes the "class-validator" converter through an internal unregisterJsonSchemaConverter — the converter is not a built-in, so re-registering cannot restore it. Chosen over a per-call override because the registration is module-global by design.
  • DTO validation is always async (class-validator's API), so a DTO must not sit where a synchronous schema is required (the WebSocket upgrade check, defineChannel) — documented, with no sync rejection mechanism.
  • bun-nest gets nothing (deliberate): a DTO keeps its native @nestjs/swagger path (phase (d)); @Validate's ValidateSchemas keeps its request parts Standard-Schema-only. Note the opposite directions of useClassDtos (class → Standard Schema) and bun-nest's SchemaDto (Standard Schema → class).
  • class-validator-jsonschema's nested classes emit #/definitions/… refs with no definitions block — dangling in the generated document unless the caller merges them. Documented; the tests and example keep documented DTOs flat.

toStandardSchema (BunValidate.ts's one-off wrapper for a plain validate function, with jsonSchema) already exists for exactly this and stays the escape hatch for non-class validators — fromDto cross-references it rather than duplicating it. A DTO class becomes a Standard Schema in one wrapper, so every target that accepts a schema accepts a class constructor, with no change to ValidationSchemas' six keys beyond widening their type.

/** A DTO class: a constructor `plainToInstance` can build. */
export type DtoClass<T = object> = new (...args: never[]) => T;

/**
 * The class-validator / class-transformer surface, injected rather than
 * imported — the structural rule the logging adapters and the §4 converters
 * follow. Neither library, nor `reflect-metadata`, becomes a bun-common
 * dependency or appears in a shipped declaration.
 */
export interface ClassValidatorAdapter {
  /** `class-transformer`'s `plainToInstance`. */
  plainToInstance: (cls: DtoClass, plain: unknown, options?: object) => object;
  /** `class-validator`'s `validate`. Async; issues are mapped to Standard Schema issues. */
  validate: (instance: object, options?: object) => Promise<readonly ClassValidationError[]>;
  /**
   * `class-validator`'s `getMetadataStorage`, when the OpenAPI bridge is
   * wanted. Optional: without it a DTO validates but documents as `{}`.
   */
  getMetadataStorage?: () => unknown;
}

/** Options for {@link useClassDtos}. */
export interface ClassDtoOptions {
  /**
   * Passed to `plainToInstance`. Defaults to
   * `{ enableImplicitConversion: true }`, which is what makes a DTO usable on
   * `query` and `params`, where every value arrives as a string
   * (**measured**: `"42"` → `42` for an `@IsInt()` property).
   */
  transform?: Record<string, unknown>;
  /**
   * Passed to class-validator's `validate`. Defaults to
   * `{ whitelist: true, forbidNonWhitelisted: false }`.
   *
   * ~~Note `whitelist` governs *errors*, not the instance:
   * `plainToInstance` keeps undeclared properties regardless~~ — **wrong as
   * measured on 0.15.1** (the (b2) build, 2026-10-26): `whitelist: true`
   * strips undeclared properties from the instance; an `extra` key does not
   * survive. `validate: { whitelist: false }` keeps them.
   */
  validate?: Record<string, unknown>;
  /**
   * Converts a DTO class to JSON Schema for the generated document — e.g.
   * `class-validator-jsonschema`'s `targetConstructorToSchema`. Without it a
   * DTO validates correctly and documents as `{}` plus a warning. (As built,
   * the return is typed `object`: `SchemaObject` has no index signature.)
   */
  toJsonSchema?: (cls: DtoClass) => JsonSchemaLike;
}

/**
 * Teaches bun-common to accept DTO classes wherever a schema is accepted.
 * Call once at startup.
 *
 * @example
 * ```ts
 * import { plainToInstance } from "class-transformer";
 * import { getMetadataStorage, validate } from "class-validator";
 * import { targetConstructorToSchema } from "class-validator-jsonschema";
 *
 * useClassDtos(
 *   { plainToInstance, validate, getMetadataStorage },
 *   { toJsonSchema: (cls) => targetConstructorToSchema(cls) },
 * );
 * ```
 */
export function useClassDtos(
  adapter: ClassValidatorAdapter,
  options?: ClassDtoOptions,
): void;

/**
 * Wraps one DTO class as a Standard Schema, for a caller who wants it
 * explicitly rather than through the global registration. The result's
 * `~standard.vendor` is `"class-validator"`.
 */
export function fromDto<T>(cls: DtoClass<T>, options?: ClassDtoOptions): StandardSchemaV1<unknown, T>;

ValidationSchemas' four keys (six since V2 — the header block's corrections apply to this sketch throughout) widen from StandardSchemaV1 to StandardSchemaV1 | DtoClass, and so do RouteDoc.request.* and RouteResponseDoc.schema. Discrimination is trivial and unambiguous: a Standard Schema has ~standard, a DTO is a function.

Feasibility — two risks, both spiked, and the answers are not the ones feared.

Bun does honour emitDecoratorMetadata. This was the risk flagged as potentially fatal. It is not. Measured under Bun 1.4.3-canary with experimentalDecorators + emitDecoratorMetadata:

name  -> String    age    -> Number   flag   -> Boolean
when  -> Date      nested -> Inner     list  -> Array     maybe -> String
metadata keys on prototype.name: ["design:type"]

and the full stack works end to end — class-validator@0.15.1 + class-transformer@0.5.1, plainToInstance coercing "42" → 42, instantiating a nested @Type(() => Address) as a real Address, and validate() returning per-property constraints (name:isString, age:min, tags:isString). getMetadataStorage().getTargetValidationMetadatas(...) returned 6 readable entries — the JSON Schema source.

The two standing design:type limits are class-validator's own, not Bun's: string[] erases to Array (hence @IsString({ each: true })) and maybe?: string erases to String (hence @IsOptional()). Neither is new.

Stage-3 decorators are a hard break, not a degradation. Measured: with experimentalDecorators off (TypeScript 5+ standard decorators), importing the same DTO throws at class-definition time —

TypeError: undefined is not an object (evaluating 'object.constructor')
  at class-validator/cjs/decorator/common/ValidateBy.js:16

because a stage-3 field decorator is called (value, context), so class-validator's object is undefined. The repo sets experimentalDecorators: true globally (tsconfig.base.json:24-26) so bun-common and bun-nest are fine; a consumer on standard decorators cannot use class DTOs at all and must set experimentalDecorators: true (and keep reflect-metadata imported first). This must be stated in the README as a requirement of the DTO path, not a footnote — and it is an argument for keeping the Standard Schema path primary.

3.7 Compile-time enforcement — spiked, and partial

Mechanism chosen: thread a narrowed res into the handler, not type the handler's return. The return route was spiked and fails for the documented reason: TypedRouteHandler's R = unknown carries an explicit comment in types/general.ts ("a handler may return res.send(...), a promise, or nothing"), and measured, a handler written (req, res) => res.json(x) returns BunResponse, which is not the body type — so return-typing rejects the repo's own dominant style.

What it takes. Four files, spiked as a full copy of packages/bun-common:

File Change
lib/types/general.ts TypedRouteHandler gains TRes = BunResponse as its last parameter, so existing TypedRouteHandler<P, Q, B, R> uses keep compiling
lib/types/routeTyping.ts ValidationShape gains responses?: Record<number, unknown>; new ResolveResponses<S>; MountedHandler passes BunResponse<unknown, ResolveResponses<MergeShape<TMountShape, TShape>>>
lib/BunResponse.ts a second defaulted parameter TResponses = undefined; status() narrows; json() constrains; one protected setStatusRaw(code: number) for the class's own ~8 internal this.status(n) calls, which cannot satisfy AllowedStatus<TResponses> while TResponses is unresolved; one as BunResponseSentBody cast
lib/BunValidate.ts responses option; ValidatedShapeFor gains a TRes parameter; validate() infers it with const
/** A declared response map: status code to that status's body type. */
export type ResponseMap = Record<number, unknown>;
/** Statuses `status()` accepts: any number until responses are declared. */
export type AllowedStatus<M> = M extends ResponseMap ? keyof M & number : number;
/** The map left after `status(C)` narrowed it. */
export type NarrowResponses<M, C extends number> = M extends ResponseMap ? Pick<M, C & keyof M> : undefined;
/** What `json()` accepts: the declared bodies, or anything JSON until declared. */
export type JsonBodyFor<M> = M extends ResponseMap ? M[keyof M] : BunResponseBody;

public status<const C extends AllowedStatus<TResponses>>(
  code: C,
): BunResponse<customWebsocketDataType, NarrowResponses<TResponses, C>>;
public json<T extends JsonBodyFor<TResponses>>(body: T): BunResponse;

Result: the patched library typechecks clean (tsc -p exit 0) with no other change anywhere in bun-common. The ~18 files naming BunResponse are unaffected because both new parameters default.

Verb overloads are untouched. MountedHandler derives the response map from the TShape it is already handed, so generate-verb-overloads.ts emits byte-identical text and --check stays green. No argument position changes, so all three CLAUDE.md constraints hold — verified by the spike compiling against the real class, including inline arrow handlers.

What is caught (every case below compiled or failed as stated):

const v = validate({ params: IdParams }, { responses: { 200: UserS, 404: ProblemS } });

r.get("/u/:id",   v, (req, res) => res.status(200).json({ id: req.params.id, name: "a" })); // OK
r.get("/bad1/:id", v, (req, res) => res.status(200).json({ code: "NOPE" }));
//   TS2353: Object literal may only specify known properties, and 'code' does not exist in type 'User'.
r.get("/bad2/:id", v, (req, res) => res.status(418).json({ id: "x", name: "y" }));
//   TS2345: Argument of type '418' is not assignable to parameter of type '404 | 200'.
r.get("/bad3/:id", v, (req, res) => res.json({ nope: true }));
//   TS2353: ... 'nope' does not exist in type 'User | Problem'.

Those are the actual messages, and they are the reason this is worth shipping: a plain TS2353/TS2345 naming the offending property, not a wall of overload-resolution noise. A mounted sub-router declaring BunRouter<"/users/:uid", { responses: { 200: User } }> produces the same clean message, and async handlers behave identically. Request narrowing is unaffected — req.params.id stays string.

What is not caught — the boundary, measured:

Case Caught? Note
res.status(c).json(b), wrong body yes
Undeclared status yes
res.status(200); res.json(b) as separate statements yes, but only against the union of all declared bodies the narrowing returned by status() is discarded; res still carries the full map
async handler, awaited yes
res.send(anything) no send takes the wide body union; constraining it would reject every legitimate string/Buffer/stream response
res.jsonp(anything) no same; could be fixed with json's treatment if wanted
helper(res) where helper(res: BunResponse) no TResponses appears only in method parameter positions, so a narrowed res is assignable to a bare one. The escape hatch, and structural — not closable without variance annotations that would break far more
Returning a bare object, Nest-style no the router ignores handler return values by design
A declared status never sent no exhaustiveness is not checkable

Nothing here is a surprise in retrospect, but all of it is measured rather than reasoned, and the README must state the send() and helper-function limits plainly.

Negative controls (the repo convention) are in the spike and must ship with the feature: a validator with no responses leaves res completely unconstrained (res.status(599).json({ anything: 1 }) compiles), as does a route with no validator — so the feature is opt-in and the "zero cost when unused" claim of §3.1 survives at the type level too.

3.8 @Validate — BunValidate as a NestJS route decorator

Nest registers routes through its own explorer, so there is no router.get(path, validate(...), handler) chain for a validator to sit in. The equivalent must be a Nest construct.

Where it runs: an interceptor, following interceptors.ts. packages/bun-nest/lib/decorators.ts is one line (export { UploadedFile, UploadedFiles } from "@nestjs/common"), so there is no decorator infrastructure to extend; packages/bun-nest/lib/interceptors.ts (351 lines) is the precedent — mixin classes from a factory, with getMultipartRequest(ctx) reaching the underlying bun-common request and transformUploadException mapping failures to Nest exceptions. Reuse both.

Why an interceptor rather than a pipe or a guard:

Candidate Verdict
Interceptor Chosen. Runs before the handler and wraps its result, so one construct does request validation and response validation (§3.5). It can reach the real BunRequest via the same ctx.switchToHttp().getRequest() route getMultipartRequest already uses, and mutate req.query/req.body in place — which is the whole point of BunValidate, and what makes @Query()/@Body() see the parsed output. Chaining semantics are preserved for free: each validator replaces the target wholesale, exactly as in the router
Pipe Per-parameter and cannot see the response. A pipe also cannot replace req.query in place — it transforms the value Nest hands the parameter, so a second reader of req.query still sees the raw one. Wrong semantics
Guard Runs too early (before pipes) and may only return a boolean; a validation failure would surface as 403
bun-common middleware via adapter.use() Runs below Nest's pipeline — BunHttpAdapter.use's own JSDoc says so: "guards, interceptors and setGlobalPrefix do not apply". Usable, but invisible to Nest's exception filters and to @nestjs/swagger

With a Nest ValidationPipe also present: the interceptor runs first (interceptors precede pipes on the request path), so the pipe receives the already-parsed value. For a class DTO that is harmless and idempotent; for a zod-parsed query whose output is no longer a string map, a ValidationPipe with transform: true may re-coerce. Recommendation: document that the two should not both own a target, and have @Validate set req[BUN_VALIDATED] so a future BunValidationPipe could no-op. Not worth building the pipe now.

/** What `@Validate` accepts — `validate()`'s surface, in decorator form. */
export interface ValidateOptions<
  S extends ValidationSchemas = ValidationSchemas,
  TRes extends ResponseSchemas | undefined = undefined,
> {
  /** Validates path parameters. A Standard Schema or a DTO class. */
  params?: S["params"];
  /** Validates the query string. */
  query?: S["query"];
  /** Validates the request body. */
  body?: S["body"];
  /** Validates the request headers. */
  headers?: S["headers"];
  /** Response schemas by status; see §3.5. Also constrains the method's return type. */
  responses?: TRes;
  /** Runtime response checking. Defaults to `"off"`. */
  validateResponses?: ResponseValidationMode;
  /** Per-target `normalize`/`transform` hooks, as `BunValidateOptions.hooks`. */
  hooks?: ValidationHooks<S>;
  /**
   * What to do on failure. Defaults to `"throw"` here rather than `"next"`:
   * in Nest a thrown `BadRequestException` is what exception filters expect.
   */
  onFailure?: "throw" | "respond";
  /** Status for a failure. Defaults to 400. */
  status?: number;
  /**
   * Also emit `@nestjs/swagger` metadata derived from these schemas, so one
   * declaration feeds validation and the OpenAPI document. Defaults to `true`
   * when `@nestjs/swagger` is present and `useSwaggerBridge()` was called,
   * `false` otherwise. See §3.8.2.
   */
  document?: boolean;
}

/**
 * Validates a controller route's request, and optionally its response.
 *
 * On a **method**, it applies to that route. On a **class**, it applies to
 * every route in the controller; a method-level `@Validate` then **merges**
 * over it per target — a target the method declares replaces the class's, a
 * target only the class declares still applies. (Merge, not replace, so a
 * controller-wide `headers` schema is not silently lost by a method that
 * declares only `body`.) `responses` merge by status, method winning.
 *
 * When `responses` is given, the decorator additionally **constrains the
 * method's return type** to the union of the declared bodies — see §3.8.3.
 */
export function Validate<
  S extends ValidationSchemas,
  const TRes extends ResponseSchemas | undefined = undefined,
>(options: ValidateOptions<S, TRes>): ValidateDecorator<TRes>;

3.8.1 Before / after

// before — class-validator DTO, no response contract, swagger restated
@Controller("users")
export class UsersController {
  @Post()
  @ApiBody({ type: CreateUserDto })
  @ApiResponse({ status: 201, type: UserDto })
  @UsePipes(new ValidationPipe({ transform: true }))
  create(@Body() body: CreateUserDto): Promise<UserDto> { … }
}

// after — one declaration; works with a DTO class or any Standard Schema
@Controller("users")
export class UsersController {
  @Post()
  @Validate({ body: CreateUserDto, responses: { 201: UserDto, 409: ProblemDto } })
  create(@Body() body: CreateUserDto): Promise<UserDto> { … }
  //                                   ^ return type is checked against 201|409

  @Get()
  @Validate({ query: PageQuery, responses: { 200: UserListSchema } })  // zod, no DTO
  list(@Query() query: InferQuery<typeof PageQuery>): Promise<UserList> { … }
}

3.8.2 The OpenAPI bridge — why this belongs in this plan

§8.5 recommends @nestjs/swagger rather than duplicating it. But @nestjs/swagger discovers DTO classes; it knows nothing about a Standard Schema. So @Validate({ body: zodSchema }) would validate correctly and document as nothing. That is the gap this closes:

/**
 * Lets `@Validate` emit `@nestjs/swagger` metadata from its schemas, so one
 * declaration produces both the validation and the document.
 *
 * The decorators are injected, not imported — `@nestjs/swagger` must not
 * become a bun-nest dependency or appear in a shipped declaration (§8.5).
 */
export function useSwaggerBridge(api: {
  /** `@nestjs/swagger`'s `ApiBody`. */
  ApiBody: (options: object) => MethodDecorator;
  /** `ApiQuery`. */
  ApiQuery: (options: object) => MethodDecorator;
  /** `ApiParam`. */
  ApiParam: (options: object) => MethodDecorator;
  /** `ApiHeader`. */
  ApiHeader: (options: object) => MethodDecorator;
  /** `ApiResponse`. */
  ApiResponse: (options: object) => MethodDecorator;
  /** `@nestjs/swagger`'s `SwaggerModule` document, for registering named components. */
  registerComponent?: (name: string, schema: JsonSchemaLike) => void;
}): void;

With it, @Validate applies the corresponding Api* decorator for every target it was given, with schema: produced by the §4 converter registry — ~standard.jsonSchema first, then the vendor registry, then class-validator metadata for a DTO. One declaration, two outputs.

Where it cannot reach, stated plainly:

  • A DTO keeps its native path. When a target is a DTO class, the bridge emits { type: TheClass }, not a converted schema — that is strictly better, because swagger's own @ApiProperty reflection produces a named component and a $ref, and it already works.
  • An anonymous Standard Schema has no component name. It is emitted inline as schema: { … }. Inline schemas are legal and render, but they are repeated per operation and produce no $ref. Mitigation: .meta({ id }) on a zod schema, or OpenApiOptions.componentName, supplies a name that registerComponent can hoist — unverified, since it depends on SwaggerModule's component registration API, which I did not execute.
  • Swagger's schema model is OpenAPI 3.0-shaped. @nestjs/swagger emits 3.0 documents, so a converted schema should be requested at target: "openapi-3.0", not draft-2020-12: nullable: true instead of type: ["x","null"], no prefixItems, boolean exclusiveMinimum. This is the one place in the plan where 3.0 is the right target, and it is why §4's ConvertContext.dialect must not be hard-coded to 2020-12 after all — widen it to "draft-2020-12" | "openapi-3.0".
  • SwaggerModule now proven against the adapter. Phase (d) executed it (2026-10-25, §8.6): setup end-to-end on BunHttpAdapter, the scanned document byte-identical to platform-express's. What remains unexecuted is only the bridge's own piece above: registerComponent's named-component hoist, which no public SwaggerModule API was found for — that is (g)'s to prove or redesign.

3.8.3 What the decorator can and cannot type — spiked

A method decorator can constrain the method's return type. Measured (legacy decorators, the repo's setting): typing @Validate's returned decorator as

<T extends (...args: never[]) => ReturnUnion<R> | Promise<ReturnUnion<R>>>(
  target: object, key: string | symbol, descriptor: TypedPropertyDescriptor<T>,
) => void

rejects a method whose return type matches no declared response, and accepts async methods. With no responses declared it imposes nothing (control compiled). This is the inversion worth calling out: in the router, return-typing is the weak option and narrowed-res is the strong one; in Nest, a handler returns a value and never touches res, so return-typing is the only option — and it happens to be the natural one.

The error is correct but noisier than the router's:

TS1241: Unable to resolve signature of method decorator when called as an expression.
  Argument of type 'TypedPropertyDescriptor<() => { totally: "wrong"; }>' is not
  assignable to parameter of type 'TypedPropertyDescriptor<(...args: any[]) =>
  UserDto | Problem | Promise<UserDto | Problem>>'.
    … 4 more levels …
          Type '{ totally: "wrong"; }' is not assignable to type 'UserDto | Problem | …'.

Six lines where the router gives one, with the useful line last. Usable, but the README should show it so nobody files it as a bug.

A decorator cannot retype a parameter. Confirmed, no workaround found. A method decorator's signature is (target, key, descriptor) and a parameter decorator's is (target, key, index) — neither has a type link to a parameter. Measured: with @ValidatedBody(CreateUserSchema) body: string, body stays string and compiles. I tried the param-decorator route specifically and it does not recover inference.

What works instead is an exported helper type the developer writes once:

/** The output type a schema or DTO produces — for annotating a `@Body()` parameter. */
export type Infer<S> = S extends StandardSchemaV1
  ? StandardSchemaV1.InferOutput<S>
  : S extends DtoClass<infer T> ? T : never;

@Validate({ body: CreateUserSchema })
create(@Body() body: Infer<typeof CreateUserSchema>) { … }   // inferred, not restated

Verified to infer correctly. One typeof per parameter is the honest cost.

So what the decorator buys, stated without overselling: runtime request validation with BunValidate's exact semantics including in-place replacement; runtime response validation, which Nest has no equivalent of; compile-time return-type checking against declared responses; one declaration site instead of DTO + @ApiBody + @ApiResponse + @UsePipes; and OpenAPI metadata for schemas swagger cannot otherwise see. It does not give parameter-type inference — the developer still annotates, with Infer<> making that a mechanical one-liner. For a user already happy with DTO classes and ValidationPipe, the honest pitch is the response half, not the request half.

4. Standard Schema → JSON Schema

4.1 There is now an official companion spec — use it, but do not trust it alone

packages/bun-common/lib/types/standardSchema.ts declares ~standard: { version, vendor, validate, types } — spec 1.0.0. Spec 1.1.0 (2025-12-15, PR standard-schema#134 by Colin Hacks) added two things this plan depends on:

  • StandardTypedV1, factored out as the base of both interfaces;
  • StandardJSONSchemaV1, whose ~standard.jsonSchema.{input,output}(options) is synchronous, returns Record<string, unknown>, and may throw for an unsupported target or an unrepresentable type.

StandardSchemaV1.Options { libraryOptions? } was also added, so validate now takes an optional second argument. Action item for phase (b): update lib/types/standardSchema.ts to 1.1.0's shape. It is a vendored copy of the spec's types, and it is currently a version behind. This is a small, isolated change that should land before the converter work.

Two caveats, and the second is why the vendor registry still exists:

  1. The interface is standardised; the output is not. standard-schema#147 (opened 2025-12-28 by logaretm, still open, no maintainer reply) makes the case: vendors differ on defaults and on whether required is emitted, and neither draft-07 nor draft-2020-12 pins a common feature subset. A consumer still needs a compatibility matrix — §4.2 is ours.
  2. zod does not throw on an unsupported target. Measured: asking for target: "nonsense-dialect" returned a schema (with $schema omitted) rather than throwing, though the spec says a library should throw. So the generator must pass only "draft-2020-12" and must not treat a successful return as proof the dialect was honoured.

4.2 Per-vendor status — all figures measured against this checkout

All five libraries are already devDependencies of bun-common (packages/bun-common/package.json), for exactly this kind of verification.

Vendor string Version tested Primary path Fallback path Needs an import? Fidelity / gaps
"zod" 4.6.5 ~standard.jsonSchema.{input,output} (measured present; zod ≥4.2) instance .toJSONSchema(opts) (added 4.2, zod#5477) or z.toJSONSchema no Best of the four. .meta({ id }) puts the schema in $defs under that id; z.globalRegistry supplies title/description/examples. Throws on transforms ("Transforms cannot be represented in JSON Schema") and z.date() unless unrepresentable: "any", which emits {}. Unrepresentable list also covers bigint, symbol, undefined, void, map, set, nan, custom. Emits $ref: "#" for self-recursion (hazard, §4.4); reused: "ref" names shared schemas __schema0. Open upstream regression to pin against: zod#6613 — format/length/integer-bound constraints dropped in some large multi-schema conversions, 4.5.4→4.6.5
"arktype" 2.2.3 ~standard.jsonSchema.{input,output} (measured present; arktype ≥2.1.28) instance .toJsonSchema(opts) no Good fidelity ("number > 0" → exclusiveMinimum: 0, measured through both paths). Throws on a morph; { fallback: { morph: (ctx) => ctx.base } } recovers the input side (measured: string.integer.parse → {type:"string",pattern:"^(?:(?!^-0$)-?(?:(?:0|[1-9]\\d*)))$"}). Failure codes: arrayObject, arrayPostfix, defaultValue, domain, morph, patternIntersection, predicate, proto, symbolKey, unit, date
"valibot" 1.5.0 core none in core (measured: ~standard has only version,vendor,validate) @valibot/to-json-schema — either toStandardJsonSchema(schema), which wraps a schema so it gains ~standard.jsonSchema (added 1.5.0), or toJsonSchema(schema, config) directly yes — one injected function Measured against 1.8.0, resolved via Bun auto-install: not a declared dependency of this repo. Default target is draft-07 ($schema: "http://json-schema.org/draft-07/schema#"); draft-2020-12 and openapi-3.0 targets were added in 1.5.0. Throws The "transform" action cannot be converted to JSON Schema.; errorMode: "ignore" degrades to the input side. Cannot convert: non-JSON literal/enum values, record with non-string key schemas, RegExp flags, variant's discriminator. length counts UTF-16 units where JSON Schema counts code points — diverges on emoji
"yup" 1.7.1 none (measured: ~standard present since yup 1.7.0, vendor: "yup", no jsonSchema) instance .describe() → we write the mapper no No official export (yup.toJSONSchema is undefined; a grep of the published index.js for jsonSchema finds nothing). describe() returns {type, optional, nullable, oneOf, tests:[{name, params}], fields} — enough for min/max/integer/matches/length/email/url, and oneOf → enum. Gotcha, measured: matches's params.regex is a RegExp, which JSON.stringifys to {} — read .source. The only third-party option, @sodaru/yup-to-json-schema, was last published 2023-04-25 and is unmaintained
"custom" (superstruct via toStandardSchema) superstruct 2.0.2 none none — No ~standard at all (measured: "NONE"); last published 2024-07-06. toStandardSchema's validate is an opaque closure, so nothing can be introspected — a wrapper can supply validate, never jsonSchema. Falls back to {} plus a warning; the user attaches JSON Schema explicitly
"bun-jobs" — none schema.json (its own field) no bun-jobs' s.* builder already is JSON Schema (schema/builder.ts). A ~15-line built-in reading .json makes every bun-jobs schema usable from bun-common's generator for free
"class-validator" (§3.6) class-validator 0.15.1 none ClassDtoOptions.toJsonSchema — e.g. class-validator-jsonschema's targetConstructorToSchema yes, injected A DTO class has no ~standard at all, so it never reaches vendor dispatch by itself. fromDto() wraps it in a Standard Schema stamped vendor: "class-validator", and keeps a WeakMap<StandardSchemaV1, DtoClass> back to the original constructor so the converter can reach the class the JSON-Schema producer needs. Measured: getMetadataStorage().getTargetValidationMetadatas(cls, "", false, false) returns readable per-property entries, so the conversion is real rather than hypothetical. Without toJsonSchema a DTO validates and documents as {} plus a warning. In bun-nest, a DTO keeps its native @nestjs/swagger path instead (§3.8.2) — strictly better, since that produces a named component

Deliberately not depended on, but worth knowing: @standard-community/standard-json (0.3.6) is a maintained generic converter doing exactly this vendor dispatch, with loadVendor(vendor, fn) for unsupported libraries; xsschema (0.5.0) covers arktype, Effect, Sury, valibot and zod. Either could replace our registry — but both are runtime dependencies for what is, after the companion spec, four small adapters. Recommend not taking one, per CLAUDE.md's dependency policy, and saying so explicitly in the README so the choice reads as deliberate rather than ignorant. @standard-schema/json-schema does not exist on npm; do not reference it.

Two measured facts that matter more than they look:

Direction is not cosmetic. For z.object({ a: z.string(), b: z.number().default(2) }), measured through ~standard.jsonSchema:

Call required additionalProperties
input({target}) ["a"] — b has a default, so a client need not send it absent
output({target}) ["a", "b"] — after defaults are applied, b is always there false

(An earlier measurement suggested input() dropped required entirely; it did not — that fixture simply had no required property. The real asymmetry is additionalProperties, and the treatment of defaults, which is exactly right.) So requests convert with input. Correction (2026-10-24, the maintainer's decision, recorded when (b) was built): responses convert with input too — the wire carries what was sent — except under V13's responseOutput: "parsed", where what the client receives is the schema's output and output is right. Getting it backwards produces a document demanding a field the API fills in itself.

Coercion is invisible. z.coerce.number().int().min(1) converts to {type:"integer", minimum:1, maximum:9007199254740991} — nothing says the wire value is a string. For a query parameter that is fine and correct (OpenAPI serialises a query parameter's schema type, it does not require type:"string"). For a body it would be wrong, and the user must not use z.coerce there. Document it; strict mode cannot detect it.

4.3 The converter registry

/** Which side of a schema a conversion describes. */
export type SchemaDirection = "input" | "output";

/** Context a converter is given. */
export interface ConvertContext {
  /**
   * `"input"` for requests, and for responses too — the wire side — except
   * under V13's `responseOutput: "parsed"`, where a response is the schema's
   * output and `"output"` is right (the 2026-10-24 correction to §4.2).
   */
  direction: SchemaDirection;
  /**
   * The dialect the caller wants: `"draft-2020-12"` for an OpenAPI 3.1
   * document (the default and the common case), `"openapi-3.0"` when the
   * output feeds `@nestjs/swagger`, which emits 3.0 (§3.8.2) — `nullable:
   * true` instead of `type: ["x","null"]`, no `prefixItems`, boolean
   * `exclusiveMinimum`. Passed straight through to
   * `~standard.jsonSchema`'s `target`.
   */
  dialect: "draft-2020-12" | "openapi-3.0";
  /** Converts a nested schema, for a converter that recurses. */
  convert: (schema: StandardSchemaV1, direction?: SchemaDirection) => JsonSchemaLike | undefined;
}

/**
 * Turns one vendor's schema into JSON Schema.
 *
 * Return `undefined` when this converter cannot describe the schema at all;
 * the generator then records a warning and emits `{}` (or throws, in
 * `strict` mode). Never throw for an unrepresentable *part* — degrade.
 */
export type JsonSchemaConverter = (
  schema: StandardSchemaV1,
  context: ConvertContext,
) => JsonSchemaLike | undefined;

/**
 * Registers a converter for a `~standard.vendor`, replacing any built-in one.
 * Process-wide; prefer `OpenApiOptions.converters` for a per-document override.
 */
export function registerJsonSchemaConverter(
  vendor: string,
  converter: JsonSchemaConverter,
): void;

/** The built-in converters, exported so they can be wrapped or re-registered. */
export const zodConverter: JsonSchemaConverter;
export const arktypeConverter: JsonSchemaConverter;
export const yupConverter: JsonSchemaConverter;
export const bunJobsConverter: JsonSchemaConverter;

/**
 * Builds the valibot converter around `@valibot/to-json-schema`'s
 * `toJsonSchema`. valibot core carries no `~standard.jsonSchema`, so the
 * function is injected rather than imported — the same structural rule the
 * logging adapters follow.
 *
 * Not needed if the schema was wrapped with that package's
 * `toStandardJsonSchema`, which adds `~standard.jsonSchema` and so takes the
 * primary path with no registration at all.
 *
 * @example
 * ```ts
 * import { toJsonSchema } from "@valibot/to-json-schema";
 * registerJsonSchemaConverter("valibot", valibotConverter(toJsonSchema));
 * ```
 */
export function valibotConverter(
  toJsonSchema: (schema: unknown, config?: { errorMode?: "throw" | "warn" | "ignore" }) => unknown,
): JsonSchemaConverter;

Resolution order, per schema:

  1. withJsonSchema() — an explicit JSON Schema the author attached.
  2. ~standard.jsonSchema[direction]({ target: "draft-2020-12" }), when the property is a function. Wrapped in try/catch: the spec says it may throw, and zod and arktype both do for unrepresentable types. A throw falls to step 3 rather than aborting the document, and records a warning naming the vendor and the thrown message.
  3. options.converters[vendor] → the process registry → the built-ins.
  4. {} plus a warning (or ConfigError in strict).

Step 2 being first among the automatic paths is the whole point: a library that adopts the companion spec tomorrow works with no change here.

The escape hatch is a raw JSON Schema (or OpenAPI fragment) anywhere a schema is accepted — RouteDoc.request.* and RouteResponseDoc.schema both take StandardSchemaV1 | JsonSchemaLike, discriminated on the presence of ~standard. There is a second form for a schema you still want to validate with:

/**
 * Attaches an explicit JSON Schema to a Standard Schema, so the generator
 * emits this instead of converting. The schema still validates as before.
 */
export function withJsonSchema<S extends StandardSchemaV1>(
  schema: S,
  json: JsonSchemaLike,
): S;

Implemented (as built, 2026-10-25) as a stamp on the schema's ~standard — a non-enumerable jsonSchema converter in the spec's shape, so the core's existing jsonSchemaOf readers (describeContract's comparison, bodyPlacement, the response checks) see it too — with a WeakMap<StandardSchemaV1, JsonSchemaLike> fallback for a frozen ~standard, checked before vendor dispatch. Either way it works for superstruct, joi, a hand-written toStandardSchema, and any vendor that ships tomorrow.

4.4 $ref and component dedup

Three problems, one of them measured and nasty.

  1. Vendors emit $defs, OpenAPI wants components.schemas. zod emits {$ref: "#/$defs/PageQuery", $defs: {...}} when .meta({id}) is set, and #/$defs/__schema0 for reused: "ref". Every converter's output is post-processed: $defs lifted into components.schemas, every #/$defs/X rewritten to #/components/schemas/X, $schema stripped (OpenAPI 3.1 forbids a per-schema $schema differing from jsonSchemaDialect), anonymous __schemaN names replaced by componentName() or inlined.

  2. Self-recursion becomes a dangling ref. Measured: a self-referential zod object converts to {properties:{self:{$ref:"#"}}}. Inlined into a document, # points at the OpenAPI document root, which is not a schema. The lift step must rewrite "#" and any "#/..." relative to the schema's new home. A test asserts danglingRefs(document) is empty and that a recursive schema round-trips through ajv.

  3. Dedup by identity, not by structure. The generator keeps a Map<StandardSchemaV1 | JsonSchemaLike, string> and a Map<string, unknown> name→body. Two references to the same schema object share one component; two structurally identical but distinct objects do not (comparing JSON would be O(n²) and would merge schemas the author meant to keep apart). Two different schemas claiming one name throw ConfigError — bun-jobs' toJsonSchema does exactly this (schema/builder.ts: "Two different schemas are both named") and the rule is worth copying verbatim.

Unreferenced components are dropped by pruneSchemaComponents, lifted from packages/bun-jobs/lib/api/spec/refs.ts (§6).


5. AsyncAPI 3.0 mapping

AsyncAPI 3.0 split channels from operations: a channel carries address, messages and parameters; a top-level operations map carries action: "send" | "receive" plus channel: {$ref}. 2.x's publish/subscribe under a channel are gone. That split is what makes a direction-less transport hard to describe — and bun-common's is direction-less.

5.1 bun-common BunWebSocket

Re-scoped as built (AD (e), 2026-10-26): since bun-validate's V8 landed defineChannel (2026-10-24), a declared channel documents itself — its contract carries the messages and the meta, and the table below's "does not exist" rows are exactly what the contract supplies. WsChannelDoc therefore shrinks to its §11-(e) role: enriching a raw ws() route's stub (a route BunWebSocket knows only as a path with lifecycle handlers), docs-only, as RouteDoc is for HTTP. The interface below shipped as designed; the table's "the only channel source; needs a new public accessor" row shipped as wsRoutes(), the read-only listing a bare route reaches a document through.

bun-common concept AsyncAPI 3.0 element Notes
setRouteHandler(path, …) / router.ws(path, …) key channels.<id>.address the only channel source; needs a new public accessor on BunWebSocket
:param in that path channels.<id>.parameters.<name> AsyncAPI 3.0 parameters have no schema keyword — bun-jobs carries it as x-bun-jobs-schema (spec/asyncapi.ts), and we would carry x-bun-common-schema
bound port + host servers.<id>.host + pathname from get port() / getServer(); protocol: "ws" | "wss" from the request or an option
Sec-WebSocket-Protocol from onUpgrade servers.<id>.bindings.ws + an extension only present if the hook sets it
idleTimeout, maxPayloadLength, perMessageDeflate vendor extension on the channel no AsyncAPI keyword for them
open / close nothing AsyncAPI has no lifecycle concept; document in prose
message (string | Buffer) one receive operation with an empty payload honest but useless
an event name components.messages.<id>.name does not exist
a payload schema components.messages.<id>.payload does not exist
direction operations.<id>.action does not exist
rooms / topics a channel each does not exist (zero grep hits)
ack / reply operations.<id>.reply does not exist

So from bun-common alone the honest output is: servers, channels with addresses and parameters, and one receive+one send operation per channel with payload: {}. That is a stub, not documentation.

The gap is closed by a declaration API, mirroring .describe():

/** Documents a WebSocket route's messages. */
export interface WsChannelDoc {
  /** Channel title. Default: the path. */
  title?: string;
  /** CommonMark description. Default: none. */
  description?: string;
  /** Messages a client sends to the server (AsyncAPI `receive` from the app's view). */
  receives?: Readonly<Record<string, WsMessageDoc>>;
  /** Messages the server sends to a client (AsyncAPI `send`). */
  sends?: Readonly<Record<string, WsMessageDoc>>;
  /** The subprotocol clients must request. Default: none declared. */
  subprotocol?: string;
}

/** One message on a channel. */
export interface WsMessageDoc {
  /** One-line summary. Default: none. */
  summary?: string;
  /** Payload schema — any Standard Schema or raw JSON Schema. Default: unconstrained. */
  payload?: StandardSchemaV1 | JsonSchemaLike;
  /** Media type. Default: `"application/json"`. */
  contentType?: string;
  /** Named examples, validated against `payload` in tests. Default: none. */
  examples?: Readonly<Record<string, unknown>>;
  /** The message id sent in reply, for a request/response pair. Default: none. */
  reply?: string;
}

/** Attaches channel documentation to a registered WebSocket route. */
BunWebSocket.prototype.describeChannel(path: string, doc: WsChannelDoc): this;

5.2 bun-nest BunWebSocketAdapter

Much better, because the wire format is fixed and the event names are in Nest's metadata.

bun-nest concept AsyncAPI 3.0 element Source
GATEWAY_OPTIONS.path + .namespace, concatenated by resolveRoute (BunWebSocketAdapter.ts:557) channels.<id>.address gateway class metadata
PORT_METADATA servers.<id> (one per distinct port) gateway class metadata
@SubscribeMessage("name") → MESSAGE_METADATA a receive operation + components.messages.<name> gateway-metadata-explorer.js:22-32
The frame envelope {type:2, namespace, id?, data:[event, ...args]} (MessageEventTypes, :134) the message payload is the whole envelope; the handler's DTO is data[1] fixed by the adapter — a generator emits the envelope wrapper itself, exactly as bun-jobs wraps events in eventEnvelope
@MessageBody() DTO class payload for data[1] needs @ApiProperty/@nestjs/swagger or an attached Standard Schema
@Ack() / isAckHandledManually (gateway-metadata-explorer.js:36) operations.<id>.reply → an ACK (type 3) message metadata
WsException → ["exception", {status,message,cause}] (:454-458) a send message, always present fixed
CONNECT(0)/DISCONNECT(1)/ERROR(4) frames three fixed messages on every channel fixed
Wildcard route "/*" (connectionMatchesRoute, :563) unmappable an AsyncAPI address is a template, not a glob
Outbound event names (client.emit(name, …), WsResponse.event) send operations runtime strings — invisible. Must be declared
namespace field on the wire nothing — it is echoed, not routed (:147,154,169,193) note it in prose so nobody reads it as a channel

Verdict: bun-nest can auto-derive every inbound operation and its envelope, and must be told about outbound ones. That is a genuinely useful document and a defensible split.


6. Reuse vs rewrite: packages/bun-jobs/lib/api/spec/

6.1 Module-by-module

Module Lines Generic? Verdict
spec/refs.ts 101 fully — collectRefs, resolveRef, danglingRefs, pruneSchemaComponents, SCHEMA_REF_PREFIX. No bun-jobs import at all Move to bun-common/lib/docs/refs.ts, verbatim. bun-jobs re-exports from there
spec/security.ts 144 nearly — openApiSecurity, toAsyncApiSecurityScheme, OpenApiSecurityScheme. Only dependency is ResolvedJobsApiDocsOptions, and only for Pick<…, "securitySchemes" | "security"> Move, widening the parameter to a structural { securitySchemes?, security? }. Keep the browserNote strings — they are correct for any browser WebSocket
spec/openapi.ts 383 mixed. Generic: the path→template conversion and its guards, parameter emission from a properties map, style/explode for arrays, the components + prune + Problem-response assembly, the duplicate-operationId and duplicate-method+path guards. Specific: TAG_DESCRIPTIONS, routeErrorCodes + API_ERROR_STATUS, the CSRF header/requireJson parameters, x-bun-jobs-action/-mutation/-requires/-csrf, packageVersion(), AnyRouteDef Rewrite in bun-common against V12's ListedRoute (corrected 2026-10-25: not "a neutral DiscoveredRoute — §3.3's is subsumed). Port toOpenApiPath's structure, not its throw sites (corrected 2026-10-25 from "verbatim": in bun-common the guards are warnings per §10.2, strict escalates; bun-jobs' throwing version stays bun-jobs'); port the parameter/response assembly as a shape, not as code
spec/asyncapi.ts 691 no. Needs CHANNELS (ws/channels.ts), QUEUE_EVENTS/RUNNER_EVENTS/WORKER_EVENTS (ws/events.ts), the nine control-frame schemas (ws/protocol.ts), RATE_BREACH_WINDOW_MS (ws/session.ts), the resolved websocket limits, and the contract constants Leave. Write a new, much smaller generator in bun-common from WsChannelDoc
spec/asyncapi-examples.ts 506 no — one hand-written example per bun-jobs message Leave
schema/builder.ts 615 It is bun-jobs' own DSL, whose entire point is "one definition feeds the validator, the TypeScript type and the specs". bun-common's story is Standard Schema from any vendor Leave. Add a 15-line bunJobsConverter in bun-common that reads schema.json (see §4.2), so the two meet without either moving
schema/validate.ts, schema/coerce.ts 617 bun-jobs' JSON Schema interpreter — a validator, not a generator Leave
docs/html.ts, docs/cdn.ts 420 fully generic apart from the title default and the CSRF interceptor line. CSP, nonce, jsonForScript, SRI assetTags, the pinned versions and hash validation are all reusable Move to bun-common/lib/docs/viewer.ts, parameterised on { title, documentUrl, requestHeaders }. bun-jobs keeps its route wrappers

So: ~665 of 1,825 lines in spec/ + docs/ move; the rest stays. The generators are not the reusable part — refs, security and the viewer shell are.

6.2 What extraction would break, and how bun-jobs keeps working

bun-jobs' specs are heavily tested and must not regress: __tests__/api/api-spec.test.ts (1,032 lines) validates every one of a 12-case mode × readOnly × security matrix against the vendored OpenAPI 3.1 meta-schema (__tests__/fixtures/schemas/oas-3.1.json, from spec.openapis.org/oas/3.1/schema/2022-10-07) and the official AsyncAPI 3.0.0 schema from @asyncapi/specs@^6.11.1; api-docs.test.ts (437) checks the CDN pins, SRI and CSP, and even fetches each pinned URL to verify its sha384.

Risks and mitigations:

Risk Mitigation
refs.ts moving changes an import path bun-jobs' lib/api/index.ts already re-exports the spec/refs utilities. Keep that barrel, change only what it re-exports from. api-spec.test.ts imports from "../../lib/api/spec/refs" — keep spec/refs.ts as a two-line re-export so no test moves
security.ts widening its parameter type openApiSecurity is called with config.docs (ResolvedJobsApiDocsOptions | false), which structurally satisfies {securitySchemes?, security?} | false. No call site changes
bun-common gaining the CSP/CDN viewer changes the pinned versions Move the module, keep bun-jobs' DOCS_CDN constant in bun-jobs. The pins are a bun-jobs decision recorded in its own tests
A circular dependency None: bun-jobs already depends on bun-common, and nothing in bun-common would import bun-jobs
bun-jobs' ConfigError vs bun-common's The moved modules throw ConfigError. bun-common has no ConfigError today (bun-jobs' lives in lib/shared/errors). Add one to bun-common and have bun-jobs' extend or alias it — this is the one non-trivial ripple, and it needs its own check that instanceof ConfigError still holds in bun-jobs' tests

Verdict: partial extraction, not a parallel implementation and not a wholesale lift. A parallel implementation would duplicate pruneSchemaComponents and the security mapping — 245 lines of subtle, already-tested code — for no benefit. A wholesale lift would drag bun-jobs' schema DSL into bun-common and make bun-common's Standard Schema story a second-class citizen in its own package.


7. Serving and viewing

7.1 Router shape

/** Options for {@link docsRouter}. */
export interface DocsRouterOptions {
  /** The OpenAPI document, or a function returning a fresh one. Required unless `asyncapi` is given. */
  openapi?: OpenApiDocument | (() => OpenApiDocument);
  /** The AsyncAPI document, or a function returning one. Default: none, and the route is absent. */
  asyncapi?: AsyncApiDocument | (() => AsyncApiDocument);
  /** Where the OpenAPI JSON is served, under the mount. Default: `"/openapi.json"`. */
  openapiPath?: string;
  /** Where the AsyncAPI JSON is served. Default: `"/asyncapi.json"`. */
  asyncapiPath?: string;
  /**
   * The viewer pages. **Default: neither is served** — they load third-party
   * script into an origin that may hold session cookies, and "try it out" is
   * a mutation console. The JSON costs nothing and is always served.
   */
  viewer?: {
    /** The OpenAPI viewer page, at the mount root (`/`). Default: not served. */
    openapi?: OpenApiViewerName | ViewerConfig<OpenApiViewerName>;
    /** The AsyncAPI viewer page, at `/asyncapi`. Requires `asyncapi`. Default: not served. */
    asyncapi?: AsyncApiViewerName | ViewerConfig<AsyncApiViewerName>;
  };
  /** Page title. Default: the document's `info.title`. */
  title?: string;
  /** CDN pins and mirror base URL. Default: the built-in pins. */
  cdn?: DocsCdnOptions;
  /** Runs before every docs route — put authorization here. Default: none. */
  authorize?: RouterHandler;
}

/** Mountable router serving the documents and, optionally, the viewer pages. */
export function docsRouter(options: DocsRouterOptions): BunRouter;

Mounted like anything else: app.use("/docs", docsRouter({ openapi })).

As built (2026-10-25, the maintainer's mid-build decision, final): the ui/uiPath/asyncapiUiPath booleans-and-paths above were replaced by the single viewer option — a per-spec declaration, a viewer name as a string (defaults applying) or { name, ...options } with the remaining properties passed through to the viewer's own configuration (spread into the init config for swagger-ui/Scalar/Redoc, element properties for RapiDoc, the config object for the AsyncAPI component). The page paths are fixed defaults (/ and /asyncapi). The viewers and their pins (bun-common's own DOCS_CDN; bun-jobs keeps its own pins untouched):

Viewer Name Package @ version Asset(s) requestHeaders
Scalar — OpenAPI default "scalar" @scalar/api-reference@1.76.0 dist/browser/standalone.js not honoured (no request hook)
Swagger UI "swagger-ui" swagger-ui-dist@5.34.0 swagger-ui-bundle.js + swagger-ui.css honoured (requestInterceptor)
Redoc "redoc" redoc@3.0.1 bundles/redoc.standalone.js — ESM-only since 3.0, so the page pins it through an import map with integrity and a nonce'd module initialiser not honoured
RapiDoc "rapidoc" rapidoc@11.0.1 dist/rapidoc-min.js (defines <rapi-doc>) not honoured
The official component — AsyncAPI default "asyncapi" @asyncapi/react-component@3.2.2 browser/standalone/index.js + styles/default.min.css not honoured

Scalar also serves the AsyncAPI document (viewer.asyncapi: "scalar"), but its AsyncAPI rendering is upstream work-in-progress — opt-in only, and the docs say so. An unknown name is a ConfigError at construction, as is a viewer page without its document or docsRouter({}) with neither document. Each viewer's asset is sha384-pinned on jsDelivr (hashes computed from the served bytes 2026-10-25), and every route docsRouter registers is .describe({ hidden: true }), so a later generateOpenApi never documents the docs — an as-built addition this design section did not prescribe. The AsyncAPI side is not WebSocket-only: it covers any realtime/event-driven mechanism OpenAPI cannot document (Kafka, AMQP, MQTT, SSE, …); the document is hand-supplied (AsyncApiDocument is (e)'s, so the option is typed structurally until then). New exported names beyond the approved list, recorded for approval: OpenApiViewerName, AsyncApiViewerName, ViewerConfig (generic, Name defaults to string), scalarPage, redocPage, rapidocPage (the last three follow the approved swaggerUiPage/asyncApiUiPage pattern), DocsCdnAssetOverride, and DocsPage (the pages' return type — bun-jobs' shim re-exports it). AssetIntegrity.css and PinnedAsset.css became optional (Scalar, Redoc and RapiDoc bundle their styles); bun-jobs keeps its own css-required copies, so its untouched api-docs.test.ts still type-checks. resolveDocsCdn(pins, overrides) takes the pins as a parameter and ResolvedDocsCdn maps them (Pins defaults to typeof DOCS_CDN).

7.2 The UI choice

Three candidates, and the repo has already built two of them.

Option Bundle cost Offline Verdict
CDN viewer pages — copy bun-jobs' docs/html.ts + docs/cdn.ts (pinned swagger-ui-dist@5.32.15 and @asyncapi/react-component@3.1.8 on jsDelivr, sha384 SRI, default-src 'none' CSP with a per-request nonce) zero in the package; ~1 MB fetched by the browser no, unless cdn.baseUrl points at a mirror Ship this in phase (c). 420 lines, already written, already tested against the live CDN hashes. Off by default
Reuse jobsUi() — — No. jobsUi() is @kingsleyweb/bun-jobs-ui, whose UiConfig (lib/shared/config.ts) is jobs-shaped (websocket, sections: {manage, docs}, apiBase) and whose app has a whole management UI beside the docs. Mounting it to read an arbitrary OpenAPI document would mean carrying the jobs screens
A generic self-hosted viewer — generalise packages/bun-jobs-ui/app/screens/docs/** (6,806 lines; largest lazy chunk measured at 66.7 KiB / 21.2 KiB gzipped on 2026-09-21, __tests__/server/budget.test.ts) into a new @kingsleyweb/bun-api-docs-ui ~67 KiB lazy, self-hosted with its own sha384 SRI and default-src 'self' CSP yes The right long-term answer, and the wrong thing to build first. (Built later as phase (f); the row's analysis held.) schema/ (resolve + SchemaTree + Prose, ~1,000 lines) is already written as dialect-agnostic 2020-12 rendering and would lift cleanly; http/model.ts (465) is plain OpenAPI 3.1 and would generalise; ws/model.ts (1,152) is bound to x-bun-jobs-* and would not. Defer to a phase (f)

Constraint that decides it: the two existing CSPs are incompatible. The CDN pages need default-src 'none'; script-src 'nonce-…' <cdn>; style-src <cdn> 'unsafe-inline' (bun-jobs/lib/api/docs/html.ts); bun-jobs-ui gets default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' with no inline styles at all (bun-jobs-ui/lib/shell.ts:73-88). A single shared viewer must pick one. Phase (c) picks the CDN one because it is 420 lines and already green; phase (f) — scheduled, and built (2026-10-26/27, F1 #541 and F2) — added the stricter self-hosted one as docsRouter's "self-hosted" viewer and keeps the CDN pages as an option.

7.3 Relationship to bun-jobs-ui

None at first, deliberately — and then exactly this: phase (f) extracted the screens into @kingsleyweb/bun-api-docs-ui and bun-jobs-ui consumes them rather than keeping a fork (F2's migration). The golden AsyncAPI fixtures (__tests__/app/docs/ws/fixtures/*.json, kept fresh by __tests__/app/pkg/docs-ws.integration.test.ts with UPDATE_WS_FIXTURES=1) were the regression net for it: unchanged through the migration.


8. Packaging

8.1 New entry point, not a new package

@kingsleyweb/bun-common/lib/docs. Reasons:

  • It has no runtime dependency of its own (§4.3): converters are structural, the viewer shell is string building, refs is a JSON walker. A separate package would exist only to be a second node_modules entry.
  • It needs deep access to BunRouter's internals (#lastRoute, RouteWithGroup.isEndpoint/wildcardNames, getMatchedLayers). Across a package boundary those become public API, which is worse.
  • CLAUDE.md's exports rule wants one explicit key per directory with an index.ts — ./lib/* would map lib/docs to a nonexistent lib/docs.ts. So the addition is exactly one key.

8.2 package.json delta for packages/bun-common

{
  "exports": {
    ".": { "@kingsleyweb/source": "./lib/index.ts", "types": "./dts/index.d.ts", "default": "./lib/index.ts" },
    "./lib": { "...": "unchanged" },
    // NEW — a directory with an index.ts needs its own key
    "./lib/docs": {
      "@kingsleyweb/source": "./lib/docs/index.ts",
      "types": "./dts/docs/index.d.ts",
      "default": "./lib/docs/index.ts"
    },
    "./lib/multipart": { "...": "unchanged" },
    "./lib/multipart/storage": { "...": "unchanged" },
    "./lib/*.ts": { "...": "unchanged" },
    "./lib/*.js": { "...": "unchanged" },
    "./lib/*":    { "...": "unchanged" },
    "./package.json": "./package.json"
  },
  "dependencies":     { /* UNCHANGED — no new runtime dependency */ },
  "peerDependencies": { /* UNCHANGED — no new peer, optional or otherwise */ }
}

Nothing else changes. That is the payoff of the structural-converter decision, and it should be stated in the PR description, because the obvious design (optional peers on zod/valibot/arktype + lazy import()) would have cost:

The rejected design What it costs
peerDependencies: { zod, valibot, arktype, yup } + peerDependenciesMeta optional four optional peers on the repo's most-depended-on package
await import("zod") in a converter the shipped dts/docs/converters/zod.d.ts would import zod — which checkPeerScopes (scripts/build-declarations.ts:273) fails on, because the root exports["."] reaches it and declares no peers
a ./lib/docs/converters/zod entry with "peers": ["zod"] in consumer-check.json four more entries, four more consumer installs, and zod pulled into any bundle that touches lib/docs

With structural converters, the shipped declarations import nothing but bun-common's own modules and StandardSchemaV1, so checkPeerScopes has nothing to complain about.

8.3 consumer-check.json additions

{
  "spelling": "@kingsleyweb/bun-common/lib/docs",
  "values": ["generateOpenApi", "generateAsyncApi", "docsRouter",
             "registerJsonSchemaConverter", "valibotConverter", "withJsonSchema"],
  "types":  ["RouteDoc", "OpenApiOptions", "OpenApiResult", "DiscoveredRoute",
             "JsonSchemaConverter", "WsChannelDoc"]
},
{
  // the browser half: a viewer bundles the *types*, never the generator
  "spelling": "@kingsleyweb/bun-common/lib/docs/types",
  "types": ["OpenApiDocument", "AsyncApiDocument", "JsonSchemaLike"],
  "browser": true
}

As built (AD (a), 2026-10-25): the ./lib/docs cell carries generateOpenApi, toOpenApiPath, the moved refs/security utilities, and the types RouteDoc, RouteResponseDoc, OpenApiOptions, OpenApiResult, DocWarning, OpenApiDocument, OpenApiSecurity, OpenApiSecurityScheme et al. — ListedRoute instead of the subsumed DiscoveredRoute; generateAsyncApi/docsRouter/WsChannelDoc and the ./lib/docs/types browser entry are the later phases' ((e), (c) — the types entry deferred there). The root entry gained the ConfigError value and the (a) types, and its snippet asserts RouteDoc is not any.

As built (AD (c), 2026-10-25): the ./lib/docs cell gained the values docsRouter, docsPageHeaders, swaggerUiPage, scalarPage, redocPage, rapidocPage, asyncApiUiPage, resolveDocsCdn, assetUrl, DOCS_CDN and the types DocsRouterOptions, DocsCdnOptions, DocsCdnAssetOverride, ResolvedDocsCdn, PinnedAsset, AssetIntegrity, DocsPage, OpenApiViewerName, AsyncApiViewerName, ViewerConfig (generics with defaults, per the consumer-check rule); and the ./lib/docs/types browser entry landed exactly as designed above — OpenApiDocument + JsonSchemaLike only (AsyncApiDocument is (e)'s), browser: true, runtime: false (it is type-only). Neither entry has "peers".

No "peers" on either, which is the assertion that matters: both must load in a consumer with no optional peer installed. The existing root entry's snippet gains one line asserting RouteDoc is not any.

8.4 dts/ and in-repo resolution

tsconfig.build.json needs no change (rootDir: lib already covers lib/docs/**). build-declarations.ts's verify step will require a declaration for every new lib/docs/*.ts, which is automatic. The customConditions: ["@kingsleyweb/source"] in tsconfig.base.json means in-repo type-checking hits lib/docs/index.ts, never a stale dts/.

8.5 bun-nest

No packaging change, and probably no code. @nestjs/swagger should be a documented recommendation, not a dependency: it is not a peer bun-nest needs, and adding it would put a Swagger-shaped API in a package whose job is the adapter. If a helper does prove necessary it belongs at ./lib/swagger with "peers": ["@nestjs/swagger"] in consumer-check.json, exactly the shape ./jobs already uses for @kingsleyweb/bun-jobs. Phase (d) executed the verification (§8.6, 2026-10-25) and found no hole: no helper was built, and the recommendation is what the package README ships.

8.6 The @nestjs/swagger verification — the bun-nest half is mostly "verify + document"

Executed 2026-10-25 (phase (d)) — the verification below is no longer a read: packages/bun-nest/__tests__/swagger.nest.test.ts boots a real Nest app on BunHttpAdapter and on @nestjs/platform-express, and examples/bun-nest/08-openapi/swagger-module.ts carries the recipe. What the first read found is kept here with its citations refreshed to today.

@nestjs/swagger is a devDependency of packages/bun-nest and examples/bun-nest (12.0.4, pinned; the maintainer's decision of 2026-10-25 — no peer entry, no declaration import, and no direct swagger-ui-dist: swagger bundles its own, 5.34.0 at 12.0.4). The checkout runs it against Nest 12.2.0; the nest-matrix columns run 11.2.7 with swagger 11.4.6 and 12.0.0 with 12.0.4 (its line is not Nest's, so scripts/nest-matrix.ts's NEST_PACKAGE_OVERRIDES pins it per major, ahead of the blanket @nestjs/* pin).

An exhaustive grep of the dist/ that executes — 12.0.4 on the checkout, 11.4.6 in the 11 column — for httpAdapter. / getHttpAdapter() / useStaticAssets gives the complete adapter surface SwaggerModule.setup() needs — four members, unchanged between the two versions (12.0.4's sites cited; 11.4.6's identical in kind):

Swagger call Site (12.0.4) bun-nest
httpAdapter.getType() swagger-module.js:111, swagger-scanner.js:25 BunHttpAdapter.ts:3325 returns "express" — so every fastify branch is skipped and the express path is taken throughout
app.useStaticAssets(path, { prefix }) swagger-module.js:123 (express branch) BunHttpAdapter.ts:3145, (path: string, options: ServeStaticOptions), reading options.prefix — exactly that signature
httpAdapter.get(path, handler) :157, :171, :208, :209, :210, :214, :221, :232 :3718 → registerVerb("get", …) (:3526) → BunRouter.get
httpAdapter.getRequestUrl(req) :201 :3204, returns request.originalUrl (path + search)

Plus two members on the raw response, because Swagger's handlers bypass Nest's pipeline: res.type(mime) (BunResponse.ts:2938) and res.send(string) (:3303). Both exist.

It never calls set(), enable(), engine(), setViewEngine(), render(), reply(), status(), setHeader(), isHeadersSent(), use() or getInstance(). When this surface was first read that mattered because those members did not exist on BunHttpAdapter; the view-engine subsystem has landed since, so they now do — and the express path still never touches them.

Two residual risks were flagged and both are now measured green:

/api      -> 200 "PAGE"    text/html     // httpAdapter.get(finalPath, html)
/api/     -> 200 "PAGE"    text/html     // the `${finalPath}/` registration (swagger-module.js:203)
/api/x.js -> 200 "STATIC"  text/plain    // falls through to `${prefix}/*`

so (1) BunRouter's path regex ends /?$, making /api/ equivalent to /api rather than falling through to the static handler, and (2) a handler that calls res.send() without next() terminates the pipeline — the second route did not also run. The end-to-end run re-measured both, plus what the read could not see: the ${finalPath}/LICENSE route's thrown NotFoundException answers a JSON 404 on both adapters (not a hang, not a 500), and @Render("index") with no engine registered answers Nest's 500 {"statusCode":500,"message":"Internal server error"} on both.

The gaps above are all closed or answered, 2026-10-25:

  1. swagger-ui-dist is not in the repo — closed by the decision: @nestjs/swagger bundles its own copy (5.34.0 at 12.0.4) and serves it through useStaticAssets; nothing imports swagger-ui-dist directly.
  2. End-to-end SwaggerModule.setup() has not been executed — executed: __tests__/swagger.nest.test.ts (12 tests, differential against platform-express, createDocument outputs byte-identical) and examples/bun-nest/08-openapi/swagger-module.ts, on the checkout (Nest 12.2.0, swagger 12.0.4) and in the nest-matrix columns 11.2.7 (swagger 11.4.6) and 12.0.0. No hole found — no ./lib/swagger fallback was needed.
  3. The mixed-app assumption is now measured (the test's prefix probes): a route registered directly on the adapter is served but never appears in swagger's document, and a Nest controller's route does survive setGlobalPrefix into bun-common's view — Nest bakes the prefix into the registration, so listRoutes sees GET /v1/users/:id. The README carries the caveat, including one sharp edge the read missed: generateOpenApi over a router that also holds swagger's docs routes throws ConfigError (GET /api, GET /api/ and the static mount's GET /api share the default operation id getApi) — filter the docs mount out or generate from a router that does not hold it.
  4. @Render() sends a raw file — stale, corrected: render (BunHttpAdapter.ts:2928) has gone through the view-engine subsystem (setBaseViewsDir/setViewEngine/engine) since that landed; with no engine registered, @Render("index") answers the same Nest 500 as platform-express (asserted in the test, noted in the README).

9. Testing strategy

The pattern is already established by bun-jobs and should be copied, not reinvented.

9.1 Meta-schema validation (the non-negotiable one)

Document Schema source Validator
OpenAPI 3.1 vendor the meta-schema into packages/bun-common/__tests__/fixtures/schemas/oas-3.1.json, as bun-jobs does (its copy is from spec.openapis.org/oas/3.1/schema/2022-10-07, fetched 2026-09-15). Record the source URL and date in the file's $id and in a comment ajv@^8 ajv/dist/2020 + ajv-formats, both devDependencies
AsyncAPI 3.0 @asyncapi/specs@^6.11.1 devDependency, schemas/3.0.0.json — a draft-07 bundle, so compile with ajv's draft-07 build and delete the embedded http://json-schema.org/draft-07/schema definition first same

Which spec version to emit. Reported by research, to be re-checked against the sources before vendoring:

  • The latest OpenAPI 3.1.x patch is 3.1.2 (2025-09-19), not 3.1.0. The patches are editorial — OAS says tooling "should make no distinction" between 3.1.0/3.1.1/3.1.2 and the schema dialect is unchanged — so openapi: "3.1.2" is right and the 2022-10-07 meta-schema still validates it. Note the line has moved on: 3.2.0 (2025-09-19) and 3.2.1 (2026-09-10) exist and are feature releases (nested tags, the query method, $self, streaming media types). Out of scope; say so.
  • The latest AsyncAPI is 3.1.0 (2026-01-31), a minor with no breaking changes — asyncapi: '3.0.0' → '3.1.0' verbatim, its one headline addition being ROS 2 bindings. bun-jobs emits 3.0.0; phase (e) should decide deliberately whether to match it or move to 3.1.0, and the two packages should agree. Decided 2026-10-26 (the maintainer's decision): emit 3.0.0, matching bun-jobs — nothing in 3.1.0 is used, and the two packages' documents agree.

Copy bun-jobs' two workarounds verbatim, with its comments:

  • ajv does not honour the OAS schema's $dynamicAnchor: "meta", so the test rewrites "$dynamicRef": "#meta" → "$ref": "#/$defs/schema" in memory and asserts the fixture still contains the original.
  • The OAS schema names a media-range format ajv-formats lacks; ajv.addFormat("media-range", true).

And copy the describe.skipIf(!ajv) + BUN_*_TEST_AJV_DIR escape hatch, so the suite skips visibly where the devDependency is absent rather than failing.

Every generated document must validate across a matrix, as bun-jobs does over mode × readOnly × security. Ours: { no schemas, zod, valibot, arktype, yup, mixed } × { no security, bearer, apiKey } × { strict, lenient }.

9.2 Round-tripping and invariants

  • danglingRefs(document) is empty for every matrix cell — the direct test for the $ref: "#" recursion hazard (§4.4).
  • Every components.schemas entry compiles standalone under ajv (bun-jobs does this; it is what catches a lifted $defs whose refs were not rewritten).
  • Every path template's parameters exactly equal the route's params, and each is required: true.
  • Route/document parity: for every documented operation, router.fetch() the synthesised path and assert the real status is one the document declares. This is bun-jobs' api-ui-gaps.test.ts idea ("real responses match the generated OpenAPI document") and it is the only test that catches a hand-written responses going stale.
  • generateOpenApi is deterministic: two calls on one router deep-equal.

9.3 Converter tests

One file per vendor, each asserting the converted JSON Schema accepts and rejects the same values the schema does — ajv-compile the output, then run a shared value table through both the Standard Schema's validate and the compiled JSON Schema, and require agreement. That is stronger than a snapshot and survives a vendor's minor release changing its emit. Known divergences (zod's z.coerce on a query, valibot's errorMode: "ignore" dropping a transform) get an explicit allow-list with a comment naming the measurement.

Negative controls, per CLAUDE.md's habit: flip one expectation and the test must fail.

9.4 Snapshots

One golden document, for a small fixture router exercising every feature (mount, wildcard, regex param, validator, .describe(), hidden, all four targets), regenerated with UPDATE_DOCS_FIXTURE=1 — the shape bun-jobs-ui uses for its WS fixtures. Not per-vendor snapshots: they would break on every dependency bump for no signal.

9.5 Type tests

__tests__/docs.type-test.ts, checked by the tests typecheck, not bun test (the repo's convention — logging.type-test.ts, verbTyping.type-test.ts):

  • .describe() returns this and chains.
  • RouteDoc.responses accepts a bare schema, a RouteResponseDoc and null.
  • RouteDoc.request.query accepts a zod, valibot, arktype and yup schema and a raw JSON Schema object.
  • The critical one: a file that registers routes with .describe() and asserts the handler's req.params/req.query types are unchanged — Equal<Params["id"], string>, IsAny<Query> false. This is the regression guard for "did adding .describe() disturb the overloads", alongside bun scripts/generate-verb-overloads.ts --check.

__tests__/responses.type-test.ts (phase b1) carries the spike verbatim — it already exists as working code:

  • positive: res.status(200).json(declared) compiles; request narrowing survives; async handlers work; a mounted sub-router declaring BunRouter<"/users/:uid", { responses: … }> is constrained;
  • four @ts-expect-error negative controls: wrong body for a status, undeclared status, bare json() outside the union, and the sub-router case;
  • boundary assertions that must compile — res.send(anything), helper(res) with helper(res: BunResponse), a bare returned object, and a validator with no responses leaving res fully unconstrained. These are not aspirational: they pin §3.7's documented limits so a later "improvement" that closes one is a deliberate decision, not a silent behaviour change.

packages/bun-nest/__tests__/validate.type-test.ts (phase g) carries spike D: a method whose return type matches no declared response is @ts-expect-error'd; one that matches compiles; @Validate with no responses imposes nothing (control); and @ValidatedBody(S) body: string compiles, pinning the "a decorator cannot retype a parameter" limit.

Runtime tests for (b1)/(b2): a response that violates its schema logs exactly once under "log" and throws under "throw"; a stream response is skipped with one warning; an undeclared status is reported under "report" and passed under "allow"; redirect() and 204 behave as §3.5's table says; and a DTO target validates, coerces ("42" → 42) and replaces req.query in place so a later reader sees the parsed value.

9.6 The examples/ obligation

CLAUDE.md's examples protocol applies: a user-facing change means a CHANGE REPORT to the examples peer, and examples/bun-common/run-all.ts must pass before merge. New folders:

  • examples/bun-common/13-api-docs/ — annotating-routes.ts, from-validators.ts, serving-and-viewing.ts, and 10-options/'s openapi-options.ts (the repo's convention is one 10-options/ file per options interface).
  • examples/bun-common/13-api-docs/response-contracts.ts (phase b1) and class-dtos.ts (b2). The DTO example must skip visibly (skipped: class-validator is not installed) rather than fail, since neither library is a dependency — and its own tsconfig must set experimentalDecorators, which is a useful demonstration in itself.
  • examples/bun-nest/08-validate/ (phase g) — route-decorator.ts, controller-scope.ts, swagger-bridge.ts, plus 10-options/validate-options.ts per the repo's one-file-per-options-interface convention.
  • examples/bun-nest/08-openapi/swagger-module.ts — the end-to-end SwaggerModule.setup() proof from §8.6, skipping visibly (skipped: …) when @nestjs/swagger or swagger-ui-dist is absent.

Each example's assertions go through shared/check.ts, and the AsyncAPI one must skip cleanly where no port can be bound.

9.7 Per-phase gate

bun scripts/typecheck.ts; CI=1 bunx eslint . in each touched package; bun test and bun test --randomize (a couple of seeds) in bun-common, bun-nest and bun-jobs — bun-jobs is downstream of every bun-common change and its spec tests are the extraction's regression net; bun run-all.ts in examples/bun-common and examples/bun-nest.


10. Bottlenecks, risks and open questions

Ordered by how much they can hurt.

  1. Response types are not inferable — they are declared, and now enforced. Nothing derives a schema from res.json(x). §3.5 and §3.7 close the drift half of this (runtime checking, compile-time checking, the route/document parity test of §9.2), but the declaration itself is hand-written and always will be. The earlier open question about a res.jsonAs(Schema, value) helper is now closed as unnecessary: validate(..., { responses }) gives the same guarantee without a second way to write a response.

1b. Compile-time enforcement is partial, and two of its holes are structural. §3.7's table is the boundary. The two that cannot be closed without disproportionate cost: res.send() (constraining it would reject every legitimate string/Buffer/stream response) and a helper function taking a plain BunResponse (TResponses appears only in method parameter positions, so a narrowed res is assignable to a bare one — closing it needs variance annotations that would break far more than they fix). The README must state both. Open question: should jsonp() be constrained like json()? It is trivially possible and nobody uses jsonp.

1c. BunResponse gains a second type parameter, and that is a public type change. Both parameters default, and measured, the patched library plus its ~18 BunResponse-naming files typecheck clean — but dts/ changes, so it is a minor-version surface change and consumer-check.json's BunResponse spellings must be re-run. The one ergonomic cost inside the class is real: TResponses is unresolved there, so all ~8 internal this.status(n) calls must go through a protected setStatusRaw. A future contributor writing this.status(500) in BunResponse.ts will get a confusing error; the method needs a comment saying why, and the spike's wording is a good start.

1d. Runtime response validation is not free, and streams are exempt. One Standard Schema validate() per response when enabled — the same order as validating a request body. Default "off", recommended dev-only, and sendFile/stream/StreamableFile responses are skipped outright because checking them would mean buffering the thing that exists not to be buffered. A route whose success path is a stream therefore gets no response checking, silently, apart from one warning per route.

1e. Class DTOs require experimentalDecorators, and stage-3 is a hard break. Measured: with standard decorators, importing a class-validator DTO throws TypeError: undefined is not an object (evaluating 'object.constructor') at class-definition time — not a degradation, a crash. The repo is fine (tsconfig.base.json:24-26), but a consumer on TS 5+ standard decorators cannot use the DTO path at all. It must be a stated requirement, and it is the strongest argument for keeping Standard Schema the primary path. Secondary, smaller: design:type erases string[] to Array and optionality entirely (both measured), so @IsString({ each: true }) and @IsOptional() remain mandatory. And the whitelist aside here was wrong as measured on the installed class-validator 0.15.1 (re-measured for (b2), 2026-10-26): whitelist: true does strip undeclared properties from the instance — which the default leans on, and validate: { whitelist: false } opts out of. Also measured for (b2): Bun reads the decorator flags from the cwd's tsconfig (oven-sh/bun#28605), so the stage-3 fixture runs with its own directory as the cwd.

1f. Neither class-validator, class-transformer, reflect-metadata nor @nestjs/swagger may become a dependency or a declaration import. All four are injected structurally (useClassDtos, useSwaggerBridge), which is what keeps checkPeerScopes (scripts/build-declarations.ts:273) quiet — the shipped dts/ names only structural interfaces. The risk is drift: somebody later "simplifies" one into a real import and the declaration starts importing an undeclared package. The packaging test should assert the import graph of lib/docs/** and bun-nest/lib/decorators.ts directly, not rely on review.

1g. @Validate's error message is six lines where the router's is one. Measured: a method whose return type matches no declared response fails with TS1241: Unable to resolve signature of method decorator when called as an expression, then four levels of TypedPropertyDescriptor nesting, with the useful line last. Correct, usable, ugly. Show it in the README.

1h. A decorator cannot type a parameter, and no workaround exists. Measured: @ValidatedBody(CreateUserSchema) body: string compiles with body still string. A parameter decorator's signature (target, key, index) has no type link to the parameter. The Infer<typeof Schema> helper is the answer and it is a genuine ergonomic cost — one typeof per parameter — that the README must show rather than gloss. Open question: is useSwaggerBridge worth building for a team already happy with DTO classes and ValidationPipe? (@Validate itself shipped in bun-validate's V7a/V7b; this question now attaches to the bridge alone.) The honest pitch for the bridge is one declaration feeding both validation and the document — and phase (d) has now shown (2026-10-25, §8.6) that @nestjs/swagger on BunHttpAdapter covers the DTO path whole. What it does not cover is a Standard Schema target: @Validate({ body: zodSchema }) validates and documents as nothing. If that gap is not worth a bridge to the maintainer, (g) should be dropped rather than shipped for symmetry.

1i. @Validate and a Nest ValidationPipe can both own a target. The interceptor runs first, so the pipe sees the parsed value; for a DTO that is idempotent, but a ValidationPipe({ transform: true }) over a zod-parsed query may re-coerce what is no longer a string map. The plan sets a BUN_VALIDATED marker so a future BunValidationPipe can no-op, but does not build that pipe — the documented rule is that the two should not both own a target. Unverified: the precise interceptor/pipe ordering claim comes from Nest's documented request lifecycle, not from a run.

  1. @routejs/router paths with no OpenAPI equivalent. toOpenApiPath (bun-jobs/lib/api/spec/openapi.ts) already throws for *, ( and ) and for an optional :name?, and it is right to. In bun-common these must be warnings, not throws, because an arbitrary router will have them:

    Path Verdict
    /files/*name no OpenAPI form. Emit a warning; strict throws. A {name} template would be a lie — * matches / and a path parameter does not
    /re/:id(\d+) documentable: {id} plus schema: { type: "string", pattern: "^\\d+$" } lifted from the path literal. A small win nobody else does
    /opt/:a? OpenAPI path parameters are always required: true. Two options, both bad: emit two paths (/opt and /opt/{a}), or warn. Recommend two paths, since it is mechanical, with a flag to fall back to a warning
    /slug/:a-:b documentable as /slug/{a}-{b} — legal OpenAPI, poorly supported by viewers. Warn
    use() middleware and useMethod() never an operation; isEndpoint is false, so they are already excluded
  2. Middleware-applied auth is undiscoverable. A use("/admin", requireAuth) is a plain function; nothing says it authenticates, or how. The generator cannot infer security and must not guess. Mitigation: a markSecurity(handler, requirement) tagger — the same non-enumerable-property trick as __schemas — so use("/admin", markSecurity(requireAuth, [{bearer:[]}])) contributes to every operation under that prefix. The pipeline replay (§3, and below) already tells us which operations those are. Open question: is that worth the API surface, or is per-route RouteDoc.security enough? Leaning per-route for phase (a), tagger in phase (b) if asked for.

  3. The pipeline replay is a heuristic in one respect. Discovering a route's validators by calling getMatchedLayers() on a synthesised concrete path is exactly right — it reuses the production matcher, so what the document says is what the pipeline does — and it is measured to work:

    POST /users/__id__/posts  →  [route 0 cb 0 (use mw), route 5 cb 0, route 5 cb 1]
    

    The heuristic is the synthesised value. compilePathRegexpToPath needs a value per param; "1" satisfies [^/]+? and most (\d+) constraints, but a path like /:slug([a-z]+) would not match its own synthesis. Mitigation: after synthesising, assert route.pathRegexp.test(compiled); on failure, try a small ladder of candidates ("1", "a", "00000000-0000-4000-8000-000000000000", a %-free UUID, "x/y" for wildcards) and warn if none match. Measured: "1"/"x/y" covered every path in the probe set, including /re/:id(\d+), /opt/:a?, /slug/:a-:b, /files/* and /{*rest}. Secondary cost: getMatchedLayers populates the route cache (DEFAULT_ROUTE_CACHE_MAX = 50_000) with synthetic keys. Generate into a throwaway clone, or clear the cache after.

  4. Ordering and specificity decide which route "owns" a path. With routeSpecificity: false (the default, Express behaviour) two matching handlers both run in registration order and the first to respond wins — which one that is depends on the handlers, not the table. OpenAPI has no notion of two operations on one method + path. Rule: the first registered wins the paths entry; a later duplicate is a ConfigError in strict and a warning in lenient, naming both. That matches bun-jobs ("Two routes are registered for {method} {path}") and is the least surprising behaviour. Note that routeSpecificity: true changes runtime order without changing the document — a documented, accepted divergence.

  5. A transform hook silently changes what the handler sees. hooks.transform replaces a target's value after validation, and its return type is the handler's type (TargetResult, BunValidate.ts). At runtime it is an opaque function. The document describes the request — which is correct for query/body/params, since transforms run server-side — so this is a documentation nuance, not a bug. Worth one sentence in the README. Similarly replace: false means the handler never sees the validated shape, but the request is still what the schema describes.

  6. Chained validators. CLAUDE.md: "each replaces req.query wholesale, so a sub-route's schema receives the mount's output". The generator sees both validators in the replayed pipeline. Rule: the last validator for a target wins the document, because it is what the handler actually receives. Warn when two validators write the same target, because the second's input type is the first's output and describing only the second may understate what the client may send.

  7. Mount params deliberately do not propagate into types. They do appear in the flattened path (/users/:id/posts), so the document is right where the types are silent. A mount validator, though, is use() middleware and sees {} at runtime — so its params schema is meaningless and the generator must ignore params on a non-endpoint validator while still honouring its query/body. Easy to get wrong.

  8. Double-mounting a sub-router duplicates the documentation. Measured: root.use("/a", sub); root.use("/b", sub) produces /a/posts/:postId and /b/posts/:postId, and the WeakMap is keyed on the shared handler, so both get the same RouteDoc — including the same operationId, which then collides. Mitigation: operationId must be derived per path by default and an explicit one must be suffixed or rejected on collision. Related existing hazard, worth reporting separately: a setNamed route in a double-mounted sub-router throws Route with name "x" already exists. Merged 2026-11-02, #604 (branch feat/bun-common-openapi-mount-suffix; the fix approved by the user 2026-11-02, the rule below awaiting the user's approval; no new public name): derived ids were already per path; a declared id (.describe({ operationId }), a defineRoutes property name) is kept by the first mount in registration order and suffixed at each later mount of the same route (one terminal callback, method and path within the mount) with the segments where its mount differs from the first one's, spelt as a derived id: getUser at /users, getUserV2 at /v2/users. Every other duplicate stays a ConfigError: two routes given one id by hand, a namer's duplicate, a suffixed id already taken, a later mount at /. Output is unchanged wherever no route is mounted twice with a declared id (those documents threw before).

  9. Bundle size if a converter reaches the browser. The converters are the reason to keep the generator out of lib/index.ts. A browser viewer needs only the document and the types; the day one imports @kingsleyweb/bun-common/lib/docs for a type, a bundler that does not erase type-only imports pulls the generator, refs, and every converter. Mitigation: a lib/docs/types.ts with no runtime exports, its own exports key and a "browser": true consumer-check entry (§8.3), plus a bundle-safety test in the shape of bun-jobs-ui's __tests__/app/pkg/bundle-safety.test.ts: build a fixture importing only lib/docs/types and assert no server marker and no converter symbol lands in the output, with a negative control.

  10. Spec drift from hand-written docs. README tables describing routes are exactly what bun-jobs' readme-tables.test.ts guards. If bun-common's README grows an options table for RouteDoc, it should be parsed and asserted the same way. Also note the existing obligation: bun-jobs-ui's README ### What each element needs table is parsed by examples/bun-jobs-ui/04-screens/permissions.ts — a precedent, and a warning about how these tables acquire readers.

  11. ConfigError does not exist in bun-common. bun-jobs throws its own (lib/shared/errors). The extraction needs one in bun-common, and bun-jobs' must stay instanceof-compatible or its tests break. Small, but it is a cross-package change and should be its own commit.

  12. compilePathRegexpToPath is undeclared in @routejs/router's types. It exists at runtime (src/route.mjs) but index.d.ts does not declare it, so using it needs a cast — and it could disappear in a @routejs/router minor. Mitigation: a tiny fallback that substitutes :name in the path literal ourselves, and a test that fails loudly if the method vanishes.

  13. @valibot/to-json-schema is not a declared dependency of this repo. My measurements used 1.8.0 resolved through Bun's auto-install from the global cache (~/.bun/install/cache/@valibot/to-json-schema@1.8.0@@@1); Bun.resolveSync from packages/bun-common fails. For the converter test it must become a devDependency of bun-common, pinned, or the valibot test must skip visibly.

  14. lib/types/standardSchema.ts is a spec version behind. It is a vendored copy of @standard-schema/spec 1.0.0; 1.1.0 added StandardTypedV1, StandardJSONSchemaV1 and StandardSchemaV1.Options { libraryOptions? } (so validate takes a second argument). Updating it is a prerequisite for §4 and touches a type every validator in the repo is checked against — it should be its own commit, landed before the converter work, with the existing bunValidate.libraries.type-test.ts as the net.

  15. The companion spec standardises the interface, not the output. standard-schema#147 is open and unanswered: vendors disagree on defaults and on whether required is emitted, and the draft-07/draft-2020-12 targets pin no common feature subset. So §4.2's matrix does not go away just because step 2 of the resolution order usually succeeds — the agreement tests of §9.3 are what keep it honest. Related: zod does not throw on an unsupported target (measured), though the spec says it should, so a successful return is not proof the dialect was honoured.

  16. Unverified. Stated plainly: (a) SwaggerModule.setup() has not been executed against BunHttpAdapter — verified 2026-10-25 (phase (d), §8.6): executed end-to-end, document byte-identical to platform-express's, every probe answering alike, on 12.0.4/Nest 12.2.0, 11.4.6/Nest 11.2.7 and 12.0.4/Nest 12.0.0; (b) whether Nest controller paths in BunRouter.routes() already carry setGlobalPrefix — measured 2026-10-25: yes, listRoutes sees GET /v1/users/:id (Nest bakes the prefix into the registration); (c) whether @nestjs/swagger's scanner needs anything from app.container that bun-nest's adapter influences — measured 2026-10-25: no, the scanner reads Nest's own container (container.getModules(), isGlobalModule) and app.config; an app mounting the jobs API module and a WebSocket gateway scans exactly the controllers; (d) the OpenAPI 3.1.2 / AsyncAPI 3.1.0 version facts in §9.1 are from research, not from a fetch I performed — re-check them against spec.openapis.org and @asyncapi/specs before vendoring; (e) TypeBox is widely claimed to ship ~standard and reportedly does not (no ~standard in either typebox or @sinclair/typebox published files) — it needs no converter anyway, since a TSchema is a JSON Schema, but the vendor string is unknown.


11. Phased delivery

Each phase is independently shippable and independently valuable. Estimates are engineer-days for someone who wrote this code.

Phase Scope Ships Est.
(a) Route metadata + manual annotation → OpenAPI — merged 2026-10-25, #531 ConfigError in bun-common; move spec/refs.ts and spec/security.ts from bun-jobs (re-export shims left behind); lib/docs/ with RouteDoc, .describe(), the WeakMap keyed on the terminal callback, DiscoveredRoute V12's listRoutes (subsumed), path→template with the wildcard/optional/regex rules of §10.2, the generator, strict/lenient + warnings; OAS 3.1 meta-schema test; golden fixture; type tests; examples/bun-common/13-api-docs/annotating-routes.ts A working OpenAPI 3.1 document from any BunRouter, hand-annotated. bun-jobs unaffected except for two import moves 6–8
(a2) defineRoute-style registrar (optional, same phase) — merged 2026-10-26, #534 A routes([...]) helper over .describe() for people who prefer bun-jobs' shape — built as defineRoutes(mount, build) (the rename to the typed-client plan's §8.5 shape, and its names, approved 2026-10-25/26: defineRoutes, RoutesOf, RouteDef, the return's .mount/.router fields, the six common verbs on the builder, no options on the inner router). lib/defineRoutes.ts at bun-common's root (RoutesOf ships now, pure type, TC's packaging table expects it there); each definition registers on a fresh BunRouter and its property name becomes the operationId through .describe()'s own store; app.use(users.mount, users.router) mounts it as an ordinary sub-router. No router or overload change — generate-verb-overloads.ts --check stays green Ergonomics, no new capability; the registry exists for inference (TC's D3), not instead of generation 1–2
(b0) Update the vendored spec types — merged 2026-10-24, #527 lib/types/standardSchema.ts → @standard-schema/spec 1.1.0: StandardTypedV1, StandardJSONSchemaV1, StandardSchemaV1.Options. Its own commit, before (b) The type surface every validator is checked against, current 0.5–1
(b) Standard Schema auto-derivation — merged 2026-10-25, #529 __schemas on the validator middleware (superseded — the contracts of V1 and V12's listRoutes already carry the schemas; decided 2026-10-24); the ~standard.jsonSchema primary path; the vendor registry; zod/arktype/yup/bun-jobs fallbacks; valibotConverter(toJsonSchema); withJsonSchema; the $defs→components lift with recursion rewriting; the agreement tests of §9.3; from-validators.ts example The headline feature. Existing validated routes document themselves 4–6
(b1) Response contracts — merged as bun-validate's V3a, #482 (§3.6's note) responses on BunValidateOptions; BunResponse gains TResponses, status() narrows, json() constrains, protected setStatusRaw (§3.7); TypedRouteHandler/MountedHandler thread TRes; runtime checking at response finalisation with bun-jobs' responseMismatch semantics (§3.5); responses.type-test.ts carrying the spike's four negative controls plus the send()/helper-escape boundary cases; generate-verb-overloads.ts --check must stay green; consumer-check re-run for the BunResponse surface change Declared responses enforced at compile time and optionally at runtime. Feeds (a)'s document for free 4–5
(b2) Class DTOs — merged 2026-10-26, #535 (names approved 2026-10-26, all final; as-built corrections in §3.6's header block) useClassDtos / fromDto (§3.6); widen the six ValidationSchemas keys (V2 made them six), ResponseSpec's schema positions, and RouteDoc.request.*/RouteResponseDoc.schema to StandardSchemaV1 | DtoClass; a "class-validator" converter reading the injected toJsonSchema through a WeakMap<StandardSchemaV1, DtoClass>; class-validator 0.15.1 + class-transformer 0.5.1 + class-validator-jsonschema 5.2.0 as devDependencies of bun-common for the tests (pinned exact); an unregistered adapter is a ConfigError naming useClassDtos; test isolation by the internal, unexported resetClassDtos() (with an internal unregisterJsonSchemaConverter); the experimentalDecorators-off negative test proving the documented crash (a child-process fixture with its own tsconfig, run as bun dto.ts with the fixture dir as cwd — Bun reads tsconfig from the cwd, oven-sh/bun#28605) plus its control; bun-nest untouched on purpose DTO users reach both validation and the document 2–3
(c) Serving and viewing — merged 2026-10-25, #532 docsRouter() (names approved 2026-10-25: docsRouter, DocsRouterOptions, DocsCdnOptions, docsPageHeaders, swaggerUiPage, asyncApiUiPage, resolveDocsCdn, ResolvedDocsCdn, PinnedAsset, AssetIntegrity, assetUrl, bun-common's own DOCS_CDN); docs/html.ts + docs/cdn.ts moved to lib/docs/viewer.ts parameterised (pins injected, requestHeaders generalising the CSRF line, the document URL resolved per request from req.baseUrl); the viewer option with five pinned viewers (§7.1's as-built note — Scalar the OpenAPI default, "swagger-ui"/"redoc"/"rapidoc", the official component the AsyncAPI default); the ./lib/docs/types browser-safe entry (OpenApiDocument + JsonSchemaLike) with its consumer-check cell and bundle-safety test; CSP/nonce/SRI tests from api-docs.test.ts plus the hidden/authorize/req.baseUrl/thunk tests; every docs route .describe({ hidden: true }) (as-built); serving-and-viewing.ts example; the playground mount. bun-jobs keeps its own pins, shims and routes (routes/docs.ts needed no change); its api-docs.test.ts passes unmodified Documents served and browsable, pages off by default 3–4
(d) bun-nest — verify @nestjs/swagger — merged 2026-10-26, #533 Executed SwaggerModule.setup() against BunHttpAdapter end-to-end (__tests__/swagger.nest.test.ts, differential against platform-express; the checkout's Nest 12.2.0 with swagger 12.0.4, and the 11.2.7 and 12.0.0 nest-matrix columns, swagger pinned per major by NEST_PACKAGE_OVERRIDES); the §8.6 gaps closed with measured answers; examples/bun-nest/08-openapi/swagger-module.ts; the README section (recipe, mixed-app caveat, setGlobalPrefix, the @Render note, the two-bundles note). No hole found, so no ./lib/swagger helper A documented, proven path. Cheapest phase per unit of value — and the phase that decides whether (g) is worth building 2–3
(g) bun-nest swagger bridge — dropped 2026-11-10 (the user's decision) (the validation half shipped: @Validate landed in bun-validate's V7a/V7b) What remains is useSwaggerBridge alone (§3.8.2): emitting ApiBody/ApiQuery/ApiParam/ApiHeader/ApiResponse from @Validate's schemas through the §4 registry at target: "openapi-3.0", the decorators injected structurally. The interceptor, the decorator's method/class forms, the return-type constraint, the Infer<> helper, examples/bun-nest/08-validate/ — shipped with V7a/V7b (07-validation/) One declaration for validation + response contract + document. Gate on (d) — see risk 1h 2–3
(e) AsyncAPI 3.0 — merged 2026-10-26, #539 (the maintainer's decisions of 2026-10-26, all final; the names recorded in the status header) Public WS route accessor on BunWebSocket (wsRoutes(), read-only); WsChannelDoc + describeChannel() (re-scoped: raw-ws() enrichment — defineChannel documents itself); the bun-common generator generateAsyncApi (servers/channels/parameters/messages/operations; the frame-envelope payloads; the SSE channels and the x-bun-common-sse-fallback cross-reference); bun-nest's gatewayAsyncApi metadata explorer reading GATEWAY_OPTIONS/PORT_METADATA/MESSAGE_METADATA, the MessageEventTypes envelope and the new "bun:validate:message" metadata; the AsyncAPI 3.0.0 meta-schema test via @asyncapi/specs; examples AsyncAPI for both adapters 7–9
(f) Generic self-hosted viewer — merged 2026-10-27, #541 and #542 (F1: @kingsleyweb/bun-api-docs-ui, the package, merged #541, since moved into bun-ui's /docs by #709; F2, merged #542: the "self-hosted" docsRouter viewer + bun-jobs-ui's migration) Extracted bun-jobs-ui/app/screens/docs/** into @kingsleyweb/bun-api-docs-ui (the models read x-* extensions through a configurable prefix list, ["x-bun-common", "x-bun-jobs"] by default); the "self-hosted" viewer name on docsRouter serves the package's bundle from the mount's own origin (optional peer, SRI over the local bytes, the strict self-only CSP); bun-jobs-ui renders the package's screens with its seams injected (the Events-console links, the contract's address rules, the permission probe, the limits vocabulary); the golden WS fixtures kept netting, unchanged An offline, CSP-strict viewer and one fewer fork 10–14

Why this order (it differs from the suggested one in one place):

  • (a) before (b) because (b) is worthless without a place to put the schemas, and because (a) alone already ships a document.
  • (c) before (d) because a document nobody can look at gets no bug reports, and the viewer is copied code with existing tests.
  • (d) before (e), against the suggested ordering. (d) is two to three days and, on the evidence in §8.6, is mostly verification; (e) is the largest phase and needs a new declaration API. Doing the cheap, high-confidence one first means bun-nest users are served before the WS design is finished.
  • (e) last because it is the only phase whose design is still open: whether outbound events are declared per-channel or per-emit, and whether bun-nest's socket.io envelope is described as the payload or peeled off, are both genuinely undecided.
  • (f) is deliberately outside the sequence. It is a UI project with a package boundary and a bun-jobs-ui migration, and none of (a)–(e) needs it.
  • (b1) sits where it does because it is the enforcement half of (a)'s declaration half, and because it is the one change touching a public type (BunResponse). Doing it immediately after (b), while the response design is fresh and before anything is built on the current dts/, is much cheaper than retrofitting it after (c)–(e) ship.
  • (b2) after (b1), not before, because responses must accept a DTO on the day DTOs land, and widening ValidationSchemas once is cheaper than twice.
  • (g) is gated on (d), deliberately — and since @Validate's validation half shipped in bun-validate's V7a/V7b, "(g)" now means useSwaggerBridge alone. (d) will show whether @nestjs/swagger plus a ValidationPipe already covers what a bun-nest user wants. If it does, the bridge should be dropped, not shipped for symmetry — risk 1h. If it is built, it is a thin shell: §4's registry plus a dialect argument.

Revised total for the enforcement work the maintainer asked for — (b1) + (b2) + (g) — is 11–15 days, of which (g) is the half most likely to be cut. (b1) is the one I would ship regardless: it is the only item here that is both fully spiked and independently valuable without any of the others.

Suggested first commit, if only one thing is done: the __schemas property on BunValidate's middleware — overtaken: the contracts of V1 and V12's listRoutes already carry the schemas (decided 2026-10-24, when (b) was built without __schemas). The cheapest remaining first step of this plan is (a)'s ConfigError plus the lib/docs/ generator skeleton. — built: (a) landed 2026-10-25.


Appendix: evidence

The feasibility claims in §3.6–§3.8 were spiked, not reasoned about. The spikes are in evidence/, with a README explaining which run standalone and which need a copy of bun-common carrying the §3.7 changes.

They settle four things that could not be settled by reading: a chained .describe() cannot constrain a handler already passed to .get(); Bun does honour emitDecoratorMetadata; the full class-validator plus class-transformer stack works under Bun; and a Nest method decorator can constrain a return type but cannot retype a parameter.

The pass condition for the patched-library set is tsc -p exiting 0, which proves both halves at once — an unsatisfied @ts-expect-error is itself an error, so a clean run means the library compiles and every negative control fires. If you edit these, keep the controls; a green run without them proves nothing.