@nifrajs/middleware 2.2.0 → 2.3.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 +22 -22
- package/dist/_utils.d.ts +3 -3
- package/dist/_utils.js +3 -3
- package/dist/admission.d.ts +2 -2
- package/dist/admission.js +2 -2
- package/dist/cache-control.d.ts +1 -1
- package/dist/cache-control.js +1 -1
- package/dist/cache.js +1 -1
- package/dist/compression.d.ts +1 -1
- package/dist/compression.js +4 -4
- package/dist/cors.d.ts +1 -1
- package/dist/cors.js +2 -2
- package/dist/durable-command.d.ts +34 -0
- package/dist/durable-command.d.ts.map +1 -0
- package/dist/durable-command.js +50 -0
- package/dist/durable-command.js.map +1 -0
- package/dist/etag.d.ts +2 -2
- package/dist/etag.js +3 -3
- package/dist/healthcheck.d.ts +2 -2
- package/dist/healthcheck.js +1 -1
- package/dist/idempotency.d.ts +4 -4
- package/dist/idempotency.js +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +2 -2
- package/dist/openapi.d.ts +4 -4
- package/dist/openapi.js +4 -4
- package/dist/rate-limit.d.ts +8 -8
- package/dist/rate-limit.js +5 -5
- package/dist/request-id.d.ts +1 -1
- package/dist/request-id.js +1 -1
- package/dist/security-headers.d.ts +1 -1
- package/dist/security-headers.js +1 -1
- package/dist/token-auth.d.ts +6 -6
- package/dist/token-auth.js +4 -4
- package/package.json +4 -4
- package/src/_utils.ts +3 -3
- package/src/admission.ts +4 -4
- package/src/cache-control.ts +1 -1
- package/src/cache.ts +1 -1
- package/src/compression.ts +4 -4
- package/src/cors.ts +2 -2
- package/src/durable-command.ts +62 -0
- package/src/etag.ts +4 -4
- package/src/healthcheck.ts +3 -3
- package/src/idempotency.ts +7 -7
- package/src/index.ts +3 -2
- package/src/logger.ts +2 -2
- package/src/openapi.ts +8 -8
- package/src/rate-limit.ts +10 -10
- package/src/request-id.ts +1 -1
- package/src/security-headers.ts +2 -2
- package/src/token-auth.ts +9 -9
package/dist/openapi.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { definePlugin } from "@nifrajs/core/server";
|
|
2
|
-
// The OpenAPI generator only
|
|
2
|
+
// The OpenAPI generator only - `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
|
|
3
3
|
// plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
|
|
4
4
|
import { toOpenAPI } from "@nifrajs/schema/openapi";
|
|
5
5
|
/**
|
|
@@ -55,10 +55,10 @@ function scalarPage(specUrl, title, cdn) {
|
|
|
55
55
|
</html>`;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
* Serve an OpenAPI 3.1 document (a structural subset
|
|
58
|
+
* Serve an OpenAPI 3.1 document (a structural subset - see {@link buildOpenApiDocument}) at
|
|
59
59
|
* `options.path` (default `/openapi.json`), generated from the app's registered routes. Generation is
|
|
60
60
|
* **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
|
|
61
|
-
* registered
|
|
61
|
+
* registered - so the plugin's own position in the chain doesn't matter.
|
|
62
62
|
*
|
|
63
63
|
* Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
|
|
64
64
|
* `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
|
|
@@ -76,7 +76,7 @@ export function openapi(options = {}) {
|
|
|
76
76
|
let uiCached;
|
|
77
77
|
return definePlugin("openapi", (app) => {
|
|
78
78
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
79
|
-
// routes lazily, at request time
|
|
79
|
+
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
80
80
|
app.register("GET", docPath, undefined, () => {
|
|
81
81
|
cached ??= JSON.stringify(buildOpenApiDocument(app.routes(), {
|
|
82
82
|
...options,
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -7,14 +7,14 @@ export interface RateLimitResult {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
|
|
10
|
-
* limit holds across instances
|
|
10
|
+
* limit holds across instances - that's a user dependency, not ours, hence the
|
|
11
11
|
* interface. {@link MemoryStore} is for dev / single-instance only.
|
|
12
12
|
*/
|
|
13
13
|
export interface RateLimitStore {
|
|
14
14
|
hit(key: string, windowMs: number): Promise<RateLimitResult>;
|
|
15
15
|
}
|
|
16
16
|
export interface MemoryStoreOptions {
|
|
17
|
-
/** Allow the in-memory store in production. Off by default
|
|
17
|
+
/** Allow the in-memory store in production. Off by default - a per-instance limiter is unsafe across instances. */
|
|
18
18
|
readonly allowInProduction?: boolean;
|
|
19
19
|
/** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
|
|
20
20
|
* Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
|
|
@@ -28,8 +28,8 @@ export interface MemoryStoreOptions {
|
|
|
28
28
|
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
29
29
|
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
30
30
|
* then oldest active keys).
|
|
31
|
-
* Without this, expired entries for keys never seen again
|
|
32
|
-
*
|
|
31
|
+
* Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
|
|
32
|
+
* - would accumulate in the map forever and OOM a single-instance deploy.
|
|
33
33
|
*/
|
|
34
34
|
export declare class MemoryStore implements RateLimitStore {
|
|
35
35
|
private readonly windows;
|
|
@@ -37,7 +37,7 @@ export declare class MemoryStore implements RateLimitStore {
|
|
|
37
37
|
private readonly sweepIntervalMs;
|
|
38
38
|
private lastSweep;
|
|
39
39
|
private evictionScans;
|
|
40
|
-
/** Cumulative entries inspected by the eviction scan since construction
|
|
40
|
+
/** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
|
|
41
41
|
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
42
42
|
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
43
43
|
get evictionScanCount(): number;
|
|
@@ -58,13 +58,13 @@ export interface RateLimitOptions {
|
|
|
58
58
|
* Default `0`.
|
|
59
59
|
*
|
|
60
60
|
* The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
|
|
61
|
-
* observed
|
|
61
|
+
* observed - the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
|
|
62
62
|
* second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
|
|
63
63
|
* the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
|
|
64
64
|
*
|
|
65
65
|
* ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
|
|
66
66
|
* Reading the
|
|
67
|
-
* *first* XFF hop
|
|
67
|
+
* *first* XFF hop - the old behavior - let any client mint a fresh bucket per request and defeat the
|
|
68
68
|
* limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
|
|
69
69
|
* trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
|
|
70
70
|
*/
|
|
@@ -77,7 +77,7 @@ export interface RateLimitOptions {
|
|
|
77
77
|
*/
|
|
78
78
|
readonly allowGlobalKey?: boolean;
|
|
79
79
|
/**
|
|
80
|
-
* Bucket key for a request. Overrides the default XFF-based key entirely
|
|
80
|
+
* Bucket key for a request. Overrides the default XFF-based key entirely - set this for accurate
|
|
81
81
|
* per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
|
|
82
82
|
* can't see the socket IP (that needs the server instance).
|
|
83
83
|
*/
|
package/dist/rate-limit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance";
|
|
2
2
|
/** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
|
|
3
3
|
* to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
|
|
4
|
-
* under a distinct-key flood
|
|
4
|
+
* under a distinct-key flood - the abuse the key cap defends against. */
|
|
5
5
|
const MAX_EVICTION_SCAN = 64;
|
|
6
6
|
/**
|
|
7
7
|
* In-process fixed-window store. Refuses to run in production unless explicitly allowed.
|
|
@@ -9,8 +9,8 @@ const MAX_EVICTION_SCAN = 64;
|
|
|
9
9
|
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
10
10
|
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
11
11
|
* then oldest active keys).
|
|
12
|
-
* Without this, expired entries for keys never seen again
|
|
13
|
-
*
|
|
12
|
+
* Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
|
|
13
|
+
* - would accumulate in the map forever and OOM a single-instance deploy.
|
|
14
14
|
*/
|
|
15
15
|
export class MemoryStore {
|
|
16
16
|
windows = new Map();
|
|
@@ -18,7 +18,7 @@ export class MemoryStore {
|
|
|
18
18
|
sweepIntervalMs;
|
|
19
19
|
lastSweep = 0;
|
|
20
20
|
evictionScans = 0;
|
|
21
|
-
/** Cumulative entries inspected by the eviction scan since construction
|
|
21
|
+
/** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
|
|
22
22
|
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
23
23
|
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
24
24
|
get evictionScanCount() {
|
|
@@ -65,7 +65,7 @@ export class MemoryStore {
|
|
|
65
65
|
if (this.windows.size <= this.maxKeys)
|
|
66
66
|
return;
|
|
67
67
|
// Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
|
|
68
|
-
// every insertion is O(n)/request under a distinct-key flood
|
|
68
|
+
// every insertion is O(n)/request under a distinct-key flood - the exact abuse the cap defends
|
|
69
69
|
// against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
|
|
70
70
|
// victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
|
|
71
71
|
// are still reclaimed by the amortized sweep in `hit`.
|
package/dist/request-id.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface RequestIdOptions {
|
|
|
7
7
|
/**
|
|
8
8
|
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
9
9
|
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
10
|
-
* threaded by `derive`), and echoes it on the response header. Idempotent
|
|
10
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
|
|
11
11
|
* no-op.
|
|
12
12
|
*
|
|
13
13
|
* ```ts
|
package/dist/request-id.js
CHANGED
|
@@ -2,7 +2,7 @@ import { definePlugin } from "@nifrajs/core/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
4
4
|
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
5
|
-
* threaded by `derive`), and echoes it on the response header. Idempotent
|
|
5
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
|
|
6
6
|
* no-op.
|
|
7
7
|
*
|
|
8
8
|
* ```ts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core/server";
|
|
2
2
|
export interface SecurityHeadersOptions {
|
|
3
|
-
/** `Strict-Transport-Security`. Off by default
|
|
3
|
+
/** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
|
|
4
4
|
readonly hsts?: {
|
|
5
5
|
readonly maxAge: number;
|
|
6
6
|
readonly includeSubDomains?: boolean;
|
package/dist/security-headers.js
CHANGED
|
@@ -22,7 +22,7 @@ export function securityHeaders(options = {}) {
|
|
|
22
22
|
return withRouteAssurance({
|
|
23
23
|
name: "security-headers",
|
|
24
24
|
// Mutate in place on the common (mutable-headers) response; clone only for an immutable one
|
|
25
|
-
// (see withHeaders
|
|
25
|
+
// (see withHeaders - the old always-clone was ~3% of a realistic request).
|
|
26
26
|
onResponse: (res) => withHeaders(res, (headers) => {
|
|
27
27
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
28
28
|
headers.set("X-Frame-Options", frameOptions);
|
package/dist/token-auth.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type NifraPlugin } from "@nifrajs/core/server";
|
|
2
2
|
type MaybePromise<T> = T | Promise<T>;
|
|
3
3
|
/**
|
|
4
|
-
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)`
|
|
4
|
+
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` - it rejects unauthorized
|
|
5
5
|
* requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
|
|
6
6
|
* inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
|
|
7
7
|
* {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
|
|
@@ -24,7 +24,7 @@ export type AuthPlugin<P> = NifraPlugin & {
|
|
|
24
24
|
};
|
|
25
25
|
export interface BearerOptions<P> {
|
|
26
26
|
/** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
|
|
27
|
-
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare
|
|
27
|
+
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare - never `===`. */
|
|
28
28
|
readonly verify: (token: string) => MaybePromise<P | null | undefined>;
|
|
29
29
|
/** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
|
|
30
30
|
readonly optional?: boolean;
|
|
@@ -34,7 +34,7 @@ export interface BearerOptions<P> {
|
|
|
34
34
|
/**
|
|
35
35
|
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
36
36
|
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
37
|
-
* verified principal is read via the returned instance
|
|
37
|
+
* verified principal is read via the returned instance - see {@link AuthPlugin}.
|
|
38
38
|
*/
|
|
39
39
|
export declare function bearer<P>(options: BearerOptions<P>): AuthPlugin<P>;
|
|
40
40
|
export interface ApiKeyVerifyOptions<P> {
|
|
@@ -53,11 +53,11 @@ export interface ApiKeyStaticOptions {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* API-key authentication via a header (default `x-api-key`). Two forms:
|
|
56
|
-
* - `apiKey({ keys })`
|
|
57
|
-
* - `apiKey({ verify })`
|
|
56
|
+
* - `apiKey({ keys })` - a fixed key set, compared in **constant time**; the matched key is the principal.
|
|
57
|
+
* - `apiKey({ verify })` - custom (e.g. DB-backed) verification returning a typed principal.
|
|
58
58
|
*
|
|
59
59
|
* Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
|
|
60
|
-
* instance
|
|
60
|
+
* instance - see {@link AuthPlugin}.
|
|
61
61
|
*/
|
|
62
62
|
export declare function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>;
|
|
63
63
|
export declare function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>;
|
package/dist/token-auth.js
CHANGED
|
@@ -9,11 +9,11 @@ function createTokenAuth(config) {
|
|
|
9
9
|
});
|
|
10
10
|
const plugin = definePlugin(config.name, (app) => app.beforeHandle(async (c) => {
|
|
11
11
|
const token = config.extract(c.req);
|
|
12
|
-
// Empty string is treated as "no credential"
|
|
12
|
+
// Empty string is treated as "no credential" - never passed to verify.
|
|
13
13
|
const principal = token !== undefined && token !== "" ? await config.verify(token) : null;
|
|
14
14
|
if (principal !== null && principal !== undefined) {
|
|
15
15
|
store.set(c.req, principal);
|
|
16
|
-
return undefined; // authorized
|
|
16
|
+
return undefined; // authorized - continue to the handler
|
|
17
17
|
}
|
|
18
18
|
return config.optional ? undefined : reject(); // 401 short-circuits (skips the handler)
|
|
19
19
|
}));
|
|
@@ -37,7 +37,7 @@ function createTokenAuth(config) {
|
|
|
37
37
|
/**
|
|
38
38
|
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
39
39
|
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
40
|
-
* verified principal is read via the returned instance
|
|
40
|
+
* verified principal is read via the returned instance - see {@link AuthPlugin}.
|
|
41
41
|
*/
|
|
42
42
|
export function bearer(options) {
|
|
43
43
|
const realm = options.realm ?? "api";
|
|
@@ -69,7 +69,7 @@ function timingSafeEqualBytes(a, b) {
|
|
|
69
69
|
/**
|
|
70
70
|
* Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
|
|
71
71
|
* 32 bytes); per request the candidate is hashed and compared against every digest **without early
|
|
72
|
-
* exit**, so timing depends only on the (public) key count
|
|
72
|
+
* exit**, so timing depends only on the (public) key count - never on the secret value or which key
|
|
73
73
|
* matched. Portable across all runtimes via WebCrypto.
|
|
74
74
|
*/
|
|
75
75
|
function staticKeyVerify(keys) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/middleware",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Composable middleware for nifra
|
|
3
|
+
"version": "2.3.0",
|
|
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",
|
|
7
7
|
"typescript",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@nifrajs/schema": "2.
|
|
49
|
+
"@nifrajs/schema": "2.3.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@nifrajs/core": "^2.
|
|
52
|
+
"@nifrajs/core": "^2.3.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@nifrajs/core": "workspace:*",
|
package/src/_utils.ts
CHANGED
|
@@ -144,12 +144,12 @@ export async function verifyHmacSha256(
|
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Apply header mutations to a response, in place when possible. Framework-built responses
|
|
147
|
-
* (`new Response`, `Response.json`) have mutable headers across every runtime
|
|
147
|
+
* (`new Response`, `Response.json`) have mutable headers across every runtime - so the common path
|
|
148
148
|
* mutates `res` directly and returns it, allocating nothing. Only an *immutable*-headers response
|
|
149
|
-
* (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd
|
|
149
|
+
* (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd -
|
|
150
150
|
* never on Bun) makes `.set`/`.append` throw; that path clones into a fresh `Headers` + `Response`,
|
|
151
151
|
* exactly the old always-clone behavior. `apply` runs once either way (immutability is all-or-nothing
|
|
152
|
-
*
|
|
152
|
+
* - the first mutation throws before any partial change), so a mutation chain is safe to pass.
|
|
153
153
|
*/
|
|
154
154
|
export function withHeaders(res: Response, apply: (headers: Headers) => void): Response {
|
|
155
155
|
try {
|
package/src/admission.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AdmissionController, AdmissionDecision } from "@nifrajs/core/serve
|
|
|
3
3
|
/**
|
|
4
4
|
* Adaptive capacity admission. Rate limiting bounds request *frequency* and `@nifrajs/core/budget` bounds
|
|
5
5
|
* request *duration*; neither stops a healthy instance from accepting more *concurrent* work than it
|
|
6
|
-
* can finish. This gate admits on live capacity evidence
|
|
6
|
+
* can finish. This gate admits on live capacity evidence - in-flight count + event-loop lag - briefly
|
|
7
7
|
* queues at the edge, and sheds the rest with `429` + `Retry-After`, so p99 stays bounded under load
|
|
8
8
|
* instead of collapsing.
|
|
9
9
|
*
|
|
@@ -11,7 +11,7 @@ import type { AdmissionController, AdmissionDecision } from "@nifrajs/core/serve
|
|
|
11
11
|
* hook layers tenant priority and reserved capacity on top without leaking
|
|
12
12
|
* those concerns into the OSS core.
|
|
13
13
|
*
|
|
14
|
-
* Wire it as the server's `admission` option (NOT an `onRequest` hook
|
|
14
|
+
* Wire it as the server's `admission` option (NOT an `onRequest` hook - a hook disables the native
|
|
15
15
|
* route table). Off by default: when unset, the request path is untouched.
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -97,7 +97,7 @@ export interface LoopDelayHistogram {
|
|
|
97
97
|
*/
|
|
98
98
|
export type LoopDelayMonitor = (resolutionMs: number) => LoopDelayHistogram | undefined
|
|
99
99
|
|
|
100
|
-
/** Shared no-op sampler
|
|
100
|
+
/** Shared no-op sampler - the fallback when an explicitly supplied monitor has no histogram. */
|
|
101
101
|
const NO_LAG: () => number = () => 0
|
|
102
102
|
|
|
103
103
|
/**
|
|
@@ -274,7 +274,7 @@ export function createAdmissionController(options: AdmissionOptions): AdmissionC
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
// Loop-lag shed: the box is behind on the event loop
|
|
277
|
+
// Loop-lag shed: the box is behind on the event loop - reject regardless of slot count.
|
|
278
278
|
if (lag > maxLagMs) return shedResponse("loop-lag")
|
|
279
279
|
|
|
280
280
|
// FAST PATH (O(1)): a slot is free and lag is fine. No timer, no queue, no extra await.
|
package/src/cache-control.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface CacheControlOptions {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
|
|
14
|
-
* function of the request (return `undefined` to leave a response untouched
|
|
14
|
+
* function of the request (return `undefined` to leave a response untouched - e.g. cache by path).
|
|
15
15
|
* Defaults to `GET`/`HEAD` + 2xx, and never clobbers a `Cache-Control` the handler set itself.
|
|
16
16
|
*
|
|
17
17
|
* ```ts
|
package/src/cache.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function cache(options: CacheOptions): Middleware {
|
|
|
249
249
|
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
250
250
|
}
|
|
251
251
|
// Shared-cache safety (RFC 9111 §3.5): never store a response to an authenticated request
|
|
252
|
-
// (Authorization or Cookie) unless it's explicitly public
|
|
252
|
+
// (Authorization or Cookie) unless it's explicitly public - otherwise a personalized 200 leaks
|
|
253
253
|
// across users. Opt in with `cacheAuthenticated: true` for a route that's the same for everyone.
|
|
254
254
|
if (
|
|
255
255
|
!cacheAuthenticated &&
|
package/src/compression.ts
CHANGED
|
@@ -44,7 +44,7 @@ const gzipHeaders = (source: Headers): Headers => {
|
|
|
44
44
|
*
|
|
45
45
|
* The body is **peeked** up to `threshold` bytes to enforce the size floor: a response that ends below
|
|
46
46
|
* the threshold is sent uncompressed (gzip's ~20-byte overhead would enlarge it), and only larger
|
|
47
|
-
* bodies are compressed
|
|
47
|
+
* bodies are compressed - the buffered prefix is replayed and the remainder streamed. (Runtimes rarely
|
|
48
48
|
* set `Content-Length` on a constructed `Response`, so the header alone can't gate this.)
|
|
49
49
|
*
|
|
50
50
|
* Skips: clients that don't accept gzip, already-encoded responses, bodyless responses (204/304/HEAD),
|
|
@@ -61,7 +61,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
61
61
|
return definePlugin("compression", (app) =>
|
|
62
62
|
app.onResponse(async (res, req) => {
|
|
63
63
|
const body = res.body
|
|
64
|
-
if (body === null) return res // 204/304/HEAD
|
|
64
|
+
if (body === null) return res // 204/304/HEAD - nothing to compress
|
|
65
65
|
if (!(req.headers.get("accept-encoding") ?? "").toLowerCase().includes("gzip")) return res
|
|
66
66
|
if (res.headers.has("content-encoding")) return res // already encoded
|
|
67
67
|
if (res.status === 206 || res.headers.has("content-range")) return res // ranges pass through
|
|
@@ -77,7 +77,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
77
77
|
while (total < threshold) {
|
|
78
78
|
const { done, value } = await reader.read()
|
|
79
79
|
if (done) {
|
|
80
|
-
// Body ended below the threshold
|
|
80
|
+
// Body ended below the threshold - too small to be worth gzipping. Emit as-is.
|
|
81
81
|
return new Response(concat(buffered, total), {
|
|
82
82
|
status: res.status,
|
|
83
83
|
statusText: res.statusText,
|
|
@@ -87,7 +87,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
87
87
|
buffered.push(value)
|
|
88
88
|
total += value.byteLength
|
|
89
89
|
}
|
|
90
|
-
// Over the threshold: replay the buffered prefix, then stream the rest
|
|
90
|
+
// Over the threshold: replay the buffered prefix, then stream the rest - all through gzip.
|
|
91
91
|
// (No explicit generic: it lets the chunk type infer from the non-shared reader so the stream
|
|
92
92
|
// matches `CompressionStream`'s `Uint8Array` typing.)
|
|
93
93
|
const source = new ReadableStream({
|
package/src/cors.ts
CHANGED
|
@@ -43,7 +43,7 @@ function resolveAllowOrigin(
|
|
|
43
43
|
* short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
|
|
44
44
|
* `onResponse`, so they also land on errors, 404s, and the preflight itself.
|
|
45
45
|
*
|
|
46
|
-
* Throws at construction if `credentials: true` is paired with `origin: "*"`
|
|
46
|
+
* Throws at construction if `credentials: true` is paired with `origin: "*"` - the
|
|
47
47
|
* browser rejects that combination, so we fail loud instead of shipping dead CORS.
|
|
48
48
|
*/
|
|
49
49
|
export function cors(options: CorsOptions = {}): Middleware {
|
|
@@ -51,7 +51,7 @@ export function cors(options: CorsOptions = {}): Middleware {
|
|
|
51
51
|
const credentials = options.credentials ?? false
|
|
52
52
|
if (credentials && origin === "*") {
|
|
53
53
|
throw new Error(
|
|
54
|
-
'cors: `credentials: true` cannot be combined with `origin: "*"`
|
|
54
|
+
'cors: `credentials: true` cannot be combined with `origin: "*"` - list explicit origin(s).',
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
const methods = (options.methods ?? DEFAULT_METHODS).join(", ")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
+
import {
|
|
3
|
+
attachCapabilityJournal,
|
|
4
|
+
type CapabilityExecutionJournal,
|
|
5
|
+
} from "@nifrajs/core/capabilities"
|
|
6
|
+
import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
|
|
7
|
+
|
|
8
|
+
export interface DurableCommandOptions {
|
|
9
|
+
/** Where effect transitions are recorded. `createDurableEffectJournal` from
|
|
10
|
+
* `@nifrajs/core/durable-execution` is the one this is built for; any implementation of the seam
|
|
11
|
+
* works, and a memory-backed one is a test double, not a durable command. */
|
|
12
|
+
readonly journal: CapabilityExecutionJournal
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Journal every capability effect on the routes below it, and declare the evidence that says so.
|
|
17
|
+
*
|
|
18
|
+
* A capability defined `idempotency: "durable"` requires `nifra.durable-command` evidence, and until
|
|
19
|
+
* this adapter existed nothing in the framework produced it. The only way to satisfy the tier was to
|
|
20
|
+
* write `assurance: ["nifra.durable-command"]` on the route - an assertion with nothing behind it,
|
|
21
|
+
* wrong in both directions: a route that genuinely journals its effects but omits the string fails
|
|
22
|
+
* `nifra check`, and a route that journals nothing but includes it passes.
|
|
23
|
+
*
|
|
24
|
+
* The evidence is a by-product here rather than a claim. Installing the adapter puts the journal on
|
|
25
|
+
* the request, so `executeCapability` records intent before an effect runs and exactly one terminal
|
|
26
|
+
* outcome after - which is what the tier is asking about. A call that passes its own `journal` still
|
|
27
|
+
* uses that one.
|
|
28
|
+
*
|
|
29
|
+
* const commands = durableCommand({ journal: createDurableEffectJournal({ store }) })
|
|
30
|
+
* const app = server().use(commands).post("/charge", { capabilities: ["billing.charge"] }, …)
|
|
31
|
+
*
|
|
32
|
+
* `beforeHandle` is order-scoped, like the auth plugins: routes registered before `.use(...)` are not
|
|
33
|
+
* covered, and the `subsequent` scope on the evidence says the same thing to `nifra check`.
|
|
34
|
+
*
|
|
35
|
+
* Note what this does NOT claim. Response replay (`schema.idempotency`) is a different guarantee and
|
|
36
|
+
* deliberately never proves this one: replaying a stored response after a crash mid-effect does not
|
|
37
|
+
* make the effect exactly-once, and the journal is what survives the crash.
|
|
38
|
+
*/
|
|
39
|
+
export function durableCommand(options: DurableCommandOptions): NifraPlugin {
|
|
40
|
+
const journal = options.journal
|
|
41
|
+
if (journal === null || typeof journal !== "object") {
|
|
42
|
+
throw new TypeError("durableCommand: journal must be a CapabilityExecutionJournal")
|
|
43
|
+
}
|
|
44
|
+
for (const method of ["intent", "executing", "committed", "failed"] as const) {
|
|
45
|
+
if (typeof journal[method] !== "function") {
|
|
46
|
+
// Fail at wiring time. A journal missing a transition would otherwise surface as a mid-effect
|
|
47
|
+
// TypeError on the first write in production, with the evidence already declared.
|
|
48
|
+
throw new TypeError(`durableCommand: journal.${method} must be a function`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const plugin = definePlugin("nifra:durable-command", (app) =>
|
|
52
|
+
app.beforeHandle((c: object) => {
|
|
53
|
+
attachCapabilityJournal(c, journal)
|
|
54
|
+
return undefined
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
return withRouteAssurance(plugin, {
|
|
58
|
+
id: NIFRA_ASSURANCE.DURABLE_COMMAND,
|
|
59
|
+
source: "durable-command",
|
|
60
|
+
scope: "subsequent",
|
|
61
|
+
})
|
|
62
|
+
}
|
package/src/etag.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { definePlugin } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
|
-
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags
|
|
3
|
+
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
|
|
4
4
|
function fnv1a(bytes: Uint8Array): string {
|
|
5
5
|
let h = 0x811c9dc5
|
|
6
6
|
for (let i = 0; i < bytes.length; i++) {
|
|
@@ -11,7 +11,7 @@ function fnv1a(bytes: Uint8Array): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface ETagOptions {
|
|
14
|
-
/** Emit a weak validator (`W/"…"`). Default `true`
|
|
14
|
+
/** Emit a weak validator (`W/"…"`). Default `true` - it's a content hash, not a byte-for-byte promise. */
|
|
15
15
|
readonly weak?: boolean
|
|
16
16
|
/** Maximum response bytes to hash. Default `1_000_000`; larger responses pass through unchanged. */
|
|
17
17
|
readonly maxBytes?: number
|
|
@@ -19,7 +19,7 @@ export interface ETagOptions {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
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
|
|
22
|
+
* **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
|
|
23
23
|
* responses. It reads and rebuilds small bodies only; larger responses pass through unchanged.
|
|
24
24
|
* Idempotent.
|
|
25
25
|
*/
|
|
@@ -40,7 +40,7 @@ export function etag(options: ETagOptions = {}) {
|
|
|
40
40
|
const headers = new Headers(res.headers)
|
|
41
41
|
headers.set("ETag", tag)
|
|
42
42
|
if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
|
|
43
|
-
// A 304 carries no body
|
|
43
|
+
// A 304 carries no body - drop the body-describing headers so strict intermediaries don't see a
|
|
44
44
|
// null body with a non-zero Content-Length.
|
|
45
45
|
headers.delete("content-length")
|
|
46
46
|
headers.delete("content-type")
|
package/src/healthcheck.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { definePlugin } from "@nifrajs/core/server"
|
|
|
3
3
|
type MaybePromise<T> = T | Promise<T>
|
|
4
4
|
|
|
5
5
|
export interface HealthcheckOptions {
|
|
6
|
-
/** Liveness path
|
|
6
|
+
/** Liveness path - always `200` while the process is up. Default `"/health"`. */
|
|
7
7
|
readonly path?: string
|
|
8
|
-
/** Readiness path
|
|
8
|
+
/** Readiness path - runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
|
|
9
9
|
readyPath?: string
|
|
10
10
|
/** Readiness checks (DB ping, cache reachable, …). Each returns a boolean or throws; a throw counts
|
|
11
11
|
* as failing. `/ready` reports each by name and is `200` only when all pass. */
|
|
@@ -38,7 +38,7 @@ export function healthcheck(options: HealthcheckOptions = {}) {
|
|
|
38
38
|
app.register("GET", readyPath, undefined, async () => {
|
|
39
39
|
const results: Record<string, boolean> = {}
|
|
40
40
|
let ready = true
|
|
41
|
-
// Run checks concurrently; a throw (or false) marks that check
|
|
41
|
+
// Run checks concurrently; a throw (or false) marks that check - and the whole probe - not ready.
|
|
42
42
|
await Promise.all(
|
|
43
43
|
checkNames.map(async (name) => {
|
|
44
44
|
try {
|
package/src/idempotency.ts
CHANGED
|
@@ -2,12 +2,12 @@ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
|
2
2
|
import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Idempotency keys for unsafe requests
|
|
5
|
+
* Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
|
|
6
6
|
* tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
|
|
7
7
|
* running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay
|
|
8
8
|
* or an in-flight collision short-circuits before any mutation.
|
|
9
9
|
*
|
|
10
|
-
* Pair it with a DB uniqueness constraint
|
|
10
|
+
* Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of
|
|
11
11
|
* truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
|
|
12
12
|
* (Redis, etc.) so the guarantee holds across instances; {@link MemoryIdempotencyStore} is dev-only.
|
|
13
13
|
*/
|
|
@@ -15,7 +15,7 @@ import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
|
15
15
|
/** A captured response, replayed verbatim on a retry. Body is base64 (binary-safe + JSON-serializable). */
|
|
16
16
|
export interface IdempotencyRecord {
|
|
17
17
|
readonly status: number
|
|
18
|
-
/** Response headers, **excluding `Set-Cookie`** (cookies are session-specific
|
|
18
|
+
/** Response headers, **excluding `Set-Cookie`** (cookies are session-specific - see {@link idempotency}). */
|
|
19
19
|
readonly headers: ReadonlyArray<readonly [string, string]>
|
|
20
20
|
/** Response body, base64-encoded. */
|
|
21
21
|
readonly body: string
|
|
@@ -45,7 +45,7 @@ export interface IdempotencyStore {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface MemoryIdempotencyStoreOptions {
|
|
48
|
-
/** Allow the in-memory store in production. Off by default
|
|
48
|
+
/** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */
|
|
49
49
|
readonly allowInProduction?: boolean
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -253,7 +253,7 @@ function replay(record: IdempotencyRecord): Response {
|
|
|
253
253
|
/**
|
|
254
254
|
* Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
|
|
255
255
|
*
|
|
256
|
-
* **`Set-Cookie` is intentionally not cached or replayed**
|
|
256
|
+
* **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is
|
|
257
257
|
* session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
|
|
258
258
|
* session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
|
|
259
259
|
*
|
|
@@ -286,7 +286,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
286
286
|
async onRequest(req) {
|
|
287
287
|
if (!methods.has(req.method.toUpperCase())) return undefined
|
|
288
288
|
const key = keyOf(req, header)
|
|
289
|
-
if (key === null || key === "") return undefined // opt-in per request
|
|
289
|
+
if (key === null || key === "") return undefined // opt-in per request - no key ⇒ no dedupe
|
|
290
290
|
const claim = await store.begin(key, lockTtlMs)
|
|
291
291
|
if (claim.state === "replay") return replay(claim.record)
|
|
292
292
|
if (claim.state === "in_flight") {
|
|
@@ -318,7 +318,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
318
318
|
throw err
|
|
319
319
|
}
|
|
320
320
|
if (!("bytes" in captured)) {
|
|
321
|
-
await store.release(key) // too large to store
|
|
321
|
+
await store.release(key) // too large to store - return it, but don't cache
|
|
322
322
|
return captured.response
|
|
323
323
|
}
|
|
324
324
|
const bytes = captured.bytes
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/middleware`
|
|
2
|
+
* `@nifrajs/middleware` - composable, dependency-free middleware + plugins applied via `app.use()`.
|
|
3
3
|
* The hardening set (`cors`, `rateLimit`, `securityHeaders`) returns a `@nifrajs/core` `Middleware`
|
|
4
4
|
* (a hook bundle, context-agnostic). The seeded plugins (`requestId`, `logger`, `etag`, `bearer`,
|
|
5
|
-
* `apiKey`) use the `definePlugin` convention
|
|
5
|
+
* `apiKey`) use the `definePlugin` convention - named (idempotent dedupe), and `requestId` threads
|
|
6
6
|
* `c.requestId` into the handler context.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -44,6 +44,7 @@ export {
|
|
|
44
44
|
csrf,
|
|
45
45
|
verifyCsrfToken,
|
|
46
46
|
} from "./csrf.ts"
|
|
47
|
+
export { type DurableCommandOptions, durableCommand } from "./durable-command.ts"
|
|
47
48
|
export { type ETagOptions, etag } from "./etag.ts"
|
|
48
49
|
export { type HealthcheckOptions, healthcheck } from "./healthcheck.ts"
|
|
49
50
|
export {
|
package/src/logger.ts
CHANGED
|
@@ -15,8 +15,8 @@ export interface LoggerOptions {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* A {@link definePlugin} plugin that logs one structured line per request
|
|
19
|
-
* and duration
|
|
18
|
+
* A {@link definePlugin} plugin that logs one structured line per request - method, path, status,
|
|
19
|
+
* and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
|
|
20
20
|
* paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
|
|
21
21
|
*/
|
|
22
22
|
export function logger(options: LoggerOptions = {}) {
|