@nifrajs/core 3.1.0 → 3.2.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.
Files changed (146) hide show
  1. package/README.md +21 -0
  2. package/dist/durable-adapters.d.ts.map +1 -1
  3. package/dist/durable-adapters.js +26 -3
  4. package/dist/durable-adapters.js.map +1 -1
  5. package/dist/durable-approval.d.ts +56 -0
  6. package/dist/durable-approval.d.ts.map +1 -0
  7. package/dist/durable-approval.js +233 -0
  8. package/dist/durable-approval.js.map +1 -0
  9. package/dist/durable-effect.d.ts +42 -0
  10. package/dist/durable-effect.d.ts.map +1 -0
  11. package/dist/durable-effect.js +234 -0
  12. package/dist/durable-effect.js.map +1 -0
  13. package/dist/durable-execution.d.ts +8 -346
  14. package/dist/durable-execution.d.ts.map +1 -1
  15. package/dist/durable-execution.js +7 -1150
  16. package/dist/durable-execution.js.map +1 -1
  17. package/dist/durable-saga.d.ts +94 -0
  18. package/dist/durable-saga.d.ts.map +1 -0
  19. package/dist/durable-saga.js +550 -0
  20. package/dist/durable-saga.js.map +1 -0
  21. package/dist/edge-kit.d.ts +2 -2
  22. package/dist/edge-kit.d.ts.map +1 -1
  23. package/dist/edge-kit.js +2 -2
  24. package/dist/edge-kit.js.map +1 -1
  25. package/dist/index.d.ts +23 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +18 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  30. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  31. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  33. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  34. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  35. package/dist/internal/durable-shared.d.ts +26 -0
  36. package/dist/internal/durable-shared.d.ts.map +1 -0
  37. package/dist/internal/durable-shared.js +131 -0
  38. package/dist/internal/durable-shared.js.map +1 -0
  39. package/dist/internal/durable-types.d.ts +159 -0
  40. package/dist/internal/durable-types.d.ts.map +1 -0
  41. package/dist/internal/durable-types.js +2 -0
  42. package/dist/internal/durable-types.js.map +1 -0
  43. package/dist/internal/route-compiler.d.ts +61 -0
  44. package/dist/internal/route-compiler.d.ts.map +1 -0
  45. package/dist/internal/route-compiler.js +121 -0
  46. package/dist/internal/route-compiler.js.map +1 -0
  47. package/dist/internal/route-execution.d.ts +6 -4
  48. package/dist/internal/route-execution.d.ts.map +1 -1
  49. package/dist/internal/route-execution.js +2 -17
  50. package/dist/internal/route-execution.js.map +1 -1
  51. package/dist/internal/route-lanes.d.ts +35 -0
  52. package/dist/internal/route-lanes.d.ts.map +1 -0
  53. package/dist/internal/route-lanes.js +150 -0
  54. package/dist/internal/route-lanes.js.map +1 -0
  55. package/dist/internal/route-program.d.ts +67 -0
  56. package/dist/internal/route-program.d.ts.map +1 -0
  57. package/dist/internal/route-program.js +199 -0
  58. package/dist/internal/route-program.js.map +1 -0
  59. package/dist/server/bare-error-lane.d.ts +1 -1
  60. package/dist/server/bare-error-lane.d.ts.map +1 -1
  61. package/dist/server/bare-error-lane.js +3 -3
  62. package/dist/server/bare-error-lane.js.map +1 -1
  63. package/dist/server/body-lane.d.ts +13 -0
  64. package/dist/server/body-lane.d.ts.map +1 -0
  65. package/dist/server/body-lane.js +134 -0
  66. package/dist/server/body-lane.js.map +1 -0
  67. package/dist/server/contract.d.ts +40 -14
  68. package/dist/server/contract.d.ts.map +1 -1
  69. package/dist/server/contract.js +7 -0
  70. package/dist/server/contract.js.map +1 -1
  71. package/dist/server/cookies.d.ts.map +1 -1
  72. package/dist/server/cookies.js +16 -2
  73. package/dist/server/cookies.js.map +1 -1
  74. package/dist/server/plugin.d.ts +2 -2
  75. package/dist/server/plugin.d.ts.map +1 -1
  76. package/dist/server/plugin.js +6 -1
  77. package/dist/server/plugin.js.map +1 -1
  78. package/dist/server/registry.d.ts +40 -12
  79. package/dist/server/registry.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts +4 -28
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/request-context.js +10 -185
  83. package/dist/server/request-context.js.map +1 -1
  84. package/dist/server/respond.d.ts.map +1 -1
  85. package/dist/server/respond.js +39 -2
  86. package/dist/server/respond.js.map +1 -1
  87. package/dist/server/runtime-core.d.ts +18 -2
  88. package/dist/server/runtime-core.d.ts.map +1 -1
  89. package/dist/server/runtime-core.js +0 -23
  90. package/dist/server/runtime-core.js.map +1 -1
  91. package/dist/server/server-types.d.ts +3 -0
  92. package/dist/server/server-types.d.ts.map +1 -1
  93. package/dist/server/server.d.ts +113 -84
  94. package/dist/server/server.d.ts.map +1 -1
  95. package/dist/server/server.js +506 -639
  96. package/dist/server/server.js.map +1 -1
  97. package/dist/server/validation.d.ts +5 -0
  98. package/dist/server/validation.d.ts.map +1 -0
  99. package/dist/server/validation.js +13 -0
  100. package/dist/server/validation.js.map +1 -0
  101. package/dist/server/ws-bun.d.ts +11 -3
  102. package/dist/server/ws-bun.d.ts.map +1 -1
  103. package/dist/server/ws-bun.js +42 -12
  104. package/dist/server/ws-bun.js.map +1 -1
  105. package/dist/server/ws-hook.d.ts +3 -2
  106. package/dist/server/ws-hook.d.ts.map +1 -1
  107. package/dist/server.d.ts +2 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +1 -1
  110. package/dist/server.js.map +1 -1
  111. package/dist/single-copy.d.ts.map +1 -1
  112. package/dist/single-copy.js +3 -1
  113. package/dist/single-copy.js.map +1 -1
  114. package/package.json +6 -3
  115. package/src/durable-adapters.ts +20 -0
  116. package/src/durable-approval.ts +281 -0
  117. package/src/durable-effect.ts +300 -0
  118. package/src/durable-execution.ts +8 -1627
  119. package/src/durable-saga.ts +744 -0
  120. package/src/edge-kit.ts +2 -2
  121. package/src/index.ts +129 -2
  122. package/src/internal/durable-approval-protocol.ts +1 -1
  123. package/src/internal/durable-effect-protocol.ts +1 -1
  124. package/src/internal/durable-saga-protocol.ts +1 -1
  125. package/src/internal/durable-shared.ts +157 -0
  126. package/src/internal/durable-types.ts +194 -0
  127. package/src/internal/route-compiler.ts +234 -0
  128. package/src/internal/route-execution.ts +34 -84
  129. package/src/internal/route-lanes.ts +221 -0
  130. package/src/internal/route-program.ts +404 -0
  131. package/src/server/bare-error-lane.ts +3 -2
  132. package/src/server/body-lane.ts +203 -0
  133. package/src/server/contract.ts +70 -20
  134. package/src/server/cookies.ts +16 -2
  135. package/src/server/plugin.ts +11 -5
  136. package/src/server/registry.ts +71 -16
  137. package/src/server/request-context.ts +7 -262
  138. package/src/server/respond.ts +45 -2
  139. package/src/server/runtime-core.ts +28 -2
  140. package/src/server/server-types.ts +3 -0
  141. package/src/server/server.ts +1152 -1260
  142. package/src/server/validation.ts +19 -0
  143. package/src/server/ws-bun.ts +44 -11
  144. package/src/server/ws-hook.ts +3 -2
  145. package/src/server.ts +7 -2
  146. package/src/single-copy.ts +3 -1
