@nifrajs/middleware 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +3 -0
  2. package/dist/_utils.d.ts +15 -9
  3. package/dist/_utils.d.ts.map +1 -1
  4. package/dist/_utils.js +39 -40
  5. package/dist/_utils.js.map +1 -1
  6. package/dist/admission.d.ts +4 -2
  7. package/dist/admission.d.ts.map +1 -1
  8. package/dist/admission.js +17 -5
  9. package/dist/admission.js.map +1 -1
  10. package/dist/basic-auth.d.ts.map +1 -1
  11. package/dist/basic-auth.js +8 -13
  12. package/dist/basic-auth.js.map +1 -1
  13. package/dist/cache-control.d.ts.map +1 -1
  14. package/dist/cache-control.js +4 -2
  15. package/dist/cache-control.js.map +1 -1
  16. package/dist/cache.d.ts +7 -4
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +58 -25
  19. package/dist/cache.js.map +1 -1
  20. package/dist/compression.d.ts.map +1 -1
  21. package/dist/compression.js +58 -1
  22. package/dist/compression.js.map +1 -1
  23. package/dist/conditional.d.ts.map +1 -1
  24. package/dist/conditional.js +1 -1
  25. package/dist/conditional.js.map +1 -1
  26. package/dist/content-negotiation.d.ts.map +1 -1
  27. package/dist/content-negotiation.js +3 -1
  28. package/dist/content-negotiation.js.map +1 -1
  29. package/dist/csrf.d.ts.map +1 -1
  30. package/dist/csrf.js +10 -5
  31. package/dist/csrf.js.map +1 -1
  32. package/dist/healthcheck.d.ts.map +1 -1
  33. package/dist/healthcheck.js +3 -1
  34. package/dist/healthcheck.js.map +1 -1
  35. package/dist/idempotency.d.ts +20 -4
  36. package/dist/idempotency.d.ts.map +1 -1
  37. package/dist/idempotency.js +44 -17
  38. package/dist/idempotency.js.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/jwt.d.ts +17 -2
  44. package/dist/jwt.d.ts.map +1 -1
  45. package/dist/jwt.js +70 -31
  46. package/dist/jwt.js.map +1 -1
  47. package/dist/language.d.ts.map +1 -1
  48. package/dist/language.js +12 -0
  49. package/dist/language.js.map +1 -1
  50. package/dist/openapi.d.ts +19 -4
  51. package/dist/openapi.d.ts.map +1 -1
  52. package/dist/openapi.js +24 -8
  53. package/dist/openapi.js.map +1 -1
  54. package/dist/rate-limit.d.ts +31 -0
  55. package/dist/rate-limit.d.ts.map +1 -1
  56. package/dist/rate-limit.js +160 -26
  57. package/dist/rate-limit.js.map +1 -1
  58. package/dist/request-id.d.ts.map +1 -1
  59. package/dist/request-id.js +1 -1
  60. package/dist/request-id.js.map +1 -1
  61. package/dist/timing.d.ts.map +1 -1
  62. package/dist/timing.js +1 -1
  63. package/dist/timing.js.map +1 -1
  64. package/dist/token-auth.d.ts.map +1 -1
  65. package/dist/token-auth.js +8 -13
  66. package/dist/token-auth.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/_utils.ts +61 -35
  69. package/src/admission.ts +15 -5
  70. package/src/basic-auth.ts +15 -13
  71. package/src/cache-control.ts +5 -2
  72. package/src/cache.ts +68 -28
  73. package/src/compression.ts +84 -4
  74. package/src/conditional.ts +8 -4
  75. package/src/content-negotiation.ts +3 -1
  76. package/src/csrf.ts +10 -6
  77. package/src/healthcheck.ts +4 -1
  78. package/src/idempotency.ts +77 -20
  79. package/src/index.ts +3 -0
  80. package/src/jwt.ts +106 -34
  81. package/src/language.ts +11 -0
  82. package/src/openapi.ts +52 -10
  83. package/src/rate-limit.ts +233 -41
  84. package/src/request-id.ts +7 -3
  85. package/src/timing.ts +8 -4
  86. package/src/token-auth.ts +18 -16
@@ -1,23 +1,18 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance";
2
2
  import { defineIdentityPlugin } from "@nifrajs/core/server";
