@nifrajs/middleware 2.1.0 → 2.3.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 (60) hide show
  1. package/README.md +22 -22
  2. package/dist/_utils.d.ts +3 -3
  3. package/dist/_utils.js +3 -3
  4. package/dist/admission.d.ts +2 -2
  5. package/dist/admission.js +2 -2
  6. package/dist/cache-control.d.ts +1 -1
  7. package/dist/cache-control.js +1 -1
  8. package/dist/cache.js +1 -1
  9. package/dist/compression.d.ts +1 -1
  10. package/dist/compression.js +4 -4
  11. package/dist/cors.d.ts +1 -1
  12. package/dist/cors.js +2 -2
  13. package/dist/durable-command.d.ts +34 -0
  14. package/dist/durable-command.d.ts.map +1 -0
  15. package/dist/durable-command.js +50 -0
  16. package/dist/durable-command.js.map +1 -0
  17. package/dist/etag.d.ts +2 -2
  18. package/dist/etag.js +3 -3
  19. package/dist/healthcheck.d.ts +2 -2
  20. package/dist/healthcheck.js +1 -1
  21. package/dist/idempotency.d.ts +4 -4
  22. package/dist/idempotency.js +3 -3
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/logger.d.ts +2 -2
  28. package/dist/logger.js +2 -2
  29. package/dist/openapi.d.ts +4 -4
  30. package/dist/openapi.js +4 -4
  31. package/dist/pretty-json.d.ts.map +1 -1
  32. package/dist/pretty-json.js +70 -18
  33. package/dist/pretty-json.js.map +1 -1
  34. package/dist/rate-limit.d.ts +8 -8
  35. package/dist/rate-limit.js +5 -5
  36. package/dist/request-id.d.ts +1 -1
  37. package/dist/request-id.js +1 -1
  38. package/dist/security-headers.d.ts +1 -1
  39. package/dist/security-headers.js +1 -1
  40. package/dist/token-auth.d.ts +6 -6
  41. package/dist/token-auth.js +4 -4
  42. package/package.json +4 -4
  43. package/src/_utils.ts +3 -3
  44. package/src/admission.ts +4 -4
  45. package/src/cache-control.ts +1 -1
  46. package/src/cache.ts +1 -1
  47. package/src/compression.ts +4 -4
  48. package/src/cors.ts +2 -2
  49. package/src/durable-command.ts +62 -0
  50. package/src/etag.ts +4 -4
  51. package/src/healthcheck.ts +3 -3
  52. package/src/idempotency.ts +7 -7
  53. package/src/index.ts +3 -2
  54. package/src/logger.ts +2 -2
  55. package/src/openapi.ts +8 -8
  56. package/src/pretty-json.ts +78 -16
  57. package/src/rate-limit.ts +10 -10
  58. package/src/request-id.ts +1 -1
  59. package/src/security-headers.ts +2 -2
  60. package/src/token-auth.ts +9 -9
package/src/etag.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { definePlugin } from "@nifrajs/core/server"
2
2
 
3
- /** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags — not crypto. */
3
+ /** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
4
4
  function fnv1a(bytes: Uint8Array): string {
5
5
  let h = 0x811c9dc5
6
6
  for (let i = 0; i < bytes.length; i++) {
@@ -11,7 +11,7 @@ function fnv1a(bytes: Uint8Array): string {
11
11
  }
12
12
 
13
13
  export interface ETagOptions {
14
- /** Emit a weak validator (`W/"…"`). Default `true` — it's a content hash, not a byte-for-byte promise. */
14
+ /** Emit a weak validator (`W/"…"`). Default `true` - it's a content hash, not a byte-for-byte promise. */
15
15
  readonly weak?: boolean
16
16
  /** Maximum response bytes to hash. Default `1_000_000`; larger responses pass through unchanged. */
17
17
  readonly maxBytes?: number
