@nifrajs/core 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/diff.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * API breaking-change detection over route reflection.
3
+ *
4
+ * `snapshotRoutes` turns an app (or reflected descriptors) into a plain-JSON snapshot — the
5
+ * `standard` validators are dropped, so a snapshot survives `JSON.stringify` and can be committed
6
+ * as a CI baseline. `diffRouteSnapshots` compares two snapshots and classifies every change by
7
+ * wire compatibility, direction-aware:
8
+ *
9
+ * request direction (body/query — what clients SEND): a new required field, a removed field, or
10
+ * a narrowed type breaks existing callers; widening (new optional field, enum superset,
11
+ * required→optional) is compatible.
12
+ * response direction (response/sse/errors — what clients RECEIVE): a removed field, a field made
13
+ * optional, or a widened type breaks existing readers; narrowing (new field, optional→required,
14
+ * enum subset) is compatible.
15
+ *
16
+ * The classifier FAILS CLOSED: a schema change it cannot prove compatible is reported as breaking.
17
+ * Schemas without JSON Schema metadata (validation-only Standard Schemas) cannot be compared and
18
+ * yield `info` — never a silent pass presented as proof.
19
+ */
20
+
21
+ import {
22
+ type JsonSchema,
23
+ type ReflectedRoute,
24
+ type ReflectedSchemaField,
25
+ reflectRoutes,
26
+ } from "./reflection.ts"
27
+
28
+ /** One schema position in a snapshot: JSON Schema metadata only, no validator. */
29
+ export interface SchemaSnapshot {
30
+ readonly jsonSchema?: JsonSchema
31
+ readonly fields?: readonly ReflectedSchemaField[]
32
+ }
33
+
34
+ export interface RouteSnapshotSchema {
35
+ readonly body?: SchemaSnapshot
36
+ readonly query?: SchemaSnapshot
37
+ readonly response?: SchemaSnapshot
38
+ readonly sse?: SchemaSnapshot
39
+ readonly errors?: Readonly<Record<string, SchemaSnapshot>>
40
+ }
41
+
42
+ /** One route in a snapshot — plain JSON, safe to persist as a CI baseline. */
43
+ export interface RouteSnapshot {
44
+ readonly method: string
45
+ readonly path: string
46
+ readonly schema?: RouteSnapshotSchema
47
+ }
48
+
49
+ export type DiffSeverity = "breaking" | "compatible" | "info"
50
+
51
+ export interface RouteChange {
52
+ readonly severity: DiffSeverity
53
+ readonly method: string
54
+ readonly path: string
55
+ /** Which part of the contract changed; "route" for add/remove of the whole route. */
56
+ readonly section: "route" | "body" | "query" | "response" | "sse" | "errors"
57
+ /** The top-level field (or error status) the change is about, when field-granular. */
58
+ readonly field?: string
59
+ readonly message: string
60
+ }
61
+
62
+ export interface RoutesDiff {
63
+ readonly changes: readonly RouteChange[]
64
+ /** True when any change is `breaking` — the CI-gate signal. */
65
+ readonly hasBreaking: boolean
66
+ }
67
+
68
+ const schemaSnapshot = (
69
+ reflection:
70
+ | {
71
+ readonly jsonSchema?: JsonSchema | undefined
72
+ readonly fields?: readonly ReflectedSchemaField[] | undefined
73
+ }
74
+ | undefined,
75
+ ): SchemaSnapshot | undefined => {
76
+ if (reflection === undefined) return undefined
77
+ return {
78
+ ...(reflection.jsonSchema !== undefined ? { jsonSchema: reflection.jsonSchema } : {}),
79
+ ...(reflection.fields !== undefined ? { fields: reflection.fields } : {}),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Snapshot an app's routes (anything `reflectRoutes` accepts) as plain JSON. Validators are
85
+ * dropped; only introspectable JSON Schema metadata is kept, so the result round-trips through
86
+ * `JSON.stringify` unchanged.
87
+ */
88
+ export function snapshotRoutes(source: unknown): readonly RouteSnapshot[] {
89
+ return reflectRoutes(source).map((route) => snapshotRoute(route))
90
+ }
91
+
92
+ const snapshotRoute = (route: ReflectedRoute): RouteSnapshot => {
93
+ const schema = route.schema
94
+ if (schema === undefined) return { method: route.method, path: route.path }
95
+ const errors: Record<string, SchemaSnapshot> = {}
96
+ for (const [status, reflection] of Object.entries(schema.errors ?? {})) {
97
+ const snapped = schemaSnapshot(reflection)
98
+ if (snapped !== undefined) errors[status] = snapped
99
+ }
100
+ const body = schemaSnapshot(schema.body)
101
+ const query = schemaSnapshot(schema.query)
102
+ const response = schemaSnapshot(schema.response)
103
+ const sse = schemaSnapshot(schema.sse)
104
+ const snapped: RouteSnapshotSchema = {
105
+ ...(body !== undefined ? { body } : {}),
106
+ ...(query !== undefined ? { query } : {}),
107
+ ...(response !== undefined ? { response } : {}),
108
+ ...(sse !== undefined ? { sse } : {}),
109
+ ...(Object.keys(errors).length > 0 ? { errors } : {}),
110
+ }
111
+ return {
112
+ method: route.method,
113
+ path: route.path,
114
+ ...(Object.keys(snapped).length > 0 ? { schema: snapped } : {}),
115
+ }
116
+ }
117
+
118
+ type Direction = "request" | "response"
119
+
120
+ const jsonEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
121
+
122
+ const enumOf = (schema: JsonSchema | undefined): readonly unknown[] | undefined => {
123
+ if (typeof schema !== "object" || schema === null) return undefined
124
+ const candidate = (schema as { enum?: unknown }).enum
125
+ return Array.isArray(candidate) ? candidate : undefined
126
+ }
127
+
128
+ const withoutEnum = (schema: JsonSchema): unknown => {
129
+ if (typeof schema !== "object" || schema === null) return schema
130
+ const { enum: _omitted, ...rest } = schema as Record<string, unknown>
131
+ return rest
132
+ }
133
+
134
+ const isSuperset = (outer: readonly unknown[], inner: readonly unknown[]): boolean => {
135
+ const keys = new Set(outer.map((value) => JSON.stringify(value)))
136
+ return inner.every((value) => keys.has(JSON.stringify(value)))
137
+ }
138
+
139
+ /**
140
+ * Compare two JSON Schemas for one position. Returns the change severity, or `undefined` when
141
+ * they are equivalent. The only refinement proven compatible is an enum-only change in the
142
+ * safe direction (request may widen, response may narrow); everything else fails closed.
143
+ */
144
+ const compareJsonSchema = (
145
+ direction: Direction,
146
+ before: JsonSchema,
147
+ after: JsonSchema,
148
+ ): DiffSeverity | undefined => {
149
+ if (jsonEqual(before, after)) return undefined
150
+ const beforeEnum = enumOf(before)
151
+ const afterEnum = enumOf(after)
152
+ if (
153
+ beforeEnum !== undefined &&
154
+ afterEnum !== undefined &&
155
+ jsonEqual(withoutEnum(before), withoutEnum(after))
156
+ ) {
157
+ const compatible =
158
+ direction === "request"
159
+ ? isSuperset(afterEnum, beforeEnum)
160
+ : isSuperset(beforeEnum, afterEnum)
161
+ return compatible ? "compatible" : "breaking"
162
+ }
163
+ return "breaking"
164
+ }
165
+
166
+ interface SectionContext {
167
+ readonly method: string
168
+ readonly path: string
169
+ readonly section: RouteChange["section"]
170
+ readonly direction: Direction
171
+ readonly changes: RouteChange[]
172
+ }
173
+
174
+ const push = (
175
+ ctx: SectionContext,
176
+ severity: DiffSeverity,
177
+ message: string,
178
+ field?: string,
179
+ ): void => {
180
+ ctx.changes.push({
181
+ severity,
182
+ method: ctx.method,
183
+ path: ctx.path,
184
+ section: ctx.section,
185
+ ...(field !== undefined ? { field } : {}),
186
+ message,
187
+ })
188
+ }
189
+
190
+ const fieldMap = (
191
+ snapshot: SchemaSnapshot,
192
+ ): ReadonlyMap<string, ReflectedSchemaField> | undefined => {
193
+ if (snapshot.fields === undefined) return undefined
194
+ return new Map(snapshot.fields.map((field) => [field.name, field]))
195
+ }
196
+
197
+ const diffFields = (ctx: SectionContext, before: SchemaSnapshot, after: SchemaSnapshot): void => {
198
+ const beforeFields = fieldMap(before)
199
+ const afterFields = fieldMap(after)
200
+ if (beforeFields === undefined || afterFields === undefined) {
201
+ // Non-object schemas: compare the whole JSON Schema in one step.
202
+ const severity = compareJsonSchema(
203
+ ctx.direction,
204
+ before.jsonSchema as JsonSchema,
205
+ after.jsonSchema as JsonSchema,
206
+ )
207
+ if (severity !== undefined) push(ctx, severity, `${ctx.section} schema changed`)
208
+ return
209
+ }
210
+ for (const [name, beforeField] of beforeFields) {
211
+ const afterField = afterFields.get(name)
212
+ if (afterField === undefined) {
213
+ // Request: strict validators reject payloads still sending the field. Response: readers lose it.
214
+ push(ctx, "breaking", `field "${name}" removed`, name)
215
+ continue
216
+ }
217
+ if (beforeField.required !== afterField.required) {
218
+ const nowRequired = afterField.required
219
+ const breaking = ctx.direction === "request" ? nowRequired : !nowRequired
220
+ push(
221
+ ctx,
222
+ breaking ? "breaking" : "compatible",
223
+ `field "${name}" is now ${nowRequired ? "required" : "optional"}`,
224
+ name,
225
+ )
226
+ }
227
+ const severity = compareJsonSchema(ctx.direction, beforeField.schema, afterField.schema)
228
+ if (severity !== undefined) push(ctx, severity, `field "${name}" type changed`, name)
229
+ }
230
+ for (const [name, afterField] of afterFields) {
231
+ if (beforeFields.has(name)) continue
232
+ if (ctx.direction === "request") {
233
+ push(
234
+ ctx,
235
+ afterField.required ? "breaking" : "compatible",
236
+ `${afterField.required ? "required" : "optional"} field "${name}" added`,
237
+ name,
238
+ )
239
+ } else {
240
+ push(ctx, "compatible", `field "${name}" added`, name)
241
+ }
242
+ }
243
+ }
244
+
245
+ const diffSchemaSection = (
246
+ ctx: SectionContext,
247
+ before: SchemaSnapshot | undefined,
248
+ after: SchemaSnapshot | undefined,
249
+ ): void => {
250
+ if (before === undefined && after === undefined) return
251
+ if (before === undefined) {
252
+ // A new request contract rejects payloads old clients send freely; a new response contract
253
+ // only documents what was already returned.
254
+ push(
255
+ ctx,
256
+ ctx.direction === "request" ? "breaking" : "compatible",
257
+ `${ctx.section} schema added`,
258
+ )
259
+ return
260
+ }
261
+ if (after === undefined) {
262
+ // Dropping a request contract loosens validation; dropping a response contract removes a
263
+ // documented shape clients (and typed consumers) rely on.
264
+ push(
265
+ ctx,
266
+ ctx.direction === "request" ? "compatible" : "breaking",
267
+ `${ctx.section} schema removed`,
268
+ )
269
+ return
270
+ }
271
+ if (before.jsonSchema === undefined || after.jsonSchema === undefined) {
272
+ // Validation-only schemas expose no JSON Schema metadata — nothing provable either way.
273
+ if (before.jsonSchema !== after.jsonSchema || before.fields !== after.fields) {
274
+ push(ctx, "info", `${ctx.section} schema is not introspectable — cannot verify compatibility`)
275
+ }
276
+ return
277
+ }
278
+ diffFields(ctx, before, after)
279
+ }
280
+
281
+ const diffErrors = (
282
+ method: string,
283
+ path: string,
284
+ changes: RouteChange[],
285
+ before: Readonly<Record<string, SchemaSnapshot>> | undefined,
286
+ after: Readonly<Record<string, SchemaSnapshot>> | undefined,
287
+ ): void => {
288
+ const beforeEntries = before ?? {}
289
+ const afterEntries = after ?? {}
290
+ for (const [status, beforeSchema] of Object.entries(beforeEntries)) {
291
+ const afterSchema = afterEntries[status]
292
+ if (afterSchema === undefined) {
293
+ // Clients parsing this structured error body lose a documented shape.
294
+ changes.push({
295
+ severity: "breaking",
296
+ method,
297
+ path,
298
+ section: "errors",
299
+ field: status,
300
+ message: `error ${status} removed from the contract`,
301
+ })
302
+ continue
303
+ }
304
+ const ctx: SectionContext = { method, path, section: "errors", direction: "response", changes }
305
+ if (beforeSchema.jsonSchema === undefined || afterSchema.jsonSchema === undefined) {
306
+ if (!jsonEqual(beforeSchema, afterSchema)) {
307
+ push(ctx, "info", `error ${status} schema is not introspectable`, status)
308
+ }
309
+ continue
310
+ }
311
+ const severity = compareJsonSchema("response", beforeSchema.jsonSchema, afterSchema.jsonSchema)
312
+ if (severity !== undefined) push(ctx, severity, `error ${status} schema changed`, status)
313
+ }
314
+ for (const status of Object.keys(afterEntries)) {
315
+ if (status in beforeEntries) continue
316
+ changes.push({
317
+ severity: "compatible",
318
+ method,
319
+ path,
320
+ section: "errors",
321
+ field: status,
322
+ message: `error ${status} added to the contract`,
323
+ })
324
+ }
325
+ }
326
+
327
+ const routeKey = (route: RouteSnapshot): string => `${route.method.toUpperCase()} ${route.path}`
328
+
329
+ /**
330
+ * Diff two route snapshots (`snapshotRoutes` output, possibly restored from JSON). Every change is
331
+ * classified breaking/compatible/info; `hasBreaking` is the CI-gate bit.
332
+ */
333
+ export function diffRouteSnapshots(
334
+ before: readonly RouteSnapshot[],
335
+ after: readonly RouteSnapshot[],
336
+ ): RoutesDiff {
337
+ const changes: RouteChange[] = []
338
+ const afterByKey = new Map(after.map((route) => [routeKey(route), route]))
339
+ const beforeKeys = new Set(before.map((route) => routeKey(route)))
340
+ for (const beforeRoute of before) {
341
+ const afterRoute = afterByKey.get(routeKey(beforeRoute))
342
+ if (afterRoute === undefined) {
343
+ changes.push({
344
+ severity: "breaking",
345
+ method: beforeRoute.method,
346
+ path: beforeRoute.path,
347
+ section: "route",
348
+ message: "route removed",
349
+ })
350
+ continue
351
+ }
352
+ const method = beforeRoute.method
353
+ const path = beforeRoute.path
354
+ const beforeSchema = beforeRoute.schema ?? {}
355
+ const afterSchema = afterRoute.schema ?? {}
356
+ for (const section of ["body", "query"] as const) {
357
+ diffSchemaSection(
358
+ { method, path, section, direction: "request", changes },
359
+ beforeSchema[section],
360
+ afterSchema[section],
361
+ )
362
+ }
363
+ for (const section of ["response", "sse"] as const) {
364
+ diffSchemaSection(
365
+ { method, path, section, direction: "response", changes },
366
+ beforeSchema[section],
367
+ afterSchema[section],
368
+ )
369
+ }
370
+ diffErrors(method, path, changes, beforeSchema.errors, afterSchema.errors)
371
+ }
372
+ for (const afterRoute of after) {
373
+ if (beforeKeys.has(routeKey(afterRoute))) continue
374
+ changes.push({
375
+ severity: "compatible",
376
+ method: afterRoute.method,
377
+ path: afterRoute.path,
378
+ section: "route",
379
+ message: "route added",
380
+ })
381
+ }
382
+ return { changes, hasBreaking: changes.some((change) => change.severity === "breaking") }
383
+ }
package/src/index.ts CHANGED
@@ -12,12 +12,14 @@
12
12
  * (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
13
13
  * consumers can pin it at the type level — see `test/version.test-d.ts`.
14
14
  */
15
- export const VERSION = "1.3.1" as const
15
+ export const VERSION = "1.5.0" as const
16
16
 
17
17
  export type Version = typeof VERSION
18
18
 
19
+ export * from "./diff.ts"
19
20
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
20
21
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
22
+ export * from "./reflection.ts"
21
23
  export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
22
24
  export type {
23
25
  InferInput,
@@ -91,6 +93,7 @@ export {
91
93
  type OnRequestResult,
92
94
  type PromptArgument,
93
95
  type PromptMessage,
96
+ type ResponseFinalization,
94
97
  type RouteDescriptor,
95
98
  type RunningServer,
96
99
  type ScheduledController,
@@ -107,6 +110,8 @@ export {
107
110
  type SSEMessage,
108
111
  type SSEStream,
109
112
  sse,
113
+ type TypedSSEStream,
114
+ typedSSEStream,
110
115
  } from "./server/sse.ts"
111
116
  export {
112
117
  type SignatureEncoding,
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Runtime reflection for schemas and registered routes.
3
+ *
4
+ * Standard Schema deliberately standardizes validation, not introspection. This module makes that
5
+ * distinction explicit: `standard` is present when a value can validate, while `jsonSchema` and
6
+ * `fields` are present only when the value carries inspectable JSON Schema metadata. The implementation
7
+ * recognizes Nifra/TypeBox carriers and raw JSON Schema without depending on a validator package.
8
+ */
9
+
10
+ import type { StandardSchemaV1 } from "./schema/standard.ts"
11
+ import type { ToolAnnotations } from "./server/server.ts"
12
+
13
+ /** JSON Schema permits either a schema object or the boolean schemas `true` and `false`. */
14
+ export type JsonSchema = boolean | Readonly<Record<string, unknown>>
15
+
16
+ /** One top-level property of an introspectable object schema. */
17
+ export interface ReflectedSchemaField {
18
+ readonly name: string
19
+ readonly required: boolean
20
+ readonly schema: JsonSchema
21
+ }
22
+
23
+ /** Validation and introspection capabilities discovered for one schema-like value. */
24
+ export interface SchemaReflection {
25
+ /** The Standard Schema validator, when the value implements Standard Schema v1. */
26
+ readonly standard: StandardSchemaV1 | undefined
27
+ /** Raw JSON Schema metadata, when the value exposes it or is itself a raw JSON Schema. */
28
+ readonly jsonSchema: JsonSchema | undefined
29
+ /** Top-level object fields, or `undefined` when the JSON Schema is absent/non-object. */
30
+ readonly fields: readonly ReflectedSchemaField[] | undefined
31
+ }
32
+
33
+ export interface ReflectedRouteSchema {
34
+ readonly body?: SchemaReflection
35
+ readonly query?: SchemaReflection
36
+ readonly response?: SchemaReflection
37
+ readonly errors?: Readonly<Record<string, SchemaReflection>>
38
+ /** The SSE event-payload schema of a typed streaming route (`app.sse()`). */
39
+ readonly sse?: SchemaReflection
40
+ }
41
+
42
+ export interface ReflectedRoute {
43
+ readonly method: string
44
+ readonly path: string
45
+ readonly schema?: ReflectedRouteSchema
46
+ readonly tool?: {
47
+ readonly name: string
48
+ readonly description: string
49
+ readonly annotations?: ToolAnnotations
50
+ }
51
+ }
52
+
53
+ const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
54
+ value !== null && typeof value === "object"
55
+ ? (value as Readonly<Record<string, unknown>>)
56
+ : undefined
57
+
58
+ const standardOf = (value: unknown): StandardSchemaV1 | undefined => {
59
+ const record = recordOf(value)
60
+ const standard = recordOf(record?.["~standard"])
61
+ return standard?.version === 1 && typeof standard.validate === "function"
62
+ ? (value as StandardSchemaV1)
63
+ : undefined
64
+ }
65
+
66
+ const jsonSchemaOf = (
67
+ value: unknown,
68
+ standard: StandardSchemaV1 | undefined,
69
+ ): JsonSchema | undefined => {
70
+ const record = recordOf(value)
71
+ if (record !== undefined && "jsonSchema" in record) {
72
+ const carried = record.jsonSchema
73
+ return normalizeJsonSchema(carried)
74
+ }
75
+ // Some Standard Schema implementations (including TypeBox-shaped adapters) expose an object
76
+ // schema directly rather than through `.jsonSchema`. Only treat an inspectable object shape as
77
+ // metadata here; validation-library internals are otherwise intentionally opaque.
78
+ if (standard !== undefined) {
79
+ return recordOf(record?.properties) !== undefined ? normalizeJsonSchema(record) : undefined
80
+ }
81
+ // A malformed/opaque Standard Schema marker is not raw JSON Schema metadata.
82
+ if (record !== undefined && "~standard" in record) return undefined
83
+ if (typeof value === "boolean" || record !== undefined) return normalizeJsonSchema(value)
84
+ return undefined
85
+ }
86
+
87
+ /** Strip symbol keys and other non-JSON metadata carried by schema-builder objects. */
88
+ const normalizeJsonSchema = (value: unknown): JsonSchema | undefined => {
89
+ if (typeof value === "boolean") return value
90
+ if (recordOf(value) === undefined) return undefined
91
+ try {
92
+ const normalized: unknown = JSON.parse(JSON.stringify(value))
93
+ if (typeof normalized === "boolean") return normalized
94
+ const record = recordOf(normalized)
95
+ if (record === undefined) return undefined
96
+ // `~standard` is a Standard Schema *validation* marker, never valid JSON Schema. A TypeBox-shaped
97
+ // carrier (a JSON Schema object that also carries `~standard`, with no separate `.jsonSchema`) would
98
+ // otherwise leak the marker into emitted OpenAPI / mock / form output.
99
+ if (!("~standard" in record)) return record
100
+ const { ["~standard"]: _omitted, ...rest } = record
101
+ return rest
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ const fieldsOf = (schema: JsonSchema | undefined): readonly ReflectedSchemaField[] | undefined => {
108
+ const record = recordOf(schema)
109
+ const properties = recordOf(record?.properties)
110
+ if (properties === undefined) return undefined
111
+ const required = new Set(
112
+ Array.isArray(record?.required)
113
+ ? record.required.filter((name): name is string => typeof name === "string")
114
+ : [],
115
+ )
116
+ const fields: ReflectedSchemaField[] = []
117
+ for (const [name, property] of Object.entries(properties)) {
118
+ if (typeof property !== "boolean" && recordOf(property) === undefined) continue
119
+ fields.push({ name, required: required.has(name), schema: property as JsonSchema })
120
+ }
121
+ return fields
122
+ }
123
+
124
+ /**
125
+ * Reflect a Standard Schema, Nifra/TypeBox schema carrier, or raw JSON Schema. Never throws.
126
+ * Validation-only schemas have `standard` but no `jsonSchema`; raw JSON Schema has the reverse.
127
+ */
128
+ export function reflectSchema(value: unknown): SchemaReflection {
129
+ const standard = standardOf(value)
130
+ const jsonSchema = jsonSchemaOf(value, standard)
131
+ return { standard, jsonSchema, fields: fieldsOf(jsonSchema) }
132
+ }
133
+
134
+ const routeCandidates = (source: unknown): readonly unknown[] => {
135
+ if (Array.isArray(source)) return source
136
+ const record = recordOf(source)
137
+ if (typeof record?.routes !== "function") return []
138
+ try {
139
+ const routes = record.routes()
140
+ return Array.isArray(routes) ? routes : []
141
+ } catch {
142
+ return []
143
+ }
144
+ }
145
+
146
+ const reflectedRouteSchema = (value: unknown): ReflectedRouteSchema | undefined => {
147
+ const schema = recordOf(value)
148
+ if (schema === undefined) return undefined
149
+ const errors = recordOf(schema.errors)
150
+ const reflectedErrors: Record<string, SchemaReflection> = {}
151
+ if (errors !== undefined) {
152
+ for (const [status, errorSchema] of Object.entries(errors)) {
153
+ reflectedErrors[status] = reflectSchema(errorSchema)
154
+ }
155
+ }
156
+ return {
157
+ ...(schema.body !== undefined ? { body: reflectSchema(schema.body) } : {}),
158
+ ...(schema.query !== undefined ? { query: reflectSchema(schema.query) } : {}),
159
+ ...(schema.response !== undefined ? { response: reflectSchema(schema.response) } : {}),
160
+ ...(Object.keys(reflectedErrors).length > 0 ? { errors: reflectedErrors } : {}),
161
+ ...(schema.sse !== undefined ? { sse: reflectSchema(schema.sse) } : {}),
162
+ }
163
+ }
164
+
165
+ const reflectedTool = (value: unknown): ReflectedRoute["tool"] => {
166
+ const tool = recordOf(value)
167
+ if (typeof tool?.name !== "string" || typeof tool.description !== "string") return undefined
168
+ return {
169
+ name: tool.name,
170
+ description: tool.description,
171
+ ...(recordOf(tool.annotations) !== undefined
172
+ ? { annotations: tool.annotations as ToolAnnotations }
173
+ : {}),
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Safely enumerate and normalize route descriptors from an app or descriptor array. Invalid entries
179
+ * are ignored; a missing/throwing `routes()` method yields an empty array.
180
+ */
181
+ export function reflectRoutes(source: unknown): readonly ReflectedRoute[] {
182
+ const reflected: ReflectedRoute[] = []
183
+ for (const candidate of routeCandidates(source)) {
184
+ const route = recordOf(candidate)
185
+ if (typeof route?.method !== "string" || typeof route.path !== "string") continue
186
+ const schema = reflectedRouteSchema(route.schema)
187
+ const tool = reflectedTool(route.tool)
188
+ reflected.push({
189
+ method: route.method.toUpperCase(),
190
+ path: route.path,
191
+ ...(schema !== undefined ? { schema } : {}),
192
+ ...(tool !== undefined ? { tool } : {}),
193
+ })
194
+ }
195
+ return reflected
196
+ }
@@ -39,6 +39,14 @@ export interface RouteSchema {
39
39
  * to tooling and coding agents. Like `response`, it is **not** validated at runtime (zero hot-path cost);
40
40
  * it's a compile-time + introspection contract. Example: `{ 404: NotFound, 409: Conflict }`. */
41
41
  readonly errors?: Readonly<Record<number, StandardSchemaV1>>
42
+ /**
43
+ * Optional **SSE event contract**: the Standard Schema of each event's `data` payload on a
44
+ * streaming route (declared via `app.sse()`). Marks the route as a typed event stream — the
45
+ * typed client grows a `.subscribe()` for it, and the schema flows into reflection. Like
46
+ * `response`, it is **not** validated at runtime (zero hot-path cost); the server-side
47
+ * `stream.send()` is compile-time-checked against it instead.
48
+ */
49
+ readonly sse?: StandardSchemaV1
42
50
  /**
43
51
  * Hook fired when the request fails `body`/`query` validation, before the handler runs. `kind` says
44
52
  * which input failed (`"body"` | `"query"`). Its return value selects one of three outcomes (may be async):
@@ -92,6 +92,9 @@ type RouteInfoForOp<O extends OperationDef> = {
92
92
  // The error union from the op's non-2xx `responses` — so a decoupled contract client sees typed error
93
93
  // bodies, just like an inline route's `errors`. `unknown` when the op declares no error responses.
94
94
  readonly errors: OpErrors<O>
95
+ // Contract operations cannot declare an SSE event contract (yet) — `never` keeps the contract
96
+ // registry mutually assignable with the inline registry's RouteInfoFor (mode conformance).
97
+ readonly sse: never
95
98
  }
96
99
 
97
100
  /** Re-key the name-keyed ops into the `path → method → RouteInfo` registry. */
@@ -191,6 +194,8 @@ export type RegistryFromImpl<C extends ContractShape, H extends HandlersFor<C>>
191
194
  // The error union from the op's non-2xx `responses` (see OpErrors) — a graduated contract client sees
192
195
  // the same typed error bodies as the inline `errors` path.
193
196
  readonly errors: OpErrors<C[K]>
197
+ // Mirrors RouteInfoForOp: no SSE contract on contract ops (yet) — mode conformance holds.
198
+ readonly sse: never
194
199
  }
195
200
  }
196
201
  }
@@ -19,6 +19,9 @@ export interface RouteInfo {
19
19
  /** Union of the route's declared error-response body types (from `schema.errors`); `unknown` when the
20
20
  * route declares none. Surfaced by the typed client as the failure `errorData`. */
21
21
  readonly errors?: unknown
22
+ /** The SSE event payload type (from `schema.sse`, declared via `app.sse()`); `never` for ordinary
23
+ * routes. The typed client keys `.subscribe()` availability and its event type off this. */
24
+ readonly sse?: unknown
22
25
  }
23
26
 
24
27
  /** The accumulated, type-level map of every route on a Server: path → method → RouteInfo. */
@@ -51,6 +54,11 @@ type RegistryErrors<S extends RouteSchema> = S extends {
51
54
  ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }[keyof E]
52
55
  : unknown
53
56
 
57
+ /** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */
58
+ type RegistrySse<S extends RouteSchema> = S extends { sse: infer E extends StandardSchemaV1 }
59
+ ? InferOutput<E>
60
+ : never
61
+
54
62
  /** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */
55
63
  export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
56
64
  readonly params: Params<Path>
@@ -58,6 +66,7 @@ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
58
66
  readonly body: RegistryBody<S>
59
67
  readonly output: RegistryOutput<S, Output>
60
68
  readonly errors: RegistryErrors<S>
69
+ readonly sse: RegistrySse<S>
61
70
  }
62
71
 
63
72
  /** The client-visible output of a handler: its awaited return, minus raw `Response`. */