@daloyjs/core 0.36.0 → 0.38.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. package/package.json +79 -3
@@ -0,0 +1,204 @@
1
+ /**
2
+ * First-class `Idempotency-Key` handling for DaloyJS.
3
+ *
4
+ * The {@link idempotency} middleware lets clients safely retry unsafe requests
5
+ * (`POST`, `PUT`, `PATCH`, `DELETE`) without risking duplicate side effects —
6
+ * the table-stakes guarantee for payment surfaces and serverless retries. It
7
+ * mirrors the IETF *The Idempotency-Key HTTP Header Field* draft and the
8
+ * conventions used by major payment processors:
9
+ *
10
+ * 1. The client sends a unique, client-generated `Idempotency-Key` header.
11
+ * 2. On the **first** request the handler runs normally; the framework
12
+ * fingerprints the request (method + path + body) and persists the final
13
+ * response keyed by the idempotency key.
14
+ * 3. On a **retry** carrying the same key and the same fingerprint, the stored
15
+ * response is replayed byte-for-byte (with an `Idempotency-Replayed: true`
16
+ * marker) — the handler never runs twice.
17
+ * 4. A retry that arrives **while the original is still in flight** gets a
18
+ * `409 Conflict` so the client backs off instead of racing.
19
+ * 5. Reusing a key with a **different** request body returns `422
20
+ * Unprocessable Content` — a key is permanently bound to its first payload.
21
+ *
22
+ * The store is pluggable via {@link IdempotencyStore}, mirroring the
23
+ * `SessionStore` / rate-limit-store pattern. The default
24
+ * {@link MemoryIdempotencyStore} is process-local; supply a shared backend
25
+ * (e.g. Redis) for multi-instance deployments.
26
+ *
27
+ * This module is dependency-free and uses only Web Crypto + Web Standard
28
+ * `Request`/`Response`, so it runs unchanged on Node, Bun, Deno, Cloudflare
29
+ * Workers, and Vercel Edge.
30
+ *
31
+ * @module
32
+ * @since 0.37.0
33
+ */
34
+ import type { Hooks } from "./types.js";
35
+ /**
36
+ * Test-only helper that clears the process-wide shared stores used by
37
+ * `idempotency({ groupId })`. Not part of the documented public API.
38
+ *
39
+ * @internal
40
+ */
41
+ export declare function _resetSharedIdempotencyStoresForTests(): void;
42
+ /**
43
+ * A captured HTTP response persisted for replay. The body is stored as
44
+ * standard base64 so arbitrary binary payloads round-trip safely.
45
+ */
46
+ export interface StoredIdempotentResponse {
47
+ /** HTTP status code of the original response. */
48
+ status: number;
49
+ /** Response headers as `[name, value]` pairs (lower-cased by `Headers`). */
50
+ headers: Array<[string, string]>;
51
+ /** Base64-encoded response body (empty string for a bodyless response). */
52
+ body: string;
53
+ }
54
+ /**
55
+ * A persisted idempotency entry. An entry is first written as `in-flight`
56
+ * while the handler runs, then upgraded to `completed` (carrying the captured
57
+ * {@link StoredIdempotentResponse}) once the response is produced.
58
+ */
59
+ export interface IdempotencyRecord {
60
+ /** SHA-256 hex fingerprint of the originating request (method + path + body). */
61
+ fingerprint: string;
62
+ /** Lifecycle state of the reservation. */
63
+ status: "in-flight" | "completed";
64
+ /** Captured response, present only when `status` is `"completed"`. */
65
+ response?: StoredIdempotentResponse;
66
+ /** Creation time as ms since epoch. */
67
+ createdAt: number;
68
+ /** Absolute expiration as ms since epoch. */
69
+ expiresAt: number;
70
+ }
71
+ /**
72
+ * Pluggable persistence backend for {@link idempotency}. All methods may be
73
+ * synchronous or asynchronous. The contract mirrors `SessionStore` /
74
+ * `RateLimitStore`: implementations should treat an `expiresAt` in the past as
75
+ * "missing" and may lazily delete expired records.
76
+ *
77
+ * The {@link reserve} method MUST be atomic ("set if absent") so two
78
+ * concurrent requests carrying the same key cannot both win the reservation —
79
+ * exactly the `SET key value NX` semantics of a Redis backend.
80
+ */
81
+ export interface IdempotencyStore {
82
+ /**
83
+ * Atomically reserve `key` for an in-flight request. If `key` is unused,
84
+ * persist `record` with the given TTL and return `null` (the caller now owns
85
+ * the key). If `key` already exists (in-flight or completed), return the
86
+ * existing record **without modifying it**.
87
+ *
88
+ * @param key - The (already namespaced) storage key.
89
+ * @param record - The in-flight record to persist on a successful reservation.
90
+ * @param ttlMs - Time-to-live in milliseconds for the reservation.
91
+ */
92
+ reserve(key: string, record: IdempotencyRecord, ttlMs: number): IdempotencyRecord | null | Promise<IdempotencyRecord | null>;
93
+ /**
94
+ * Persist the final response for a previously reserved key, upgrading it to
95
+ * `completed` so subsequent retries replay it.
96
+ *
97
+ * @param key - The (already namespaced) storage key.
98
+ * @param record - The completed record carrying the captured response.
99
+ * @param ttlMs - Time-to-live in milliseconds for the stored response.
100
+ */
101
+ complete(key: string, record: IdempotencyRecord, ttlMs: number): void | Promise<void>;
102
+ /**
103
+ * Release a reservation so the client may retry. Called when the handler
104
+ * produces a non-cacheable response (e.g. `5xx`) or throws.
105
+ *
106
+ * @param key - The (already namespaced) storage key.
107
+ */
108
+ release(key: string): void | Promise<void>;
109
+ }
110
+ /** Options for the {@link idempotency} middleware. */
111
+ export interface IdempotencyOptions {
112
+ /** Pluggable persistence backend. Default: a fresh in-memory store. */
113
+ store?: IdempotencyStore;
114
+ /** How long a key (and its replayed response) lives, in seconds. Default: `86400` (24h). */
115
+ ttlSeconds?: number;
116
+ /** Request header carrying the key. Default: `"idempotency-key"`. */
117
+ headerName?: string;
118
+ /** Response header marking a replayed response. Default: `"idempotency-replayed"`. */
119
+ replayHeaderName?: string;
120
+ /**
121
+ * HTTP methods the middleware applies to. Requests with other methods pass
122
+ * through untouched even when they carry the header. Default:
123
+ * `["POST", "PUT", "PATCH", "DELETE"]`.
124
+ */
125
+ methods?: string[];
126
+ /**
127
+ * Require the key on every applicable request, returning `400` when it is
128
+ * missing. Default: `false` (idempotency is opt-in per request).
129
+ */
130
+ requireKey?: boolean;
131
+ /** Maximum accepted key length in characters. Default: `255`. */
132
+ maxKeyLength?: number;
133
+ /**
134
+ * Maximum response body size (bytes) the middleware will buffer and store.
135
+ * Larger responses are streamed through without caching and the reservation
136
+ * is released so a later retry can re-run the handler. Default: `1048576`
137
+ * (1 MiB). A guard against unbounded memory growth from large replies.
138
+ */
139
+ maxResponseBytes?: number;
140
+ /**
141
+ * Decide whether a produced response should be cached for replay. Returning
142
+ * `false` releases the reservation so the client may retry. Default: cache
143
+ * any response with status `< 500` (server errors are retryable).
144
+ */
145
+ cacheableStatus?: (status: number) => boolean;
146
+ /**
147
+ * Share a single in-memory store across every `idempotency()` mount that
148
+ * declares the same `groupId`. Only meaningful for the default in-memory
149
+ * store; supply an explicit `store` to coordinate across processes.
150
+ */
151
+ groupId?: string;
152
+ }
153
+ /**
154
+ * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
155
+ * deployments. Expired records are dropped on access; the map is opportunistically
156
+ * pruned so it cannot grow without bound.
157
+ */
158
+ export declare class MemoryIdempotencyStore implements IdempotencyStore {
159
+ private readonly map;
160
+ /** @inheritDoc */
161
+ reserve(key: string, record: IdempotencyRecord): IdempotencyRecord | null;
162
+ /** @inheritDoc */
163
+ complete(key: string, record: IdempotencyRecord): void;
164
+ /** @inheritDoc */
165
+ release(key: string): void;
166
+ private read;
167
+ private prune;
168
+ /** Test helper. Remove every record. */
169
+ clear(): void;
170
+ /** Test helper. Number of stored records (including expired). */
171
+ size(): number;
172
+ }
173
+ /**
174
+ * Idempotency-key middleware. Mount it ahead of the routes that need
175
+ * exactly-once semantics under retries (typically the payment / write
176
+ * surface).
177
+ *
178
+ * Behavior for an applicable method (see {@link IdempotencyOptions.methods}):
179
+ *
180
+ * - **No key** → pass through (or `400` when {@link IdempotencyOptions.requireKey}).
181
+ * - **First key** → run the handler, then persist the response keyed by the
182
+ * request fingerprint for {@link IdempotencyOptions.ttlSeconds}.
183
+ * - **Same key + same body, completed** → replay the stored response with an
184
+ * `Idempotency-Replayed: true` header; the handler does not run.
185
+ * - **Same key, still in flight** → {@link ConflictError} (`409`).
186
+ * - **Same key + different body** → `422 Unprocessable Content` (a key is
187
+ * permanently bound to its first payload).
188
+ *
189
+ * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
190
+ * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
191
+ * cached and the reservation is released so the client can retry.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * import { idempotency } from "@daloyjs/core";
196
+ *
197
+ * app.use(idempotency({ ttlSeconds: 86_400 }));
198
+ * ```
199
+ *
200
+ * @param opts - Idempotency configuration.
201
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
202
+ * @since 0.37.0
203
+ */
204
+ export declare function idempotency(opts?: IdempotencyOptions): Hooks;
@@ -0,0 +1,341 @@
1
+ /**
2
+ * First-class `Idempotency-Key` handling for DaloyJS.
3
+ *
4
+ * The {@link idempotency} middleware lets clients safely retry unsafe requests
5
+ * (`POST`, `PUT`, `PATCH`, `DELETE`) without risking duplicate side effects —
6
+ * the table-stakes guarantee for payment surfaces and serverless retries. It
7
+ * mirrors the IETF *The Idempotency-Key HTTP Header Field* draft and the
8
+ * conventions used by major payment processors:
9
+ *
10
+ * 1. The client sends a unique, client-generated `Idempotency-Key` header.
11
+ * 2. On the **first** request the handler runs normally; the framework
12
+ * fingerprints the request (method + path + body) and persists the final
13
+ * response keyed by the idempotency key.
14
+ * 3. On a **retry** carrying the same key and the same fingerprint, the stored
15
+ * response is replayed byte-for-byte (with an `Idempotency-Replayed: true`
16
+ * marker) — the handler never runs twice.
17
+ * 4. A retry that arrives **while the original is still in flight** gets a
18
+ * `409 Conflict` so the client backs off instead of racing.
19
+ * 5. Reusing a key with a **different** request body returns `422
20
+ * Unprocessable Content` — a key is permanently bound to its first payload.
21
+ *
22
+ * The store is pluggable via {@link IdempotencyStore}, mirroring the
23
+ * `SessionStore` / rate-limit-store pattern. The default
24
+ * {@link MemoryIdempotencyStore} is process-local; supply a shared backend
25
+ * (e.g. Redis) for multi-instance deployments.
26
+ *
27
+ * This module is dependency-free and uses only Web Crypto + Web Standard
28
+ * `Request`/`Response`, so it runs unchanged on Node, Bun, Deno, Cloudflare
29
+ * Workers, and Vercel Edge.
30
+ *
31
+ * @module
32
+ * @since 0.37.0
33
+ */
34
+ import { BadRequestError, ConflictError, HttpError } from "./errors.js";
35
+ const enc = new TextEncoder();
36
+ /** Internal `ctx.state` key carrying the reservation between hooks. */
37
+ const PENDING_STATE_KEY = "__idempotencyPending";
38
+ /**
39
+ * Process-wide registry of in-memory stores shared by
40
+ * {@link IdempotencyOptions.groupId}. Two `idempotency({ groupId: "payments" })`
41
+ * mounts receive the same store so a key reserved on one route is honored on
42
+ * the others.
43
+ *
44
+ * @internal
45
+ */
46
+ const SHARED_IDEMPOTENCY_STORES = new Map();
47
+ /**
48
+ * Test-only helper that clears the process-wide shared stores used by
49
+ * `idempotency({ groupId })`. Not part of the documented public API.
50
+ *
51
+ * @internal
52
+ */
53
+ export function _resetSharedIdempotencyStoresForTests() {
54
+ SHARED_IDEMPOTENCY_STORES.clear();
55
+ }
56
+ // ---------- Default store ----------
57
+ /**
58
+ * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
59
+ * deployments. Expired records are dropped on access; the map is opportunistically
60
+ * pruned so it cannot grow without bound.
61
+ */
62
+ export class MemoryIdempotencyStore {
63
+ map = new Map();
64
+ /** @inheritDoc */
65
+ reserve(key, record) {
66
+ const existing = this.read(key);
67
+ if (existing)
68
+ return existing;
69
+ this.map.set(key, record);
70
+ if (this.map.size > 10_000)
71
+ this.prune();
72
+ return null;
73
+ }
74
+ /** @inheritDoc */
75
+ complete(key, record) {
76
+ this.map.set(key, record);
77
+ }
78
+ /** @inheritDoc */
79
+ release(key) {
80
+ this.map.delete(key);
81
+ }
82
+ read(key) {
83
+ const rec = this.map.get(key);
84
+ if (!rec)
85
+ return null;
86
+ if (rec.expiresAt <= Date.now()) {
87
+ this.map.delete(key);
88
+ return null;
89
+ }
90
+ return rec;
91
+ }
92
+ prune() {
93
+ const now = Date.now();
94
+ for (const [k, v] of this.map) {
95
+ if (v.expiresAt <= now)
96
+ this.map.delete(k);
97
+ }
98
+ }
99
+ /** Test helper. Remove every record. */
100
+ clear() {
101
+ this.map.clear();
102
+ }
103
+ /** Test helper. Number of stored records (including expired). */
104
+ size() {
105
+ return this.map.size;
106
+ }
107
+ }
108
+ function getSubtle() {
109
+ const c = globalThis.crypto;
110
+ if (!c?.subtle) {
111
+ throw new Error("idempotency(): Web Crypto (crypto.subtle) is required. Provide a polyfill in environments without it.");
112
+ }
113
+ return c.subtle;
114
+ }
115
+ const HEX = "0123456789abcdef";
116
+ function bytesToHex(bytes) {
117
+ let out = "";
118
+ for (let i = 0; i < bytes.length; i++) {
119
+ const b = bytes[i];
120
+ out += HEX[b >> 4] + HEX[b & 0x0f];
121
+ }
122
+ return out;
123
+ }
124
+ function bytesToBase64(bytes) {
125
+ let bin = "";
126
+ const CHUNK = 0x8000;
127
+ for (let i = 0; i < bytes.length; i += CHUNK) {
128
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
129
+ }
130
+ return btoa(bin);
131
+ }
132
+ function base64ToBytes(b64) {
133
+ const bin = atob(b64);
134
+ const out = new Uint8Array(bin.length);
135
+ for (let i = 0; i < bin.length; i++)
136
+ out[i] = bin.charCodeAt(i);
137
+ return out;
138
+ }
139
+ /**
140
+ * Deterministic, key-order-insensitive serialization of a parsed request body
141
+ * so two logically-identical retries fingerprint the same.
142
+ */
143
+ function stableStringify(value) {
144
+ if (value === null || value === undefined)
145
+ return "null";
146
+ if (typeof value !== "object")
147
+ return JSON.stringify(value) ?? "null";
148
+ if (Array.isArray(value))
149
+ return `[${value.map(stableStringify).join(",")}]`;
150
+ const obj = value;
151
+ const keys = Object.keys(obj).sort();
152
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
153
+ }
154
+ async function computeFingerprint(method, ctx) {
155
+ const url = new URL(ctx.request.url);
156
+ const material = `${method}\n${url.pathname}${url.search}\n${stableStringify(ctx.body)}`;
157
+ const digest = new Uint8Array(await getSubtle().digest("SHA-256", enc.encode(material)));
158
+ return bytesToHex(digest);
159
+ }
160
+ // Printable ASCII only (no control chars / whitespace). Anchored + bounded to
161
+ // the character class, so this is linear-time and ReDoS-free.
162
+ const KEY_PATTERN = /^[\x21-\x7e]+$/;
163
+ function validateKey(key, headerName, maxLen) {
164
+ if (key.length === 0) {
165
+ throw new BadRequestError(`${headerName} header must not be empty.`);
166
+ }
167
+ if (key.length > maxLen) {
168
+ throw new BadRequestError(`${headerName} header must be at most ${maxLen} characters.`);
169
+ }
170
+ if (!KEY_PATTERN.test(key)) {
171
+ throw new BadRequestError(`${headerName} header contains invalid characters.`);
172
+ }
173
+ }
174
+ async function captureResponse(res, maxBytes) {
175
+ const buf = new Uint8Array(await res.clone().arrayBuffer());
176
+ if (buf.byteLength > maxBytes)
177
+ return null;
178
+ const headers = [];
179
+ res.headers.forEach((value, name) => {
180
+ headers.push([name, value]);
181
+ });
182
+ return { status: res.status, headers, body: buf.byteLength ? bytesToBase64(buf) : "" };
183
+ }
184
+ function buildReplayResponse(stored, replayHeaderName) {
185
+ const headers = new Headers();
186
+ for (const [name, value] of stored.headers)
187
+ headers.set(name, value);
188
+ headers.set(replayHeaderName, "true");
189
+ const body = stored.body ? base64ToBytes(stored.body) : null;
190
+ return new Response(body, { status: stored.status, headers });
191
+ }
192
+ // ---------- Middleware ----------
193
+ /**
194
+ * Idempotency-key middleware. Mount it ahead of the routes that need
195
+ * exactly-once semantics under retries (typically the payment / write
196
+ * surface).
197
+ *
198
+ * Behavior for an applicable method (see {@link IdempotencyOptions.methods}):
199
+ *
200
+ * - **No key** → pass through (or `400` when {@link IdempotencyOptions.requireKey}).
201
+ * - **First key** → run the handler, then persist the response keyed by the
202
+ * request fingerprint for {@link IdempotencyOptions.ttlSeconds}.
203
+ * - **Same key + same body, completed** → replay the stored response with an
204
+ * `Idempotency-Replayed: true` header; the handler does not run.
205
+ * - **Same key, still in flight** → {@link ConflictError} (`409`).
206
+ * - **Same key + different body** → `422 Unprocessable Content` (a key is
207
+ * permanently bound to its first payload).
208
+ *
209
+ * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
210
+ * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
211
+ * cached and the reservation is released so the client can retry.
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * import { idempotency } from "@daloyjs/core";
216
+ *
217
+ * app.use(idempotency({ ttlSeconds: 86_400 }));
218
+ * ```
219
+ *
220
+ * @param opts - Idempotency configuration.
221
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
222
+ * @since 0.37.0
223
+ */
224
+ export function idempotency(opts = {}) {
225
+ const ttlSeconds = opts.ttlSeconds ?? 86_400;
226
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
227
+ throw new Error("idempotency(): ttlSeconds must be a positive integer.");
228
+ }
229
+ const maxKeyLength = opts.maxKeyLength ?? 255;
230
+ if (!Number.isInteger(maxKeyLength) || maxKeyLength <= 0) {
231
+ throw new Error("idempotency(): maxKeyLength must be a positive integer.");
232
+ }
233
+ const maxResponseBytes = opts.maxResponseBytes ?? 1_048_576;
234
+ if (!Number.isInteger(maxResponseBytes) || maxResponseBytes <= 0) {
235
+ throw new Error("idempotency(): maxResponseBytes must be a positive integer.");
236
+ }
237
+ const headerName = (opts.headerName ?? "idempotency-key").toLowerCase();
238
+ const replayHeaderName = (opts.replayHeaderName ?? "idempotency-replayed").toLowerCase();
239
+ const methods = new Set((opts.methods ?? ["POST", "PUT", "PATCH", "DELETE"]).map((m) => m.toUpperCase()));
240
+ const requireKey = opts.requireKey === true;
241
+ const cacheableStatus = opts.cacheableStatus ?? ((status) => status < 500);
242
+ const ttlMs = ttlSeconds * 1_000;
243
+ let store;
244
+ if (opts.store) {
245
+ store = opts.store;
246
+ }
247
+ else if (opts.groupId) {
248
+ let shared = SHARED_IDEMPOTENCY_STORES.get(opts.groupId);
249
+ if (!shared) {
250
+ shared = new MemoryIdempotencyStore();
251
+ SHARED_IDEMPOTENCY_STORES.set(opts.groupId, shared);
252
+ }
253
+ store = shared;
254
+ }
255
+ else {
256
+ store = new MemoryIdempotencyStore();
257
+ }
258
+ const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
259
+ return {
260
+ async beforeHandle(ctx) {
261
+ const method = ctx.request.method.toUpperCase();
262
+ if (!methods.has(method))
263
+ return undefined;
264
+ const rawKey = ctx.request.headers.get(headerName);
265
+ if (rawKey === null || rawKey.trim() === "") {
266
+ if (requireKey) {
267
+ throw new BadRequestError(`Missing required ${headerName} header.`);
268
+ }
269
+ return undefined;
270
+ }
271
+ const key = rawKey.trim();
272
+ validateKey(key, headerName, maxKeyLength);
273
+ const fingerprint = await computeFingerprint(method, ctx);
274
+ const storeKey = `${keyPrefix}${key}`;
275
+ const now = Date.now();
276
+ const record = {
277
+ fingerprint,
278
+ status: "in-flight",
279
+ createdAt: now,
280
+ expiresAt: now + ttlMs,
281
+ };
282
+ const reserveResult = store.reserve(storeKey, record, ttlMs);
283
+ const existing = isPromiseLike(reserveResult) ? await reserveResult : reserveResult;
284
+ if (existing) {
285
+ if (existing.fingerprint !== fingerprint) {
286
+ throw new HttpError(422, {
287
+ type: "https://daloyjs.dev/errors/idempotency-key-reuse",
288
+ title: "Unprocessable Content",
289
+ detail: `The ${headerName} header was already used with a different request payload.`,
290
+ }, { "cache-control": "no-store" });
291
+ }
292
+ if (existing.status === "in-flight") {
293
+ throw new ConflictError(`A request with this ${headerName} is still being processed. Retry after it completes.`);
294
+ }
295
+ // Completed: replay the stored response verbatim.
296
+ return buildReplayResponse(existing.response, replayHeaderName);
297
+ }
298
+ ctx.state[PENDING_STATE_KEY] = {
299
+ storeKey,
300
+ fingerprint,
301
+ };
302
+ return undefined;
303
+ },
304
+ async onSend(res, ctx) {
305
+ if (!ctx)
306
+ return undefined;
307
+ const state = ctx.state;
308
+ const pending = state[PENDING_STATE_KEY];
309
+ if (!pending)
310
+ return undefined;
311
+ // Consume once: a replayed response on a later retry must not re-store.
312
+ delete state[PENDING_STATE_KEY];
313
+ if (!cacheableStatus(res.status)) {
314
+ await store.release(pending.storeKey);
315
+ return undefined;
316
+ }
317
+ const captured = await captureResponse(res, maxResponseBytes);
318
+ if (captured === null) {
319
+ // Body too large to cache safely: drop the reservation so retries work.
320
+ await store.release(pending.storeKey);
321
+ return undefined;
322
+ }
323
+ const now = Date.now();
324
+ const completeResult = store.complete(pending.storeKey, {
325
+ fingerprint: pending.fingerprint,
326
+ status: "completed",
327
+ response: captured,
328
+ createdAt: now,
329
+ expiresAt: now + ttlMs,
330
+ }, ttlMs);
331
+ if (isPromiseLike(completeResult))
332
+ await completeResult;
333
+ return undefined;
334
+ },
335
+ };
336
+ }
337
+ function isPromiseLike(value) {
338
+ return (value !== null &&
339
+ (typeof value === "object" || typeof value === "function") &&
340
+ typeof value.then === "function");
341
+ }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { createApp } from "./app.js";
3
3
  export { _resetPackageJsonCacheForTests } from "./app.js";
