@nifrajs/core 3.2.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.
- package/dist/diff.d.ts +5 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +11 -1
- package/dist/diff.js.map +1 -1
- package/dist/evidence.d.ts +12 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +52 -1
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +8 -1
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +11 -9
- package/dist/manifest.js.map +1 -1
- package/dist/server/cookies.d.ts +4 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +40 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/server-types.d.ts +2 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +3 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +139 -59
- package/dist/server/server.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +24 -2
- package/src/evidence.ts +66 -1
- package/src/idempotency.ts +8 -1
- package/src/index.ts +2 -1
- package/src/manifest.ts +11 -10
- package/src/server/cookies.ts +34 -2
- package/src/server/server-types.ts +2 -0
- package/src/server/server.ts +165 -66
- package/src/server.ts +1 -0
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
341
|
+
reflected.map((route) =>
|
|
277
342
|
evidenceRouteOf(route, locations?.get(keyOf(route.method, route.path))),
|
|
278
343
|
),
|
|
279
344
|
)
|
package/src/idempotency.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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 =
|
|
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)
|
package/src/server/cookies.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
const
|
|
2575
|
-
|
|
2576
|
-
const
|
|
2577
|
-
const
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|