@nifrajs/core 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/assurance.d.ts +15 -1
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +97 -71
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +2 -0
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +1 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -1
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/route-compiler.d.ts +9 -0
  17. package/dist/internal/route-compiler.d.ts.map +1 -1
  18. package/dist/internal/route-compiler.js +95 -13
  19. package/dist/internal/route-compiler.js.map +1 -1
  20. package/dist/internal/same-origin.d.ts +11 -1
  21. package/dist/internal/same-origin.d.ts.map +1 -1
  22. package/dist/internal/same-origin.js +8 -0
  23. package/dist/internal/same-origin.js.map +1 -1
  24. package/dist/server/body-lane.d.ts +8 -2
  25. package/dist/server/body-lane.d.ts.map +1 -1
  26. package/dist/server/body-lane.js +27 -2
  27. package/dist/server/body-lane.js.map +1 -1
  28. package/dist/server/client-ip.js +6 -5
  29. package/dist/server/client-ip.js.map +1 -1
  30. package/dist/server/cookies.d.ts.map +1 -1
  31. package/dist/server/cookies.js +13 -1
  32. package/dist/server/cookies.js.map +1 -1
  33. package/dist/server/edge.d.ts.map +1 -1
  34. package/dist/server/edge.js +2 -1
  35. package/dist/server/edge.js.map +1 -1
  36. package/dist/server/http.d.ts.map +1 -1
  37. package/dist/server/http.js +5 -1
  38. package/dist/server/http.js.map +1 -1
  39. package/dist/server/response-contract-lane.d.ts +21 -6
  40. package/dist/server/response-contract-lane.d.ts.map +1 -1
  41. package/dist/server/response-contract-lane.js +139 -14
  42. package/dist/server/response-contract-lane.js.map +1 -1
  43. package/dist/server/server-types.d.ts +16 -5
  44. package/dist/server/server-types.d.ts.map +1 -1
  45. package/dist/server/server.d.ts +23 -1
  46. package/dist/server/server.d.ts.map +1 -1
  47. package/dist/server/server.js +37 -13
  48. package/dist/server/server.js.map +1 -1
  49. package/dist/server/websocket.d.ts +11 -0
  50. package/dist/server/websocket.d.ts.map +1 -1
  51. package/dist/server/websocket.js +26 -12
  52. package/dist/server/websocket.js.map +1 -1
  53. package/dist/server/ws-bun.d.ts +7 -1
  54. package/dist/server/ws-bun.d.ts.map +1 -1
  55. package/dist/server/ws-bun.js +42 -2
  56. package/dist/server/ws-bun.js.map +1 -1
  57. package/dist/server/ws-hook.d.ts +9 -3
  58. package/dist/server/ws-hook.d.ts.map +1 -1
  59. package/dist/server.d.ts +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +1 -1
  62. package/dist/server.js.map +1 -1
  63. package/dist/single-copy.d.ts.map +1 -1
  64. package/dist/single-copy.js +35 -5
  65. package/dist/single-copy.js.map +1 -1
  66. package/dist/wire.d.ts +5 -0
  67. package/dist/wire.d.ts.map +1 -1
  68. package/dist/wire.js +5 -0
  69. package/dist/wire.js.map +1 -1
  70. package/dist/ws.d.ts.map +1 -1
  71. package/dist/ws.js +2 -1
  72. package/dist/ws.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/assurance.ts +120 -81
  75. package/src/capabilities.ts +3 -1
  76. package/src/edge-kit.ts +1 -1
  77. package/src/index.ts +2 -2
  78. package/src/internal/route-compiler.ts +107 -16
  79. package/src/internal/same-origin.ts +14 -1
  80. package/src/server/body-lane.ts +36 -1
  81. package/src/server/client-ip.ts +5 -5
  82. package/src/server/cookies.ts +12 -1
  83. package/src/server/edge.ts +2 -1
  84. package/src/server/http.ts +7 -1
  85. package/src/server/response-contract-lane.ts +162 -10
  86. package/src/server/server-types.ts +16 -5
  87. package/src/server/server.ts +48 -9
  88. package/src/server/websocket.ts +39 -13
  89. package/src/server/ws-bun.ts +48 -1
  90. package/src/server/ws-hook.ts +9 -2
  91. package/src/server.ts +1 -1
  92. package/src/single-copy.ts +35 -4
  93. package/src/wire.ts +14 -0
  94. package/src/ws.ts +2 -0
