@nifrajs/middleware 4.0.2 → 5.0.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 (81) 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/basic-auth.d.ts.map +1 -1
  7. package/dist/basic-auth.js +8 -13
  8. package/dist/basic-auth.js.map +1 -1
  9. package/dist/cache-control.d.ts.map +1 -1
  10. package/dist/cache-control.js +4 -2
  11. package/dist/cache-control.js.map +1 -1
  12. package/dist/cache.d.ts +7 -4
  13. package/dist/cache.d.ts.map +1 -1
  14. package/dist/cache.js +58 -25
  15. package/dist/cache.js.map +1 -1
  16. package/dist/compression.d.ts.map +1 -1
  17. package/dist/compression.js +58 -1
  18. package/dist/compression.js.map +1 -1
  19. package/dist/conditional.d.ts.map +1 -1
  20. package/dist/conditional.js +1 -1
  21. package/dist/conditional.js.map +1 -1
  22. package/dist/content-negotiation.d.ts.map +1 -1
  23. package/dist/content-negotiation.js +3 -1
  24. package/dist/content-negotiation.js.map +1 -1
  25. package/dist/csrf.d.ts.map +1 -1
  26. package/dist/csrf.js +10 -5
  27. package/dist/csrf.js.map +1 -1
  28. package/dist/healthcheck.d.ts.map +1 -1
  29. package/dist/healthcheck.js +3 -1
  30. package/dist/healthcheck.js.map +1 -1
  31. package/dist/idempotency.d.ts +10 -2
  32. package/dist/idempotency.d.ts.map +1 -1
  33. package/dist/idempotency.js +8 -4
  34. package/dist/idempotency.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/jwt.d.ts +10 -2
  40. package/dist/jwt.d.ts.map +1 -1
  41. package/dist/jwt.js +52 -31
  42. package/dist/jwt.js.map +1 -1
  43. package/dist/language.d.ts.map +1 -1
  44. package/dist/language.js +12 -0
  45. package/dist/language.js.map +1 -1
  46. package/dist/openapi.d.ts +3 -0
  47. package/dist/openapi.d.ts.map +1 -1
  48. package/dist/openapi.js +4 -1
  49. package/dist/openapi.js.map +1 -1
  50. package/dist/rate-limit.d.ts +31 -0
  51. package/dist/rate-limit.d.ts.map +1 -1
  52. package/dist/rate-limit.js +160 -26
  53. package/dist/rate-limit.js.map +1 -1
  54. package/dist/request-id.d.ts.map +1 -1
  55. package/dist/request-id.js +1 -1
  56. package/dist/request-id.js.map +1 -1
  57. package/dist/timing.d.ts.map +1 -1
  58. package/dist/timing.js +1 -1
  59. package/dist/timing.js.map +1 -1
  60. package/dist/token-auth.d.ts.map +1 -1
  61. package/dist/token-auth.js +8 -13
  62. package/dist/token-auth.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/_utils.ts +61 -35
  65. package/src/basic-auth.ts +15 -13
  66. package/src/cache-control.ts +5 -2
  67. package/src/cache.ts +68 -28
  68. package/src/compression.ts +84 -4
  69. package/src/conditional.ts +8 -4
  70. package/src/content-negotiation.ts +3 -1
  71. package/src/csrf.ts +10 -6
  72. package/src/healthcheck.ts +4 -1
  73. package/src/idempotency.ts +30 -7
  74. package/src/index.ts +3 -0
  75. package/src/jwt.ts +76 -34
  76. package/src/language.ts +11 -0
  77. package/src/openapi.ts +13 -2
  78. package/src/rate-limit.ts +233 -41
  79. package/src/request-id.ts +7 -3
  80. package/src/timing.ts +8 -4
  81. package/src/token-auth.ts +18 -16
package/src/jwt.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  base64UrlDecode,
5
5
  bearerToken,
6
6
  guardName,
7
+ type HeaderGet,
8
+ installAuthGuard,
7
9
  jsonError,
8
10
  type MaybePromise,
