@nifrajs/middleware 3.3.0 → 4.0.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 +11 -6
- package/dist/_ip.d.ts +17 -0
- package/dist/_ip.d.ts.map +1 -0
- package/dist/_ip.js +91 -0
- package/dist/_ip.js.map +1 -0
- package/dist/_utils.d.ts +16 -0
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +33 -0
- package/dist/_utils.js.map +1 -1
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +2 -2
- package/dist/basic-auth.js.map +1 -1
- package/dist/body-limit.js +2 -2
- package/dist/body-limit.js.map +1 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +110 -22
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +13 -2
- package/dist/compression.js.map +1 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +50 -5
- package/dist/cors.js.map +1 -1
- package/dist/csrf.d.ts +21 -2
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +99 -10
- package/dist/csrf.js.map +1 -1
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +7 -1
- package/dist/etag.js.map +1 -1
- package/dist/idempotency.d.ts +16 -7
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +51 -22
- package/dist/idempotency.js.map +1 -1
- package/dist/ip-restriction.d.ts +12 -7
- package/dist/ip-restriction.d.ts.map +1 -1
- package/dist/ip-restriction.js +25 -72
- package/dist/ip-restriction.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +3 -3
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +4 -1
- package/dist/language.js.map +1 -1
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +6 -1
- package/dist/multipart.js.map +1 -1
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +63 -7
- package/dist/pretty-json.js.map +1 -1
- package/dist/problem-details.d.ts.map +1 -1
- package/dist/problem-details.js +8 -1
- package/dist/problem-details.js.map +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +5 -2
- package/dist/range.js.map +1 -1
- package/dist/rate-limit.d.ts +11 -10
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +29 -19
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts +8 -1
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +6 -3
- package/dist/request-id.js.map +1 -1
- package/dist/security-headers.d.ts +4 -0
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +7 -1
- package/dist/security-headers.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +3 -5
- package/dist/token-auth.js.map +1 -1
- package/dist/trailing-slash.d.ts.map +1 -1
- package/dist/trailing-slash.js +12 -1
- package/dist/trailing-slash.js.map +1 -1
- package/package.json +6 -3
- package/src/_ip.ts +87 -0
- package/src/_utils.ts +37 -0
- package/src/basic-auth.ts +2 -1
- package/src/body-limit.ts +2 -2
- package/src/cache.ts +119 -21
- package/src/compression.ts +13 -2
- package/src/cors.ts +51 -7
- package/src/csrf.ts +115 -10
- package/src/etag.ts +11 -1
- package/src/idempotency.ts +76 -28
- package/src/ip-restriction.ts +38 -75
- package/src/jwt.ts +4 -2
- package/src/language.ts +5 -1
- package/src/multipart.ts +6 -1
- package/src/pretty-json.ts +56 -7
- package/src/problem-details.ts +8 -1
- package/src/range.ts +6 -3
- package/src/rate-limit.ts +48 -31
- package/src/request-id.ts +16 -4
- package/src/security-headers.ts +7 -1
- package/src/token-auth.ts +3 -5
- package/src/trailing-slash.ts +13 -1
package/src/cors.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { withResponseObserver } from "@nifrajs/core/response-observer"
|
|
2
|
-
import type { Middleware } from "@nifrajs/core/server"
|
|
2
|
+
import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
|
|
3
|
+
import { setNodeHeader } from "./_utils.ts"
|
|
4
|
+
|
|
5
|
+
const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
|
|
3
6
|
|
|
4
7
|
export interface CorsOptions {
|
|
5
8
|
/** Allowed origin(s): `"*"`, an exact origin, a list, or a predicate. Default `"*"`. */
|
|
@@ -38,6 +41,31 @@ function resolveAllowOrigin(
|
|
|
38
41
|
return option === requestOrigin ? requestOrigin : null
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
function nodeHeader(
|
|
45
|
+
headers: Readonly<Record<string, string | readonly string[]>> | undefined,
|
|
46
|
+
name: string,
|
|
47
|
+
headersAreLowercase: boolean | undefined,
|
|
48
|
+
): string | null {
|
|
49
|
+
if (headers === undefined) return null
|
|
50
|
+
const direct = headers[name]
|
|
51
|
+
if (direct !== undefined) return typeof direct === "string" ? direct : direct.join(", ")
|
|
52
|
+
if (headersAreLowercase) return null
|
|
53
|
+
for (const key of Object.keys(headers)) {
|
|
54
|
+
if (key.toLowerCase() !== name) continue
|
|
55
|
+
const value = headers[key]
|
|
56
|
+
return value === undefined ? null : typeof value === "string" ? value : value.join(", ")
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function varyOnOrigin(value: string | null): string {
|
|
62
|
+
if (value === null || value.trim() === "") return "Origin"
|
|
63
|
+
const fields = value.split(",").map((field) => field.trim())
|
|
64
|
+
// `Vary: *` is the complete field value, not one member of a list.
|
|
65
|
+
if (fields.includes("*")) return "*"
|
|
66
|
+
return fields.some((field) => field.toLowerCase() === "origin") ? value : `${value}, Origin`
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
/**
|
|
42
70
|
* CORS as a {@link Middleware}. Preflight (`OPTIONS` + `Access-Control-Request-Method`)
|
|
43
71
|
* short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
|
|
@@ -59,7 +87,7 @@ export function cors(options: CorsOptions = {}): Middleware {
|
|
|
59
87
|
const exposedHeaders = options.exposedHeaders?.join(", ")
|
|
60
88
|
const maxAge = options.maxAge
|
|
61
89
|
|
|
62
|
-
|
|
90
|
+
const middleware = withResponseObserver({
|
|
63
91
|
name: "cors",
|
|
64
92
|
onRequest(req) {
|
|
65
93
|
const isPreflight =
|
|
@@ -86,15 +114,31 @@ export function cors(options: CorsOptions = {}): Middleware {
|
|
|
86
114
|
return new Response(null, { status: 204, headers })
|
|
87
115
|
},
|
|
88
116
|
onResponseHeaders(headers, req) {
|
|
117
|
+
// Any policy other than `*` makes the answer depend on the request's Origin - including the
|
|
118
|
+
// answer that omits Allow-Origin. Without `Vary` on that one, a shared cache stores the
|
|
119
|
+
// header-less response and replays it to an allowed origin, breaking CORS for everyone.
|
|
120
|
+
if (origin !== "*") {
|
|
121
|
+
headers.set("vary", varyOnOrigin(headers.get("vary")))
|
|
122
|
+
}
|
|
89
123
|
const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
|
|
90
124
|
if (allowOrigin === null) return
|
|
91
125
|
headers.set("access-control-allow-origin", allowOrigin)
|
|
92
|
-
if (allowOrigin !== "*") {
|
|
93
|
-
const vary = headers.get("vary")
|
|
94
|
-
headers.set("vary", vary === null || vary === "" ? "Origin" : `${vary}, Origin`)
|
|
95
|
-
}
|
|
96
126
|
if (credentials) headers.set("access-control-allow-credentials", "true")
|
|
97
127
|
if (exposedHeaders !== undefined) headers.set("access-control-expose-headers", exposedHeaders)
|
|
98
128
|
},
|
|
99
|
-
|
|
129
|
+
onNodeResponseHeaders(response: NodeResponseContext, req: NodeRequestContext) {
|
|
130
|
+
if (origin !== "*") {
|
|
131
|
+
const vary = nodeHeader(response.headers, "vary", response.headersAreLowercase)
|
|
132
|
+
setNodeHeader(response, "vary", varyOnOrigin(vary))
|
|
133
|
+
}
|
|
134
|
+
const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
|
|
135
|
+
if (allowOrigin === null) return
|
|
136
|
+
setNodeHeader(response, "access-control-allow-origin", allowOrigin)
|
|
137
|
+
if (credentials) setNodeHeader(response, "access-control-allow-credentials", "true")
|
|
138
|
+
if (exposedHeaders !== undefined)
|
|
139
|
+
setNodeHeader(response, "access-control-expose-headers", exposedHeaders)
|
|
140
|
+
},
|
|
141
|
+
} satisfies Middleware)
|
|
142
|
+
Object.defineProperty(middleware, BUN_NATIVE_REQUEST_SAFE, { value: true })
|
|
143
|
+
return middleware
|
|
100
144
|
}
|
package/src/csrf.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
2
|
+
import { isSameOriginRequest, METHODS, type Middleware } from "@nifrajs/core/server"
|
|
3
3
|
import {
|
|
4
4
|
base64UrlEncode,
|
|
5
|
+
guardName,
|
|
5
6
|
hmacSha256,
|
|
6
7
|
jsonError,
|
|
7
8
|
parseCookies,
|
|
@@ -35,9 +36,27 @@ export interface CsrfOptions {
|
|
|
35
36
|
readonly cookie?: string
|
|
36
37
|
/** Header carrying the same signed token. Default `"x-csrf-token"`. */
|
|
37
38
|
readonly header?: string
|
|
39
|
+
/**
|
|
40
|
+
* Form field that may carry the token when the header is absent, so a plain HTML `<form>` (no
|
|
41
|
+
* script to set a header) can submit - e.g. `field: "_csrf"` with
|
|
42
|
+
* `<input type="hidden" name="_csrf" value="...">`. Read from `application/x-www-form-urlencoded`
|
|
43
|
+
* and `multipart/form-data` bodies only, through a clone, so the handler still reads the body.
|
|
44
|
+
* Unset (default), only the header is accepted.
|
|
45
|
+
*
|
|
46
|
+
* A cross-site form can set any field but never a custom header, so keep `checkOrigin` on with a
|
|
47
|
+
* field: the Origin check is what stops a site that can plant a cookie on your domain (a sibling
|
|
48
|
+
* subdomain) from pairing it with a matching field.
|
|
49
|
+
*/
|
|
50
|
+
readonly field?: string
|
|
51
|
+
/**
|
|
52
|
+
* Largest body scanned for {@link field}, in bytes. Default 64 KiB. A bigger form (a file upload)
|
|
53
|
+
* fails closed - send the header, or raise this for that app.
|
|
54
|
+
*/
|
|
55
|
+
readonly fieldMaxBytes?: number
|
|
38
56
|
/** Unsafe methods to protect. Default: every method except GET/HEAD/OPTIONS/TRACE. */
|
|
39
57
|
readonly methods?: readonly string[]
|
|
40
|
-
/** Allowed request origins. Default: same
|
|
58
|
+
/** Allowed request origins. Default: same host as the request URL, where an `https:` Origin may
|
|
59
|
+
* reach an `http:` URL (what a TLS-terminating proxy looks like from the server). */
|
|
41
60
|
readonly origins?: readonly string[]
|
|
42
61
|
/** Check Origin/Referer on protected requests. Default true. */
|
|
43
62
|
readonly checkOrigin?: boolean
|
|
@@ -49,20 +68,95 @@ function protectedMethod(method: string, configured: Set<string> | undefined): b
|
|
|
49
68
|
return configured !== undefined ? configured.has(method) : !SAFE_METHODS.has(method)
|
|
50
69
|
}
|
|
51
70
|
|
|
71
|
+
/**
|
|
72
|
+
* An explicit allowlist compares exact origins. The same-origin default goes through core's
|
|
73
|
+
* {@link isSameOriginRequest} instead of comparing against `new URL(req.url).origin`: behind a
|
|
74
|
+
* TLS-terminating proxy the request URL says `http:` while the browser reports `https:`, and an exact
|
|
75
|
+
* compare rejected every protected request there.
|
|
76
|
+
*/
|
|
52
77
|
function originAllowed(req: Request, origins: Set<string> | undefined): boolean {
|
|
53
|
-
const
|
|
78
|
+
const allows = (candidate: string): boolean =>
|
|
79
|
+
origins !== undefined ? origins.has(candidate) : isSameOriginRequest(candidate, req)
|
|
54
80
|
const origin = req.headers.get("origin")
|
|
55
|
-
if (origin !== null) return
|
|
81
|
+
if (origin !== null) return allows(origin)
|
|
56
82
|
|
|
57
83
|
const referer = req.headers.get("referer")
|
|
58
84
|
if (referer === null) return false
|
|
59
85
|
try {
|
|
60
|
-
return
|
|
86
|
+
return allows(new URL(referer).origin)
|
|
61
87
|
} catch {
|
|
62
88
|
return false
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
function mediaEssence(contentType: string): string {
|
|
93
|
+
const semi = contentType.indexOf(";")
|
|
94
|
+
return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Up to `maxBytes` of a request clone, or `null` when it is longer (or fails mid-read). */
|
|
98
|
+
async function readAtMost(req: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
99
|
+
const body = req.clone().body
|
|
100
|
+
if (body === null) return null
|
|
101
|
+
const reader = body.getReader()
|
|
102
|
+
const chunks: Uint8Array[] = []
|
|
103
|
+
let total = 0
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read()
|
|
107
|
+
if (done) break
|
|
108
|
+
total += value.byteLength
|
|
109
|
+
if (total > maxBytes) {
|
|
110
|
+
// A clone tees the request: cancel both branches on terminal rejection, without waiting
|
|
111
|
+
// for the unread branch or an upstream cleanup promise that may never settle.
|
|
112
|
+
void Promise.allSettled([reader.cancel(), req.body?.cancel()])
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
chunks.push(value)
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
} finally {
|
|
120
|
+
reader.releaseLock()
|
|
121
|
+
}
|
|
122
|
+
const out = new Uint8Array(total)
|
|
123
|
+
let offset = 0
|
|
124
|
+
for (const chunk of chunks) {
|
|
125
|
+
out.set(chunk, offset)
|
|
126
|
+
offset += chunk.byteLength
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The token a form body carries in `field`, read from a clone so the handler keeps the original body.
|
|
133
|
+
* Anything unexpected - another media type, an oversized or unreadable body, a file part under the
|
|
134
|
+
* field's name - yields `null`, which the caller treats as a missing token.
|
|
135
|
+
*/
|
|
136
|
+
async function formFieldToken(
|
|
137
|
+
req: Request,
|
|
138
|
+
field: string,
|
|
139
|
+
maxBytes: number,
|
|
140
|
+
): Promise<string | null> {
|
|
141
|
+
if (req.body === null) return null
|
|
142
|
+
const contentType = req.headers.get("content-type") ?? ""
|
|
143
|
+
const essence = mediaEssence(contentType)
|
|
144
|
+
const urlencoded = essence === "application/x-www-form-urlencoded"
|
|
145
|
+
if (!urlencoded && essence !== "multipart/form-data") return null
|
|
146
|
+
const declared = req.headers.get("content-length")
|
|
147
|
+
if (declared !== null && !(Number(declared) <= maxBytes)) return null
|
|
148
|
+
const bytes = await readAtMost(req, maxBytes)
|
|
149
|
+
if (bytes === null) return null
|
|
150
|
+
try {
|
|
151
|
+
if (urlencoded) return new URLSearchParams(new TextDecoder().decode(bytes)).get(field)
|
|
152
|
+
const form = await new Response(bytes, { headers: { "content-type": contentType } }).formData()
|
|
153
|
+
const value = form.get(field)
|
|
154
|
+
return typeof value === "string" ? value : null
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
66
160
|
export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promise<string> {
|
|
67
161
|
const key = csrfKeys(secret)[0] as Uint8Array
|
|
68
162
|
const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
|
|
@@ -93,27 +187,38 @@ export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promis
|
|
|
93
187
|
|
|
94
188
|
/**
|
|
95
189
|
* Signed double-submit CSRF protection. A protected request must carry the same signed token in a
|
|
96
|
-
* cookie and a header,
|
|
190
|
+
* cookie and in a header (or, for a plain HTML form, the {@link CsrfOptions.field} form field), and
|
|
191
|
+
* must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
|
|
97
192
|
*/
|
|
98
193
|
export function csrf(options: CsrfOptions): Middleware {
|
|
99
194
|
const keys = csrfKeys(options.secret)
|
|
100
195
|
const cookie = options.cookie ?? "csrf-token"
|
|
101
196
|
const header = (options.header ?? "x-csrf-token").toLowerCase()
|
|
197
|
+
const field = options.field
|
|
198
|
+
if (field !== undefined && field.trim() === "") throw new Error("csrf: field must not be empty")
|
|
199
|
+
const fieldMaxBytes = options.fieldMaxBytes ?? 64 * 1024
|
|
200
|
+
if (!Number.isInteger(fieldMaxBytes) || fieldMaxBytes < 1) {
|
|
201
|
+
throw new Error("csrf: fieldMaxBytes must be a positive integer")
|
|
202
|
+
}
|
|
102
203
|
const methods =
|
|
103
204
|
options.methods !== undefined ? new Set(options.methods.map((m) => m.toUpperCase())) : undefined
|
|
104
205
|
const origins = options.origins !== undefined ? new Set(options.origins) : undefined
|
|
105
206
|
const checkOrigin = options.checkOrigin !== false
|
|
106
207
|
|
|
107
208
|
const middleware: Middleware = {
|
|
108
|
-
name: "csrf",
|
|
209
|
+
name: guardName("csrf"),
|
|
109
210
|
async onRequest(req) {
|
|
110
211
|
if (!protectedMethod(req.method, methods)) return undefined
|
|
111
212
|
if (checkOrigin && !originAllowed(req, origins)) return jsonError(403, "csrf_failed")
|
|
112
213
|
|
|
113
214
|
const cookieToken = parseCookies(req.headers.get("cookie"))[cookie]
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
215
|
+
if (cookieToken === undefined) return jsonError(403, "csrf_failed")
|
|
216
|
+
// The header wins; the body is only read (from a clone) when a form had no way to set one.
|
|
217
|
+
const requestToken =
|
|
218
|
+
req.headers.get(header) ??
|
|
219
|
+
(field === undefined ? null : await formFieldToken(req, field, fieldMaxBytes))
|
|
220
|
+
if (requestToken === null) return jsonError(403, "csrf_failed")
|
|
221
|
+
if (!(await timingSafeEqualString(cookieToken, requestToken))) {
|
|
117
222
|
return jsonError(403, "csrf_failed")
|
|
118
223
|
}
|
|
119
224
|
return (await verifyCsrfToken(cookieToken, keys)) ? undefined : jsonError(403, "csrf_failed")
|
package/src/etag.ts
CHANGED
|
@@ -137,6 +137,16 @@ export function etag(options: ETagOptions = {}) {
|
|
|
137
137
|
if (req.method !== "GET" || response.status !== 200 || response.body === null) {
|
|
138
138
|
return response
|
|
139
139
|
}
|
|
140
|
+
// Hashing reads the body before any of it is sent. A stream meant to arrive as it is
|
|
141
|
+
// produced - an event stream, or one marked no-store or no-transform - passes through.
|
|
142
|
+
if (
|
|
143
|
+
/^text\/event-stream\b/i.test(response.headers.get("content-type") ?? "") ||
|
|
144
|
+
/(?:^|,)\s*(?:no-store|no-transform)\s*(?:,|$)/i.test(
|
|
145
|
+
response.headers.get("cache-control") ?? "",
|
|
146
|
+
)
|
|
147
|
+
) {
|
|
148
|
+
return response
|
|
149
|
+
}
|
|
140
150
|
const declared = response.headers.get("content-length")
|
|
141
151
|
if (
|
|
142
152
|
declared !== null &&
|
|
@@ -173,7 +183,7 @@ async function etagRawResponse(
|
|
|
173
183
|
// The body was consumed and rebuilt from bytes. Re-derive framing at the serving adapter
|
|
174
184
|
// instead of trusting a stale upstream declaration.
|
|
175
185
|
headers.delete("content-length")
|
|
176
|
-
return new Response(read.bytes, {
|
|
186
|
+
return new Response(read.bytes.slice(), {
|
|
177
187
|
status: response.status,
|
|
178
188
|
statusText: response.statusText,
|
|
179
189
|
headers,
|
package/src/idempotency.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
2
|
+
import { METHODS, type Middleware, replacedRequestOf } from "@nifrajs/core/server"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
|
|
@@ -22,7 +22,7 @@ export interface IdempotencyRecord {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export type IdempotencyClaim =
|
|
25
|
-
| { readonly state: "new" }
|
|
25
|
+
| { readonly state: "new"; readonly reservation: string }
|
|
26
26
|
| { readonly state: "in_flight" }
|
|
27
27
|
| { readonly state: "replay"; readonly record: IdempotencyRecord }
|
|
28
28
|
|
|
@@ -38,10 +38,15 @@ export interface IdempotencyStore {
|
|
|
38
38
|
* The in-flight lock expires after `lockTtlMs` so a crashed handler can't wedge the key forever.
|
|
39
39
|
*/
|
|
40
40
|
begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim>
|
|
41
|
-
/** Store the completed response
|
|
42
|
-
complete(
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
/** Store the completed response only when `reservation` still owns the lock (kept for `ttlMs`). */
|
|
42
|
+
complete(
|
|
43
|
+
key: string,
|
|
44
|
+
reservation: string,
|
|
45
|
+
record: IdempotencyRecord,
|
|
46
|
+
ttlMs: number,
|
|
47
|
+
): Promise<boolean>
|
|
48
|
+
/** Release only the lock owned by `reservation` (handler errored / response not cacheable). */
|
|
49
|
+
release(key: string, reservation: string): Promise<boolean>
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface MemoryIdempotencyStoreOptions {
|
|
@@ -54,7 +59,7 @@ export interface MemoryIdempotencyStoreOptions {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
type Entry =
|
|
57
|
-
| { readonly kind: "lock"; readonly expiresAt: number }
|
|
62
|
+
| { readonly kind: "lock"; readonly reservation: string; readonly expiresAt: number }
|
|
58
63
|
| { readonly kind: "record"; readonly record: IdempotencyRecord; readonly expiresAt: number }
|
|
59
64
|
|
|
60
65
|
const KEY_ENCODER = new TextEncoder()
|
|
@@ -137,21 +142,38 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
137
142
|
)
|
|
138
143
|
}
|
|
139
144
|
// Free (or expired): take the lock. Synchronous Map write ⇒ atomic on a single instance.
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
const reservation = crypto.randomUUID()
|
|
146
|
+
this.reserve(key, { kind: "lock", reservation, expiresAt: now + lockTtlMs })
|
|
147
|
+
return Promise.resolve({ state: "new", reservation })
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
complete(
|
|
150
|
+
complete(
|
|
151
|
+
key: string,
|
|
152
|
+
reservation: string,
|
|
153
|
+
record: IdempotencyRecord,
|
|
154
|
+
ttlMs: number,
|
|
155
|
+
): Promise<boolean> {
|
|
145
156
|
this.validateKey(key)
|
|
146
157
|
assertPositiveTtl(ttlMs, "ttlMs")
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
const entry = this.entries.get(key)
|
|
159
|
+
if (entry === undefined || entry.kind !== "lock" || entry.reservation !== reservation) {
|
|
160
|
+
return Promise.resolve(false)
|
|
161
|
+
}
|
|
162
|
+
if (entry.expiresAt <= Date.now()) {
|
|
163
|
+
this.entries.delete(key)
|
|
164
|
+
return Promise.resolve(false)
|
|
165
|
+
}
|
|
166
|
+
this.entries.set(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
|
|
167
|
+
return Promise.resolve(true)
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
release(key: string): Promise<
|
|
170
|
+
release(key: string, reservation: string): Promise<boolean> {
|
|
152
171
|
const entry = this.entries.get(key)
|
|
153
|
-
if (entry
|
|
154
|
-
|
|
172
|
+
if (entry === undefined || entry.kind !== "lock" || entry.reservation !== reservation) {
|
|
173
|
+
return Promise.resolve(false)
|
|
174
|
+
}
|
|
175
|
+
this.entries.delete(key)
|
|
176
|
+
return Promise.resolve(true)
|
|
155
177
|
}
|
|
156
178
|
}
|
|
157
179
|
|
|
@@ -168,7 +190,11 @@ export interface IdempotencyOptions {
|
|
|
168
190
|
readonly lockTtlMs?: number
|
|
169
191
|
/** Max response bytes to cache. A larger response is returned but **not** stored. Default 1 MiB. */
|
|
170
192
|
readonly maxBytes?: number
|
|
171
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Whether a response should be cached for replay. Default: any status below 500 except 401, 403,
|
|
195
|
+
* 408, 409, 425, and 429. Those, like a transient 5xx, say the operation did not run; replaying one
|
|
196
|
+
* would refuse every retry under that key until it expired.
|
|
197
|
+
*/
|
|
172
198
|
readonly shouldCache?: (response: Response) => boolean
|
|
173
199
|
/**
|
|
174
200
|
* Derive the store key from the request. Default: the `header` value scoped by method + path **and
|
|
@@ -197,6 +223,7 @@ export interface IdempotencyOptions {
|
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
|
|
226
|
+
const NOT_THE_OPERATIONS_ANSWER = new Set([401, 403, 408, 409, 425, 429])
|
|
200
227
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
201
228
|
|
|
202
229
|
function assertPositiveTtl(value: number, name: string): void {
|
|
@@ -250,7 +277,8 @@ function responseWithBody(
|
|
|
250
277
|
res: Response,
|
|
251
278
|
body: Uint8Array | ReadableStream<Uint8Array> | null,
|
|
252
279
|
): Response {
|
|
253
|
-
|
|
280
|
+
const safeBody = body instanceof Uint8Array ? body.slice() : body
|
|
281
|
+
return new Response(safeBody, {
|
|
254
282
|
status: res.status,
|
|
255
283
|
statusText: res.statusText,
|
|
256
284
|
headers: res.headers,
|
|
@@ -332,7 +360,8 @@ async function captureBody(
|
|
|
332
360
|
function replay(record: IdempotencyRecord): Response {
|
|
333
361
|
const headers = new Headers(record.headers as Array<[string, string]>)
|
|
334
362
|
headers.set("idempotent-replayed", "true")
|
|
335
|
-
|
|
363
|
+
const body = bodyFor(fromBase64(record.body))
|
|
364
|
+
return new Response(body === null ? null : body.slice(), { status: record.status, headers })
|
|
336
365
|
}
|
|
337
366
|
|
|
338
367
|
const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
|
|
@@ -381,6 +410,10 @@ function defaultIdempotencyKey(
|
|
|
381
410
|
* session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
|
|
382
411
|
* session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
|
|
383
412
|
*
|
|
413
|
+
* The key is not bound to the request body: a key reused with a different body replays the first
|
|
414
|
+
* answer. A route that needs that check declares `schema.idempotency`, which fingerprints the request
|
|
415
|
+
* and refuses a mismatched reuse with 409.
|
|
416
|
+
*
|
|
384
417
|
* Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
|
|
385
418
|
*/
|
|
386
419
|
export function idempotency(options: IdempotencyOptions): Middleware {
|
|
@@ -395,7 +428,9 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
395
428
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
396
429
|
throw new Error("idempotency: maxBytes must be a non-negative integer")
|
|
397
430
|
}
|
|
398
|
-
const shouldCache =
|
|
431
|
+
const shouldCache =
|
|
432
|
+
options.shouldCache ??
|
|
433
|
+
((res: Response) => res.status < 500 && !NOT_THE_OPERATIONS_ANSWER.has(res.status))
|
|
399
434
|
const principalHeaders = (options.principalHeaders ?? DEFAULT_PRINCIPAL_HEADERS).map((name) =>
|
|
400
435
|
name.toLowerCase(),
|
|
401
436
|
)
|
|
@@ -405,7 +440,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
405
440
|
? (req: Request): Promise<string | null> | null =>
|
|
406
441
|
defaultIdempotencyKey(req, header, principalHeaders)
|
|
407
442
|
: (req: Request): string | null | Promise<string | null> => custom(req, header)
|
|
408
|
-
const claimed = new WeakMap<Request, string>()
|
|
443
|
+
const claimed = new WeakMap<Request, { readonly key: string; readonly reservation: string }>()
|
|
409
444
|
|
|
410
445
|
const middleware: Middleware = {
|
|
411
446
|
name: "idempotency",
|
|
@@ -436,15 +471,23 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
436
471
|
},
|
|
437
472
|
})
|
|
438
473
|
}
|
|
439
|
-
|
|
474
|
+
if (claim.state !== "new") return undefined
|
|
475
|
+
claimed.set(req, { key, reservation: claim.reservation })
|
|
440
476
|
return undefined
|
|
441
477
|
},
|
|
442
478
|
async onResponse(res, req) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
479
|
+
// A later onRequest hook (methodOverride, a transport codec) may have replaced the request
|
|
480
|
+
// this one claimed; walk back to it.
|
|
481
|
+
let claimedReq: Request | undefined = req
|
|
482
|
+
let claim = claimed.get(req)
|
|
483
|
+
while (claim === undefined && claimedReq !== undefined) {
|
|
484
|
+
claimedReq = replacedRequestOf(claimedReq)
|
|
485
|
+
if (claimedReq !== undefined) claim = claimed.get(claimedReq)
|
|
486
|
+
}
|
|
487
|
+
if (claim === undefined || claimedReq === undefined) return res // not a claimed request
|
|
488
|
+
claimed.delete(claimedReq)
|
|
446
489
|
if (!shouldCache(res)) {
|
|
447
|
-
await store.release(key)
|
|
490
|
+
await store.release(claim.key, claim.reservation)
|
|
448
491
|
return res
|
|
449
492
|
}
|
|
450
493
|
// Buffer the body (consumes `res`), so a fresh Response is returned in its place.
|
|
@@ -452,16 +495,21 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
452
495
|
try {
|
|
453
496
|
captured = await captureBody(res, maxBytes)
|
|
454
497
|
} catch (err) {
|
|
455
|
-
await store.release(key)
|
|
498
|
+
await store.release(claim.key, claim.reservation)
|
|
456
499
|
throw err
|
|
457
500
|
}
|
|
458
501
|
if (!("bytes" in captured)) {
|
|
459
|
-
await store.release(key) // too large to store - return it, but don't cache
|
|
502
|
+
await store.release(claim.key, claim.reservation) // too large to store - return it, but don't cache
|
|
460
503
|
return captured.response
|
|
461
504
|
}
|
|
462
505
|
const bytes = captured.bytes
|
|
463
506
|
const headers = [...captured.response.headers].filter(([name]) => name !== "set-cookie")
|
|
464
|
-
await store.complete(
|
|
507
|
+
await store.complete(
|
|
508
|
+
claim.key,
|
|
509
|
+
claim.reservation,
|
|
510
|
+
{ status: res.status, headers, body: toBase64(bytes) },
|
|
511
|
+
ttlMs,
|
|
512
|
+
)
|
|
465
513
|
return captured.response
|
|
466
514
|
},
|
|
467
515
|
}
|