@nifrajs/core 4.0.0 → 5.0.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 (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +7 -2
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. package/src/ws.ts +4 -0
@@ -194,6 +194,37 @@ function transportCapError(status: 400 | 413): Response {
194
194
  return jsonError(status, status === 413 ? "payload_too_large" : "invalid_content_length")
195
195
  }
196
196
 
197
+ /**
198
+ * A copy of `request` whose body errors with the transport cap's `413` once more than `maxBytes`
199
+ * arrive, or as soon as it is read when the declared Content-Length is over the cap (`413`) or
200
+ * malformed (`400`). The compact form of {@link applyTransportCap} for a server that hands the
201
+ * handler its own `Request` and has no identity to preserve; nothing is read until the handler reads.
202
+ */
203
+ export function capBodyBytes(request: Request, maxBytes: number): Request {
204
+ const body = request.body
205
+ if (body === null) return request
206
+ const declared = request.headers.get("content-length")
207
+ const length = declared === null ? 0 : parseContentLength(declared)
208
+ let total = 0
209
+ const init: RequestInit & { duplex: "half" } = {
210
+ body: body.pipeThrough(
211
+ new TransformStream<Uint8Array, Uint8Array>({
212
+ start(controller) {
213
+ if (length === undefined || length > maxBytes)
214
+ controller.error(transportCapError(length === undefined ? 400 : 413))
215
+ },
216
+ transform(chunk, controller) {
217
+ total += chunk.byteLength
218
+ if (total > maxBytes) controller.error(transportCapError(413))
219
+ else controller.enqueue(chunk)
220
+ },
221
+ }),
222
+ ),
223
+ duplex: "half",
224
+ }
225
+ return new Request(request, init)
226
+ }
227
+
197
228
  /** First matching own-or-inherited descriptor, so instance shadows and prototype accessors both resolve. */
198
229
  function descriptorOf(target: object, key: string): PropertyDescriptor | undefined {
199
230
  let current: object | null = target
@@ -278,7 +309,9 @@ function cappedStreamOf(
278
309
  }
279
310
  total += value.byteLength
280
311
  if (total > maxBytes) {
281
- await active.cancel().catch(() => {})
312
+ // Not awaited, as in drainCapped: on a teed body the cancel settles only once the other
313
+ // branch is read or cancelled too, and the 413 must not wait for an unread clone.
314
+ active.cancel().catch(() => {})
282
315
  controller.error(reject(413))
283
316
  return
284
317
  }
@@ -22,6 +22,9 @@
22
22
  * - `{ header: name }` - trust a single named header's first value (e.g. a CDN that overwrites it at
23
23
  * the edge). Only declare this when the app is reachable **exclusively** through that edge; a
24
24
  * directly-reachable app lets any client forge the header.
25
+ *
26
+ * A hop count that is not a non-negative integer throws when the server is created; an invalid
27
+ * header name fails on the first request that reads `c.clientIp`.
25
28
  */
26
29
  export type ClientIpTrust = { readonly trustedHops: number } | { readonly header: string }
27
30
 
@@ -31,7 +31,8 @@ export interface IdempotencyConfig {
31
31
  * Required server-owned key namespace. Use a tenant/subject-scoped opaque token for authenticated
32
32
  * routes so two principals choosing the same header key never collide. A static namespace is an
33
33
  * explicit shared/public scope and is safe only when the response is principal-independent. The
34
- * resolver receives a clone and must not derive identity from unauthenticated client input.
34
+ * resolver receives the request's method, URL and headers, never its body, and must not derive
35
+ * identity from unauthenticated client input.
35
36
  */
36
37
  readonly namespace:
37
38
  | string
@@ -4,8 +4,9 @@
4
4
  * `server()` tree-shakes the whole dedupe machinery out.
5
5
  *
6
6
  * The kernel resolves a route's declared idempotency at registration (via {@link IdempotencyRuntime.resolve})
7
- * and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Everything
8
- * server-specific is supplied through {@link IdempotencyHost} so this module depends on no server internals.
7
+ * and, on the request path, hands a matched idempotent route to {@link IdempotencyRuntime.run}. Server
8
+ * execution is supplied through {@link IdempotencyHost}; the opt-in module adapts the matched
9
+ * route's program for replay without adding replay preparation to the shared server kernel.
9
10
  */
10
11
  import { RouteConfigError } from "../errors.ts"
11
12
  import {
@@ -28,6 +29,11 @@ import {
28
29
  requestEffectEvidence,
29
30
  requestIsSafeToRetry,
30
31
  } from "../internal/effect-execution.ts"
32
+ import {
33
+ compileRouteExecutionPlan,
34
+ type InternalHandler,
35
+ type RouteEntry,
36
+ } from "../internal/route-execution.ts"
31
37
  import { readBoundedBytes } from "./body.ts"
32
38
  import type { Platform, RouteSchema } from "./context.ts"
33
39
  import { jsonError, urlPartsOf } from "./http.ts"
@@ -42,6 +48,17 @@ const HANDLER_ENTERED = new WeakSet<Request>()
42
48
  * owned effect begun, releases its key, so a refused caller cannot hold a store entry for the TTL. */
43
49
  const REFUSALS = new Set([401, 403, 408, 409, 425, 429])
44
50
 
51
+ // Compile once in the opt-in plugin. The around runner also handles an empty hook list.
52
+ const REPLAY_EXECUTION = compileRouteExecutionPlan({
53
+ lane: "lifecycle",
54
+ contextless: false,
55
+ hasAround: true,
56
+ hasLedger: false,
57
+ fusedWeb: undefined,
58
+ fusedBody: undefined,
59
+ fusedLane: undefined,
60
+ })
61
+
45
62
  /** Registration-resolved idempotency for a route: the config with its store + defaults pinned. */
46
63
  export interface ResolvedIdempotency {
47
64
  readonly store: IdempotencyStore
@@ -55,7 +72,8 @@ export interface ResolvedIdempotency {
55
72
 
56
73
  /**
57
74
  * What the dedupe lane needs from the server to run a fresh key: the app-wide body cap and a way to
58
- * run the buffered request through the route's normal lanes to a concrete `Response`.
75
+ * run the buffered request through the route's normal lanes to a concrete `Response`. The replay
76
+ * path supplies an entry with the stored response replacing the handler and stored transformations.
59
77
  */
60
78
  export interface IdempotencyHost {
61
79
  readonly maxBodyBytes: number
@@ -65,7 +83,7 @@ export interface IdempotencyHost {
65
83
  entry: unknown,
66
84
  params: Record<string, string>,
67
85
  search: string | undefined,
68
- ): Promise<Response>
86
+ ): Response | Promise<Response>
69
87
  }
70
88
 
71
89
  /** The injected idempotency implementation the kernel calls through when the plugin is installed. */
@@ -101,7 +119,9 @@ interface IdempotencyInstallable {
101
119
 
102
120
  /**
103
121
  * Enable request idempotency. Routes that declare `schema.idempotency` get the dedupe lane: a repeat
104
- * `Idempotency-Key` replays the stored response instead of re-running the handler. Without this plugin,
122
+ * `Idempotency-Key` rechecks the current route prerequisites (authentication, validation and policy
123
+ * hooks) before replaying the stored response. The handler, effect ledger and stored response
124
+ * transformations run only for the fresh request. Without this plugin,
105
125
  * declaring `schema.idempotency` is a registration error (the safety gate can never be silently
106
126
  * dropped). `store` is the app-wide default backing routes that do not pin their own; omit it for a
107
127
  * shared in-process store (development/tests only - inject a durable store in production).
@@ -231,7 +251,10 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
231
251
  }
232
252
  const namespace =
233
253
  typeof config.namespace === "function"
234
- ? await config.namespace(req.clone(), platform)
254
+ ? await config.namespace(
255
+ { method: req.method, url: req.url, headers: req.headers },
256
+ platform,
257
+ )
235
258
  : config.namespace
236
259
  if (!validIdempotencyNamespace(namespace)) {
237
260
  return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
@@ -250,6 +273,13 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
250
273
  canonicalBody,
251
274
  req.headers.get("content-type") ?? "",
252
275
  )
276
+ const buffered = new Request(req.url, {
277
+ method: req.method,
278
+ headers: req.headers,
279
+ signal: req.signal,
280
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: readBoundedBytes owns this byte view; Request accepts the same bytes on every supported runtime.
281
+ body: read.bytes.byteLength ? (read.bytes as NonNullable<RequestInit["body"]>) : null,
282
+ })
253
283
 
254
284
  const pendingTtlMs = config.pendingTtlMs
255
285
  const begin = await config.store.begin({
@@ -259,11 +289,6 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
259
289
  ttlMs: config.ttlMs,
260
290
  pendingTtlMs,
261
291
  })
262
- if (begin.state === "replay") {
263
- return wrapResponse(
264
- responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
265
- )
266
- }
267
292
  if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
268
293
  if (begin.state === "in-flight") {
269
294
  return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
@@ -271,6 +296,21 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
271
296
  if (begin.state === "capacity") {
272
297
  return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
273
298
  }
299
+ beginRequestEffectTracking(buffered)
300
+ if (begin.state === "replay") {
301
+ return wrapResponse(
302
+ await host.runLanes(
303
+ buffered,
304
+ platform,
305
+ replayEntry(
306
+ entry,
307
+ responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }),
308
+ ),
309
+ params,
310
+ search,
311
+ ),
312
+ )
313
+ }
274
314
  const reservation = begin.reservation
275
315
  let heartbeat: ReturnType<typeof setInterval> | undefined
276
316
  if (pendingTtlMs !== undefined) {
@@ -286,17 +326,6 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
286
326
  ;(heartbeat as { unref?: () => void }).unref?.()
287
327
  }
288
328
  try {
289
- // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
290
- // then run those lanes to a concrete Response.
291
- const bufferedInit: RequestInit = {
292
- method: req.method,
293
- headers: req.headers,
294
- signal: req.signal,
295
- }
296
- if (read.bytes.byteLength > 0)
297
- bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
298
- const buffered = new Request(req.url, bufferedInit)
299
- beginRequestEffectTracking(buffered)
300
329
  // The handler threw, or its response body failed while being captured: no response to store.
301
330
  const settleThrown = async (err: unknown): Promise<ReturnType<typeof wrapResponse>> => {
302
331
  if (!requestEffectEvidence(buffered).began) {
@@ -394,3 +423,16 @@ export function createIdempotencyRuntime(options?: IdempotencyPluginOptions): Id
394
423
  },
395
424
  }
396
425
  }
426
+
427
+ function replayEntry(entry: unknown, response: Response): RouteEntry {
428
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the kernel supplies its matched RouteEntry through this internal opaque install seam.
429
+ const route = entry as RouteEntry
430
+ const handler: InternalHandler = () => response
431
+ return {
432
+ ...route,
433
+ handler,
434
+ responseContract: undefined,
435
+ program: { ...route.program, handler, afterHandle: [] },
436
+ execution: REPLAY_EXECUTION,
437
+ }
438
+ }
@@ -25,6 +25,9 @@ export const INSTALL_SSE: unique symbol = Symbol.for("@nifrajs/core/install-sse"
25
25
  /** @internal Install the Node-direct renderer on a server (called by the `nodeDirect()` plugin). */
26
26
  export const INSTALL_NODE_DIRECT: unique symbol = Symbol.for("@nifrajs/core/install-node-direct")
27
27
 
28
+ /** @internal Log an error an adapter caught past `fetch`, through the app's own logger. */
29
+ export const REPORT_ERROR: unique symbol = Symbol.for("@nifrajs/core/report-error")
30
+
28
31
  /** @internal Install the not-found lane on a server (called by the `notFound()` plugin). */
29
32
  export const INSTALL_NOT_FOUND: unique symbol = Symbol.for("@nifrajs/core/install-not-found")
30
33
 
@@ -57,7 +57,9 @@ export interface RedactOptions {
57
57
  export const commonSecretPatterns: ReadonlyArray<RegExp> = [
58
58
  /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, // Authorization: Bearer <token>
59
59
  /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, // JWT (header.payload.signature)
60
- /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // email
60
+ // Email. The lookbehind starts a match only where a run of address characters starts; `\b` would
61
+ // restart inside `a.a.a.` at every `a` and rescan the run each time, quadratic in its length.
62
+ /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
61
63
  /\b[sprk]k_(?:live|test)_[A-Za-z0-9]{8,}/g, // Stripe-style secret/restricted keys
62
64
  /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub personal/OAuth/refresh tokens
63
65
  /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
@@ -9,6 +9,7 @@ import type { MethodRoutesPlugin } from "./plugin.ts"
9
9
  import type { OutputOf } from "./registry.ts"
10
10
  import type { Handler } from "./server.ts"
11
11
 
12
+ export { isRegistrableMethod } from "../router/router.ts"
12
13
  export type { MethodRoutesPlugin } from "./plugin.ts"
13
14
 
14
15
  /** The part of a server these plugins use: one atomic batch registration. */
@@ -4,11 +4,13 @@
4
4
  * plain-data render straight to the socket. Imports only runtime-core + respond + the spine types.
5
5
  */
6
6
  import {
7
- appendCookiesToResponse,
7
+ applySetToResponse,
8
8
  applyStaticResponseHeaders,
9
9
  markTaggedResponse,
10
10
  normalizeBodylessResponse,
11
- rememberMutableHeaders,
11
+ RESPONSE_OWNED_HEADERS,
12
+ rememberRenderedResponse,
13
+ SENT_BODY,
12
14
  taggedResponseBody,
13
15
  } from "./respond.ts"
14
16
  import {
@@ -72,16 +74,17 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
72
74
  return {
73
75
  kind: "body",
74
76
  status: body.status,
75
- headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
77
+ headers: applySetToNodeHeaders(body.headers, body.status, set),
76
78
  body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
77
79
  }
78
80
  }
79
81
  return nodeOutcomeFromResponse(
80
- appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
82
+ applySetToResponse(normalizeBodylessResponse(result.toResponse()), set),
81
83
  )
82
84
  }
83
85
  if (result instanceof Response) {
84
- return nodeOutcomeFromResponse(appendCookiesToResponse(normalizeBodylessResponse(result), set))
86
+ if (result.bodyUsed) return plainNodeOutcome(SENT_BODY.plain!, EMPTY_RESPONSE_CONTROLS)
87
+ return nodeOutcomeFromResponse(applySetToResponse(normalizeBodylessResponse(result), set))
85
88
  }
86
89
  const status = set.status ?? (result === undefined ? 204 : 200)
87
90
  const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
@@ -152,8 +155,7 @@ export function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
152
155
  headers,
153
156
  })
154
157
  if (body !== null) markTaggedResponse(response, body)
155
- rememberMutableHeaders(response.headers)
156
- return response
158
+ return rememberRenderedResponse(response, body !== null)
157
159
  }
158
160
 
159
161
  /**
@@ -232,13 +234,33 @@ function responseHeadersForNode(
232
234
  return headers
233
235
  }
234
236
 
235
- function appendCookiesToNodeHeaders(
237
+ /** {@link applySetToResponse} for a body the node lane writes from a header record. */
238
+ function applySetToNodeHeaders(
236
239
  headers: Readonly<Record<string, string | readonly string[]>> | undefined,
237
- cookies: readonly string[] | undefined,
240
+ status: number,
241
+ set: CtxSet,
238
242
  ): Readonly<Record<string, string | readonly string[]>> | undefined {
239
- if (cookies === undefined || cookies.length === 0) return headers
240
- const out = Object.create(null) as Record<string, string | readonly string[]>
241
- if (headers !== undefined) Object.assign(out, headers)
243
+ const ambient = status >= 200 ? set._headers : undefined
244
+ const cookies = set._cookies
245
+ const copy = (): Record<string, string | readonly string[]> =>
246
+ Object.assign(Object.create(null) as Record<string, string | readonly string[]>, headers)
247
+ let out: Record<string, string | readonly string[]> | undefined
248
+ if (ambient !== undefined) {
249
+ let own: Set<string> | undefined
250
+ for (const name in ambient) {
251
+ const lower = name.toLowerCase()
252
+ if (RESPONSE_OWNED_HEADERS.has(lower)) continue
253
+ own ??= new Set(
254
+ headers === undefined ? [] : Object.keys(headers).map((key) => key.toLowerCase()),
255
+ )
256
+ if (own.has(lower)) continue
257
+ own.add(lower)
258
+ out ??= copy()
259
+ out[name] = ambient[name]!
260
+ }
261
+ }
262
+ if (cookies === undefined || cookies.length === 0) return out ?? headers
263
+ out ??= copy()
242
264
  const existing = out["set-cookie"]
243
265
  const setCookies =
244
266
  existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
@@ -67,8 +67,9 @@ function absoluteUrl(
67
67
  // Fall through to the structured event fields.
68
68
  }
69
69
  }
70
- const host = headers.get("host") ?? "nifra.invalid"
71
- const url = new URL(path || "/", `https://${host}`)
70
+ const url = new URL(`https://${headers.get("host") ?? "nifra.invalid"}`)
71
+ // Assigned, not resolved against the origin: resolving reads `//evil.example/admin` as a host.
72
+ url.pathname = path || "/"
72
73
  if (query !== undefined && query !== "") url.search = query.startsWith("?") ? query : `?${query}`
73
74
  return url.toString()
74
75
  }
@@ -15,6 +15,7 @@ import type { ProtoPoisoning } from "./proto-guard.ts"
15
15
  import { searchOf } from "./query.ts"
16
16
  import { responseJsonContentType } from "./respond.ts"
17
17
  import {
18
+ CONTEXT_REQUEST,
18
19
  CONTEXT_SEARCH,
19
20
  CONTEXT_SET,
20
21
  fallbackWaitUntil,
@@ -256,6 +257,10 @@ export class RequestContext implements RawContext {
256
257
  return this.setValue
257
258
  }
258
259
 
260
+ get [CONTEXT_REQUEST](): Request {
261
+ return requestOf(this.source)
262
+ }
263
+
259
264
  get [CONTEXT_SEARCH](): string {
260
265
  this.searchValue ??= searchOf(this.source.url)
261
266
  return this.searchValue