9
11
  parseCookies,
@@ -88,12 +90,20 @@ export interface JwksOptions {
88
90
  readonly fetch?: typeof fetch
89
91
  readonly cacheMs?: number
90
92
  /**
91
- * Extra time to keep using the last successful key set when refresh fails.
92
- * Defaults to one cache window; set `0` to fail closed immediately on refresh errors.
93
+ * Extra time to keep using the last successful key set past `cacheMs`. Inside it a request is
94
+ * answered from that set at once while one refresh runs behind it, so a slow or failing identity
95
+ * provider holds no request. Defaults to one cache window; set `0` to wait for every refresh and
96
+ * fail closed on refresh errors.
93
97
  */
94
98
  readonly staleMs?: number
95
99
  readonly timeoutMs?: number
96
100
  readonly maxBytes?: number
101
+ /**
102
+ * Least time (ms) between the refreshes a token with a `kid` the cached set lacks may start, so a
103
+ * newly rotated key is picked up without made-up `kid`s turning every request into a fetch.
104
+ * Default 30 s.
105
+ */
106
+ readonly cooldownMs?: number
97
107
  }
98
108
 
99
109
  const HASH: Record<JwtAlgorithm, "SHA-256" | "SHA-384" | "SHA-512"> = {
@@ -300,15 +310,19 @@ export async function tryVerifyJwt<C extends JwtClaims = JwtClaims>(
300
310
  }
301
311
  }
302
312
 
303
- function tokenFromRequest(req: Request, header: string, cookie: string | undefined): string | null {
304
- const headerValue = req.headers.get(header)
313
+ function tokenFromHeaders(
314
+ get: HeaderGet,
315
+ header: string,
316
+ cookie: string | undefined,
317
+ ): string | null {
318
+ const headerValue = get(header)
305
319
  if (headerValue !== null) {
306
320
  if (header === "authorization") {
307
321
  return bearerToken(headerValue)
308
322
  }
309
323
  return headerValue.trim() || null
310
324
  }
311
- return cookie === undefined ? null : (parseCookies(req.headers.get("cookie"))[cookie] ?? null)
325
+ return cookie === undefined ? null : (parseCookies(get("cookie"))[cookie] ?? null)
312
326
  }
313
327
 
314
328
  function challenge(realm: string): string {
@@ -327,17 +341,23 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
327
341
  const store = new WeakMap<Request, C>()
328
342
 
329
343
  const plugin = defineIdentityPlugin(guardName("jwt"), (app) =>
330
- app.beforeHandle(async (c: { readonly req: Request }) => {
331
- const token = tokenFromRequest(c.req, header, options.cookie)
332
- if (token === null) return optional ? undefined : reject(realm)
333
- try {
334
- const verified = await verifyJwt<C>(token, options)
335
- store.set(c.req, verified.claims)
336
- return undefined
337
- } catch {
338
- return optional ? undefined : reject(realm)
339
- }
340
- }),
344
+ installAuthGuard(
345
+ app,
346
+ "jwt",
347
+ optional,
348
+ store,
349
+ async (get) => {
350
+ const token = tokenFromHeaders(get, header, options.cookie)
351
+ if (token === null) return null
352
+ try {
353
+ return (await verifyJwt<C>(token, options)).claims
354
+ } catch {
355
+ // A token that fails verification is no credential: a 401, never a 503.
356
+ return null
357
+ }
358
+ },
359
+ () => reject(realm),
360
+ ),
341
361
  )
342
362
  const instrumented = optional
343
363
  ? plugin
@@ -429,6 +449,9 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
429
449
  const staleMs = options.staleMs ?? cacheMs
430
450
  const timeoutMs = options.timeoutMs ?? 5_000
431
451
  const maxBytes = options.maxBytes ?? 65_536
452
+ const cooldownMs = options.cooldownMs ?? 30_000
453
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0)
454
+ throw new Error("jwks: cooldownMs must be non-negative")
432
455
  if (!Number.isFinite(cacheMs) || cacheMs < 0)
433
456
  throw new Error("jwks: cacheMs must be non-negative")
434
457
  if (!Number.isFinite(staleMs) || staleMs < 0)
@@ -440,30 +463,49 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
440
463
  }