@@ -5,9 +5,11 @@ import { METHODS, type Method } from "../router/router.ts"
5
5
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
6
6
  import { assertByteLimit } from "./body.ts"
7
7
  import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
8
- import type { EmptyRegistry, OutputOf, Registry } from "./registry.ts"
8
+ import type { EmptyRegistry, OutputOf, Registry, RouteInfoFor } from "./registry.ts"
9
9
  import { Server } from "./server.ts"
10
10
 
11
+ type EmptyResponseMap = NonNullable<unknown>
12
+
11
13
  /** An additional (non-success) response a contract operation can document, e.g. a `404`. */
12
14
  export interface ResponseDef {
13
15
  readonly description?: string
@@ -88,7 +90,9 @@ type OpErrorBodies<Rs extends Record<string, ResponseDef>> = {
88
90
  [K in keyof Rs as K extends `2${string}`
89
91
  ? never
90
92
  : K extends `${infer N extends number}`
91
- ? N
93
+ ? Rs[K] extends { schema: StandardSchemaV1 }
94
+ ? N
95
+ : never
92
96
  : never]: Rs[K] extends {
93
97
  schema: infer S extends StandardSchemaV1
94
98
  }
@@ -106,6 +110,26 @@ type OpErrors<O extends OperationDef> = O extends {
106
110
  : OpErrorBodies<Rs>
107
111
  : unknown
108
112
 
113
+ /** The schema-bearing status map a contract consumer can use for success and failure narrowing. */
114
+ type OpAdditionalResponseMap<O extends OperationDef> = O extends {
115
+ responses: infer Rs extends Record<string, ResponseDef>
116
+ }
117
+ ? {
118
+ [K in keyof Rs as K extends `${infer N extends number}`
119
+ ? Rs[K] extends { schema: StandardSchemaV1 }
120
+ ? N
121
+ : never
122
+ : never]: Rs[K] extends { schema: infer S extends StandardSchemaV1 }
123
+ ? InferOutput<S>
124
+ : never
125
+ }
126
+ : EmptyResponseMap
127
+
128
+ type OpResponseMap<O extends OperationDef> = Omit<OpAdditionalResponseMap<O>, 200> &
129
+ (O extends { response: infer R extends StandardSchemaV1 }
130
+ ? { 200: InferOutput<R> }
131
+ : EmptyResponseMap)
132
+
109
133
  /**
110
134
  * RouteInfo as a *decoupled consumer* sees it from the contract alone: the
111
135
  * `output` is the declared `response` schema's type, or `unknown` when none is
@@ -118,6 +142,7 @@ type RouteInfoForOp<O extends OperationDef> = {
118
142
  readonly query: OpQuery<O>
119
143
  readonly body: OpBody<O>
120
144
  readonly output: OpResponse<O>
145
+ readonly responses: OpResponseMap<O>
121
146
  // The error union from the op's non-2xx `responses` - so a decoupled contract client sees typed error
122
147
  // bodies, just like an inline route's `errors`. `unknown` when the op declares no error responses.
123
148
  readonly errors: OpErrors<O>
@@ -143,6 +168,25 @@ type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends St
143
168
  : Record<never, never>) &
144
169
  (O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
145
170
 
171
+ type OpResponseSchemas<O extends OperationDef> = O extends {
172
+ responses: infer Rs extends Record<string, ResponseDef>
173
+ }
174
+ ? {
175
+ [K in keyof Rs as K extends `2${string}`
176
+ ? never
177
+ : Rs[K] extends { schema: StandardSchemaV1 }
178
+ ? K extends `${infer N extends number}`
179
+ ? N
180
+ : never
181
+ : never]: Rs[K] extends { schema: infer S extends StandardSchemaV1 } ? S : never
182
+ }
183
+ : EmptyResponseMap
184
+
185
+ type RouteSchemaForOp<O extends OperationDef> = SchemaForOp<O> &
186
+ RouteSchema &
187
+ (O extends { response: infer R extends StandardSchemaV1 } ? { response: R } : EmptyResponseMap) &
188
+ (keyof OpResponseSchemas<O> extends never ? EmptyResponseMap : { errors: OpResponseSchemas<O> })
189
+
146
190
  /**
147
191
  * The handler context for an op - identical to the inline `Context<Path, S>`, so a
148
192
  * handler written for an inline route type-checks unchanged under `implement`
@@ -245,23 +289,12 @@ export type RegistryFromImpl<
245
289
  C extends ContractShape,
246
290
  H extends HandlersFor<C, Ctx>,
247
291
  Ctx = NonNullable<unknown>,
292
+ HookOutput = never,
248
293
  > = {
249
294
  [P in C[keyof C]["path"]]: {
250
- [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: {
251
- readonly params: Params<C[K]["path"]>
252
- readonly query: OpQuery<C[K]>
253
- readonly body: OpBody<C[K]>
254
- readonly output: C[K] extends { response: infer R extends StandardSchemaV1 }
255
- ? InferOutput<R>
256
- : H[K] extends AnyFn
257
- ? OutputOf<H[K]>
258
- : unknown
259
- // The error union from the op's non-2xx `responses` (see OpErrors) - a graduated contract client sees
260
- // the same typed error bodies as the inline `errors` path.
261
- readonly errors: OpErrors<C[K]>
262
- // Mirrors RouteInfoForOp: no SSE contract on contract ops (yet) - mode conformance holds.
263
- readonly sse: never
264
- }
295
+ [K in keyof C as C[K]["path"] extends P ? C[K]["method"] : never]: H[K] extends AnyFn
296
+ ? RouteInfoFor<C[K]["path"], RouteSchemaForOp<C[K]>, OutputOf<H[K]>, HookOutput>
297
+ : RouteInfoFor<C[K]["path"], RouteSchemaForOp<C[K]>, unknown, HookOutput>
265
298
  }
266
299
  }
267
300
 
@@ -290,9 +323,24 @@ export function implement<
290
323
  H extends HandlersFor<C, Ctx>,
291
324
  R extends Registry = EmptyRegistry,
292
325
  Ctx = NonNullable<unknown>,
293
- >(contract: C, handlers: H, app?: Server<R, Ctx>): Server<R & RegistryFromImpl<C, H, Ctx>, Ctx> {
294
- const target = (app ?? new Server()) as Server<R, Ctx>
326
+ HookOutput = never,
327
+ >(
328
+ contract: C,
329
+ handlers: H,
330
+ app?: Server<R, Ctx, HookOutput>,
331
+ ): Server<R & RegistryFromImpl<C, H, Ctx, HookOutput>, Ctx, HookOutput> {
332
+ const target = (app ?? new Server()) as Server<R, Ctx, HookOutput>
295
333
  const routes = Object.entries(contract).map(([name, op]) => {
334
+ const errors =
335
+ op.responses === undefined
336
+ ? undefined
337
+ : Object.fromEntries(
338
+ Object.entries(op.responses)
339
+ .filter(
340
+ ([status, response]) => !status.startsWith("2") && response.schema !== undefined,
341
+ )
342
+ .map(([status, response]) => [Number(status), response.schema!]),
343
+ )
296
344
  // body/query are validated at the request boundary; `response` is a type + introspection contract
297
345
  // ONLY - never validated at runtime and never read on the request hot path (the lifecycle reads
298
346
  // schema.body/query by name; the `bare`/bodyOnly/queryOnly fast-path gates ignore `response`). Built
@@ -304,6 +352,7 @@ export function implement<
304
352
  op.body !== undefined ||
305
353
  op.query !== undefined ||
306
354
  op.response !== undefined ||
355
+ (errors !== undefined && Object.keys(errors).length > 0) ||
307
356
  op.capabilities !== undefined ||
308
357
  op.idempotency !== undefined ||
309
358
  op.bodyLimit !== undefined ||
@@ -316,6 +365,7 @@ export function implement<
316
365
  ...(op.body !== undefined ? { body: op.body } : {}),
317
366
  ...(op.query !== undefined ? { query: op.query } : {}),
318
367
  ...(op.response !== undefined ? { response: op.response } : {}),
368
+ ...(errors !== undefined && Object.keys(errors).length > 0 ? { errors } : {}),
319
369
  ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
320
370
  ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
321
371
  ...(op.bodyLimit !== undefined ? { bodyLimit: op.bodyLimit } : {}),
@@ -335,5 +385,5 @@ export function implement<
335
385
  target.registerBatch(routes)
336
386
  // Runtime registered exactly the contract's routes through the inline path; the
337
387
  // registry type is computed from the contract inputs + handler return types.
338
- return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx>, Ctx>
388
+ return target as unknown as Server<R & RegistryFromImpl<C, H, Ctx, HookOutput>, Ctx, HookOutput>
339
389
  }
@@ -202,11 +202,25 @@ const toBase64Url = (buf: ArrayBuffer): string => {
202
202
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
203
203
  }
204
204
 
205
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
206
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/
207
+
205
208
  // Returns `Uint8Array<ArrayBuffer>` (not the generic `Uint8Array<ArrayBufferLike>`) so it satisfies
206
- // WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics.
209
+ // WebCrypto's `BufferSource` parameter under TS 5.7+'s typed-array generics. Reject non-canonical
210
+ // encodings: base64 decoders otherwise accept alternate final characters whose unused padding bits
211
+ // decode to the same bytes, which lets a visibly changed signed cookie verify as if it were original.
207
212
  const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
213
+ if (s.length === 0 || s.length % 4 === 1 || !BASE64URL_RE.test(s)) return null
214
+ const remainder = s.length % 4
215
+ const last = BASE64URL_ALPHABET.indexOf(s[s.length - 1]!)
216
+ // Two or three base64 characters leave four or two unused low bits respectively. The encoder above
217
+ // always emits those bits as zero; accepting anything else creates a second spelling of the same
218
+ // byte sequence.
219
+ if ((remainder === 2 && (last & 0x0f) !== 0) || (remainder === 3 && (last & 0x03) !== 0))
220
+ return null
208
221
  try {
209
- const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"))
222
+ const padded = s + "=".repeat((4 - remainder) % 4)
223
+ const bin = atob(padded.replace(/-/g, "+").replace(/_/g, "/"))
210
224
  const bytes = new Uint8Array(bin.length)
211
225
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
212
226
  return bytes
@@ -23,7 +23,9 @@ export type NifraPlugin<In extends AnyServer = AnyServer, Out extends AnyServer
23
23
  * server type it receives, preserving the caller's typed registry and context across `.use()` while
24
24
  * still allowing the plugin to register runtime hooks or handlers.
25
25
  */
26
- export type IdentityPlugin = (<S extends AnyServer>(app: S) => S) & {
26
+ export type IdentityPlugin<AddedHookOutput = never> = (<R extends Registry, Ctx, HookOutput>(
27
+ app: Server<R, Ctx, HookOutput>,
28
+ ) => Server<R, Ctx, HookOutput | AddedHookOutput>) & {
27
29
  readonly pluginName?: string
28
30
  }
29
31
 
@@ -142,11 +144,15 @@ export function definePlugin(
142
144
  * const api = server().get("/a", h).use(audit).get("/b", h) // /a AND /b stay typed
143
145
  * ```
144
146
  */
145
- export function defineIdentityPlugin(
147
+ export function defineIdentityPlugin<AddedHookOutput = never>(
146
148
  name: string,
147
- apply: <S extends AnyServer>(app: S) => S,
148
- ): IdentityPlugin {
149
- return Object.assign(apply, { pluginName: name }) as IdentityPlugin
149
+ // The runtime identity plugin may install lifecycle hooks whose richer type is intentionally not
150
+ // threaded through this legacy identity seam. Accept the resulting server here so existing
151
+ // middleware remains source-compatible; direct typed hooks and future typed middleware can use the
152
+ // concrete Server return type without forcing every identity plugin to become response-aware.
153
+ apply: <S extends AnyServer>(app: S) => S | AnyServer,
154
+ ): IdentityPlugin<AddedHookOutput> {
155
+ return Object.assign(apply, { pluginName: name }) as IdentityPlugin<AddedHookOutput>
150
156
  }
151
157
 
152
158
  /**
@@ -1,6 +1,7 @@
1
1
  import type { BinaryResponse, RawResponse } from "../binary.ts"
2
2
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
3
3
  import type { Params, RouteSchema } from "./context.ts"
4
+ import type { StatusResponse } from "./runtime-core.ts"
4
5
 
5
6
  /**
6
7
  * One route's input/output shape as the **client** will consume it. `query`/`body`
@@ -17,6 +18,8 @@ export interface RouteInfo {
17
18
  readonly query: unknown
18
19
  readonly body: unknown
19
20
  readonly output: unknown
21
+ /** The complete inferred/declared status-keyed response map. */
22
+ readonly responses?: unknown
20
23
  /** The route's declared error bodies as a status-keyed record (from `schema.errors`, e.g.
21
24
  * `{ 404: NotFound }`); `unknown` when the route declares none. The typed client turns this into
22
25
  * a status-discriminated failure union. */
@@ -48,22 +51,73 @@ type RegistryQuery<S extends RouteSchema> = S extends { query: infer Q extends S
48
51
  ? InferInput<Q>
49
52
  : never
50
53
 
51
- /** The client-visible output: the declared `response` contract when present (so the client sees the
52
- * contract, not the handler's incidental return), otherwise the inferred handler output. */
53
- type RegistryOutput<S extends RouteSchema, Output> = S extends {
54
+ type StatusCodeOf<Output> =
55
+ Output extends StatusResponse<infer Code, unknown> ? (number extends Code ? never : Code) : never
56
+
57
+ type StatusBodyAt<Output, Code extends number> =
58
+ Output extends StatusResponse<infer Actual, infer Body>
59
+ ? Code extends Actual
60
+ ? Body
61
+ : never
62
+ : never
63
+
64
+ type IsSuccessCode<Code extends number> = `${Code}` extends `2${string}` ? true : false
65
+
66
+ type EmptyResponseMap = NonNullable<unknown>
67
+
68
+ type StatusBodies<Output, Codes extends number = StatusCodeOf<Output>> = [Codes] extends [never]
69
+ ? EmptyResponseMap
70
+ : { [Code in Codes]: StatusBodyAt<Output, Code> }
71
+
72
+ /** A dynamic status code cannot be classified as success or failure; expose an opaque success arm
73
+ * rather than guessing a status or body schema. Explicit route schemas still override this fallback. */
74
+ type DynamicStatusResponses<Output> =
75
+ Output extends StatusResponse<infer Code, unknown>
76
+ ? number extends Code
77
+ ? { 200: unknown }
78
+ : EmptyResponseMap
79
+ : EmptyResponseMap
80
+
81
+ type InferredResponses<Output> = (Exclude<
82
+ Output,
83
+ StatusResponse<number, unknown> | Response
84
+ > extends never
85
+ ? EmptyResponseMap
86
+ : { 200: Exclude<Output, StatusResponse<number, unknown> | Response> }) &
87
+ StatusBodies<Output> &
88
+ DynamicStatusResponses<Output>
89
+
90
+ type ExplicitResponses<S extends RouteSchema> = (S extends {
54
91
  response: infer R extends StandardSchemaV1
55
92
  }
56
- ? InferOutput<R>
57
- : Output
58
-
59
- /** A route's declared error-response body types as a STATUS-KEYED record (`schema.errors` →
60
- * `{ 404: NotFound, 409: Conflict }`), so the typed client can discriminate the failure body by
61
- * `status`, not just surface a union. `unknown` when the route declares no `errors`. */
62
- type RegistryErrors<S extends RouteSchema> = S extends {
63
- errors: infer E extends Record<number, StandardSchemaV1>
93
+ ? { 200: InferOutput<R> }
94
+ : EmptyResponseMap) &
95
+ (S extends { errors: infer E extends Record<number, StandardSchemaV1> }
96
+ ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
97
+ : EmptyResponseMap)
98
+
99
+ type MergeResponseMaps<Inferred, Explicit> = Omit<Inferred, keyof Explicit> & Explicit
100
+
101
+ type ResponseMap<S extends RouteSchema, Output> = MergeResponseMaps<
102
+ InferredResponses<Output>,
103
+ ExplicitResponses<S>
104
+ >
105
+
106
+ /** Public type seam for build-time consumers that need the complete route response map. */
107
+ export type ResponseMapFor<S extends RouteSchema, Output> = ResponseMap<S, Output>
108
+
109
+ type ResponseErrors<M> = {
110
+ [K in keyof M as K extends number ? (IsSuccessCode<K> extends true ? never : K) : never]: M[K]
64
111
  }
65
- ? { [K in keyof E]: E[K] extends StandardSchemaV1 ? InferOutput<E[K]> : never }
66
- : unknown
112
+
113
+ type ResponseSuccessBodies<M> = {
114
+ [K in keyof M]: K extends number ? (IsSuccessCode<K> extends true ? M[K] : never) : never
115
+ }[keyof M]
116
+
117
+ type NonEmptyOrUnknown<M> = keyof M extends never ? unknown : M
118
+
119
+ /** The client-visible output is the union of all inferred/declared 2xx bodies. */
120
+ type RegistryOutput<S extends RouteSchema, Output> = ResponseSuccessBodies<ResponseMap<S, Output>>
67
121
 
68
122
  /** The SSE event payload type from a route's `sse` schema; `never` for ordinary routes. */
69
123
  type RegistrySse<S extends RouteSchema> = S extends { sse: infer E extends StandardSchemaV1 }
@@ -71,12 +125,13 @@ type RegistrySse<S extends RouteSchema> = S extends { sse: infer E extends Stand
71
125
  : never
72
126
 
73
127
  /** Build a {@link RouteInfo} from a route's path, schema, and handler output type. */
74
- export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
128
+ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output, HookOutput = never> = {
75
129
  readonly params: Params<Path>
76
130
  readonly query: RegistryQuery<S>
77
131
  readonly body: RegistryBody<S>
78
- readonly output: RegistryOutput<S, Output>
79
- readonly errors: RegistryErrors<S>
132
+ readonly output: RegistryOutput<S, Output | HookOutput>
133
+ readonly responses?: ResponseMapFor<S, Output | HookOutput>
134
+ readonly errors: NonEmptyOrUnknown<ResponseErrors<ResponseMapFor<S, Output | HookOutput>>>
80
135
  readonly sse: RegistrySse<S>
81
136
  }
82
137
 
@@ -5,20 +5,14 @@
5
5
  * sits below the server module in the graph.
6
6
  */
7
7
  import type { RequestBudget } from "../budget.ts"
8
- import {
9
- applyTransportCap,
10
- drainCapped,
11
- hasTrustedBodyFraming,
12
- parseContentLength,
13
- RAW_BODY_READERS,
14
- readBoundedBytes,
15
- } from "./body.ts"
8
+ import { applyTransportCap, readBoundedBytes } from "./body.ts"
9
+ import { readBoundedJsonSource } from "./body-lane.ts"
16
10
  import type { Platform } from "./context.ts"
17
11
  import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
18
12
  import { headerObjectOf } from "./headers.ts"
19
13
  import { plainError } from "./http.ts"
20
- import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
- import { isUrlEncodedForm, readBoundedForm, searchOf } from "./query.ts"
14
+ import type { ProtoPoisoning } from "./proto-guard.ts"
15
+ import { searchOf } from "./query.ts"
22
16
  import { responseJsonContentType } from "./respond.ts"
23
17
  import {
24
18
  CONTEXT_SEARCH,
@@ -28,13 +22,11 @@ import {
28
22
  getUnboundedRequestBudget,
29
23
  headerOf,
30
24
  isResponseResult,
31
- PRE_DECODED_BODY,
32
- type PreDecodedBody,
33
- type ResponseResult,
34
25
  requestOf,
35
- TEXT_DECODER,
36
26
  } from "./runtime-core.ts"
37
- import type { CtxSet, MaybePromise, RawContext, RequestSource } from "./server.ts"
27
+ import type { CtxSet, RawContext, RequestSource } from "./server.ts"
28
+
29
+ export { readBodyFramed, readBoundedJsonSource } from "./body-lane.ts"
38
30
 
39
31
  /** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
40
32
  * argless `new Date()`. */
@@ -428,250 +420,3 @@ async function readBoundedJsonBodyOrThrow<T>(
428
420
  * with an in-cap `Content-Length` take the runtime's fused `json()` and walk the parsed value;
429
421
  * chunked or length-less requests fall back to the streaming byte-cap guard.
430
422
  */
431
- /**
432
- * Deliberately **not** `async`: the framed lane is the hot path for every JSON POST, and an async
433
- * wrapper costs a coroutine frame, a wrapper promise, and an extra microtask hop on top of the one
434
- * the body read already pays (~90ns/request on Node, measured). The lane returns the body read's
435
- * own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
436
- * - runs as an async function. Callers that `await` the result are unaffected.
437
- */
438
- type JsonResultContinuation<T> = (result: unknown | ResponseResult) => T | Promise<T>
439
- type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
440
- type FramedJsonSource = RequestSource & {
441
- readonly bytes?: () => Promise<Uint8Array>
442
- readonly jsonWithByteLength?: () => Promise<{
443
- readonly value: unknown
444
- readonly byteLength: number
445
- }>
446
- }
447
-
448
- export function readBoundedJsonSource(
449
- req: RequestSource,
450
- maxBytes: number,
451
- protoPoisoning?: ProtoPoisoning,
452
- ): Promise<unknown | ResponseResult>
453
- export function readBoundedJsonSource<T>(
454
- req: RequestSource,
455
- maxBytes: number,
456
- protoPoisoning: ProtoPoisoning,
457
- onResult: JsonResultContinuation<T>,
458
- onError: JsonErrorContinuation<T>,
459
- ): Promise<T>
460
- export function readBoundedJsonSource<T>(
461
- req: RequestSource,
462
- maxBytes: number,
463
- protoPoisoning: ProtoPoisoning = "reject",
464
- onResult?: JsonResultContinuation<T>,
465
- onError?: JsonErrorContinuation<T>,
466
- ): Promise<unknown | ResponseResult | T> {
467
- // A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
468
- // is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
469
- // lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
470
- const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
471
- if (preDecoded !== undefined) {
472
- return onResult === undefined
473
- ? Promise.resolve(preDecoded.value)
474
- : Promise.resolve(preDecoded.value).then(onResult)
475
- }
476
- // Read through the pre-cap readers when the request is transport-capped: this lane enforces its
477
- // own `maxBytes` (which may exceed the route's transport cap), so the shadowed user-facing
478
- // readers must not narrow it.
479
- // Keep the source's header dispatch local: the normal Node source exposes a direct `header`
480
- // lookup, while a Web Request only exposes `Headers.get`. Rechecking that optional method through
481
- // `headerOf` twice per body is measurable on the framed lane. The raw-reader shadow is still
482
- // bypassed for reads, but header authority remains the original source (same semantics as before).
483
- const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
484
- req) as FramedJsonSource
485
- const sourceHeader = req.header
486
- const declared =
487
- sourceHeader === undefined
488
- ? req.headers.get("content-length")
489
- : sourceHeader.call(req, "content-length")
490
- if (declared !== null) {
491
- // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
492
- // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
493
- // "abc"->NaN, "-5", "1.5", "1e3", "0x10") is malformed -> 400, rather than silently falling
494
- // through to the streaming guard - which is an UPPER-bound cap only, so a lying SMALLER length
495
- // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
496
- // hardens hand-built Requests (tests, the in-process client) and crafted input.
497
- const length = parseContentLength(declared)
498
- if (length === undefined)
499
- return onResult === undefined
500
- ? Promise.resolve(plainError(400, "invalid_content_length"))
501
- : Promise.resolve(plainError(400, "invalid_content_length")).then(onResult)
502
- if (length > maxBytes)
503
- return onResult === undefined
504
- ? Promise.resolve(plainError(413, "payload_too_large"))
505
- : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
506
- // Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
507
- // delimited the bytes, so the declared length IS the transport frame and chunked routing is
508
- // moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
509
- // decode+parse straight away, before the transfer-encoding probe that only exists to route
510
- // *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
511
- // `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
512
- // through below. The parsed-value poisoning walk still applies.
513
- if (hasTrustedBodyFraming(req)) {
514
- return raw.json().then(
515
- (parsed) => {
516
- let guarded: unknown
517
- try {
518
- guarded = guardParsedValue(parsed, protoPoisoning)
519
- } catch {
520
- return onResult === undefined
521
- ? plainError(400, "invalid_json")
522
- : onResult(plainError(400, "invalid_json"))
523
- }
524
- return onResult === undefined ? guarded : onResult(guarded)
525
- },
526
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
527
- )
528
- }
529
- const chunked =
530
- (sourceHeader === undefined
531
- ? req.headers.get("transfer-encoding")
532
- : sourceHeader.call(req, "transfer-encoding")) !== null
533
- if (!chunked) {
534
- const jsonWithByteLength = raw.jsonWithByteLength
535
- if (jsonWithByteLength !== undefined) {
536
- return jsonWithByteLength.call(raw).then(
537
- ({ value, byteLength }) => {
538
- if (byteLength > length || byteLength > maxBytes) {
539
- return onResult === undefined
540
- ? plainError(413, "payload_too_large")
541
- : onResult(plainError(413, "payload_too_large"))
542
- }
543
- let guarded: unknown
544
- try {
545
- guarded = guardParsedValue(value, protoPoisoning)
546
- } catch {
547
- return onResult === undefined
548
- ? plainError(400, "invalid_json")
549
- : onResult(plainError(400, "invalid_json"))
550
- }
551
- return onResult === undefined ? guarded : onResult(guarded)
552
- },
553
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
554
- )
555
- }
556
- const bytesReader = raw.bytes
557
- if (bytesReader !== undefined) {
558
- return bytesReader.call(raw).then(
559
- (bytes) => {
560
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
561
- return onResult === undefined
562
- ? plainError(413, "payload_too_large")
563
- : onResult(plainError(413, "payload_too_large"))
564
- }
565
- try {
566
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
567
- return onResult === undefined ? parsed : onResult(parsed)
568
- } catch {
569
- return onResult === undefined
570
- ? plainError(400, "invalid_json")
571
- : onResult(plainError(400, "invalid_json"))
572
- }
573
- },
574
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
575
- )
576
- }
577
- // Read the actual framed bytes before parsing. A declared length is only a hint on Web
578
- // Requests and adapter-shaped sources: a caller can construct a Request (or an adapter can
579
- // decode/expand a body) whose header claims fewer bytes than the delivered payload. Calling
580
- // native `json()` here would accept that understatement and bypass the cap. The byte lane
581
- // already owns this post-read check; keeping the JSON lane on the same raw-byte read makes the
582
- // trust boundary consistent. `Uint8Array(buffer)` is a view, not a copy.
583
- return raw.arrayBuffer().then(
584
- (buffer) => {
585
- const bytes = new Uint8Array(buffer)
586
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
587
- return onResult === undefined
588
- ? plainError(413, "payload_too_large")
589
- : onResult(plainError(413, "payload_too_large"))
590
- }
591
- let parsed: unknown
592
- try {
593
- parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
594
- } catch {
595
- return onResult === undefined
596
- ? plainError(400, "invalid_json")
597
- : onResult(plainError(400, "invalid_json"))
598
- }
599
- return onResult === undefined ? parsed : onResult(parsed)
600
- },
601
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
602
- )
603
- }
604
- }
605
- const streamed = readStreamedJsonSource(raw, maxBytes, protoPoisoning)
606
- return onResult === undefined ? streamed : streamed.then(onResult, onError)
607
- }
608
-
609
- /** The shared rejection handler for a body read that never completed (client abort, socket error,
610
- * malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
611
- * lane allocates no per-request closure for it. */
612
- const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
613
-
614
- /** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
615
- async function readStreamedJsonSource(
616
- raw: RequestSource,
617
- maxBytes: number,
618
- protoPoisoning: ProtoPoisoning,
619
- ): Promise<unknown | ResponseResult> {
620
- const body = raw.body
621
- if (body === null) return plainError(400, "invalid_json")
622
- const drained = await drainCapped(body, maxBytes)
623
- if (!drained.ok) return plainError(413, "payload_too_large")
624
- try {
625
- return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
626
- } catch {
627
- return plainError(400, "invalid_json")
628
- }
629
- }
630
-
631
- /**
632
- * The shared bounded body framing/parser: the single trust-boundary enforcement point every body
633
- * lane routes through. Content-type dispatch, the urlencoded-form cap, and the JSON path
634
- * (`Content-Length` pre-reject -> streaming cap -> prototype-poisoning guard, all inside
635
- * `readBoundedJsonSource`) live here so the fused body runner, the generic body-only lane, and a
636
- * compact edge entrypoint all enforce the same boundary from one definition. `protoPoisoning` is a
637
- * parameter, not read off a server instance, so this stays free of any dependency on the class.
638
- *
639
- * `onParsed` receives the decoded, guard-screened value; a framing rejection (`Content-Length` over
640
- * the cap, a lying length caught mid-stream, malformed JSON, an unsupported media type) is rendered
641
- * through `wrapResponse`; a read that never completed (client abort, socket error) reaches `onError`.
642
- */
643
- export function readBodyFramed<T>(
644
- source: RequestSource,
645
- maxBodyBytes: number,
646
- protoPoisoning: ProtoPoisoning,
647
- onParsed: (parsed: unknown) => MaybePromise<T>,
648
- wrapResponse: (response: Response | ResponseResult) => T,
649
- onError: (err: unknown) => MaybePromise<T>,
650
- ): Promise<T> {
651
- const contentType = headerOf(source, "content-type") ?? ""
652
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
653
- if (isUrlEncodedForm(contentType)) {
654
- return readBoundedForm(source, maxBodyBytes).then(
655
- (form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
656
- onError,
657
- ) as Promise<T>
658
- }
659
- // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't fit a
660
- // value schema; use a schema-less route + @nifrajs/uploads helpers for those.
661
- return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
662
- }
663
- // All JSON bodies go through readBoundedJsonSource - the single enforcement point for the framed
664
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard. A
665
- // separate inlined native-json() fast path here would fork the trust boundary in two.
666
- try {
667
- return readBoundedJsonSource(
668
- source,
669
- maxBodyBytes,
670
- protoPoisoning,
671
- (parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
672
- onError,
673
- )
674
- } catch (err) {
675
- return Promise.resolve(onError(err))
676
- }
677
- }