@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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/_utils.d.ts +27 -0
- package/dist/_utils.d.ts.map +1 -0
- package/dist/_utils.js +141 -0
- package/dist/_utils.js.map +1 -0
- package/dist/basic-auth.d.ts +27 -0
- package/dist/basic-auth.d.ts.map +1 -0
- package/dist/basic-auth.js +64 -0
- package/dist/basic-auth.js.map +1 -0
- package/dist/body-limit.d.ts +19 -0
- package/dist/body-limit.d.ts.map +1 -0
- package/dist/body-limit.js +42 -0
- package/dist/body-limit.js.map +1 -0
- package/dist/cache-control.d.ts +23 -0
- package/dist/cache-control.d.ts.map +1 -0
- package/dist/cache-control.js +35 -0
- package/dist/cache-control.js.map +1 -0
- package/dist/cache.d.ts +60 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +224 -0
- package/dist/cache.js.map +1 -0
- package/dist/combine.d.ts +7 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +21 -0
- package/dist/combine.js.map +1 -0
- package/dist/compression.d.ts +29 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +105 -0
- package/dist/compression.js.map +1 -0
- package/dist/context-storage.d.ts +14 -0
- package/dist/context-storage.d.ts.map +1 -0
- package/dist/context-storage.js +30 -0
- package/dist/context-storage.js.map +1 -0
- package/dist/cors.d.ts +25 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +75 -0
- package/dist/cors.js.map +1 -0
- package/dist/csrf.d.ts +23 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +68 -0
- package/dist/csrf.js.map +1 -0
- package/dist/etag.d.ts +14 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/etag.js +95 -0
- package/dist/etag.js.map +1 -0
- package/dist/healthcheck.d.ts +25 -0
- package/dist/healthcheck.d.ts.map +1 -0
- package/dist/healthcheck.js +41 -0
- package/dist/healthcheck.js.map +1 -0
- package/dist/idempotency.d.ts +83 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +217 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/ip-restriction.d.ts +21 -0
- package/dist/ip-restriction.d.ts.map +1 -0
- package/dist/ip-restriction.js +161 -0
- package/dist/ip-restriction.js.map +1 -0
- package/dist/jwt.d.ts +82 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +327 -0
- package/dist/jwt.js.map +1 -0
- package/dist/language.d.ts +20 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/language.js +94 -0
- package/dist/language.js.map +1 -0
- package/dist/logger.d.ts +19 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +28 -0
- package/dist/logger.js.map +1 -0
- package/dist/method-override.d.ts +28 -0
- package/dist/method-override.d.ts.map +1 -0
- package/dist/method-override.js +67 -0
- package/dist/method-override.js.map +1 -0
- package/dist/openapi.d.ts +83 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +100 -0
- package/dist/openapi.js.map +1 -0
- package/dist/powered-by.d.ts +15 -0
- package/dist/powered-by.d.ts.map +1 -0
- package/dist/powered-by.js +23 -0
- package/dist/powered-by.js.map +1 -0
- package/dist/pretty-json.d.ts +18 -0
- package/dist/pretty-json.d.ts.map +1 -0
- package/dist/pretty-json.js +88 -0
- package/dist/pretty-json.js.map +1 -0
- package/dist/rate-limit.d.ts +92 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +172 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/request-id.d.ts +18 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-id.js +21 -0
- package/dist/request-id.js.map +1 -0
- package/dist/security-headers.d.ts +23 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +36 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/timing.d.ts +25 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +101 -0
- package/dist/timing.js.map +1 -0
- package/dist/token-auth.d.ts +65 -0
- package/dist/token-auth.d.ts.map +1 -0
- package/dist/token-auth.js +90 -0
- package/dist/token-auth.js.map +1 -0
- package/dist/trailing-slash.d.ts +23 -0
- package/dist/trailing-slash.d.ts.map +1 -0
- package/dist/trailing-slash.js +70 -0
- package/dist/trailing-slash.js.map +1 -0
- package/package.json +68 -0
- package/src/_utils.ts +163 -0
- package/src/basic-auth.ts +109 -0
- package/src/body-limit.ts +53 -0
- package/src/cache-control.ts +45 -0
- package/src/cache.ts +277 -0
- package/src/combine.ts +24 -0
- package/src/compression.ts +111 -0
- package/src/context-storage.ts +36 -0
- package/src/cors.ts +90 -0
- package/src/csrf.ts +102 -0
- package/src/etag.ts +99 -0
- package/src/healthcheck.ts +59 -0
- package/src/idempotency.ts +313 -0
- package/src/index.ts +110 -0
- package/src/ip-restriction.ts +187 -0
- package/src/jwt.ts +441 -0
- package/src/language.ts +109 -0
- package/src/logger.ts +44 -0
- package/src/method-override.ts +87 -0
- package/src/openapi.ts +174 -0
- package/src/powered-by.ts +31 -0
- package/src/pretty-json.ts +97 -0
- package/src/rate-limit.ts +248 -0
- package/src/request-id.ts +30 -0
- package/src/security-headers.ts +51 -0
- package/src/timing.ts +133 -0
- package/src/token-auth.ts +172 -0
- 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
|
+
}
|