@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/src/core/index.cjs +1463 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1339 -0
- package/dist/src/core/index.d.ts +1339 -0
- package/dist/src/core/index.js +1399 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1217 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +729 -0
- package/dist/src/server/index.d.ts +729 -0
- package/dist/src/server/index.js +1180 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +108 -0
|
@@ -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
|