4
4
  export { _resetCrashHandlersForTests } from "./app.js";
5
5
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
6
- export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, HealthRouteOptions, CspReportRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
6
+ export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
7
7
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
8
8
  export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
9
9
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
@@ -11,11 +11,13 @@ export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
11
11
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
12
12
  export type { DependencyHooks, DependencyOptions, } from "./dependency.js";
13
13
  export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
14
- export { HttpError, BadRequestError, ValidationError, NotFoundError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
14
+ export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
15
15
  export type { ProblemDetails, ProblemRenderOptions, HttpErrorOptions } from "./errors.js";
16
16
  export type { StandardSchemaV1 } from "./schema.js";
17
17
  export { validate, isStandardSchema } from "./schema.js";
18
- export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
18
+ export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
19
+ export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult, } from "./openapi-diff.js";
20
+ export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
19
21
  export type { WebhookHmacAlgorithm } from "./security.js";
20
22
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
21
23
  export { etag } from "./etag.js";
@@ -36,6 +38,30 @@ export { ipRestriction } from "./ip-restriction.js";
36
38
  export type { IpRestrictionOptions } from "./ip-restriction.js";
37
39
  export { fetchGuard, SsrfBlockedError } from "./fetch-guard.js";
38
40
  export type { FetchGuardOptions, SsrfBlockReason } from "./fetch-guard.js";
