@nifrajs/core 3.5.0 → 4.0.2

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 (325) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +26 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +24 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +168 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +40 -8
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +8 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts.map +1 -1
  75. package/dist/manifest.js +41 -37
  76. package/dist/manifest.js.map +1 -1
  77. package/dist/mount.d.ts +36 -0
  78. package/dist/mount.d.ts.map +1 -1
  79. package/dist/mount.js +32 -0
  80. package/dist/mount.js.map +1 -1
  81. package/dist/reflection.d.ts +26 -0
  82. package/dist/reflection.d.ts.map +1 -1
  83. package/dist/reflection.js +65 -1
  84. package/dist/reflection.js.map +1 -1
  85. package/dist/response-observer.d.ts +1 -1
  86. package/dist/response-observer.d.ts.map +1 -1
  87. package/dist/response-observer.js.map +1 -1
  88. package/dist/router/overlap.d.ts +23 -0
  89. package/dist/router/overlap.d.ts.map +1 -0
  90. package/dist/router/overlap.js +224 -0
  91. package/dist/router/overlap.js.map +1 -0
  92. package/dist/router/pattern.d.ts +117 -2
  93. package/dist/router/pattern.d.ts.map +1 -1
  94. package/dist/router/pattern.js +283 -23
  95. package/dist/router/pattern.js.map +1 -1
  96. package/dist/router/router.d.ts +18 -4
  97. package/dist/router/router.d.ts.map +1 -1
  98. package/dist/router/router.js +51 -44
  99. package/dist/router/router.js.map +1 -1
  100. package/dist/server/auth.d.ts +53 -0
  101. package/dist/server/auth.d.ts.map +1 -0
  102. package/dist/server/auth.js +9 -0
  103. package/dist/server/auth.js.map +1 -0
  104. package/dist/server/bare-error-lane.d.ts +12 -7
  105. package/dist/server/bare-error-lane.d.ts.map +1 -1
  106. package/dist/server/bare-error-lane.js +13 -11
  107. package/dist/server/bare-error-lane.js.map +1 -1
  108. package/dist/server/body-lane.d.ts +20 -2
  109. package/dist/server/body-lane.d.ts.map +1 -1
  110. package/dist/server/body-lane.js +26 -7
  111. package/dist/server/body-lane.js.map +1 -1
  112. package/dist/server/body-parser.d.ts +68 -0
  113. package/dist/server/body-parser.d.ts.map +1 -0
  114. package/dist/server/body-parser.js +172 -0
  115. package/dist/server/body-parser.js.map +1 -0
  116. package/dist/server/body.d.ts.map +1 -1
  117. package/dist/server/body.js +62 -7
  118. package/dist/server/body.js.map +1 -1
  119. package/dist/server/client-ip.d.ts +8 -0
  120. package/dist/server/client-ip.d.ts.map +1 -1
  121. package/dist/server/client-ip.js +8 -0
  122. package/dist/server/client-ip.js.map +1 -1
  123. package/dist/server/context.d.ts +71 -6
  124. package/dist/server/context.d.ts.map +1 -1
  125. package/dist/server/contract.d.ts +15 -5
  126. package/dist/server/contract.d.ts.map +1 -1
  127. package/dist/server/contract.js +25 -4
  128. package/dist/server/contract.js.map +1 -1
  129. package/dist/server/core-copies.d.ts +2 -0
  130. package/dist/server/core-copies.d.ts.map +1 -0
  131. package/dist/server/core-copies.js +20 -0
  132. package/dist/server/core-copies.js.map +1 -0
  133. package/dist/server/edge.d.ts.map +1 -1
  134. package/dist/server/edge.js +2 -0
  135. package/dist/server/edge.js.map +1 -1
  136. package/dist/server/http.d.ts +11 -0
  137. package/dist/server/http.d.ts.map +1 -1
  138. package/dist/server/http.js +36 -0
  139. package/dist/server/http.js.map +1 -1
  140. package/dist/server/idempotency-lane.d.ts +6 -0
  141. package/dist/server/idempotency-lane.d.ts.map +1 -1
  142. package/dist/server/idempotency-lane.js +130 -73
  143. package/dist/server/idempotency-lane.js.map +1 -1
  144. package/dist/server/install.d.ts +4 -0
  145. package/dist/server/install.d.ts.map +1 -1
  146. package/dist/server/install.js +4 -0
  147. package/dist/server/install.js.map +1 -1
  148. package/dist/server/methods.d.ts +45 -0
  149. package/dist/server/methods.d.ts.map +1 -0
  150. package/dist/server/methods.js +37 -0
  151. package/dist/server/methods.js.map +1 -0
  152. package/dist/server/multipart.d.ts +63 -0
  153. package/dist/server/multipart.d.ts.map +1 -0
  154. package/dist/server/multipart.js +374 -0
  155. package/dist/server/multipart.js.map +1 -0
  156. package/dist/server/node-outcome-hook.d.ts +20 -0
  157. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  158. package/dist/server/node-outcome-hook.js +15 -6
  159. package/dist/server/node-outcome-hook.js.map +1 -1
  160. package/dist/server/not-found-answer.d.ts +40 -0
  161. package/dist/server/not-found-answer.d.ts.map +1 -0
  162. package/dist/server/not-found-answer.js +56 -0
  163. package/dist/server/not-found-answer.js.map +1 -0
  164. package/dist/server/not-found.d.ts +23 -0
  165. package/dist/server/not-found.d.ts.map +1 -0
  166. package/dist/server/not-found.js +81 -0
  167. package/dist/server/not-found.js.map +1 -0
  168. package/dist/server/plugin.d.ts +26 -1
  169. package/dist/server/plugin.d.ts.map +1 -1
  170. package/dist/server/plugin.js.map +1 -1
  171. package/dist/server/proto-guard.d.ts +18 -5
  172. package/dist/server/proto-guard.d.ts.map +1 -1
  173. package/dist/server/proto-guard.js +114 -9
  174. package/dist/server/proto-guard.js.map +1 -1
  175. package/dist/server/query.d.ts +7 -0
  176. package/dist/server/query.d.ts.map +1 -1
  177. package/dist/server/query.js +47 -0
  178. package/dist/server/query.js.map +1 -1
  179. package/dist/server/registry.d.ts +13 -2
  180. package/dist/server/registry.d.ts.map +1 -1
  181. package/dist/server/request-context.d.ts +11 -0
  182. package/dist/server/request-context.d.ts.map +1 -1
  183. package/dist/server/request-context.js +33 -19
  184. package/dist/server/request-context.js.map +1 -1
  185. package/dist/server/request-lineage.d.ts +8 -0
  186. package/dist/server/request-lineage.d.ts.map +1 -0
  187. package/dist/server/request-lineage.js +14 -0
  188. package/dist/server/request-lineage.js.map +1 -0
  189. package/dist/server/request-target.d.ts +31 -0
  190. package/dist/server/request-target.d.ts.map +1 -0
  191. package/dist/server/request-target.js +44 -0
  192. package/dist/server/request-target.js.map +1 -0
  193. package/dist/server/respond.d.ts +1 -1
  194. package/dist/server/respond.d.ts.map +1 -1
  195. package/dist/server/respond.js +16 -4
  196. package/dist/server/respond.js.map +1 -1
  197. package/dist/server/response-contract-lane.d.ts +19 -1
  198. package/dist/server/response-contract-lane.d.ts.map +1 -1
  199. package/dist/server/response-contract-lane.js +59 -14
  200. package/dist/server/response-contract-lane.js.map +1 -1
  201. package/dist/server/response-observer-runtime.d.ts +9 -5
  202. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  203. package/dist/server/response-observer-runtime.js +10 -6
  204. package/dist/server/response-observer-runtime.js.map +1 -1
  205. package/dist/server/runtime-core.d.ts +2 -0
  206. package/dist/server/runtime-core.d.ts.map +1 -1
  207. package/dist/server/runtime-core.js.map +1 -1
  208. package/dist/server/server-types.d.ts +53 -3
  209. package/dist/server/server-types.d.ts.map +1 -1
  210. package/dist/server/server.d.ts +191 -19
  211. package/dist/server/server.d.ts.map +1 -1
  212. package/dist/server/server.js +1206 -218
  213. package/dist/server/server.js.map +1 -1
  214. package/dist/server/sse.d.ts.map +1 -1
  215. package/dist/server/sse.js +10 -7
  216. package/dist/server/sse.js.map +1 -1
  217. package/dist/server/static-headers.d.ts +3 -0
  218. package/dist/server/static-headers.d.ts.map +1 -1
  219. package/dist/server/static-headers.js +19 -1
  220. package/dist/server/static-headers.js.map +1 -1
  221. package/dist/server/transport-lane.d.ts +8 -0
  222. package/dist/server/transport-lane.d.ts.map +1 -1
  223. package/dist/server/transport-lane.js +9 -8
  224. package/dist/server/transport-lane.js.map +1 -1
  225. package/dist/server/validation.d.ts.map +1 -1
  226. package/dist/server/validation.js +5 -1
  227. package/dist/server/validation.js.map +1 -1
  228. package/dist/server/websocket.d.ts +10 -3
  229. package/dist/server/websocket.d.ts.map +1 -1
  230. package/dist/server/websocket.js +7 -4
  231. package/dist/server/websocket.js.map +1 -1
  232. package/dist/server/ws-bun.d.ts +2 -0
  233. package/dist/server/ws-bun.d.ts.map +1 -1
  234. package/dist/server/ws-bun.js +6 -3
  235. package/dist/server/ws-bun.js.map +1 -1
  236. package/dist/server/ws-hook.d.ts +2 -1
  237. package/dist/server/ws-hook.d.ts.map +1 -1
  238. package/dist/server.d.ts +7 -4
  239. package/dist/server.d.ts.map +1 -1
  240. package/dist/server.js +4 -1
  241. package/dist/server.js.map +1 -1
  242. package/dist/single-copy-register.js +5 -0
  243. package/dist/single-copy-register.js.map +1 -1
  244. package/dist/single-copy.d.ts +46 -3
  245. package/dist/single-copy.d.ts.map +1 -1
  246. package/dist/single-copy.js +125 -12
  247. package/dist/single-copy.js.map +1 -1
  248. package/dist/tool-contract.d.ts +23 -0
  249. package/dist/tool-contract.d.ts.map +1 -1
  250. package/dist/tool-contract.js +37 -3
  251. package/dist/tool-contract.js.map +1 -1
  252. package/dist/transport-codec.d.ts.map +1 -1
  253. package/dist/transport-codec.js +2 -1
  254. package/dist/transport-codec.js.map +1 -1
  255. package/package.json +27 -2
  256. package/src/assurance.ts +52 -2
  257. package/src/capabilities.ts +81 -22
  258. package/src/diff.ts +31 -6
  259. package/src/durable-adapters.ts +20 -3
  260. package/src/durable-effect.ts +10 -3
  261. package/src/edge-kit.ts +7 -0
  262. package/src/effect-lifecycle.ts +5 -1
  263. package/src/effect-scope.ts +2 -1
  264. package/src/errors.ts +3 -0
  265. package/src/evidence.ts +237 -12
  266. package/src/idempotency.ts +72 -8
  267. package/src/index.ts +40 -3
  268. package/src/internal/code-unit-order.ts +8 -0
  269. package/src/internal/durable-saga-protocol.ts +2 -0
  270. package/src/internal/route-catalog.ts +19 -18
  271. package/src/internal/route-compiler.ts +62 -13
  272. package/src/internal/route-execution.ts +13 -3
  273. package/src/internal/route-lanes.ts +37 -28
  274. package/src/internal/route-program.ts +434 -20
  275. package/src/ledger.ts +11 -5
  276. package/src/manifest.ts +41 -36
  277. package/src/mount.ts +62 -0
  278. package/src/reflection.ts +84 -1
  279. package/src/response-observer.ts +1 -0
  280. package/src/router/overlap.ts +258 -0
  281. package/src/router/pattern.ts +349 -25
  282. package/src/router/router.ts +76 -51
  283. package/src/server/auth.ts +75 -0
  284. package/src/server/bare-error-lane.ts +12 -11
  285. package/src/server/body-lane.ts +46 -6
  286. package/src/server/body-parser.ts +241 -0
  287. package/src/server/body.ts +82 -9
  288. package/src/server/client-ip.ts +9 -0
  289. package/src/server/context.ts +219 -12
  290. package/src/server/contract.ts +54 -9
  291. package/src/server/core-copies.ts +21 -0
  292. package/src/server/edge.ts +2 -0
  293. package/src/server/http.ts +34 -0
  294. package/src/server/idempotency-lane.ts +147 -72
  295. package/src/server/install.ts +6 -0
  296. package/src/server/methods.ts +143 -0
  297. package/src/server/multipart.ts +424 -0
  298. package/src/server/node-outcome-hook.ts +40 -7
  299. package/src/server/not-found-answer.ts +109 -0
  300. package/src/server/not-found.ts +121 -0
  301. package/src/server/plugin.ts +45 -1
  302. package/src/server/proto-guard.ts +128 -9
  303. package/src/server/query.ts +49 -0
  304. package/src/server/registry.ts +17 -2
  305. package/src/server/request-context.ts +44 -16
  306. package/src/server/request-lineage.ts +15 -0
  307. package/src/server/request-target.ts +46 -0
  308. package/src/server/respond.ts +14 -3
  309. package/src/server/response-contract-lane.ts +97 -16
  310. package/src/server/response-observer-runtime.ts +37 -8
  311. package/src/server/runtime-core.ts +2 -0
  312. package/src/server/server-types.ts +59 -2
  313. package/src/server/server.ts +1613 -269
  314. package/src/server/sse.ts +10 -8
  315. package/src/server/static-headers.ts +22 -1
  316. package/src/server/transport-lane.ts +17 -9
  317. package/src/server/validation.ts +7 -1
  318. package/src/server/websocket.ts +23 -9
  319. package/src/server/ws-bun.ts +8 -3
  320. package/src/server/ws-hook.ts +2 -1
  321. package/src/server.ts +38 -2
  322. package/src/single-copy-register.ts +5 -0
  323. package/src/single-copy.ts +160 -12
  324. package/src/tool-contract.ts +66 -3
  325. package/src/transport-codec.ts +2 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The `notFound()` handler's rules:
3
+ * - Only for a `404`: a wrong method stays a `405`, a malformed path a `400`, a mount's `404` its own.
4
+ * - It sees the request line and headers, never the body: an unrouted request has no body limit.
5
+ * - A method token no route could take gets the default `404`, as it skips `onRequest` hooks.
6
+ * - A `2xx` is sent as a `404`; a `3xx`, `410` or other error status is sent as built.
7
+ * - Fails closed: a throw or an unsendable value is logged and answered with the plain `500`.
8
+ */
9
+ import { type ErrorLogDetail, emitRequestErrorLog } from "./bare-error-lane.ts"
10
+ import type { Platform } from "./context.ts"
11
+ import { plainError } from "./http.ts"
12
+ import { INSTALL_NOT_FOUND } from "./install.ts"
13
+ import type { Logger } from "./logger.ts"
14
+ import {
15
+ answerNotFound,
16
+ type NotFoundHandler,
17
+ type NotFoundLane,
18
+ notFoundInput,
19
+ } from "./not-found-answer.ts"
20
+ import type { IdentityPlugin } from "./plugin.ts"
21
+ import type { ResponseResult } from "./runtime-core.ts"
22
+ import type { AnyServer, MaybePromise, RequestSource } from "./server.ts"
23
+
24
+ export type { NotFoundHandler, NotFoundInput } from "./not-found-answer.ts"
25
+
26
+ /** The kernel state the lane reads. Fixed at construction, so reading it per miss is three loads. */
27
+ interface NotFoundHost {
28
+ readonly requestTimeoutMs: number
29
+ readonly logger: Logger
30
+ readonly errorLogDetail: ErrorLogDetail
31
+ }
32
+
33
+ /** The install seam a server exposes so the `notFound()` plugin can hand it the lane. */
34
+ interface NotFoundInstallable {
35
+ [INSTALL_NOT_FOUND](lane: NotFoundLane): void
36
+ readonly notFoundLane?: NotFoundLane
37
+ readonly routePrefix: string
38
+ }
39
+
40
+ /**
41
+ * Answer requests no route matched with a handler of your own: `app.use(notFound(handler))`.
42
+ *
43
+ * ```ts
44
+ * import { notFound } from "@nifrajs/core/not-found"
45
+ *
46
+ * const app = server().use(
47
+ * notFound(({ pathname }) => Response.json({ ok: false, error: "no_such_route", pathname })),
48
+ * )
49
+ * ```
50
+ *
51
+ * The answer is a `404` whatever `2xx` status the handler gives it; see the module notes for the
52
+ * full rules. The server's `requestTimeoutMs` bounds an async handler and aborts `input.signal`.
53
+ *
54
+ * A server takes one handler, and it belongs to the server that listens: applying `notFound()` a
55
+ * second time throws, and so does applying it inside a `group()` (a miss has no prefix to scope it
56
+ * to). A server composed into another with `merge()` does not carry its handler along.
57
+ */
58
+ export function notFound<Env = unknown>(handler: NotFoundHandler<Env>): IdentityPlugin {
59
+ if (typeof handler !== "function") throw new TypeError("notFound() needs a handler function")
60
+ const lane: NotFoundLane = <T>(
61
+ host: unknown,
62
+ source: RequestSource,
63
+ pathname: string,
64
+ platform: Platform<unknown> | undefined,
65
+ wrap: (response: Response | ResponseResult) => T,
66
+ onTimeout: () => T,
67
+ ): MaybePromise<T> => {
68
+ const { requestTimeoutMs, logger, errorLogDetail } = host as NotFoundHost
69
+ const env = platform as Platform<Env> | undefined
70
+ const failed = (error: unknown): T => {
71
+ // A logger that throws must not decide the answer: the fault is still the plain `500`.
72
+ try {
73
+ emitRequestErrorLog(logger, errorLogDetail, error, { req: source })
74
+ } catch {}
75
+ return wrap(plainError(500, "internal_error"))
76
+ }
77
+ if (requestTimeoutMs === 0) {
78
+ return answerNotFound(handler, notFoundInput(source, pathname, env), wrap, failed)
79
+ }
80
+ const controller = new AbortController()
81
+ const answer = answerNotFound(
82
+ handler,
83
+ notFoundInput(source, pathname, env, controller.signal),
84
+ wrap,
85
+ failed,
86
+ )
87
+ if (!(answer instanceof Promise)) return answer
88
+ // The same bound a matched route gets: abort the signal, answer the timeout, discard the late
89
+ // result. Both arms are attached, so a handler that settles after the deadline is still observed.
90
+ return new Promise<T>((resolve, reject) => {
91
+ const timer = setTimeout(() => {
92
+ controller.abort()
93
+ resolve(onTimeout())
94
+ }, requestTimeoutMs)
95
+ answer.then(
96
+ (value) => {
97
+ clearTimeout(timer)
98
+ resolve(value)
99
+ },
100
+ (error) => {
101
+ clearTimeout(timer)
102
+ reject(error)
103
+ },
104
+ )
105
+ })
106
+ }
107
+ const apply = <S extends AnyServer>(app: S): S => {
108
+ const host = app as unknown as NotFoundInstallable
109
+ if (host.notFoundLane !== undefined) {
110
+ throw new TypeError("notFound() is already applied to this server")
111
+ }
112
+ if (host.routePrefix !== "") {
113
+ throw new TypeError(
114
+ "notFound() inside a group answers nothing - apply it to the parent server",
115
+ )
116
+ }
117
+ host[INSTALL_NOT_FOUND](lane)
118
+ return app
119
+ }
120
+ return apply as IdentityPlugin
121
+ }
@@ -3,7 +3,9 @@
3
3
  * `use`/`derive`/`decorate` and/or registering routes) and returns it; these helpers attach a name
