@nifrajs/middleware 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/_utils.d.ts +27 -0
  4. package/dist/_utils.d.ts.map +1 -0
  5. package/dist/_utils.js +141 -0
  6. package/dist/_utils.js.map +1 -0
  7. package/dist/basic-auth.d.ts +27 -0
  8. package/dist/basic-auth.d.ts.map +1 -0
  9. package/dist/basic-auth.js +64 -0
  10. package/dist/basic-auth.js.map +1 -0
  11. package/dist/body-limit.d.ts +19 -0
  12. package/dist/body-limit.d.ts.map +1 -0
  13. package/dist/body-limit.js +42 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/cache-control.d.ts +23 -0
  16. package/dist/cache-control.d.ts.map +1 -0
  17. package/dist/cache-control.js +35 -0
  18. package/dist/cache-control.js.map +1 -0
  19. package/dist/cache.d.ts +60 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +224 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/combine.d.ts +7 -0
  24. package/dist/combine.d.ts.map +1 -0
  25. package/dist/combine.js +21 -0
  26. package/dist/combine.js.map +1 -0
  27. package/dist/compression.d.ts +29 -0
  28. package/dist/compression.d.ts.map +1 -0
  29. package/dist/compression.js +105 -0
  30. package/dist/compression.js.map +1 -0
  31. package/dist/context-storage.d.ts +14 -0
  32. package/dist/context-storage.d.ts.map +1 -0
  33. package/dist/context-storage.js +30 -0
  34. package/dist/context-storage.js.map +1 -0
  35. package/dist/cors.d.ts +25 -0
  36. package/dist/cors.d.ts.map +1 -0
  37. package/dist/cors.js +75 -0
  38. package/dist/cors.js.map +1 -0
  39. package/dist/csrf.d.ts +23 -0
  40. package/dist/csrf.d.ts.map +1 -0
  41. package/dist/csrf.js +68 -0
  42. package/dist/csrf.js.map +1 -0
  43. package/dist/etag.d.ts +14 -0
  44. package/dist/etag.d.ts.map +1 -0
  45. package/dist/etag.js +95 -0
  46. package/dist/etag.js.map +1 -0
  47. package/dist/healthcheck.d.ts +25 -0
  48. package/dist/healthcheck.d.ts.map +1 -0
  49. package/dist/healthcheck.js +41 -0
  50. package/dist/healthcheck.js.map +1 -0
  51. package/dist/idempotency.d.ts +83 -0
  52. package/dist/idempotency.d.ts.map +1 -0
  53. package/dist/idempotency.js +217 -0
  54. package/dist/idempotency.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ip-restriction.d.ts +21 -0
  60. package/dist/ip-restriction.d.ts.map +1 -0
  61. package/dist/ip-restriction.js +161 -0
  62. package/dist/ip-restriction.js.map +1 -0
  63. package/dist/jwt.d.ts +82 -0
  64. package/dist/jwt.d.ts.map +1 -0
  65. package/dist/jwt.js +327 -0
  66. package/dist/jwt.js.map +1 -0
  67. package/dist/language.d.ts +20 -0
  68. package/dist/language.d.ts.map +1 -0
  69. package/dist/language.js +94 -0
  70. package/dist/language.js.map +1 -0
  71. package/dist/logger.d.ts +19 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +28 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/method-override.d.ts +28 -0
  76. package/dist/method-override.d.ts.map +1 -0
  77. package/dist/method-override.js +67 -0
  78. package/dist/method-override.js.map +1 -0
  79. package/dist/openapi.d.ts +83 -0
  80. package/dist/openapi.d.ts.map +1 -0
  81. package/dist/openapi.js +100 -0
  82. package/dist/openapi.js.map +1 -0
  83. package/dist/powered-by.d.ts +15 -0
  84. package/dist/powered-by.d.ts.map +1 -0
  85. package/dist/powered-by.js +23 -0
  86. package/dist/powered-by.js.map +1 -0
  87. package/dist/pretty-json.d.ts +18 -0
  88. package/dist/pretty-json.d.ts.map +1 -0
  89. package/dist/pretty-json.js +88 -0
  90. package/dist/pretty-json.js.map +1 -0
  91. package/dist/rate-limit.d.ts +92 -0
  92. package/dist/rate-limit.d.ts.map +1 -0
  93. package/dist/rate-limit.js +172 -0
  94. package/dist/rate-limit.js.map +1 -0
  95. package/dist/request-id.d.ts +18 -0
  96. package/dist/request-id.d.ts.map +1 -0
  97. package/dist/request-id.js +21 -0
  98. package/dist/request-id.js.map +1 -0
  99. package/dist/security-headers.d.ts +23 -0
  100. package/dist/security-headers.d.ts.map +1 -0
  101. package/dist/security-headers.js +36 -0
  102. package/dist/security-headers.js.map +1 -0
  103. package/dist/timing.d.ts +25 -0
  104. package/dist/timing.d.ts.map +1 -0
  105. package/dist/timing.js +101 -0
  106. package/dist/timing.js.map +1 -0
  107. package/dist/token-auth.d.ts +65 -0
  108. package/dist/token-auth.d.ts.map +1 -0
  109. package/dist/token-auth.js +90 -0
  110. package/dist/token-auth.js.map +1 -0
  111. package/dist/trailing-slash.d.ts +23 -0
  112. package/dist/trailing-slash.d.ts.map +1 -0
  113. package/dist/trailing-slash.js +70 -0
  114. package/dist/trailing-slash.js.map +1 -0
  115. package/package.json +68 -0
  116. package/src/_utils.ts +163 -0
  117. package/src/basic-auth.ts +109 -0
  118. package/src/body-limit.ts +53 -0
  119. package/src/cache-control.ts +45 -0
  120. package/src/cache.ts +277 -0
  121. package/src/combine.ts +24 -0
  122. package/src/compression.ts +111 -0
  123. package/src/context-storage.ts +36 -0
  124. package/src/cors.ts +90 -0
  125. package/src/csrf.ts +102 -0
  126. package/src/etag.ts +99 -0
  127. package/src/healthcheck.ts +59 -0
  128. package/src/idempotency.ts +313 -0
  129. package/src/index.ts +110 -0
  130. package/src/ip-restriction.ts +187 -0
  131. package/src/jwt.ts +441 -0
  132. package/src/language.ts +109 -0
  133. package/src/logger.ts +44 -0
  134. package/src/method-override.ts +87 -0
  135. package/src/openapi.ts +174 -0
  136. package/src/powered-by.ts +31 -0
  137. package/src/pretty-json.ts +97 -0
  138. package/src/rate-limit.ts +248 -0
  139. package/src/request-id.ts +30 -0
  140. package/src/security-headers.ts +51 -0
  141. package/src/timing.ts +133 -0
  142. package/src/token-auth.ts +172 -0
  143. package/src/trailing-slash.ts +94 -0
