@nifrajs/core 2.14.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -0,0 +1,19 @@
1
+ import type { StandardIssue } from "../schema/standard.ts"
2
+ import { type ResponseResult, status } from "./runtime-core.ts"
3
+
4
+ function validationIssues(issues: ReadonlyArray<StandardIssue>): {
5
+ ok: false
6
+ error: string
7
+ issues: unknown[]
8
+ } {
9
+ const serialized = issues.map((issue) => {
10
+ const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
11
+ return path !== undefined ? { message: issue.message, path } : { message: issue.message }
12
+ })
13
+ return { ok: false, error: "validation", issues: serialized }
14
+ }
15
+
16
+ /** The shared 422 response result used by every body-validation lane. */
17
+ export function plainValidationError(issues: ReadonlyArray<StandardIssue>): ResponseResult {
18
+ return status(422, validationIssues(issues))
19
+ }
@@ -164,10 +164,18 @@ export interface WebSocketHandler<
164
164
  /**
165
165
  * The **outbound** frame contract (server → client), a Standard Schema. Purely type-level: it types
166
166
  * the frames the typed client's `.ws()` handle receives, and documents what this route pushes. The
167
- * server does NOT runtime-validate its own sends - the inbound `messageSchema` guards the trust
168
- * boundary; outbound honesty is the handler author's code, checked by tests.
167
+ * server does NOT runtime-validate its own sends by default - the inbound `messageSchema` guards the
168
+ * trust boundary; outbound honesty is the handler author's code. Set {@link validateSend} to opt into
169
+ * a synchronous runtime check of JSON text/UTF-8 binary frames.
169
170
  */
170
171
  sendSchema?: Send
172
+ /**
173
+ * Opt-in runtime enforcement for {@link sendSchema}. Invalid JSON, schema failures, and async
174
+ * validators are dropped before the native socket's `send()` runs and reported to `error()` when it
175
+ * exists. This is a development/contract check for the server's own output, not an inbound security
176
+ * boundary. It is off by default and requires `sendSchema` at registration.
177
+ */
178
+ validateSend?: boolean
171
179
  /** Decode versioned transport frames before inbound schema validation. Omit for legacy JSON. */