441
464
  let cached: readonly JwkKey[] = []
442
465
  let cachedAt = 0
466
+ let refreshedAt = Number.NEGATIVE_INFINITY
443
467
  let pending: Promise<readonly JwkKey[]> | undefined
444
468
 
445
- const load = async (): Promise<readonly JwkKey[]> => {
446
- const now = Date.now()
447
- if (cached.length > 0 && now - cachedAt < cacheMs) return cached
448
- pending ??= fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes }).finally(() => {
449
- pending = undefined
450
- })
451
- try {
452
- cached = await pending
453
- cachedAt = Date.now()
454
- return cached
455
- } catch (error) {
456
- if (cached.length > 0 && staleMs > 0 && Date.now() - cachedAt < cacheMs + staleMs) {
457
- return cached
458
- }
459
- throw error
469
+ const refresh = (): Promise<readonly JwkKey[]> => {
470
+ if (pending === undefined) {
471
+ refreshedAt = Date.now()
472
+ pending = fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes })
473
+ .then((keys) => {
474
+ cached = keys
475
+ cachedAt = Date.now()
476
+ return keys
477
+ })
478
+ .finally(() => {
479
+ pending = undefined
480
+ })
460
481
  }
482
+ return pending
483
+ }
484
+
485
+ const load = (): Promise<readonly JwkKey[]> => {
486
+ const age = Date.now() - cachedAt
487
+ if (cached.length > 0 && age < cacheMs) return Promise.resolve(cached)
488
+ if (cached.length > 0 && age < cacheMs + staleMs) {
489
+ refresh().catch(() => {})
490
+ return Promise.resolve(cached)
491
+ }
492
+ return refresh()
461
493
  }
462
494
 
463
495
  return async (header) => {
464
- if (typeof header.kid !== "string" || header.kid === "") return null
465
- const keys = await load()
496
+ const kid = header.kid
497
+ if (typeof kid !== "string" || kid === "") return null
466
498
  const alg = header.alg as JwtAlgorithm
467
- return keys.find((key) => key.kid === header.kid && validateJwkForAlg(key, alg, header.kid))
499
+ const find = (keys: readonly JwkKey[]): JwkKey | undefined =>
500
+ keys.find((key) => key.kid === kid && validateJwkForAlg(key, alg, kid))
501
+ const found = find(await load())
502
+ if (found !== undefined) return found
503
+ // A kid the set lacks may be a key the provider just rotated in.
504
+ if (pending === undefined && Date.now() - refreshedAt < cooldownMs) return undefined
505
+ try {
506
+ return find(await refresh())
507
+ } catch {
508
+ return undefined
509
+ }
468
510
  }
469
511
  }
package/src/language.ts CHANGED
@@ -81,6 +81,15 @@ export function pickLanguage<const L extends readonly string[]>(
81
81
  return { language: defaultLanguage, matched: "default" }
82
82
  }
83
83
 
