@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.
- package/README.md +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- 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;
|