@nifrajs/core 3.3.0 → 3.5.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 (43) hide show
  1. package/dist/diff.d.ts +5 -1
  2. package/dist/diff.d.ts.map +1 -1
  3. package/dist/diff.js +11 -1
  4. package/dist/diff.js.map +1 -1
  5. package/dist/evidence.d.ts +12 -0
  6. package/dist/evidence.d.ts.map +1 -1
  7. package/dist/evidence.js +52 -1
  8. package/dist/evidence.js.map +1 -1
  9. package/dist/idempotency.d.ts.map +1 -1
  10. package/dist/idempotency.js +8 -1
  11. package/dist/idempotency.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/manifest.d.ts +1 -1
  17. package/dist/manifest.d.ts.map +1 -1
  18. package/dist/manifest.js +11 -9
  19. package/dist/manifest.js.map +1 -1
  20. package/dist/server/cookies.d.ts +4 -1
  21. package/dist/server/cookies.d.ts.map +1 -1
  22. package/dist/server/cookies.js +40 -2
  23. package/dist/server/cookies.js.map +1 -1
  24. package/dist/server/server-types.d.ts +2 -0
  25. package/dist/server/server-types.d.ts.map +1 -1
  26. package/dist/server/server.d.ts +3 -0
  27. package/dist/server/server.d.ts.map +1 -1
  28. package/dist/server/server.js +139 -59
  29. package/dist/server/server.js.map +1 -1
  30. package/dist/server.d.ts +1 -1
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +1 -1
  33. package/dist/server.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/diff.ts +24 -2
  36. package/src/evidence.ts +66 -1
  37. package/src/idempotency.ts +8 -1
  38. package/src/index.ts +2 -1
  39. package/src/manifest.ts +11 -10
  40. package/src/server/cookies.ts +34 -2
  41. package/src/server/server-types.ts +2 -0
  42. package/src/server/server.ts +165 -66
  43. package/src/server.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/core",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
