@daloyjs/core 0.36.0 → 0.37.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 (77) hide show
  1. package/README.md +21 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +144 -1
  8. package/dist/app.js +208 -1
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Inbound request-decompression bomb guard.
3
+ *
4
+ * DaloyJS core deliberately does **not** decompress request bodies — it is safe
5
+ * by omission, so a `Content-Encoding: gzip` request body is read as-is and a
6
+ * schema parse simply fails on the compressed bytes. Some services, though,
7
+ * genuinely need to accept compressed uploads (chatty IoT clients, log
8
+ * shippers, mobile apps on slow links). The moment you inflate attacker-supplied
9
+ * bytes you inherit the classic **decompression bomb** (a.k.a. "zip bomb"): a
10
+ * few kilobytes of crafted gzip can expand to gigabytes and blow straight past
11
+ * {@link "./app.js".AppOptions.bodyLimitBytes}, which only ever sees the small
12
+ * compressed payload.
13
+ *
14
+ * {@link requestDecompression} is the opt-in middleware that adds request
15
+ * decompression **with the bomb guard baked in**. It inflates the body with two
16
+ * independent caps enforced *during* inflation (so a bomb is aborted long before
17
+ * it is fully materialised):
18
+ *
19
+ * - an **absolute** cap (`maxDecompressedBytes`) — the inflated body may never
20
+ * exceed this many bytes; and
21
+ * - a **ratio** cap (`maxRatio`) — the inflated size may never exceed
22
+ * `compressedBytes * maxRatio`, which catches small-but-explosive payloads
23
+ * that stay under the absolute cap in isolation but would amplify wildly.
24
+ *
25
+ * The compressed input itself is bounded by `maxCompressedBytes` before a single
26
+ * byte is inflated. Built on the web-standard `DecompressionStream`, so the same
27
+ * line works on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. Zero
28
+ * runtime dependencies.
29
+ *
30
+ * The middleware runs in the {@link "./types.js".Hooks.onRequest} phase — before
31
+ * the per-request context (and therefore before schema-body validation) is
32
+ * built — and stashes the inflated bytes on the request so the framework's own
33
+ * body reader transparently sees the decompressed payload. That means it works
34
+ * for both schema-validated bodies and handlers that read the raw body
35
+ * themselves. Register it globally with `app.use(requestDecompression(...))`.
36
+ *
37
+ * Secure-by-default posture:
38
+ * - Only `gzip` and `deflate` are accepted (the encodings `DecompressionStream`
39
+ * implements across runtimes). An unknown, unsupported, or **layered**
40
+ * (`gzip, gzip`) `Content-Encoding` is refused with `415` — never inflated.
41
+ * - Malformed compressed input is refused with `400`, never silently treated as
42
+ * an empty body.
43
+ * - The bomb caps are mandatory: there is no "unlimited" mode.
44
+ *
45
+ * @module
46
+ * @since 0.37.0
47
+ */
48
+ import { HttpError, PayloadTooLargeError } from "./errors.js";
49
+ import { readBodyLimited } from "./security.js";
50
+ /**
51
+ * Internal Symbol (shared via the global registry, same key the adapters and
52
+ * {@link "./security.js".readBodyLimited} use) under which a pre-resolved
53
+ * request body is stashed. Setting it lets the framework's body reader skip the
54
+ * stream and return our inflated bytes directly. Referenced via `Symbol.for`
55
+ * rather than imported to avoid an `app.ts` import cycle.
56
+ */
57
+ const REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
58
+ /** Default cap on the compressed request body (bytes) before inflation: 1 MiB. */
59
+ const DEFAULT_MAX_COMPRESSED_BYTES = 1024 * 1024;
60
+ /** Default maximum inflated:compressed expansion ratio. */
61
+ const DEFAULT_MAX_RATIO = 100;
62
+ /**
63
+ * `413 Payload Too Large` raised when an inflating request body crosses either
64
+ * the absolute (`maxDecompressedBytes`) or ratio (`maxRatio`) cap. Thrown
65
+ * *during* inflation, so the full bomb is never materialised in memory.
66
+ *
67
+ * @since 0.37.0
68
+ */
69
+ export class DecompressionBombError extends HttpError {
70
+ /** Structured details about the rejected bomb. */
71
+ info;
72
+ constructor(info) {
73
+ super(413, {
74
+ type: "https://daloyjs.dev/errors/decompression-bomb",
75
+ title: "Payload Too Large",
76
+ detail: info.reason === "ratio"
77
+ ? `Decompressed body exceeded the allowed expansion ratio for ${info.encoding} content`
78
+ : `Decompressed body exceeded the maximum allowed size for ${info.encoding} content`,
79
+ });
80
+ this.name = "DecompressionBombError";
81
+ this.info = info;
82
+ }
83
+ }
84
+ /**
85
+ * `415 Unsupported Media Type` raised when a request declares a
86
+ * `Content-Encoding` this guard cannot safely inflate — an unknown encoding, an
87
+ * encoding not in the configured allowlist, an encoding the runtime's
88
+ * `DecompressionStream` does not implement, or a layered encoding such as
89
+ * `gzip, gzip`. The body is refused, never inflated.
90
+ *
91
+ * @since 0.37.0
92
+ */
93
+ export class UnsupportedContentEncodingError extends HttpError {
94
+ constructor(encoding, allowed) {
95
+ super(415, {
96
+ type: "https://daloyjs.dev/errors/unsupported-content-encoding",
97
+ title: "Unsupported Media Type",
98
+ detail: `Unsupported request Content-Encoding "${encoding}". Allowed: ${allowed.join(", ")}`,
99
+ }, { "accept-encoding": allowed.join(", ") });
100
+ this.name = "UnsupportedContentEncodingError";
101
+ }
102
+ }
103
+ /**
104
+ * `400 Bad Request` raised when the compressed request body is not valid for its
105
+ * declared `Content-Encoding` (truncated or corrupt stream). Refusing — rather
106
+ * than treating a malformed body as empty — prevents request-smuggling-style
107
+ * desync between this guard and any downstream parser.
108
+ *
109
+ * @since 0.37.0
110
+ */
111
+ export class MalformedCompressedBodyError extends HttpError {
112
+ constructor(encoding) {
113
+ super(400, {
114
+ type: "https://daloyjs.dev/errors/malformed-compressed-body",
115
+ title: "Bad Request",
116
+ detail: `Request body is not a valid ${encoding} stream`,
117
+ });
118
+ this.name = "MalformedCompressedBodyError";
119
+ }
120
+ }
121
+ function assertPositiveInteger(value, label) {
122
+ if (!Number.isInteger(value) || value <= 0) {
123
+ throw new TypeError(`requestDecompression(): \`${label}\` must be a positive integer, received ${String(value)}`);
124
+ }
125
+ }
126
+ let cachedRuntimeSupport = null;
127
+ /**
128
+ * Probe (once) which encodings the runtime's `DecompressionStream` implements.
129
+ * Mirrors the `compression()` response-side runtime probe.
130
+ *
131
+ * @internal
132
+ */
133
+ function detectRuntimeSupport() {
134
+ if (cachedRuntimeSupport)
135
+ return cachedRuntimeSupport;
136
+ const Stream = globalThis.DecompressionStream;
137
+ const supported = new Set();
138
+ if (Stream) {
139
+ for (const enc of ["gzip", "deflate"]) {
140
+ try {
141
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
142
+ const _probe = new Stream(enc);
143
+ supported.add(enc);
144
+ }
145
+ catch {
146
+ // not supported on this runtime
147
+ }
148
+ }
149
+ }
150
+ cachedRuntimeSupport = supported;
151
+ return supported;
152
+ }
153
+ /**
154
+ * @internal Reset the cached `DecompressionStream` runtime probe. Test-only.
155
+ * @since 0.37.0
156
+ */
157
+ export function _resetRequestDecompressionProbeForTests() {
158
+ cachedRuntimeSupport = null;
159
+ }
160
+ async function cancelReader(reader) {
161
+ try {
162
+ await reader.cancel();
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
167
+ }
168
+ /**
169
+ * Inflate `compressed` under `encoding` while enforcing the absolute-size and
170
+ * expansion-ratio caps *during* decompression. This is the low-level guard used
171
+ * by {@link requestDecompression}; it is exported so handlers that read raw
172
+ * bodies (or custom flows) can decompress request bytes with the same
173
+ * bomb-resistant semantics.
174
+ *
175
+ * @param compressed - The compressed request bytes.
176
+ * @param encoding - The declared `Content-Encoding` (`"gzip"` or `"deflate"`).
177
+ * @param opts - Caps; only the size/ratio/`onBomb` fields are consulted here.
178
+ * @returns The inflated body as a `Uint8Array`.
179
+ * @throws {DecompressionBombError} When an inflating cap is exceeded (`413`).
180
+ * @throws {MalformedCompressedBodyError} When the input is not a valid stream (`400`).
181
+ * @throws {UnsupportedContentEncodingError} When the runtime cannot inflate the encoding (`415`).
182
+ * @since 0.37.0
183
+ */
184
+ export async function decompressRequestBody(compressed, encoding, opts) {
185
+ assertPositiveInteger(opts.maxDecompressedBytes, "maxDecompressedBytes");
186
+ const maxRatio = opts.maxRatio ?? DEFAULT_MAX_RATIO;
187
+ if (!Number.isFinite(maxRatio) || maxRatio < 1) {
188
+ throw new TypeError(`requestDecompression(): \`maxRatio\` must be a finite number >= 1, received ${String(maxRatio)}`);
189
+ }
190
+ return inflateGuarded(compressed, encoding, {
191
+ maxDecompressedBytes: opts.maxDecompressedBytes,
192
+ maxRatio,
193
+ onBomb: opts.onBomb,
194
+ });
195
+ }
196
+ async function inflateGuarded(compressed, encoding, caps) {
197
+ // An empty body has nothing to inflate; treat it as an empty payload rather
198
+ // than feeding an invalid (zero-byte) stream to DecompressionStream.
199
+ if (compressed.byteLength === 0)
200
+ return new Uint8Array(0);
201
+ const Stream = globalThis.DecompressionStream;
202
+ if (!Stream || !detectRuntimeSupport().has(encoding)) {
203
+ throw new UnsupportedContentEncodingError(encoding, [encoding]);
204
+ }
205
+ const ds = new Stream(encoding);
206
+ const writer = ds.writable.getWriter();
207
+ // Feed the whole compressed payload, then close. Errors on the write/close
208
+ // side (malformed stream) surface as a rejected close; capture rather than
209
+ // leak an unhandled rejection.
210
+ let writeError = false;
211
+ void writer.write(compressed).catch(() => {
212
+ writeError = true;
213
+ });
214
+ const closePromise = writer.close().catch(() => {
215
+ writeError = true;
216
+ });
217
+ const ratioCapBytes = compressed.byteLength * caps.maxRatio;
218
+ const reader = ds.readable.getReader();
219
+ const chunks = [];
220
+ let total = 0;
221
+ try {
222
+ // eslint-disable-next-line no-constant-condition
223
+ while (true) {
224
+ const { done, value } = await reader.read();
225
+ if (done)
226
+ break;
227
+ if (!value)
228
+ continue;
229
+ total += value.byteLength;
230
+ if (total > caps.maxDecompressedBytes) {
231
+ await cancelReader(reader);
232
+ const info = {
233
+ encoding,
234
+ compressedBytes: compressed.byteLength,
235
+ decompressedBytes: total,
236
+ reason: "absolute",
237
+ };
238
+ caps.onBomb?.(info);
239
+ throw new DecompressionBombError(info);
240
+ }
241
+ if (total > ratioCapBytes) {
242
+ await cancelReader(reader);
243
+ const info = {
244
+ encoding,
245
+ compressedBytes: compressed.byteLength,
246
+ decompressedBytes: total,
247
+ reason: "ratio",
248
+ };
249
+ caps.onBomb?.(info);
250
+ throw new DecompressionBombError(info);
251
+ }
252
+ chunks.push(value);
253
+ }
254
+ }
255
+ catch (err) {
256
+ if (err instanceof DecompressionBombError)
257
+ throw err;
258
+ throw new MalformedCompressedBodyError(encoding);
259
+ }
260
+ await closePromise;
261
+ if (writeError)
262
+ throw new MalformedCompressedBodyError(encoding);
263
+ const out = new Uint8Array(total);
264
+ let offset = 0;
265
+ for (const chunk of chunks) {
266
+ out.set(chunk, offset);
267
+ offset += chunk.byteLength;
268
+ }
269
+ return out;
270
+ }
271
+ /**
272
+ * Opt-in middleware that decompresses inbound request bodies behind a
273
+ * decompression-bomb guard. Inflates `gzip` / `deflate` request bodies under an
274
+ * absolute size cap and an expansion-ratio cap, then hands the inflated bytes to
275
+ * the framework's normal body pipeline so schema validation and raw-body reads
276
+ * both see the decompressed payload.
277
+ *
278
+ * Register it globally so it runs before the per-request context is built:
279
+ *
280
+ * ```ts
281
+ * app.use(requestDecompression({
282
+ * maxDecompressedBytes: 1024 * 1024, // inflated body never exceeds 1 MiB
283
+ * maxCompressedBytes: 64 * 1024, // reject compressed uploads over 64 KiB
284
+ * maxRatio: 50, // and never expand more than 50x
285
+ * }));
286
+ * ```
287
+ *
288
+ * Requests without a `Content-Encoding` (or `identity`) pass through untouched.
289
+ * `GET` / `HEAD` requests are never decompressed. Unknown, unsupported, or
290
+ * layered encodings are refused with `415`; malformed streams with `400`; bombs
291
+ * with `413` (thrown mid-inflation).
292
+ *
293
+ * @param opts - Bomb-guard caps and the encoding allowlist. `maxDecompressedBytes` is required.
294
+ * @returns A {@link "./types.js".Hooks} bundle exposing only an `onRequest` hook.
295
+ * @throws {TypeError} At construction when a cap is invalid.
296
+ * @since 0.37.0
297
+ */
298
+ export function requestDecompression(opts) {
299
+ assertPositiveInteger(opts.maxDecompressedBytes, "maxDecompressedBytes");
300
+ const maxCompressedBytes = opts.maxCompressedBytes ?? DEFAULT_MAX_COMPRESSED_BYTES;
301
+ assertPositiveInteger(maxCompressedBytes, "maxCompressedBytes");
302
+ const maxRatio = opts.maxRatio ?? DEFAULT_MAX_RATIO;
303
+ if (!Number.isFinite(maxRatio) || maxRatio < 1) {
304
+ throw new TypeError(`requestDecompression(): \`maxRatio\` must be a finite number >= 1, received ${String(maxRatio)}`);
305
+ }
306
+ const allowed = (opts.encodings ?? ["gzip", "deflate"]).map((e) => e.toLowerCase());
307
+ if (allowed.length === 0) {
308
+ throw new TypeError("requestDecompression(): `encodings` must contain at least one encoding");
309
+ }
310
+ for (const enc of allowed) {
311
+ if (enc !== "gzip" && enc !== "deflate") {
312
+ throw new TypeError(`requestDecompression(): unsupported encoding "${enc}"; only "gzip" and "deflate" are supported`);
313
+ }
314
+ }
315
+ const allowedSet = new Set(allowed);
316
+ const caps = {
317
+ maxDecompressedBytes: opts.maxDecompressedBytes,
318
+ maxRatio,
319
+ onBomb: opts.onBomb,
320
+ };
321
+ return {
322
+ async onRequest(request) {
323
+ const ceRaw = request.headers.get("content-encoding");
324
+ if (!ceRaw)
325
+ return;
326
+ const ce = ceRaw.trim().toLowerCase();
327
+ if (ce === "" || ce === "identity")
328
+ return;
329
+ // Layered encodings (e.g. "gzip, gzip") are a classic nested-bomb vector;
330
+ // refuse rather than inflate recursively.
331
+ const parts = ce
332
+ .split(",")
333
+ .map((p) => p.trim())
334
+ .filter((p) => p.length > 0);
335
+ if (parts.length !== 1) {
336
+ throw new UnsupportedContentEncodingError(ce, allowed);
337
+ }
338
+ const encoding = parts[0];
339
+ if (!allowedSet.has(encoding) || !detectRuntimeSupport().has(encoding)) {
340
+ throw new UnsupportedContentEncodingError(parts[0], allowed);
341
+ }
342
+ // Bodyless methods carry nothing to inflate.
343
+ const method = request.method.toUpperCase();
344
+ if (method === "GET" || method === "HEAD")
345
+ return;
346
+ // Read the compressed body under the compressed-size cap (413 if over).
347
+ const compressed = await readBodyLimited(request, maxCompressedBytes);
348
+ const target = request;
349
+ if (compressed.byteLength === 0) {
350
+ target[REQUEST_RAW_BODY] = compressed;
351
+ return;
352
+ }
353
+ const inflated = await inflateGuarded(compressed, encoding, caps);
354
+ // Defense in depth: the inflated body must still fit the absolute cap.
355
+ if (inflated.byteLength > caps.maxDecompressedBytes) {
356
+ throw new PayloadTooLargeError(caps.maxDecompressedBytes);
357
+ }
358
+ // Stash the inflated bytes so the framework's body reader returns them
359
+ // transparently (schema-validated bodies and raw-body handlers alike).
360
+ target[REQUEST_RAW_BODY] = inflated;
361
+ },
362
+ };
363
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Server-side response caching for DaloyJS.
3
+ *
4
+ * The {@link responseCache} middleware stores rendered response bodies in a
5
+ * pluggable backend and replays them for subsequent matching requests, so a
6
+ * hot read endpoint can skip the handler (and its database / upstream calls)
7
+ * entirely while a cached representation is fresh. It complements — and does
8
+ * not overlap with — the two caching-adjacent helpers DaloyJS already ships:
9
+ *
10
+ * - `etag()` answers conditional `GET`s with `304 Not Modified` but still runs
11
+ * the handler to produce the body it hashes.
12
+ * - `compression()` shrinks the bytes on the wire but caches nothing.
13
+ *
14
+ * `responseCache()` is the missing third piece: it caches the **body** so the
15
+ * handler is not invoked at all on a fresh hit.
16
+ *
17
+ * Highlights:
18
+ *
19
+ * - **`Cache-Control` orchestration.** Freshness is derived from the response's
20
+ * own `Cache-Control` (`s-maxage` wins over `max-age`) when present, falling
21
+ * back to the configured `ttlSeconds`. Responses marked `no-store` /
22
+ * `private` / `no-cache`, or carrying `Set-Cookie`, are never cached.
23
+ * - **Request directives.** `Cache-Control: no-store` on the request bypasses
24
+ * the cache completely; `no-cache` bypasses the read but still refreshes the
25
+ * stored entry (the same directive the background SWR refresh uses, which
26
+ * makes revalidation recursion-safe).
27
+ * - **stale-while-revalidate.** With `staleWhileRevalidateSeconds` plus a
28
+ * `revalidate` callback (typically wired to `app.fetch`), a stale-but-recent
29
+ * entry is served immediately (marked `X-Cache: STALE`) while a single,
30
+ * de-duplicated background refresh repopulates the cache.
31
+ * - **Pluggable store.** {@link ResponseCacheStore} mirrors `SessionStore` /
32
+ * the rate-limit store, with an in-memory {@link MemoryResponseCacheStore}
33
+ * default; supply a shared backend (e.g. Redis) for multi-instance fleets.
34
+ *
35
+ * This module is dependency-free and uses only Web Standard
36
+ * `Request`/`Response` + `Headers`, so it runs unchanged on Node, Bun, Deno,
37
+ * Cloudflare Workers, and Vercel Edge.
38
+ *
39
+ * @module
40
+ * @since 0.37.0
41
+ */
42
+ import type { BaseContext, Hooks } from "./types.js";
43
+ /**
44
+ * Test-only helper that clears the process-wide shared stores used by
45
+ * `responseCache({ groupId })`. Not part of the documented public API.
46
+ *
47
+ * @internal
48
+ */
49
+ export declare function _resetSharedResponseCacheStoresForTests(): void;
50
+ /**
51
+ * A cached HTTP response. The body is stored as standard base64 so arbitrary
52
+ * binary payloads round-trip safely.
53
+ */
54
+ export interface CachedResponse {
55
+ /** HTTP status code of the cached response. */
56
+ status: number;
57
+ /** Response headers as `[name, value]` pairs (lower-cased by `Headers`). */
58
+ headers: Array<[string, string]>;
59
+ /** Base64-encoded response body (empty string for a bodyless response). */
60
+ body: string;
61
+ /** Creation time as ms since epoch (drives the `Age` header). */
62
+ storedAt: number;
63
+ /** End of the freshness window as ms since epoch. */
64
+ freshUntil: number;
65
+ /** End of the stale-while-revalidate window as ms since epoch. */
66
+ staleUntil: number;
67
+ }
68
+ /**
69
+ * Pluggable persistence backend for {@link responseCache}. All methods may be
70
+ * synchronous or asynchronous. Implementations should treat an entry whose
71
+ * `staleUntil` is in the past as "missing" and may lazily delete it.
72
+ */
73
+ export interface ResponseCacheStore {
74
+ /**
75
+ * Fetch the cached entry for `key`, or `null` when absent / fully expired.
76
+ */
77
+ get(key: string): CachedResponse | null | Promise<CachedResponse | null>;
78
+ /**
79
+ * Persist `entry` under `key` with the given total time-to-live (freshness +
80
+ * stale window) in milliseconds.
81
+ */
82
+ set(key: string, entry: CachedResponse, ttlMs: number): void | Promise<void>;
83
+ /** Remove the cached entry for `key`. */
84
+ delete(key: string): void | Promise<void>;
85
+ }
86
+ /** Options for the {@link responseCache} middleware. */
87
+ export interface ResponseCacheOptions {
88
+ /** Pluggable persistence backend. Default: a fresh in-memory store. */
89
+ store?: ResponseCacheStore;
90
+ /**
91
+ * Default freshness lifetime in seconds, used when the response carries no
92
+ * `s-maxage` / `max-age`. Default: `60`.
93
+ */
94
+ ttlSeconds?: number;
95
+ /**
96
+ * Extra seconds a stale entry may be served while a background refresh runs.
97
+ * Requires {@link revalidate}. Default: `0` (no stale serving).
98
+ */
99
+ staleWhileRevalidateSeconds?: number;
100
+ /**
101
+ * Background refresh callback, typically `(req) => app.fetch(req)`. Invoked
102
+ * (fire-and-forget, de-duplicated per key) with a clone of the original
103
+ * request carrying `Cache-Control: no-cache` so it bypasses the cached read
104
+ * but still repopulates the entry. Required to enable
105
+ * {@link staleWhileRevalidateSeconds}.
106
+ */
107
+ revalidate?: (request: Request) => Promise<Response> | Response;
108
+ /**
109
+ * HTTP methods eligible for caching. Default: `["GET", "HEAD"]`.
110
+ */
111
+ methods?: string[];
112
+ /**
113
+ * Decide whether a produced response is cacheable by status. Default: only
114
+ * `200 OK`.
115
+ */
116
+ cacheableStatus?: (status: number) => boolean;
117
+ /**
118
+ * Request header names whose values partition the cache (e.g.
119
+ * `["accept-language"]`). Their values are folded into the cache key.
120
+ * Default: none.
121
+ */
122
+ varyHeaders?: string[];
123
+ /**
124
+ * Derive the cache key from the request. Default: method + URL +
125
+ * {@link varyHeaders} values. Return `null` to skip caching for this request.
126
+ */
127
+ keyGenerator?: (ctx: BaseContext<any, any>) => string | null;
128
+ /**
129
+ * Maximum response body size (bytes) the middleware will buffer and store.
130
+ * Larger responses pass through uncached. Default: `1048576` (1 MiB).
131
+ */
132
+ maxBodyBytes?: number;
133
+ /**
134
+ * Response header marking cache outcome (`HIT` / `MISS` / `STALE`). Set to
135
+ * `null` to disable. Default: `"x-cache"`.
136
+ */
137
+ statusHeaderName?: string | null;
138
+ /**
139
+ * Share a single in-memory store across every `responseCache()` mount that
140
+ * declares the same `groupId`. Only meaningful for the default in-memory
141
+ * store.
142
+ */
143
+ groupId?: string;
144
+ }
145
+ /**
146
+ * In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
147
+ * deployments. Expired entries are dropped on access; the map is
148
+ * opportunistically pruned so it cannot grow without bound.
149
+ */
150
+ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
151
+ private readonly map;
152
+ /** @inheritDoc */
153
+ get(key: string): CachedResponse | null;
154
+ /** @inheritDoc */
155
+ set(key: string, entry: CachedResponse): void;
156
+ /** @inheritDoc */
157
+ delete(key: string): void;
158
+ private prune;
159
+ /** Test helper. Remove every entry. */
160
+ clear(): void;
161
+ /** Test helper. Number of stored entries (including expired). */
162
+ size(): number;
163
+ }
164
+ /**
165
+ * Server-side response cache middleware. Mount it ahead of the read endpoints
166
+ * whose rendered bodies are safe to reuse for a short window.
167
+ *
168
+ * Behavior for an eligible method (see {@link ResponseCacheOptions.methods}):
169
+ *
170
+ * - **Fresh hit** → the stored response is served and the handler does not run
171
+ * (`X-Cache: HIT`, plus an `Age` header).
172
+ * - **Stale hit within the SWR window** (requires
173
+ * {@link ResponseCacheOptions.revalidate}) → the stale response is served
174
+ * immediately (`X-Cache: STALE`) while a single background refresh runs.
175
+ * - **Miss** → the handler runs; a cacheable response is stored
176
+ * (`X-Cache: MISS`).
177
+ *
178
+ * Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
179
+ * bypasses the read but still refreshes the stored entry. Responses marked
180
+ * `no-store` / `private` / `no-cache`, carrying `Set-Cookie`, failing
181
+ * {@link ResponseCacheOptions.cacheableStatus}, or larger than
182
+ * {@link ResponseCacheOptions.maxBodyBytes} are never cached.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * import { App, responseCache } from "@daloyjs/core";
187
+ *
188
+ * const app = new App();
189
+ * app.use(responseCache({ ttlSeconds: 30 }));
190
+ *
191
+ * // stale-while-revalidate, wired to the app itself:
192
+ * app.use(
193
+ * responseCache({
194
+ * ttlSeconds: 30,
195
+ * staleWhileRevalidateSeconds: 300,
196
+ * revalidate: (req) => app.fetch(req),
197
+ * }),
198
+ * );
199
+ * ```
200
+ *
201
+ * @param opts - Response-cache configuration.
202
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
203
+ * @since 0.37.0
204
+ */
205
+ export declare function responseCache(opts?: ResponseCacheOptions): Hooks;