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