41
+ export { resilientFetch, CircuitBreaker, CircuitOpenError, FetchTimeoutError, } from "./fetch-resilience.js";
42
+ export type { ResilientFetchOptions, CircuitBreakerOptions, CircuitState, RetryContext, } from "./fetch-resilience.js";
43
+ export { createWebhookSender, MemoryWebhookDeadLetterSink, } from "./webhook-delivery.js";
44
+ export type { WebhookEvent, WebhookSenderOptions, WebhookDeliveryResult, WebhookDeadLetter, WebhookDeadLetterSink, WebhookAttempt, } from "./webhook-delivery.js";
45
+ export { Scheduler, CronParseError, parseCron, nextCronRun, } from "./scheduler.js";
46
+ export type { SchedulerOptions, SchedulerLogger, TimerFns, TaskDefinition, TaskHandler, TaskRunContext, TaskErrorInfo, TaskState, CronFields, } from "./scheduler.js";
47
+ export { clientCertAuth, setClientCertificate, getClientCertificate, normalizePeerCertificate, parseForwardedClientCert, } from "./mtls.js";
48
+ export type { ClientCertificate, ClientCertificateSource, ClientCertAuthOptions, ClientCertHeaderConfig, PeerCertificateLike, } from "./mtls.js";
49
+ export { signMessage, signRequest, verifyMessage, verifyRequest, httpSignatureAuth, contentDigest, verifyContentDigest, DEFAULT_SIGNATURE_LABEL, DEFAULT_MAX_SIGNATURE_AGE_SECONDS, DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS, } from "./http-signatures.js";
50
+ export type { HttpSignatureAlgorithm, HttpSignatureKeyMaterial, HttpSignatureKey, SignMessageOptions, SignRequestOptions, MessageSignature, VerifyMessageOptions, VerifyResult, VerifySuccess, VerifyFailure, KeyResolutionInfo, HttpSignatureAuthOptions, ContentDigestAlgorithm, } from "./http-signatures.js";
51
+ export { autoBan, MemoryAutoBanStore, _resetAutoBanStoresForTests, } from "./auto-ban.js";
52
+ export type { AutoBanOptions, AutoBanStore, AutoBanRecord, AutoBanEvent, AutoBanStrikeEvent, } from "./auto-ban.js";
53
+ export { botGuard, GOOGLEBOT, BINGBOT, WELL_KNOWN_BOTS } from "./bot-guard.js";
54
+ export type { BotGuardOptions, BotGuardEvent, BotResolver, VerifiedBotRule, } from "./bot-guard.js";
55
+ export { ipReputation, urlFeed } from "./ip-reputation.js";
56
+ export type { IpReputationOptions, IpReputationFeed, IpReputationMatch, IpReputationController, UrlFeedOptions, } from "./ip-reputation.js";
57
+ export { geoBlock } from "./geo-block.js";
58
+ export type { GeoBlockOptions, GeoBlockDecision, GeoBlockReason, GeoState, CountryFromIp, CountryFromContext, } from "./geo-block.js";
59
+ export { concurrencyLimit } from "./concurrency-limit.js";
60
+ export type { ConcurrencyLimitOptions, ConcurrencyRejection, } from "./concurrency-limit.js";
61
+ export { requestDecompression, decompressRequestBody, DecompressionBombError, UnsupportedContentEncodingError, MalformedCompressedBodyError, _resetRequestDecompressionProbeForTests, } from "./request-decompression.js";
62
+ export type { RequestDecompressionOptions, RequestDecompressionEncoding, DecompressionBombInfo, } from "./request-decompression.js";
63
+ export { waf } from "./waf.js";
64
+ export type { WafOptions, WafMode, WafRuleId, WafRuleConfig, WafInspectConfig, WafMatch, WafEvent, WafInspectionLocation, } from "./waf.js";
39
65
  export { safeRedirect, OpenRedirectBlockedError } from "./safe-redirect.js";
