@nifrajs/core 5.5.1 → 5.7.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 (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
package/src/server/sse.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  * }, { keepAlive: 15_000 }))
23
23
  */
24
24
  import { INSTALL_SSE } from "./install.ts"
25
+ import { jsonText } from "./json-text.ts"
25
26
  import type { IdentityPlugin } from "./plugin.ts"
26
27
  import type { AnyServer } from "./server.ts"
27
28
  import type { SseRuntime } from "./sse-hook.ts"
@@ -66,7 +67,7 @@ export interface SSEInit {
66
67
  * Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
67
68
  * `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
68
69
  * 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
+ * the client can read. A single frame over the cap is also refused; `Infinity` turns the limit off.
70
71
  */
71
72
  readonly maxBufferedBytes?: number
72
73
  }
@@ -92,7 +93,7 @@ export interface TypedSSEStream<Event> {
92
93
  export function typedSSEStream<Event>(stream: SSEStream): TypedSSEStream<Event> {
93
94
  return {
94
95
  send(event, init = {}) {
95
- stream.send({ ...init, data: JSON.stringify(event) })
96
+ stream.send({ ...init, data: jsonText(event) })
96
97
  },
97
98
  comment(text) {
98
99
  stream.send({ comment: text })
@@ -158,9 +159,10 @@ export function sse(
158
159
  const stream: SSEStream = {
159
160
  send(message) {
160
161
  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) {
162
+ const frame = encoder.encode(formatMessage(message))
163
+ // Check the incoming frame too: a lone oversized event has no later send to trigger
164
+ // overflow detection. Erroring the body releases its queue and stops the producer.
165
+ if (frame.byteLength > (controller.desiredSize ?? 0)) {
164
166
  closed = true
165
167
  teardown()
166
168
  const reason = new Error("sse_client_too_slow")
@@ -168,7 +170,7 @@ export function sse(
168
170
  controller.error(reason)
169
171
  return
170
172
  }
171
- controller.enqueue(encoder.encode(formatMessage(message)))
173
+ controller.enqueue(frame)
172
174
  },
173
175
  close,
174
176
  signal: stopped.signal,
@@ -0,0 +1,21 @@
1
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
2
+ import type { RouteSchema } from "./context.ts"
3
+
4
+ // What a schema offers for a slot whose values arrive as text: `@nifrajs/schema` sets it on a
5
+ // `t.object` (and any `t` schema that does not coerce) to the coercing form of the same schema.
6
+ const TEXT_SLOT = Symbol.for("nifra.schema.textSlot")
7
+
8
+ /**
9
+ * `schema` with its `query`, `headers` and `cookies` schemas swapped for the text form each offers, or
10
+ * `schema` itself when none offers one. A query string, header values and a `Cookie` header are
11
+ * always text, so a schema that reads a boolean or a number there can only validate its coercing form.
12
+ */
13
+ export function withTextSlots(schema: RouteSchema | undefined): RouteSchema | undefined {
14
+ let out = schema
15
+ for (const slot of ["query", "headers", "cookies"] as const) {
16
+ const validate = out?.[slot]?.["~standard"].validate
17
+ const form: StandardSchemaV1 | undefined = validate && Reflect.get(validate, TEXT_SLOT)
18
+ if (form !== undefined) out = { ...out, [slot]: form }
19
+ }
20
+ return out
21
+ }
@@ -4,9 +4,10 @@
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
- * 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
9
- * can't be discovered through timing. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
7
+ * Verification is **constant-time**: the expected HMAC is computed once per secret with
8
+ * `crypto.subtle.sign` (WebCrypto) and compared to each signature the header lists without an early
9
+ * exit - so a wrong signature can't be discovered through timing, and a header listing many
10
+ * signatures costs no extra HMAC over the body. WebCrypto-only, so it runs on Bun / Node / Deno / workerd.
10
11
  *
11
12
  * Presets cover Stripe (`t=…,v1=…` with a replay-window check) and GitHub (`sha256=…`); `generic`
12
13
  * takes an explicit header + encoding for any other provider.
@@ -48,6 +49,7 @@ export type WebhookFailureReason =
48
49
  | "malformed_signature"
49
50
  | "payload_too_large"
50
51
  | "invalid_content_length"
52
+ | "invalid_body"
51
53
 
52
54
  /** Verified ⇒ the raw `payload` text (parse it with your schema). Rejected ⇒ a stable `reason`. */
53
55
  export type WebhookResult =
@@ -74,7 +76,7 @@ const importHmacKey = (secret: string): Promise<CryptoKey> => {
74
76
  TEXT.encode(secret),
75
77
  { name: "HMAC", hash: "SHA-256" },
76
78
  false,
77
- ["verify"],
79
+ ["sign"],
78
80
  )
79
81
  }
80
82
 
@@ -120,17 +122,26 @@ const decodeSignature = (
120
122
  ? fromBase64Url(value)
121
123
  : fromBase64(value)
122
124
 
123
- /** True iff `sig` is a valid HMAC-SHA256 of `payload` under any of `keys` (constant-time per check). */
124
- async function verifyAny(
125
- keys: readonly CryptoKey[],
126
- sig: Uint8Array<ArrayBuffer>,
127
- payload: Uint8Array,
128
- ): Promise<boolean> {
129
- for (const key of keys) {
130
- if (await crypto.subtle.verify("HMAC", key, sig, payload as Uint8Array<ArrayBuffer>))
131
- return true
125
+ /** The HMAC-SHA256 of `payload` under each of `keys`. */
126
+ const expectedMacs = (keys: readonly CryptoKey[], payload: Uint8Array): Promise<Uint8Array[]> =>
127
+ Promise.all(
128
+ keys.map(
129
+ async (key) =>
130
+ new Uint8Array(await crypto.subtle.sign("HMAC", key, payload as Uint8Array<ArrayBuffer>)),
131
+ ),
132
+ )
133
+
134
+ /** True iff `sig` equals one of `macs`. Every byte of a same-length MAC is visited; a MAC's length is
135
+ * fixed (32 bytes), so a length mismatch reveals nothing. */
136
+ function matchesAny(macs: readonly Uint8Array[], sig: Uint8Array): boolean {
137
+ let matched = false
138
+ for (const mac of macs) {
139
+ if (mac.length !== sig.length) continue
140
+ let diff = 0
141
+ for (let i = 0; i < mac.length; i++) diff |= (mac[i] ?? 0) ^ (sig[i] ?? 0)
142
+ if (diff === 0) matched = true
132
143
  }
133
- return false
144
+ return matched
134
145
  }
135
146
 
136
147
  // Parse a Stripe `Stripe-Signature` header: `t=<unix>,v1=<hex>[,v1=<hex>…]` (other schemes ignored).
@@ -193,10 +204,7 @@ export async function verifyWebhook(
193
204
 
194
205
  const read = await readBoundedBytes(req, maxBytes)
195
206
  if (!read.ok) {
196
- return {
197
- ok: false,
198
- reason: read.status === 413 ? "payload_too_large" : "invalid_content_length",
199
- }
207
+ return { ok: false, reason: read.error }
200
208
  }
201
209
  const payload = new TextDecoder().decode(read.bytes)
202
210
 
@@ -209,14 +217,11 @@ export async function verifyWebhook(
209
217
  if (preset.timestamped) {
210
218
  const parsed = parseStripeHeader(headerValue)
211
219
  if (parsed === null) return { ok: false, reason: "malformed_signature" }
212
- const signedPayload = TEXT.encode(`${parsed.t}.${payload}`)
220
+ const macs = await expectedMacs(keys, TEXT.encode(`${parsed.t}.${payload}`))
213
221
  let matched = false
214
222
  for (const v1 of parsed.v1) {
215
223
  const sig = decodeSignature(v1, encoding)
216
- if (sig !== null && (await verifyAny(keys, sig, signedPayload))) {
217
- matched = true
218
- break
219
- }
224
+ if (sig !== null && matchesAny(macs, sig)) matched = true
220
225
  }
221
226
  if (!matched) return { ok: false, reason: "invalid_signature" }
222
227
  // The signature covers `t`, so `t` is authentic once matched - now enforce the replay window.
@@ -232,6 +237,6 @@ export async function verifyWebhook(
232
237
  prefix !== "" && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue
233
238
  const sig = decodeSignature(raw, encoding)
234
239
  if (sig === null) return { ok: false, reason: "malformed_signature" }
235
- if (await verifyAny(keys, sig, read.bytes)) return { ok: true, payload }
240
+ if (matchesAny(await expectedMacs(keys, read.bytes), sig)) return { ok: true, payload }
236
241
  return { ok: false, reason: "invalid_signature" }
237
242
  }
@@ -18,7 +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
+ import { isResponseResult, type StatusResponse } from "./runtime-core.ts"
22
22
 
23
23
  type MaybePromise<T> = T | Promise<T>
24
24
 
@@ -35,7 +35,9 @@ type WsMessageInput<Schema extends StandardSchemaV1 | undefined> = Schema extend
35
35
  export interface NifraWebSocket<Data = unknown> {
36
36
  /** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */
37
37
  send(data: string | ArrayBufferView | ArrayBuffer): void
38
- /** Close the connection (optional code 1000-4999 + short reason). */
38
+ /** Close the connection, with a code an endpoint may send (1000-1003, 1007-1014, 3000-4999;
39
+ * 1000 when none is given, on every runtime) and a reason of at most 123 UTF-8 bytes. Any other
40
+ * close throws a `RangeError`, and the socket stays open. */
39
41
  close(code?: number, reason?: string): void
40
42
  /** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */
41
43
  readonly readyState: number
@@ -170,7 +172,7 @@ export interface WebSocketHandler<
170
172
  * `data: undefined`. A thrown `Response` rejects with it; any other throw goes to the route's
171
173
  * `onError` hooks, then a flat 500.
172
174
  */
173
- upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response>
175
+ upgrade?(c: WebSocketContext<Env> & Extra): MaybePromise<Data | Response | StatusResponse>
174
176
  /**
175
177
  * Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame.
176
178
  * Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a
@@ -211,6 +213,9 @@ export interface WebSocketHandler<
211
213
  issues: ReadonlyArray<StandardIssue>,
212
214
  raw: WebSocketData,
213
215
  ): MaybePromise<void>
216
+ /** The connection closed. `code` and `reason` are the server's own close when it closed first
217
+ * (`ws.close()`, or 1009 for a message over the cap), the peer's when the peer did, and 1006 when
218
+ * the connection dropped without a close frame. */
214
219
  close?(ws: NifraWebSocket<Data>, code: number, reason: string): MaybePromise<void>
215
220
  error?(ws: NifraWebSocket<Data>, error: unknown): MaybePromise<void>
216
221
  }
@@ -314,9 +319,20 @@ export function attachWebSocket<
314
319
  handler.transport,
315
320
  reportError,
316
321
  )
322
+ // The runtime's close event carries the peer's echo, which may drop the reason; `close` is given
323
+ // the server's own close when the server closed first, as on Bun.
324
+ let sentClose: readonly [number, string] | undefined
325
+ // A close with no code is 1000 here as on Bun, whose close() cannot send one without a status.
326
+ const closeSocket = (code = 1000, reason?: string): void => {
327
+ if (sentClose === undefined && socket.readyState <= 1) sentClose = [code, reason ?? ""]
328
+ socket.close(code, reason)
329
+ }
317
330
  ws = {
318
331
  send,
319
- close: (code, reason) => socket.close(code, reason),
332
+ close: (code, reason) => {
333
+ assertCloseArgs(code, reason)
334
+ closeSocket(code, reason)
335
+ },
320
336
  get readyState() {
321
337
  return socket.readyState
322
338
  },
@@ -342,7 +358,7 @@ export function attachWebSocket<
342
358
  // The runtime hands over each message whole, so this bounds what reaches the handler, not what
343
359
  // the runtime buffered to deliver it.
344
360
  if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
345
- socket.close(1009, "message too large")
361
+ closeSocket(1009, "message too large")
346
362
  return
347
363
  }
348
364
  safe(() =>
@@ -357,7 +373,8 @@ export function attachWebSocket<
357
373
  "close",
358
374
  (event: { readonly code?: number; readonly reason?: string }) => {
359
375
  pubsub.unsubscribeAll(ws) // drop topic subscriptions so the registry never holds a dead socket
360
- safe(() => handler.close?.(ws, event.code ?? 1005, event.reason ?? ""))
376
+ const [code, reason] = sentClose ?? [event.code ?? 1005, event.reason ?? ""]
377
+ safe(() => handler.close?.(ws, code, reason))
361
378
  },
362
379
  )
363
380
  socket.addEventListener("error", (event: unknown) => reportError(event))
@@ -372,7 +389,8 @@ export function attachWebSocket<
372
389
  const WS_MESSAGE_DECODER = new TextDecoder()
373
390
 
374
391
  /** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
375
- * 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
392
+ * 1-3 bytes (a surrogate pair 4 for its 2, a lone surrogate the 3 of U+FFFD), so only a length
393
+ * between those bounds is counted. */
376
394
  export function exceedsPayloadCap(
377
395
  message: string | ArrayBuffer | Uint8Array,
378
396
  cap: number,
@@ -384,12 +402,43 @@ export function exceedsPayloadCap(
384
402
  let bytes = 0
385
403
  for (let i = 0; i < units; i++) {
386
404
  const unit = message.charCodeAt(i)
387
- bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
405
+ if (unit >= 0xd800 && unit <= 0xdbff && (message.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
406
+ bytes += 4
407
+ i++
408
+ } else bytes += unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3
388
409
  if (bytes > cap) return true
389
410
  }
390
411
  return false
391
412
  }
392
413
 
414
+ /**
415
+ * Refuse a close RFC 6455 forbids before the runtime sees it: a code outside 1000-1003, 1007-1014 and
416
+ * 3000-4999, a reason over 123 UTF-8 bytes, or a reason without a code. Runtimes disagreed on such a
417
+ * close - Bun and Deno sent it, Node's `ws` threw after marking the socket closing, which left it
418
+ * half-open, and workerd threw - so every adapter throws this `RangeError` first and the socket stays
419
+ * open.
420
+ */
421
+ export function assertCloseArgs(code: number | undefined, reason: string | undefined): void {
422
+ if (code === undefined) {
423
+ if (reason !== undefined && reason !== "")
424
+ throw new RangeError("ws.close(): a reason needs a code")
425
+ return
426
+ }
427
+ if (
428
+ !Number.isInteger(code) ||
429
+ !(
430
+ (code >= 1000 && code <= 1003) ||
431
+ (code >= 1007 && code <= 1014) ||
432
+ (code >= 3000 && code <= 4999)
433
+ )
434
+ )
435
+ throw new RangeError(
436
+ `ws.close(): ${code} is not a code a server may send (1000-1003, 1007-1014, 3000-4999)`,
437
+ )
438
+ if (reason !== undefined && exceedsPayloadCap(reason, 123))
439
+ throw new RangeError("ws.close(): the reason is over 123 UTF-8 bytes")
440
+ }
441
+
393
442
  type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
394
443
 
395
444
  const OUTBOUND_VALIDATION_ERROR = "outbound WebSocket frame failed sendSchema validation"
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ assertCloseArgs,
9
10
  createWebSocketSender,
10
11
  exceedsPayloadCap,
11
12
  type NifraWebSocket,
@@ -103,7 +104,10 @@ function wrapBunSocket(
103
104
  )
104
105
  ws = {
105
106
  send,
106
- close: (code, reason) => raw.close(code, reason),
107
+ close: (code, reason) => {
108
+ assertCloseArgs(code, reason)
109
+ raw.close(code, reason)
110
+ },
107
111
  get readyState() {
108
112
  return raw.readyState
109
113
  },
@@ -206,8 +210,8 @@ export function createBunWsHandlers(
206
210
  // Registry mode: drop topic subscriptions so it never holds a dead socket. Native mode: Bun
207
211
  // unsubscribes the socket from every topic on close, so there is nothing to sweep here.
208
212
  if (!nativePubsub) (ws.data.pubsub ?? topics).unsubscribeAll(nifra)
209
- // Bun's own maxPayloadLength drops the connection without a close frame, so the peer sees 1006;
210
- // the handler gets the 1009 every other payload cap reports.
213
+ // Bun's own maxPayloadLength reports 1006 here, whether it dropped the connection (up to 1.4.3)
214
+ // or sent the peer a 1009 (from 1.4.4); the handler gets the 1009 every other payload cap reports.
211
215
  const oversize = code === 1006 && reason === BUN_TOO_BIG_REASON
212
216
  const closeCode = oversize ? 1009 : code
213
217
  const closeReason = oversize ? "message too large" : reason
package/src/server.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  markAuthenticatedRequest,
47
47
  rejected,
48
48
  } from "./server/auth.ts"
49
+ export { beforeHandler } from "./server/before-handler.ts"
49
50
  export type {
50
51
  Context,
51
52
  Params,
@@ -107,6 +108,7 @@ export type {
107
108
  } from "./server/registry.ts"
108
109
  export { replacedRequestOf } from "./server/request-lineage.ts"
109
110
  export { writableResponse } from "./server/respond.ts"
111
+ export { routeTemplateOf } from "./server/route-template.ts"
110
112
  export {
111
113
  type PlainRender,
112
114
  type ResponseResult,
@@ -78,13 +78,9 @@ export const IDENTITY_SENSITIVE_PACKAGES: readonly string[] = [
78
78
  /** Files a redirect may rewrite. Everything else in a package (json, css, wasm) loads unchanged. */
79
79
  const REDIRECTABLE = /\.(?:m|c)?[jt]sx?$/
80
80
 
81
- /** The loader a {@link REDIRECTABLE} file is parsed with. `.ts` is never `tsx`: under JSX rules a
82
- * generic arrow (`<T>(x: T) => x`) reads as an unclosed element and swallows the file. */
83
- function sourceLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
84
- if (path.endsWith(".tsx")) return "tsx"
85
- if (path.endsWith(".jsx")) return "jsx"
86
- return /\.[mc]?ts$/.test(path) ? "ts" : "js"
87
- }
81
+ /** Appended to a linked file the load hook hands back to Bun untouched; see the hook. */
82
+ const OWN_FILE_QUERY = "?nifra-single-copy"
83
+
88
84
  /** Runaway guards. A real `node_modules` top level is hundreds of entries, a repo walk is a few. */
89
85
  const MAX_SCANNED_ENTRIES = 4_096
90
86
  const MAX_ANCESTOR_DIRS = 16
@@ -233,6 +229,11 @@ export interface SingleCopyRegistration {
233
229
  readonly run: boolean
234
230
  /** `[test] preload`: covers `bun test`, the surface that runs app sources unbundled. */
235
231
  readonly test: boolean
232
+ /**
233
+ * Tables whose `preload` names the registrar although Bun reads it from neither place: below
234
+ * `[install]` the line is `install.preload`, so `bun run` loads every copy while the file looks armed.
235
+ */
236
+ readonly ignored?: readonly string[]
236
237
  /** The `bunfig.toml` that was read, if one exists. */
237
238
  readonly config?: string
238
239
  }
@@ -286,6 +287,7 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
286
287
  let section = ""
287
288
  let run = false
288
289
  let test = false
290
+ const ignored: string[] = []
289
291
  for (const line of text.split(/\r?\n/)) {
290
292
  const match = PRELOAD_ASSIGNMENT.exec(line)
291
293
  if (match?.groups === undefined) continue
@@ -298,8 +300,9 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
298
300
  continue
299
301
  if (section === "test") test = true
300
302
  else if (section === "") run = true
303
+ else if (!ignored.includes(section)) ignored.push(section)
301
304
  }
302
- return { run, test, config }
305
+ return ignored.length === 0 ? { run, test, config } : { run, test, ignored, config }
303
306
  }
304
307
 
305
308
  /** The repository a linked checkout resolves its own dependencies from: its nearest `.git` ancestor.
@@ -598,6 +601,8 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
598
601
  // hook has to return contents for everything it matches, and re-emitting a CommonJS entry as an
599
602
  // ES module is how `Export named 'useEffect' not found` happens.
600
603
  const filter = new RegExp(`^(?:${roots})[/\\\\].*${REDIRECTABLE.source}`)
604
+ // `Bun.plugin` leaves `config` unset although the type says otherwise; only `Bun.build` sets it.
605
+ const bundling = build.config !== undefined
601
606
  build.onLoad({ filter }, (args) => {
602
607
  for (const redirect of plan.redirects) {
603
608
  const target = counterpart(redirect, args.path)
@@ -616,10 +621,13 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
616
621
  detail: `${relative(owner.from, args.path)} has no file at the same path in the app's copy, so it loads from the linked copy and its module state is not shared`,
617
622
  })