@@ -233,8 +233,19 @@ export type WebSocketUpgradeOutcome =
233
233
  * `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
234
234
  readonly attach: WsAttach
235
235
  readonly maxPayloadBytes?: number
236
+ /** Builds `ws.send` over an adapter's native send, enforcing the route's {@link
237
+ * WebSocketHandler.validateSend}. Set on every outcome a nifra app produces; an adapter that
238
+ * wires its own sockets instead of calling `attach` (Node, Deno) builds `send` with it. */
239
+ readonly sender?: WsSender
236
240
  }
237
241
 
242
+ /** The send-builder the upgrade outcome carries (the installed runtime's `sender`). */
243
+ export type WsSender = (
244
+ handler: WebSocketHandler,
245
+ send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
246
+ onError: (error: unknown) => void,
247
+ ) => (payload: string | ArrayBufferView | ArrayBuffer) => void
248
+
238
249
  /** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */
239
250
  export type WsAttach = (
240
251
  socket: StandardWebSocket,
@@ -325,18 +336,11 @@ export function attachWebSocket<
325
336
  socket.addEventListener("message", (event: { readonly data: unknown }) => {
326
337
  const raw = event.data
327
338
  const payload: WebSocketData = typeof raw === "string" ? raw : toBinary(raw)
328
- // Measuring a text frame costs a full UTF-8 encode plus the buffer it allocates, so it happens
329
- // only for a route that actually declared a cap. Reading `.byteLength` off an already-decoded
330
- // binary frame is free, but the branch stays under the same guard to keep one shape here.
331
- if (maxPayloadBytes !== undefined) {
332
- const payloadBytes =
333
- typeof raw === "string"
334
- ? WS_PAYLOAD_ENCODER.encode(raw).byteLength
335
- : (payload as Uint8Array).byteLength
336
- if (payloadBytes > maxPayloadBytes) {
337
- socket.close(1009, "message too large")
338
- return
339
- }
339
+ // The runtime hands over each message whole, so this bounds what reaches the handler, not what
340
+ // the runtime buffered to deliver it.
341
+ if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
342
+ socket.close(1009, "message too large")
343
+ return
340
344
  }
341
345
  safe(() =>
342
346
  (
@@ -363,7 +367,25 @@ export function attachWebSocket<
363
367
  }
364
368
 
365
369
  const WS_MESSAGE_DECODER = new TextDecoder()
366
- const WS_PAYLOAD_ENCODER = new TextEncoder()
370
+
371
+ /** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
372
+ * 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
373
+ export function exceedsPayloadCap(
374
+ message: string | ArrayBuffer | Uint8Array,
375
+ cap: number,
376
+ ): boolean {
377
+ if (typeof message !== "string") return message.byteLength > cap
378
+ const units = message.length
379
+ if (units > cap) return true
380
+ if (units * 3 <= cap) return false
381
+ let bytes = 0
382
+ for (let i = 0; i < units; i++) {
383
+ const unit = message.charCodeAt(i)
384
+ bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
385
+ if (bytes > cap) return true
386
+ }
387
+ return false
388
+ }
367
389
 
368
390
  type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
369
391
 
@@ -448,6 +470,10 @@ export function createWebSocketSender(
448
470
  }
449
471
  }
450
472
 
473
+ /** {@link createWebSocketSender} configured from a route's handler: the outcome's `sender`. */
474
+ export const webSocketSender: WsSender = (handler, send, onError) =>
475
+ createWebSocketSender(send, handler.sendSchema, handler.validateSend, handler.transport, onError)
476
+
451
477
  /**
452
478
  * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
453
479
  * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  createWebSocketSender,
10
+ exceedsPayloadCap,
10
11
  type NifraWebSocket,
11
12
  type TopicRegistry,
12
13
  type WebSocketData,
@@ -20,6 +21,9 @@ export interface BunWsData {
20
21
  readonly handler: WebSocketHandler
21
22
  /** Mounted/modern upgrades carry this registry; old/manual fixtures may omit it. */
22
23
  readonly pubsub?: TopicRegistry
24
+ /** The inbound cap of the app this socket belongs to (the upgrade outcome's). Absent, the
25
+ * listening app's applies. */
26
+ readonly maxPayloadBytes?: number | undefined
23
27
  data: unknown
24
28
  nifra?: NifraWebSocket
25
29
  }
@@ -38,6 +42,31 @@ export interface BunSocket {
38
42
  readonly data: BunWsData
39
43
  }
40
44
 
45
+ /**
46
+ * The largest `wsMaxPayloadBytes` among the nifra apps mounted under `app` that serve sockets, or
47
+ * `floor` when none declares more. Bun takes one frame limit per listener, so it has to admit every
48
+ * mounted app's cap. Reads each server's own mount table, as reflection does, so an app that never
49
+ * listens pays nothing for it; a child that is not a nifra server names no cap here.
50
+ */
51
+ function mountedPayloadCeiling(app: object, floor: number, seen: Set<object>): number {
52
+ if (seen.has(app)) return floor
53
+ seen.add(app)
54
+ const mounts: unknown = Reflect.get(app, "fetchMounts")
55
+ if (!Array.isArray(mounts)) return floor
56
+ let ceiling = floor
57
+ for (const mount of mounts) {
58
+ const child: unknown = typeof mount === "object" && mount !== null ? mount.app : undefined
59
+ if (typeof child !== "object" || child === null) continue
60
+ const routes: unknown = Reflect.get(child, "wsRoutes")
61
+ const cap: unknown = Reflect.get(child, "wsMaxPayloadBytes")
62
+ if (Array.isArray(routes) && routes.length > 0 && typeof cap === "number" && cap > ceiling) {
63
+ ceiling = cap
64
+ }
65
+ ceiling = mountedPayloadCeiling(child, ceiling, seen)
66
+ }
67
+ return ceiling
68
+ }
69
+
41
70
  /** Normalize a received binary frame to `Uint8Array` (Bun hands a `Buffer`, already one; an
42
71
  * `ArrayBuffer` is wrapped). Text frames stay `string`. */
43
72
  function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
@@ -127,9 +156,21 @@ function dispatchWsCallback(
127
156
  * `nativePubsub` (set by `listen()` when the app has no validated-send route) makes subscriptions use
128
157
  * Bun's own pub/sub; there the registry holds nothing and Bun auto-drops native subscriptions on
129
158
  * close, so the `unsubscribeAll` sweep is skipped.
159
+ *
160
+ * `app` is the listening server. Its `wsMaxPayloadBytes` and its mounted apps' set the listener's
161
+ * `maxPayloadLength`; a socket whose app declares less is closed with `1009` here.
130
162
  */
131
- export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false): BunWsHandlers {
163
+ export function createBunWsHandlers(
164
+ topics: TopicRegistry,
165
+ nativePubsub = false,
166
+ app?: object,
167
+ ): BunWsHandlers {
168
+ const own: unknown = app === undefined ? undefined : Reflect.get(app, "wsMaxPayloadBytes")
169
+ const appCap = typeof own === "number" ? own : Number.POSITIVE_INFINITY
170
+ const listenerCap =
171
+ app === undefined ? appCap : mountedPayloadCeiling(app, appCap, new Set<object>())
132
172
  return {
173
+ maxPayloadLength: listenerCap,
133
174
  open: (raw) => {
134
175
  const ws = raw as BunSocket
135
176
  const nifra = wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
@@ -138,6 +179,12 @@ export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false)
138
179
  },
139
180
  message: (raw, message) => {
140
181
  const ws = raw as BunSocket
182
+ // Only a socket whose app declares less than the listener admits pays for a size check.
183
+ const cap = ws.data.maxPayloadBytes ?? appCap
184
+ if (cap < listenerCap && exceedsPayloadCap(message, cap)) {
185
+ ws.close(1009, "message too large")
186
+ return
187
+ }
141
188
  const handler = ws.data.handler
142
189
  const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, handler, nativePubsub)
143
190
  const data: WebSocketData = typeof message === "string" ? message : toBinaryData(message)
@@ -15,6 +15,7 @@ import type {
15
15
  TopicRegistry,
16
16
  WebSocketContext,
17
17
  WebSocketHandler,
18
+ WsSender,
18
19
  } from "./websocket.ts"
19
20
 
20
21
  /** The Bun serve `websocket` callback trio, closed over one app's `TopicRegistry`. The `ws`
@@ -24,6 +25,9 @@ export interface BunWsHandlers {
24
25
  open(ws: unknown): void
25
26
  message(ws: unknown, message: string | ArrayBuffer | Uint8Array): void
26
27
  close(ws: unknown, code: number, reason: string): void
28
+ /** The listener's `maxPayloadLength`: the largest `wsMaxPayloadBytes` among the app and the apps
29
+ * mounted on it. A socket whose app declares less checks its own cap per message. */
30
+ readonly maxPayloadLength: number
27
31
  }
28
32
 
29
33
  /** What `@nifrajs/core/ws` registers: everything `server.ts` and `toFetchHandler` need at runtime. */
@@ -37,8 +41,9 @@ export interface WsRuntime {
37
41
  /** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
38
42
  createTopics(): TopicRegistry
39
43
  /** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
40
- * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). */
41
- bunHandlers(topics: TopicRegistry, nativePubsub?: boolean): BunWsHandlers
44
+ * `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). `app` is
45
+ * the listening server, whose mount table names the payload caps the listener has to admit. */
46
+ bunHandlers(topics: TopicRegistry, nativePubsub?: boolean, app?: object): BunWsHandlers
42
47
  /** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
43
48
  attach(
44
49
  socket: StandardWebSocket,
@@ -46,4 +51,6 @@ export interface WsRuntime {
46
51
  data: unknown,
47
52
  options: { openNow: boolean; pubsub: TopicRegistry },
48
53
  ): NifraWebSocket
54
+ /** `webSocketSender` - `ws.send` for an adapter that wires its own sockets (Node, Deno). */
55
+ sender: WsSender
49
56
  }
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
12
12
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
13
- export { isSameOriginRequest } from "./internal/same-origin.ts"
13
+ export { isSameOriginForm, isSameOriginRequest } from "./internal/same-origin.ts"
14
14
  export {
15
15
  ROUTE_PATTERN_OVERLAP_MAX_STATES,
16
16
  RoutePatternOverlapLimitError,
@@ -55,7 +55,7 @@
55
55
  */
56
56
 
57
57
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
58
- import { dirname, join, relative, resolve, sep } from "node:path"
58
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"
59
59
  import type { BunPlugin } from "bun"
60
60
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
61
61
 
@@ -339,6 +339,26 @@ const installedCopy = (
339
339
  }
340
340
  }
