@nifrajs/core 2.2.0 → 2.4.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 (136) hide show
  1. package/README.md +2 -2
  2. package/dist/assurance.d.ts +48 -4
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +87 -9
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/binary.d.ts +54 -0
  7. package/dist/binary.d.ts.map +1 -0
  8. package/dist/binary.js +90 -0
  9. package/dist/binary.js.map +1 -0
  10. package/dist/budget.d.ts +1 -1
  11. package/dist/budget.js +1 -1
  12. package/dist/capabilities.d.ts +19 -2
  13. package/dist/capabilities.d.ts.map +1 -1
  14. package/dist/capabilities.js +60 -8
  15. package/dist/capabilities.js.map +1 -1
  16. package/dist/classification.d.ts +1 -1
  17. package/dist/classification.js +1 -1
  18. package/dist/diff.d.ts +6 -6
  19. package/dist/diff.js +6 -6
  20. package/dist/durable-adapters.d.ts.map +1 -1
  21. package/dist/durable-adapters.js +36 -70
  22. package/dist/durable-adapters.js.map +1 -1
  23. package/dist/durable-execution.d.ts +2 -2
  24. package/dist/durable-execution.js +2 -2
  25. package/dist/idempotency.d.ts +3 -3
  26. package/dist/idempotency.js +4 -4
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +3 -3
  29. package/dist/internal/brand.d.ts +1 -1
  30. package/dist/internal/brand.js +1 -1
  31. package/dist/internal/route-catalog.d.ts +58 -0
  32. package/dist/internal/route-catalog.d.ts.map +1 -0
  33. package/dist/internal/route-catalog.js +58 -0
  34. package/dist/internal/route-catalog.js.map +1 -0
  35. package/dist/internal/route-execution.d.ts +75 -0
  36. package/dist/internal/route-execution.d.ts.map +1 -0
  37. package/dist/internal/route-execution.js +2 -0
  38. package/dist/internal/route-execution.js.map +1 -0
  39. package/dist/internal/same-origin.d.ts +29 -0
  40. package/dist/internal/same-origin.d.ts.map +1 -0
  41. package/dist/internal/same-origin.js +47 -0
  42. package/dist/internal/same-origin.js.map +1 -0
  43. package/dist/internal/secret.d.ts +1 -1
  44. package/dist/internal/secret.js +1 -1
  45. package/dist/ledger.d.ts +9 -9
  46. package/dist/ledger.js +5 -5
  47. package/dist/reflection.d.ts +1 -1
  48. package/dist/router/pattern.d.ts +9 -0
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +13 -4
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.js +3 -3
  53. package/dist/schema/standard.d.ts +2 -2
  54. package/dist/schema/standard.js +2 -2
  55. package/dist/seo.d.ts +3 -3
  56. package/dist/seo.js +4 -4
  57. package/dist/server/body.d.ts +2 -2
  58. package/dist/server/body.js +2 -2
  59. package/dist/server/context.d.ts +18 -18
  60. package/dist/server/contract.d.ts +7 -7
  61. package/dist/server/contract.js +3 -3
  62. package/dist/server/cookies.d.ts +4 -4
  63. package/dist/server/cookies.js +8 -8
  64. package/dist/server/edge.d.ts +83 -0
  65. package/dist/server/edge.d.ts.map +1 -0
  66. package/dist/server/edge.js +71 -0
  67. package/dist/server/edge.js.map +1 -0
  68. package/dist/server/install.d.ts +2 -0
  69. package/dist/server/install.d.ts.map +1 -1
  70. package/dist/server/install.js +2 -0
  71. package/dist/server/install.js.map +1 -1
  72. package/dist/server/logger.d.ts +7 -7
  73. package/dist/server/logger.js +4 -4
  74. package/dist/server/registry.d.ts +5 -5
  75. package/dist/server/response-contract-lane.d.ts +91 -0
  76. package/dist/server/response-contract-lane.d.ts.map +1 -0
  77. package/dist/server/response-contract-lane.js +81 -0
  78. package/dist/server/response-contract-lane.js.map +1 -0
  79. package/dist/server/server.d.ts +52 -91
  80. package/dist/server/server.d.ts.map +1 -1
  81. package/dist/server/server.js +130 -186
  82. package/dist/server/server.js.map +1 -1
  83. package/dist/server/sse.d.ts +4 -4
  84. package/dist/server/sse.js +7 -7
  85. package/dist/server/webhook.d.ts +1 -1
  86. package/dist/server/webhook.js +5 -5
  87. package/dist/server/websocket.d.ts +19 -19
  88. package/dist/server/websocket.js +9 -9
  89. package/dist/server/ws-bun.d.ts +4 -4
  90. package/dist/server/ws-bun.js +3 -3
  91. package/dist/server.d.ts +3 -1
  92. package/dist/server.d.ts.map +1 -1
  93. package/dist/server.js +3 -1
  94. package/dist/server.js.map +1 -1
  95. package/dist/transport-codec.d.ts +12 -0
  96. package/dist/transport-codec.d.ts.map +1 -1
  97. package/dist/transport-codec.js +19 -4
  98. package/dist/transport-codec.js.map +1 -1
  99. package/package.json +12 -2
  100. package/src/assurance.ts +154 -8
  101. package/src/binary.ts +128 -0
  102. package/src/budget.ts +1 -1
  103. package/src/capabilities.ts +71 -9
  104. package/src/classification.ts +1 -1
  105. package/src/diff.ts +8 -8
  106. package/src/durable-adapters.ts +40 -70
  107. package/src/durable-execution.ts +2 -2
  108. package/src/idempotency.ts +4 -4
  109. package/src/index.ts +3 -3
  110. package/src/internal/brand.ts +1 -1
  111. package/src/internal/route-catalog.ts +94 -0
  112. package/src/internal/route-execution.ts +106 -0
  113. package/src/internal/same-origin.ts +43 -0
  114. package/src/internal/secret.ts +1 -1
  115. package/src/ledger.ts +11 -11
  116. package/src/reflection.ts +1 -1
  117. package/src/router/pattern.ts +16 -4
  118. package/src/router/router.ts +5 -5
  119. package/src/schema/standard.ts +2 -2
  120. package/src/seo.ts +6 -6
  121. package/src/server/body.ts +2 -2
  122. package/src/server/context.ts +18 -18
  123. package/src/server/contract.ts +13 -13
  124. package/src/server/cookies.ts +8 -8
  125. package/src/server/edge.ts +150 -0
  126. package/src/server/install.ts +5 -0
  127. package/src/server/logger.ts +8 -8
  128. package/src/server/registry.ts +5 -5
  129. package/src/server/response-contract-lane.ts +164 -0
  130. package/src/server/server.ts +159 -366
  131. package/src/server/sse.ts +11 -11
  132. package/src/server/webhook.ts +5 -5
  133. package/src/server/websocket.ts +22 -22
  134. package/src/server/ws-bun.ts +4 -4
  135. package/src/server.ts +8 -5
  136. package/src/transport-codec.ts +24 -5