84
+ /** `value` naming each of `names` once; `Vary: *` already covers every request header. */
85
+ function varyOn(value: string | null, names: readonly string[]): string {
86
+ if (value === null || value.trim() === "") return names.join(", ")
87
+ const fields = value.split(",").map((field) => field.trim().toLowerCase())
88
+ if (fields.includes("*")) return value
89
+ const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
90
+ return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
91
+ }
92
+
84
93
  /**
85
94
  * Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
86
95
  *
@@ -114,6 +123,8 @@ export function language<const L extends readonly string[]>(
114
123
  .use(
115
124
  withResponseObserver<Middleware>({
116
125
  onResponseHeaders(headers, req) {
126
+ // The language is chosen from Accept-Language, so a shared cache must key on it.
127
+ headers.set("vary", varyOn(headers.get("vary"), ["Accept-Language"]))
117
128
  if (!emitHeader) return
118
129
  if (headers.has("content-language")) return
119
130
  const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
package/src/openapi.ts CHANGED
@@ -11,7 +11,12 @@ export interface RouteLike {
11
11
  // `| undefined` (not just `?`) so a core `RouteDescriptor` - whose `schema` is a *required*
12
12
  // `RouteSchema | undefined` - is assignable under `exactOptionalPropertyTypes`.
13
13
  readonly schema?:
14
- | { readonly body?: unknown; readonly query?: unknown; readonly response?: unknown }
14
+ | {
15
+ readonly body?: unknown
16
+ readonly query?: unknown
17
+ readonly response?: unknown
18
+ readonly sse?: unknown
19
+ }
15
20
  | undefined
16
21
  }
17
22
 
@@ -45,6 +50,8 @@ export interface OpenApiUiOptions {
45
50
  }
46
51
 
47
52
  export interface OpenApiOptions {
53
+ /** The OpenAPI version to declare. `"3.2.0"` describes each SSE event with the standard `itemSchema`. */
54
+ readonly openapiVersion?: "3.1.0" | "3.2.0"
48
55
  readonly info?: OpenApiInfo
49
56
  readonly servers?: readonly OpenApiServer[]
50
57
  /** Tag definitions (top-level `tags`). Reference them from an operation via `operations`. */