341
341
 
342
+ /** The package a bare specifier names, without its subpath: `@scope/name` or `name`. */
343
+ const packageNameOf = (specifier: string): string =>
344
+ specifier
345
+ .split("/")
346
+ .slice(0, specifier.startsWith("@") ? 2 : 1)
347
+ .join("/")
348
+
349
+ /**
350
+ * Whether Bun's own node_modules walk from `importer` already reaches the app's copy. Bun then keeps
351
+ * the package's `sideEffects: false` pruning; a path handed back from `onResolve` loses it, so the
352
+ * bundle reads every module the entry re-exports, `node:` imports included.
353
+ */
354
+ const reachesAppCopy = (specifier: string, importer: string, root: string): boolean => {
355
+ if (!isAbsolute(importer)) return false
356
+ const name = packageNameOf(specifier)
357
+ const ours = installedCopy(root, name, parse(root).root)
358
+ if (ours === undefined) return false
359
+ return installedCopy(dirname(importer), name, parse(importer).root)?.root === ours.root
360
+ }
361
+
342
362
  /** Every `node_modules` from the app root up to (and including) its repository root. */
343
363
  const nodeModulesDirs = (root: string): readonly string[] => {
344
364
  const boundary = repoBoundary(root)
@@ -355,6 +375,17 @@ const nodeModulesDirs = (root: string): readonly string[] => {
355
375
  return dirs
356
376
  }
357
377
 
378
+ /** Where a real package folder lives when only its files are links: Bun installs a `file:` folder
379
+ * dependency as a directory of per-file symlinks, so its imports resolve beside the linked files. */
380
+ const fileLinkedDir = (dir: string): string | undefined => {
381
+ const manifest = join(dir, "package.json")
382
+ try {
383
+ return lstatSync(manifest).isSymbolicLink() ? dirname(realpathOrSelf(manifest)) : undefined
384
+ } catch {
385
+ return undefined
386
+ }
387
+ }
388
+
358
389
  /**
359
390
  * Package directories that are symlinked OUT of this repository - the `link:` / `file:` / `npm link`
360
391
  * consumers whose own imports resolve somewhere this install does not own. Those are the only
@@ -388,9 +419,8 @@ const linkedPackageDirs = (root: string): readonly string[] => {
388
419
  } catch {
389
420
  continue
390
421
  }
391
- if (!link.isSymbolicLink()) continue
392
- const target = realpathOrSelf(candidate)
393
- if (inside(repo, target)) continue
422
+ const target = link.isSymbolicLink() ? realpathOrSelf(candidate) : fileLinkedDir(candidate)
423
+ if (target === undefined || inside(repo, target)) continue
394
424
  found.add(target)
395
425
  }
396
426
  }
@@ -550,6 +580,7 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
550
580
  if (alternatives.length > 0) {
551
581
  const filter = new RegExp(`^(?:${alternatives.join("|")})$`)
552
582
  build.onResolve({ filter }, (args) => {
583
+ if (reachesAppCopy(args.path, args.importer, plan.root)) return undefined
553
584
  try {
554
585
  return { path: Bun.resolveSync(args.path, plan.root) }
555
586
  } catch {
package/src/wire.ts CHANGED
@@ -43,6 +43,11 @@ export interface WireDecodeLimits {
43
43
  readonly maxCollectionEntries?: number
44
44
  /** Maximum UTF-8 string plus decoded binary bytes materialized. Default 16 MiB. */
45
45
  readonly maxDecodedBytes?: number
46
+ /**
47
+ * Maximum decimal digits in one `bigint`. Default 4,096 (past 8192-bit integers). Parsing grows
48
+ * faster than the digit count, so one long token would cost seconds of CPU under the byte limit.
49
+ */
50
+ readonly maxBigIntDigits?: number
46
51
  }