package/src/server/sse.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Server-Sent Events — a portable `text/event-stream` response helper.
2
+ * Server-Sent Events - a portable `text/event-stream` response helper.
3
3
  *
4
4
  * `sse(c, run)` returns a streaming `Response` a handler can return directly. The `run` callback
5
5
  * receives a stream it pushes messages to; the connection stays open until `run`'s promise resolves,
6
6
  * it calls `stream.close()`, or the client disconnects (`c.req.signal`). Works on every nifra runtime
7
- * (Bun, Node via `@nifrajs/node`, Deno, Cloudflare/Vercel edge) — it only uses a Web `ReadableStream`.
7
+ * (Bun, Node via `@nifrajs/node`, Deno, Cloudflare/Vercel edge) - it only uses a Web `ReadableStream`.
8
8
  *
9
- * // finite stream — run resolves, the connection closes
9
+ * // finite stream - run resolves, the connection closes
10
10
  * app.get("/ticks", (c) => sse(c, async (stream) => {
11
11
  * for (let i = 0; i < 5; i++) {
12
12
  * stream.send({ event: "tick", data: String(i) })
@@ -14,7 +14,7 @@
14
14
  * }
15
15
  * }))
16
16
  *
17
- * // event-driven — keep `run` pending until the client leaves
17
+ * // event-driven - keep `run` pending until the client leaves
18
18
  * app.get("/feed", (c) => sse(c, (stream) => {
19
19
  * const off = bus.subscribe((e) => stream.send({ data: JSON.stringify(e) }))
20
20
  * return new Promise<void>((resolve) =>
@@ -43,11 +43,11 @@ export interface SSEStream {
43
43
  send(message: SSEMessage): void
44
44
  /** End the stream now. */
45
45
  close(): void
46
- /** Aborts when the client disconnects — use it to tear down subscriptions/loops. */
46
+ /** Aborts when the client disconnects - use it to tear down subscriptions/loops. */
47
47
  readonly signal: AbortSignal
48
48
  }
49
49
 
50
- /** Minimal context shape `sse` needs — the live request, for its client-disconnect signal. */
50
+ /** Minimal context shape `sse` needs - the live request, for its client-disconnect signal. */
51
51
  export interface SSEContext {
52
52
  readonly req: Request
53
53
  }
@@ -63,7 +63,7 @@ export interface SSEInit {
63
63
 
64
64
  /**
65
65
  * The stream handed to an `app.sse()` handler: `send` takes the route's TYPED event payload and
66
- * serializes it (JSON) into the SSE `data:` field — the compile-time half of the `sse` contract.
66
+ * serializes it (JSON) into the SSE `data:` field - the compile-time half of the `sse` contract.
67
67
  */
68
68
  export interface TypedSSEStream<Event> {
69
69
  /** Send one typed event. `init` sets the optional SSE frame fields (`event`, `id`, `retry`). */
@@ -72,7 +72,7 @@ export interface TypedSSEStream<Event> {
72
72
  comment(text: string): void
73
73
  /** End the stream now. */
74
74
  close(): void
75
- /** Aborts when the client disconnects — use it to tear down subscriptions/loops. */
75
+ /** Aborts when the client disconnects - use it to tear down subscriptions/loops. */
76
76
  readonly signal: AbortSignal
77
77
  }
78
78
 
@@ -93,7 +93,7 @@ export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event>
93
93
  const CRLF = /[\r\n]/g
94
94
  const LINE_SPLIT = /\r\n|\r|\n/
95
95
 
96
- /** `event:`/`id:`/`comment` must be single-line — stripping CR/LF prevents SSE frame injection. */
96
+ /** `event:`/`id:`/`comment` must be single-line - stripping CR/LF prevents SSE frame injection. */
97
97
  function formatMessage(m: SSEMessage): string {
98
98
  let frame = ""
99
99
  if (m.comment !== undefined) frame += `: ${m.comment.replace(CRLF, "")}\n`
@@ -139,7 +139,7 @@ export function sse(
139
139
  try {
140
140
  controller.close()
141
141
  } catch {
142
- // Already closed by the runtime's cancel() — safe to ignore.
142
+ // Already closed by the runtime's cancel() - safe to ignore.
143
143
  }
144
144
  }
145
145
  const stream: SSEStream = {
@@ -180,7 +180,7 @@ export function sse(
180
180
  })
181
181
  },
182
182
  cancel() {
183
- // Consumer/runtime canceled (client gone) — pending sends become no-ops.
183
+ // Consumer/runtime canceled (client gone) - pending sends become no-ops.
184
184
  closed = true
185
185
  teardown()
186
186
  },
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Webhook signature verification — **read the raw body, verify the HMAC, *then* parse**. A handler
2
+ * Webhook signature verification - **read the raw body, verify the HMAC, *then* parse**. A handler
3
3
  * that `JSON.parse`s a webhook before checking the signature is trusting an unauthenticated payload;
4
4
  * this reads the body bounded (DoS guard, shared with `c.boundedBody`) and verifies before handing
5
5
  * back the raw text for the handler to parse with its own schema.
6
6
  *
7
7
  * Verification is **constant-time**: the provider's signature is fed to `crypto.subtle.verify`
8
- * (WebCrypto), which checks the HMAC without a byte-by-byte string compare — so a wrong signature
8
+ * (WebCrypto), which checks the HMAC without a byte-by-byte string compare - so a wrong signature
9
9
  * can't be discovered through timing. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
10
10
  *
11
11
  * Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
@@ -15,7 +15,7 @@ import { requireSecretBytes } from "../internal/secret.ts"
15
15
  import { readBoundedBytes } from "./body.ts"
16
16
 
17
17
  const TEXT = new TextEncoder()
18
- const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB — webhook payloads are small; cap the raw read.
18
+ const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MiB - webhook payloads are small; cap the raw read.
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"
@@ -136,7 +136,7 @@ function parseStripeHeader(value: string): { t: number; v1: string[] } | null {
136
136
 
137
137
  /**
138
138
  * Verify a webhook request's signature and return its raw payload. Reads `req.body` (bounded), so the
139
- * body is consumed — parse the returned `payload`, don't re-read the request.
139
+ * body is consumed - parse the returned `payload`, don't re-read the request.
140
140
  *
141
141
  * @param secret the signing secret (or an array, to accept either during a secret rotation).
142
142
  *
@@ -189,7 +189,7 @@ export async function verifyWebhook(
189
189
  }
190
190
  }
191
191
  if (!matched) return { ok: false, reason: "invalid_signature" }
192
- // The signature covers `t`, so `t` is authentic once matched — now enforce the replay window.
192
+ // The signature covers `t`, so `t` is authentic once matched - now enforce the replay window.
193
193
  const now = options.now ?? Math.floor(Date.now() / 1000)
194
194
  const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
195
195
  if (Math.abs(now - parsed.t) > tolerance) {
@@ -1,10 +1,10 @@
1
1
  /**
2
- * WebSocket seam — portable types shared by `@nifrajs/core`'s `app.ws()` and every serving adapter
2
+ * WebSocket seam - portable types shared by `@nifrajs/core`'s `app.ws()` and every serving adapter
3
3
  * (Bun `listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler` on Workers). No runtime code here, so
4
4
  * it stays edge-safe and dependency-free; each adapter implements {@link NifraWebSocket} over its
5
5
  * runtime's native socket and dispatches to the handler.
6
6
  *
7
- * A WS upgrade can't go through `app.fetch` (which has no socket) — it's adapter-integrated. The
7
+ * A WS upgrade can't go through `app.fetch` (which has no socket) - it's adapter-integrated. The
8
8
  * adapter calls `app.resolveWebSocketUpgrade(req)` (runs the route's `upgrade()` guard in a real
9
9
  * request context), then performs the runtime upgrade with the returned data.
10
10
  */
@@ -33,22 +33,22 @@ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extend
33
33
  export interface NifraWebSocket<Data = unknown> {
34
34
  /** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
35
35
  send(data: string | ArrayBufferView | ArrayBuffer): void
36
- /** Close the connection (optional code 1000–4999 + short reason). */
36
+ /** Close the connection (optional code 1000-4999 + short reason). */
37
37
  close(code?: number, reason?: string): void
38
38
  /** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
39
39
  readonly readyState: number
40
- /** Join a pub/sub topic — `app.publish(topic, data)` then reaches this connection. Idempotent. */
40
+ /** Join a pub/sub topic - `app.publish(topic, data)` then reaches this connection. Idempotent. */
41
41
  subscribe(topic: string): void
42
42
  /** Leave a pub/sub topic. Idempotent. (All topics are dropped automatically on close.) */
43
43
  unsubscribe(topic: string): void
44
- /** Per-connection state — seeded by `upgrade()`, mutable for the connection's lifetime. */
44
+ /** Per-connection state - seeded by `upgrade()`, mutable for the connection's lifetime. */
45
45
  data: Data
46
46
  /** Escape hatch to the runtime's native socket (Bun `ServerWebSocket`, Web `WebSocket`, `ws`). */
47
47
  readonly raw: unknown
48
48
  }
49
49
 
50
50
  /**
51
- * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** —
51
+ * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** -
52
52
  * topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load
53
53
  * balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to
54
54
  * `app.publish`. The same registry instance is shared by an app's connections and its `publish`.
@@ -76,12 +76,12 @@ export class TopicRegistry {
76
76
  const subs = this.topics.get(topic)
77
77
  if (subs !== undefined) {
78
78
  subs.delete(ws)
79
- if (subs.size === 0) this.topics.delete(topic) // reclaim empty topics — no unbounded growth
79
+ if (subs.size === 0) this.topics.delete(topic) // reclaim empty topics - no unbounded growth
80
80
  }
81
81
  this.memberships.get(ws)?.delete(topic)
82
82
  }
83
83
 
84
- /** Drop every subscription for a connection — called on close so a registry never leaks dead sockets. */
84
+ /** Drop every subscription for a connection - called on close so a registry never leaks dead sockets. */
85
85
  unsubscribeAll(ws: NifraWebSocket): void {
86
86
  const mine = this.memberships.get(ws)
87
87
  if (mine === undefined) return
@@ -110,7 +110,7 @@ export class TopicRegistry {
110
110
  }
111
111
 
112
112
  /**
113
- * The request-context subset the `upgrade()` guard sees — the same lazy accessors a route handler's
113
+ * The request-context subset the `upgrade()` guard sees - the same lazy accessors a route handler's
114
114
  * `c` has (cookies/headers/env are read straight off the upgrade request). Structurally a slice of the
115
115
  * core `RawContext`, so the real context object satisfies it.
116
116
  */
@@ -134,7 +134,7 @@ export interface WebSocketHandler<
134
134
  Send extends StandardSchemaV1 | undefined = undefined,
135
135
  > {
136
136
  /**
137
- * Cross-site WebSocket hijacking (CSWSH) guard — checked BEFORE `upgrade()`. A browser does not
137
+ * Cross-site WebSocket hijacking (CSWSH) guard - checked BEFORE `upgrade()`. A browser does not
138
138
  * apply CORS to WebSocket handshakes and DOES send the page's cookies, so without an Origin check
139
139
  * any site can open an authenticated socket to your app. Set this to lock the route to known
140
140
  * origins: a string allow-list (exact `Origin` header match) or a predicate. A request whose
@@ -145,11 +145,11 @@ export interface WebSocketHandler<
145
145
  * whose host differs from the request's) is rejected with `403`. Non-browser clients (no `Origin`) and
146
146
  * same-origin browsers pass. Set this to allow specific cross-origin clients, or `() => true` for a
147
147
  * genuinely public socket. Non-browser clients can spoof `Origin`, so this is a browser-CSWSH defense,
148
- * not authentication — pair it with auth in `upgrade()`.
148
+ * not authentication - pair it with auth in `upgrade()`.
149
149
  */
150
150
  allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
151
151
  /**
152
- * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) — the
152
+ * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) - the
153
153
  * place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a
154
154
  * `Response` to **reject** the upgrade (the client never connects). Omit to accept with
155
155
  * `data: undefined`. A thrown error rejects with a flat 500.
@@ -188,10 +188,10 @@ export interface WebSocketHandler<
188
188
  }
189
189
 
190
190
  /**
191
- * The outcome of `app.resolveWebSocketUpgrade(req)` — for serving adapters:
192
- * - `pass` — not a WS upgrade for a registered WS route; handle as a normal HTTP request.
193
- * - `reject` — a WS route matched but `upgrade()` rejected (or the path was malformed); return `response`.
194
- * - `upgrade` — perform the runtime upgrade, then dispatch the native socket's events to `handler`,
191
+ * The outcome of `app.resolveWebSocketUpgrade(req)` - for serving adapters:
192
+ * - `pass` - not a WS upgrade for a registered WS route; handle as a normal HTTP request.
193
+ * - `reject` - a WS route matched but `upgrade()` rejected (or the path was malformed); return `response`.
194
+ * - `upgrade` - perform the runtime upgrade, then dispatch the native socket's events to `handler`,
195
195
  * seeding `ws.data` with `data`.
196
196
  */
197
197
  export type WebSocketUpgradeOutcome =
@@ -201,7 +201,7 @@ export type WebSocketUpgradeOutcome =
201
201
  readonly kind: "upgrade"
202
202
  readonly handler: WebSocketHandler
203
203
  readonly data: unknown
204
- /** The app's pub/sub registry — the adapter wires `ws.subscribe` + close-cleanup to it. */
204
+ /** The app's pub/sub registry - the adapter wires `ws.subscribe` + close-cleanup to it. */
205
205
  readonly pubsub: TopicRegistry
206
206
  /** The installed runtime's {@link attachWebSocket}, carried on the outcome so an adapter can wire
207
207
  * a standard socket without a static import of the WS implementation (which would defeat the
@@ -218,7 +218,7 @@ export type WsAttach = (
218
218
  ) => NifraWebSocket
219
219
 
220
220
  /**
221
- * A standard server-side `WebSocket` — the half returned by Deno's `Deno.upgradeWebSocket` and the
221
+ * A standard server-side `WebSocket` - the half returned by Deno's `Deno.upgradeWebSocket` and the
222
222
  * Workers `WebSocketPair`. {@link attachWebSocket} wires one to a nifra handler, so the Deno and Workers
223
223
  * bridges share all the dispatch/normalization/error-isolation logic (only the upgrade call differs).
224
224
  */
@@ -235,14 +235,14 @@ function toBinary(raw: unknown): Uint8Array {
235
235
  if (raw instanceof Uint8Array) return raw
236
236
  if (raw instanceof ArrayBuffer) return new Uint8Array(raw)
237
237
  if (ArrayBuffer.isView(raw)) return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
238
- return new Uint8Array(0) // unknown (e.g. an unconverted Blob) — adapters set binaryType to avoid this
238
+ return new Uint8Array(0) // unknown (e.g. an unconverted Blob) - adapters set binaryType to avoid this
239
239
  }
240
240
 
241
241
  /**
242
242
  * Wire a standard server-side `WebSocket` to a nifra {@link WebSocketHandler}, returning the portable
243
243
  * {@link NifraWebSocket}. Shared by the Deno and Workers bridges. `openNow` fires `open` immediately
244
244
  * (Workers, where the socket is already open after `accept()`); otherwise `open` waits for the socket's
245
- * `open` event (Deno). Lifecycle callbacks are error-isolated — a throw (or async rejection) routes to
245
+ * `open` event (Deno). Lifecycle callbacks are error-isolated - a throw (or async rejection) routes to
246
246
  * `error()` and never tears the process down; binary frames are normalized to `Uint8Array`.
247
247
  */
248
248
  export function attachWebSocket(
@@ -269,7 +269,7 @@ export function attachWebSocket(
269
269
  const r = handler.error(ws, error)
270
270
  if (r instanceof Promise) r.catch(() => {})
271
271
  } catch {
272
- /* the error handler itself failed — last resort, swallow */
272
+ /* the error handler itself failed - last resort, swallow */
273
273
  }
274
274
  }
275
275
  const safe = (call: () => MaybePromise<void>): void => {
@@ -306,7 +306,7 @@ export function attachWebSocket(
306
306
  const WS_MESSAGE_DECODER = new TextDecoder()
307
307
 
308
308
  /**
309
- * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame —
309
+ * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
310
310
  * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
311
311
  * `onInvalidMessage` on failure. Returns the handler unchanged when no schema is set. Called once at
312
312
  * `app.ws()` registration, so every adapter dispatches validated messages with no per-adapter code.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Bun WebSocket plumbing — the `websocket` dispatcher config `listen()` hands to Bun's serve when
2
+ * Bun WebSocket plumbing - the `websocket` dispatcher config `listen()` hands to Bun's serve when
3
3
  * the app has WS routes. Lives behind the `@nifrajs/core/ws` subpath (via `ws-hook.ts`) so the base
4
4
  * bundle of a no-WebSocket app never ships it; `server.ts` only imports the **types** from here
5
- * (erased). No `Bun.*` calls here — the runtime seam stays in `server.ts` (see runtime-boundary.test).
5
+ * (erased). No `Bun.*` calls here - the runtime seam stays in `server.ts` (see runtime-boundary.test).
6
6
  */
7
7
 
8
8
  import type { NifraWebSocket, TopicRegistry, WebSocketData, WebSocketHandler } from "./websocket.ts"
@@ -16,7 +16,7 @@ export interface BunWsData {
16
16
  nifra?: NifraWebSocket
17
17
  }
18
18
 
19
- /** Structural view of Bun's `ServerWebSocket` — keeps `Bun.*` types out of the public `.d.ts`
19
+ /** Structural view of Bun's `ServerWebSocket` - keeps `Bun.*` types out of the public `.d.ts`
20
20
  * (`listen()` casts for the same reason). The real socket satisfies this. */
21
21
  export interface BunSocket {
22
22
  send(data: string | ArrayBufferView | ArrayBuffer, compress?: boolean): number
@@ -80,7 +80,7 @@ function dispatchWsCallback(
80
80
  }
81
81
  }
82
82
 
83
- /** The shared Bun `websocket` dispatcher config for one app — each connection's
83
+ /** The shared Bun `websocket` dispatcher config for one app - each connection's
84
84
  * `ws.data.handler` is the matched route's handler, set by `server.upgrade`. */
85
85
  export function createBunWsHandlers(topics: TopicRegistry): BunWsHandlers {
86
86
  return {
package/src/server.ts CHANGED
@@ -10,6 +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
14
  export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
14
15
  export type {
15
16
  InferInput,
@@ -35,6 +36,13 @@ export {
35
36
  signValue,
36
37
  unsignValue,
37
38
  } from "./server/cookies.ts"
39
+ export {
40
+ type DurableObjectNamespaceLike,
41
+ type ExecutionContext,
42
+ type ScheduledController,
43
+ type ScheduledHandler,
44
+ toFetchHandler,
45
+ } from "./server/edge.ts"
38
46
  export {
39
47
  commonSecretPatterns,
40
48
  jsonLogger,
@@ -49,11 +57,9 @@ export {
49
57
  type AdmissionController,
50
58
  type AdmissionDecision,
51
59
  type AnyServer,
52
- type DurableObjectNamespaceLike,
53
60
  defineIdentityPlugin,
54
61
  definePlugin,
55
62
  defineRouterPlugin,
56
- type ExecutionContext,
57
63
  type Handler,
58
64
  type IdentityPlugin,
59
65
  type McpPromptDescriptor,
@@ -67,13 +73,10 @@ export {
67
73
  type ResponseFinalization,
68
74
  type RouteDescriptor,
69
75
  type RunningServer,
70
- type ScheduledController,
71
- type ScheduledHandler,
72
76
  Server,
73
77
  type ServerOptions,
74
78
  server,
75
79
  type ToolAnnotations,
76
- toFetchHandler,
77
80
  } from "./server/server.ts"
78
81
  export type {
79
82
  SSEContext,
@@ -29,9 +29,9 @@ export class TransportCodecError extends TypeError {
29
29
  * Run a decode step and normalize whatever it throws into {@link TransportCodecError}.
30
30
  *
31
31
  * Decoding is the one place this module touches attacker-controlled bytes, and the primitives it
32
- * delegates to raise their own native types — `JSON.parse` throws `SyntaxError`, a BYO codec throws
32
+ * delegates to raise their own native types - `JSON.parse` throws `SyntaxError`, a BYO codec throws
33
33
  * whatever it likes. Every other failure here is a `TransportCodecError`, so letting those escape
34
- * would mean a malformed payload — the single most likely hostile input — is the one case that slips
34
+ * would mean a malformed payload - the single most likely hostile input - is the one case that slips
35
35
  * past a caller catching the documented error type. The original is kept as `cause`, so the parse
36
36
  * failure stays diagnosable; a codec already speaking the contract is re-thrown untouched.
37
37
  */
@@ -190,15 +190,26 @@ function appendVary(current: string | null, name: string): string {
190
190
  return values.includes(name.toLowerCase()) ? current : `${current}, ${name}`
191
191
  }
192
192
 
193
- async function readBoundedText(
193
+ /**
194
+ * Read a response body into memory, refusing to exceed `maxBytes`.
195
+ *
196
+ * Bounded while STREAMING rather than after the fact: the `content-length` shortcut only helps when a
197
+ * sender declares one, so the loop also counts as it goes and cancels the reader the moment the total
198
+ * passes the limit. A cap that buffers first and complains afterwards has already spent the memory it
199
+ * was meant to protect.
200
+ *
201
+ * Exported so the client can bound a plain-text body with the same reader it bounds JSON with, rather
202
+ * than growing a second copy of this loop that could drift from it.
203
+ */
204
+ export async function readBoundedBytes(
194
205
  response: Response,
195
206
  options: TransportDecodeOptions,
196
- ): Promise<string> {
207
+ ): Promise<Uint8Array> {
197
208
  const maxBytes = maxBytesOf(options)
198
209
  const declared = response.headers.get("content-length")
199
210
  if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
200
211
  throw new TransportCodecError("transport payload exceeds maxBytes")
201
- if (response.body === null) return ""
212
+ if (response.body === null) return new Uint8Array(0)
202
213
  const reader = response.body.getReader()
203
214
  const chunks: Uint8Array[] = []
204
215
  let total = 0
@@ -222,6 +233,14 @@ async function readBoundedText(
222
233
  bytes.set(chunk, offset)
223
234
  offset += chunk.byteLength
224
235
  }
236
+ return bytes
237
+ }
238
+
239
+ async function readBoundedText(
240
+ response: Response,
241
+ options: TransportDecodeOptions,
242
+ ): Promise<string> {
243
+ const bytes = await readBoundedBytes(response, options)
225
244
  return decodeOrThrow(
226
245
  () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
227
246
  "transport payload is not valid UTF-8",