@orkestrel/middleware 0.0.1

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.
@@ -0,0 +1,1399 @@
1
+ import { isBoolean, isFiniteNumber, isFunction, isRecord, isString } from "@orkestrel/contract";
2
+ import { HTTPError, clearCookie, clientRateKey, computeBodyETag, isCompressibleType, isHTTPError, isValidRequestId, matchesETag, mergeVary, negotiateEncoding, readSignedCookie, resolveOrigin, resolveSecure, resolveSecurityHeader, signToken, verifyToken, writeSignedCookie } from "@orkestrel/server";
3
+ import { linkSignal } from "@orkestrel/abort";
4
+ import { createBudget } from "@orkestrel/budget";
5
+ import { createTimeout } from "@orkestrel/timeout";
6
+ //#region src/core/constants.ts
7
+ /** Default minimum buffered body size (bytes) `createCompression` will compress. */
8
+ var DEFAULT_COMPRESSION_THRESHOLD = 1024;
9
+ /**
10
+ * Default content-codings `createCompression` offers, in preference order —
11
+ * intersected at construction with what the runtime's `CompressionStream`
12
+ * actually supports.
13
+ *
14
+ * @remarks
15
+ * The shipped `@orkestrel/server` peer's {@link Encoding} union is
16
+ * `'gzip' | 'deflate' | 'identity'` — it does not include `'br'` (see the
17
+ * deviation recorded against this constant in the build report). This
18
+ * default therefore offers every non-`identity` coding the peer's type
19
+ * admits; a node-face brotli variant, if one ships, extends this list there.
20
+ */
21
+ var DEFAULT_COMPRESSION_ENCODINGS = Object.freeze(["gzip", "deflate"]);
22
+ /** Default `X-Frame-Options` value `createSecurity` sets. */
23
+ var DEFAULT_FRAME_OPTIONS = "DENY";
24
+ /**
25
+ * Default `Content-Security-Policy` value `createSecurity` sets — a custom
26
+ * `csp` option REPLACES this wholesale, never merges.
27
+ */
28
+ var DEFAULT_CSP = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'";
29
+ /** Default `Referrer-Policy` value `createSecurity` sets. */
30
+ var DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
31
+ /** Default `Permissions-Policy` value `createSecurity` sets. */
32
+ var DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()";
33
+ /** Default `Cross-Origin-Opener-Policy` value `createSecurity` sets. */
34
+ var DEFAULT_COOP = "same-origin";
35
+ /** Default `Cross-Origin-Resource-Policy` value `createSecurity` sets. */
36
+ var DEFAULT_CORP = "same-origin";
37
+ /** Default `Origin-Agent-Cluster` value `createSecurity` sets. */
38
+ var DEFAULT_CLUSTER = "?1";
39
+ /** Value `createSecurity` sets for `Cross-Origin-Embedder-Policy` when `coep: true`. */
40
+ var DEFAULT_COEP = "require-corp";
41
+ /** Value `createSecurity` sets for `Strict-Transport-Security` when `hsts: true`. */
42
+ var DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
43
+ /** Default header `createSecurity` mints/echoes a request identifier into. */
44
+ var DEFAULT_IDENTIFIER_HEADER = "x-request-id";
45
+ /** Default methods `createCors` advertises on a preflight response. */
46
+ var DEFAULT_CORS_METHODS = Object.freeze([
47
+ "GET",
48
+ "POST",
49
+ "PUT",
50
+ "PATCH",
51
+ "DELETE",
52
+ "OPTIONS"
53
+ ]);
54
+ /** Default headers `createCors` advertises on a preflight response. */
55
+ var DEFAULT_CORS_HEADERS = Object.freeze(["Content-Type", "Authorization"]);
56
+ /** Default response status `createDeadline` returns when its deadline fires first. */
57
+ var DEFAULT_DEADLINE_STATUS = 503;
58
+ /** Default header `createBearer` reads the token from. */
59
+ var DEFAULT_BEARER_HEADER = "authorization";
60
+ /** Default scheme prefix `createBearer` strips before verification. */
61
+ var DEFAULT_BEARER_SCHEME = "Bearer";
62
+ /** Default maximum number of distinct rate-limit keys `createLimiter` tracks before LRU eviction. */
63
+ var DEFAULT_LIMITER_CAPACITY = 1e4;
64
+ /** Default maximum number of distinct session ids `createMemorySessionStore` tracks before LRU (by last write) eviction. */
65
+ var DEFAULT_SESSION_CAPACITY = 1e4;
66
+ /** Default 429 body message `createLimiter` sends when a key is over budget. */
67
+ var DEFAULT_LIMITER_MESSAGE = "rate limit exceeded";
68
+ /** Default cookie name `createCookieTransport` writes the signed session id under. */
69
+ var DEFAULT_SESSION_COOKIE = "session";
70
+ /** Default header `createHeaderTransport` carries the session id in. */
71
+ var DEFAULT_SESSION_HEADER = "session-id";
72
+ /** Default signed-cookie name `createCSRF` writes the CSRF token under. */
73
+ var DEFAULT_CSRF_COOKIE = "csrf";
74
+ /** Default header `createCSRF` reads a mutating request's submitted token from. */
75
+ var DEFAULT_CSRF_HEADER = "x-csrf-token";
76
+ /** Default body field `createCSRF` falls back to reading a mutating request's submitted token from. */
77
+ var DEFAULT_CSRF_FIELD = "_csrf";
78
+ /** Default methods `createCSRF` treats as safe (mint instead of verify). */
79
+ var DEFAULT_CSRF_SAFE_METHODS = Object.freeze([
80
+ "GET",
81
+ "HEAD",
82
+ "OPTIONS"
83
+ ]);
84
+ //#endregion
85
+ //#region src/core/helpers.ts
86
+ /**
87
+ * Derive `createLimiter`'s default rate-limit bucket key from a request's
88
+ * resolved identity facts.
89
+ *
90
+ * @remarks
91
+ * Prefers a verified bearer token ({@link BearerState.token}) as
92
+ * `token:<value>`; else a resolved client IP ({@link ClientState.client.ip},
93
+ * set when `createForwarded` is mounted) or the raw socket peer
94
+ * ({@link ConnectionState.connection.ip}) collapsed via `clientRateKey`
95
+ * (IPv6 to its `/64` network) as `ip:<key>`; else the literal `ip:unknown`.
96
+ * Never reads `X-Forwarded-For` itself — that trust decision belongs solely
97
+ * to `createForwarded`.
98
+ *
99
+ * @param state - The slices `createLimiter`'s default key may read
100
+ * @returns The bucket key for the request
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * resolveKey({ token: 'abc' }) // 'token:abc'
105
+ * resolveKey({ client: { ip: '2001:db8::1' } }) // 'ip:2001:db8:0:0::/64'
106
+ * ```
107
+ */
108
+ function resolveKey(state) {
109
+ if (state.token !== void 0) return `token:${state.token}`;
110
+ const ip = state.client?.ip ?? state.connection?.ip;
111
+ if (ip !== void 0) return `ip:${clientRateKey(ip)}`;
112
+ return "ip:unknown";
113
+ }
114
+ /**
115
+ * Build the `Retry-After` header value — whole seconds until a window reset,
116
+ * floored at a minimum of `1` (ruling I).
117
+ *
118
+ * @param resetAt - The window reset instant (same clock unit as `now`)
119
+ * @param now - The current instant
120
+ * @returns The `Retry-After` value in whole seconds, minimum `1`
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * buildRetryAfter(1_500, 1_000) // '1'
125
+ * ```
126
+ */
127
+ function buildRetryAfter(resetAt, now) {
128
+ const seconds = Math.ceil((resetAt - now) / 1e3);
129
+ return String(Math.max(1, seconds));
130
+ }
131
+ /**
132
+ * Build the draft `RateLimit` structured header field (ruling I) — emitted
133
+ * only when `createLimiter`'s `policy` option is `true`.
134
+ *
135
+ * @param remaining - The requests still admitted this window
136
+ * @param resetAt - The window reset instant (same clock unit as `now`)
137
+ * @param now - The current instant
138
+ * @returns The `RateLimit` header value
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * buildRateLimitField(4, 1_500, 1_000) // '"default";r=4;t=1'
143
+ * ```
144
+ */
145
+ function buildRateLimitField(remaining, resetAt, now) {
146
+ return `"default";r=${remaining};t=${Math.max(1, Math.ceil((resetAt - now) / 1e3))}`;
147
+ }
148
+ /**
149
+ * Build the draft `RateLimit-Policy` structured header field (ruling I) —
150
+ * emitted only when `createLimiter`'s `policy` option is `true`.
151
+ *
152
+ * @param max - The window's admitted request count
153
+ * @param window - The window length in milliseconds
154
+ * @returns The `RateLimit-Policy` header value
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * buildRateLimitPolicyField(10, 60_000) // '"default";q=10;w=60'
159
+ * ```
160
+ */
161
+ function buildRateLimitPolicyField(max, window) {
162
+ return `"default";q=${max};w=${Math.ceil(window / 1e3)}`;
163
+ }
164
+ /**
165
+ * Whether a candidate address is a bare (non-CIDR) trusted-hop match — an
166
+ * exact string match, or a simple prefix-CIDR match for IPv4 (`/8`–`/32`).
167
+ * An IPv6 entry matches by exact string only — there is no IPv6 CIDR
168
+ * support.
169
+ *
170
+ * @remarks
171
+ * Supports exact addresses and dotted-decimal IPv4 CIDR (`10.0.0.0/8`
172
+ * through `/32`) verbatim. An IPv6 entry is matched by exact string only
173
+ * (no CIDR expansion) — documented as the supported subset; `createForwarded`
174
+ * never claims full CIDR generality beyond IPv4.
175
+ *
176
+ * @remarks
177
+ * An IPv6 `trusted` roster entry is compared as an EXACT string — it must be
178
+ * supplied in canonical form (no zero-compression normalization, no case
179
+ * folding) by the caller; this function performs no IPv6 normalization of
180
+ * its own.
181
+ *
182
+ * @param address - The candidate hop address
183
+ * @param entry - One `trusted` roster entry — an exact address or an IPv4 CIDR
184
+ * @returns `true` when `address` is covered by `entry`
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * matchesTrustedEntry('10.1.2.3', '10.0.0.0/8') // true
189
+ * matchesTrustedEntry('192.168.1.1', '10.0.0.0/8') // false
190
+ * ```
191
+ */
192
+ function matchesTrustedEntry(address, entry) {
193
+ const slash = entry.indexOf("/");
194
+ if (slash === -1) return address === entry;
195
+ const network = entry.slice(0, slash);
196
+ const bits = Number(entry.slice(slash + 1));
197
+ const networkParts = network.split(".");
198
+ const addressParts = address.split(".");
199
+ if (networkParts.length !== 4 || addressParts.length !== 4) return false;
200
+ if (!Number.isInteger(bits) || bits < 8 || bits > 32) return false;
201
+ const networkOctets = networkParts.map(Number);
202
+ const addressOctets = addressParts.map(Number);
203
+ if (networkOctets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
204
+ if (addressOctets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
205
+ const networkInt = (networkOctets[0] ?? 0) << 24 | (networkOctets[1] ?? 0) << 16 | (networkOctets[2] ?? 0) << 8 | (networkOctets[3] ?? 0);
206
+ const addressInt = (addressOctets[0] ?? 0) << 24 | (addressOctets[1] ?? 0) << 16 | (addressOctets[2] ?? 0) << 8 | (addressOctets[3] ?? 0);
207
+ const mask = bits === 32 ? -1 : ~((1 << 32 - bits) - 1);
208
+ return (networkInt & mask) === (addressInt & mask);
209
+ }
210
+ /**
211
+ * Walk `X-Forwarded-For` right-to-left and resolve the first UNTRUSTED hop
212
+ * address — `createForwarded`'s core algorithm.
213
+ *
214
+ * @remarks
215
+ * Parses `X-Forwarded-For` only. With `proxies` set, trusts exactly that
216
+ * many hops counted from the right (the closest to this server) and returns
217
+ * the next one left of them; with `trusted` set, trusts every CONSECUTIVE
218
+ * hop from the right that matches one of the roster
219
+ * ({@link matchesTrustedEntry}) and returns the first hop that does not. If
220
+ * the rightmost hop (the immediate sender) does not match the roster, the
221
+ * whole header is untrustworthy and this returns `undefined` rather than
222
+ * returning any client-supplied hop value. When every hop is trusted, or the
223
+ * header is absent/empty, returns `undefined` (the caller falls back to the
224
+ * socket peer).
225
+ *
226
+ * @param header - The raw `X-Forwarded-For` header value (comma-separated hops), if present
227
+ * @param trust - Either a trusted hop COUNT or a `trusted` CIDR/exact roster
228
+ * @returns The first untrusted hop address, or `undefined` when none qualifies
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * resolveForwardedFor('203.0.113.7, 10.0.0.1', { proxies: 1 }) // '203.0.113.7'
233
+ * ```
234
+ */
235
+ function resolveForwardedFor(header, trust) {
236
+ if (header === void 0) return void 0;
237
+ const hops = header.split(",").map((hop) => hop.trim()).filter((hop) => hop.length > 0);
238
+ if (hops.length === 0) return void 0;
239
+ if ("proxies" in trust) {
240
+ const index = hops.length - trust.proxies - 1;
241
+ return index >= 0 ? hops[index] : void 0;
242
+ }
243
+ const rightmost = hops[hops.length - 1];
244
+ if (rightmost === void 0) return void 0;
245
+ if (!trust.trusted.some((entry) => matchesTrustedEntry(rightmost, entry))) return void 0;
246
+ for (let index = hops.length - 2; index >= 0; index -= 1) {
247
+ const hop = hops[index];
248
+ if (hop === void 0) return void 0;
249
+ if (!trust.trusted.some((entry) => matchesTrustedEntry(hop, entry))) return hop;
250
+ }
251
+ }
252
+ /**
253
+ * Feature-detect which of `candidates` the runtime's `CompressionStream`
254
+ * actually supports — `createCompression`'s construction-time intersection
255
+ * (ruling J).
256
+ *
257
+ * @remarks
258
+ * Probes each candidate with `new CompressionStream(candidate)` inside a
259
+ * `try`/`catch`; a coding the runtime rejects is dropped silently. `identity`
260
+ * is never probed (it has no `CompressionStream` coding and is always
261
+ * implicitly acceptable to negotiation).
262
+ *
263
+ * @param candidates - The codings to probe, in preference order
264
+ * @returns The subset of `candidates` the runtime's `CompressionStream` supports, in order
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * detectEncodings(['gzip', 'deflate']) // ['gzip', 'deflate'] on a runtime with both
269
+ * ```
270
+ */
271
+ function detectEncodings(candidates) {
272
+ const supported = [];
273
+ for (const candidate of candidates) {
274
+ if (candidate === "identity") continue;
275
+ try {
276
+ if (new CompressionStream(candidate).readable) supported.push(candidate);
277
+ } catch {}
278
+ }
279
+ return supported;
280
+ }
281
+ /**
282
+ * Whether a response is eligible for the compression/ETag buffering pipeline
283
+ * (ruling J) — the shared cheap-skip predicate both batteries apply before
284
+ * ever touching `response.arrayBuffer()`.
285
+ *
286
+ * @remarks
287
+ * Skips a `HEAD` request, a `204`/`304` or otherwise bodyless response, an
288
+ * `event-stream` response (SSE — buffering would hang the connection), and a
289
+ * response that already carries the header the caller is about to set
290
+ * (`skipHeader`, e.g. `Content-Encoding` for compression, `ETag` for the
291
+ * ETag battery).
292
+ *
293
+ * @param method - The request's HTTP method
294
+ * @param response - The candidate response
295
+ * @param skipHeader - The response header whose presence means "already handled"
296
+ * @returns `true` when the response should be left untouched
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * isBufferingIneligible('GET', new Response(null, { status: 204 }), 'content-encoding') // true
301
+ * ```
302
+ */
303
+ function isBufferingIneligible(method, response, skipHeader) {
304
+ if (method === "HEAD") return true;
305
+ if (response.status === 204 || response.status === 304) return true;
306
+ if (response.body === null) return true;
307
+ const contentType = response.headers.get("content-type");
308
+ if (contentType !== null && contentType.toLowerCase().startsWith("text/event-stream")) return true;
309
+ if (response.headers.has(skipHeader)) return true;
310
+ return false;
311
+ }
312
+ /**
313
+ * Whether a negotiated `Accept-Encoding` outcome is worth acting on —
314
+ * `createCompression`'s negotiation-eligibility half of ruling J's skip list.
315
+ *
316
+ * @param encoding - The negotiated coding, or `undefined` when negotiation failed
317
+ * @returns `true` when `encoding` names an actionable, non-`identity` coding
318
+ *
319
+ * @example
320
+ * ```ts
321
+ * isCompressionNegotiated('gzip') // true
322
+ * isCompressionNegotiated(undefined) // false
323
+ * ```
324
+ */
325
+ function isCompressionNegotiated(encoding) {
326
+ return encoding !== void 0 && encoding !== "identity";
327
+ }
328
+ /**
329
+ * Resolve an opt-in, value-bearing security header — `string | boolean`
330
+ * (default OFF, `true` uses the secure default), the shape `createSecurity`'s
331
+ * `coep`/`hsts` options use, distinct from the plain value-or-`false` shape
332
+ * `resolveSecurityHeader` (the peer substrate) handles.
333
+ *
334
+ * @param value - The option value — a `string` override, `true` for the secure default, or `false`/`undefined` to omit
335
+ * @param fallback - The secure-default value used when `value` is `true`
336
+ * @returns The header value to set, or `undefined` to omit the header
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * resolveOptInHeader(true, 'require-corp') // 'require-corp'
341
+ * resolveOptInHeader(undefined, 'require-corp') // undefined — omitted
342
+ * ```
343
+ */
344
+ function resolveOptInHeader(value, fallback) {
345
+ if (value === true) return fallback;
346
+ if (value === false || value === void 0) return void 0;
347
+ return value;
348
+ }
349
+ /**
350
+ * Rebuild a `Response` around a replacement body while preserving its
351
+ * status/statusText — the buffered-response reconstruction shared by the
352
+ * compression and ETag batteries after they have consumed
353
+ * `response.arrayBuffer()`.
354
+ *
355
+ * @param body - The replacement body (already-buffered bytes, or `null`)
356
+ * @param response - The original response whose status/statusText/headers are preserved
357
+ * @param headers - The headers to apply; defaults to a fresh copy of `response.headers`
358
+ * @returns A new `Response` carrying `body` with `response`'s status/statusText
359
+ *
360
+ * @example
361
+ * ```ts
362
+ * rebuildResponse(bytes, response) // same status/statusText, response's headers copied
363
+ * rebuildResponse(bytes, response, headers) // explicit replacement headers
364
+ * ```
365
+ */
366
+ function rebuildResponse(body, response, headers) {
367
+ return new Response(body, {
368
+ status: response.status,
369
+ statusText: response.statusText,
370
+ headers: headers ?? new Headers(response.headers)
371
+ });
372
+ }
373
+ /**
374
+ * The shared negotiate → skip → threshold → compress → header-set skeleton
375
+ * both faces' `createCompression` batteries compose — response-body
376
+ * compression over a caller-supplied set of feature-detected codings.
377
+ *
378
+ * @remarks
379
+ * Decision order: {@link isBufferingIneligible} → `options.filter` → stamp
380
+ * `Vary: Accept-Encoding` (every negotiation-eligible response carries it,
381
+ * even when a later skip declines to compress) → `negotiateEncoding` over
382
+ * `options.encodings` → {@link isCompressionNegotiated} → `isCompressibleType`
383
+ * on `Content-Type` → a fast skip when the response already carries a
384
+ * numeric `Content-Length` BELOW `options.threshold` (avoids buffering a
385
+ * body known too small to be worth compressing) → buffer via
386
+ * `response.arrayBuffer()` → a threshold passthrough when the buffered size
387
+ * is still below `options.threshold` → `options.compress` → set
388
+ * `Content-Encoding` and a fresh `Content-Length` via {@link rebuildResponse}.
389
+ * Returns `response` unchanged (aside from the `Vary` stamp) on any skip.
390
+ *
391
+ * @param request - The inbound `Request` (read for `Accept-Encoding`)
392
+ * @param context - The `MiddlewareContext` (read for `context.method`)
393
+ * @param response - The downstream `Response` to consider compressing
394
+ * @param options - The threshold, optional filter, offered encodings, and the runtime's `compress` primitive
395
+ * @returns The original `response` when skipped, or a new compressed `Response`
396
+ *
397
+ * @example
398
+ * ```ts
399
+ * await compressResponse(request, context, response, {
400
+ * threshold: 1024,
401
+ * encodings: ['gzip'],
402
+ * compress: async (bytes, encoding) => gzip(bytes),
403
+ * })
404
+ * ```
405
+ */
406
+ async function compressResponse(request, context, response, options) {
407
+ if (isBufferingIneligible(context.method, response, "content-encoding")) return response;
408
+ if (options.filter !== void 0 && !options.filter(request, response)) return response;
409
+ response.headers.set("vary", mergeVary(response.headers.get("vary") ?? void 0, "Accept-Encoding"));
410
+ const acceptEncoding = request.headers.get("accept-encoding");
411
+ if (acceptEncoding === null) return response;
412
+ const negotiated = negotiateEncoding(acceptEncoding, options.encodings);
413
+ if (!isCompressionNegotiated(negotiated)) return response;
414
+ const contentType = response.headers.get("content-type");
415
+ if (contentType === null || !isCompressibleType(contentType)) return response;
416
+ const declaredLength = response.headers.get("content-length");
417
+ if (declaredLength !== null) {
418
+ const declared = Number(declaredLength);
419
+ if (Number.isFinite(declared) && declared < options.threshold) return response;
420
+ }
421
+ const buffer = await response.arrayBuffer();
422
+ if (buffer.byteLength < options.threshold) return rebuildResponse(buffer, response);
423
+ const compressed = await options.compress(new Uint8Array(buffer), negotiated);
424
+ const headers = new Headers(response.headers);
425
+ headers.set("content-encoding", negotiated);
426
+ headers.set("content-length", String(compressed.byteLength));
427
+ return rebuildResponse(compressed, response, headers);
428
+ }
429
+ /**
430
+ * Copy every entry of one session's `data` into another — the regenerate
431
+ * data-carry `createSession`'s `control.regenerate()` applies (ruling D).
432
+ *
433
+ * @param from - The source session whose `data` is copied
434
+ * @param to - The destination session `data` is copied into
435
+ *
436
+ * @example
437
+ * ```ts
438
+ * transferSessionData(oldSession, newSession)
439
+ * ```
440
+ */
441
+ function transferSessionData(from, to) {
442
+ for (const [key, value] of from.data) to.data.set(key, value);
443
+ }
444
+ /**
445
+ * Determine whether a value implements {@link SessionInterface} — a total
446
+ * structural guard (§14): an `id` string plus a `data` `Map`.
447
+ *
448
+ * @param value - The candidate value
449
+ * @returns `true` when `value` is shaped like a {@link SessionInterface}
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * isSession({ id: 'a', data: new Map() }) // true
454
+ * ```
455
+ */
456
+ function isSession(value) {
457
+ if (!isRecord(value)) return false;
458
+ return isString(value.id) && value.data instanceof Map;
459
+ }
460
+ /**
461
+ * Determine whether a value implements {@link SessionControlInterface} — a
462
+ * total structural guard (§14): callable `regenerate` and `destroy`.
463
+ *
464
+ * @param value - The candidate value
465
+ * @returns `true` when `value` is shaped like a {@link SessionControlInterface}
466
+ *
467
+ * @example
468
+ * ```ts
469
+ * isSessionControl({ regenerate() {}, destroy() {} }) // true
470
+ * ```
471
+ */
472
+ function isSessionControl(value) {
473
+ if (!isRecord(value)) return false;
474
+ return typeof value.regenerate === "function" && typeof value.destroy === "function";
475
+ }
476
+ /**
477
+ * Determine whether a value is one staged {@link MultipartFile} record — a
478
+ * total structural guard (§14) checking every required field's shape.
479
+ *
480
+ * @param value - The candidate value
481
+ * @returns `true` when `value` is shaped like a {@link MultipartFile}
482
+ */
483
+ function isMultipartFile(value) {
484
+ if (!isRecord(value)) return false;
485
+ return isString(value.field) && isString(value.name) && typeof value.size === "number" && isString(value.mime) && typeof value.validated === "boolean" && isString(value.status) && isString(value.path);
486
+ }
487
+ /**
488
+ * Determine whether a value implements {@link MultipartBody} — a total
489
+ * structural guard (§14): `files` keyed by field name to arrays of
490
+ * {@link MultipartFile}, and a `fields` string record.
491
+ *
492
+ * @param value - The candidate value
493
+ * @returns `true` when `value` is shaped like a {@link MultipartBody}
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * isMultipartBody({ files: {}, fields: { name: 'a' } }) // true
498
+ * ```
499
+ */
500
+ function isMultipartBody(value) {
501
+ if (!isRecord(value)) return false;
502
+ if (!isRecord(value.files) || !isRecord(value.fields)) return false;
503
+ for (const entries of Object.values(value.files)) {
504
+ if (!Array.isArray(entries)) return false;
505
+ for (const entry of entries) if (!isMultipartFile(entry)) return false;
506
+ }
507
+ for (const fieldValue of Object.values(value.fields)) if (!isString(fieldValue)) return false;
508
+ return true;
509
+ }
510
+ /**
511
+ * Determine whether a request is a CORS PREFLIGHT — an `OPTIONS` request
512
+ * carrying an `Access-Control-Request-Method` header.
513
+ *
514
+ * @param method - The request's HTTP method
515
+ * @param headers - The request's `Headers`
516
+ * @returns `true` when the request is a CORS preflight `createCors` must answer
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * isPreflight('OPTIONS', new Headers({ 'access-control-request-method': 'POST' })) // true
521
+ * ```
522
+ */
523
+ function isPreflight(method, headers) {
524
+ return method === "OPTIONS" && headers.has("access-control-request-method");
525
+ }
526
+ /**
527
+ * A parsed client-info fact for {@link ClientInfo} — a leaf shaping helper
528
+ * `createForwarded` uses to build its stashed slice.
529
+ *
530
+ * @param ip - The resolved client IP, if any
531
+ * @returns The {@link ClientInfo} slice value
532
+ *
533
+ * @example
534
+ * ```ts
535
+ * buildClientInfo('203.0.113.7') // { ip: '203.0.113.7' }
536
+ * ```
537
+ */
538
+ function buildClientInfo(ip) {
539
+ return { ip };
540
+ }
541
+ /**
542
+ * Constant-time string equality — `createCSRF`'s double-submit token
543
+ * comparison, avoiding a timing oracle on the submitted-vs-cookie match.
544
+ *
545
+ * @remarks
546
+ * Length-guarded XOR-accumulate over char codes: a length mismatch short
547
+ * circuits (safe — the lengths of two independently-generated tokens are
548
+ * not a useful timing signal), but once lengths match every character is
549
+ * compared with no early return, so a per-character mismatch never affects
550
+ * how long the comparison takes.
551
+ *
552
+ * @param a - The first string
553
+ * @param b - The second string
554
+ * @returns `true` when `a` and `b` are exactly equal
555
+ *
556
+ * @example
557
+ * ```ts
558
+ * equalsConstantTime('abc', 'abc') // true
559
+ * equalsConstantTime('abc', 'abd') // false
560
+ * ```
561
+ */
562
+ function equalsConstantTime(a, b) {
563
+ if (a.length !== b.length) return false;
564
+ let diff = 0;
565
+ for (let index = 0; index < a.length; index += 1) diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
566
+ return diff === 0;
567
+ }
568
+ //#endregion
569
+ //#region src/core/Session.ts
570
+ /**
571
+ * A server-managed session's default entity — the `create` option's default
572
+ * value factory for `createSession` (ruling G: `Session` ships WITHOUT a
573
+ * `createSession` factory of its own, since that name belongs to the
574
+ * battery).
575
+ *
576
+ * @remarks
577
+ * `data` is a live, mutable `Map` a handler reads/writes directly;
578
+ * `createSession` persists it to the configured store on the way out.
579
+ *
580
+ * @example
581
+ * ```ts
582
+ * const session = new Session('abc123')
583
+ * session.data.set('userId', 'u_1')
584
+ * ```
585
+ */
586
+ var Session = class {
587
+ id;
588
+ data;
589
+ constructor(id) {
590
+ this.id = id;
591
+ this.data = /* @__PURE__ */ new Map();
592
+ }
593
+ };
594
+ //#endregion
595
+ //#region src/core/MemorySessionStore.ts
596
+ /**
597
+ * The default in-process {@link SessionStoreInterface} — a `Map`-backed store
598
+ * enforcing both an idle timeout and an absolute lifetime, with lazy
599
+ * (read-time) eviction, a bounded capacity, and no background timers.
600
+ *
601
+ * @typeParam S - The session data payload type
602
+ *
603
+ * @remarks
604
+ * `get` evicts a session whose idle time (`now - lastSeen >= ttl`) or
605
+ * absolute lifetime (`now - createdAt >= lifetime`) has elapsed — the
606
+ * lifetime check fires EVEN IF the session was continuously touched, since
607
+ * `createdAt` is stamped once at the first `set` and preserved across every
608
+ * later re-`set` of the same id. A live read touches `lastSeen`. `delete` of
609
+ * an absent id is a no-op.
610
+ *
611
+ * Capacity is enforced as least-recently-used **by last write**: `set`
612
+ * refreshes an id's recency (deleting then re-inserting so the Map's
613
+ * iteration tail is the most-recently-written id). Inserting a brand-new id
614
+ * once the store is at `capacity` first prunes expired entries; if the store
615
+ * is still full, the least-recently-written id (the Map's head) is evicted.
616
+ * `options.evict` — when provided — is invoked (throw-isolated) with the id
617
+ * on every eviction the store's own policy performs (a capacity eviction or
618
+ * an expired-entry prune), but never for an explicit `delete`.
619
+ *
620
+ * @example
621
+ * ```ts
622
+ * const store = new MemorySessionStore({ ttl: 60_000, lifetime: 3_600_000 })
623
+ * await store.set('abc', { userId: 'u_1' }, Date.now())
624
+ * ```
625
+ */
626
+ var MemorySessionStore = class {
627
+ #entries;
628
+ #ttl;
629
+ #lifetime;
630
+ #capacity;
631
+ #evict;
632
+ constructor(options) {
633
+ if (options?.ttl !== void 0 && !isFiniteNumber(options.ttl)) throw new TypeError("MemorySessionStore requires options.ttl to be a finite number when provided");
634
+ if (options?.ttl !== void 0 && options.ttl <= 0) throw new TypeError("MemorySessionStore requires options.ttl to be positive when provided");
635
+ if (options?.lifetime !== void 0 && !isFiniteNumber(options.lifetime)) throw new TypeError("MemorySessionStore requires options.lifetime to be a finite number when provided");
636
+ if (options?.lifetime !== void 0 && options.lifetime <= 0) throw new TypeError("MemorySessionStore requires options.lifetime to be positive when provided");
637
+ if (options?.capacity !== void 0 && (!isFiniteNumber(options.capacity) || !Number.isInteger(options.capacity) || options.capacity <= 0)) throw new TypeError("MemorySessionStore requires options.capacity to be a positive integer when provided");
638
+ if (options?.evict !== void 0 && !isFunction(options.evict)) throw new TypeError("MemorySessionStore requires options.evict to be a function when provided");
639
+ this.#entries = /* @__PURE__ */ new Map();
640
+ this.#ttl = options?.ttl;
641
+ this.#lifetime = options?.lifetime;
642
+ this.#capacity = options?.capacity ?? 1e4;
643
+ this.#evict = options?.evict;
644
+ }
645
+ async get(id, now) {
646
+ const entry = this.#entries.get(id);
647
+ if (entry === void 0) return void 0;
648
+ if (this.#expired(entry, now)) {
649
+ this.#entries.delete(id);
650
+ this.#notify(id);
651
+ return;
652
+ }
653
+ entry.lastSeen = now;
654
+ return entry.session;
655
+ }
656
+ async set(id, session, now) {
657
+ const existing = this.#entries.get(id);
658
+ if (existing === void 0) this.#reserve(now);
659
+ const createdAt = existing?.createdAt ?? now;
660
+ this.#entries.delete(id);
661
+ this.#entries.set(id, {
662
+ session,
663
+ lastSeen: now,
664
+ createdAt
665
+ });
666
+ }
667
+ async delete(id) {
668
+ this.#entries.delete(id);
669
+ }
670
+ #expired(entry, now) {
671
+ if (this.#ttl !== void 0 && now - entry.lastSeen >= this.#ttl) return true;
672
+ if (this.#lifetime !== void 0 && now - entry.createdAt >= this.#lifetime) return true;
673
+ return false;
674
+ }
675
+ #reserve(now) {
676
+ if (this.#entries.size < this.#capacity) return;
677
+ for (const [id, entry] of this.#entries) if (this.#expired(entry, now)) {
678
+ this.#entries.delete(id);
679
+ this.#notify(id);
680
+ }
681
+ if (this.#entries.size >= this.#capacity) {
682
+ const oldest = this.#entries.keys().next().value;
683
+ if (oldest !== void 0) {
684
+ this.#entries.delete(oldest);
685
+ this.#notify(oldest);
686
+ }
687
+ }
688
+ }
689
+ #notify(id) {
690
+ if (this.#evict === void 0) return;
691
+ try {
692
+ this.#evict(id);
693
+ } catch {}
694
+ }
695
+ };
696
+ //#endregion
697
+ //#region src/core/middlewares.ts
698
+ /**
699
+ * The outermost error-rendering battery — catches a downstream throw and
700
+ * renders it as a `Response`.
701
+ *
702
+ * @typeParam TState - The consumer's opaque per-request state type
703
+ * @param options - See {@link BoundaryOptions}
704
+ * @returns A `MiddlewareHandler<TState>`
705
+ * @throws {TypeError} When `options.expose` or `options.report` is malformed
706
+ *
707
+ * @example
708
+ * ```ts
709
+ * const boundary = createBoundary({ expose: false })
710
+ * ```
711
+ */
712
+ function createBoundary(options) {
713
+ if (options?.expose !== void 0 && !isBoolean(options.expose)) throw new TypeError("BoundaryOptions.expose must be a boolean when provided");
714
+ if (options?.report !== void 0 && !isFunction(options.report)) throw new TypeError("BoundaryOptions.report must be a function when provided");
715
+ const expose = options?.expose ?? false;
716
+ const report = options?.report;
717
+ return async (request, context, next) => {
718
+ try {
719
+ return await next();
720
+ } catch (error) {
721
+ if (report !== void 0) try {
722
+ report(error);
723
+ } catch {}
724
+ if (isHTTPError(error)) return new Response(error.message, { status: error.status });
725
+ const message = expose ? error instanceof Error ? error.message : String(error) : "internal server error";
726
+ return new Response(message, { status: 500 });
727
+ }
728
+ };
729
+ }
730
+ /**
731
+ * The access-log/timing seam — records one {@link TelemetryEntry} per request
732
+ * after the response settles.
733
+ *
734
+ * @typeParam TState - The consumer's opaque per-request state type
735
+ * @param options - See {@link TelemetryOptions}
736
+ * @returns A `MiddlewareHandler<TState>`
737
+ * @throws {TypeError} When `options.record` is not a function
738
+ *
739
+ * @example
740
+ * ```ts
741
+ * const telemetry = createTelemetry({ record: (entry) => console.log(entry) })
742
+ * ```
743
+ */
744
+ function createTelemetry(options) {
745
+ if (!isFunction(options.record)) throw new TypeError("TelemetryOptions.record must be a function");
746
+ const record = options.record;
747
+ return async (request, context, next) => {
748
+ const start = Date.now();
749
+ let response;
750
+ try {
751
+ response = await next();
752
+ return response;
753
+ } finally {
754
+ const duration = Date.now() - start;
755
+ const status = response?.status ?? 500;
756
+ try {
757
+ record({
758
+ method: context.method,
759
+ pathname: context.url.pathname,
760
+ status,
761
+ duration
762
+ });
763
+ } catch {}
764
+ }
765
+ };
766
+ }
767
+ /**
768
+ * Response-body compression — negotiates and compresses a buffered response
769
+ * body over the runtime's feature-detected `CompressionStream` codings.
770
+ *
771
+ * @typeParam TState - The consumer's opaque per-request state type
772
+ * @param options - See {@link CompressionOptions}
773
+ * @returns A `MiddlewareHandler<TState>`
774
+ * @throws {TypeError} When `options.threshold` or `options.filter` is malformed
775
+ *
776
+ * @example
777
+ * ```ts
778
+ * const compression = createCompression({ threshold: 1024 })
779
+ * ```
780
+ */
781
+ function createCompression(options) {
782
+ if (options?.threshold !== void 0 && (!isFiniteNumber(options.threshold) || options.threshold < 0)) throw new TypeError("CompressionOptions.threshold must be a finite number when provided");
783
+ if (options?.filter !== void 0 && !isFunction(options.filter)) throw new TypeError("CompressionOptions.filter must be a function when provided");
784
+ if (options?.encodings !== void 0 && !Array.isArray(options.encodings)) throw new TypeError("CompressionOptions.encodings must be an array when provided");
785
+ const threshold = options?.threshold ?? 1024;
786
+ const encodings = detectEncodings(options?.encodings ?? DEFAULT_COMPRESSION_ENCODINGS);
787
+ const filter = options?.filter;
788
+ const compress = async (bytes, encoding) => {
789
+ const source = new Response(bytes).body;
790
+ if (source === null) return bytes;
791
+ const compressed = await new Response(source.pipeThrough(new CompressionStream(encoding))).arrayBuffer();
792
+ return new Uint8Array(compressed);
793
+ };
794
+ return async (request, context, next) => {
795
+ return compressResponse(request, context, await next(), {
796
+ threshold,
797
+ filter,
798
+ encodings,
799
+ compress
800
+ });
801
+ };
802
+ }
803
+ /**
804
+ * Security-headers + request-identifier battery.
805
+ *
806
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link IdentifierState}
807
+ * @param options - See {@link SecurityOptions}
808
+ * @returns A `MiddlewareHandler<TState>`
809
+ * @throws {TypeError} When any option is malformed
810
+ *
811
+ * @example
812
+ * ```ts
813
+ * const security = createSecurity({ hsts: true })
814
+ * ```
815
+ */
816
+ function createSecurity(options) {
817
+ if (options?.frame !== void 0 && options.frame !== false && options.frame !== "DENY" && options.frame !== "SAMEORIGIN") throw new TypeError("SecurityOptions.frame must be 'DENY', 'SAMEORIGIN', or false when provided");
818
+ if (options?.csp !== void 0 && options.csp !== false && !isString(options.csp)) throw new TypeError("SecurityOptions.csp must be a string or false when provided");
819
+ if (options?.referrer !== void 0 && options.referrer !== false && !isString(options.referrer)) throw new TypeError("SecurityOptions.referrer must be a string or false when provided");
820
+ if (options?.permissions !== void 0 && options.permissions !== false && !isString(options.permissions)) throw new TypeError("SecurityOptions.permissions must be a string or false when provided");
821
+ if (options?.coop !== void 0 && options.coop !== false && !isString(options.coop)) throw new TypeError("SecurityOptions.coop must be a string or false when provided");
822
+ if (options?.corp !== void 0 && options.corp !== false && !isString(options.corp)) throw new TypeError("SecurityOptions.corp must be a string or false when provided");
823
+ if (options?.cluster !== void 0 && options.cluster !== false && !isString(options.cluster)) throw new TypeError("SecurityOptions.cluster must be a string or false when provided");
824
+ if (options?.coep !== void 0 && !isBoolean(options.coep) && !isString(options.coep)) throw new TypeError("SecurityOptions.coep must be a string or boolean when provided");
825
+ if (options?.hsts !== void 0 && !isBoolean(options.hsts) && !isString(options.hsts)) throw new TypeError("SecurityOptions.hsts must be a string or boolean when provided");
826
+ if (options?.identifier !== void 0 && options.identifier !== false && !isRecord(options.identifier)) throw new TypeError("SecurityOptions.identifier must be an options bag or false when provided");
827
+ if (isRecord(options?.identifier) && options.identifier.trust !== void 0 && !isBoolean(options.identifier.trust)) throw new TypeError("SecurityOptions.identifier.trust must be a boolean when provided");
828
+ const frame = options?.frame;
829
+ const csp = options?.csp;
830
+ const referrer = options?.referrer;
831
+ const permissions = options?.permissions;
832
+ const coop = options?.coop;
833
+ const corp = options?.corp;
834
+ const cluster = options?.cluster;
835
+ const coep = options?.coep;
836
+ const hsts = options?.hsts;
837
+ const identifierOption = options?.identifier;
838
+ const identifierEnabled = identifierOption !== false;
839
+ const trust = isRecord(identifierOption) ? identifierOption.trust === true : false;
840
+ return async (request, context, next) => {
841
+ let identifier;
842
+ if (identifierEnabled) {
843
+ const incoming = request.headers.get(DEFAULT_IDENTIFIER_HEADER);
844
+ identifier = trust && incoming !== null && isValidRequestId(incoming) ? incoming : crypto.randomUUID();
845
+ context.state.identifier = identifier;
846
+ }
847
+ const response = await next();
848
+ response.headers.set("x-content-type-options", "nosniff");
849
+ const headerTable = [
850
+ ["x-frame-options", resolveSecurityHeader(frame, DEFAULT_FRAME_OPTIONS)],
851
+ ["content-security-policy", resolveSecurityHeader(csp, DEFAULT_CSP)],
852
+ ["referrer-policy", resolveSecurityHeader(referrer, DEFAULT_REFERRER_POLICY)],
853
+ ["permissions-policy", resolveSecurityHeader(permissions, DEFAULT_PERMISSIONS_POLICY)],
854
+ ["cross-origin-opener-policy", resolveSecurityHeader(coop, DEFAULT_COOP)],
855
+ ["cross-origin-resource-policy", resolveSecurityHeader(corp, DEFAULT_CORP)],
856
+ ["origin-agent-cluster", resolveSecurityHeader(cluster, "?1")],
857
+ ["cross-origin-embedder-policy", resolveOptInHeader(coep, DEFAULT_COEP)],
858
+ ["strict-transport-security", resolveOptInHeader(hsts, DEFAULT_HSTS)]
859
+ ];
860
+ for (const [name, value] of headerTable) if (value !== void 0) response.headers.set(name, value);
861
+ if (identifierEnabled && identifier !== void 0) response.headers.set(DEFAULT_IDENTIFIER_HEADER, identifier);
862
+ return response;
863
+ };
864
+ }
865
+ /**
866
+ * Cross-Origin Resource Sharing battery.
867
+ *
868
+ * @typeParam TState - The consumer's opaque per-request state type
869
+ * @param options - See {@link CorsOptions}
870
+ * @returns A `MiddlewareHandler<TState>`
871
+ * @throws {TypeError} When any option is malformed
872
+ *
873
+ * @example
874
+ * ```ts
875
+ * const cors = createCors({ origin: ['https://app.example'] })
876
+ * ```
877
+ */
878
+ function createCors(options) {
879
+ if (options?.origin !== void 0 && !isString(options.origin) && !Array.isArray(options.origin)) throw new TypeError("CorsOptions.origin must be a string or string array when provided");
880
+ if (options?.methods !== void 0 && !Array.isArray(options.methods)) throw new TypeError("CorsOptions.methods must be an array when provided");
881
+ if (options?.headers !== void 0 && !Array.isArray(options.headers)) throw new TypeError("CorsOptions.headers must be an array when provided");
882
+ const origin = options?.origin ?? "*";
883
+ const methods = options?.methods ?? DEFAULT_CORS_METHODS;
884
+ const headers = options?.headers ?? DEFAULT_CORS_HEADERS;
885
+ const reflecting = Array.isArray(origin);
886
+ return async (request, context, next) => {
887
+ const requestOrigin = request.headers.get("origin") ?? void 0;
888
+ const allowOrigin = resolveOrigin(origin, requestOrigin);
889
+ if (isPreflight(context.method, request.headers)) {
890
+ const preflightHeaders = new Headers();
891
+ if (allowOrigin !== void 0) preflightHeaders.set("access-control-allow-origin", allowOrigin);
892
+ preflightHeaders.set("access-control-allow-methods", methods.join(", "));
893
+ preflightHeaders.set("access-control-allow-headers", headers.join(", "));
894
+ if (reflecting && allowOrigin !== void 0) preflightHeaders.set("vary", mergeVary(void 0, "Origin"));
895
+ return new Response(null, {
896
+ status: 204,
897
+ headers: preflightHeaders
898
+ });
899
+ }
900
+ const response = await next();
901
+ if (allowOrigin !== void 0) response.headers.set("access-control-allow-origin", allowOrigin);
902
+ if (reflecting && allowOrigin !== void 0) response.headers.set("vary", mergeVary(response.headers.get("vary") ?? void 0, "Origin"));
903
+ return response;
904
+ };
905
+ }
906
+ /**
907
+ * The application-level per-request deadline battery.
908
+ *
909
+ * @typeParam TState - The consumer's opaque per-request state type
910
+ * @param options - See {@link DeadlineOptions}
911
+ * @returns A `MiddlewareHandler<TState>`
912
+ * @throws {TypeError} When `options.ms` or `options.status` is malformed
913
+ *
914
+ * @remarks
915
+ * MUST sit OUTSIDE `createBody` in the chain — it reconstructs the inbound
916
+ * `Request` (to link its `signal` to the deadline `signal`), which throws if
917
+ * the body was already consumed upstream (e.g. by `createBody`'s cached read).
918
+ *
919
+ * @example
920
+ * ```ts
921
+ * const deadline = createDeadline({ ms: 5_000 })
922
+ * ```
923
+ */
924
+ function createDeadline(options) {
925
+ if (!isFiniteNumber(options.ms) || options.ms <= 0) throw new TypeError("DeadlineOptions.ms must be a finite number");
926
+ if (options.status !== void 0 && !isFiniteNumber(options.status)) throw new TypeError("DeadlineOptions.status must be a finite number when provided");
927
+ const ms = options.ms;
928
+ const status = options.status ?? 503;
929
+ return async (request, context, next) => {
930
+ const timeout = createTimeout({ ms });
931
+ const linked = linkSignal(timeout.signal, request.signal);
932
+ timeout.start();
933
+ const downstream = next(new Request(request, { signal: linked }));
934
+ const deadline = new Promise((resolve) => {
935
+ timeout.signal.addEventListener("abort", () => resolve(new Response(null, { status })), { once: true });
936
+ });
937
+ let deadlineWon = false;
938
+ try {
939
+ return await Promise.race([downstream, deadline.then((response) => {
940
+ deadlineWon = true;
941
+ return response;
942
+ })]);
943
+ } finally {
944
+ timeout.clear();
945
+ downstream.then((response) => {
946
+ if (deadlineWon) response.body?.cancel().catch(() => {});
947
+ }).catch(() => {});
948
+ }
949
+ };
950
+ }
951
+ /**
952
+ * The trusted-proxy client-IP resolver battery.
953
+ *
954
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link ClientState} and {@link ConnectionState}
955
+ * @param options - See {@link ForwardedOptions}
956
+ * @returns A `MiddlewareHandler<TState>`
957
+ * @throws {TypeError} When neither or both of `proxies`/`trusted` are provided, or either is malformed
958
+ *
959
+ * @example
960
+ * ```ts
961
+ * const forwarded = createForwarded({ proxies: 1 })
962
+ * ```
963
+ */
964
+ function createForwarded(options) {
965
+ const hasProxies = isRecord(options) && "proxies" in options;
966
+ const hasTrusted = isRecord(options) && "trusted" in options;
967
+ if (!isRecord(options) || hasProxies === hasTrusted) throw new TypeError("ForwardedOptions requires exactly one of proxies or trusted");
968
+ if (hasProxies) {
969
+ const proxies = options.proxies;
970
+ if (!isFiniteNumber(proxies) || !Number.isInteger(proxies) || proxies < 1) throw new TypeError("ForwardedOptions.proxies must be a positive integer");
971
+ } else {
972
+ const trusted = options.trusted;
973
+ if (!Array.isArray(trusted)) throw new TypeError("ForwardedOptions.trusted must be an array when provided");
974
+ }
975
+ const trust = hasProxies ? { proxies: options.proxies } : { trusted: options.trusted };
976
+ return async (request, context, next) => {
977
+ const ip = resolveForwardedFor(request.headers.get("x-forwarded-for") ?? void 0, trust) ?? context.state.connection?.ip;
978
+ context.state.client = buildClientInfo(ip);
979
+ return next();
980
+ };
981
+ }
982
+ /**
983
+ * Dynamic response `ETag` + conditional GET battery.
984
+ *
985
+ * @typeParam TState - The consumer's opaque per-request state type
986
+ * @param options - See {@link ETagOptions}
987
+ * @returns A `MiddlewareHandler<TState>`
988
+ * @throws {TypeError} When `options.weak` is not a boolean
989
+ *
990
+ * @example
991
+ * ```ts
992
+ * const etag = createETag({ weak: true })
993
+ * ```
994
+ */
995
+ function createETag(options) {
996
+ if (options?.weak !== void 0 && !isBoolean(options.weak)) throw new TypeError("ETagOptions.weak must be a boolean when provided");
997
+ const weak = options?.weak ?? true;
998
+ return async (request, context, next) => {
999
+ const response = await next();
1000
+ if (context.method !== "GET" || response.status !== 200) return response;
1001
+ if (isBufferingIneligible(context.method, response, "etag")) return response;
1002
+ const buffer = await response.arrayBuffer();
1003
+ const bytes = new Uint8Array(buffer);
1004
+ const etag = await computeBodyETag(bytes, weak);
1005
+ const ifNoneMatch = request.headers.get("if-none-match");
1006
+ if (ifNoneMatch !== null && matchesETag(ifNoneMatch, etag)) {
1007
+ const headers = new Headers(response.headers);
1008
+ headers.set("etag", etag);
1009
+ headers.delete("content-length");
1010
+ return new Response(null, {
1011
+ status: 304,
1012
+ headers
1013
+ });
1014
+ }
1015
+ const headers = new Headers(response.headers);
1016
+ headers.set("etag", etag);
1017
+ return rebuildResponse(bytes, response, headers);
1018
+ };
1019
+ }
1020
+ /**
1021
+ * Bearer-token authentication battery.
1022
+ *
1023
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link BearerState}
1024
+ * @param options - See {@link BearerOptions}
1025
+ * @returns A `MiddlewareHandler<TState>`
1026
+ * @throws {TypeError} When any option is malformed
1027
+ * @throws {HTTPError} `401` when the token is missing, invalid, or expired
1028
+ *
1029
+ * @example
1030
+ * ```ts
1031
+ * const bearer = createBearer({ secret: 'shh' })
1032
+ * ```
1033
+ */
1034
+ function createBearer(options) {
1035
+ if (!isString(options.secret) && (!Array.isArray(options.secret) || !options.secret.every(isString))) throw new TypeError("BearerOptions.secret must be a string or string array");
1036
+ if (options.header !== void 0 && !isString(options.header)) throw new TypeError("BearerOptions.header must be a string when provided");
1037
+ if (options.scheme !== void 0 && !isString(options.scheme)) throw new TypeError("BearerOptions.scheme must be a string when provided");
1038
+ const secret = options.secret;
1039
+ const header = (options.header ?? "authorization").toLowerCase();
1040
+ const scheme = options.scheme ?? "Bearer";
1041
+ return async (request, context, next) => {
1042
+ const raw = request.headers.get(header);
1043
+ if (raw === null) throw new HTTPError(401, "missing token");
1044
+ let candidate = raw;
1045
+ if (scheme !== "") {
1046
+ const prefix = `${scheme.toLowerCase()} `;
1047
+ if (!raw.toLowerCase().startsWith(prefix)) throw new HTTPError(401, "missing token");
1048
+ candidate = raw.slice(prefix.length);
1049
+ }
1050
+ const verified = await verifyToken(candidate, secret);
1051
+ if (verified === void 0) throw new HTTPError(401, "invalid token");
1052
+ context.state.token = verified;
1053
+ return next();
1054
+ };
1055
+ }
1056
+ /**
1057
+ * Fixed-window rate-limiting battery.
1058
+ *
1059
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link BearerState}, {@link ClientState}, and {@link ConnectionState}
1060
+ * @param options - See {@link LimiterOptions}
1061
+ * @returns A `MiddlewareHandler<TState>`
1062
+ * @throws {TypeError} When any option is malformed
1063
+ *
1064
+ * @example
1065
+ * ```ts
1066
+ * const limiter = createLimiter({ max: 100, window: 60_000 })
1067
+ * ```
1068
+ */
1069
+ function createLimiter(options) {
1070
+ if (!isFiniteNumber(options.max) || !Number.isInteger(options.max) || options.max <= 0) throw new TypeError("LimiterOptions.max must be a positive integer");
1071
+ if (!isFiniteNumber(options.window) || options.window <= 0) throw new TypeError("LimiterOptions.window must be a positive finite number");
1072
+ if (options.capacity !== void 0 && (!isFiniteNumber(options.capacity) || !Number.isInteger(options.capacity) || options.capacity <= 0)) throw new TypeError("LimiterOptions.capacity must be a positive integer when provided");
1073
+ if (options.key !== void 0 && !isFunction(options.key)) throw new TypeError("LimiterOptions.key must be a function when provided");
1074
+ if (options.message !== void 0 && !isString(options.message)) throw new TypeError("LimiterOptions.message must be a string when provided");
1075
+ if (options.clock !== void 0 && !isFunction(options.clock)) throw new TypeError("LimiterOptions.clock must be a function when provided");
1076
+ if (options.policy !== void 0 && !isBoolean(options.policy)) throw new TypeError("LimiterOptions.policy must be a boolean when provided");
1077
+ if (options.evict !== void 0 && !isFunction(options.evict)) throw new TypeError("LimiterOptions.evict must be a function when provided");
1078
+ const max = options.max;
1079
+ const window = options.window;
1080
+ const capacity = options.capacity ?? 1e4;
1081
+ const deriveKey = options.key ?? ((context) => resolveKey(context.state));
1082
+ const message = options.message ?? "rate limit exceeded";
1083
+ const clock = options.clock ?? Date.now;
1084
+ const policy = options.policy ?? false;
1085
+ const evict = options.evict;
1086
+ const notify = (evictedKey) => {
1087
+ if (evict === void 0) return;
1088
+ try {
1089
+ evict(evictedKey);
1090
+ } catch {}
1091
+ };
1092
+ const buckets = /* @__PURE__ */ new Map();
1093
+ return async (request, context, next) => {
1094
+ const key = deriveKey(context);
1095
+ const now = clock();
1096
+ let bucket = buckets.get(key);
1097
+ if (bucket === void 0) {
1098
+ if (buckets.size >= capacity) {
1099
+ const oldest = buckets.keys().next().value;
1100
+ if (oldest !== void 0) {
1101
+ buckets.delete(oldest);
1102
+ notify(oldest);
1103
+ }
1104
+ }
1105
+ bucket = {
1106
+ budget: createBudget({
1107
+ max,
1108
+ consume: (value) => value
1109
+ }),
1110
+ resetAt: now + window
1111
+ };
1112
+ buckets.set(key, bucket);
1113
+ } else {
1114
+ buckets.delete(key);
1115
+ buckets.set(key, bucket);
1116
+ if (now >= bucket.resetAt) {
1117
+ bucket.budget.clear();
1118
+ bucket.resetAt = now + window;
1119
+ }
1120
+ }
1121
+ if (bucket.budget.exhausted) {
1122
+ const headers = new Headers();
1123
+ headers.set("retry-after", buildRetryAfter(bucket.resetAt, now));
1124
+ if (policy) {
1125
+ headers.set("ratelimit", buildRateLimitField(0, bucket.resetAt, now));
1126
+ headers.set("ratelimit-policy", buildRateLimitPolicyField(max, window));
1127
+ }
1128
+ return new Response(message, {
1129
+ status: 429,
1130
+ headers
1131
+ });
1132
+ }
1133
+ bucket.budget.consume(1);
1134
+ return next();
1135
+ };
1136
+ }
1137
+ /**
1138
+ * The body-driving battery — eagerly awaits the cached `context.body()` so
1139
+ * its throws (or a malformed-JSON `undefined`) surface before the handler runs.
1140
+ *
1141
+ * @typeParam TState - The consumer's opaque per-request state type
1142
+ * @returns A `MiddlewareHandler<TState>`
1143
+ * @throws {HTTPError} `400` when the request declares `application/json` and the body resolves `undefined`
1144
+ *
1145
+ * @remarks
1146
+ * The shipped `MiddlewareContext.body()` is a parameterless, server-owned
1147
+ * cache (`ServerOptions.limit` governs its size cap) — this battery carries
1148
+ * no `limit`/`decompression` options (a deliberate break from the deleted old
1149
+ * `createBodyParser` surface, which configured them itself).
1150
+ *
1151
+ * @example
1152
+ * ```ts
1153
+ * const body = createBody()
1154
+ * ```
1155
+ */
1156
+ function createBody() {
1157
+ return async (request, context, next) => {
1158
+ const body = await context.body();
1159
+ const contentType = request.headers.get("content-type");
1160
+ if (contentType !== null && contentType.toLowerCase().startsWith("application/json") && body === void 0) throw new HTTPError(400, "invalid json");
1161
+ return next();
1162
+ };
1163
+ }
1164
+ /**
1165
+ * The generic session battery — resolves, mints, and persists a session
1166
+ * across the request, with a mid-handler `regenerate`/`destroy` control handle.
1167
+ *
1168
+ * @typeParam S - The session entity type the store persists (must implement {@link SessionInterface})
1169
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link SessionState} and {@link ConnectionState}
1170
+ * @param options - See {@link SessionOptions}
1171
+ * @returns A `MiddlewareHandler<TState>`
1172
+ * @throws {TypeError} When any option is malformed
1173
+ * @throws {HTTPError} `404` when `require` is set and no session resolves or mints
1174
+ *
1175
+ * @example
1176
+ * ```ts
1177
+ * const session = createSession({ transport: createHeaderTransport() })
1178
+ * ```
1179
+ */
1180
+ function createSession(options) {
1181
+ if (!isFunction(options.transport.read) || !isFunction(options.transport.write) || !isFunction(options.transport.clear)) throw new TypeError("SessionOptions.transport must implement SessionTransport");
1182
+ if (options.store !== void 0 && (!isFunction(options.store.get) || !isFunction(options.store.set) || !isFunction(options.store.delete))) throw new TypeError("SessionOptions.store must implement SessionStoreInterface when provided");
1183
+ if (options.ttl !== void 0 && !isFiniteNumber(options.ttl)) throw new TypeError("SessionOptions.ttl must be a finite number when provided");
1184
+ if (options.lifetime !== void 0 && !isFiniteNumber(options.lifetime)) throw new TypeError("SessionOptions.lifetime must be a finite number when provided");
1185
+ if (options.create !== void 0 && !isFunction(options.create)) throw new TypeError("SessionOptions.create must be a function when provided");
1186
+ if (options.mint !== void 0 && !isFunction(options.mint)) throw new TypeError("SessionOptions.mint must be a function when provided");
1187
+ if (options.require !== void 0 && !isBoolean(options.require)) throw new TypeError("SessionOptions.require must be a boolean when provided");
1188
+ if (options.ends !== void 0 && !isBoolean(options.ends)) throw new TypeError("SessionOptions.ends must be a boolean when provided");
1189
+ if (options.clock !== void 0 && !isFunction(options.clock)) throw new TypeError("SessionOptions.clock must be a function when provided");
1190
+ const transport = options.transport;
1191
+ const clock = options.clock ?? Date.now;
1192
+ const store = options.store ?? new MemorySessionStore({
1193
+ ttl: options.ttl,
1194
+ lifetime: options.lifetime,
1195
+ capacity: options.capacity,
1196
+ evict: options.evict
1197
+ });
1198
+ const create = options.create ?? ((id) => new Session(id));
1199
+ const mint = options.mint;
1200
+ const requireSession = options.require ?? false;
1201
+ const ends = options.ends ?? false;
1202
+ return async (request, context, next) => {
1203
+ const now = clock();
1204
+ const encrypted = context.state.connection?.encrypted ?? false;
1205
+ const incomingId = await transport.read(request);
1206
+ let session = incomingId !== void 0 ? await store.get(incomingId, now) : void 0;
1207
+ if (ends && context.method === "DELETE" && session !== void 0) {
1208
+ await store.delete(session.id);
1209
+ return new Response(null, { status: 204 });
1210
+ }
1211
+ let minted = false;
1212
+ if (session === void 0) {
1213
+ if (mint !== void 0 ? await mint(context) : true) {
1214
+ const id = crypto.randomUUID();
1215
+ session = create(id);
1216
+ minted = true;
1217
+ } else if (requireSession) throw new HTTPError(404, "session required");
1218
+ }
1219
+ let destroyed = false;
1220
+ let regenerated;
1221
+ const activeSession = session;
1222
+ if (activeSession !== void 0) {
1223
+ const control = {
1224
+ regenerate() {
1225
+ if (destroyed) return;
1226
+ const newSession = create(crypto.randomUUID());
1227
+ transferSessionData(activeSession, newSession);
1228
+ regenerated = newSession;
1229
+ },
1230
+ destroy() {
1231
+ destroyed = true;
1232
+ regenerated = void 0;
1233
+ }
1234
+ };
1235
+ context.state.session = activeSession;
1236
+ context.state.control = control;
1237
+ }
1238
+ const response = await next();
1239
+ if (activeSession !== void 0) if (destroyed) {
1240
+ await store.delete(activeSession.id);
1241
+ await transport.clear(response);
1242
+ } else if (regenerated !== void 0) {
1243
+ await store.set(regenerated.id, regenerated, clock());
1244
+ await store.delete(activeSession.id);
1245
+ await transport.write(response, regenerated.id, encrypted);
1246
+ } else {
1247
+ await store.set(activeSession.id, activeSession, clock());
1248
+ if (minted) await transport.write(response, activeSession.id, encrypted);
1249
+ }
1250
+ return response;
1251
+ };
1252
+ }
1253
+ /**
1254
+ * Session-bound double-submit CSRF protection battery.
1255
+ *
1256
+ * @typeParam TState - The consumer's opaque per-request state type, must carry {@link CSRFState}, {@link SessionState}, and {@link ConnectionState}
1257
+ * @param options - See {@link CSRFOptions}
1258
+ * @returns A `MiddlewareHandler<TState>`
1259
+ * @throws {TypeError} When any option is malformed
1260
+ * @throws {HTTPError} `403` when the submitted token is missing, mismatched, or bound to a different session
1261
+ *
1262
+ * @example
1263
+ * ```ts
1264
+ * const csrf = createCSRF({ secret: 'shh' })
1265
+ * ```
1266
+ */
1267
+ function createCSRF(options) {
1268
+ if (!isString(options.secret) && (!Array.isArray(options.secret) || !options.secret.every(isString))) throw new TypeError("CSRFOptions.secret must be a string or string array");
1269
+ if (options.cookie !== void 0 && !isString(options.cookie)) throw new TypeError("CSRFOptions.cookie must be a string when provided");
1270
+ if (options.header !== void 0 && !isString(options.header)) throw new TypeError("CSRFOptions.header must be a string when provided");
1271
+ if (options.field !== void 0 && !isString(options.field)) throw new TypeError("CSRFOptions.field must be a string when provided");
1272
+ if (options.safe !== void 0 && !Array.isArray(options.safe)) throw new TypeError("CSRFOptions.safe must be an array when provided");
1273
+ const secret = options.secret;
1274
+ const cookieName = options.cookie ?? "csrf";
1275
+ const header = options.header ?? "x-csrf-token";
1276
+ const field = options.field ?? "_csrf";
1277
+ const safe = options.safe ?? DEFAULT_CSRF_SAFE_METHODS;
1278
+ return async (request, context, next) => {
1279
+ if (safe.includes(context.method)) {
1280
+ const token = await signToken(context.state.session?.id ?? crypto.randomUUID(), { secret });
1281
+ context.state.csrf = token;
1282
+ const response = await next();
1283
+ const secure = resolveSecure(void 0, context.state.connection?.encrypted ?? false);
1284
+ await writeSignedCookie(response.headers, cookieName, token, secret, {
1285
+ sameSite: "Strict",
1286
+ httpOnly: false,
1287
+ path: "/",
1288
+ secure
1289
+ });
1290
+ return response;
1291
+ }
1292
+ const headerToken = request.headers.get(header);
1293
+ let submitted;
1294
+ if (headerToken !== null) submitted = headerToken;
1295
+ else {
1296
+ const body = await context.body();
1297
+ if (isRecord(body) && isString(body[field])) submitted = body[field];
1298
+ }
1299
+ if (submitted === void 0) throw new HTTPError(403, "invalid csrf token");
1300
+ const cookieToken = await readSignedCookie(request, cookieName, secret);
1301
+ if (cookieToken === void 0 || !equalsConstantTime(cookieToken, submitted)) throw new HTTPError(403, "invalid csrf token");
1302
+ const sessionId = context.state.session?.id;
1303
+ if (sessionId !== void 0) {
1304
+ if (await verifyToken(cookieToken, secret) !== sessionId) throw new HTTPError(403, "invalid csrf token");
1305
+ }
1306
+ return next();
1307
+ };
1308
+ }
1309
+ //#endregion
1310
+ //#region src/core/factories.ts
1311
+ /**
1312
+ * Create a signed-cookie {@link SessionTransport} — the session id travels as
1313
+ * a `signToken`-signed cookie value.
1314
+ *
1315
+ * @param options - See {@link CookieTransportOptions}
1316
+ * @returns A {@link SessionTransport}
1317
+ * @throws {TypeError} When `options.secret` or `options.name` is malformed
1318
+ *
1319
+ * @example
1320
+ * ```ts
1321
+ * const transport = createCookieTransport({ secret: 'shh' })
1322
+ * ```
1323
+ */
1324
+ function createCookieTransport(options) {
1325
+ if (!isString(options.secret) && (!Array.isArray(options.secret) || !options.secret.every(isString))) throw new TypeError("CookieTransportOptions.secret must be a string or string array");
1326
+ if (options.name !== void 0 && !isString(options.name)) throw new TypeError("CookieTransportOptions.name must be a string when provided");
1327
+ const name = options.name ?? "session";
1328
+ const secret = options.secret;
1329
+ const cookie = options.cookie;
1330
+ return {
1331
+ read(request) {
1332
+ return readSignedCookie(request, name, secret);
1333
+ },
1334
+ async write(response, id, encrypted) {
1335
+ const secure = resolveSecure(cookie?.secure, encrypted);
1336
+ await writeSignedCookie(response.headers, name, id, secret, {
1337
+ ...cookie,
1338
+ secure
1339
+ });
1340
+ },
1341
+ clear(response) {
1342
+ clearCookie(response.headers, name, cookie);
1343
+ }
1344
+ };
1345
+ }
1346
+ /**
1347
+ * Create a bare-header {@link SessionTransport} — the session id travels
1348
+ * verbatim in a request/response header.
1349
+ *
1350
+ * @param options - See {@link HeaderTransportOptions}
1351
+ * @returns A {@link SessionTransport}
1352
+ * @throws {TypeError} When `options.header` is malformed
1353
+ *
1354
+ * @example
1355
+ * ```ts
1356
+ * const transport = createHeaderTransport()
1357
+ * ```
1358
+ */
1359
+ function createHeaderTransport(options) {
1360
+ if (options?.header !== void 0 && !isString(options.header)) throw new TypeError("HeaderTransportOptions.header must be a string when provided");
1361
+ const header = options?.header ?? "session-id";
1362
+ return {
1363
+ read(request) {
1364
+ return request.headers.get(header) ?? void 0;
1365
+ },
1366
+ write(response, id) {
1367
+ response.headers.set(header, id);
1368
+ },
1369
+ clear(response) {
1370
+ response.headers.delete(header);
1371
+ }
1372
+ };
1373
+ }
1374
+ /**
1375
+ * Create the default in-process {@link SessionStoreInterface} — a `Map`-backed
1376
+ * store enforcing an idle timeout and an absolute lifetime.
1377
+ *
1378
+ * @typeParam S - The session data payload type
1379
+ * @param options - See {@link MemorySessionStoreOptions}
1380
+ * @returns A {@link SessionStoreInterface}
1381
+ * @throws {TypeError} When `options.ttl` or `options.lifetime` is malformed
1382
+ *
1383
+ * @remarks
1384
+ * The {@link Session} entity is the `create` option's default value factory
1385
+ * for `createSession` and deliberately ships WITHOUT its own `create*`
1386
+ * factory — the name `createSession` belongs to the battery, not this class.
1387
+ *
1388
+ * @example
1389
+ * ```ts
1390
+ * const store = createMemorySessionStore({ ttl: 60_000 })
1391
+ * ```
1392
+ */
1393
+ function createMemorySessionStore(options) {
1394
+ return new MemorySessionStore(options);
1395
+ }
1396
+ //#endregion
1397
+ export { DEFAULT_BEARER_HEADER, DEFAULT_BEARER_SCHEME, DEFAULT_CLUSTER, DEFAULT_COEP, DEFAULT_COMPRESSION_ENCODINGS, DEFAULT_COMPRESSION_THRESHOLD, DEFAULT_COOP, DEFAULT_CORP, DEFAULT_CORS_HEADERS, DEFAULT_CORS_METHODS, DEFAULT_CSP, DEFAULT_CSRF_COOKIE, DEFAULT_CSRF_FIELD, DEFAULT_CSRF_HEADER, DEFAULT_CSRF_SAFE_METHODS, DEFAULT_DEADLINE_STATUS, DEFAULT_FRAME_OPTIONS, DEFAULT_HSTS, DEFAULT_IDENTIFIER_HEADER, DEFAULT_LIMITER_CAPACITY, DEFAULT_LIMITER_MESSAGE, DEFAULT_PERMISSIONS_POLICY, DEFAULT_REFERRER_POLICY, DEFAULT_SESSION_CAPACITY, DEFAULT_SESSION_COOKIE, DEFAULT_SESSION_HEADER, MemorySessionStore, Session, buildClientInfo, buildRateLimitField, buildRateLimitPolicyField, buildRetryAfter, compressResponse, createBearer, createBody, createBoundary, createCSRF, createCompression, createCookieTransport, createCors, createDeadline, createETag, createForwarded, createHeaderTransport, createLimiter, createMemorySessionStore, createSecurity, createSession, createTelemetry, detectEncodings, equalsConstantTime, isBufferingIneligible, isCompressionNegotiated, isMultipartBody, isMultipartFile, isPreflight, isSession, isSessionControl, matchesTrustedEntry, rebuildResponse, resolveForwardedFor, resolveKey, resolveOptInHeader, transferSessionData };
1398
+
1399
+ //# sourceMappingURL=index.js.map