@nifrajs/core 4.0.2 → 5.1.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 (239) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +86 -6
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +186 -95
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +8 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +53 -1
  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 +8 -3
  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 +5 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -3
  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-compiler.d.ts +9 -0
  49. package/dist/internal/route-compiler.d.ts.map +1 -1
  50. package/dist/internal/route-compiler.js +95 -13
  51. package/dist/internal/route-compiler.js.map +1 -1
  52. package/dist/internal/route-program.d.ts +1 -1
  53. package/dist/internal/route-program.d.ts.map +1 -1
  54. package/dist/internal/route-program.js +59 -16
  55. package/dist/internal/route-program.js.map +1 -1
  56. package/dist/internal/same-origin.d.ts +11 -1
  57. package/dist/internal/same-origin.d.ts.map +1 -1
  58. package/dist/internal/same-origin.js +8 -0
  59. package/dist/internal/same-origin.js.map +1 -1
  60. package/dist/reconciliation-worker.d.ts.map +1 -1
  61. package/dist/reconciliation-worker.js +12 -2
  62. package/dist/reconciliation-worker.js.map +1 -1
  63. package/dist/reflection.d.ts +53 -2
  64. package/dist/reflection.d.ts.map +1 -1
  65. package/dist/reflection.js +352 -5
  66. package/dist/reflection.js.map +1 -1
  67. package/dist/router/pattern.d.ts +1 -1
  68. package/dist/router/pattern.d.ts.map +1 -1
  69. package/dist/router/pattern.js +6 -3
  70. package/dist/router/pattern.js.map +1 -1
  71. package/dist/security/baseline.d.ts +3 -0
  72. package/dist/security/baseline.d.ts.map +1 -1
  73. package/dist/security/baseline.js +9 -5
  74. package/dist/security/baseline.js.map +1 -1
  75. package/dist/server/auth.d.ts +32 -3
  76. package/dist/server/auth.d.ts.map +1 -1
  77. package/dist/server/auth.js +23 -0
  78. package/dist/server/auth.js.map +1 -1
  79. package/dist/server/body-lane.d.ts +8 -2
  80. package/dist/server/body-lane.d.ts.map +1 -1
  81. package/dist/server/body-lane.js +27 -2
  82. package/dist/server/body-lane.js.map +1 -1
  83. package/dist/server/body.d.ts +7 -0
  84. package/dist/server/body.d.ts.map +1 -1
  85. package/dist/server/body.js +34 -1
  86. package/dist/server/body.js.map +1 -1
  87. package/dist/server/client-ip.d.ts +3 -0
  88. package/dist/server/client-ip.d.ts.map +1 -1
  89. package/dist/server/client-ip.js +6 -5
  90. package/dist/server/client-ip.js.map +1 -1
  91. package/dist/server/context.d.ts +2 -1
  92. package/dist/server/context.d.ts.map +1 -1
  93. package/dist/server/cookies.d.ts.map +1 -1
  94. package/dist/server/cookies.js +13 -1
  95. package/dist/server/cookies.js.map +1 -1
  96. package/dist/server/edge.d.ts.map +1 -1
  97. package/dist/server/edge.js +2 -1
  98. package/dist/server/edge.js.map +1 -1
  99. package/dist/server/http.d.ts.map +1 -1
  100. package/dist/server/http.js +5 -1
  101. package/dist/server/http.js.map +1 -1
  102. package/dist/server/idempotency-lane.d.ts +6 -3
  103. package/dist/server/idempotency-lane.d.ts.map +1 -1
  104. package/dist/server/idempotency-lane.js +38 -17
  105. package/dist/server/idempotency-lane.js.map +1 -1
  106. package/dist/server/install.d.ts +2 -0
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +2 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/logger.d.ts.map +1 -1
  111. package/dist/server/logger.js +3 -1
  112. package/dist/server/logger.js.map +1 -1
  113. package/dist/server/methods.d.ts +1 -0
  114. package/dist/server/methods.d.ts.map +1 -1
  115. package/dist/server/methods.js +1 -0
  116. package/dist/server/methods.js.map +1 -1
  117. package/dist/server/node-outcome.d.ts.map +1 -1
  118. package/dist/server/node-outcome.js +29 -11
  119. package/dist/server/node-outcome.js.map +1 -1
  120. package/dist/server/platform-adapters.d.ts.map +1 -1
  121. package/dist/server/platform-adapters.js +3 -2
  122. package/dist/server/platform-adapters.js.map +1 -1
  123. package/dist/server/request-context.d.ts +2 -1
  124. package/dist/server/request-context.d.ts.map +1 -1
  125. package/dist/server/request-context.js +4 -1
  126. package/dist/server/request-context.js.map +1 -1
  127. package/dist/server/respond.d.ts +44 -11
  128. package/dist/server/respond.d.ts.map +1 -1
  129. package/dist/server/respond.js +192 -79
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +21 -6
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +139 -14
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  136. package/dist/server/response-observer-runtime.js +10 -41
  137. package/dist/server/response-observer-runtime.js.map +1 -1
  138. package/dist/server/runtime-core.d.ts +2 -0
  139. package/dist/server/runtime-core.d.ts.map +1 -1
  140. package/dist/server/runtime-core.js +2 -0
  141. package/dist/server/runtime-core.js.map +1 -1
  142. package/dist/server/server-types.d.ts +45 -10
  143. package/dist/server/server-types.d.ts.map +1 -1
  144. package/dist/server/server.d.ts +67 -32
  145. package/dist/server/server.d.ts.map +1 -1
  146. package/dist/server/server.js +481 -334
  147. package/dist/server/server.js.map +1 -1
  148. package/dist/server/sse.d.ts +11 -1
  149. package/dist/server/sse.d.ts.map +1 -1
  150. package/dist/server/sse.js +18 -3
  151. package/dist/server/sse.js.map +1 -1
  152. package/dist/server/transport-lane.d.ts +7 -1
  153. package/dist/server/transport-lane.d.ts.map +1 -1
  154. package/dist/server/transport-lane.js.map +1 -1
  155. package/dist/server/webhook.d.ts +5 -2
  156. package/dist/server/webhook.d.ts.map +1 -1
  157. package/dist/server/webhook.js +17 -1
  158. package/dist/server/webhook.js.map +1 -1
  159. package/dist/server/websocket.d.ts +33 -14
  160. package/dist/server/websocket.d.ts.map +1 -1
  161. package/dist/server/websocket.js +47 -12
  162. package/dist/server/websocket.js.map +1 -1
  163. package/dist/server/ws-bun.d.ts +7 -1
  164. package/dist/server/ws-bun.d.ts.map +1 -1
  165. package/dist/server/ws-bun.js +42 -2
  166. package/dist/server/ws-bun.js.map +1 -1
  167. package/dist/server/ws-hook.d.ts +15 -3
  168. package/dist/server/ws-hook.d.ts.map +1 -1
  169. package/dist/server.d.ts +4 -3
  170. package/dist/server.d.ts.map +1 -1
  171. package/dist/server.js +3 -2
  172. package/dist/server.js.map +1 -1
  173. package/dist/single-copy.d.ts +6 -0
  174. package/dist/single-copy.d.ts.map +1 -1
  175. package/dist/single-copy.js +55 -6
  176. package/dist/single-copy.js.map +1 -1
  177. package/dist/tool-contract.d.ts.map +1 -1
  178. package/dist/tool-contract.js +71 -40
  179. package/dist/tool-contract.js.map +1 -1
  180. package/dist/wire.d.ts +5 -0
  181. package/dist/wire.d.ts.map +1 -1
  182. package/dist/wire.js +5 -0
  183. package/dist/wire.js.map +1 -1
  184. package/dist/ws.d.ts.map +1 -1
  185. package/dist/ws.js +4 -1
  186. package/dist/ws.js.map +1 -1
  187. package/package.json +1 -1
  188. package/src/assurance.ts +299 -111
  189. package/src/capabilities.ts +62 -1
  190. package/src/durable-effect.ts +1 -1
  191. package/src/durable-saga.ts +94 -10
  192. package/src/edge-kit.ts +8 -3
  193. package/src/effect-scope.ts +15 -5
  194. package/src/errors.ts +1 -0
  195. package/src/evidence.ts +12 -3
  196. package/src/index.ts +7 -2
  197. package/src/internal/durable-shared.ts +25 -37
  198. package/src/internal/durable-types.ts +3 -0
  199. package/src/internal/hook-origins.ts +153 -0
  200. package/src/internal/route-assurance.ts +32 -18
  201. package/src/internal/route-compiler.ts +107 -16
  202. package/src/internal/route-program.ts +98 -44
  203. package/src/internal/same-origin.ts +14 -1
  204. package/src/reconciliation-worker.ts +10 -2
  205. package/src/reflection.ts +455 -4
  206. package/src/router/pattern.ts +6 -3
  207. package/src/security/baseline.ts +10 -5
  208. package/src/server/auth.ts +45 -3
  209. package/src/server/body-lane.ts +36 -1
  210. package/src/server/body.ts +34 -1
  211. package/src/server/client-ip.ts +8 -5
  212. package/src/server/context.ts +2 -1
  213. package/src/server/cookies.ts +12 -1
  214. package/src/server/edge.ts +2 -1
  215. package/src/server/http.ts +7 -1
  216. package/src/server/idempotency-lane.ts +64 -22
  217. package/src/server/install.ts +3 -0
  218. package/src/server/logger.ts +3 -1
  219. package/src/server/methods.ts +1 -0
  220. package/src/server/node-outcome.ts +34 -12
  221. package/src/server/platform-adapters.ts +3 -2
  222. package/src/server/request-context.ts +5 -0
  223. package/src/server/respond.ts +222 -69
  224. package/src/server/response-contract-lane.ts +162 -10
  225. package/src/server/response-observer-runtime.ts +16 -38
  226. package/src/server/runtime-core.ts +2 -0
  227. package/src/server/server-types.ts +45 -10
  228. package/src/server/server.ts +707 -539
  229. package/src/server/sse.ts +84 -55
  230. package/src/server/transport-lane.ts +7 -1
  231. package/src/server/webhook.ts +22 -3
  232. package/src/server/websocket.ts +83 -18
  233. package/src/server/ws-bun.ts +48 -1
  234. package/src/server/ws-hook.ts +14 -2
  235. package/src/server.ts +6 -1
  236. package/src/single-copy.ts +55 -5
  237. package/src/tool-contract.ts +67 -39
  238. package/src/wire.ts +14 -0
  239. package/src/ws.ts +6 -0
