@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
@@ -0,0 +1,45 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ export interface CacheControlOptions {
4
+ /** Methods whose responses get the header. Default `["GET", "HEAD"]`. */
5
+ readonly methods?: readonly string[]
6
+ /** Predicate over the response status. Default: 2xx only. */
7
+ readonly status?: (status: number) => boolean
8
+ /** When `true` (default), don't overwrite a `Cache-Control` the handler already set. */
9
+ readonly respectExisting?: boolean
10
+ }
11
+
12
+ /**
13
+ * Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
14
+ * function of the request (return `undefined` to leave a response untouched — e.g. cache by path).
15
+ * Defaults to `GET`/`HEAD` + 2xx, and never clobbers a `Cache-Control` the handler set itself.
16
+ *
17
+ * ```ts
18
+ * app.use(cacheControl("public, max-age=3600, stale-while-revalidate=60"))
19
+ * // or per-path:
20
+ * app.use(cacheControl((req) =>
21
+ * new URL(req.url).pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : undefined,
22
+ * ))
23
+ * ```
24
+ */
25
+ export function cacheControl(
26
+ value: string | ((request: Request) => string | undefined),
27
+ options: CacheControlOptions = {},
28
+ ) {
29
+ const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
30
+ const statusOk = options.status ?? ((status: number) => status >= 200 && status < 300)
31
+ const respectExisting = options.respectExisting !== false
32
+ const resolve = typeof value === "function" ? value : () => value
33
+ return definePlugin("cacheControl", (app) =>
34
+ app.onResponse((res, req) => {
35
+ if (!methods.has(req.method)) return res
36
+ if (!statusOk(res.status)) return res
37
+ if (respectExisting && res.headers.has("cache-control")) return res
38
+ const directive = resolve(req)
39
+ if (directive === undefined) return res
40
+ const headers = new Headers(res.headers)
41
+ headers.set("cache-control", directive)
42
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
43
+ }),
44
+ )
45
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,277 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import type { MaybePromise } from "./_utils.ts"
3
+
4
+ export interface CachedResponse {
5
+ readonly status: number
6
+ readonly statusText: string
7
+ readonly headers: readonly [string, string][]
8
+ readonly body: Uint8Array
9
+ readonly expiresAt: number
10
+ readonly storedAt: number
11
+ }
12
+
13
+ export interface ResponseCacheStore {
14
+ get(key: string): MaybePromise<CachedResponse | undefined>
15
+ set(key: string, response: CachedResponse): MaybePromise<void>
16
+ delete?(key: string): MaybePromise<void>
17
+ }
18
+
19
+ export interface MemoryResponseCacheOptions {
20
+ /** Maximum entries retained. Default `1000`. Oldest entries are evicted first. */
21
+ readonly maxEntries?: number
22
+ /** Allow in-process cache in production. Off by default because it is per-instance. */
23
+ readonly allowInProduction?: boolean
24
+ }
25
+
26
+ export class MemoryResponseCache implements ResponseCacheStore {
27
+ private readonly entries = new Map<string, CachedResponse>()
28
+ private readonly maxEntries: number
29
+
30
+ constructor(options: MemoryResponseCacheOptions = {}) {
31
+ const env = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV
32
+ if (options.allowInProduction !== true && env === "production") {
33
+ throw new Error(
34
+ "MemoryResponseCache is per-instance and unsafe in production. Use a shared cache store, " +
35
+ "or pass { allowInProduction: true } for a single-instance deploy.",
36
+ )
37
+ }
38
+ this.maxEntries = options.maxEntries ?? 1000
39
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
40
+ throw new Error("MemoryResponseCache: maxEntries must be a positive integer")
41
+ }
42
+ }
43
+
44
+ get(key: string): CachedResponse | undefined {
45
+ const entry = this.entries.get(key)
46
+ if (entry === undefined) return undefined
47
+ if (Date.now() >= entry.expiresAt) {
48
+ this.entries.delete(key)
49
+ return undefined
50
+ }
51
+ return entry
52
+ }
53
+
54
+ set(key: string, response: CachedResponse): void {
55
+ if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
56
+ const oldest = this.entries.keys().next().value
57
+ if (oldest !== undefined) this.entries.delete(oldest)
58
+ }
59
+ this.entries.set(key, response)
60
+ }
61
+
62
+ delete(key: string): void {
63
+ this.entries.delete(key)
64
+ }
65
+ }
66
+
67
+ export interface CacheOptions {
68
+ readonly store: ResponseCacheStore
69
+ /** Time to live in milliseconds. */
70
+ readonly ttlMs: number
71
+ /** Methods cached. Default `["GET", "HEAD"]`. */
72
+ readonly methods?: readonly string[]
73
+ /** Status predicate. Default: `200` only. */
74
+ readonly status?: (status: number) => boolean
75
+ /** Header names included in the cache key and emitted in `Vary`. */
76
+ readonly vary?: readonly string[]
77
+ /** Custom cache key. Receives the normalized `vary` headers through the request itself. */
78
+ readonly key?: (request: Request) => string
79
+ /** Maximum response bytes to store. Default `1_000_000`. Larger responses pass through. */
80
+ readonly maxBytes?: number
81
+ /** Respect request `Cache-Control: no-cache/no-store`. Default `true`. */
82
+ readonly respectRequestCacheControl?: boolean
83
+ /** Respect response `Cache-Control: private/no-store`. Default `true`. */
84
+ readonly respectResponseCacheControl?: boolean
85
+ /** Cache responses with `Set-Cookie`. Default `false`. */
86
+ readonly cacheSetCookie?: boolean
87
+ /** Response header for cache status. Default `"x-nifra-cache"`; set `false` to disable. */
88
+ readonly cacheStatusHeader?: string | false
89
+ }
90
+
91
+ function parseLength(value: string | null): number | undefined {
92
+ if (value === null) return undefined
93
+ if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
94
+ return Number(value)
95
+ }
96
+
97
+ function cacheControlHas(headers: Headers, names: readonly string[]): boolean {
98
+ const value = headers.get("cache-control")
99
+ if (value === null) return false
100
+ const parts = value
101
+ .toLowerCase()
102
+ .split(",")
103
+ .map((part) => part.trim().split("=", 1)[0])
104
+ return names.some((name) => parts.includes(name))
105
+ }
106
+
107
+ function appendVary(headers: Headers, vary: readonly string[]): void {
108
+ if (vary.length === 0) return
109
+ const existing = headers
110
+ .get("vary")
111
+ ?.split(",")
112
+ .map((v) => v.trim().toLowerCase())
113
+ const seen = new Set(existing ?? [])
114
+ const additions: string[] = []
115
+ for (const header of vary) {
116
+ const normalized = header.toLowerCase()
117
+ if (!seen.has(normalized)) {
118
+ seen.add(normalized)
119
+ additions.push(header)
120
+ }
121
+ }
122
+ if (additions.length === 0) return
123
+ const current = headers.get("vary")
124
+ headers.set(
125
+ "vary",
126
+ current === null ? additions.join(", ") : `${current}, ${additions.join(", ")}`,
127
+ )
128
+ }
129
+
130
+ function defaultKey(req: Request, vary: readonly string[]): string {
131
+ let key = `${req.method.toUpperCase()} ${req.url}`
132
+ for (const header of vary) key += `\n${header.toLowerCase()}:${req.headers.get(header) ?? ""}`
133
+ return key
134
+ }
135
+
136
+ async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array | null> {
137
+ const body = res.clone().body
138
+ if (body === null) return new Uint8Array()
139
+ const reader = body.getReader()
140
+ const chunks: Uint8Array[] = []
141
+ let total = 0
142
+ try {
143
+ while (true) {
144
+ const { done, value } = await reader.read()
145
+ if (done) {
146
+ const out = new Uint8Array(total)
147
+ let offset = 0
148
+ for (const chunk of chunks) {
149
+ out.set(chunk, offset)
150
+ offset += chunk.byteLength
151
+ }
152
+ return out
153
+ }
154
+ total += value.byteLength
155
+ if (total > maxBytes) {
156
+ await reader.cancel()
157
+ return null
158
+ }
159
+ chunks.push(value)
160
+ }
161
+ } catch {
162
+ return null
163
+ }
164
+ }
165
+
166
+ function withStatusHeader(
167
+ res: Response,
168
+ header: string | false,
169
+ status: "HIT" | "MISS" | "BYPASS",
170
+ ): Response {
171
+ if (header === false) return res
172
+ const headers = new Headers(res.headers)
173
+ headers.set(header, status)
174
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
175
+ }
176
+
177
+ /**
178
+ * Full response cache for small, cacheable responses. Use a shared `store` in production. The
179
+ * middleware honors `Cache-Control` by default, avoids `Set-Cookie`, caps stored bytes, emits `Age`,
180
+ * and keeps `Vary` headers aligned with the cache key.
181
+ */
182
+ export function cache(options: CacheOptions): Middleware {
183
+ const { store } = options
184
+ const ttlMs = options.ttlMs
185
+ if (!Number.isInteger(ttlMs) || ttlMs < 1)
186
+ throw new Error("cache: ttlMs must be a positive integer")
187
+ const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
188
+ const statusOk = options.status ?? ((status: number) => status === 200)
189
+ const vary = options.vary ?? []
190
+ const keyOf = options.key ?? ((req: Request) => defaultKey(req, vary))
191
+ const maxBytes = options.maxBytes ?? 1_000_000
192
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
193
+ throw new Error("cache: maxBytes must be a non-negative integer")
194
+ }
195
+ const respectRequestCacheControl = options.respectRequestCacheControl !== false
196
+ const respectResponseCacheControl = options.respectResponseCacheControl !== false
197
+ const cacheSetCookie = options.cacheSetCookie === true
198
+ const cacheStatusHeader =
199
+ options.cacheStatusHeader === undefined ? "x-nifra-cache" : options.cacheStatusHeader
200
+ if (cacheStatusHeader !== false && cacheStatusHeader.trim() === "") {
201
+ throw new Error("cache: cacheStatusHeader is empty")
202
+ }
203
+ const hits = new WeakSet<Request>()
204
+
205
+ return {
206
+ name: "cache",
207
+ async onRequest(req) {
208
+ if (!methods.has(req.method.toUpperCase())) return undefined
209
+ if (respectRequestCacheControl && cacheControlHas(req.headers, ["no-cache", "no-store"])) {
210
+ return undefined
211
+ }
212
+ const key = keyOf(req)
213
+ const entry = await store.get(key)
214
+ if (entry === undefined) return undefined
215
+ if (Date.now() >= entry.expiresAt) {
216
+ await store.delete?.(key)
217
+ return undefined
218
+ }
219
+ hits.add(req)
220
+ const headers = new Headers([...entry.headers])
221
+ headers.set("age", String(Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))))
222
+ if (cacheStatusHeader !== false) headers.set(cacheStatusHeader, "HIT")
223
+ return new Response(req.method === "HEAD" ? null : entry.body.slice(), {
224
+ status: entry.status,
225
+ statusText: entry.statusText,
226
+ headers,
227
+ })
228
+ },
229
+ async onResponse(res, req) {
230
+ if (hits.has(req)) {
231
+ hits.delete(req)
232
+ return res
233
+ }
234
+ if (!methods.has(req.method.toUpperCase())) return res
235
+ if (!statusOk(res.status)) return res
236
+ if (respectRequestCacheControl && cacheControlHas(req.headers, ["no-store"])) {
237
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS")
238
+ }
239
+ if (respectResponseCacheControl && cacheControlHas(res.headers, ["private", "no-store"])) {
240
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS")
241
+ }
242
+ if (!cacheSetCookie && res.headers.has("set-cookie")) {
243
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS")
244
+ }
245
+ const declared = parseLength(res.headers.get("content-length"))
246
+ if (declared !== undefined && declared > maxBytes) {
247
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS")
248
+ }
249
+ const body = await readBytesCapped(res, maxBytes)
250
+ if (body === null) return withStatusHeader(res, cacheStatusHeader, "BYPASS")
251
+
252
+ const headers = new Headers(res.headers)
253
+ headers.delete("age")
254
+ if (cacheStatusHeader !== false) headers.delete(cacheStatusHeader)
255
+ appendVary(headers, vary)
256
+ const now = Date.now()
257
+ await store.set(keyOf(req), {
258
+ status: res.status,
259
+ statusText: res.statusText,
260
+ headers: [...headers.entries()],
261
+ body,
262
+ expiresAt: now + ttlMs,
263
+ storedAt: now,
264
+ })
265
+ const outgoing = new Headers(res.headers)
266
+ appendVary(outgoing, vary)
267
+ if (cacheStatusHeader !== false) outgoing.set(cacheStatusHeader, "MISS")
268
+ return new Response(res.body, {
269
+ status: res.status,
270
+ statusText: res.statusText,
271
+ headers: outgoing,
272
+ })
273
+ },
274
+ }
275
+ }
276
+
277
+ export const responseCache = cache
package/src/combine.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { definePlugin, type Middleware, type NifraPlugin } from "@nifrajs/core"
2
+
3
+ export type Composable = Middleware | NifraPlugin
4
+
5
+ function applyAll(app: Parameters<NifraPlugin>[0], items: readonly Composable[]) {
6
+ let current = app
7
+ for (const item of items) {
8
+ // The public `use` overload accepts both shapes; this helper intentionally preserves runtime
9
+ // composition while leaving advanced context type threading to direct `.use(...)` chains.
10
+ current = current.use(item as never)
11
+ }
12
+ return current
13
+ }
14
+
15
+ /** Compose middleware/plugins into one reusable bundle. Individual named plugins still dedupe. */
16
+ export function combine(...items: readonly Composable[]): NifraPlugin {
17
+ return ((app) => applyAll(app, items)) as NifraPlugin
18
+ }
19
+
20
+ /** Compose middleware/plugins into one idempotent named bundle. */
21
+ export function namedCombine(name: string, ...items: readonly Composable[]): NifraPlugin {
22
+ if (name.trim() === "") throw new Error("namedCombine: name is empty")
23
+ return definePlugin(name, (app) => applyAll(app, items))
24
+ }
@@ -0,0 +1,111 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ export interface CompressionOptions {
4
+ /** Don't compress bodies smaller than this many bytes. Default `1024`. Enforced by peeking the body
5
+ * (see below) since runtimes rarely expose `Content-Length` on a constructed `Response`. */
6
+ readonly threshold?: number
7
+ /** Decide whether a `Content-Type` is worth compressing. Default: text, JSON/+json, JS, XML/+xml,
8
+ * NDJSON, wasm, SVG. Already-compressed media (images, video, archives) is skipped. */
9
+ readonly compressible?: (contentType: string) => boolean
10
+ }
11
+
12
+ // Text-like payloads benefit from gzip; binary media (images/video/archives) is already compressed.
13
+ const COMPRESSIBLE =
14
+ /^(?:text\/|application\/(?:json|[\w.-]+\+json|javascript|xml|[\w.-]+\+xml|wasm|x-ndjson)|image\/svg\+xml)/i
15
+
16
+ const defaultCompressible = (contentType: string): boolean => COMPRESSIBLE.test(contentType)
17
+
18
+ const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
19
+ const out = new Uint8Array(total)
20
+ let offset = 0
21
+ for (const chunk of chunks) {
22
+ out.set(chunk, offset)
23
+ offset += chunk.byteLength
24
+ }
25
+ return out
26
+ }
27
+
28
+ const gzipHeaders = (source: Headers): Headers => {
29
+ const headers = new Headers(source)
30
+ headers.set("content-encoding", "gzip")
31
+ headers.delete("content-length") // compressed length is unknown up front → chunked
32
+ const vary = headers.get("vary")
33
+ if (vary === null) headers.set("vary", "Accept-Encoding")
34
+ else if (!vary.toLowerCase().split(",").includes("accept-encoding"))
35
+ headers.set("vary", `${vary}, Accept-Encoding`)
36
+ return headers
37
+ }
38
+
39
+ /**
40
+ * Transparently **gzip** responses when the client sends `Accept-Encoding: gzip` and the body is a
41
+ * compressible type larger than `threshold`. Uses the Web-standard `CompressionStream` (streaming, no
42
+ * full-body buffering), so it runs on every nifra runtime including the edge. gzip is the one encoding
43
+ * `CompressionStream` guarantees everywhere; brotli isn't part of the standard, so it's not offered.
44
+ *
45
+ * The body is **peeked** up to `threshold` bytes to enforce the size floor: a response that ends below
46
+ * the threshold is sent uncompressed (gzip's ~20-byte overhead would enlarge it), and only larger
47
+ * bodies are compressed — the buffered prefix is replayed and the remainder streamed. (Runtimes rarely
48
+ * set `Content-Length` on a constructed `Response`, so the header alone can't gate this.)
49
+ *
50
+ * Skips: clients that don't accept gzip, already-encoded responses, bodyless responses (204/304/HEAD),
51
+ * `Range` responses (206 / `Content-Range`), `Cache-Control: no-transform`, and non-compressible types.
52
+ * Adds `Vary: Accept-Encoding`.
53
+ *
54
+ * ```ts
55
+ * app.use(compression())
56
+ * ```
57
+ */
58
+ export function compression(options: CompressionOptions = {}) {
59
+ const threshold = options.threshold ?? 1024
60
+ const isCompressible = options.compressible ?? defaultCompressible
61
+ return definePlugin("compression", (app) =>
62
+ app.onResponse(async (res, req) => {
63
+ const body = res.body
64
+ if (body === null) return res // 204/304/HEAD — nothing to compress
65
+ if (!(req.headers.get("accept-encoding") ?? "").toLowerCase().includes("gzip")) return res
66
+ if (res.headers.has("content-encoding")) return res // already encoded
67
+ if (res.status === 206 || res.headers.has("content-range")) return res // ranges pass through
68
+ if ((res.headers.get("cache-control") ?? "").includes("no-transform")) return res
69
+ if (!isCompressible(res.headers.get("content-type") ?? "")) return res
70
+ const declared = res.headers.get("content-length")
71
+ if (declared !== null && Number(declared) < threshold) return res // fast path when length is known
72
+
73
+ // Peek up to `threshold` bytes to decide whether compressing is worth it.
74
+ const reader = body.getReader()
75
+ const buffered: Uint8Array[] = []
76
+ let total = 0
77
+ while (total < threshold) {
78
+ const { done, value } = await reader.read()
79
+ if (done) {
80
+ // Body ended below the threshold — too small to be worth gzipping. Emit as-is.
81
+ return new Response(concat(buffered, total), {
82
+ status: res.status,
83
+ statusText: res.statusText,
84
+ headers: res.headers,
85
+ })
86
+ }
87
+ buffered.push(value)
88
+ total += value.byteLength
89
+ }
90
+ // Over the threshold: replay the buffered prefix, then stream the rest — all through gzip.
91
+ // (No explicit generic: it lets the chunk type infer from the non-shared reader so the stream
92
+ // matches `CompressionStream`'s `Uint8Array` typing.)
93
+ const source = new ReadableStream({
94
+ start(controller) {
95
+ for (const chunk of buffered) controller.enqueue(chunk)
96
+ },
97
+ async pull(controller) {
98
+ const { done, value } = await reader.read()
99
+ if (done) controller.close()
100
+ else controller.enqueue(value)
101
+ },
102
+ cancel: (reason) => reader.cancel(reason),
103
+ })
104
+ return new Response(source.pipeThrough(new CompressionStream("gzip")), {
105
+ status: res.status,
106
+ statusText: res.statusText,
107
+ headers: gzipHeaders(res.headers),
108
+ })
109
+ }),
110
+ )
111
+ }
@@ -0,0 +1,36 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import type { Context, Middleware } from "@nifrajs/core"
3
+
4
+ const storage = new AsyncLocalStorage<Context>()
5
+
6
+ /**
7
+ * Store the current nifra `Context` in `AsyncLocalStorage` for helpers that run away from the handler
8
+ * argument, e.g. repository/logger functions called deep in the stack.
9
+ *
10
+ * Import from `@nifrajs/middleware/context-storage` so ordinary `@nifrajs/middleware` imports stay
11
+ * node-compat-free on edge runtimes. The runtime must support `node:async_hooks` AsyncLocalStorage.
12
+ */
13
+ export function contextStorage(): Middleware {
14
+ return {
15
+ name: "context-storage",
16
+ around(context, next) {
17
+ return storage.run(context, next)
18
+ },
19
+ }
20
+ }
21
+
22
+ /** Return the current request context, or throw when no context-storage wrapper is active. */
23
+ export function getContext<C extends Context = Context>(): C {
24
+ const context = tryGetContext<C>()
25
+ if (context === undefined) {
26
+ throw new Error(
27
+ "contextStorage: no active context. Add app.use(contextStorage()) before the route and call getContext() during that request.",
28
+ )
29
+ }
30
+ return context
31
+ }
32
+
33
+ /** Return the current request context, or `undefined` outside a context-storage request. */
34
+ export function tryGetContext<C extends Context = Context>(): C | undefined {
35
+ return storage.getStore() as C | undefined
36
+ }
package/src/cors.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import { withHeaders } from "./_utils.ts"
3
+
4
+ export interface CorsOptions {
5
+ /** Allowed origin(s): `"*"`, an exact origin, a list, or a predicate. Default `"*"`. */
6
+ readonly origin?: string | ReadonlyArray<string> | ((origin: string) => boolean)
7
+ /** Methods advertised in the preflight response. Default the common verbs. */
8
+ readonly methods?: ReadonlyArray<string>
9
+ /** Headers allowed on the actual request. Default: reflect the preflight's requested headers. */
10
+ readonly allowedHeaders?: ReadonlyArray<string>
11
+ /** Response headers exposed to the browser. */
12
+ readonly exposedHeaders?: ReadonlyArray<string>
13
+ /** Allow credentials (cookies / `Authorization`). Cannot be combined with `origin: "*"`. Default false. */
14
+ readonly credentials?: boolean
15
+ /** Preflight cache duration, in seconds. */
16
+ readonly maxAge?: number
17
+ }
18
+
19
+ const DEFAULT_METHODS: ReadonlyArray<string> = [
20
+ "GET",
21
+ "HEAD",
22
+ "POST",
23
+ "PUT",
24
+ "PATCH",
25
+ "DELETE",
26
+ "OPTIONS",
27
+ ]
28
+
29
+ /** Resolve the `Access-Control-Allow-Origin` value for a request, or `null` to omit it. */
30
+ function resolveAllowOrigin(
31
+ option: NonNullable<CorsOptions["origin"]>,
32
+ requestOrigin: string | null,
33
+ ): string | null {
34
+ if (option === "*") return "*"
35
+ if (requestOrigin === null) return null
36
+ if (typeof option === "function") return option(requestOrigin) ? requestOrigin : null
37
+ if (Array.isArray(option)) return option.includes(requestOrigin) ? requestOrigin : null
38
+ return option === requestOrigin ? requestOrigin : null
39
+ }
40
+
41
+ /**
42
+ * CORS as a {@link Middleware}. Preflight (`OPTIONS` + `Access-Control-Request-Method`)
43
+ * short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
44
+ * `onResponse`, so they also land on errors, 404s, and the preflight itself.
45
+ *
46
+ * Throws at construction if `credentials: true` is paired with `origin: "*"` — the
47
+ * browser rejects that combination, so we fail loud instead of shipping dead CORS.
48
+ */
49
+ export function cors(options: CorsOptions = {}): Middleware {
50
+ const origin = options.origin ?? "*"
51
+ const credentials = options.credentials ?? false
52
+ if (credentials && origin === "*") {
53
+ throw new Error(
54
+ 'cors: `credentials: true` cannot be combined with `origin: "*"` — list explicit origin(s).',
55
+ )
56
+ }
57
+ const methods = (options.methods ?? DEFAULT_METHODS).join(", ")
58
+ const allowedHeaders = options.allowedHeaders?.join(", ")
59
+ const exposedHeaders = options.exposedHeaders?.join(", ")
60
+ const maxAge = options.maxAge
61
+
62
+ return {
63
+ name: "cors",
64
+ onRequest(req) {
65
+ const isPreflight =
66
+ req.method === "OPTIONS" && req.headers.has("access-control-request-method")
67
+ if (!isPreflight) return undefined
68
+ const headers = new Headers()
69
+ headers.set("Access-Control-Allow-Methods", methods)
70
+ const requested = allowedHeaders ?? req.headers.get("access-control-request-headers")
71
+ if (requested) headers.set("Access-Control-Allow-Headers", requested)
72
+ if (maxAge !== undefined) headers.set("Access-Control-Max-Age", String(maxAge))
73
+ // Allow-Origin / Allow-Credentials are added by onResponse (runs on every response).
74
+ return new Response(null, { status: 204, headers })
75
+ },
76
+ onResponse(res, req) {
77
+ const allowOrigin = resolveAllowOrigin(origin, req.headers.get("origin"))
78
+ if (allowOrigin === null) return res
79
+ // Mutate in place on the common (mutable-headers) response; clone only for an immutable one.
80
+ return withHeaders(res, (headers) => {
81
+ headers.set("Access-Control-Allow-Origin", allowOrigin)
82
+ if (allowOrigin !== "*") headers.append("Vary", "Origin")
83
+ if (credentials) headers.set("Access-Control-Allow-Credentials", "true")
84
+ if (exposedHeaders !== undefined) {
85
+ headers.set("Access-Control-Expose-Headers", exposedHeaders)
86
+ }
87
+ })
88
+ },
89
+ }
90
+ }
package/src/csrf.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import {
3
+ base64UrlEncode,
4
+ hmacSha256,
5
+ jsonError,
6
+ parseCookies,
7
+ SAFE_METHODS,
8
+ secretBytes,
9
+ timingSafeEqualString,
10
+ verifyHmacSha256,
11
+ } from "./_utils.ts"
12
+
13
+ export interface CsrfOptions {
14
+ /** HMAC secret. Must be at least 32 bytes. */
15
+ readonly secret: string | Uint8Array
16
+ /** Cookie carrying the signed token. Default `"csrf-token"`. */
17
+ readonly cookie?: string
18
+ /** Header carrying the same signed token. Default `"x-csrf-token"`. */
19
+ readonly header?: string
20
+ /** Unsafe methods to protect. Default: every method except GET/HEAD/OPTIONS/TRACE. */
21
+ readonly methods?: readonly string[]
22
+ /** Allowed request origins. Default: same-origin derived from the request URL. */
23
+ readonly origins?: readonly string[]
24
+ /** Check Origin/Referer on protected requests. Default true. */
25
+ readonly checkOrigin?: boolean
26
+ }
27
+
28
+ const TOKEN_PREFIX = "v1"
29
+
30
+ function protectedMethod(method: string, configured: Set<string> | undefined): boolean {
31
+ return configured !== undefined ? configured.has(method) : !SAFE_METHODS.has(method)
32
+ }
33
+
34
+ function originAllowed(req: Request, origins: Set<string> | undefined): boolean {
35
+ const allowed = origins ?? new Set([new URL(req.url).origin])
36
+ const origin = req.headers.get("origin")
37
+ if (origin !== null) return allowed.has(origin)
38
+
39
+ const referer = req.headers.get("referer")
40
+ if (referer === null) return false
41
+ try {
42
+ return allowed.has(new URL(referer).origin)
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ export async function createCsrfToken(
49
+ secret: string | Uint8Array,
50
+ nonce?: string,
51
+ ): Promise<string> {
52
+ const key = secretBytes(secret, "csrf")
53
+ const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
54
+ if (!/^[A-Za-z0-9_-]{22,}$/.test(tokenNonce)) {
55
+ throw new Error("csrf: nonce must be base64url-like and at least 22 characters")
56
+ }
57
+ const payload = `${TOKEN_PREFIX}.${tokenNonce}`
58
+ return `${payload}.${await hmacSha256(payload, key)}`
59
+ }
60
+
61
+ export async function verifyCsrfToken(
62
+ token: string,
63
+ secret: string | Uint8Array,
64
+ ): Promise<boolean> {
65
+ const key = secretBytes(secret, "csrf")
66
+ const parts = token.split(".")
67
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || parts[1] === "" || parts[2] === "") {
68
+ return false
69
+ }
70
+ const [prefix, nonce, signature] = parts as [string, string, string]
71
+ return verifyHmacSha256(`${prefix}.${nonce}`, signature, key)
72
+ }
73
+
74
+ /**
75
+ * Signed double-submit CSRF protection. A protected request must carry the same signed token in a
76
+ * cookie and a header, and must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
77
+ */
78
+ export function csrf(options: CsrfOptions): Middleware {
79
+ const key = secretBytes(options.secret, "csrf")
80
+ const cookie = options.cookie ?? "csrf-token"
81
+ const header = (options.header ?? "x-csrf-token").toLowerCase()
82
+ const methods =
83
+ options.methods !== undefined ? new Set(options.methods.map((m) => m.toUpperCase())) : undefined
84
+ const origins = options.origins !== undefined ? new Set(options.origins) : undefined
85
+ const checkOrigin = options.checkOrigin !== false
86
+
87
+ return {
88
+ name: "csrf",
89
+ async onRequest(req) {
90
+ if (!protectedMethod(req.method, methods)) return undefined
91
+ if (checkOrigin && !originAllowed(req, origins)) return jsonError(403, "csrf_failed")
92
+
93
+ const cookieToken = parseCookies(req.headers.get("cookie"))[cookie]
94
+ const headerToken = req.headers.get(header)
95
+ if (cookieToken === undefined || headerToken === null) return jsonError(403, "csrf_failed")
96
+ if (!(await timingSafeEqualString(cookieToken, headerToken))) {
97
+ return jsonError(403, "csrf_failed")
98
+ }
99
+ return (await verifyCsrfToken(cookieToken, key)) ? undefined : jsonError(403, "csrf_failed")
100
+ },
101
+ }
102
+ }