172
180
  transport?: {
173
181
  readonly registry: TransportCodecRegistry
@@ -246,24 +254,19 @@ function toBinary(raw: unknown): Uint8Array {
246
254
  * `open` event (Deno). Lifecycle callbacks are error-isolated - a throw (or async rejection) routes to
247
255
  * `error()` and never tears the process down; binary frames are normalized to `Uint8Array`.
248
256
  */
249
- export function attachWebSocket(
257
+ export function attachWebSocket<
258
+ Data = unknown,
259
+ Env = unknown,
260
+ Schema extends StandardSchemaV1 | undefined = undefined,
261
+ Send extends StandardSchemaV1 | undefined = undefined,
262
+ >(
250
263
  socket: StandardWebSocket,
251
- handler: WebSocketHandler,
264
+ handler: WebSocketHandler<Data, Env, Schema, Send>,
252
265
  data: unknown,
253
266
  options: { openNow: boolean; pubsub: TopicRegistry; maxPayloadBytes?: number },
254
- ): NifraWebSocket {
267
+ ): NifraWebSocket<Data> {
255
268
  const { pubsub } = options
256
- const ws: NifraWebSocket = {
257
- send: (payload) => socket.send(payload),
258
- close: (code, reason) => socket.close(code, reason),
259
- get readyState() {
260
- return socket.readyState
261
- },
262
- subscribe: (topic) => pubsub.subscribe(topic, ws),
263
- unsubscribe: (topic) => pubsub.unsubscribe(topic, ws),
264
- data,
265
- raw: socket,
266
- }
269
+ let ws!: NifraWebSocket<Data>
267
270
  const reportError = (error: unknown): void => {
268
271
  if (handler.error === undefined) return
269
272
  try {
@@ -273,6 +276,24 @@ export function attachWebSocket(
273
276
  /* the error handler itself failed - last resort, swallow */
274
277
  }
275
278
  }
279
+ const send = createWebSocketSender(
280
+ (payload) => socket.send(payload),
281
+ handler.sendSchema,
282
+ handler.validateSend,
283
+ handler.transport,
284
+ reportError,
285
+ )
286
+ ws = {
287
+ send,
288
+ close: (code, reason) => socket.close(code, reason),
289
+ get readyState() {
290
+ return socket.readyState
291
+ },
292
+ subscribe: (topic) => pubsub.subscribe(topic, ws),
293
+ unsubscribe: (topic) => pubsub.unsubscribe(topic, ws),
294
+ data: data as Data,
295
+ raw: socket,
296
+ }
276
297
  const safe = (call: () => MaybePromise<void>): void => {
277
298
  try {
278
299
  const r = call()
@@ -300,7 +321,13 @@ export function attachWebSocket(
300
321
  return
301
322
  }
302
323
  }
303
- safe(() => handler.message?.(ws, payload))
324
+ safe(() =>
325
+ (
326
+ handler.message as
327
+ | ((socket: NifraWebSocket<Data>, value: WebSocketData) => MaybePromise<void>)
328
+ | undefined
329
+ )?.(ws, payload),
330
+ )
304
331
  })
305
332
  socket.addEventListener(
306
333
  "close",
@@ -321,6 +348,89 @@ export function attachWebSocket(
321
348
  const WS_MESSAGE_DECODER = new TextDecoder()
322
349
  const WS_PAYLOAD_ENCODER = new TextEncoder()
323
350
 
351
+ type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
352
+
353
+ const OUTBOUND_VALIDATION_ERROR = "outbound WebSocket frame failed sendSchema validation"
354
+
355
+ function outboundValueOf(
356
+ payload: WebSocketSendPayload,
357
+ transport: WebSocketHandler["transport"],
358
+ ): unknown {
359
+ const text =
360
+ typeof payload === "string"
361
+ ? payload
362
+ : WS_MESSAGE_DECODER.decode(
363
+ payload instanceof ArrayBuffer
364
+ ? new Uint8Array(payload)
365
+ : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength),
366
+ )
367
+ return transport === undefined
368
+ ? JSON.parse(text)
369
+ : decodeTransportFrame(text, transport.registry, {
370
+ ...(transport.maxBytes === undefined ? {} : { maxBytes: transport.maxBytes }),
371
+ })
372
+ }
373
+
374
+ /**
375
+ * Build the socket's send function once. The disabled path returns the native sender directly, so
376
+ * the default type-only contract adds no per-frame branch or schema work. Runtime validation is
377
+ * deliberately synchronous: a socket `send()` cannot await an async Standard Schema without changing
378
+ * the portable API, so an async result fails closed and is never sent.
379
+ */
380
+ export function createWebSocketSender(
381
+ send: (payload: WebSocketSendPayload) => void,
382
+ schema: StandardSchemaV1 | undefined,
383
+ enabled: boolean | undefined,
384
+ transport: WebSocketHandler["transport"],
385
+ onError: (error: unknown) => void,
386
+ ): (payload: WebSocketSendPayload) => void {
387
+ if (enabled !== true || schema === undefined) return send
388
+ let reporting = false
389
+ const reportFailure = (error: unknown): void => {
390
+ // An error handler may try to send a diagnostic. Do not recurse forever when that diagnostic also
391
+ // violates the same outbound contract. EVERY failure path reports through here - a path that calls
392
+ // `onError` directly reopens that recursion for the one rejection shape it happens to cover.
393
+ if (reporting) return
394
+ reporting = true
395
+ try {
396
+ onError(error)
397
+ } finally {
398
+ reporting = false
399
+ }
400
+ }
401
+ return (payload) => {
402
+ let value: unknown
403
+ try {
404
+ value = outboundValueOf(payload, transport)
405
+ } catch {
406
+ reportFailure(new Error(OUTBOUND_VALIDATION_ERROR))
407
+ return
408
+ }
409
+
410
+ let outcome: ValidationOutcome<unknown> | Promise<ValidationOutcome<unknown>>
411
+ try {
412
+ outcome = validateStandard(schema, value) as
413
+ | ValidationOutcome<unknown>
414
+ | Promise<ValidationOutcome<unknown>>
415
+ } catch {
416
+ reportFailure(new Error(OUTBOUND_VALIDATION_ERROR))
417
+ return
418
+ }
419
+ if (outcome instanceof Promise) {
420
+ // Do not await or send later: the portable `send()` contract is synchronous. Attach a rejection
421
+ // handler so a validator that rejects cannot become an unhandled process-level rejection.
422
+ outcome.catch(() => {})
423
+ reportFailure(new Error(`${OUTBOUND_VALIDATION_ERROR}; validator must be synchronous`))
424
+ return
425
+ }
426
+ if (!outcome.ok) {
427
+ reportFailure(new Error(OUTBOUND_VALIDATION_ERROR))
428
+ return
429
+ }
430
+ send(payload)
431
+ }
432
+ }
433
+
324
434
  /**
325
435
  * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
326
436
  * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
@@ -5,7 +5,13 @@
5
5
  * (erased). No `Bun.*` calls here - the runtime seam stays in `server.ts` (see runtime-boundary.test).
6
6
  */
7
7
 
8
- import type { NifraWebSocket, TopicRegistry, WebSocketData, WebSocketHandler } from "./websocket.ts"
8
+ import {
9
+ createWebSocketSender,
10
+ type NifraWebSocket,
11
+ type TopicRegistry,
12
+ type WebSocketData,
13
+ type WebSocketHandler,
14
+ } from "./websocket.ts"
9
15
  import type { BunWsHandlers } from "./ws-hook.ts"
10
16
 
11
17
  /** Per-connection state Bun's `websocket` callbacks read via `ws.data`: the matched handler, the
@@ -22,6 +28,10 @@ export interface BunSocket {
22
28
  send(data: string | ArrayBufferView | ArrayBuffer, compress?: boolean): number
23
29
  close(code?: number, reason?: string): void
24
30
  readonly readyState: number
31
+ // Bun's native (uWebSockets) topic pub/sub - used only in native-pubsub mode (no per-socket
32
+ // outbound validation), where `app.publish` routes through the Bun server's `publish`.
33
+ subscribe(topic: string): void
34
+ unsubscribe(topic: string): void
25
35
  // readonly: we never reassign `ws.data` wholesale, only mutate its fields (`.nifra`, `.data`).
26
36
  readonly data: BunWsData
27
37
  }
@@ -32,18 +42,43 @@ function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
32
42
  return message instanceof Uint8Array ? message : new Uint8Array(message)
33
43
  }
34
44
 
35
- /** Wrap a Bun `ServerWebSocket` as the portable {@link NifraWebSocket} handed to WS callbacks. */
36
- function wrapBunSocket(raw: BunSocket, topics: TopicRegistry): NifraWebSocket {
37
- const ws: NifraWebSocket = {
38
- send: (data) => {
45
+ /**
46
+ * Wrap a Bun `ServerWebSocket` as the portable {@link NifraWebSocket} handed to WS callbacks.
47
+ *
48
+ * `nativePubsub` selects the topic backend: in native mode `subscribe`/`unsubscribe` go straight to
49
+ * Bun's own (uWebSockets) pub/sub so a broadcast via the Bun server's `publish` reaches this socket
50
+ * with no per-connection JS loop; otherwise they use the runtime-neutral {@link TopicRegistry}. Native
51
+ * mode is used only when no route validates outbound frames, so raw and validated sends are identical.
52
+ */
53
+ function wrapBunSocket(
54
+ raw: BunSocket,
55
+ topics: TopicRegistry,
56
+ handler: WebSocketHandler,
57
+ nativePubsub: boolean,
58
+ ): NifraWebSocket {
59
+ let ws!: NifraWebSocket
60
+ const reportError = (error: unknown): void => reportWsError(error, ws, handler)
61
+ const send = createWebSocketSender(
62
+ (data) => {
39
63
  raw.send(data)
40
64
  },
65
+ handler.sendSchema,
66
+ handler.validateSend,
67
+ handler.transport,
68
+ reportError,
69
+ )
70
+ ws = {
71
+ send,
41
72
  close: (code, reason) => raw.close(code, reason),
42
73
  get readyState() {
43
74
  return raw.readyState
44
75
  },
45
- subscribe: (topic) => topics.subscribe(topic, ws),
46
- unsubscribe: (topic) => topics.unsubscribe(topic, ws),
76
+ subscribe: nativePubsub
77
+ ? (topic) => raw.subscribe(topic)
78
+ : (topic) => topics.subscribe(topic, ws),
79
+ unsubscribe: nativePubsub
80
+ ? (topic) => raw.unsubscribe(topic)
81
+ : (topic) => topics.unsubscribe(topic, ws),
47
82
  get data() {
48
83
  return raw.data.data
49
84
  },
@@ -80,26 +115,43 @@ function dispatchWsCallback(
80
115
  }
81
116
  }
82
117
 
83
- /** The shared Bun `websocket` dispatcher config for one app - each connection's
84
- * `ws.data.handler` is the matched route's handler, set by `server.upgrade`. */
85
- export function createBunWsHandlers(topics: TopicRegistry): BunWsHandlers {
118
+ /**
119
+ * The shared Bun `websocket` dispatcher config for one app - each connection's `ws.data.handler` is
120
+ * the matched route's handler, set by `server.upgrade`.
121
+ *
122
+ * `nativePubsub` (set by `listen()` when the app has no validated-send route) makes subscriptions use
123
+ * Bun's own pub/sub; there the registry holds nothing and Bun auto-drops native subscriptions on
124
+ * close, so the `unsubscribeAll` sweep is skipped.
125
+ */
126
+ export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false): BunWsHandlers {
86
127
  return {
87
128
  open: (raw) => {
88
129
  const ws = raw as BunSocket
89
- const nifra = wrapBunSocket(ws, topics)
130
+ const nifra = wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
90
131
  ws.data.nifra = nifra
91
132
  dispatchWsCallback(() => ws.data.handler.open?.(nifra), nifra, ws.data.handler)
92
133
  },
93
134
  message: (raw, message) => {
94
135
  const ws = raw as BunSocket
95
- const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics)
136
+ const handler = ws.data.handler
137
+ const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, handler, nativePubsub)
96
138
  const data: WebSocketData = typeof message === "string" ? message : toBinaryData(message)
97
- dispatchWsCallback(() => ws.data.handler.message?.(nifra, data), nifra, ws.data.handler)
139
+ // Hottest WS path - dispatch inline rather than through `dispatchWsCallback` so no per-frame
140
+ // closure is allocated. Same contract: a sync throw or async rejection routes to `handler.error`.
141
+ try {
142
+ const result = handler.message?.(nifra, data)
143
+ if (result instanceof Promise)
144
+ result.catch((e: unknown) => reportWsError(e, nifra, handler))
145
+ } catch (error) {
146
+ reportWsError(error, nifra, handler)
147
+ }
98
148
  },
99
149
  close: (raw, code, reason) => {
100
150
  const ws = raw as BunSocket
101
- const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics)
102
- topics.unsubscribeAll(nifra) // drop topic subscriptions so the registry never holds a dead socket
151
+ const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
152
+ // Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
153
+ // unsubscribes the socket from every topic on close, so there is nothing to sweep here.
154
+ if (!nativePubsub) topics.unsubscribeAll(nifra)
103
155
  dispatchWsCallback(() => ws.data.handler.close?.(nifra, code, reason), nifra, ws.data.handler)
104
156
  },
105
157
  }
@@ -30,8 +30,9 @@ export interface WsRuntime {
30
30
  wrapHandler(handler: WebSocketHandler): WebSocketHandler
31
31
  /** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
32
32
  createTopics(): TopicRegistry
33
- /** The Bun `websocket` config for `listen()` when the app has WS routes. */
34
- bunHandlers(topics: TopicRegistry): BunWsHandlers
33
+ /** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
34
+ * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). */
35
+ bunHandlers(topics: TopicRegistry, nativePubsub?: boolean): BunWsHandlers
35
36
  /** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
36
37
  attach(
37
38
  socket: StandardWebSocket,
package/src/server.ts CHANGED
@@ -45,7 +45,7 @@ export {
45
45
  type ScheduledHandler,
46
46
  toFetchHandler,
47
47
  } from "./server/edge.ts"
48
- export { pathnameOf, type UrlParts, urlPartsOf } from "./server/http.ts"
48
+ export { isSameOriginPath, pathnameOf, type UrlParts, urlPartsOf } from "./server/http.ts"
49
49
  export {
50
50
  commonSecretPatterns,
51
51
  jsonLogger,
@@ -70,7 +70,13 @@ export {
70
70
  toVercelHandler,
71
71
  type VercelHandler,
72
72
  } from "./server/platform-adapters.ts"
73
- export type { Registry, RouteInfo } from "./server/registry.ts"
73
+ export type { Registry, ResponseMapFor, RouteInfo, RouteInfoFor } from "./server/registry.ts"
74
+ export {
75
+ type PlainRender,
76
+ type ResponseResult,
77
+ type StatusResponse,
78
+ status,
79
+ } from "./server/runtime-core.ts"
74
80
  export {
75
81
  type AdmissionController,
76
82
  type AdmissionDecision,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Side-effect entry (`@nifrajs/core/single-copy/register`): install the single-copy resolver into the
3
+ * Bun runtime.
4
+ *
5
+ * Preload it - never import it from application code:
6
+ *
7
+ * ```toml
8
+ * # bunfig.toml
9
+ * preload = ["@nifrajs/core/single-copy/register"]
10
+ * [test]
11
+ * preload = ["@nifrajs/core/single-copy/register"]
12
+ * ```
13
+ *
14
+ * A resolver only affects imports that have not resolved yet, and a module's own imports resolve
15
+ * before its body runs. Registering from inside application code would therefore miss exactly the
16
+ * imports it exists to redirect. `preload` runs before the entry point, which is the only moment that
17
+ * covers the whole graph.
18
+ *
19
+ * `nifra check` reads this specifier out of `bunfig.toml` as the proof that the runtime arm is armed,
20
+ * so the string above is a contract rather than a suggestion.
21
+ */
22
+ import { registerSingleCopy } from "./single-copy"
23
+
24
+ registerSingleCopy()