@@ -91,6 +98,7 @@ export function buildOpenApiDocument(
91
98
  // and the shim only satisfies it structurally.
92
99
  const appShim = { routes: () => included } as unknown as Parameters<typeof toOpenAPI>[0]
93
100
  return toOpenAPI(appShim, {
101
+ ...(options.openapiVersion !== undefined ? { openapiVersion: options.openapiVersion } : {}),
94
102
  title: options.info?.title ?? "nifra API",
95
103
  version: options.info?.version ?? "0.0.0",
96
104
  ...(options.info?.description !== undefined ? { description: options.info.description } : {}),
@@ -104,6 +112,9 @@ export function buildOpenApiDocument(
104
112
 
105
113
  const SCALAR_CDN = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
106
114
 
115
+ // Named per call, so a second, differently configured instance applies instead of being deduped.
116
+ let instances = 0
117
+
107
118
  /**
108
119
  * Minimal HTML that mounts the Scalar API reference against a spec URL. The interpolated values are
109
120
  * dev-provided config (not request input), but they're still escaped so a stray quote or `<` can't
@@ -149,7 +160,7 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
149
160
  let uiCached: string | undefined
150
161
  // A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
151
162
  // app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
152
- return defineRouterPlugin("openapi", (app) => {
163
+ return defineRouterPlugin(`openapi#${++instances}`, (app) => {
153
164
  // Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
154
165
  // routes lazily, at request time - so all routes exist regardless of where the plugin sits.
155
166
  app.register("GET", docPath, undefined, () => {
package/src/rate-limit.ts CHANGED
@@ -164,6 +164,27 @@ export interface RateLimitOptions {
164
164
  * when none is declared).
165
165
  */
166
166
  readonly key?: (req: Request, platform?: Platform) => string
167
+ /**
168
+ * Leave a request uncharged: it is not counted and carries no `RateLimit-*` headers. Runs before
169
+ * the key is derived, so a health check or an internal probe never touches the store.
170
+ */
171
+ readonly skip?: (req: Request, platform?: Platform) => boolean
172
+ }
173
+
174
+ /** One bucket of a {@link rateLimitTiers} limiter: the requests at `prefix` or below it. */
175
+ export interface RateLimitTier {
176
+ /** Static path prefix such as `/api/public`: no params, wildcards, dot segments or trailing slash. */
177
+ readonly prefix: string
178
+ readonly max: number
179
+ readonly windowMs: number
180
+ /** Bucket key for this tier only. Default: the limiter's `key`, or the derived client IP. */
181
+ readonly key?: (req: Request, platform?: Platform) => string
182
+ }
183
+
184
+ export interface RateLimitTiersOptions extends Omit<RateLimitOptions, "max" | "windowMs"> {
185
+ /** The bucket for every request no tier claims. */
186
+ readonly floor: { readonly max: number; readonly windowMs: number }
187
+ readonly tiers: readonly RateLimitTier[]
167
188
  }
168
189
 
169
190
  /**
@@ -230,49 +251,76 @@ function nativeKey(
230
251
  return allowGlobalKey ? "global" : null
231
252
  }
232
253
 
233
- /**
234
- * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
235
- * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
236
- * `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
237
- */
238
- export function rateLimit(options: RateLimitOptions): Middleware {
239
- const { store, max, windowMs } = options
240
- if (!Number.isInteger(max) || max < 1)
241
- throw new Error("rateLimit: max must be a positive integer")
254
+ interface QuotaInfo {
255
+ readonly limit: number
256
+ readonly remaining: number
257
+ readonly resetSeconds: number
258
+ }
259
+
260
+ /** A counter a request can be charged to. `scope` prefixes its store keys so buckets never share one. */
261
+ interface Bucket {
262
+ readonly max: number
263
+ readonly windowMs: number
264
+ readonly scope: string
265
+ readonly key: (req: Request, platform?: Platform) => string | null
266
+ readonly nativeKey: ((req: NodeRequestContext, platform?: Platform) => string | null) | undefined
267
+ }
268
+
269
+ function assertWindow(max: number, windowMs: number, label: string): void {
270
+ if (!Number.isInteger(max) || max < 1) throw new Error(`${label}: max must be a positive integer`)
242
271
  if (!Number.isFinite(windowMs) || windowMs <= 0) {
243
- throw new Error("rateLimit: windowMs must be a positive number")
272
+ throw new Error(`${label}: windowMs must be a positive number`)
244
273
  }
274
+ }
275
+
276
+ /** The shared keying options, validated once, as the default key pair every bucket starts from. */
277
+ function defaultKeys(
278
+ options: RateLimitOptions | RateLimitTiersOptions,
279
+ label: string,
280
+ ): Pick<Bucket, "key" | "nativeKey"> {
245
281
  const trustedProxies = options.trustedProxies ?? 0
246
282
  if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
247
- throw new Error("rateLimit: trustedProxies must be a non-negative integer")
283
+ throw new Error(`${label}: trustedProxies must be a non-negative integer`)
248
284
  }
249
285
  const header = options.header?.trim().toLowerCase()
250
- if (header !== undefined && header.trim() === "") throw new Error("rateLimit: header is empty")
286
+ if (header !== undefined && header.trim() === "") throw new Error(`${label}: header is empty`)
251
287
  const allowGlobalKey = options.allowGlobalKey === true
252
- const keyOf =
253
- options.key ??
254
- ((req: Request, platform?: Platform) =>
255
- defaultKey(req, platform, trustedProxies, header, allowGlobalKey))
256
- const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
288
+ if (options.key !== undefined) return { key: options.key, nativeKey: undefined }
289
+ return {
290
+ key: (req, platform) => defaultKey(req, platform, trustedProxies, header, allowGlobalKey),
291
+ nativeKey: (req, platform) => nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The limiter both public factories build: `bucketFor` picks the one bucket a request is charged to,
297
+ * from its pathname when `pathAware`. The Node twins exist only when every bucket can key the
298
+ * allocation-light native request and nothing needs a real `Request` (`skip`, a custom `key`).
299
+ */
300
+ function limiter(
301
+ name: string,
302
+ store: RateLimitStore,
303
+ buckets: readonly Bucket[],
304
+ bucketFor: (pathname: string) => Bucket,
305
+ pathAware: boolean,
306
+ skip: RateLimitOptions["skip"],
307
+ ): Middleware {
308
+ const quota = new WeakMap<Request, QuotaInfo>()
257
309
  // State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
258
310
  // to the request and response twins - the core identity contract that replaces `Request` keying).
259
- const nativeQuota = new WeakMap<object, { remaining: number; resetSeconds: number }>()
260
-
261
- interface QuotaInfo {
262
- readonly remaining: number
263
- readonly resetSeconds: number
264
- }
311
+ const nativeQuota = new WeakMap<object, QuotaInfo>()
265
312
 
266
- const hitStore = async (
313
+ const charge = async (
314
+ bucket: Bucket,
267
315
  key: string | null,
268
316
  ): Promise<{ info: QuotaInfo; reject: Response | undefined } | Response> => {
269
317
  if (typeof key !== "string" || key.trim() === "") {
270
318
  return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
271
319
  }
272
- const { count, resetAt } = await store.hit(key, windowMs)
320
+ const { count, resetAt } = await store.hit(bucket.scope + key, bucket.windowMs)
273
321
  const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
274
- const info = { remaining: Math.max(0, max - count), resetSeconds }
275
- if (count > max) {
322
+ const info = { limit: bucket.max, remaining: Math.max(0, bucket.max - count), resetSeconds }
323
+ if (count > bucket.max) {
276
324
  return {
277
325
  info,
278
326
  reject: new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
@@ -284,10 +332,13 @@ export function rateLimit(options: RateLimitOptions): Middleware {
284
332
  return { info, reject: undefined }
285
333
  }
286
334
 
335
+ const native = skip === undefined && buckets.every((bucket) => bucket.nativeKey !== undefined)
287
336
  const middleware: Middleware = {
288
- name: guardName("rate-limit"),
337
+ name,
289
338
  async onRequest(req, platform) {
290
- const outcome = await hitStore(keyOf(req, platform))
339
+ if (skip?.(req, platform) === true) return undefined
340
+ const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
341
+ const outcome = await charge(bucket, bucket.key(req, platform))
291
342
  if (outcome instanceof Response) return outcome
292
343
  quota.set(req, outcome.info)
293
344
  return outcome.reject
@@ -296,20 +347,21 @@ export function rateLimit(options: RateLimitOptions): Middleware {
296
347
  const info = quota.get(req)
297
348
  if (info === undefined) return res
298
349
  quota.delete(req)
299
- return withHeaders(res, (headers) => {
300
- headers.set("RateLimit-Limit", String(max))
301
- headers.set("RateLimit-Remaining", String(info.remaining))
302
- headers.set("RateLimit-Reset", String(info.resetSeconds))
303
- })
350
+ return withHeaders(
351
+ res,
352
+ (headers) => {
353
+ headers.set("RateLimit-Limit", String(info.limit))
354
+ headers.set("RateLimit-Remaining", String(info.remaining))
355
+ headers.set("RateLimit-Reset", String(info.resetSeconds))
356
+ },
357
+ req,
358
+ )
304
359
  },
305
- // Node twins exist only for the built-in header/XFF key derivation - a custom `key` callback
306
- // takes a real `Request`, which is exactly the object the native lane avoids building.
307
- ...(options.key === undefined
360
+ ...(native
308
361
  ? {
309
362
  onNodeRequest: async (req: NodeRequestContext, platform?: Platform) => {
310
- const outcome = await hitStore(
311
- nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
312
- )
363
+ const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
364
+ const outcome = await charge(bucket, bucket.nativeKey?.(req, platform) ?? null)
313
365
  if (outcome instanceof Response) return outcome
314
366
  nativeQuota.set(req, outcome.info)
315
367
  return outcome.reject
@@ -320,16 +372,156 @@ export function rateLimit(options: RateLimitOptions): Middleware {
320
372
  nativeQuota.delete(req)
321
373
  // Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so this
322
374
  // is byte-identical output.
323
- setNodeHeader(res, "ratelimit-limit", String(max))
375
+ setNodeHeader(res, "ratelimit-limit", String(info.limit))
324
376
  setNodeHeader(res, "ratelimit-remaining", String(info.remaining))
325
377
  setNodeHeader(res, "ratelimit-reset", String(info.resetSeconds))
326
378
  },
327
379
  }
328
380
  : {}),
329
381
  }
382
+ return middleware
383
+ }
384
+
385
+ /**
386
+ * The pathname the router matches: an absolute URL's path or a raw request target, without query
387
+ * or fragment. A dot segment is resolved the way the server resolves it before routing.
388
+ */
389
+ function pathnameOf(url: string): string {
390
+ let start = 0
391
+ if (!url.startsWith("/")) {
392
+ const authority = url.indexOf("//")
393
+ const slash = url.indexOf("/", authority === -1 ? 0 : authority + 2)
394
+ if (slash === -1) return "/"
395
+ start = slash
396
+ }
397
+ let end = url.length
398
+ const query = url.indexOf("?", start)
399
+ if (query !== -1) end = query
400
+ const hash = url.indexOf("#", start)
401
+ if (hash !== -1 && hash < end) end = hash
402
+ const path = url.slice(start, end)
403
+ return DOT_SEGMENT.test(path) ? new URL(path, "http://n").pathname : path
404
+ }
405
+
406
+ const DOT_SEGMENT = /\/(?:\.|%2e)/i
407
+
408
+ /**
409
+ * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
410
+ * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
411
+ * `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
412
+ */
413
+ export function rateLimit(options: RateLimitOptions): Middleware {
414
+ assertWindow(options.max, options.windowMs, "rateLimit")
415
+ if (options.skip !== undefined && typeof options.skip !== "function") {
416
+ throw new TypeError("rateLimit: skip must be a function")
417
+ }
418
+ const bucket: Bucket = {
419
+ max: options.max,
420
+ windowMs: options.windowMs,
421
+ scope: "",
422
+ ...defaultKeys(options, "rateLimit"),
423
+ }
424
+ const middleware = limiter(
425
+ guardName("rate-limit"),
426
+ options.store,
427
+ [bucket],
428
+ () => bucket,
429
+ false,
430
+ options.skip,
431
+ )
330
432
  return withRouteAssurance(middleware, {
331
433
  id: NIFRA_ASSURANCE.RATE_LIMITED,
332
434
  source: "rate-limit",
333
435
  scope: "global",
334
436
  })
335
437
  }
438
+
439
+ const STATIC_PREFIX = /^(?:\/[A-Za-z0-9\-._~%!$&'()+,;=@]+)+$/
440
+
441
+ function assertTierPrefix(prefix: unknown): asserts prefix is string {
442
+ const bad = (why: string): never => {
443
+ throw new Error(`rateLimitTiers: tier prefix ${JSON.stringify(prefix)} ${why}`)
444
+ }
445
+ if (typeof prefix !== "string" || !STATIC_PREFIX.test(prefix)) {
446
+ return bad('must be a static path such as "/api/public", with no trailing slash')
447
+ }
448
+ for (const segment of prefix.split("/").slice(1)) {
449
+ if (segment === "." || segment === "..") bad("must not contain a dot segment")
450
+ if (segment.startsWith(":") || segment.startsWith("*"))
451
+ bad("must be static: no params or wildcards")
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Rate limiting in tiers under one app-wide floor, as a single {@link Middleware}. Every request is
457
+ * charged to exactly one bucket: the tier with the longest `prefix` that is the request's path or an
458
+ * ancestor of it, else the `floor`. Tiers never stack, so a tier may sit above the floor
459
+ * (`600/min` for `/api/public` under a `120/min` floor). It runs in `onRequest`, before routing, so
460
+ * a 404, a 405 or a body that fails validation is charged too. Prefixes are validated here; each
461
+ * tier counts in its own keys, so tiers can share one store.
462
+ */
463
+ export function rateLimitTiers(options: RateLimitTiersOptions): Middleware {
464
+ const label = "rateLimitTiers"
465
+ if (options.floor === null || typeof options.floor !== "object") {
466
+ throw new TypeError(`${label}: floor must be { max, windowMs }`)
467
+ }
468
+ if (!Array.isArray(options.tiers)) throw new TypeError(`${label}: tiers must be an array`)
469
+ if (options.skip !== undefined && typeof options.skip !== "function") {
470
+ throw new TypeError(`${label}: skip must be a function`)
471
+ }
472
+ assertWindow(options.floor.max, options.floor.windowMs, `${label} floor`)
473
+ const keys = defaultKeys(options, label)
474
+ const floor: Bucket = {
475
+ max: options.floor.max,
476
+ windowMs: options.floor.windowMs,
477
+ scope: "floor:",
478
+ ...keys,
479
+ }
480
+ const seen = new Set<string>()
481
+ const tiers: (Bucket & { readonly prefix: string })[] = []
482
+ for (const tier of options.tiers) {
483
+ assertTierPrefix(tier.prefix)
484
+ if (seen.has(tier.prefix)) {
485
+ throw new Error(`${label}: duplicate tier prefix ${JSON.stringify(tier.prefix)}`)
486
+ }
487
+ seen.add(tier.prefix)
488
+ assertWindow(tier.max, tier.windowMs, `${label} tier ${tier.prefix}`)
489
+ if (tier.key !== undefined && typeof tier.key !== "function") {
490
+ throw new TypeError(`${label}: tier ${tier.prefix} key must be a function`)
491
+ }
492
+ tiers.push({
493
+ prefix: tier.prefix,
494
+ max: tier.max,
495
+ windowMs: tier.windowMs,
496
+ scope: `tier:${tier.prefix}:`,
497
+ key: tier.key ?? keys.key,
498
+ nativeKey: tier.key === undefined ? keys.nativeKey : undefined,
499
+ })
500
+ }
501
+ // Longest first, so the first tier whose prefix holds the path is the most specific one.
502
+ tiers.sort((a, b) => b.prefix.length - a.prefix.length)
503
+ const bucketFor = (pathname: string): Bucket => {
504
+ for (const tier of tiers) {
505
+ if (
506
+ pathname.startsWith(tier.prefix) &&
507
+ (pathname.length === tier.prefix.length || pathname.charCodeAt(tier.prefix.length) === 47)
508
+ ) {
509
+ return tier
510
+ }
511
+ }
512
+ return floor
513
+ }
514
+ const middleware = limiter(
515
+ guardName("rate-limit-tiers"),
516
+ options.store,
517
+ [floor, ...tiers],
518
+ bucketFor,
519
+ true,
520
+ options.skip,
521
+ )
522
+ return withRouteAssurance(middleware, {
523
+ id: NIFRA_ASSURANCE.RATE_LIMITED,
524
+ source: "rate-limit-tiers",
525
+ scope: "global",
526
+ })
527
+ }
package/src/request-id.ts CHANGED
@@ -65,9 +65,13 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
65
65
  // The id the derive generated; else the inbound header, then a fresh id - so a response
66
66
  // no derive saw (a 404, an earlier middleware's short-circuit) is still covered.
67
67
  const echo = id ?? inboundOf(req.headers.get(header)) ?? generate()
68
- return withHeaders(res, (headers) => {
69
- headers.set(header, echo)
70
- })
68
+ return withHeaders(
69
+ res,
70
+ (headers) => {
71
+ headers.set(header, echo)
72
+ },
73
+ req,
74
+ )
71
75
  },
72
76
  onNodeResponse(res, req) {
73
77
  // The common node path (a handler route, whose derive wrote the id through `c.set`) pays
package/src/timing.ts CHANGED
@@ -124,10 +124,14 @@ export function timing(options: TimingOptions = {}): ContextPlugin<{ timing: Tim
124
124
  if (metrics.length === 0) return res
125
125
 
126
126
  const value = metrics.map((metric) => formatMetric(metric, precision)).join(", ")
127
- return withHeaders(res, (headers) => {
128
- const existing = headers.get("server-timing")
129
- headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
130
- })
127
+ return withHeaders(
128
+ res,
129
+ (headers) => {
130
+ const existing = headers.get("server-timing")
131
+ headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
132
+ },
133
+ req,
134
+ )
131
135
  }),
132
136
  )
133
137
  }