package/src/server/sse.ts CHANGED
@@ -39,7 +39,10 @@ export interface SSEMessage {
39
39
 
40
40
  /** The stream handed to the `run` callback. */
41
41
  export interface SSEStream {
42
- /** Enqueue an event frame. No-op once the stream is closed. */
42
+ /**
43
+ * Enqueue an event frame. No-op once the stream is closed, including after a client that fell
44
+ * more than `maxBufferedBytes` behind was dropped.
45
+ */
43
46
  send(message: SSEMessage): void
44
47
  /** End the stream now. */
45
48
  close(): void
@@ -59,8 +62,17 @@ export interface SSEInit {
59
62
  readonly headers?: ConstructorParameters<typeof Headers>[0]
60
63
  /** Send a `: ` comment ping every N ms so idle proxies don't drop the connection. */
61
64
  readonly keepAlive?: number
65
+ /**
66
+ * Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
67
+ * `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
68
+ * stalls cannot hold server memory without limit. Raise it for a burst larger than this sent before
69
+ * the client can read; `Infinity` turns the limit off.
70
+ */
71
+ readonly maxBufferedBytes?: number
62
72
  }
63
73
 
74
+ const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024
75
+
64
76
  /**
65
77
  * The stream handed to an `app.sse()` handler: `send` takes the route's TYPED event payload and
66
78
  * serializes it (JSON) into the SSE `data:` field - the compile-time half of the `sse` contract.
@@ -111,6 +123,9 @@ export function sse(
111
123
  run: (stream: SSEStream) => void | Promise<void>,
112
124
  init: SSEInit = {},
113
125
  ): Response {
126
+ const maxBuffered = init.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES
127
+ if (!(maxBuffered >= 0))
128
+ throw new RangeError("sse maxBufferedBytes must be a non-negative number")
114
129
  const encoder = new TextEncoder()
115
130
  const requestSignal: AbortSignal | undefined = c.req?.signal
116
131
  // The producer's signal also aborts when the runtime cancels the body: a writer that finds the
@@ -127,66 +142,80 @@ export function sse(
127
142
  }
128
143
  }
129
144
 
130
- const body = new ReadableStream<Uint8Array>({
131
- start(controller) {
132
- const close = (): void => {
133
- if (closed) return
134
- closed = true
135
- teardown()
136
- try {
137
- controller.close()
138
- } catch {
139
- // Already closed by the runtime's cancel() - safe to ignore.
145
+ const body = new ReadableStream<Uint8Array>(
146
+ {
147
+ start(controller) {
148
+ const close = (): void => {
149
+ if (closed) return
150
+ closed = true
151
+ teardown()
152
+ try {
153
+ controller.close()
154
+ } catch {
155
+ // Already closed by the runtime's cancel() - safe to ignore.
156
+ }
140
157
  }
141
- }
142
- const stream: SSEStream = {
143
- send(message) {
144
- if (!closed) controller.enqueue(encoder.encode(formatMessage(message)))
145
- },
146
- close,
147
- signal: stopped.signal,
148
- }
149
-
150
- // Stop the moment the client goes away.
151
- if (requestSignal !== undefined) {
152
- if (requestSignal.aborted) {
153
- stopped.abort()
154
- close()
155
- return
158
+ const stream: SSEStream = {
159
+ send(message) {
160
+ if (closed) return
161
+ // The queue only shrinks as the runtime writes to the socket, so a backlog past the cap
162
+ // is a client that stopped reading. Erroring the body releases the queue and the socket.
163
+ if ((controller.desiredSize ?? 0) < 0) {
164
+ closed = true
165
+ teardown()
166
+ const reason = new Error("sse_client_too_slow")
167
+ stopped.abort(reason)
168
+ controller.error(reason)
169
+ return
170
+ }
171
+ controller.enqueue(encoder.encode(formatMessage(message)))
172
+ },
173
+ close,
174
+ signal: stopped.signal,
175
+ }
176
+
177
+ // Stop the moment the client goes away.
178
+ if (requestSignal !== undefined) {
179
+ if (requestSignal.aborted) {
180
+ stopped.abort()
181
+ close()
182
+ return
183
+ }
184
+ onAbort = () => {
185
+ stopped.abort()
186
+ close()
187
+ }
188
+ requestSignal.addEventListener("abort", onAbort, { once: true })
156
189
  }
157
- onAbort = () => {
158
- stopped.abort()
159
- close()
190
+
191
+ if (init.keepAlive !== undefined && init.keepAlive > 0) {
192
+ heartbeat = setInterval(() => stream.send({ comment: "" }), init.keepAlive)
193
+ // Don't let the ping keep a Node process alive on its own.
194
+ ;(heartbeat as { unref?: () => void }).unref?.()
160
195
  }
161
- requestSignal.addEventListener("abort", onAbort, { once: true })
162
- }
163
-
164
- if (init.keepAlive !== undefined && init.keepAlive > 0) {
165
- heartbeat = setInterval(() => stream.send({ comment: "" }), init.keepAlive)
166
- // Don't let the ping keep a Node process alive on its own.
167
- ;(heartbeat as { unref?: () => void }).unref?.()
168
- }
169
- // An async IIFE so a synchronous throw in `run` becomes a rejection we can surface.
170
- ;(async () => {
171
- await run(stream)
172
- })().then(close, (err: unknown) => {
173
- if (closed) return
196
+ // An async IIFE so a synchronous throw in `run` becomes a rejection we can surface.
197
+ ;(async () => {
198
+ await run(stream)
199
+ })().then(close, (err: unknown) => {
200
+ if (closed) return
201
+ closed = true
202
+ teardown()
203
+ try {
204
+ controller.error(err instanceof Error ? err : new Error("sse_producer_failed"))
205
+ } catch {
206
+ // Stream already torn down.
207
+ }
208
+ })
209
+ },
210
+ cancel() {
211
+ // Consumer/runtime canceled (client gone) - pending sends become no-ops.
174
212
  closed = true
175
213
  teardown()
176
- try {
177
- controller.error(err instanceof Error ? err : new Error("sse_producer_failed"))
178
- } catch {
179
- // Stream already torn down.
180
- }
181
- })
182
- },
183
- cancel() {
184
- // Consumer/runtime canceled (client gone) - pending sends become no-ops.
185
- closed = true
186
- teardown()
187
- stopped.abort()
214
+ stopped.abort()
215
+ },
188
216
  },
189
- })
217
+ { highWaterMark: maxBuffered, size: (chunk) => chunk?.byteLength ?? 0 },
218
+ )
190
219
 
191
220
  const headers = new Headers(init.headers)
192
221
  headers.set("content-type", "text/event-stream; charset=utf-8")
@@ -27,7 +27,13 @@ export interface TransportRuntime {
27
27
  }
28
28
 
29
29
  export interface TransportCodecsOptions {
30
- /** Maximum encoded request bytes. Keep aligned with `server({ maxBodyBytes })`. */
30
+ /**
31
+ * Maximum encoded request bytes. Default 1,000,000. Keep aligned with `server({ maxBodyBytes })`.
32
+ *
33
+ * The plugin decodes a codec body in an `onRequest` hook, before routing, so a route's own
34
+ * `bodyLimit` and its `authenticate()` stages run only after the decode. This cap is the only
35
+ * bound on that work: set it to the largest codec body any route should accept.
36
+ */
31
37
  readonly maxBytes?: number
32
38
  /**
33
39
  * Prototype-poisoning policy for decoded request bodies, mirroring
@@ -19,14 +19,17 @@ const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB - webhook payloads are small; cap
19
19
  const DEFAULT_TOLERANCE_SECONDS = 300 // 5-min replay window for timestamped schemes (Stripe's default).
20
20
 
21
21
  export type WebhookProvider = "stripe" | "github" | "generic"
22
- export type SignatureEncoding = "hex" | "base64"
22
+ export type SignatureEncoding = "hex" | "base64" | "base64url"
23
23
 
24
24
  export interface VerifyWebhookOptions {
25
25
  /** Known-provider preset. Default `"generic"` (which requires {@link header}). */
26
26
  readonly provider?: WebhookProvider
27
27
  /** Header carrying the signature. Required for `generic`; overrides the preset otherwise. */
28
28
  readonly header?: string
29
- /** How the signature is encoded in the header. Default `"hex"`. */
29
+ /**
30
+ * How the signature is encoded in the header. Default `"hex"`. `"base64url"` accepts the padded and
31
+ * unpadded forms and nothing outside its alphabet.
32
+ */
30
33
  readonly encoding?: SignatureEncoding
31
34
  /** A prefix stripped from the header value before decoding, e.g. `"sha256="`. */
32
35
  readonly prefix?: string
@@ -96,10 +99,26 @@ const fromBase64 = (value: string): Uint8Array<ArrayBuffer> | null => {
96
99
  }
97
100
  }
98
101
 
102
+ const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/
103
+
104
+ const fromBase64Url = (value: string): Uint8Array<ArrayBuffer> | null => {
105
+ if (!BASE64URL.test(value)) return null
106
+ const unpadded = value.replace(/=+$/, "")
107
+ // Padding only ever completes the last quartet; a lone trailing character encodes no byte.
108
+ if (unpadded.length % 4 === 1) return null
109
+ if (unpadded.length !== value.length && value.length % 4 !== 0) return null
110
+ return fromBase64(unpadded.replaceAll("-", "+").replaceAll("_", "/"))
111
+ }
112
+
99
113
  const decodeSignature = (
100
114
  value: string,
101
115
  encoding: SignatureEncoding,
102
- ): Uint8Array<ArrayBuffer> | null => (encoding === "hex" ? fromHex(value) : fromBase64(value))
116
+ ): Uint8Array<ArrayBuffer> | null =>
117
+ encoding === "hex"
118
+ ? fromHex(value)
119
+ : encoding === "base64url"
120
+ ? fromBase64Url(value)
121
+ : fromBase64(value)
103
122
 
