@nifrajs/middleware 4.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/_utils.d.ts +15 -9
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +39 -40
- package/dist/_utils.js.map +1 -1
- package/dist/admission.d.ts +4 -2
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js +17 -5
- package/dist/admission.js.map +1 -1
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +8 -13
- package/dist/basic-auth.js.map +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +4 -2
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +7 -4
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +58 -25
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +58 -1
- package/dist/compression.js.map +1 -1
- package/dist/conditional.d.ts.map +1 -1
- package/dist/conditional.js +1 -1
- package/dist/conditional.js.map +1 -1
- package/dist/content-negotiation.d.ts.map +1 -1
- package/dist/content-negotiation.js +3 -1
- package/dist/content-negotiation.js.map +1 -1
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +10 -5
- package/dist/csrf.js.map +1 -1
- package/dist/healthcheck.d.ts.map +1 -1
- package/dist/healthcheck.js +3 -1
- package/dist/healthcheck.js.map +1 -1
- package/dist/idempotency.d.ts +20 -4
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +44 -17
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +17 -2
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +70 -31
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +12 -0
- package/dist/language.js.map +1 -1
- package/dist/openapi.d.ts +19 -4
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +24 -8
- package/dist/openapi.js.map +1 -1
- package/dist/rate-limit.d.ts +31 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +160 -26
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +1 -1
- package/dist/request-id.js.map +1 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +1 -1
- package/dist/timing.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +8 -13
- package/dist/token-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +61 -35
- package/src/admission.ts +15 -5
- package/src/basic-auth.ts +15 -13
- package/src/cache-control.ts +5 -2
- package/src/cache.ts +68 -28
- package/src/compression.ts +84 -4
- package/src/conditional.ts +8 -4
- package/src/content-negotiation.ts +3 -1
- package/src/csrf.ts +10 -6
- package/src/healthcheck.ts +4 -1
- package/src/idempotency.ts +77 -20
- package/src/index.ts +3 -0
- package/src/jwt.ts +106 -34
- package/src/language.ts +11 -0
- package/src/openapi.ts +52 -10
- package/src/rate-limit.ts +233 -41
- package/src/request-id.ts +7 -3
- package/src/timing.ts +8 -4
- package/src/token-auth.ts +18 -16
package/dist/token-auth.js
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance";
|
|
2
2
|
import { defineIdentityPlugin } from "@nifrajs/core/server";
|
|
3
|
-
import { bearerToken, guardName } from "./_utils.js";
|
|
3
|
+
import { bearerToken, guardName, installAuthGuard } from "./_utils.js";
|
|
4
4
|
function createTokenAuth(config) {
|
|
5
|
-
// Per-request principal cache: verify runs once
|
|
5
|
+
// Per-request principal cache: verify runs once, in the guard; reads are free and can't desync.
|
|
6
6
|
const store = new WeakMap();
|
|
7
7
|
const reject = () => Response.json({ ok: false, error: "unauthorized" }, {
|
|
8
8
|
status: 401,
|
|
9
9
|
headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
|
|
10
10
|
});
|
|
11
|
-
const plugin = defineIdentityPlugin(guardName(config.name), (app) => app.
|
|
12
|
-
const token = config.extract(
|
|
11
|
+
const plugin = defineIdentityPlugin(guardName(config.name), (app) => installAuthGuard(app, config.name, config.optional, store, (header) => {
|
|
12
|
+
const token = config.extract(header);
|
|
13
13
|
// Empty string is treated as "no credential" - never passed to verify.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
store.set(c.req, principal);
|
|
17
|
-
return undefined; // authorized - continue to the handler
|
|
18
|
-
}
|
|
19
|
-
return config.optional ? undefined : reject(); // 401 short-circuits (skips the handler)
|
|
20
|
-
}));
|
|
14
|
+
return token !== undefined && token !== "" ? config.verify(token) : null;
|
|
15
|
+
}, reject));
|
|
21
16
|
const instrumented = config.optional
|
|
22
17
|
? plugin
|
|
23
18
|
: withRouteAssurance(plugin, {
|
|
@@ -44,7 +39,7 @@ export function bearer(options) {
|
|
|
44
39
|
const realm = options.realm ?? "api";
|
|
45
40
|
return createTokenAuth({
|
|
46
41
|
name: "bearer",
|
|
47
|
-
extract: (
|
|
42
|
+
extract: (header) => bearerToken(header("authorization")) ?? undefined,
|
|
48
43
|
verify: options.verify,
|
|
49
44
|
optional: options.optional === true,
|
|
50
45
|
challenge: `Bearer realm="${realm}"`,
|
|
@@ -88,7 +83,7 @@ export function apiKey(options) {
|
|
|
88
83
|
const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify;
|
|
89
84
|
return createTokenAuth({
|
|
90
85
|
name: "apiKey",
|
|
91
|
-
extract: (
|
|
86
|
+
extract: (get) => get(header) ?? undefined,
|
|
92
87
|
verify,
|
|
93
88
|
optional: options.optional === true,
|
|
94
89
|
});
|
package/dist/token-auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token-auth.js","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAC7E,OAAO,EAAE,oBAAoB,EAAuB,MAAM,sBAAsB,CAAA;AAChF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"token-auth.js","sourceRoot":"","sources":["../src/token-auth.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/middleware",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Composable middleware for nifra - auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"access": "public"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@nifrajs/schema": "
|
|
52
|
+
"@nifrajs/schema": "5.1.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@nifrajs/core": "^
|
|
55
|
+
"@nifrajs/core": "^5.1.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@nifrajs/core": "workspace:*",
|
package/src/_utils.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export type MaybePromise<T> = T | Promise<T>
|
|
2
2
|
|
|
3
|
-
import type { NodeResponseContext } from "@nifrajs/core/server"
|
|
4
|
-
import {
|
|
3
|
+
import type { AnyServer, NodeResponseContext } from "@nifrajs/core/server"
|
|
4
|
+
import {
|
|
5
|
+
authenticated,
|
|
6
|
+
markAuthenticatedRequest,
|
|
7
|
+
parseCookies as parseCoreCookies,
|
|
8
|
+
rejected,
|
|
9
|
+
writableResponse,
|
|
10
|
+
} from "@nifrajs/core/server"
|
|
5
11
|
|
|
6
12
|
export const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"])
|
|
7
13
|
|
|
@@ -30,6 +36,47 @@ export function guardName(base: string): string {
|
|
|
30
36
|
return `${base}#${guardInstances}`
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
/** One request header by name, or `null`: what a credential extractor reads. */
|
|
40
|
+
export type HeaderGet = (name: string) => string | null
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Install a credential guard. Required, it is an `authenticate()` stage, so a request without a
|
|
44
|
+
* principal is refused before its body or query is read or validated, and before any `derive` or
|
|
45
|
+
* `beforeHandle`. Optional never refuses, so it stays a `beforeHandle` that records a principal when
|
|
46
|
+
* the request carries one.
|
|
47
|
+
*/
|
|
48
|
+
export function installAuthGuard<P>(
|
|
49
|
+
app: AnyServer,
|
|
50
|
+
id: string,
|
|
51
|
+
optional: boolean,
|
|
52
|
+
store: WeakMap<Request, P>,
|
|
53
|
+
verify: (header: HeaderGet) => MaybePromise<P | null | undefined>,
|
|
54
|
+
refuse: () => Response,
|
|
55
|
+
): AnyServer {
|
|
56
|
+
if (optional) {
|
|
57
|
+
return app.beforeHandle(async (c: { readonly req: Request }) => {
|
|
58
|
+
const principal = await verify((name) => c.req.headers.get(name))
|
|
59
|
+
if (principal !== null && principal !== undefined) {
|
|
60
|
+
store.set(c.req, principal)
|
|
61
|
+
markAuthenticatedRequest(c.req)
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return app.authenticate({
|
|
67
|
+
id,
|
|
68
|
+
mode: "async",
|
|
69
|
+
run: async (input) => {
|
|
70
|
+
const principal = await verify((name) => input.headers.get(name))
|
|
71
|
+
if (principal === null || principal === undefined) {
|
|
72
|
+
return rejected("unauthenticated", refuse())
|
|
73
|
+
}
|
|
74
|
+
store.set(input.request, principal)
|
|
75
|
+
return authenticated(principal)
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
33
80
|
/** Shared with core so auth/CSRF middleware gets the allocation-light cookie scanner too. */
|
|
34
81
|
export const parseCookies = parseCoreCookies
|
|
35
82
|
|
|
@@ -163,40 +210,19 @@ export async function verifyHmacSha256(
|
|
|
163
210
|
}
|
|
164
211
|
|
|
165
212
|
/**
|
|
166
|
-
* Apply header mutations to
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* never on Bun) makes `.set`/`.append` throw; that path clones into a fresh `Headers` + `Response`,
|
|
171
|
-
* exactly the old always-clone behavior. `apply` runs once either way (immutability is all-or-nothing
|
|
172
|
-
* - the first mutation throws before any partial change), so a mutation chain is safe to pass.
|
|
213
|
+
* Apply header mutations to the copy of `res` this request owns (`writableResponse`): a body-bearing
|
|
214
|
+
* response with mutable headers is written in place, a body-less one (sendable any number of times,
|
|
215
|
+
* so possibly shared) or one with guarded headers is copied first. Pass the `request` being answered
|
|
216
|
+
* so a copy already made for it is reused.
|
|
173
217
|
*/
|
|
174
|
-
export function withHeaders(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (previous === null) responseHeaders.delete(probe)
|
|
183
|
-
else responseHeaders.set(probe, previous)
|
|
184
|
-
} catch {
|
|
185
|
-
mutable = false
|
|
186
|
-
try {
|
|
187
|
-
if (previous === null) responseHeaders.delete(probe)
|
|
188
|
-
else responseHeaders.set(probe, previous)
|
|
189
|
-
} catch {
|
|
190
|
-
// The guarded response rejected cleanup; the clone below is authoritative.
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (!mutable) {
|
|
194
|
-
const headers = new Headers(res.headers)
|
|
195
|
-
apply(headers)
|
|
196
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
197
|
-
}
|
|
198
|
-
apply(responseHeaders)
|
|
199
|
-
return res
|
|
218
|
+
export function withHeaders(
|
|
219
|
+
res: Response,
|
|
220
|
+
apply: (headers: Headers) => void,
|
|
221
|
+
request?: Request,
|
|
222
|
+
): Response {
|
|
223
|
+
const target = writableResponse(res, request)
|
|
224
|
+
apply(target.headers)
|
|
225
|
+
return target
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
/**
|
package/src/admission.ts
CHANGED
|
@@ -103,8 +103,10 @@ const NO_LAG: () => number = () => 0
|
|
|
103
103
|
/**
|
|
104
104
|
* Event-loop-lag sampler. By default it measures timer drift using only Web/JS runtime primitives, so
|
|
105
105
|
* it works under Node ESM, Bun, Deno, and workers without a hidden CommonJS `require` fallback. An
|
|
106
|
-
* injected histogram remains available for deterministic tests or a runtime-native monitor.
|
|
107
|
-
* returns
|
|
106
|
+
* injected histogram remains available for deterministic tests or a runtime-native monitor. A read
|
|
107
|
+
* returns the mean lag of the samples taken since the previous read and starts a new window; a read
|
|
108
|
+
* with no new sample since then returns the same value again, so every request that arrives between
|
|
109
|
+
* two samples of a lagging loop sees the lag.
|
|
108
110
|
*/
|
|
109
111
|
export function createEventLoopLagSampler(
|
|
110
112
|
resolutionMs = 20,
|
|
@@ -127,11 +129,15 @@ export function createEventLoopLagSampler(
|
|
|
127
129
|
// A metrics sampler must never keep a Node/Bun process alive by itself. Browser/Deno numeric timer
|
|
128
130
|
// handles simply do not expose `unref`.
|
|
129
131
|
;(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.()
|
|
132
|
+
let latest = 0
|
|
130
133
|
return () => {
|
|
131
|
-
|
|
134
|
+
// An empty window is not a healthy loop: a burst of requests reads between two ticks.
|
|
135
|
+
if (samples === 0) return latest
|
|
136
|
+
const mean = totalLagMs / samples
|
|
132
137
|
totalLagMs = 0
|
|
133
138
|
samples = 0
|
|
134
|
-
|
|
139
|
+
latest = Number.isFinite(mean) ? mean : 0
|
|
140
|
+
return latest
|
|
135
141
|
}
|
|
136
142
|
}
|
|
137
143
|
let histogram: LoopDelayHistogram | undefined
|
|
@@ -142,10 +148,14 @@ export function createEventLoopLagSampler(
|
|
|
142
148
|
}
|
|
143
149
|
if (histogram === undefined) return NO_LAG
|
|
144
150
|
histogram.enable()
|
|
151
|
+
let latest = 0
|
|
145
152
|
return () => {
|
|
146
153
|
const meanNs = histogram.mean
|
|
154
|
+
// A histogram reads NaN until it records a sample after a reset: the window is still open.
|
|
155
|
+
if (!Number.isFinite(meanNs)) return latest
|
|
147
156
|
histogram.reset()
|
|
148
|
-
|
|
157
|
+
latest = Math.max(0, meanNs / 1e6 - resolutionMs)
|
|
158
|
+
return latest
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
161
|
|
package/src/basic-auth.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
|
3
3
|
import {
|
|
4
4
|
decodeBase64,
|
|
5
5
|
guardName,
|
|
6
|
+
installAuthGuard,
|
|
6
7
|
jsonError,
|
|
7
8
|
type MaybePromise,
|
|
8
9
|
quotedHeaderValue,
|
|
@@ -39,9 +40,9 @@ function reject(realm: string): Response {
|
|
|
39
40
|
return jsonError(401, "unauthorized", { "www-authenticate": challenge(realm) })
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function credentials(
|
|
43
|
-
|
|
44
|
-
if (header
|
|
43
|
+
function credentials(header: string | null): { username: string; password: string } | null {
|
|
44
|
+
// The auth-scheme is case-insensitive (RFC 9110 section 11.1): `basic` and `BASIC` are Basic too.
|
|
45
|
+
if (header == null || header.slice(0, 6).toLowerCase() !== "basic ") return null
|
|
45
46
|
const raw = decodeBase64(header.slice(6).trim())
|
|
46
47
|
if (raw === null) return null
|
|
47
48
|
let decoded: string
|
|
@@ -89,16 +90,17 @@ export function basicAuth<P>(
|
|
|
89
90
|
: staticVerifier(options.username, options.password, options.principal ?? options.username)
|
|
90
91
|
|
|
91
92
|
const plugin = defineIdentityPlugin(guardName("basicAuth"), (app) =>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
installAuthGuard(
|
|
94
|
+
app,
|
|
95
|
+
"basicAuth",
|
|
96
|
+
optional,
|
|
97
|
+
store,
|
|
98
|
+
async (header) => {
|
|
99
|
+
const parsed = credentials(header("authorization"))
|
|
100
|
+
return parsed === null ? null : (await verifyPromise)(parsed.username, parsed.password)
|
|
101
|
+
},
|
|
102
|
+
() => reject(realm),
|
|
103
|
+
),
|
|
102
104
|
)
|
|
103
105
|
const instrumented = optional
|
|
104
106
|
? plugin
|
package/src/cache-control.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface CacheControlOptions {
|
|
|
11
11
|
readonly respectExisting?: boolean
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Named per call, so a second, differently configured instance applies instead of being deduped.
|
|
15
|
+
let instances = 0
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
|
|
16
19
|
* function of the request (return `undefined` to leave a response untouched - e.g. cache by path).
|
|
@@ -31,7 +34,7 @@ export function cacheControl(
|
|
|
31
34
|
const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
|
|
32
35
|
const statusOk = options.status ?? ((status: number) => status >= 200 && status < 300)
|
|
33
36
|
const respectExisting = options.respectExisting !== false
|
|
34
|
-
return defineRouterPlugin(
|
|
37
|
+
return defineRouterPlugin(`cacheControl#${++instances}`, (app) => {
|
|
35
38
|
// A fixed directive needs nothing from the request beyond the method, so it ships as one
|
|
36
39
|
// portable header hook. A dynamic directive's resolver takes a real `Request`, which only the
|
|
37
40
|
// Web response walk carries - it keeps the full `onResponse` contract (and its cost).
|
|
@@ -51,7 +54,7 @@ export function cacheControl(
|
|
|
51
54
|
if (respectExisting && res.headers.has("cache-control")) return res
|
|
52
55
|
const directive = value(req)
|
|
53
56
|
if (directive === undefined) return res
|
|
54
|
-
return withHeaders(res, (headers) => headers.set("cache-control", directive))
|
|
57
|
+
return withHeaders(res, (headers) => headers.set("cache-control", directive), req)
|
|
55
58
|
},
|
|
56
59
|
}
|
|
57
60
|
return app.use(middleware)
|
package/src/cache.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { isAuthenticatedRequest, type Middleware } from "@nifrajs/core/server"
|
|
2
2
|
import { type MaybePromise, withHeaders } from "./_utils.ts"
|
|
3
3
|
|
|
4
4
|
export interface CachedResponse {
|
|
@@ -66,7 +66,7 @@ export class MemoryResponseCache implements ResponseCacheStore {
|
|
|
66
66
|
|
|
67
67
|
export interface CacheOptions {
|
|
68
68
|
readonly store: ResponseCacheStore
|
|
69
|
-
/**
|
|
69
|
+
/** Maximum time to live in milliseconds; response freshness can shorten it. */
|
|
70
70
|
readonly ttlMs: number
|
|
71
71
|
/** Methods cached. Default `["GET", "HEAD"]`. */
|
|
72
72
|
readonly methods?: readonly string[]
|
|
@@ -80,7 +80,8 @@ export interface CacheOptions {
|
|
|
80
80
|
readonly maxBytes?: number
|
|
81
81
|
/** Respect request `Cache-Control: no-cache/no-store`. Default `true`. */
|
|
82
82
|
readonly respectRequestCacheControl?: boolean
|
|
83
|
-
/** Respect response
|
|
83
|
+
/** Respect response private/no-store/no-cache and max-age/s-maxage freshness. Default `true`.
|
|
84
|
+
* Responses requiring validation bypass this cache, which has no revalidation transport. */
|
|
84
85
|
readonly respectResponseCacheControl?: boolean
|
|
85
86
|
/** Cache responses with `Set-Cookie`. Default `false`. */
|
|
86
87
|
readonly cacheSetCookie?: boolean
|
|
@@ -89,7 +90,9 @@ export interface CacheOptions {
|
|
|
89
90
|
* personalized response (RFC 9111 §3.5), or it would be replayed to other users. Leave this off unless
|
|
90
91
|
* the cached route is genuinely the same for every caller. */
|
|
91
92
|
readonly cacheAuthenticated?: boolean
|
|
92
|
-
/** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key.
|
|
93
|
+
/** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key.
|
|
94
|
+
* A response to a request an authentication stage accepted (or an integration marked with
|
|
95
|
+
* `markAuthenticatedRequest`) is treated as private too, whichever header carried the credential. */
|
|
93
96
|
readonly authenticatedHeaders?: readonly string[]
|
|
94
97
|
/** Response header for cache status. Default `"x-nifra-cache"`; set `false` to disable. */
|
|
95
98
|
readonly cacheStatusHeader?: string | false
|
|
@@ -107,10 +110,30 @@ function cacheControlHas(headers: Headers, names: readonly string[]): boolean {
|
|
|
107
110
|
const parts = value
|
|
108
111
|
.toLowerCase()
|
|
109
112
|
.split(",")
|
|
110
|
-
.map((part) => part.trim().split("=", 1)[0])
|
|
113
|
+
.map((part) => part.trim().split("=", 1)[0]?.trim())
|
|
111
114
|
return names.some((name) => parts.includes(name))
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
function responseLifetimeMs(headers: Headers, ttlMs: number): number {
|
|
118
|
+
if (cacheControlHas(headers, ["private", "no-store", "no-cache"])) return 0
|
|
119
|
+
const directives = headers.get("cache-control")?.split(",") ?? []
|
|
120
|
+
// This is a shared cache: s-maxage takes precedence over max-age. Ambiguous freshness is stale.
|
|
121
|
+
for (const name of ["s-maxage", "max-age"]) {
|
|
122
|
+
const matches = directives.filter(
|
|
123
|
+
(part) => part.trim().split("=", 1)[0]?.trim().toLowerCase() === name,
|
|
124
|
+
)
|
|
125
|
+
if (matches.length === 0) continue
|
|
126
|
+
if (matches.length !== 1) return 0
|
|
127
|
+
const raw = matches[0]?.slice(matches[0].indexOf("=") + 1).trim() ?? ""
|
|
128
|
+
const value = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
|
|
129
|
+
const seconds = parseLength(value)
|
|
130
|
+
if (seconds === undefined || !Number.isFinite(seconds)) return 0
|
|
131
|
+
const age = parseLength(headers.get("age")) ?? 0
|
|
132
|
+
return Math.min(ttlMs, Math.max(0, seconds - age) * 1000)
|
|
133
|
+
}
|
|
134
|
+
return ttlMs
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
function appendVary(headers: Headers, vary: readonly string[]): void {
|
|
115
138
|
if (vary.length === 0) return
|
|
116
139
|
const existing = headers
|
|
@@ -257,9 +280,10 @@ function withStatusHeader(
|
|
|
257
280
|
res: Response,
|
|
258
281
|
header: string | false,
|
|
259
282
|
status: "HIT" | "MISS" | "BYPASS",
|
|
283
|
+
request?: Request,
|
|
260
284
|
): Response {
|
|
261
285
|
if (header === false) return res
|
|
262
|
-
return withHeaders(res, (headers) => headers.set(header, status))
|
|
286
|
+
return withHeaders(res, (headers) => headers.set(header, status), request)
|
|
263
287
|
}
|
|
264
288
|
|
|
265
289
|
/**
|
|
@@ -308,25 +332,33 @@ export function cache(options: CacheOptions): Middleware {
|
|
|
308
332
|
requestKeys.set(req, key)
|
|
309
333
|
const entry = await store.get(key)
|
|
310
334
|
if (entry === undefined) return undefined
|
|
335
|
+
const cachedHeaders = new Headers([...entry.headers])
|
|
311
336
|
// Never replay a personalized entry before route-scoped authentication runs. Public entries
|
|
312
337
|
// remain eligible (and are explicitly marked by their response Cache-Control metadata).
|
|
313
338
|
if (
|
|
314
339
|
!cacheAuthenticated &&
|
|
315
340
|
hasAuthentication(req) &&
|
|
316
|
-
!cacheControlHas(
|
|
317
|
-
"public",
|
|
318
|
-
"s-maxage",
|
|
319
|
-
])
|
|
341
|
+
!cacheControlHas(cachedHeaders, ["public", "s-maxage"])
|
|
320
342
|
) {
|
|
321
343
|
return undefined
|
|
322
344
|
}
|
|
323
|
-
|
|
345
|
+
const lifetimeMs = respectResponseCacheControl
|
|
346
|
+
? responseLifetimeMs(cachedHeaders, ttlMs)
|
|
347
|
+
: ttlMs
|
|
348
|
+
if (
|
|
349
|
+
lifetimeMs === 0 ||
|
|
350
|
+
Date.now() >= Math.min(entry.expiresAt, entry.storedAt + lifetimeMs)
|
|
351
|
+
) {
|
|
324
352
|
await store.delete?.(key)
|
|
325
353
|
return undefined
|
|
326
354
|
}
|
|
327
355
|
hits.add(req)
|
|
328
|
-
const headers =
|
|
329
|
-
headers.
|
|
356
|
+
const headers = cachedHeaders
|
|
357
|
+
const age = parseLength(headers.get("age")) ?? 0
|
|
358
|
+
headers.set(
|
|
359
|
+
"age",
|
|
360
|
+
String(age + Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))),
|
|
361
|
+
)
|
|
330
362
|
if (cacheStatusHeader !== false) headers.set(cacheStatusHeader, "HIT")
|
|
331
363
|
return new Response(req.method === "HEAD" ? null : entry.body.slice(), {
|
|
332
364
|
status: entry.status,
|
|
@@ -343,46 +375,54 @@ export function cache(options: CacheOptions): Middleware {
|
|
|
343
375
|
if (!methods.has(req.method.toUpperCase())) return res
|
|
344
376
|
if (!statusOk(res.status)) return res
|
|
345
377
|
if (respectRequestCacheControl && cacheControlHas(req.headers, ["no-store"])) {
|
|
346
|
-
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
378
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
347
379
|
}
|
|
348
|
-
|
|
349
|
-
|
|
380
|
+
const lifetimeMs = respectResponseCacheControl
|
|
381
|
+
? responseLifetimeMs(res.headers, ttlMs)
|
|
382
|
+
: ttlMs
|
|
383
|
+
if (lifetimeMs === 0) {
|
|
384
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
350
385
|
}
|
|
351
386
|
if (!cacheSetCookie && res.headers.has("set-cookie")) {
|
|
352
|
-
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
387
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
353
388
|
}
|
|
354
389
|
// A response-declared Vary must be represented in the lookup key. If the caller did not opt
|
|
355
390
|
// those request headers into `vary`, bypass storage instead of replaying one representation to
|
|
356
391
|
// another request. `Vary: *` is never cacheable by a shared cache.
|
|
357
392
|
if (!responseVaryMatchesKey(res.headers, vary)) {
|
|
358
|
-
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
393
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
359
394
|
}
|
|
360
395
|
// Shared-cache safety (RFC 9111 §3.5): never store a response to an authenticated request
|
|
361
|
-
//
|
|
362
|
-
//
|
|
396
|
+
// unless it's explicitly public - otherwise a personalized 200 leaks across users. A credential
|
|
397
|
+
// header outside `authenticatedHeaders` is caught by the authentication stage that accepted it,
|
|
398
|
+
// because a hit is served before that stage runs. Opt in with `cacheAuthenticated: true` for a
|
|
399
|
+
// route that's the same for everyone.
|
|
363
400
|
if (
|
|
364
401
|
!cacheAuthenticated &&
|
|
365
|
-
hasAuthentication(req) &&
|
|
402
|
+
(hasAuthentication(req) || isAuthenticatedRequest(req)) &&
|
|
366
403
|
!cacheControlHas(res.headers, ["public", "s-maxage"])
|
|
367
404
|
) {
|
|
368
|
-
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
405
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
369
406
|
}
|
|
370
407
|
const declared = parseLength(res.headers.get("content-length"))
|
|
371
408
|
if (declared !== undefined && declared > maxBytes) {
|
|
372
|
-
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
409
|
+
return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
|
|
373
410
|
}
|
|
411
|
+
const storedAt = Date.now()
|
|
374
412
|
const captured = await readBytesCapped(res, maxBytes)
|
|
375
413
|
if (!("bytes" in captured)) {
|
|
376
|
-
return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS")
|
|
414
|
+
return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS", req)
|
|
377
415
|
}
|
|
378
416
|
const body = captured.bytes
|
|
379
417
|
const capturedResponse = captured.response
|
|
418
|
+
const expiresAt = storedAt + lifetimeMs
|
|
419
|
+
if (Date.now() >= expiresAt) {
|
|
420
|
+
return withStatusHeader(capturedResponse, cacheStatusHeader, "BYPASS", req)
|
|
421
|
+
}
|
|
380
422
|
|
|
381
423
|
const headers = new Headers(capturedResponse.headers)
|
|
382
|
-
headers.delete("age")
|
|
383
424
|
if (cacheStatusHeader !== false) headers.delete(cacheStatusHeader)
|
|
384
425
|
appendVary(headers, vary)
|
|
385
|
-
const now = Date.now()
|
|
386
426
|
const key = requestKeys.get(req) ?? keyOf(req)
|
|
387
427
|
requestKeys.delete(req)
|
|
388
428
|
await store.set(key, {
|
|
@@ -390,8 +430,8 @@ export function cache(options: CacheOptions): Middleware {
|
|
|
390
430
|
statusText: res.statusText,
|
|
391
431
|
headers: [...headers.entries()],
|
|
392
432
|
body,
|
|
393
|
-
expiresAt
|
|
394
|
-
storedAt
|
|
433
|
+
expiresAt,
|
|
434
|
+
storedAt,
|
|
395
435
|
})
|
|
396
436
|
const outgoing = new Headers(capturedResponse.headers)
|
|
397
437
|
appendVary(outgoing, vary)
|
package/src/compression.ts
CHANGED
|
@@ -179,10 +179,90 @@ async function compressRawBody(
|
|
|
179
179
|
},
|
|
180
180
|
cancel: (reason) => reader.cancel(reason),
|
|
181
181
|
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
const zlib = zlibModule()
|
|
183
|
+
return new Response(
|
|
184
|
+
zlib === undefined
|
|
185
|
+
? source.pipeThrough(new CompressionStream("gzip"))
|
|
186
|
+
: syncFlushedGzip(zlib, source),
|
|
187
|
+
{
|
|
188
|
+
status: response.status,
|
|
189
|
+
statusText: response.statusText,
|
|
190
|
+
headers: gzipHeaders(response.headers),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface GzipStream {
|
|
196
|
+
write(chunk: Uint8Array): boolean
|
|
197
|
+
flush(kind: number, callback: () => void): void
|
|
198
|
+
end(): void
|
|
199
|
+
destroy(): void
|
|
200
|
+
on(event: "data", listener: (chunk: Uint8Array) => void): unknown
|
|
201
|
+
once(event: "end" | "error", listener: (error?: unknown) => void): unknown
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface Zlib {
|
|
205
|
+
createGzip(): GzipStream
|
|
206
|
+
readonly constants: { readonly Z_SYNC_FLUSH: number }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isZlib = (value: unknown): value is Zlib =>
|
|
210
|
+
typeof value === "object" &&
|
|
211
|
+
value !== null &&
|
|
212
|
+
"createGzip" in value &&
|
|
213
|
+
typeof value.createGzip === "function" &&
|
|
214
|
+
"constants" in value &&
|
|
215
|
+
typeof value.constants === "object" &&
|
|
216
|
+
value.constants !== null &&
|
|
217
|
+
"Z_SYNC_FLUSH" in value.constants
|
|
218
|
+
|
|
219
|
+
let zlibLookup: { readonly zlib: Zlib | undefined } | undefined
|
|
220
|
+
|
|
221
|
+
function zlibModule(): Zlib | undefined {
|
|
222
|
+
if (zlibLookup !== undefined) return zlibLookup.zlib
|
|
223
|
+
let zlib: Zlib | undefined
|
|
224
|
+
try {
|
|
225
|
+
const proc: unknown = Reflect.get(globalThis, "process")
|
|
226
|
+
const load: unknown =
|
|
227
|
+
typeof proc === "object" && proc !== null ? Reflect.get(proc, "getBuiltinModule") : undefined
|
|
228
|
+
const module: unknown = typeof load === "function" ? load.call(proc, "node:zlib") : undefined
|
|
229
|
+
if (isZlib(module)) zlib = module
|
|
230
|
+
} catch {}
|
|
231
|
+
zlibLookup = { zlib }
|
|
232
|
+
return zlib
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `CompressionStream` holds deflate output until its window fills or the input closes, so a
|
|
236
|
+
* streamed shell would not reach the client until the whole page did. A sync flush after each
|
|
237
|
+
* upstream chunk emits what that chunk compressed to, at the cost of a few bytes per flush. */
|
|
238
|
+
function syncFlushedGzip(
|
|
239
|
+
zlib: Zlib,
|
|
240
|
+
source: ReadableStream<Uint8Array>,
|
|
241
|
+
): ReadableStream<Uint8Array> {
|
|
242
|
+
const reader = source.getReader()
|
|
243
|
+
const gzip = zlib.createGzip()
|
|
244
|
+
return new ReadableStream<Uint8Array>({
|
|
245
|
+
start(controller) {
|
|
246
|
+
gzip.on("data", (chunk) => controller.enqueue(chunk))
|
|
247
|
+
gzip.once("error", (error) => controller.error(error))
|
|
248
|
+
},
|
|
249
|
+
async pull(controller) {
|
|
250
|
+
const { done, value } = await reader.read()
|
|
251
|
+
if (done) {
|
|
252
|
+
await new Promise((resolve) => {
|
|
253
|
+
gzip.once("end", resolve)
|
|
254
|
+
gzip.end()
|
|
255
|
+
})
|
|
256
|
+
controller.close()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
gzip.write(value)
|
|
260
|
+
await new Promise<void>((resolve) => gzip.flush(zlib.constants.Z_SYNC_FLUSH, resolve))
|
|
261
|
+
},
|
|
262
|
+
cancel(reason) {
|
|
263
|
+
gzip.destroy()
|
|
264
|
+
return reader.cancel(reason)
|
|
265
|
+
},
|
|
186
266
|
})
|
|
187
267
|
}
|
|
188
268
|
|
package/src/conditional.ts
CHANGED
|
@@ -55,10 +55,14 @@ export function conditionalResponse(
|
|
|
55
55
|
if (response.status < 200 || response.status >= 300) return response
|
|
56
56
|
if (!isFresh(request, options)) {
|
|
57
57
|
if (options.etag === undefined && modified === undefined) return response
|
|
58
|
-
return withHeaders(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
return withHeaders(
|
|
59
|
+
response,
|
|
60
|
+
(headers) => {
|
|
61
|
+
if (options.etag !== undefined) headers.set("etag", options.etag)
|
|
62
|
+
if (modified !== undefined) headers.set("last-modified", modified)
|
|
63
|
+
},
|
|
64
|
+
request,
|
|
65
|
+
)
|
|
62
66
|
}
|
|
63
67
|
const headers = new Headers(response.headers)
|
|
64
68
|
if (options.etag !== undefined) headers.set("etag", options.etag)
|
|
@@ -37,7 +37,9 @@ function parseMediaType(value: string, qDefault = 1): ParsedMediaType | undefine
|
|
|
37
37
|
if (slash <= 0 || slash === media.length - 1) return undefined
|
|
38
38
|
const type = media.slice(0, slash)
|
|
39
39
|
const subtype = media.slice(slash + 1)
|
|
40
|
-
|
|
40
|
+
// `*/*` is the one legal type wildcard; `*/json` names no media range.
|
|
41
|
+
const typeOk = type === "*" ? subtype === "*" : /^[\w!#$&^_.+-]+$/.test(type)
|
|
42
|
+
if (!typeOk || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
|
|
41
43
|
|
|
42
44
|
let q = qDefault
|
|
43
45
|
const parameters = new Map<string, string>()
|