5
5
  "keywords": [
6
6
  "bun",
package/src/diff.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * yield `info` - never a silent pass presented as proof.
19
19
  */
20
20
 
21
+ import { type ProjectEvidenceSnapshot, reflectedRoutesFromEvidence } from "./evidence.ts"
21
22
  import {
22
23
  type JsonSchema,
23
24
  type ReflectedRoute,
@@ -35,6 +36,7 @@ export interface RouteSnapshotSchema {
35
36
  readonly headers?: SchemaSnapshot
36
37
  readonly body?: SchemaSnapshot
37
38
  readonly query?: SchemaSnapshot
39
+ readonly params?: SchemaSnapshot
38
40
  readonly response?: SchemaSnapshot
39
41
  readonly sse?: SchemaSnapshot
40
42
  readonly errors?: Readonly<Record<string, SchemaSnapshot>>
@@ -54,7 +56,15 @@ export interface RouteChange {
54
56
  readonly method: string
55
57
  readonly path: string
56
58
  /** Which part of the contract changed; "route" for add/remove of the whole route. */
57
- readonly section: "route" | "headers" | "body" | "query" | "response" | "sse" | "errors"
59
+ readonly section:
60
+ | "route"
61
+ | "headers"
62
+ | "body"
63
+ | "query"
64
+ | "params"
65
+ | "response"
66
+ | "sse"
67
+ | "errors"
58
68
  /** The top-level field (or error status) the change is about, when field-granular. */
59
69
  readonly field?: string
60
70
  readonly message: string
@@ -87,9 +97,19 @@ const schemaSnapshot = (
87
97
  * `JSON.stringify` unchanged.
88
98
  */
89
99
  export function snapshotRoutes(source: unknown): readonly RouteSnapshot[] {
100
+ // Preserve the historical registration order of this low-level helper. The canonical evidence
101
+ // seam intentionally sorts routes for persistence, while callers using snapshotRoutes directly
102
+ // may use the order to compare a live reflection with an older baseline.
90
103
  return reflectRoutes(source).map((route) => snapshotRoute(route))
91
104
  }
92
105
 
106
+ /** Snapshot routes from an existing canonical project-evidence pass. */
107
+ export function snapshotRoutesFromEvidence(
108
+ evidence: ProjectEvidenceSnapshot,
109
+ ): readonly RouteSnapshot[] {
110
+ return reflectedRoutesFromEvidence(evidence).map((route) => snapshotRoute(route))
111
+ }
112
+
93
113
  const snapshotRoute = (route: ReflectedRoute): RouteSnapshot => {
94
114
  const schema = route.schema
95
115
  if (schema === undefined) return { method: route.method, path: route.path }
@@ -101,12 +121,14 @@ const snapshotRoute = (route: ReflectedRoute): RouteSnapshot => {
101
121
  const body = schemaSnapshot(schema.body)
102
122
  const headers = schemaSnapshot(schema.headers)
103
123
  const query = schemaSnapshot(schema.query)
124
+ const params = schemaSnapshot(schema.params)
104
125
  const response = schemaSnapshot(schema.response)
105
126
  const sse = schemaSnapshot(schema.sse)
106
127
  const snapped: RouteSnapshotSchema = {
107
128
  ...(headers !== undefined ? { headers } : {}),
108
129
  ...(body !== undefined ? { body } : {}),
109
130
  ...(query !== undefined ? { query } : {}),
131
+ ...(params !== undefined ? { params } : {}),
110
132
  ...(response !== undefined ? { response } : {}),
111
133
  ...(sse !== undefined ? { sse } : {}),
112
134
  ...(Object.keys(errors).length > 0 ? { errors } : {}),
@@ -356,7 +378,7 @@ export function diffRouteSnapshots(
356
378
  const path = beforeRoute.path
357
379
  const beforeSchema = beforeRoute.schema ?? {}
358
380
  const afterSchema = afterRoute.schema ?? {}
359
- for (const section of ["headers", "body", "query"] as const) {
381
+ for (const section of ["headers", "body", "query", "params"] as const) {
360
382
  diffSchemaSection(
361
383
  { method, path, section, direction: "request", changes },
362
384
  beforeSchema[section],
package/src/evidence.ts CHANGED
@@ -100,6 +100,12 @@ export interface ProjectEvidenceSnapshot {
100
100
  export interface ProjectEvidenceOptions {
101
101
  readonly assurance?: AssuranceReport
102
102
  readonly capabilities?: CapabilityAssuranceReport
103
+ /**
104
+ * An existing reflection pass. Supplying it lets several offline projections share one pass
105
+ * without asking the runtime source for `.routes()` again. Validators are consumed only while
106
+ * building the token-only snapshot and never cross this seam.
107
+ */
108
+ readonly routes?: readonly ReflectedRoute[]
103
109
  /** Optional static source locations keyed by `${METHOD}\n${path}`. */
104
110
  readonly sourceLocations?: ReadonlyMap<string, readonly ProjectEvidenceSourceLocation[]>
105
111
  }
@@ -262,6 +268,64 @@ function capabilitiesOf(
262
268
  })
263
269
  }
264
270
 
271
+ function schemaPartToReflection(
272
+ value: ProjectEvidenceSchemaPart | undefined,
273
+ ): SchemaReflection | undefined {
274
+ return value === undefined
275
+ ? undefined
276
+ : { standard: undefined, jsonSchema: value.jsonSchema, fields: value.fields }
277
+ }
278
+
279
+ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
280
+ if (schema === undefined) return undefined
281
+ const errors = Object.fromEntries(
282
+ Object.entries(schema.errors ?? {}).map(([status, value]) => [
283
+ status,
284
+ schemaPartToReflection(value) as SchemaReflection,
285
+ ]),
286
+ )
287
+ const headers = schemaPartToReflection(schema.headers)
288
+ const body = schemaPartToReflection(schema.body)
289
+ const query = schemaPartToReflection(schema.query)
290
+ const params = schemaPartToReflection(schema.params)
291
+ const response = schemaPartToReflection(schema.response)
292
+ const sse = schemaPartToReflection(schema.sse)
293
+ return {
294
+ ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
295
+ ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
296
+ ...(headers !== undefined ? { headers } : {}),
297
+ ...(body !== undefined ? { body } : {}),
298
+ ...(query !== undefined ? { query } : {}),
299
+ ...(params !== undefined ? { params } : {}),
300
+ ...(response !== undefined ? { response } : {}),
301
+ ...(Object.keys(errors).length > 0 ? { errors } : {}),
302
+ ...(sse !== undefined ? { sse } : {}),
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Adapt the canonical token-only snapshot to the runtime-reflection shape used by projections.
308
+ * The returned schemas deliberately have no `standard` validator: this adapter is for offline
309
+ * contract views, not request validation or mock generation.
310
+ */
311
+ export function reflectedRoutesFromEvidence(
312
+ evidence: ProjectEvidenceSnapshot,
313
+ ): readonly ReflectedRoute[] {
314
+ return evidence.routes.map((route) => {
315
+ const schema = schemaFromEvidence(route.schema)
316
+ return {
317
+ method: route.method,
318
+ path: route.path,
319
+ ...(schema !== undefined ? { schema } : {}),
320
+ ...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
321
+ ...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
322
+ ...(route.family === true ? { family: true } : {}),
323
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
324
+ ...(route.tool !== undefined ? { tool: route.tool } : {}),
325
+ }
326
+ })
327
+ }
328
+
265
329
  /** Build the canonical snapshot from reflection and already-evaluated offline reports. */
266
330
  export function snapshotProjectEvidence(
267
331
  source: unknown,
@@ -270,10 +334,11 @@ export function snapshotProjectEvidence(
270
334
  const locations = options.sourceLocations
271
335
  const assurance = assuranceOf(options.assurance)
272
336
  const capabilities = capabilitiesOf(options.capabilities)
337
+ const reflected = options.routes ?? reflectRoutes(source)
273
338
  const routes = sortByRoute(
274
339
  // Importantly, this is the only reflection pass for this snapshot. Every projection can consume
275
340
  // the result instead of independently asking the app for `.routes()` and re-shaping schemas.
276
- reflectRoutes(source).map((route) =>
341
+ reflected.map((route) =>
277
342
  evidenceRouteOf(route, locations?.get(keyOf(route.method, route.path))),
278
343
  ),
279
344
  )
@@ -240,7 +240,14 @@ async function boundedResponseBytes(response: Response, maxBytes: number): Promi
240
240
  if (done) break
241
241
  total += value.byteLength
242
242
  if (total > maxBytes) {
243
- await reader.cancel()
243
+ // `Response.clone()` tees the body. Cancelling only the clone branch leaves the live branch
244
+ // unread, so a streaming producer can keep the tee pending forever. This path stores no
245
+ // response and the caller will replace it with a terminal error, so cancel both branches.
246
+ const liveBody = response.body
247
+ await Promise.allSettled([
248
+ reader.cancel(),
249
+ liveBody === null ? Promise.resolve() : liveBody.cancel(),
250
+ ])
244
251
  throw new IdempotencyResponseTooLargeError(maxBytes)
245
252
  }
246
253
  chunks.push(value)
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "3.3.0" as const
19
+ export const VERSION = "3.5.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -45,6 +45,7 @@ export {
45
45
  type CookieOptions,
46
46
  type CookieSecret,
47
47
  cookieNamePrefix,
48
+ hasDuplicateCookie,
48
49
  parseCookies,
49
50
  serializeCookie,
50
51
  signValue,
package/src/manifest.ts CHANGED
@@ -12,10 +12,8 @@ import {
12
12
  diffRouteSnapshots,
13
13
  type RouteChange,
14
14
  type RouteSnapshotSchema,
15
- snapshotRoutes,
16
15
  } from "./diff.ts"
17
- import type { ProjectEvidenceSnapshot } from "./evidence.ts"
18
- import { reflectRoutes } from "./reflection.ts"
16
+ import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
19
17
 
20
18
  export interface NifraManifestAssurance {
21
19
  readonly rule?: string
@@ -190,25 +188,28 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
190
188
  if (input.evidence?.capabilities !== undefined && !input.evidence.capabilities.ok) {
191
189
  throw new Error("nifra manifest: refusing to emit failing capability assurance")
192
190
  }
191
+ const evidence =
192
+ input.evidence ??
193
+ snapshotProjectEvidence(input.source, {
194
+ ...(input.assurance === undefined ? {} : { assurance: input.assurance }),
195
+ ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }),
196
+ })
193
197
  const reflected = new Map(
194
- (input.evidence?.routes ?? reflectRoutes(input.source)).map((route) => [
195
- routeKey(route.method, route.path),
196
- route,
197
- ]),
198
+ evidence.routes.map((route) => [routeKey(route.method, route.path), route]),
198
199
  )
199
200
  const assured = new Map(
200
- (input.evidence?.assurance?.routes ?? input.assurance?.routes ?? []).map((route) => [
201
+ (evidence.assurance?.routes ?? input.assurance?.routes ?? []).map((route) => [
201
202
  routeKey(route.method, route.path),
202
203
  route,
203
204
  ]),
204
205
  )
205
206
  const capable = new Map(
206
- (input.evidence?.capabilities?.routes ?? input.capabilities?.routes ?? []).map((route) => [
207
+ (evidence.capabilities?.routes ?? input.capabilities?.routes ?? []).map((route) => [
207
208
  routeKey(route.method, route.path),
208
209
  route,
209
210
  ]),
210
211
  )
211
- const routes = (input.evidence?.routes ?? snapshotRoutes(input.source))
212
+ const routes = evidence.routes
212
213
  .map((route): NifraManifestRoute => {
213
214
  const key = routeKey(route.method, route.path)
214
215
  const reflection = reflected.get(key)
@@ -66,7 +66,8 @@ const safeDecode = (v: string): string => {
66
66
  *
67
67
  * Hand-rolled index walk instead of `split(";")` + `trim()` chains: those allocated an array + up
68
68
  * to three substrings per pair and showed up at ~6% of a realistic (auth + cookie) request. Same
69
- * semantics, two slices per pair, no intermediate array. */
69
+ * semantics, two slices per pair, no intermediate array. Duplicate names use the first value
70
+ * deterministically; security-sensitive callers can use {@link hasDuplicateCookie} and fail closed. */
70
71
  export function parseCookies(header: string | null | undefined): Record<string, string> {
71
72
  // Null-prototype: cookie NAMES come straight from the untrusted header, so a cookie named
72
73
  // `constructor`/`__proto__`/`toString` must be an inert own key, not a shadow of a prototype
@@ -106,7 +107,11 @@ export function parseCookies(header: string | null | undefined): Record<string,
106
107
  }
107
108
  // Interned: cookie names repeat across requests, and V8 pays ~300ns to internalize a
108
109
  // fresh sliced key on a null-proto store (see internParseKey).
109
- out[internParseKey(name)] = safeDecode(value)
110
+ const key = internParseKey(name)
111
+ // Cookie headers can contain same-name cookies from different paths/domains. Do not let a
112
+ // later value silently replace the first one: session/auth callers can separately reject the
113
+ // ambiguity, while ordinary callers get deterministic first-value semantics.
114
+ if (!Object.hasOwn(out, key)) out[key] = safeDecode(value)
110
115
  }
111
116
  }
112
117
  pos = end + 1
@@ -114,6 +119,33 @@ export function parseCookies(header: string | null | undefined): Record<string,
114
119
  return out
115
120
  }
116
121
 
122
+ /** True when a raw Cookie header contains the same exact cookie name more than once. */
123
+ export function hasDuplicateCookie(header: string | null | undefined, targetName: string): boolean {
124
+ if (!header || targetName.length === 0) return false
125
+ let found = false
126
+ const len = header.length
127
+ let pos = 0
128
+ while (pos < len) {
129
+ let end = header.indexOf(";", pos)
130
+ if (end === -1) end = len
131
+ let start = pos
132
+ while (start < end && header.charCodeAt(start) === 32) start++
133
+ let stop = end
134
+ while (stop > start && header.charCodeAt(stop - 1) === 32) stop--
135
+ const eq = header.indexOf("=", start)
136
+ if (eq > start && eq < stop) {
137
+ let nameEnd = eq
138
+ while (nameEnd > start && header.charCodeAt(nameEnd - 1) === 32) nameEnd--
139
+ if (header.slice(start, nameEnd) === targetName) {
140
+ if (found) return true
141
+ found = true
142
+ }
143
+ }
144
+ pos = end + 1
145
+ }
146
+ return false
147
+ }
148
+
117
149
  /**
118
150
  * Serialize a `Set-Cookie` header value. Pure - applies **no** security defaults (the caller, e.g.
119
151
  * `c.set.cookie`, layers `HttpOnly`/`Secure`/`SameSite` on). Throws on an invalid cookie name, a
@@ -85,6 +85,8 @@ export interface ServerOptions {
85
85
  readonly wsMaxPayloadBytes?: number
86
86
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
87
87
  readonly requestTimeoutMs?: number
88
+ /** WebSocket upgrade admission/guard timeout (ms). Default 10_000; set to 0 only for a deliberate unbounded guard. */
89
+ readonly wsUpgradeTimeoutMs?: number
88
90
  /**
89
91
  * How to derive `c.clientIp` behind a reverse proxy or CDN. Omit (default) to trust only the raw
90
92
  * socket peer and never believe a forwarded header. Set `{ trustedHops: n }` when the app sits
@@ -326,6 +326,7 @@ type BunNativeRoutes = Record<string, BunNativeMethodTable>
326
326
  type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
327
327
 
328
328
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
329
+ const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
329
330
 
330
331
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
331
332
  function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
@@ -679,6 +680,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
679
680
  private readonly trustBodyFraming: boolean
680
681
  private readonly wsMaxPayloadBytes: number
681
682
  private readonly requestTimeoutMs: number
683
+ private readonly wsUpgradeTimeoutMs: number
682
684
  /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
683
685
  * and the state in which a declared `response` schema stays a compile-time contract only. */
684
686
  private responseContractRuntime: ResponseContractRuntime | undefined
@@ -782,6 +784,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
782
784
  this.maxBodyBytes = maxBodyBytes
783
785
  this.wsMaxPayloadBytes = wsMaxPayloadBytes
784
786
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
787
+ this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS
785
788
  this.clientIpTrust = options.clientIp
786
789
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
787
790
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
@@ -792,6 +795,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
792
795
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
793
796
  throw new RangeError("requestTimeoutMs must be a finite non-negative number")
794
797
  }
798
+ if (!Number.isFinite(this.wsUpgradeTimeoutMs) || this.wsUpgradeTimeoutMs < 0) {
799
+ throw new RangeError("wsUpgradeTimeoutMs must be a finite non-negative number")
800
+ }
795
801
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
796
802
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
797
803
  }
@@ -2558,83 +2564,173 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2558
2564
  * `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
2559
2565
  * `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
2560
2566
  */
2567
+ private runWebSocketRequestHooks(
2568
+ req: Request,
2569
+ platform?: Platform<EnvOf<Ctx>>,
2570
+ ): MaybePromise<Request | Response> {
2571
+ let current = req
2572
+ for (let i = 0; i < this.onRequestHooks.length; i++) {
2573
+ const hook = this.onRequestHooks[i]
2574
+ if (hook === undefined) return current
2575
+ const outcome = hook(current, platform)
2576
+ if (outcome instanceof Promise) {
2577
+ return outcome.then((first) =>
2578
+ this.continueWebSocketRequestHooks(first, i + 1, current, platform),
2579
+ )
2580
+ }
2581
+ if (outcome instanceof Request) {
2582
+ current = outcome
2583
+ continue
2584
+ }
2585
+ if (outcome !== undefined) return outcome
2586
+ }
2587
+ return current
2588
+ }
2589
+
2590
+ private async continueWebSocketRequestHooks(
2591
+ first: OnRequestResult,
2592
+ start: number,
2593
+ current: Request,
2594
+ platform?: Platform<EnvOf<Ctx>>,
2595
+ ): Promise<Request | Response> {
2596
+ let next = current
2597
+ let outcome = first
2598
+ for (let i = start; ; i++) {
2599
+ if (outcome instanceof Request) next = outcome
2600
+ else if (outcome !== undefined) return outcome
2601
+ if (i >= this.onRequestHooks.length) return next
2602
+ const hook = this.onRequestHooks[i]
2603
+ if (hook === undefined) return next
2604
+ outcome = await hook(next, platform)
2605
+ }
2606
+ }
2607
+
2561
2608
  resolveWebSocketUpgrade(
2562
2609
  req: Request,
2563
2610
  platform?: Platform<EnvOf<Ctx>>,
2564
2611
  ): MaybePromise<WebSocketUpgradeOutcome> {
2565
2612
  if (this.wsRouteCount === 0) return WS_PASS
2566
2613
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
2567
- const url = urlPartsOf(req.url)
2568
- const match = this.wsRouter.find("GET", url.pathname)
2569
- if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
2570
- // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2571
- // already did and made every plain dynamic route pay for unrelated static path bytes.
2572
- const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2573
- if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
2574
- const handler = match.payload.handler
2575
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2576
- const pubsub = this.topics as TopicRegistry
2577
- const attach = (this.wsRuntime as WsRuntime).attach
2578
- // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2579
- // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2580
- // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
2581
- const origin = req.headers.get("origin")
2582
- if (handler.allowedOrigins !== undefined) {
2583
- const allowed =
2584
- typeof handler.allowedOrigins === "function"
2585
- ? handler.allowedOrigins(origin)
2586
- : origin !== null && handler.allowedOrigins.includes(origin)
2587
- if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2588
- } else if (origin !== null && !wsSameOrigin(origin, req)) {
2589
- // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2590
- // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2591
- // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2592
- // specific cross-origin clients (or `() => true` for a genuinely public socket).
2593
- return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2594
- }
2595
- if (handler.upgrade === undefined) {
2596
- return {
2597
- kind: "upgrade",
2598
- handler,
2599
- data: undefined,
2600
- pubsub,
2601
- attach,
2602
- maxPayloadBytes: this.wsMaxPayloadBytes,
2614
+
2615
+ const timeoutMs =
2616
+ this.wsUpgradeTimeoutMs === 0
2617
+ ? 0
2618
+ : this.requestTimeoutMs > 0
2619
+ ? Math.min(this.wsUpgradeTimeoutMs, this.requestTimeoutMs)
2620
+ : this.wsUpgradeTimeoutMs
2621
+ const controller = timeoutMs > 0 ? new AbortController() : undefined
2622
+ const signal = controller?.signal ?? getNeverAbortSignal()
2623
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined
2624
+ const timeoutResponse = jsonError(503, "request_timeout")
2625
+ const onTimeout = (): WebSocketUpgradeOutcome => ({
2626
+ kind: "reject",
2627
+ response: timeoutResponse,
2628
+ })
2629
+ const bounded = <T>(work: Promise<T>, onDone: () => T): Promise<T> =>
2630
+ controller === undefined
2631
+ ? work
2632
+ : this.withTimeout(work, controller, onDone, Math.max(0, deadline! - Date.now()))
2633
+
2634
+ const resolveMatched = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
2635
+ const url = urlPartsOf(request.url)
2636
+ const match = this.wsRouter.find("GET", url.pathname)
2637
+ if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
2638
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2639
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
2640
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2641
+ if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
2642
+ const handler = match.payload.handler
2643
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2644
+ const pubsub = this.topics as TopicRegistry
2645
+ const attach = (this.wsRuntime as WsRuntime).attach
2646
+ // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2647
+ // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2648
+ // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
2649
+ const origin = request.headers.get("origin")
2650
+ if (handler.allowedOrigins !== undefined) {
2651
+ const allowed =
2652
+ typeof handler.allowedOrigins === "function"
2653
+ ? handler.allowedOrigins(origin)
2654
+ : origin !== null && handler.allowedOrigins.includes(origin)
2655
+ if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2656
+ } else if (origin !== null && !wsSameOrigin(origin, request)) {
2657
+ // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2658
+ // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2659
+ // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2660
+ // specific cross-origin clients (or `() => true` for a genuinely public socket).
2661
+ return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2662
+ }
2663
+ if (handler.upgrade === undefined) {
2664
+ return {
2665
+ kind: "upgrade",
2666
+ handler,
2667
+ data: undefined,
2668
+ pubsub,
2669
+ attach,
2670
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2671
+ }
2672
+ }
2673
+ const budget =
2674
+ controller === undefined
2675
+ ? createUnboundedRequestBudget(signal)
2676
+ : createRequestBudget({ deadline: deadline!, signal })
2677
+ const ctx = new RequestContext(
2678
+ request,
2679
+ params,
2680
+ url.search,
2681
+ signal,
2682
+ budget,
2683
+ platform,
2684
+ this.maxBodyBytes,
2685
+ this.protoPoisoning,
2686
+ )
2687
+ const settle = (value: unknown): WebSocketUpgradeOutcome =>
2688
+ value instanceof Response
2689
+ ? { kind: "reject", response: value }
2690
+ : {
2691
+ kind: "upgrade",
2692
+ handler,
2693
+ data: value,
2694
+ pubsub,
2695
+ attach,
2696
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2697
+ }
2698
+ try {
2699
+ const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
2700
+ if (!(result instanceof Promise)) return settle(result)
2701
+ const pending = result.then(settle, () => ({
2702
+ kind: "reject" as const,
2703
+ response: jsonError(500, "internal_error"),
2704
+ }))
2705
+ return bounded(pending, onTimeout)
2706
+ } catch {
2707
+ return { kind: "reject", response: jsonError(500, "internal_error") }
2603
2708
  }
2604
2709
  }
2605
- const upgradeSignal = getNeverAbortSignal()
2606
- const ctx = new RequestContext(
2607
- req,
2608
- params,
2609
- url.search,
2610
- upgradeSignal,
2611
- createUnboundedRequestBudget(upgradeSignal),
2612
- platform,
2613
- this.maxBodyBytes,
2614
- this.protoPoisoning,
2615
- )
2616
- const settle = (value: unknown): WebSocketUpgradeOutcome =>
2617
- value instanceof Response
2618
- ? { kind: "reject", response: value }
2619
- : {
2620
- kind: "upgrade",
2621
- handler,
2622
- data: value,
2623
- pubsub,
2624
- attach,
2625
- maxPayloadBytes: this.wsMaxPayloadBytes,
2626
- }
2710
+
2711
+ if (this.onRequestHooks.length === 0) return resolveMatched(req)
2712
+ let hooked: MaybePromise<Request | Response>
2627
2713
  try {
2628
- const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
2629
- return result instanceof Promise
2630
- ? result.then(settle, () => ({
2631
- kind: "reject" as const,
2632
- response: jsonError(500, "internal_error"),
2633
- }))
2634
- : settle(result)
2714
+ hooked = this.runWebSocketRequestHooks(req, platform)
2635
2715
  } catch {
2636
2716
  return { kind: "reject", response: jsonError(500, "internal_error") }
2637
2717
  }
2718
+ if (!(hooked instanceof Promise)) {
2719
+ return hooked instanceof Response
2720
+ ? { kind: "reject", response: hooked }
2721
+ : resolveMatched(hooked)
2722
+ }
2723
+ const pending = hooked.then(
2724
+ (result) =>
2725
+ result instanceof Response
2726
+ ? { kind: "reject" as const, response: result }
2727
+ : resolveMatched(result),
2728
+ () => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
2729
+ )
2730
+ return bounded(
2731
+ Promise.resolve(pending).then((result) => result),
2732
+ onTimeout,
2733
+ )
2638
2734
  }
2639
2735
 
2640
2736
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -2652,7 +2748,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2652
2748
  ? undefined
2653
2749
  : jsonError(426, "upgrade_required")
2654
2750
  }
2655
- const outcome = this.resolveWebSocketUpgrade(req)
2751
+ const outcome = this.resolveWebSocketUpgrade(
2752
+ req,
2753
+ bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>,
2754
+ )
2656
2755
  return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
2657
2756
  }
2658
2757
 
package/src/server.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  type CookieOptions,
34
34
  type CookieSecret,
35
35
  cookieNamePrefix,
36
+ hasDuplicateCookie,
36
37
  parseCookies,
37
38
  serializeCookie,
38
39
  signValue,