@@ -19,7 +19,7 @@ export interface ETagOptions {
19
19
 
20
20
  /**
21
21
  * A {@link definePlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
22
- * **`304 Not Modified`** when the client's `If-None-Match` matches — saving bandwidth on unchanged
22
+ * **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
23
23
  * responses. It reads and rebuilds small bodies only; larger responses pass through unchanged.
24
24
  * Idempotent.
25
25
  */
@@ -40,7 +40,7 @@ export function etag(options: ETagOptions = {}) {
40
40
  const headers = new Headers(res.headers)
41
41
  headers.set("ETag", tag)
42
42
  if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
43
- // A 304 carries no body — drop the body-describing headers so strict intermediaries don't see a
43
+ // A 304 carries no body - drop the body-describing headers so strict intermediaries don't see a
44
44
  // null body with a non-zero Content-Length.
45
45
  headers.delete("content-length")
46
46
  headers.delete("content-type")
@@ -3,9 +3,9 @@ import { definePlugin } from "@nifrajs/core/server"
3
3
  type MaybePromise<T> = T | Promise<T>
4
4
 
5
5
  export interface HealthcheckOptions {
6
- /** Liveness path — always `200` while the process is up. Default `"/health"`. */
6
+ /** Liveness path - always `200` while the process is up. Default `"/health"`. */
7
7
  readonly path?: string
8
- /** Readiness path — runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
8
+ /** Readiness path - runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
9
9
  readyPath?: string
10
10
  /** Readiness checks (DB ping, cache reachable, …). Each returns a boolean or throws; a throw counts
11
11
  * as failing. `/ready` reports each by name and is `200` only when all pass. */
@@ -38,7 +38,7 @@ export function healthcheck(options: HealthcheckOptions = {}) {
38
38
  app.register("GET", readyPath, undefined, async () => {
39
39
  const results: Record<string, boolean> = {}
40
40
  let ready = true
41
- // Run checks concurrently; a throw (or false) marks that check — and the whole probe — not ready.
41
+ // Run checks concurrently; a throw (or false) marks that check - and the whole probe - not ready.
42
42
  await Promise.all(
43
43
  checkNames.map(async (name) => {
44
44
  try {
@@ -2,12 +2,12 @@ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { METHODS, type Middleware } from "@nifrajs/core/server"
3
3
 
4
4
  /**
5
- * Idempotency keys for unsafe requests — a client retrying a `POST` (dropped connection, impatient
5
+ * Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
6
6
  * tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
7
7
  * running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay
8
8
  * or an in-flight collision short-circuits before any mutation.
9
9
  *
10
- * Pair it with a DB uniqueness constraint — this stops the *retry*, the constraint is the source of
10
+ * Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of
11
11
  * truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
12
12
  * (Redis, etc.) so the guarantee holds across instances; {@link MemoryIdempotencyStore} is dev-only.
13
13
  */
@@ -15,7 +15,7 @@ import { METHODS, type Middleware } from "@nifrajs/core/server"
15
15
  /** A captured response, replayed verbatim on a retry. Body is base64 (binary-safe + JSON-serializable). */
16
16
  export interface IdempotencyRecord {
17
17
  readonly status: number
18
- /** Response headers, **excluding `Set-Cookie`** (cookies are session-specific — see {@link idempotency}). */
18
+ /** Response headers, **excluding `Set-Cookie`** (cookies are session-specific - see {@link idempotency}). */
19
19
  readonly headers: ReadonlyArray<readonly [string, string]>
20
20
  /** Response body, base64-encoded. */
21
21
  readonly body: string
@@ -45,7 +45,7 @@ export interface IdempotencyStore {
45
45
  }
46
46
 
47
47
  export interface MemoryIdempotencyStoreOptions {
48
- /** Allow the in-memory store in production. Off by default — a per-instance store can't dedupe across instances. */
48
+ /** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */
49
49
  readonly allowInProduction?: boolean
50
50
  }
51
51
 
@@ -253,7 +253,7 @@ function replay(record: IdempotencyRecord): Response {
253
253
  /**
254
254
  * Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
255
255
  *
256
- * **`Set-Cookie` is intentionally not cached or replayed** — a cookie set on the first request is
256
+ * **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is
257
257
  * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
258
258
  * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
259
259
  *
@@ -286,7 +286,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
286
286
  async onRequest(req) {
287
287
  if (!methods.has(req.method.toUpperCase())) return undefined
288
288
  const key = keyOf(req, header)
289
- if (key === null || key === "") return undefined // opt-in per request — no key ⇒ no dedupe
289
+ if (key === null || key === "") return undefined // opt-in per request - no key ⇒ no dedupe
290
290
  const claim = await store.begin(key, lockTtlMs)
291
291
  if (claim.state === "replay") return replay(claim.record)
292
292
  if (claim.state === "in_flight") {
@@ -318,7 +318,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
318
318
  throw err
319
319
  }
320
320
  if (!("bytes" in captured)) {
321
- await store.release(key) // too large to store — return it, but don't cache
321
+ await store.release(key) // too large to store - return it, but don't cache
322
322
  return captured.response
323
323
  }
324
324
  const bytes = captured.bytes
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `@nifrajs/middleware` — composable, dependency-free middleware + plugins applied via `app.use()`.
2
+ * `@nifrajs/middleware` - composable, dependency-free middleware + plugins applied via `app.use()`.
3
3
  * The hardening set (`cors`, `rateLimit`, `securityHeaders`) returns a `@nifrajs/core` `Middleware`
4
4
  * (a hook bundle, context-agnostic). The seeded plugins (`requestId`, `logger`, `etag`, `bearer`,
5
- * `apiKey`) use the `definePlugin` convention — named (idempotent dedupe), and `requestId` threads
5
+ * `apiKey`) use the `definePlugin` convention - named (idempotent dedupe), and `requestId` threads
6
6
  * `c.requestId` into the handler context.
7
7
  */
8
8
 
@@ -44,6 +44,7 @@ export {
44
44
  csrf,
45
45
  verifyCsrfToken,
46
46
  } from "./csrf.ts"
47
+ export { type DurableCommandOptions, durableCommand } from "./durable-command.ts"
47
48
  export { type ETagOptions, etag } from "./etag.ts"
48
49
  export { type HealthcheckOptions, healthcheck } from "./healthcheck.ts"
49
50
  export {
package/src/logger.ts CHANGED
@@ -15,8 +15,8 @@ export interface LoggerOptions {
15
15
  }
16
16
 
17
17
  /**
18
- * A {@link definePlugin} plugin that logs one structured line per request — method, path, status,
19
- * and duration — via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
18
+ * A {@link definePlugin} plugin that logs one structured line per request - method, path, status,
19
+ * and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
20
20
  * paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
21
21
  */
22
22
  export function logger(options: LoggerOptions = {}) {
package/src/openapi.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { definePlugin } from "@nifrajs/core/server"
2
- // The OpenAPI generator only — `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
2
+ // The OpenAPI generator only - `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
3
3
  // plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
4
4
  import { toOpenAPI } from "@nifrajs/schema/openapi"
5
5
 
6
- /** A registered route as seen by {@link buildOpenApiDocument} — structurally a `@nifrajs/core`
6
+ /** A registered route as seen by {@link buildOpenApiDocument} - structurally a `@nifrajs/core`
7
7
  * `RouteDescriptor` (so `app.routes()` is passed straight through). */
8
8
  export interface RouteLike {
9
9
  readonly method: string
10
10
  readonly path: string
11
- // `| undefined` (not just `?`) so a core `RouteDescriptor` — whose `schema` is a *required*
12
- // `RouteSchema | undefined` — is assignable under `exactOptionalPropertyTypes`.
11
+ // `| undefined` (not just `?`) so a core `RouteDescriptor` - whose `schema` is a *required*
12
+ // `RouteSchema | undefined` - is assignable under `exactOptionalPropertyTypes`.
13
13
  readonly schema?:
14
14
  | { readonly body?: unknown; readonly query?: unknown; readonly response?: unknown }
15
15
  | undefined
@@ -57,7 +57,7 @@ export interface OpenApiOptions {
57
57
  readonly path?: string
58
58
  /** Exclude routes from the document (the doc path itself is always excluded). */
59
59
  readonly exclude?: (route: { readonly method: string; readonly path: string }) => boolean
60
- /** Per-operation overrides keyed by `"GET /users/:id"`, shallow-merged over the generated skeleton —
60
+ /** Per-operation overrides keyed by `"GET /users/:id"`, shallow-merged over the generated skeleton -
61
61
  * the escape hatch for rich request/response schemas, tags, and per-op security (Standard Schema
62
62
  * can't be introspected). */
63
63
  readonly operations?: Readonly<Record<string, Record<string, unknown>>>
@@ -127,10 +127,10 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
127
127
  }
128
128
 
129
129
  /**
130
- * Serve an OpenAPI 3.1 document (a structural subset — see {@link buildOpenApiDocument}) at
130
+ * Serve an OpenAPI 3.1 document (a structural subset - see {@link buildOpenApiDocument}) at
131
131
  * `options.path` (default `/openapi.json`), generated from the app's registered routes. Generation is
132
132
  * **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
133
- * registered — so the plugin's own position in the chain doesn't matter.
133
+ * registered - so the plugin's own position in the chain doesn't matter.
134
134
  *
135
135
  * Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
136
136
  * `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
@@ -149,7 +149,7 @@ export function openapi(options: OpenApiOptions = {}) {
149
149
  let uiCached: string | undefined
150
150
  return definePlugin("openapi", (app) => {
151
151
  // Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
152
- // routes lazily, at request time — so all routes exist regardless of where the plugin sits.
152
+ // routes lazily, at request time - so all routes exist regardless of where the plugin sits.
153
153
  app.register("GET", docPath, undefined, () => {
154
154
  cached ??= JSON.stringify(
155
155
  buildOpenApiDocument(app.routes(), {
@@ -25,26 +25,82 @@ function parseLength(value: string | null): number | undefined {
25
25
  return Number(value)
26
26
  }
27
27
 
28
- async function readTextCapped(res: Response, maxBytes: number): Promise<string | null> {
29
- const body = res.clone().body
30
- if (body === null) return null
28
+ /**
29
+ * The two reader methods this file uses, structurally.
30
+ *
31
+ * Named by use rather than imported: `lib.dom`'s `ReadableStreamDefaultReader` and Bun's augmented one
32
+ * are not mutually assignable (Bun adds `readMany`), and `ReturnType<…getReader>` resolves to the BYOB
33
+ * overload, whose `read` requires an argument. A structural shape sidesteps all of it without a cast.
34
+ */
35
+ interface ChunkReader {
36
+ read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
37
+ cancel(reason?: unknown): Promise<void>
38
+ }
39
+
40
+ /** Re-emit the bytes already pulled, then the untouched remainder of the same reader. */
41
+ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkReader): Response {
42
+ const stream = new ReadableStream<Uint8Array>({
43
+ start(controller) {
44
+ for (const chunk of chunks) controller.enqueue(chunk)
45
+ },
46
+ async pull(controller) {
47
+ try {
48
+ const { done, value } = await reader.read()
49
+ // No value and not done cannot happen for a byte stream, but it means the same thing here:
50
+ // there is nothing further to emit.
51
+ if (done || value === undefined) controller.close()
52
+ else controller.enqueue(value)
53
+ } catch (error) {
54
+ // The upstream body failed; surface that to the client rather than truncating silently.
55
+ controller.error(error)
56
+ }
57
+ },
58
+ cancel: (reason) => reader.cancel(reason),
59
+ })
60
+ return new Response(stream, {
61
+ status: res.status,
62
+ statusText: res.statusText,
63
+ headers: res.headers,
64
+ })
65
+ }
66
+
67
+ type Peeked = { readonly text: string } | { readonly response: Response }
68
+
69
+ /**
70
+ * Read up to `maxBytes` of a response, without disturbing it when the answer is "pass through".
71
+ *
72
+ * `Response.clone()` is the obvious way to peek and is a trap here. Cancelling the clone's reader once
73
+ * the cap is exceeded also stalls the ORIGINAL body in Bun, so an oversized streamed response hung the
74
+ * client instead of passing through - a hang, from a middleware whose entire job is cosmetic. (A clone
75
+ * read to completion is fine; only the cancel breaks it, which is why every buffered case passed and
76
+ * only a streamed one failed.) Abandoning the clone rather than cancelling it trades the hang for
77
+ * buffering the whole oversized body, which is the exact cost the cap exists to avoid.
78
+ *
79
+ * So the body is read directly and, when it proves too large, replayed: the bytes already pulled are
80
+ * re-emitted ahead of the rest of the same reader. No tee, nothing buffered past the cap, and the
81
+ * response the client receives is byte-for-byte the one the handler produced.
82
+ */
83
+ async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
84
+ const body = res.body
85
+ if (body === null) return { response: res }
31
86
  const reader = body.getReader()
32
- const decoder = new TextDecoder()
87
+ const chunks: Uint8Array[] = []
33
88
  let total = 0
34
- let text = ""
35
89
  try {
36
90
  while (true) {
37
91
  const { done, value } = await reader.read()
38
- if (done) return text + decoder.decode()
39
- total += value.byteLength
40
- if (total > maxBytes) {
41
- await reader.cancel()
42
- return null
92
+ if (done) {
93
+ const decoder = new TextDecoder()
94
+ let text = ""
95
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
96
+ return { text: text + decoder.decode() }
43
97
  }
44
- text += decoder.decode(value, { stream: true })
98
+ chunks.push(value)
99
+ total += value.byteLength
100
+ if (total > maxBytes) return { response: replayFrom(res, chunks, reader) }
45
101
  }
46
102
  } catch {
47
- return null
103
+ return { response: replayFrom(res, chunks, reader) }
48
104
  }
49
105
  }
50
106
 
@@ -76,13 +132,19 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
76
132
  const declared = parseLength(res.headers.get("content-length"))
77
133
  if (declared !== undefined && declared > maxBytes) return res
78
134
 
79
- const text = await readTextCapped(res, maxBytes)
80
- if (text === null) return res
135
+ const peeked = await peekText(res, maxBytes)
136
+ if ("response" in peeked) return peeked.response
81
137
  let parsed: unknown
82
138
  try {
83
- parsed = JSON.parse(text)
139
+ parsed = JSON.parse(peeked.text)
84
140
  } catch {
85
- return res
141
+ // The body was read to decide that, so `res` can no longer be returned as-is. Rebuild it from
142
+ // the text: re-encoding UTF-8 reproduces the original bytes, so `content-length` still holds.
143
+ return new Response(peeked.text, {
144
+ status: res.status,
145
+ statusText: res.statusText,
146
+ headers: res.headers,
147
+ })
86
148
  }
87
149
 
88
150
  const headers = new Headers(res.headers)
package/src/rate-limit.ts CHANGED
@@ -10,7 +10,7 @@ export interface RateLimitResult {
10
10
 
11
11
  /**
12
12
  * Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
13
- * limit holds across instances — that's a user dependency, not ours, hence the
13
+ * limit holds across instances - that's a user dependency, not ours, hence the
14
14
  * interface. {@link MemoryStore} is for dev / single-instance only.
15
15
  */
16
16
  export interface RateLimitStore {
@@ -18,7 +18,7 @@ export interface RateLimitStore {
18
18
  }
19
19
 
20
20
  export interface MemoryStoreOptions {
21
- /** Allow the in-memory store in production. Off by default — a per-instance limiter is unsafe across instances. */
21
+ /** Allow the in-memory store in production. Off by default - a per-instance limiter is unsafe across instances. */
22
22
  readonly allowInProduction?: boolean
23
23
  /** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
24
24
  * Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
@@ -29,7 +29,7 @@ export interface MemoryStoreOptions {
29
29
 
30
30
  /** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
31
31
  * to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
32
- * under a distinct-key flood — the abuse the key cap defends against. */
32
+ * under a distinct-key flood - the abuse the key cap defends against. */
33
33
  const MAX_EVICTION_SCAN = 64
34
34
 
35
35
  /**
@@ -38,8 +38,8 @@ const MAX_EVICTION_SCAN = 64
38
38
  * Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
39
39
  * `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
40
40
  * then oldest active keys).
41
- * Without this, expired entries for keys never seen again — and the unbounded key space of a bot scan
42
- * — would accumulate in the map forever and OOM a single-instance deploy.
41
+ * Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
42
+ * - would accumulate in the map forever and OOM a single-instance deploy.
43
43
  */
44
44
  export class MemoryStore implements RateLimitStore {
45
45
  private readonly windows = new Map<string, { count: number; resetAt: number }>()
@@ -48,7 +48,7 @@ export class MemoryStore implements RateLimitStore {
48
48
  private lastSweep = 0
49
49
  private evictionScans = 0
50
50
 
51
- /** Cumulative entries inspected by the eviction scan since construction — an eviction-pressure
51
+ /** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
52
52
  * gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
53
53
  * regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
54
54
  get evictionScanCount(): number {
@@ -98,7 +98,7 @@ export class MemoryStore implements RateLimitStore {
98
98
  private enforceMaxKeys(now: number): void {
99
99
  if (this.windows.size <= this.maxKeys) return
100
100
  // Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
101
- // every insertion is O(n)/request under a distinct-key flood — the exact abuse the cap defends
101
+ // every insertion is O(n)/request under a distinct-key flood - the exact abuse the cap defends
102
102
  // against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
103
103
  // victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
104
104
  // are still reclaimed by the amortized sweep in `hit`.
@@ -132,13 +132,13 @@ export interface RateLimitOptions {
132
132
  * Default `0`.
133
133
  *
134
134
  * The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
135
- * observed — the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
135
+ * observed - the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
136
136
  * second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
137
137
  * the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
138
138
  *
139
139
  * ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
140
140
  * Reading the
141
- * *first* XFF hop — the old behavior — let any client mint a fresh bucket per request and defeat the
141
+ * *first* XFF hop - the old behavior - let any client mint a fresh bucket per request and defeat the
142
142
  * limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
143
143
  * trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
144
144
  */
@@ -151,7 +151,7 @@ export interface RateLimitOptions {
151
151
  */
152
152
  readonly allowGlobalKey?: boolean
153
153
  /**
154
- * Bucket key for a request. Overrides the default XFF-based key entirely — set this for accurate
154
+ * Bucket key for a request. Overrides the default XFF-based key entirely - set this for accurate
155
155
  * per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
156
156
  * can't see the socket IP (that needs the server instance).
157
157
  */
package/src/request-id.ts CHANGED
@@ -10,7 +10,7 @@ export interface RequestIdOptions {
10
10
  /**
11
11
  * A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
12
12
  * `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
13
- * threaded by `derive`), and echoes it on the response header. Idempotent — applying it twice is a
13
+ * threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
14
14
  * no-op.
15
15
  *
16
16
  * ```ts
@@ -3,7 +3,7 @@ import type { Middleware } from "@nifrajs/core/server"
3
3
  import { withHeaders } from "./_utils.ts"
4
4
 
5
5
  export interface SecurityHeadersOptions {
6
- /** `Strict-Transport-Security`. Off by default — opt in once you're sure you're HTTPS-only. */
6
+ /** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
7
7
  readonly hsts?: {
8
8
  readonly maxAge: number
9
9
  readonly includeSubDomains?: boolean
@@ -40,7 +40,7 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
40
40
  {
41
41
  name: "security-headers",
42
42
  // Mutate in place on the common (mutable-headers) response; clone only for an immutable one
43
- // (see withHeaders — the old always-clone was ~3% of a realistic request).
43
+ // (see withHeaders - the old always-clone was ~3% of a realistic request).
44
44
  onResponse: (res) =>
45
45
  withHeaders(res, (headers) => {
46
46
  headers.set("X-Content-Type-Options", "nosniff")
package/src/token-auth.ts CHANGED
@@ -4,7 +4,7 @@ import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
4
4
  type MaybePromise<T> = T | Promise<T>
5
5
 
6
6
  /**
7
- * A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` — it rejects unauthorized
7
+ * A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` - it rejects unauthorized
8
8
  * requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
9
9
  * inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
10
10
  * {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
@@ -51,11 +51,11 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
51
51
  const plugin = definePlugin(config.name, (app) =>
52
52
  app.beforeHandle(async (c: { readonly req: Request }) => {
53
53
  const token = config.extract(c.req)
54
- // Empty string is treated as "no credential" — never passed to verify.
54
+ // Empty string is treated as "no credential" - never passed to verify.
55
55
  const principal = token !== undefined && token !== "" ? await config.verify(token) : null
56
56
  if (principal !== null && principal !== undefined) {
57
57
  store.set(c.req, principal)
58
- return undefined // authorized — continue to the handler
58
+ return undefined // authorized - continue to the handler
59
59
  }
60
60
  return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
61
61
  }),
@@ -79,7 +79,7 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
79
79
 
80
80
  export interface BearerOptions<P> {
81
81
  /** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
82
- * (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare — never `===`. */
82
+ * (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare - never `===`. */
83
83
  readonly verify: (token: string) => MaybePromise<P | null | undefined>
84
84
  /** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
85
85
  readonly optional?: boolean
@@ -90,7 +90,7 @@ export interface BearerOptions<P> {
90
90
  /**
91
91
  * `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
92
92
  * `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
93
- * verified principal is read via the returned instance — see {@link AuthPlugin}.
93
+ * verified principal is read via the returned instance - see {@link AuthPlugin}.
94
94
  */
95
95
  export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
96
96
  const realm = options.realm ?? "api"
@@ -140,7 +140,7 @@ function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
140
140
  /**
141
141
  * Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
142
142
  * 32 bytes); per request the candidate is hashed and compared against every digest **without early
143
- * exit**, so timing depends only on the (public) key count — never on the secret value or which key
143
+ * exit**, so timing depends only on the (public) key count - never on the secret value or which key
144
144
  * matched. Portable across all runtimes via WebCrypto.
145
145
  */
146
146
  function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promise<string | null> {
@@ -158,11 +158,11 @@ function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promis
158
158
 
159
159
  /**
160
160
  * API-key authentication via a header (default `x-api-key`). Two forms:
161
- * - `apiKey({ keys })` — a fixed key set, compared in **constant time**; the matched key is the principal.
162
- * - `apiKey({ verify })` — custom (e.g. DB-backed) verification returning a typed principal.
161
+ * - `apiKey({ keys })` - a fixed key set, compared in **constant time**; the matched key is the principal.
162
+ * - `apiKey({ verify })` - custom (e.g. DB-backed) verification returning a typed principal.
163
163
  *
164
164
  * Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
165
- * instance — see {@link AuthPlugin}.
165
+ * instance - see {@link AuthPlugin}.
166
166
  */
167
167
  export function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>
168
168
  export function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>