3
- import { bearerToken, guardName } from "./_utils.js";
3
+ import { bearerToken, guardName, installAuthGuard } from "./_utils.js";
4
4
  function createTokenAuth(config) {
5
- // Per-request principal cache: verify runs once (in beforeHandle), reads are free + can't desync.
5
+ // Per-request principal cache: verify runs once, in the guard; reads are free and can't desync.
6
6
  const store = new WeakMap();
7
7
  const reject = () => Response.json({ ok: false, error: "unauthorized" }, {
8
8
  status: 401,
9
9
  headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
10
10
  });
11
- const plugin = defineIdentityPlugin(guardName(config.name), (app) => app.beforeHandle(async (c) => {
12
- const token = config.extract(c.req);
11
+ const plugin = defineIdentityPlugin(guardName(config.name), (app) => installAuthGuard(app, config.name, config.optional, store, (header) => {
12
+ const token = config.extract(header);
13
13
  // Empty string is treated as "no credential" - never passed to verify.
14
- const principal = token !== undefined && token !== "" ? await config.verify(token) : null;
15
- if (principal !== null && principal !== undefined) {
16
- store.set(c.req, principal);
17
- return undefined; // authorized - continue to the handler
18
- }
19
- return config.optional ? undefined : reject(); // 401 short-circuits (skips the handler)
20
- }));
14
+ return token !== undefined && token !== "" ? config.verify(token) : null;
15
+ }, reject));
21
16
  const instrumented = config.optional
22
17
  ? plugin
23
18
  : withRouteAssurance(plugin, {
@@ -44,7 +39,7 @@ export function bearer(options) {
44
39
  const realm = options.realm ?? "api";
45
40
  return createTokenAuth({
46
41
  name: "bearer",
47
- extract: (request) => bearerToken(request.headers.get("authorization")) ?? undefined,
42
+ extract: (header) => bearerToken(header("authorization")) ?? undefined,
48
43
  verify: options.verify,
49
44
  optional: options.optional === true,
50
45
  challenge: `Bearer realm="${realm}"`,
@@ -88,7 +83,7 @@ export function apiKey(options) {
88
83
  const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify;
89
84
  return createTokenAuth({
90
85
  name: "apiKey",
91
- extract: (request) => request.headers.get(header) ?? undefined,
86
+ extract: (get) => get(header) ?? undefined,
92
87
  verify,
93
88
  optional: options.optional === true,
94
89
  });
@@ -1 +1 @@
1
- {"version":3,"file":"token-auth.js","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"token-auth.js","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/middleware",
3
- "version": "4.0.2",
3
+ "version": "5.1.0",
4
4
  "description": "Composable middleware for nifra - auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
5
5
  "keywords": [
6
6
  "bun",
@@ -49,10 +49,10 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@nifrajs/schema": "4.0.2"
52
+ "@nifrajs/schema": "5.1.0"
53
53
  },
54
54
  "peerDependencies": {
55
- "@nifrajs/core": "^4.0.2"
55
+ "@nifrajs/core": "^5.1.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@nifrajs/core": "workspace:*",
package/src/_utils.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  export type MaybePromise<T> = T | Promise<T>
2
2
 
3
- import type { NodeResponseContext } from "@nifrajs/core/server"
4
- import { parseCookies as parseCoreCookies } from "@nifrajs/core/server"
3
+ import type { AnyServer, NodeResponseContext } from "@nifrajs/core/server"
4
+ import {
5
+ authenticated,
6
+ markAuthenticatedRequest,
7
+ parseCookies as parseCoreCookies,
8
+ rejected,
9
+ writableResponse,
10
+ } from "@nifrajs/core/server"
5
11
 
6
12
  export const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"])
7
13
 
@@ -30,6 +36,47 @@ export function guardName(base: string): string {
30
36
  return `${base}#${guardInstances}`
31
37
  }
32
38
 
39
+ /** One request header by name, or `null`: what a credential extractor reads. */
40
+ export type HeaderGet = (name: string) => string | null
41
+
42
+ /**
43
+ * Install a credential guard. Required, it is an `authenticate()` stage, so a request without a
44
+ * principal is refused before its body or query is read or validated, and before any `derive` or
45
+ * `beforeHandle`. Optional never refuses, so it stays a `beforeHandle` that records a principal when
46
+ * the request carries one.
47
+ */
48
+ export function installAuthGuard<P>(
49
+ app: AnyServer,
50
+ id: string,
51
+ optional: boolean,
52
+ store: WeakMap<Request, P>,
53
+ verify: (header: HeaderGet) => MaybePromise<P | null | undefined>,
54
+ refuse: () => Response,
55
+ ): AnyServer {
56
+ if (optional) {
57
+ return app.beforeHandle(async (c: { readonly req: Request }) => {
58
+ const principal = await verify((name) => c.req.headers.get(name))
59
+ if (principal !== null && principal !== undefined) {
60
+ store.set(c.req, principal)
61
+ markAuthenticatedRequest(c.req)
62
+ }
63
+ return undefined
64
+ })
65
+ }
66
+ return app.authenticate({
67
+ id,
68
+ mode: "async",
69
+ run: async (input) => {
70
+ const principal = await verify((name) => input.headers.get(name))
71
+ if (principal === null || principal === undefined) {
72
+ return rejected("unauthenticated", refuse())
73
+ }
74
+ store.set(input.request, principal)
75
+ return authenticated(principal)
76
+ },
77
+ })
78
+ }
79
+
33
80
  /** Shared with core so auth/CSRF middleware gets the allocation-light cookie scanner too. */
34
81
  export const parseCookies = parseCoreCookies
35
82
 
@@ -163,40 +210,19 @@ export async function verifyHmacSha256(
163
210
  }
164
211
 
165
212
  /**
166
- * Apply header mutations to a response, in place when possible. Framework-built responses
167
- * (`new Response`, `Response.json`) have mutable headers across every runtime - so the common path
168
- * mutates `res` directly and returns it, allocating nothing. Only an *immutable*-headers response
169
- * (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd -
170
- * never on Bun) makes `.set`/`.append` throw; that path clones into a fresh `Headers` + `Response`,
171
- * exactly the old always-clone behavior. `apply` runs once either way (immutability is all-or-nothing
172
- * - the first mutation throws before any partial change), so a mutation chain is safe to pass.
213
+ * Apply header mutations to the copy of `res` this request owns (`writableResponse`): a body-bearing
214
+ * response with mutable headers is written in place, a body-less one (sendable any number of times,
215
+ * so possibly shared) or one with guarded headers is copied first. Pass the `request` being answered
216
+ * so a copy already made for it is reused.
173
217
  */
174
- export function withHeaders(res: Response, apply: (headers: Headers) => void): Response {
175
- const responseHeaders = res.headers
176
- const probe = "x-nifra-header-probe"
177
- let previous: string | null = null
178
- let mutable = true
179
- try {
180
- previous = responseHeaders.get(probe)
181
- responseHeaders.set(probe, "1")
182
- if (previous === null) responseHeaders.delete(probe)
183
- else responseHeaders.set(probe, previous)
184
- } catch {
185
- mutable = false
186
- try {
187
- if (previous === null) responseHeaders.delete(probe)
188
- else responseHeaders.set(probe, previous)
189
- } catch {
190
- // The guarded response rejected cleanup; the clone below is authoritative.
191
- }
192
- }
193
- if (!mutable) {
194
- const headers = new Headers(res.headers)
195
- apply(headers)
196
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
197
- }
198
- apply(responseHeaders)
199
- return res
218
+ export function withHeaders(
219
+ res: Response,
220
+ apply: (headers: Headers) => void,
221
+ request?: Request,
222
+ ): Response {
223
+ const target = writableResponse(res, request)
224
+ apply(target.headers)
225
+ return target
200
226
  }
201
227
 
202
228
  /**
package/src/admission.ts CHANGED
@@ -103,8 +103,10 @@ const NO_LAG: () => number = () => 0
103
103
  /**
104
104
  * Event-loop-lag sampler. By default it measures timer drift using only Web/JS runtime primitives, so
105
105
  * it works under Node ESM, Bun, Deno, and workers without a hidden CommonJS `require` fallback. An
106
- * injected histogram remains available for deterministic tests or a runtime-native monitor. Each read
107
- * returns recent mean lag and resets the sampling window.
106
+ * injected histogram remains available for deterministic tests or a runtime-native monitor. A read
107
+ * returns the mean lag of the samples taken since the previous read and starts a new window; a read
108
+ * with no new sample since then returns the same value again, so every request that arrives between
109
+ * two samples of a lagging loop sees the lag.
108
110
  */
109
111
  export function createEventLoopLagSampler(
110
112
  resolutionMs = 20,
@@ -127,11 +129,15 @@ export function createEventLoopLagSampler(
127
129
  // A metrics sampler must never keep a Node/Bun process alive by itself. Browser/Deno numeric timer
128
130
  // handles simply do not expose `unref`.
129
131
  ;(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.()
132
+ let latest = 0
130
133
  return () => {
131
- const mean = samples === 0 ? 0 : totalLagMs / samples
134
+ // An empty window is not a healthy loop: a burst of requests reads between two ticks.
135
+ if (samples === 0) return latest
136
+ const mean = totalLagMs / samples
132
137
  totalLagMs = 0
133
138
  samples = 0
134
- return Number.isFinite(mean) ? mean : 0
139
+ latest = Number.isFinite(mean) ? mean : 0
140
+ return latest
135
141
  }
136
142
  }
137
143
  let histogram: LoopDelayHistogram | undefined
@@ -142,10 +148,14 @@ export function createEventLoopLagSampler(
142
148
  }
143
149
  if (histogram === undefined) return NO_LAG
144
150
  histogram.enable()
151
+ let latest = 0
145
152
  return () => {
146
153
  const meanNs = histogram.mean
154
+ // A histogram reads NaN until it records a sample after a reset: the window is still open.
155
+ if (!Number.isFinite(meanNs)) return latest
147
156
  histogram.reset()
148
- return Number.isFinite(meanNs) ? Math.max(0, meanNs / 1e6 - resolutionMs) : 0
157
+ latest = Math.max(0, meanNs / 1e6 - resolutionMs)
158
+ return latest
149
159
  }
150
160
  }
151
161
 
package/src/basic-auth.ts CHANGED
@@ -3,6 +3,7 @@ import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
3
  import {
4
4
  decodeBase64,
5
5
  guardName,
6
+ installAuthGuard,
6
7
  jsonError,
7
8
  type MaybePromise,
8
9
  quotedHeaderValue,
@@ -39,9 +40,9 @@ function reject(realm: string): Response {
39
40
  return jsonError(401, "unauthorized", { "www-authenticate": challenge(realm) })
40
41
  }
41
42
 
42
- function credentials(request: Request): { username: string; password: string } | null {
43
- const header = request.headers.get("authorization")
44
- if (header?.startsWith("Basic ") !== true) return null
43
+ function credentials(header: string | null): { username: string; password: string } | null {
44
+ // The auth-scheme is case-insensitive (RFC 9110 section 11.1): `basic` and `BASIC` are Basic too.
45
+ if (header == null || header.slice(0, 6).toLowerCase() !== "basic ") return null
45
46
  const raw = decodeBase64(header.slice(6).trim())
46
47
  if (raw === null) return null
47
48
  let decoded: string
@@ -89,16 +90,17 @@ export function basicAuth<P>(
89
90
  : staticVerifier(options.username, options.password, options.principal ?? options.username)
90
91
 
91
92
  const plugin = defineIdentityPlugin(guardName("basicAuth"), (app) =>
92
- app.beforeHandle(async (c: { readonly req: Request }) => {
93
- const parsed = credentials(c.req)
94
- const principal =
95
- parsed === null ? null : await (await verifyPromise)(parsed.username, parsed.password)
96
- if (principal !== null && principal !== undefined) {
97
- store.set(c.req, principal)
98
- return undefined
99
- }
100
- return optional ? undefined : reject(realm)
101
- }),
93
+ installAuthGuard(
94
+ app,
95
+ "basicAuth",
96
+ optional,
97
+ store,
98
+ async (header) => {
99
+ const parsed = credentials(header("authorization"))
100
+ return parsed === null ? null : (await verifyPromise)(parsed.username, parsed.password)
101
+ },
102
+ () => reject(realm),
103
+ ),
102
104
  )
103
105
  const instrumented = optional
104
106
  ? plugin
@@ -11,6 +11,9 @@ export interface CacheControlOptions {
11
11
  readonly respectExisting?: boolean
12
12
  }
13
13
 
14
+ // Named per call, so a second, differently configured instance applies instead of being deduped.
15
+ let instances = 0
16
+
14
17
  /**
15
18
  * Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
16
19
  * function of the request (return `undefined` to leave a response untouched - e.g. cache by path).
@@ -31,7 +34,7 @@ export function cacheControl(
31
34
  const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
32
35
  const statusOk = options.status ?? ((status: number) => status >= 200 && status < 300)
33
36
  const respectExisting = options.respectExisting !== false
34
- return defineRouterPlugin("cacheControl", (app) => {
37
+ return defineRouterPlugin(`cacheControl#${++instances}`, (app) => {
35
38
  // A fixed directive needs nothing from the request beyond the method, so it ships as one
36
39
  // portable header hook. A dynamic directive's resolver takes a real `Request`, which only the
37
40
  // Web response walk carries - it keeps the full `onResponse` contract (and its cost).
@@ -51,7 +54,7 @@ export function cacheControl(
51
54
  if (respectExisting && res.headers.has("cache-control")) return res
52
55
  const directive = value(req)
53
56
  if (directive === undefined) return res
54
- return withHeaders(res, (headers) => headers.set("cache-control", directive))
57
+ return withHeaders(res, (headers) => headers.set("cache-control", directive), req)
55
58
  },
56
59
  }
57
60
  return app.use(middleware)
package/src/cache.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Middleware } from "@nifrajs/core/server"
1
+ import { isAuthenticatedRequest, type Middleware } from "@nifrajs/core/server"
2
2
  import { type MaybePromise, withHeaders } from "./_utils.ts"
3
3
 
4
4
  export interface CachedResponse {
@@ -66,7 +66,7 @@ export class MemoryResponseCache implements ResponseCacheStore {
66
66
 
67
67
  export interface CacheOptions {
68
68
  readonly store: ResponseCacheStore
69
- /** Time to live in milliseconds. */
69
+ /** Maximum time to live in milliseconds; response freshness can shorten it. */
70
70
  readonly ttlMs: number
71
71
  /** Methods cached. Default `["GET", "HEAD"]`. */
72
72
  readonly methods?: readonly string[]
@@ -80,7 +80,8 @@ export interface CacheOptions {
80
80
  readonly maxBytes?: number
81
81
  /** Respect request `Cache-Control: no-cache/no-store`. Default `true`. */
82
82
  readonly respectRequestCacheControl?: boolean
83
- /** Respect response `Cache-Control: private/no-store`. Default `true`. */
83
+ /** Respect response private/no-store/no-cache and max-age/s-maxage freshness. Default `true`.
84
+ * Responses requiring validation bypass this cache, which has no revalidation transport. */
84
85
  readonly respectResponseCacheControl?: boolean
85
86
  /** Cache responses with `Set-Cookie`. Default `false`. */
86
87
  readonly cacheSetCookie?: boolean
@@ -89,7 +90,9 @@ export interface CacheOptions {
89
90
  * personalized response (RFC 9111 §3.5), or it would be replayed to other users. Leave this off unless
90
91
  * the cached route is genuinely the same for every caller. */
91
92
  readonly cacheAuthenticated?: boolean
92
- /** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key. */
93
+ /** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key.
94
+ * A response to a request an authentication stage accepted (or an integration marked with
95
+ * `markAuthenticatedRequest`) is treated as private too, whichever header carried the credential. */
93
96
  readonly authenticatedHeaders?: readonly string[]
94
97
  /** Response header for cache status. Default `"x-nifra-cache"`; set `false` to disable. */
95
98
  readonly cacheStatusHeader?: string | false
@@ -107,10 +110,30 @@ function cacheControlHas(headers: Headers, names: readonly string[]): boolean {
107
110
  const parts = value
108
111
  .toLowerCase()
109
112
  .split(",")
110
- .map((part) => part.trim().split("=", 1)[0])
113
+ .map((part) => part.trim().split("=", 1)[0]?.trim())
111
114
  return names.some((name) => parts.includes(name))
112
115
  }
113
116
 
117
+ function responseLifetimeMs(headers: Headers, ttlMs: number): number {
118
+ if (cacheControlHas(headers, ["private", "no-store", "no-cache"])) return 0
119
+ const directives = headers.get("cache-control")?.split(",") ?? []
120
+ // This is a shared cache: s-maxage takes precedence over max-age. Ambiguous freshness is stale.
121
+ for (const name of ["s-maxage", "max-age"]) {
122
+ const matches = directives.filter(
123
+ (part) => part.trim().split("=", 1)[0]?.trim().toLowerCase() === name,
124
+ )
125
+ if (matches.length === 0) continue
126
+ if (matches.length !== 1) return 0
127
+ const raw = matches[0]?.slice(matches[0].indexOf("=") + 1).trim() ?? ""
128
+ const value = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
129
+ const seconds = parseLength(value)
130
+ if (seconds === undefined || !Number.isFinite(seconds)) return 0
131
+ const age = parseLength(headers.get("age")) ?? 0
132
+ return Math.min(ttlMs, Math.max(0, seconds - age) * 1000)
133
+ }
134
+ return ttlMs
135
+ }
136
+
114
137
  function appendVary(headers: Headers, vary: readonly string[]): void {
115
138
  if (vary.length === 0) return
116
139
  const existing = headers
@@ -257,9 +280,10 @@ function withStatusHeader(
257
280
  res: Response,
258
281
  header: string | false,
259
282
  status: "HIT" | "MISS" | "BYPASS",
283
+ request?: Request,
260
284
  ): Response {
261
285
  if (header === false) return res
262
- return withHeaders(res, (headers) => headers.set(header, status))
286
+ return withHeaders(res, (headers) => headers.set(header, status), request)
263
287
  }
264
288
 
265
289
  /**
@@ -308,25 +332,33 @@ export function cache(options: CacheOptions): Middleware {
308
332
  requestKeys.set(req, key)
309
333
  const entry = await store.get(key)
310
334
  if (entry === undefined) return undefined
335
+ const cachedHeaders = new Headers([...entry.headers])
311
336
  // Never replay a personalized entry before route-scoped authentication runs. Public entries
312
337
  // remain eligible (and are explicitly marked by their response Cache-Control metadata).
313
338
  if (
314
339
  !cacheAuthenticated &&
315
340
  hasAuthentication(req) &&
316
- !cacheControlHas(new Headers(entry.headers as Array<[string, string]>), [
317
- "public",
318
- "s-maxage",
319
- ])
341
+ !cacheControlHas(cachedHeaders, ["public", "s-maxage"])
320
342
  ) {
321
343
  return undefined
322
344
  }
323
- if (Date.now() >= entry.expiresAt) {
345
+ const lifetimeMs = respectResponseCacheControl
346
+ ? responseLifetimeMs(cachedHeaders, ttlMs)
347
+ : ttlMs
348
+ if (
349
+ lifetimeMs === 0 ||
350
+ Date.now() >= Math.min(entry.expiresAt, entry.storedAt + lifetimeMs)
351
+ ) {
324
352
  await store.delete?.(key)
325
353
  return undefined
326
354
  }
327
355
  hits.add(req)
328
- const headers = new Headers([...entry.headers])
329
- headers.set("age", String(Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))))
356
+ const headers = cachedHeaders
357
+ const age = parseLength(headers.get("age")) ?? 0
358
+ headers.set(
359
+ "age",
360
+ String(age + Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))),
361
+ )
330
362
  if (cacheStatusHeader !== false) headers.set(cacheStatusHeader, "HIT")
331
363
  return new Response(req.method === "HEAD" ? null : entry.body.slice(), {
332
364
  status: entry.status,
@@ -343,46 +375,54 @@ export function cache(options: CacheOptions): Middleware {
343
375
  if (!methods.has(req.method.toUpperCase())) return res
344
376
  if (!statusOk(res.status)) return res
345
377
  if (respectRequestCacheControl && cacheControlHas(req.headers, ["no-store"])) {
346
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
378
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
347
379
  }
348
- if (respectResponseCacheControl && cacheControlHas(res.headers, ["private", "no-store"])) {
349
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
380
+ const lifetimeMs = respectResponseCacheControl
381
+ ? responseLifetimeMs(res.headers, ttlMs)
382
+ : ttlMs
383
+ if (lifetimeMs === 0) {
384
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
350
385
  }
351
386
  if (!cacheSetCookie && res.headers.has("set-cookie")) {
352
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
387
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
353
388
  }
354
389
  // A response-declared Vary must be represented in the lookup key. If the caller did not opt
355
390
  // those request headers into `vary`, bypass storage instead of replaying one representation to
356
391
  // another request. `Vary: *` is never cacheable by a shared cache.
357
392
  if (!responseVaryMatchesKey(res.headers, vary)) {
358
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
393
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
359
394
  }
360
395
  // Shared-cache safety (RFC 9111 §3.5): never store a response to an authenticated request
361
- // (Authorization or Cookie) unless it's explicitly public - otherwise a personalized 200 leaks
362
- // across users. Opt in with `cacheAuthenticated: true` for a route that's the same for everyone.
396
+ // unless it's explicitly public - otherwise a personalized 200 leaks across users. A credential
397
+ // header outside `authenticatedHeaders` is caught by the authentication stage that accepted it,
398
+ // because a hit is served before that stage runs. Opt in with `cacheAuthenticated: true` for a
399
+ // route that's the same for everyone.
363
400
  if (
364
401
  !cacheAuthenticated &&
365
- hasAuthentication(req) &&
402
+ (hasAuthentication(req) || isAuthenticatedRequest(req)) &&
366
403
  !cacheControlHas(res.headers, ["public", "s-maxage"])
367
404
  ) {
368
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
405
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
369
406
  }
370
407
  const declared = parseLength(res.headers.get("content-length"))
371
408
  if (declared !== undefined && declared > maxBytes) {
372
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
409
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
373
410
  }
411
+ const storedAt = Date.now()
374
412
  const captured = await readBytesCapped(res, maxBytes)
375
413
  if (!("bytes" in captured)) {
376
- return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS")
414
+ return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS", req)
377
415
  }
378
416
  const body = captured.bytes
379
417
  const capturedResponse = captured.response
418
+ const expiresAt = storedAt + lifetimeMs
419
+ if (Date.now() >= expiresAt) {
420
+ return withStatusHeader(capturedResponse, cacheStatusHeader, "BYPASS", req)
421
+ }
380
422
 
381
423
  const headers = new Headers(capturedResponse.headers)
382
- headers.delete("age")
383
424
  if (cacheStatusHeader !== false) headers.delete(cacheStatusHeader)
384
425
  appendVary(headers, vary)
385
- const now = Date.now()
386
426
  const key = requestKeys.get(req) ?? keyOf(req)
387
427
  requestKeys.delete(req)
388
428
  await store.set(key, {
@@ -390,8 +430,8 @@ export function cache(options: CacheOptions): Middleware {
390
430
  statusText: res.statusText,
391
431
  headers: [...headers.entries()],
392
432
  body,
393
- expiresAt: now + ttlMs,
394
- storedAt: now,
433
+ expiresAt,
434
+ storedAt,
395
435
  })
396
436
  const outgoing = new Headers(capturedResponse.headers)
397
437
  appendVary(outgoing, vary)
@@ -179,10 +179,90 @@ async function compressRawBody(
179
179
  },
180
180
  cancel: (reason) => reader.cancel(reason),
181
181
  })
182
- return new Response(source.pipeThrough(new CompressionStream("gzip")), {
183
- status: response.status,
184
- statusText: response.statusText,
185
- headers: gzipHeaders(response.headers),
182
+ const zlib = zlibModule()
183
+ return new Response(
184
+ zlib === undefined
185
+ ? source.pipeThrough(new CompressionStream("gzip"))
186
+ : syncFlushedGzip(zlib, source),
187
+ {
188
+ status: response.status,
189
+ statusText: response.statusText,
190
+ headers: gzipHeaders(response.headers),
191
+ },
192
+ )
193
+ }
194
+
195
+ interface GzipStream {
196
+ write(chunk: Uint8Array): boolean
197
+ flush(kind: number, callback: () => void): void
198
+ end(): void
199
+ destroy(): void
200
+ on(event: "data", listener: (chunk: Uint8Array) => void): unknown
201
+ once(event: "end" | "error", listener: (error?: unknown) => void): unknown
202
+ }
203
+
204
+ interface Zlib {
205
+ createGzip(): GzipStream
206
+ readonly constants: { readonly Z_SYNC_FLUSH: number }
207
+ }
208
+
209
+ const isZlib = (value: unknown): value is Zlib =>
210
+ typeof value === "object" &&
211
+ value !== null &&
212
+ "createGzip" in value &&
213
+ typeof value.createGzip === "function" &&
214
+ "constants" in value &&
215
+ typeof value.constants === "object" &&
216
+ value.constants !== null &&
217
+ "Z_SYNC_FLUSH" in value.constants
218
+
219
+ let zlibLookup: { readonly zlib: Zlib | undefined } | undefined
220
+
221
+ function zlibModule(): Zlib | undefined {
222
+ if (zlibLookup !== undefined) return zlibLookup.zlib
223
+ let zlib: Zlib | undefined
224
+ try {
225
+ const proc: unknown = Reflect.get(globalThis, "process")
226
+ const load: unknown =
227
+ typeof proc === "object" && proc !== null ? Reflect.get(proc, "getBuiltinModule") : undefined
228
+ const module: unknown = typeof load === "function" ? load.call(proc, "node:zlib") : undefined
229
+ if (isZlib(module)) zlib = module
230
+ } catch {}
231
+ zlibLookup = { zlib }
232
+ return zlib
233
+ }
234
+
235
+ /** `CompressionStream` holds deflate output until its window fills or the input closes, so a
236
+ * streamed shell would not reach the client until the whole page did. A sync flush after each
237
+ * upstream chunk emits what that chunk compressed to, at the cost of a few bytes per flush. */
238
+ function syncFlushedGzip(
239
+ zlib: Zlib,
240
+ source: ReadableStream<Uint8Array>,
241
+ ): ReadableStream<Uint8Array> {
242
+ const reader = source.getReader()
243
+ const gzip = zlib.createGzip()
244
+ return new ReadableStream<Uint8Array>({
245
+ start(controller) {
246
+ gzip.on("data", (chunk) => controller.enqueue(chunk))
247
+ gzip.once("error", (error) => controller.error(error))
248
+ },
249
+ async pull(controller) {
250
+ const { done, value } = await reader.read()
251
+ if (done) {
252
+ await new Promise((resolve) => {
253
+ gzip.once("end", resolve)
254
+ gzip.end()
255
+ })
256
+ controller.close()
257
+ return
258
+ }
259
+ gzip.write(value)
260
+ await new Promise<void>((resolve) => gzip.flush(zlib.constants.Z_SYNC_FLUSH, resolve))
261
+ },
262
+ cancel(reason) {
263
+ gzip.destroy()
264
+ return reader.cancel(reason)
265
+ },
186
266
  })
187
267
  }
188
268
 
@@ -55,10 +55,14 @@ export function conditionalResponse(
55
55
  if (response.status < 200 || response.status >= 300) return response
56
56
  if (!isFresh(request, options)) {
57
57
  if (options.etag === undefined && modified === undefined) return response
58
- return withHeaders(response, (headers) => {
59
- if (options.etag !== undefined) headers.set("etag", options.etag)
60
- if (modified !== undefined) headers.set("last-modified", modified)
61
- })
58
+ return withHeaders(
59
+ response,
60
+ (headers) => {
61
+ if (options.etag !== undefined) headers.set("etag", options.etag)
62
+ if (modified !== undefined) headers.set("last-modified", modified)
63
+ },
64
+ request,
65
+ )
62
66
  }
63
67
  const headers = new Headers(response.headers)
64
68
  if (options.etag !== undefined) headers.set("etag", options.etag)
@@ -37,7 +37,9 @@ function parseMediaType(value: string, qDefault = 1): ParsedMediaType | undefine
37
37
  if (slash <= 0 || slash === media.length - 1) return undefined
38
38
  const type = media.slice(0, slash)
39
39
  const subtype = media.slice(slash + 1)
40
- if (!/^[\w!#$&^_.+-]+$/.test(type) || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
40
+ // `*/*` is the one legal type wildcard; `*/json` names no media range.
41
+ const typeOk = type === "*" ? subtype === "*" : /^[\w!#$&^_.+-]+$/.test(type)
42
+ if (!typeOk || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
41
43
 
42
44
  let q = qDefault
43
45
  const parameters = new Map<string, string>()