47
52
 
48
53
  export const DEFAULT_WIRE_DECODE_LIMITS: Readonly<Required<WireDecodeLimits>> = Object.freeze({
@@ -50,6 +55,7 @@ export const DEFAULT_WIRE_DECODE_LIMITS: Readonly<Required<WireDecodeLimits>> =
50
55
  maxDepth: 256,
51
56
  maxCollectionEntries: 100_000,
52
57
  maxDecodedBytes: 16 * 1024 * 1024,
58
+ maxBigIntDigits: 4096,
53
59
  })
54
60
 
55
61
  /** Thrown by {@link encode} for a value it will not encode (a function or a symbol). */
@@ -223,6 +229,11 @@ export function decode(wire: Wire, limits: WireDecodeLimits = {}): unknown {
223
229
  DEFAULT_WIRE_DECODE_LIMITS.maxDecodedBytes,
224
230
  "maxDecodedBytes",
225
231
  )
232
+ const maxBigIntDigits = decodeLimit(
233
+ limits.maxBigIntDigits,
234
+ DEFAULT_WIRE_DECODE_LIMITS.maxBigIntDigits,
235
+ "maxBigIntDigits",
236
+ )
226
237
  if (nodes.length > maxNodes) throw new WireDecodeError("wire node limit exceeded")