package/src/etag.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ /** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags — not crypto. */
4
+ function fnv1a(bytes: Uint8Array): string {
5
+ let h = 0x811c9dc5
6
+ for (let i = 0; i < bytes.length; i++) {
7
+ h ^= bytes[i]!
8
+ h = Math.imul(h, 0x01000193)
9
+ }
10
+ return (h >>> 0).toString(16)
11
+ }
12
+
13
+ export interface ETagOptions {
14
+ /** Emit a weak validator (`W/"…"`). Default `true` — it's a content hash, not a byte-for-byte promise. */
15
+ readonly weak?: boolean
16
+ /** Maximum response bytes to hash. Default `1_000_000`; larger responses pass through unchanged. */
17
+ readonly maxBytes?: number
18
+ }
19
+
20
+ /**
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
23
+ * responses. It reads and rebuilds small bodies only; larger responses pass through unchanged.
24
+ * Idempotent.
25
+ */
26
+ export function etag(options: ETagOptions = {}) {
27
+ const prefix = (options.weak ?? true) ? "W/" : ""
28
+ const maxBytes = options.maxBytes ?? 1_000_000
29
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
30
+ throw new Error("etag: maxBytes must be a non-negative integer")
31
+ }
32
+ return definePlugin("etag", (app) =>
33
+ app.onResponse(async (res, req) => {
34
+ if (req.method !== "GET" || res.status !== 200 || res.body === null) return res
35
+ const declared = parseLength(res.headers.get("content-length"))
36
+ if (declared !== undefined && declared > maxBytes) return res
37
+ const body = await readBytesCapped(res, maxBytes)
38
+ if (body === null) return res
39
+ const tag = `${prefix}"${fnv1a(body)}"`
40
+ const headers = new Headers(res.headers)
41
+ headers.set("ETag", tag)
42
+ if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
43
+ return new Response(null, { status: 304, headers })
44
+ }
45
+ return new Response(body, { status: res.status, statusText: res.statusText, headers })
46
+ }),
47
+ )
48
+ }
49
+
50
+ function matchesIfNoneMatch(value: string | null, tag: string): boolean {
51
+ if (value === null) return false
52
+ const normalizedTag = weakComparableTag(tag)
53
+ for (const part of value.split(",")) {
54
+ const candidate = part.trim()
55
+ if (candidate === "*") return true
56
+ if (weakComparableTag(candidate) === normalizedTag) return true
57
+ }
58
+ return false
59
+ }
60
+
61
+ function weakComparableTag(tag: string): string {
62
+ return tag.startsWith("W/") ? tag.slice(2) : tag
63
+ }
64
+
65
+ function parseLength(value: string | null): number | undefined {
66
+ if (value === null) return undefined
67
+ if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
68
+ return Number(value)
69
+ }
70
+
71
+ async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array | null> {
72
+ const body = res.clone().body
73
+ if (body === null) return new Uint8Array()
74
+ const reader = body.getReader()
75
+ const chunks: Uint8Array[] = []
76
+ let total = 0
77
+ try {
78
+ for (;;) {
79
+ const { done, value } = await reader.read()
80
+ if (done) {
81
+ const out = new Uint8Array(total)
82
+ let offset = 0
83
+ for (const chunk of chunks) {
84
+ out.set(chunk, offset)
85
+ offset += chunk.byteLength
86
+ }
87
+ return out
88
+ }
89
+ total += value.byteLength
90
+ if (total > maxBytes) {
91
+ await reader.cancel()
92
+ return null
93
+ }
94
+ chunks.push(value)
95
+ }
96
+ } catch {
97
+ return null
98
+ }
99
+ }
@@ -0,0 +1,59 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ type MaybePromise<T> = T | Promise<T>
4
+
5
+ export interface HealthcheckOptions {
6
+ /** Liveness path — always `200` while the process is up. Default `"/health"`. */
7
+ readonly path?: string
8
+ /** Readiness path — runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
9
+ readyPath?: string
10
+ /** Readiness checks (DB ping, cache reachable, …). Each returns a boolean or throws; a throw counts
11
+ * as failing. `/ready` reports each by name and is `200` only when all pass. */
12
+ readonly checks?: Readonly<Record<string, () => MaybePromise<boolean>>>
13
+ }
14
+
15
+ const noStore = { "cache-control": "no-store" } as const
16
+
17
+ /**
18
+ * Register **liveness** (`/health`) and **readiness** (`/ready`) endpoints. Liveness is a flat `200`
19
+ * (the process is serving). Readiness runs each `check` and returns `200 { status: "ok", checks }`
20
+ * when all pass, or `503 { status: "error", checks }` when any fail (a thrown check counts as failed).
21
+ * Both are `Cache-Control: no-store`.
22
+ *
23
+ * Apply it **before** auth guards so the endpoints stay public (`beforeHandle` is order-scoped):
24
+ *
25
+ * ```ts
26
+ * app.use(healthcheck({ checks: { db: () => db.ping() } })).use(bearer({ verify }))
27
+ * ```
28
+ */
29
+ export function healthcheck(options: HealthcheckOptions = {}) {
30
+ const path = options.path ?? "/health"
31
+ const readyPath = options.readyPath ?? "/ready"
32
+ const checks = options.checks ?? {}
33
+ const checkNames = Object.keys(checks)
34
+ return definePlugin("healthcheck", (app) => {
35
+ app.register("GET", path, undefined, () =>
36
+ Response.json({ status: "ok" }, { headers: noStore }),
37
+ )
38
+ app.register("GET", readyPath, undefined, async () => {
39
+ const results: Record<string, boolean> = {}
40
+ let ready = true
41
+ // Run checks concurrently; a throw (or false) marks that check — and the whole probe — not ready.
42
+ await Promise.all(
43
+ checkNames.map(async (name) => {
44
+ try {
45
+ results[name] = (await checks[name]?.()) === true
46
+ } catch {
47
+ results[name] = false
48
+ }
49
+ if (!results[name]) ready = false
50
+ }),
51
+ )
52
+ return Response.json(
53
+ { status: ready ? "ok" : "error", checks: results },
54
+ { status: ready ? 200 : 503, headers: noStore },
55
+ )
56
+ })
57
+ return app
58
+ })
59
+ }
@@ -0,0 +1,313 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+
3
+ /**
4
+ * Idempotency keys for unsafe requests — a client retrying a `POST` (dropped connection, impatient
5
+ * tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
6
+ * running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay
7
+ * or an in-flight collision short-circuits before any mutation.
8
+ *
9
+ * Pair it with a DB uniqueness constraint — this stops the *retry*, the constraint is the source of
10
+ * truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
11
+ * (Redis, etc.) so the guarantee holds across instances; {@link MemoryIdempotencyStore} is dev-only.
12
+ */
13
+
14
+ /** A captured response, replayed verbatim on a retry. Body is base64 (binary-safe + JSON-serializable). */
15
+ export interface IdempotencyRecord {
16
+ readonly status: number
17
+ /** Response headers, **excluding `Set-Cookie`** (cookies are session-specific — see {@link idempotency}). */
18
+ readonly headers: ReadonlyArray<readonly [string, string]>
19
+ /** Response body, base64-encoded. */
20
+ readonly body: string
21
+ }
22
+
23
+ export type IdempotencyClaim =
24
+ | { readonly state: "new" }
25
+ | { readonly state: "in_flight" }
26
+ | { readonly state: "replay"; readonly record: IdempotencyRecord }
27
+
28
+ /**
29
+ * Store backing the idempotency guarantee. Production deploys MUST use a shared store so the key holds
30
+ * across instances; `begin` MUST be **atomic** (e.g. Redis `SET key NX PX lockTtlMs`) or two concurrent
31
+ * retries can both see `"new"`. {@link MemoryIdempotencyStore} is for dev / single-instance only.
32
+ */
33
+ export interface IdempotencyStore {
34
+ /**
35
+ * Atomically claim `key`: `"replay"` if a completed response is stored, `"in_flight"` if another
36
+ * request holds the lock, else `"new"` (the caller now owns the lock and must `complete`/`release`).
37
+ * The in-flight lock expires after `lockTtlMs` so a crashed handler can't wedge the key forever.
38
+ */
39
+ begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim>
40
+ /** Store the completed response and release the lock (kept for `ttlMs`). */
41
+ complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void>
42
+ /** Release the lock without storing (handler errored / response not cacheable). */
43
+ release(key: string): Promise<void>
44
+ }
45
+
46
+ export interface MemoryIdempotencyStoreOptions {
47
+ /** Allow the in-memory store in production. Off by default — a per-instance store can't dedupe across instances. */
48
+ readonly allowInProduction?: boolean
49
+ }
50
+
51
+ type Entry =
52
+ | { readonly kind: "lock"; readonly expiresAt: number }
53
+ | { readonly kind: "record"; readonly record: IdempotencyRecord; readonly expiresAt: number }
54
+
55
+ /** In-process store. Refuses to run in production unless explicitly allowed (per-instance ⇒ no cross-instance dedupe). */
56
+ export class MemoryIdempotencyStore implements IdempotencyStore {
57
+ private readonly entries = new Map<string, Entry>()
58
+
59
+ constructor(options: MemoryIdempotencyStoreOptions = {}) {
60
+ if (options.allowInProduction !== true && process.env.NODE_ENV === "production") {
61
+ throw new Error(
62
+ "MemoryIdempotencyStore is per-instance and can't dedupe across instances in production. " +
63
+ "Use a shared store (e.g. Redis), or pass { allowInProduction: true } for a single-instance deploy.",
64
+ )
65
+ }
66
+ }
67
+
68
+ begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim> {
69
+ const now = Date.now()
70
+ const entry = this.entries.get(key)
71
+ if (entry !== undefined && entry.expiresAt > now) {
72
+ return Promise.resolve(
73
+ entry.kind === "record"
74
+ ? { state: "replay", record: entry.record }
75
+ : { state: "in_flight" },
76
+ )
77
+ }
78
+ // Free (or expired): take the lock. Synchronous Map write ⇒ atomic on a single instance.
79
+ this.entries.set(key, { kind: "lock", expiresAt: now + lockTtlMs })
80
+ return Promise.resolve({ state: "new" })
81
+ }
82
+
83
+ complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void> {
84
+ this.entries.set(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
85
+ return Promise.resolve()
86
+ }
87
+
88
+ release(key: string): Promise<void> {
89
+ const entry = this.entries.get(key)
90
+ if (entry !== undefined && entry.kind === "lock") this.entries.delete(key)
91
+ return Promise.resolve()
92
+ }
93
+ }
94
+
95
+ export interface IdempotencyOptions {
96
+ /** Where claims + cached responses live. `MemoryIdempotencyStore` for dev; a shared store in production. */
97
+ readonly store: IdempotencyStore
98
+ /** Header carrying the key. Default `"idempotency-key"`. */
99
+ readonly header?: string
100
+ /** Methods the guard applies to. Default `["POST", "PUT", "PATCH", "DELETE"]` (unsafe methods). */
101
+ readonly methods?: readonly string[]
102
+ /** How long a completed response is replayable, in ms. Default 24h. */
103
+ readonly ttlMs?: number
104
+ /** How long the in-flight lock survives a crashed handler, in ms. Default 60s. */
105
+ readonly lockTtlMs?: number
106
+ /** Max response bytes to cache. A larger response is returned but **not** stored. Default 1 MiB. */
107
+ readonly maxBytes?: number
108
+ /** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */
109
+ readonly shouldCache?: (response: Response) => boolean
110
+ }
111
+
112
+ const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
113
+ const DAY_MS = 24 * 60 * 60 * 1000
114
+
115
+ const toBase64 = (bytes: Uint8Array): string => {
116
+ let bin = ""
117
+ for (const b of bytes) bin += String.fromCharCode(b)
118
+ return btoa(bin)
119
+ }
120
+
121
+ const fromBase64 = (value: string): Uint8Array => {
122
+ const bin = atob(value)
123
+ const out = new Uint8Array(bin.length)
124
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
125
+ return out
126
+ }
127
+
128
+ // 204/205/304 (and an empty body) must be constructed with a `null` body or the Response ctor throws.
129
+ const bodyFor = (bytes: Uint8Array): Uint8Array | null => (bytes.byteLength === 0 ? null : bytes)
130
+
131
+ interface ByteReader {
132
+ read(): Promise<
133
+ | { readonly done: true; readonly value?: undefined }
134
+ | { readonly done: false; readonly value: Uint8Array }
135
+ >
136
+ cancel(reason?: unknown): Promise<void>
137
+ releaseLock(): void
138
+ }
139
+
140
+ const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
141
+ const out = new Uint8Array(total)
142
+ let offset = 0
143
+ for (const chunk of chunks) {
144
+ out.set(chunk, offset)
145
+ offset += chunk.byteLength
146
+ }
147
+ return out
148
+ }
149
+
150
+ function parseLength(value: string | null): number | undefined {
151
+ if (value === null) return undefined
152
+ if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
153
+ return Number(value)
154
+ }
155
+
156
+ function responseWithBody(
157
+ res: Response,
158
+ body: Uint8Array | ReadableStream<Uint8Array> | null,
159
+ ): Response {
160
+ return new Response(body, {
161
+ status: res.status,
162
+ statusText: res.statusText,
163
+ headers: res.headers,
164
+ })
165
+ }
166
+
167
+ function replayBufferedBody(
168
+ reader: ByteReader,
169
+ buffered: readonly Uint8Array[],
170
+ ): ReadableStream<Uint8Array> {
171
+ let index = 0
172
+ let released = false
173
+ const release = (): void => {
174
+ if (!released) {
175
+ released = true
176
+ reader.releaseLock()
177
+ }
178
+ }
179
+ return new ReadableStream<Uint8Array>({
180
+ async pull(controller) {
181
+ if (index < buffered.length) {
182
+ controller.enqueue(buffered[index]!)
183
+ index += 1
184
+ return
185
+ }
186
+ try {
187
+ const { done, value } = await reader.read()
188
+ if (done) {
189
+ release()
190
+ controller.close()
191
+ } else {
192
+ controller.enqueue(value)
193
+ }
194
+ } catch (err) {
195
+ release()
196
+ controller.error(err)
197
+ }
198
+ },
199
+ cancel(reason) {
200
+ return reader.cancel(reason).finally(release)
201
+ },
202
+ })
203
+ }
204
+
205
+ async function captureBody(
206
+ res: Response,
207
+ maxBytes: number,
208
+ ): Promise<
209
+ { readonly bytes: Uint8Array; readonly response: Response } | { readonly response: Response }
210
+ > {
211
+ const declared = parseLength(res.headers.get("content-length"))
212
+ if (declared !== undefined && declared > maxBytes) return { response: res }
213
+ const body = res.body
214
+ if (body === null) return { bytes: new Uint8Array(), response: responseWithBody(res, null) }
215
+
216
+ const reader: ByteReader = body.getReader()
217
+ const chunks: Uint8Array[] = []
218
+ let total = 0
219
+ try {
220
+ for (;;) {
221
+ const { done, value } = await reader.read()
222
+ if (done) {
223
+ reader.releaseLock()
224
+ const bytes = concat(chunks, total)
225
+ return { bytes, response: responseWithBody(res, bodyFor(bytes)) }
226
+ }
227
+ chunks.push(value)
228
+ total += value.byteLength
229
+ if (total > maxBytes) {
230
+ return { response: responseWithBody(res, replayBufferedBody(reader, chunks)) }
231
+ }
232
+ }
233
+ } catch (err) {
234
+ reader.releaseLock()
235
+ throw err
236
+ }
237
+ }
238
+
239
+ function replay(record: IdempotencyRecord): Response {
240
+ const headers = new Headers(record.headers as Array<[string, string]>)
241
+ headers.set("idempotent-replayed", "true")
242
+ return new Response(bodyFor(fromBase64(record.body)), { status: record.status, headers })
243
+ }
244
+
245
+ /**
246
+ * Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
247
+ *
248
+ * **`Set-Cookie` is intentionally not cached or replayed** — a cookie set on the first request is
249
+ * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
250
+ * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
251
+ *
252
+ * Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
253
+ */
254
+ export function idempotency(options: IdempotencyOptions): Middleware {
255
+ const { store } = options
256
+ const header = (options.header ?? "idempotency-key").toLowerCase()
257
+ const methods = new Set((options.methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase()))
258
+ const ttlMs = options.ttlMs ?? DAY_MS
259
+ const lockTtlMs = options.lockTtlMs ?? 60_000
260
+ const maxBytes = options.maxBytes ?? 1024 * 1024
261
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
262
+ throw new Error("idempotency: maxBytes must be a non-negative integer")
263
+ }
264
+ const shouldCache = options.shouldCache ?? ((res: Response) => res.status < 500)
265
+ const claimed = new WeakMap<Request, string>()
266
+
267
+ return {
268
+ name: "idempotency",
269
+ async onRequest(req) {
270
+ if (!methods.has(req.method)) return undefined
271
+ const key = req.headers.get(header)
272
+ if (key === null || key === "") return undefined // opt-in per request — no key ⇒ no dedupe
273
+ const claim = await store.begin(key, lockTtlMs)
274
+ if (claim.state === "replay") return replay(claim.record)
275
+ if (claim.state === "in_flight") {
276
+ return new Response(JSON.stringify({ ok: false, error: "idempotency_in_progress" }), {
277
+ status: 409,
278
+ headers: {
279
+ "content-type": "application/json",
280
+ "retry-after": String(Math.ceil(lockTtlMs / 1000)),
281
+ },
282
+ })
283
+ }
284
+ claimed.set(req, key)
285
+ return undefined
286
+ },
287
+ async onResponse(res, req) {
288
+ const key = claimed.get(req)
289
+ if (key === undefined) return res // not a claimed request (safe method / no key / a replay)
290
+ claimed.delete(req)
291
+ if (!shouldCache(res)) {
292
+ await store.release(key)
293
+ return res
294
+ }
295
+ // Buffer the body (consumes `res`), so a fresh Response is returned in its place.
296
+ let captured: Awaited<ReturnType<typeof captureBody>>
297
+ try {
298
+ captured = await captureBody(res, maxBytes)
299
+ } catch (err) {
300
+ await store.release(key)
301
+ throw err
302
+ }
303
+ if (!("bytes" in captured)) {
304
+ await store.release(key) // too large to store — return it, but don't cache
305
+ return captured.response
306
+ }
307
+ const bytes = captured.bytes
308
+ const headers = [...captured.response.headers].filter(([name]) => name !== "set-cookie")
309
+ await store.complete(key, { status: res.status, headers, body: toBase64(bytes) }, ttlMs)
310
+ return captured.response
311
+ },
312
+ }
313
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `@nifrajs/middleware` — composable, dependency-free middleware + plugins applied via `app.use()`.
3
+ * The hardening set (`cors`, `rateLimit`, `securityHeaders`) returns a `@nifrajs/core` `Middleware`
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
6
+ * `c.requestId` into the handler context.
7
+ */
8
+
9
+ export {
10
+ type BasicAuthPlugin,
11
+ type BasicAuthStaticOptions,
12
+ type BasicAuthVerifyOptions,
13
+ basicAuth,
14
+ } from "./basic-auth.ts"
15
+ export { type BodyLimitOptions, bodyLimit } from "./body-limit.ts"
16
+ export {
17
+ type CachedResponse,
18
+ type CacheOptions,
19
+ cache,
20
+ MemoryResponseCache,
21
+ type MemoryResponseCacheOptions,
22
+ type ResponseCacheStore,
23
+ responseCache,
24
+ } from "./cache.ts"
25
+ export { type CacheControlOptions, cacheControl } from "./cache-control.ts"
26
+ export { type Composable, combine, namedCombine } from "./combine.ts"
27
+ export { type CompressionOptions, compression } from "./compression.ts"
28
+ export { type CorsOptions, cors } from "./cors.ts"
29
+ export {
30
+ type CsrfOptions,
31
+ createCsrfToken,
32
+ csrf,
33
+ verifyCsrfToken,
34
+ } from "./csrf.ts"
35
+ export { type ETagOptions, etag } from "./etag.ts"
36
+ export { type HealthcheckOptions, healthcheck } from "./healthcheck.ts"
37
+ export {
38
+ type IdempotencyClaim,
39
+ type IdempotencyOptions,
40
+ type IdempotencyRecord,
41
+ type IdempotencyStore,
42
+ idempotency,
43
+ MemoryIdempotencyStore,
44
+ type MemoryIdempotencyStoreOptions,
45
+ } from "./idempotency.ts"
46
+ export { type IpMatcher, type IpRestrictionOptions, ipRestriction } from "./ip-restriction.ts"
47
+ export {
48
+ type JwkKey,
49
+ type JwksOptions,
50
+ type JwtAlgorithm,
51
+ type JwtClaims,
52
+ type JwtHeader,
53
+ type JwtKeyResolver,
54
+ type JwtOptions,
55
+ type JwtPlugin,
56
+ type JwtVerificationKey,
57
+ jwk,
58
+ jwks,
59
+ jwt,
60
+ tryVerifyJwt,
61
+ type VerifiedJwt,
62
+ type VerifyJwtOptions,
63
+ type VerifyJwtResult,
64
+ verifyJwt,
65
+ } from "./jwt.ts"
66
+ export {
67
+ type LanguageMatch,
68
+ type LanguageOptions,
69
+ language,
70
+ pickLanguage,
71
+ } from "./language.ts"
72
+ export { type LoggerOptions, logger, type RequestLogFields } from "./logger.ts"
73
+ export { type MethodOverrideOptions, methodOverride } from "./method-override.ts"
74
+ export {
75
+ buildOpenApiDocument,
76
+ type OpenApiInfo,
77
+ type OpenApiOptions,
78
+ type OpenApiServer,
79
+ type OpenApiTag,
80
+ type OpenApiUiOptions,
81
+ openapi,
82
+ type RouteLike,
83
+ type SecurityRequirement,
84
+ } from "./openapi.ts"
85
+ export { type PoweredByOptions, poweredBy } from "./powered-by.ts"
86
+ export { type PrettyJsonOptions, prettyJson } from "./pretty-json.ts"
87
+ export {
88
+ MemoryStore,
89
+ type MemoryStoreOptions,
90
+ type RateLimitOptions,
91
+ type RateLimitResult,
92
+ type RateLimitStore,
93
+ rateLimit,
94
+ } from "./rate-limit.ts"
95
+ export { type RequestIdOptions, requestId } from "./request-id.ts"
96
+ export { type SecurityHeadersOptions, securityHeaders } from "./security-headers.ts"
97
+ export { type TimingControls, type TimingMetric, type TimingOptions, timing } from "./timing.ts"
98
+ export {
99
+ type ApiKeyStaticOptions,
100
+ type ApiKeyVerifyOptions,
101
+ type AuthPlugin,
102
+ apiKey,
103
+ type BearerOptions,
104
+ bearer,
105
+ } from "./token-auth.ts"
106
+ export {
107
+ appendTrailingSlash,
108
+ type TrailingSlashOptions,
109
+ trimTrailingSlash,
110
+ } from "./trailing-slash.ts"