4
4
  * for idempotent dedupe and pin the type-threading so `.use()` preserves the caller's typed server.
5
5
  */
6
- import type { Registry } from "./registry.ts"
6
+ import type { Method } from "../router/router.ts"
7
+ import type { RouteSchema } from "./context.ts"
8
+ import type { AddRoute, Registry, RouteInfoFor } from "./registry.ts"
7
9
  import type { AnyServer, Server } from "./server.ts"
8
10
 
9
11
  /**
@@ -62,6 +64,48 @@ export function defineContextPlugin<D extends object>(
62
64
  return Object.assign(apply, { pluginName: name }) as ContextPlugin<D>
63
65
  }
64
66
 
67
+ declare const METHOD_ROUTES: unique symbol
68
+
69
+ /**
70
+ * What `all()` and `method()` from `@nifrajs/core/methods` return: a plugin for `app.use()` that
71
+ * registers one handler under the methods `M` of `Path`. It is opaque - apply it with `use`, which
72
+ * reads the route's types from it and adds them to the app's registry.
73
+ */
74
+ export interface MethodRoutesPlugin<
75
+ M extends string,
76
+ Path extends string,
77
+ S extends RouteSchema,
78
+ Output,
79
+ Ctx,
80
+ > {
81
+ readonly [METHOD_ROUTES]: {
82
+ readonly methods: M
83
+ readonly path: Path
84
+ readonly schema: S
85
+ readonly output: Output
86
+ /** The context the handler was written against; the app must provide at least this. */
87
+ readonly context: (context: Ctx) => void
88
+ }
89
+ }
90
+
91
+ /** The standard methods among `M`, as the registry keys them. A custom token contributes none. */
92
+ type StandardMethodOf<M extends string> = Extract<Uppercase<M>, Method>
93
+
94
+ /**
95
+ * The registry after a {@link MethodRoutesPlugin} is applied: `Path` gains the standard methods
96
+ * among `M`. A method outside the standard set is served but not typed, so it adds nothing.
97
+ */
98
+ export type WithMethodRoutes<
99
+ R extends Registry,
100
+ M extends string,
101
+ Path extends string,
102
+ S extends RouteSchema,
103
+ Output,
104
+ HookOutput,
105
+ > = [StandardMethodOf<M>] extends [never]
106
+ ? R
107
+ : AddRoute<R, StandardMethodOf<M>, Path, RouteInfoFor<Path, S, Output, HookOutput>>
108
+
65
109
  declare const COLLAPSED: unique symbol