227
238
  const built = new Map<number, unknown>()
228
239
  let collectionEntries = 0
@@ -279,6 +290,9 @@ export function decode(wire: Wire, limits: WireDecodeLimits = {}): unknown {
279
290
  if (typeof record.v !== "string" || !/^-?(?:0|[1-9][0-9]*)$/u.test(record.v)) {
280
291
  throw new WireDecodeError("malformed bigint token")
281
292
  }
293
+ if (record.v.length - (record.v.charCodeAt(0) === 45 ? 1 : 0) > maxBigIntDigits) {
294
+ throw new WireDecodeError("wire bigint digit limit exceeded")
295
+ }
282
296
  chargeString(record.v)
283
297
  return BigInt(record.v)
284
298
  }
package/src/ws.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  attachWebSocket,
26
26
  handshakeUpgradeStep,
27
27
  TopicRegistry,
28
+ webSocketSender,
28
29
  wrapWebSocketMessageValidation,
29
30
  } from "./server/websocket.ts"
30
31
  import { createBunWsHandlers } from "./server/ws-bun.ts"
@@ -37,6 +38,7 @@ const WS_RUNTIME: WsRuntime = {
37
38
  createTopics: () => new TopicRegistry(),
38
39
  bunHandlers: createBunWsHandlers,
39
40
  attach: attachWebSocket,
41
+ sender: webSocketSender,
40
42
  }
41
43
 
42
44
  /** The install seam a server exposes so the `websocket()` plugin can hand it the runtime. */