618
623
  }
619
- // A runtime `onLoad` must always return an object - returning nothing fails the load rather
620
- // than falling through to the default. Hand back the file as it was, at its own path, so its
621
- // relative imports keep resolving exactly as they would have.
622
- return { contents: readFileSync(args.path, "utf8"), loader: sourceLoader(args.path) }
624
+ // The file loads as it is, in its own module format. A bundler takes `undefined` for that. A
625
+ // runtime `onLoad` must return an object, and source returned from here loads as an ES module,
626
+ // which empties a CommonJS file's exports - so the runtime gets a re-export of the file under a
627
+ // query the filter's `$` cannot match, which Bun then loads natively from its own directory.
628
+ // There its `__filename` and `import.meta.url` carry the query; the bundler cannot resolve it.
629
+ if (bundling) return undefined
630
+ return { contents: reexport(`${args.path}${OWN_FILE_QUERY}`), loader: "js" }
623
631
  })
624
632
  },
625
633
  }
@@ -646,12 +654,20 @@ const processSet = (key: symbol): Set<string> => {
646
654
  const copyLine = (label: string, path: string | undefined, version: string | undefined): string =>
647
655
  ` ${label}: ${path ?? "(not installed for the app)"}${version === undefined ? "" : ` (${version})`}`
648
656
 
649
- /** One report per package: both copies, both versions, the reason, and the fix. */
657
+ /**
658
+ * One report per package: both copies, both versions, the reason, and the fix. A version skew comes
659
+ * from the plan, which reads installs, so it says installed: nothing may ever load that copy, and a
660
+ * runtime `onLoad` cannot watch for it without re-emitting CommonJS files as ES modules.
661
+ */
650
662
  const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string => {
651
663
  const first = skips[0]
652
664
  const reason = first?.reason ?? "version-skew"
665
+ const effect =
666
+ reason === "version-skew"
667
+ ? "a second copy is installed at another version, and whatever loads it does not share this copy's module state."
668
+ : "a second copy loads, so its module state is not shared."
653
669
  const lines = [
654
- `[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - a second copy loads, so its module state is not shared.`,
670
+ `[nifra] single-copy: ${name} is NOT deduplicated (${reason}) - ${effect}`,
655
671
  copyLine("app copy", first?.to, first?.toVersion),
656
672
  ]
657
673
  for (const skip of skips) {
@@ -683,7 +699,7 @@ const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
683
699
  if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
684
700
  const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
685
701
  return [
686
- `[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each loads, so their module state is not shared.`,
702
+ `[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each is installed at another version, and whatever loads one does not share the app copy's module state.`,
687
703
  copyLine(
688
704
  "app copies",
689
705
  first.to === undefined ? undefined : modulesDirOf(first.to),