66
110
 
67
111
  /**
@@ -28,11 +28,12 @@ export function parseJsonGuarded(text: string, policy: ProtoPoisoning): unknown
28
28
  }
29
29
 
30
30
  /**
31
- * The policy applied to an already-parsed value - the native-`json()` lane, which never holds the
32
- * raw text, and the transport codecs, which parse with their own decoder. Escapes are resolved by
33
- * the time a key is an own property, so a `\u`-spelled `__proto__` and a literal one look identical
34
- * here. Cost is one iterative pass over the value's object nodes; `"ignore"` skips even that.
35
- * Throws the reject singleton; callers map it to their lane's flat error.
31
+ * The policy applied to an already-parsed JSON value - the native-`json()` lane, which never holds
32
+ * the raw text. Escapes are resolved by the time a key is an own property, so a `\u`-spelled
33
+ * `__proto__` and a literal one look identical here. Cost is one iterative pass over the value's
34
+ * object nodes; `"ignore"` skips even that. The walk assumes a tree, which JSON always is; a
35
+ * transport codec's output goes through {@link guardDecodedValue}. Throws the reject singleton;
36
+ * callers map it to their lane's flat error.
36
37
  */
37
38
  export function guardParsedValue(value: unknown, policy: ProtoPoisoning): unknown {
38
39
  if (policy === "ignore") return value
@@ -81,10 +82,10 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
81
82
  // `for-in`, not `Object.keys()`: the keys array would be an allocation per node (~2x the whole
82
83
  // walk on measured bodies, both engines, at realistic widths; V8 only prefers `Object.keys` on
83
84
  // 200+-property dictionary-mode objects). On JSON.parse output the two enumerate identically -
84
- // own enumerable string keys, nothing enumerable on the prototype chain. A custom transport
85
- // codec could hand `guardParsedValue` an object with enumerable INHERITED properties; `for-in`
86
- // sweeps those too, which only over-sweeps - a poisoned inherited subtree rejects rather than
87
- // slips through. A *non-enumerable* own `__proto__` is the one shape this misses, and it is
85
+ // own enumerable string keys, nothing enumerable on the prototype chain. Were a value with
86
+ // enumerable INHERITED properties handed in, `for-in` would sweep those too, which only
87
+ // over-sweeps - a poisoned inherited subtree rejects rather than slips through. A
88
+ // *non-enumerable* own `__proto__` is the one shape this misses, and it is
88
89
  // not the poisoning shape: `JSON.parse` never produces one, and the merges that carry the
89
90
  // payload onward (spread, `Object.assign`, key loops) copy enumerable own keys only.
90
91
  for (const key in record) {
@@ -116,3 +117,121 @@ function sweep(root: unknown, policy: "reject" | "strip"): unknown {
116
117
  }
117
118
  return root
118
119
  }
120
+
121
+ /** How many nodes shared references may add to a decoded value, counted as a tree walk visits it. */
122
+ const MAX_SHARED_EXPANSION = 100_000
123
+
124
+ /** Thrown (reused singleton, mapped like {@link POISONED}) for a decoded value no tree walk can finish,
125
+ * or one holding a pattern the caller did not allow. */
126
+ const NOT_A_TREE = /* @__PURE__ */ new Error("decoded_value_not_a_tree")
127
+
128
+ /** Marks a node whose subtree is still being walked: meeting it again then is a cycle. */
129
+ const ON_PATH = -1
130
+
131
+ interface DecodedFrame {
132
+ readonly node: object
133
+ readonly children: readonly object[]
134
+ next: number
135
+ size: number
136
+ }
137
+
138
+ /**
139
+ * The policy for a value a transport codec decoded. Unlike `JSON.parse` output that can be a graph -
140
+ * a codec that keeps references, as the rich wire codec does, returns shared nodes and cycles - while
141
+ * everything downstream (validators, handlers, serializers) walks a body as a tree. So a cycle is
142
+ * refused, and shared nodes may add at most {@link MAX_SHARED_EXPANSION} nodes to the tree they expand
143
+ * to: forty levels of doubled references fit in 2KB and expand to 2^40 paths. Map and Set members are
144
+ * walked like properties. These shape rules hold under every policy, `"ignore"` included.
145
+ *
146
+ * A `RegExp` is refused unless `patterns` allows it: a pattern from the client is code, and one with
147
+ * catastrophic backtracking stalls the event loop the moment anything runs it.
148
+ */
149
+ export function guardDecodedValue(
150
+ value: unknown,
151
+ policy: ProtoPoisoning,
152
+ patterns = false,
153
+ ): unknown {
154
+ if (value === null || typeof value !== "object") return value
155
+ const sizes = new Map<object, number>()
156
+ const frames: DecodedFrame[] = [openDecoded(value, policy, patterns, sizes)]
157
+ let expanded = 0
158
+ for (let frame = frames.at(-1); frame !== undefined; frame = frames.at(-1)) {
159
+ const child = frame.children[frame.next]
160
+ if (child !== undefined) {
161
+ frame.next++
162
+ const known = sizes.get(child)
163
+ if (known === undefined) frames.push(openDecoded(child, policy, patterns, sizes))
164
+ else if (known === ON_PATH) throw NOT_A_TREE
165
+ else frame.size += known
166
+ continue
167
+ }
168
+ frames.pop()
169
+ sizes.set(frame.node, frame.size)
170
+ const parent = frames.at(-1)
171
+ if (parent === undefined) expanded = frame.size
172
+ else parent.size += frame.size
173
+ }
174
+ if (expanded - sizes.size > MAX_SHARED_EXPANSION) throw NOT_A_TREE
175
+ return value
176
+ }
177
+
178
+ function openDecoded(
179
+ node: object,
180
+ policy: ProtoPoisoning,
181
+ patterns: boolean,
182
+ sizes: Map<object, number>,
183
+ ): DecodedFrame {
184
+ if (!patterns && node instanceof RegExp) throw NOT_A_TREE
185
+ sizes.set(node, ON_PATH)
186
+ return { node, children: decodedChildren(node, policy), next: 0, size: 1 }
187
+ }
188
+
189
+ function decodedChildren(node: object, policy: ProtoPoisoning): object[] {
190
+ const children: object[] = []
191
+ const add = (value: unknown): void => {
192
+ if (value !== null && typeof value === "object") children.push(value)
193
+ }
194
+ if (Array.isArray(node)) {
195
+ for (let i = 0; i < node.length; i++) add(node[i])
196
+ return children
197
+ }
198
+ if (node instanceof Map) {
199
+ for (const [key, value] of node) {
200
+ add(key)
201
+ add(value)
202
+ }
203
+ return children
204
+ }
205
+ if (node instanceof Set) {
206
+ for (const item of node) add(item)
207
+ return children
208
+ }
209
+ // Binary and scalar wrappers carry no keys to poison; `for-in` over a typed array would enumerate
210
+ // every index.
211
+ if (
212
+ ArrayBuffer.isView(node) ||
213
+ node instanceof ArrayBuffer ||
214
+ node instanceof Date ||
215
+ node instanceof RegExp ||
216
+ node instanceof URL
217
+ ) {
218
+ return children
219
+ }
220
+ for (const key in node) {
221
+ const value: unknown = Reflect.get(node, key)
222
+ if (
223
+ policy !== "ignore" &&
224
+ (key === "__proto__" ||
225
+ (key === "constructor" &&
226
+ value !== null &&
227
+ typeof value === "object" &&
228
+ Object.hasOwn(value, "prototype")))
229
+ ) {
230
+ if (policy === "reject") throw POISONED
231
+ Reflect.deleteProperty(node, key)
232
+ continue
233
+ }
234
+ add(value)
235
+ }
236
+ return children
237
+ }
@@ -110,6 +110,55 @@ export function queryObjectOf(search: string): Record<string, QueryValue> {
110
110
  return out
111
111
  }
112
112
 
113
+ /**
114
+ * `application/json` or an `application/*+json` type, with or without parameters, compared as a media
115
+ * type rather than searched as a substring. A substring test also matches `text/plain; x=application/json`,
116
+ * whose essence is `text/plain` - a type a browser sends cross-origin with credentials and no preflight,
117
+ * so a JSON route would parse a body no CORS policy ever saw.
118
+ */
119
+ export function isJsonMediaType(contentType: string): boolean {
120
+ // The spellings common clients send, by exact match.
121
+ if (
122
+ contentType === "application/json" ||
123
+ contentType === "application/json; charset=utf-8" ||
124
+ contentType === "application/json;charset=UTF-8"
125
+ ) {
126
+ return true
127
+ }
128
+ // Every other lowercase `application/json` spelling, parameters or not, without slicing or lowercasing.
129
+ if (contentType.startsWith("application/json")) {
130
+ let delimiter = 16
131
+ while (delimiter < contentType.length) {
132
+ const char = contentType.charCodeAt(delimiter)
133
+ if (char !== 32 /* SP */ && char !== 9 /* HTAB */) break
134
+ delimiter++
135
+ }
136
+ if (delimiter === contentType.length || contentType.charCodeAt(delimiter) === 59 /* ; */) {
137
+ return true
138
+ }
139
+ }
140
+ // Lowercase `application/*+json` spellings by the same rules as the parse below, without allocating.
141
+ if (contentType.startsWith("application/")) {
142
+ const params = contentType.indexOf(";", 12)
143
+ let end = params === -1 ? contentType.length : params
144
+ while (end > 12) {
145
+ const char = contentType.charCodeAt(end - 1)
146
+ if (char !== 32 /* SP */ && char !== 9 /* HTAB */) break
147
+ end--
148
+ }
149
+ if (end >= 17 && contentType.startsWith("+json", end - 5)) {
150
+ const space = contentType.indexOf(" ", 12)
151
+ if (space === -1 || space >= end) return true
152
+ }
153
+ }
154
+ const semi = contentType.indexOf(";")
155
+ const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
156
+ return (
157
+ essence === "application/json" ||
158
+ (essence.startsWith("application/") && essence.endsWith("+json") && !essence.includes(" "))
159
+ )
160
+ }
161
+
113
162
  /** `application/x-www-form-urlencoded`, with or without a charset suffix. */
114
163
  export function isUrlEncodedForm(contentType: string): boolean {
115
164
  return (
@@ -1,6 +1,6 @@
1
1
  import type { BinaryResponse, RawResponse } from "../binary.ts"
2
2
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
3
- import type { Params, RouteSchema } from "./context.ts"
3
+ import type { Params, RoutePaths, RouteSchema } from "./context.ts"
4
4
  import type { StatusResponse } from "./runtime-core.ts"
5
5
 
6
6
  /**
@@ -196,10 +196,25 @@ export type WsRouteInfoFor<
196
196
  * `.merge()` them (each group is a short chain; a merge is one `R & R2` intersection with no
197
197
  * per-call context work), or contract-first `implement()` (the registry is one object type declared
198
198
  * upfront - no grow-R-per-call, so no ceiling at all).
199
+ *
200
+ * A path ending in optional parameters is keyed once per concrete path it serves (`RoutePaths`), all
201
+ * sharing the one route info, so `/users/:id?` adds `/users` and `/users/:id`.
199
202
  */
200
203
  export type AddRoute<
201
204
  R extends Registry,
202
205
  Method extends string,
203
206
  Path extends string,
204
207
  Info extends RouteInfo,
205
- > = R & { [P in Path]: { [M in Method]: Info } }
208
+ > = R & { [P in RoutePaths<Path>]: { [M in Method]: Info } }
209
+
210
+ /** Join a group prefix and a route path the way `Server.group` does at runtime: a group's `/` route
211
+ * serves the prefix itself (no trailing slash), every other path is appended verbatim. */
212
+ export type JoinRoutePath<Prefix extends string, Path extends string> = Path extends "/"
213
+ ? Prefix
214
+ : `${Prefix}${Path}`
215
+
216
+ /** Re-key a registry under a static path prefix. Route info (params, schemas, responses) is carried
217
+ * unchanged: a prefix is static text, so it adds no params and cannot change any route's contract. */
218
+ export type PrefixRegistry<Prefix extends string, R extends Registry> = {
219
+ [Path in keyof R & string as JoinRoutePath<Prefix, Path>]: R[Path]
220
+ }
@@ -157,6 +157,15 @@ function responseTextContentType(): string {
157
157
  * passes no headers of their own. */
158
158
  const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
159
159
 
160
+ /** Where a serving adapter that shares one platform across requests leaves its socket-peer lookup.
161
+ * `c.clientIp` calls it with the request when a handler reads the address, so a request whose
162
+ * handler never reads it costs the adapter neither a lookup nor a platform object of its own. */
163
+ export const PLATFORM_PEER = Symbol()
164
+
165
+ export type PeerPlatform = Platform & {
166
+ readonly [PLATFORM_PEER]?: (source: RequestSource) => string | undefined
167
+ }
168
+
160
169
  export class RequestContext implements RawContext {
161
170
  // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
162
171
  // constructor initializes only the eager request state, while lazy fields remain absent until used.
@@ -167,13 +176,16 @@ export class RequestContext implements RawContext {
167
176
  private declare searchValue: string | undefined
168
177
  private declare signalValue: AbortSignal | undefined
169
178
  private declare budgetValue: RequestBudget | undefined
170
- private declare platformValue: Platform | undefined
179
+ private declare platformValue: PeerPlatform | undefined
180
+ private declare peerValue: string | undefined
171
181
 
172
182
  private declare setValue: CtxSet | undefined
173
183
  private declare queryValue: unknown
174
184
  private declare queryReady: boolean
175
185
  private declare headersValue: Record<string, string> | undefined
176
186
  private declare cookiesValue: Readonly<Record<string, string>> | undefined
187
+ declare jsonReply: Response | undefined
188
+ declare jsonBody: unknown
177
189
  private declare readonly source: RequestSource
178
190
  private declare readonly maxBodyBytes: number
179
191
  private declare readonly protoPoisoning: ProtoPoisoning
@@ -268,8 +280,12 @@ export class RequestContext implements RawContext {
268
280
 
269
281
  get clientIp(): string | undefined {
270
282
  // The server resolves the trust declaration into `platform.clientIp` before the context is built,
271
- // so this getter just surfaces the already-derived value (raw socket peer by default).
272
- return this.platformValue?.clientIp
283
+ // so this getter just surfaces the already-derived value (raw socket peer by default). A platform
284
+ // shared across requests holds no address of its own and is asked for this request's peer, once:
285
+ // the answer is kept, so a read after the socket closed agrees with one before it.
286
+ const platform = this.platformValue
287
+ this.peerValue ??= platform?.clientIp ?? platform?.[PLATFORM_PEER]?.(this.source)
288
+ return this.peerValue
273
289
  }
274
290
 
275
291
  get waitUntil(): (promise: Promise<unknown>) => void {
@@ -312,19 +328,27 @@ export class RequestContext implements RawContext {
312
328
  */
313
329
  json(body: unknown, init?: ResponseInit | number): Response {
314
330
  const i = statusInit(init)
315
- if (!DEFERS_RESPONSE) return Response.json(body, i)
316
- const defer = deferredResponderFor()
317
- if (defer === undefined) return Response.json(body, i)
318
- // The same bytes `Response.json` would produce, kept one step short of it so the direct writer
319
- // can have them. `JSON.stringify` returns `undefined` for a value with no JSON form (`undefined`,
320
- // a function, a symbol) - exactly the case where `Response.json` throws, so hand it back the
321
- // throw rather than inventing a body. The content-type is the runtime's own, read off
322
- // `Response.json` once, so a deferred response carries the same one byte for byte.
323
- const text = JSON.stringify(body) as string | undefined
324
- if (text === undefined) return Response.json(body, i)
325
- const headers = ownHeaderRecord(i?.headers, responseJsonContentType())
326
- if (headers === undefined) return Response.json(body, i)
327
- return defer(text, i?.status ?? 200, headers)
331
+ let reply: Response | undefined
332
+ if (DEFERS_RESPONSE) {
333
+ const defer = deferredResponderFor()
334
+ // The same bytes `Response.json` would produce, kept one step short of it so the direct
335
+ // writer can have them. `JSON.stringify` returns `undefined` for a value with no JSON form
336
+ // (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
337
+ // gets the throw rather than an invented body. The content-type is the runtime's own, read
338
+ // off `Response.json` once, so a deferred response carries the same one byte for byte.
339
+ const text = defer === undefined ? undefined : (JSON.stringify(body) as string | undefined)
340
+ const headers =
341
+ text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
342
+ if (defer !== undefined && text !== undefined && headers !== undefined) {
343
+ reply = defer(text, i?.status ?? 200, headers)
344
+ }
345
+ }
346
+ reply ??= Response.json(body, i)
347
+ // Remembered so a response contract holds the value to the route's schema: this is the
348
+ // framework's JSON helper, not a raw-Response escape hatch.
349
+ this.jsonBody = body
350
+ this.jsonReply = reply
351
+ return reply
328
352
  }
329
353
 
330
354
  /**
@@ -370,6 +394,10 @@ export class RequestContext implements RawContext {
370
394
  return this.cookiesValue
371
395
  }
372
396
 
397
+ set cookies(value: Readonly<Record<string, string>>) {
398
+ this.cookiesValue = value
399
+ }
400
+
373
401
  boundedBody(maxBytes?: number): Promise<Uint8Array> {
374
402
  return readBoundedBodyOrThrow(this.source, this.maxBodyBytes, maxBytes)
375
403
  }
@@ -0,0 +1,15 @@
1
+ /** Each request an `onRequest` hook returned in place of the one it was handed, mapped to that one. */
2
+ const REPLACED = new WeakMap<Request, Request>()
3
+
4
+ export function recordRequestReplacement(replacement: Request, replaced: Request): void {
5
+ REPLACED.set(replacement, replaced)
6
+ }
7
+
8
+ /**
9
+ * The request an `onRequest` hook returned this one in place of, or `undefined` for the request the
10
+ * exchange began with. Response hooks receive the request the route ran with, so a middleware that
11
+ * keyed state on the request its `onRequest` saw walks back from there to find it.
12
+ */
13
+ export function replacedRequestOf(request: Request): Request | undefined {
14
+ return REPLACED.get(request)
15
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Dot segments in a request target, resolved the way a WHATWG URL parser resolves them.
3
+ *
4
+ * Bun and workerd hand an app a request whose URL is already parsed: a client that sends
5
+ * `/users/../posts`, `/users/%2e%2e/posts` or `/users/..\posts` is asking for `/posts`, and that
6
+ * is the URL the app sees. Node's `IncomingMessage.url` and Deno's `Request.url` carry the target
7
+ * exactly as it was sent. Routing the raw text on those runtimes and the parsed text on the others
8
+ * sends one request to two different handlers - with two different sets of guards - depending on
9
+ * where the app runs. So a raw target is resolved before anything reads it, and every runtime routes
10
+ * the same path.
11
+ *
12
+ * No imports: `@nifrajs/node` carries a generated copy of this module, since it never depends on
13
+ * core at runtime.
14
+ */
15
+
16
+ // A `.` or `..` segment (`%2e` counts as a dot, in either case), or a backslash, which a WHATWG
17
+ // parser reads as a `/` in an http(s) path.
18
+ const DOT_SEGMENT = /\\|\/(?:\.|%2e){1,2}(?:[/?#]|$)/i
19
+
20
+ /**
21
+ * Whether `target` - an absolute URL or an origin-form `/path?query` - may hold a `.` or `..`
22
+ * segment, raw or percent-encoded, or a backslash: what a WHATWG parser would rewrite. Never false
23
+ * when the path has one. It can be true when only the query looks like one (`?next=/../x`):
24
+ * resolving that target leaves its path and query as they are, apart from the characters a parser
25
+ * always percent-encodes.
26
+ */
27
+ export function hasDotSegment(target: string): boolean {
28
+ // One regex pass: measured faster on Node and Deno than substring scans that gate it.
29
+ return DOT_SEGMENT.test(target)
30
+ }
31
+
32
+ /**
33
+ * `target` with its dot segments resolved and its backslashes read as `/`, by the platform's own
34
+ * WHATWG parser. An origin-form target stays origin-form (the fragment, which no client sends,
35
+ * dropped); an absolute URL comes back absolute. The parser also percent-encodes what it always
36
+ * encodes, which is what Bun and workerd deliver for the same request. A target that is neither -
37
+ * `*`, or text no URL parser accepts - comes back as it arrived: there is no path to resolve.
38
+ */
39
+ export function resolveDotSegments(target: string): string {
40
+ if (target.charCodeAt(0) === 47 /* / */) {
41
+ // Appended to an origin, not resolved against one: `//host/..` must stay a path on this origin.
42
+ const url = new URL(`http://localhost${target}`)
43
+ return url.pathname + url.search
44
+ }
45
+ return URL.canParse(target) ? new URL(target).href : target
46
+ }
@@ -52,6 +52,7 @@ const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
52
52
  */
53
53
  export function buildStaticResponseHeaders(
54
54
  record: Readonly<Record<string, string>>,
55
+ inherited?: Readonly<Record<string, string>>,
55
56
  ): StaticResponseHeaders {
56
57
  const frozen = Object.freeze({ ...record })
57
58
  const jsonHeaders = new Headers({ ...frozen, "content-type": JSON_CONTENT_TYPE })
@@ -61,6 +62,7 @@ export function buildStaticResponseHeaders(
61
62
  entries: Object.freeze(Object.entries(frozen)),
62
63
  jsonHeaders,
63
64
  jsonInit200: { status: 200, headers: jsonHeaders },
65
+ inherited,
64
66
  responseJsonInit200: () => {
65
67
  responseJson ??= {
66
68
  status: 200,
@@ -138,7 +140,9 @@ function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders):
138
140
  const entries = statics.entries
139
141
  for (let i = 0; i < entries.length; i++) {
140
142
  const entry = entries[i]!
141
- if (!headers.has(entry[0])) headers.set(entry[0], entry[1])
143
+ // Absent, or still an enclosing scope's value rather than one the request set: this one applies.
144
+ if (headers.get(entry[0]) === (statics.inherited?.[entry[0]] ?? null))
145
+ headers.set(entry[0], entry[1])
142
146
  }
143
147
  }
144
148
 
@@ -555,8 +559,15 @@ export function appendCookiesToResponse(response: Response, set: CtxSet): Respon
555
559
  // returned Response - otherwise the canonical set-session-then-redirect pattern would silently drop
556
560
  // the cookie. (Other `c.set` fields stay the returned Response's own concern.)
557
561
  const cookies = set._cookies
558
- if (cookies !== undefined && cookies.length > 0) {
562
+ if (cookies === undefined || cookies.length === 0) return response
563
+ try {
559
564
  for (const cookie of cookies) response.headers.append("set-cookie", cookie)
565
+ return response
566
+ } catch {
567
+ // Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
568
+ // first append throws with nothing applied and the cookies land on a mutable copy instead.
569
+ const clone = new Response(response.body, response)
570
+ for (const cookie of cookies) clone.headers.append("set-cookie", cookie)
571
+ return stamped(clone)
560
572
  }
561
- return response
562
573
  }