104
123
  /** True iff `sig` is a valid HMAC-SHA256 of `payload` under any of `keys` (constant-time per check). */
105
124
  async function verifyAny(
@@ -18,6 +18,7 @@ import type {
18
18
  import { validateStandard } from "../schema/standard.ts"
19
19
  import { decodeTransportFrame, type TransportCodecRegistry } from "../transport-codec.ts"
20
20
  import { guardDecodedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
+ import { isResponseResult } from "./runtime-core.ts"
21
22
 
22
23
  type MaybePromise<T> = T | Promise<T>
23
24
 
@@ -133,6 +134,7 @@ export interface WebSocketHandler<
133
134
  Env = unknown,
134
135
  Schema extends StandardSchemaV1 | undefined = undefined,
135
136
  Send extends StandardSchemaV1 | undefined = undefined,
137
+ Extra = unknown,
136
138
  > {
137
139
  /**
138
140
  * Cross-site WebSocket hijacking (CSWSH) guard - checked BEFORE `upgrade()`. A browser does not
@@ -150,12 +152,22 @@ export interface WebSocketHandler<
150
152
  */
151
153
  allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
152
154
  /**
153
- * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) - the
154
- * place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a
155
- * `Response` to **reject** the upgrade (the client never connects). Omit to accept with
156
- * `data: undefined`. A thrown error rejects with a flat 500.
155
+ * The capability ids this socket's code may reach, exactly as an HTTP route's `capabilities`.
156
+ * `nifra capabilities check` compares them with the effects reachable from the module that
157
+ * registers the socket. The handshake context carries the guard, so a runtime beacon in `upgrade()`
158
+ * (or one that keeps `c`) fails closed on an undeclared capability.
157
159
  */
158
- upgrade?(c: WebSocketContext<Env>): MaybePromise<Data | Response>
160
+ capabilities?: readonly string[]
161
+ /**
162
+ * Runs in the HTTP request context **before** the upgrade, last in the chain an HTTP `GET` to the
163
+ * same path runs: after {@link allowedOrigins}, then the `authenticate()` stages, `derive`s and
164
+ * `beforeHandle`s registered before this route, any of which can refuse the handshake. `c` carries
165
+ * what they derived. Return the initial per-connection `data` (→ `ws.data`), or a `Response` or
166
+ * `status(...)` to **reject** the upgrade (the client never connects). Omit to accept with
167
+ * `data: undefined`. A thrown `Response` rejects with it; any other throw goes to the route's
168
+ * `onError` hooks, then a flat 500.
169
+ */
170
+ upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response>
159
171
  /**
160
172
  * Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
161
173
  * Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
@@ -221,8 +233,19 @@ export type WebSocketUpgradeOutcome =
221
233
  * `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
222
234
  readonly attach: WsAttach
223
235
  readonly maxPayloadBytes?: number
236
+ /** Builds `ws.send` over an adapter's native send, enforcing the route's {@link
237
+ * WebSocketHandler.validateSend}. Set on every outcome a nifra app produces; an adapter that
238
+ * wires its own sockets instead of calling `attach` (Node, Deno) builds `send` with it. */
239
+ readonly sender?: WsSender
224
240
  }
225
241
 
242
+ /** The send-builder the upgrade outcome carries (the installed runtime's `sender`). */
243
+ export type WsSender = (
244
+ handler: WebSocketHandler,
245
+ send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
246
+ onError: (error: unknown) => void,
247
+ ) => (payload: string | ArrayBufferView | ArrayBuffer) => void
248
+
226
249
  /** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */
227
250
  export type WsAttach = (
228
251
  socket: StandardWebSocket,
@@ -313,18 +336,11 @@ export function attachWebSocket<
313
336
  socket.addEventListener("message", (event: { readonly data: unknown }) => {
314
337
  const raw = event.data
315
338
  const payload: WebSocketData = typeof raw === "string" ? raw : toBinary(raw)
316
- // Measuring a text frame costs a full UTF-8 encode plus the buffer it allocates, so it happens
317
- // only for a route that actually declared a cap. Reading `.byteLength` off an already-decoded
318
- // binary frame is free, but the branch stays under the same guard to keep one shape here.
319
- if (maxPayloadBytes !== undefined) {
320
- const payloadBytes =
321
- typeof raw === "string"
322
- ? WS_PAYLOAD_ENCODER.encode(raw).byteLength
323
- : (payload as Uint8Array).byteLength
324
- if (payloadBytes > maxPayloadBytes) {
325
- socket.close(1009, "message too large")
326
- return
327
- }
339
+ // The runtime hands over each message whole, so this bounds what reaches the handler, not what
340
+ // the runtime buffered to deliver it.
341
+ if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
342
+ socket.close(1009, "message too large")
343
+ return
328
344
  }
329
345
  safe(() =>
330
346
  (
@@ -351,7 +367,25 @@ export function attachWebSocket<
351
367
  }
352
368
 
353
369
  const WS_MESSAGE_DECODER = new TextDecoder()
354
- const WS_PAYLOAD_ENCODER = new TextEncoder()
370
+
371
+ /** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
372
+ * 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
373
+ export function exceedsPayloadCap(
374
+ message: string | ArrayBuffer | Uint8Array,
375
+ cap: number,
376
+ ): boolean {
377
+ if (typeof message !== "string") return message.byteLength > cap
378
+ const units = message.length
379
+ if (units > cap) return true
380
+ if (units * 3 <= cap) return false
381
+ let bytes = 0
382
+ for (let i = 0; i < units; i++) {
383
+ const unit = message.charCodeAt(i)
384
+ bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
385
+ if (bytes > cap) return true
386
+ }
387
+ return false
388
+ }
355
389
 
356
390
  type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
357
391
 
@@ -436,6 +470,10 @@ export function createWebSocketSender(
436
470
  }
437
471
  }
438
472
 
473
+ /** {@link createWebSocketSender} configured from a route's handler: the outcome's `sender`. */
474
+ export const webSocketSender: WsSender = (handler, send, onError) =>
475
+ createWebSocketSender(send, handler.sendSchema, handler.validateSend, handler.transport, onError)
476
+
439
477
  /**
440
478
  * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
441
479
  * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
@@ -444,6 +482,33 @@ export function createWebSocketSender(
444
482
  * `protoPoisoning` is the app's body policy: a frame with a `__proto__` key gets what a JSON body
445
483
  * with one gets, and under `"reject"` reads as invalid JSON.
446
484
  */
485
+ /** `upgrade()`'s acceptance, wrapped so a handshake's route chain tells it from an early result such
486
+ * as a guard's `Response` or a rejected auth stage. */
487
+ class HandshakeAccepted {
488
+ readonly data: unknown
489
+ constructor(data: unknown) {
490
+ this.data = data
491
+ }
492
+ }
493
+
494
+ const acceptHandshake = (value: unknown): unknown =>
495
+ value instanceof Response || isResponseResult(value) ? value : new HandshakeAccepted(value)
496
+
497
+ /** The step a handshake's route chain ends in: the route's `upgrade()`, or an acceptance without data. */
498
+ export function handshakeUpgradeStep(
499
+ handler: WebSocketHandler,
500
+ ): (ctx: WebSocketContext) => unknown {
501
+ return (ctx) => {
502
+ const data = handler.upgrade?.(ctx)
503
+ return data instanceof Promise ? data.then(acceptHandshake) : acceptHandshake(data)
504
+ }
505
+ }
506
+
507
+ /** The acceptance a handshake's route chain ended in, or `undefined` when the chain refused it. */
508
+ export function acceptedHandshake(result: unknown): { readonly data: unknown } | undefined {
509
+ return result instanceof HandshakeAccepted ? result : undefined
510
+ }
511
+
447
512
  export function wrapWebSocketMessageValidation(
448
513
  handler: WebSocketHandler,
449
514
  protoPoisoning: ProtoPoisoning = "reject",
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  createWebSocketSender,
10
+ exceedsPayloadCap,
10
11
  type NifraWebSocket,
11
12
  type TopicRegistry,
12
13
  type WebSocketData,
@@ -20,6 +21,9 @@ export interface BunWsData {
20
21
  readonly handler: WebSocketHandler
21
22
  /** Mounted/modern upgrades carry this registry; old/manual fixtures may omit it. */
22
23
  readonly pubsub?: TopicRegistry
24
+ /** The inbound cap of the app this socket belongs to (the upgrade outcome's). Absent, the
25
+ * listening app's applies. */
26
+ readonly maxPayloadBytes?: number | undefined
23
27
  data: unknown
24
28
  nifra?: NifraWebSocket
25
29
  }
@@ -38,6 +42,31 @@ export interface BunSocket {
38
42
  readonly data: BunWsData
39
43
  }
40
44
 
45
+ /**
46
+ * The largest `wsMaxPayloadBytes` among the nifra apps mounted under `app` that serve sockets, or
47
+ * `floor` when none declares more. Bun takes one frame limit per listener, so it has to admit every
48
+ * mounted app's cap. Reads each server's own mount table, as reflection does, so an app that never
49
+ * listens pays nothing for it; a child that is not a nifra server names no cap here.
50
+ */
51
+ function mountedPayloadCeiling(app: object, floor: number, seen: Set<object>): number {
52
+ if (seen.has(app)) return floor
53
+ seen.add(app)
54
+ const mounts: unknown = Reflect.get(app, "fetchMounts")
55
+ if (!Array.isArray(mounts)) return floor
56
+ let ceiling = floor
57
+ for (const mount of mounts) {
58
+ const child: unknown = typeof mount === "object" && mount !== null ? mount.app : undefined
59
+ if (typeof child !== "object" || child === null) continue
60
+ const routes: unknown = Reflect.get(child, "wsRoutes")
61
+ const cap: unknown = Reflect.get(child, "wsMaxPayloadBytes")
62
+ if (Array.isArray(routes) && routes.length > 0 && typeof cap === "number" && cap > ceiling) {
63
+ ceiling = cap
64
+ }
65
+ ceiling = mountedPayloadCeiling(child, ceiling, seen)
66
+ }
67
+ return ceiling
68
+ }
69
+
41
70
  /** Normalize a received binary frame to `Uint8Array` (Bun hands a `Buffer`, already one; an
42
71
  * `ArrayBuffer` is wrapped). Text frames stay `string`. */
43
72
  function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
@@ -127,9 +156,21 @@ function dispatchWsCallback(
127
156
  * `nativePubsub` (set by `listen()` when the app has no validated-send route) makes subscriptions use
128
157
  * Bun's own pub/sub; there the registry holds nothing and Bun auto-drops native subscriptions on
129
158
  * close, so the `unsubscribeAll` sweep is skipped.
159
+ *
160
+ * `app` is the listening server. Its `wsMaxPayloadBytes` and its mounted apps' set the listener's
161
+ * `maxPayloadLength`; a socket whose app declares less is closed with `1009` here.
130
162
  */
131
- export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false): BunWsHandlers {
163
+ export function createBunWsHandlers(
164
+ topics: TopicRegistry,
165
+ nativePubsub = false,
166
+ app?: object,
167
+ ): BunWsHandlers {
168
+ const own: unknown = app === undefined ? undefined : Reflect.get(app, "wsMaxPayloadBytes")
169
+ const appCap = typeof own === "number" ? own : Number.POSITIVE_INFINITY
170
+ const listenerCap =
171
+ app === undefined ? appCap : mountedPayloadCeiling(app, appCap, new Set<object>())
132
172
  return {
173
+ maxPayloadLength: listenerCap,
133
174
  open: (raw) => {
134
175
  const ws = raw as BunSocket
135
176
  const nifra = wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
@@ -138,6 +179,12 @@ export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false)
138
179
  },
139
180
  message: (raw, message) => {
140
181
  const ws = raw as BunSocket
182
+ // Only a socket whose app declares less than the listener admits pays for a size check.
183
+ const cap = ws.data.maxPayloadBytes ?? appCap
184
+ if (cap < listenerCap && exceedsPayloadCap(message, cap)) {
185
+ ws.close(1009, "message too large")
186
+ return
187
+ }
141
188
  const handler = ws.data.handler
142
189
  const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, handler, nativePubsub)
143
190
  const data: WebSocketData = typeof message === "string" ? message : toBinaryData(message)
@@ -13,7 +13,9 @@ import type {
13
13
  NifraWebSocket,
14
14
  StandardWebSocket,
15
15
  TopicRegistry,
16
+ WebSocketContext,
16
17
  WebSocketHandler,
18
+ WsSender,
17
19
  } from "./websocket.ts"
18
20
 
19
21
  /** The Bun serve `websocket` callback trio, closed over one app's `TopicRegistry`. The `ws`
@@ -23,17 +25,25 @@ export interface BunWsHandlers {
23
25
  open(ws: unknown): void
24
26
  message(ws: unknown, message: string | ArrayBuffer | Uint8Array): void
25
27
  close(ws: unknown, code: number, reason: string): void
28
+ /** The listener's `maxPayloadLength`: the largest `wsMaxPayloadBytes` among the app and the apps
29
+ * mounted on it. A socket whose app declares less checks its own cap per message. */
30
+ readonly maxPayloadLength: number
26
31
  }
27
32
 
28
33
  /** What `@nifrajs/core/ws` registers: everything `server.ts` and `toFetchHandler` need at runtime. */
29
34
  export interface WsRuntime {
30
35
  /** `wrapWebSocketMessageValidation` - applied once at `app.ws()` registration. */
31
36
  wrapHandler(handler: WebSocketHandler, protoPoisoning: ProtoPoisoning): WebSocketHandler
37
+ /** `handshakeUpgradeStep` - the step a handshake's route chain ends in. */
38
+ upgradeStep(handler: WebSocketHandler): (ctx: WebSocketContext) => unknown
39
+ /** `acceptedHandshake` - the acceptance a chain ended in, `undefined` when it refused. */
40
+ accepted(result: unknown): { readonly data: unknown } | undefined
32
41
  /** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
33
42
  createTopics(): TopicRegistry
34
43
  /** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
35
- * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). */
36
- bunHandlers(topics: TopicRegistry, nativePubsub?: boolean): BunWsHandlers
44
+ * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). `app` is
45
+ * the listening server, whose mount table names the payload caps the listener has to admit. */
46
+ bunHandlers(topics: TopicRegistry, nativePubsub?: boolean, app?: object): BunWsHandlers
37
47
  /** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
38
48
  attach(
39
49
  socket: StandardWebSocket,
@@ -41,4 +51,6 @@ export interface WsRuntime {
41
51
  data: unknown,
42
52
  options: { openNow: boolean; pubsub: TopicRegistry },
43
53
  ): NifraWebSocket
54
+ /** `webSocketSender` - `ws.send` for an adapter that wires its own sockets (Node, Deno). */
55
+ sender: WsSender
44
56
  }
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
12
12
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
13
- export { isSameOriginRequest } from "./internal/same-origin.ts"
13
+ export { isSameOriginForm, isSameOriginRequest } from "./internal/same-origin.ts"
14
14
  export {
15
15
  ROUTE_PATTERN_OVERLAP_MAX_STATES,
16
16
  RoutePatternOverlapLimitError,
@@ -42,6 +42,8 @@ export {
42
42
  type AuthHeaders,
43
43
  type AuthMaybePromise,
44
44
  authenticated,
45
+ isAuthenticatedRequest,
46
+ markAuthenticatedRequest,
45
47
  rejected,
46
48
  } from "./server/auth.ts"
47
49
  export type {
@@ -104,6 +106,7 @@ export type {
104
106
  RouteInfoFor,
105
107
  } from "./server/registry.ts"
106
108
  export { replacedRequestOf } from "./server/request-lineage.ts"
109
+ export { writableResponse } from "./server/respond.ts"
107
110
  export {
108
111
  type PlainRender,
109
112
  type ResponseResult,
@@ -115,6 +118,7 @@ export {
115
118
  type AdmissionDecision,
116
119
  type AnyServer,
117
120
  type ContextPlugin,
121
+ type DeclaredMountRoute,
118
122
  type DefinePluginResult,
119
123
  defineContextPlugin,
120
124
  defineIdentityPlugin,
@@ -137,6 +141,7 @@ export {
137
141
  type NodeResponseHook,
138
142
  type NodeServeOutcome,
139
143
  type OnRequestResult,
144
+ type OpaqueMountDeclaration,
140
145
  type PluginTypeCollapsed,
141
146
  type PromptArgument,
142
147
  type PromptMessage,