40
66
  export type { SafeRedirectOptions, SafeRedirectStatus, SafeRedirectBlockReason, } from "./safe-redirect.js";
41
67
  export { loadShedding, LOAD_SHEDDING_MARKER } from "./load-shedding.js";
@@ -46,7 +72,7 @@ export type { RequestIdOptions, SecureHeadersOptions, CspDirectivesOptions, Cors
46
72
  export type { BearerAuthOptions, BearerAuthVerifyHook } from "./middleware.js";
47
73
  export { createLogger, noopLogger, DEFAULT_REDACT_KEYS } from "./logger.js";
48
74
  export type { Logger, LogLevel, ConsoleLoggerOptions, LoggerRedactionOptions, } from "./logger.js";
49
- export type { ScalarJsonPrimitive, ScalarJsonValue, ScalarReferenceConfiguration, ScalarTheme, } from "./docs.js";
75
+ export type { ScalarJsonPrimitive, ScalarJsonValue, ScalarReferenceConfiguration, ScalarTheme, DocsAssetOptions, } from "./docs.js";
50
76
  export { formatStartupBanner, printStartupBanner } from "./banner.js";
51
77
  export type { StartupBannerLink, StartupBannerOptions } from "./banner.js";
52
78
  export { sseStream, sseResponse, ndjsonStream, ndjsonResponse, } from "./streaming.js";
@@ -57,9 +83,17 @@ export { discriminator, discriminatedUnion } from "./discriminator.js";
57
83
  export type { DiscriminatorObject, DiscriminatedUnion, DiscriminatedUnionOptions, } from "./discriminator.js";
58
84
  export { session, rotateSession, signValue, verifySignedValue, MemorySessionStore, SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
59
85
  export type { SessionOptions, SessionCookieOptions, SessionContext, SessionRecord, SessionStore, SessionState, RotateSessionOptions, } from "./session.js";
86
+ export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTests, } from "./idempotency.js";
87
+ export type { IdempotencyOptions, IdempotencyStore, IdempotencyRecord, StoredIdempotentResponse, } from "./idempotency.js";
88
+ export { responseCache, MemoryResponseCacheStore, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
89
+ export type { ResponseCacheOptions, ResponseCacheStore, CachedResponse, } from "./response-cache.js";
90
+ export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
91
+ export type { PaginationLink, PageLinkOptions, PageLinks, PaginationQueryOptions, PaginationParams, PaginationQuerySchema, } from "./pagination.js";
92
+ export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
93
+ export type { MetricLabels, MetricsRegistryOptions, HttpMetricsOptions, } from "./metrics.js";
60
94
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
61
95
  export type { FileFieldSchema, FileFieldOptions, FileMagicBytesOption, FileMagicBytesSignature, MultipartObjectOptions, MultipartShape, UploadedFile, } from "./multipart.js";
62
96
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
63
97
  export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
64
98
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
65
- export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
99
+ export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";