@daloyjs/core 0.35.2 → 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 +22 -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 +223 -1
- package/dist/app.js +358 -8
- 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,374 @@
|
|
|
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
|
+
/** Internal `ctx.state` key carrying the pending cache key between hooks. */
|
|
43
|
+
const PENDING_STATE_KEY = "__responseCachePending";
|
|
44
|
+
/**
|
|
45
|
+
* Process-wide registry of in-memory stores shared by
|
|
46
|
+
* {@link ResponseCacheOptions.groupId}.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
const SHARED_RESPONSE_CACHE_STORES = new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Test-only helper that clears the process-wide shared stores used by
|
|
53
|
+
* `responseCache({ groupId })`. Not part of the documented public API.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export function _resetSharedResponseCacheStoresForTests() {
|
|
58
|
+
SHARED_RESPONSE_CACHE_STORES.clear();
|
|
59
|
+
}
|
|
60
|
+
// ---------- Default store ----------
|
|
61
|
+
/**
|
|
62
|
+
* In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
|
|
63
|
+
* deployments. Expired entries are dropped on access; the map is
|
|
64
|
+
* opportunistically pruned so it cannot grow without bound.
|
|
65
|
+
*/
|
|
66
|
+
export class MemoryResponseCacheStore {
|
|
67
|
+
map = new Map();
|
|
68
|
+
/** @inheritDoc */
|
|
69
|
+
get(key) {
|
|
70
|
+
const entry = this.map.get(key);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return null;
|
|
73
|
+
if (entry.staleUntil <= Date.now()) {
|
|
74
|
+
this.map.delete(key);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
/** @inheritDoc */
|
|
80
|
+
set(key, entry) {
|
|
81
|
+
this.map.set(key, entry);
|
|
82
|
+
if (this.map.size > 10_000)
|
|
83
|
+
this.prune();
|
|
84
|
+
}
|
|
85
|
+
/** @inheritDoc */
|
|
86
|
+
delete(key) {
|
|
87
|
+
this.map.delete(key);
|
|
88
|
+
}
|
|
89
|
+
prune() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
for (const [k, v] of this.map) {
|
|
92
|
+
if (v.staleUntil <= now)
|
|
93
|
+
this.map.delete(k);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Test helper. Remove every entry. */
|
|
97
|
+
clear() {
|
|
98
|
+
this.map.clear();
|
|
99
|
+
}
|
|
100
|
+
/** Test helper. Number of stored entries (including expired). */
|
|
101
|
+
size() {
|
|
102
|
+
return this.map.size;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function bytesToBase64(bytes) {
|
|
106
|
+
let bin = "";
|
|
107
|
+
const CHUNK = 0x8000;
|
|
108
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
109
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
110
|
+
}
|
|
111
|
+
return btoa(bin);
|
|
112
|
+
}
|
|
113
|
+
function base64ToBytes(b64) {
|
|
114
|
+
const bin = atob(b64);
|
|
115
|
+
const out = new Uint8Array(bin.length);
|
|
116
|
+
for (let i = 0; i < bin.length; i++)
|
|
117
|
+
out[i] = bin.charCodeAt(i);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** Parse a `Cache-Control` header into a lower-cased directive map. */
|
|
121
|
+
function parseCacheControl(value) {
|
|
122
|
+
const out = new Map();
|
|
123
|
+
if (!value)
|
|
124
|
+
return out;
|
|
125
|
+
for (const part of value.split(",")) {
|
|
126
|
+
const token = part.trim();
|
|
127
|
+
if (token.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
const eq = token.indexOf("=");
|
|
130
|
+
if (eq === -1) {
|
|
131
|
+
out.set(token.toLowerCase(), "");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
out.set(token.slice(0, eq).trim().toLowerCase(), token.slice(eq + 1).trim());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function parseSeconds(raw) {
|
|
140
|
+
if (raw === undefined || raw === "")
|
|
141
|
+
return null;
|
|
142
|
+
const n = Number(raw.replace(/^"|"$/g, ""));
|
|
143
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Decide whether a freshly produced response may be cached and, if so, its
|
|
147
|
+
* freshness lifetime override (ms) from `Cache-Control`. Returns `null` when
|
|
148
|
+
* the response must not be cached.
|
|
149
|
+
*/
|
|
150
|
+
function freshnessFromResponse(res) {
|
|
151
|
+
if (res.headers.has("set-cookie"))
|
|
152
|
+
return null;
|
|
153
|
+
const cc = parseCacheControl(res.headers.get("cache-control"));
|
|
154
|
+
if (cc.has("no-store") || cc.has("private") || cc.has("no-cache"))
|
|
155
|
+
return null;
|
|
156
|
+
const sMaxAge = parseSeconds(cc.get("s-maxage"));
|
|
157
|
+
if (sMaxAge !== null)
|
|
158
|
+
return sMaxAge * 1_000;
|
|
159
|
+
const maxAge = parseSeconds(cc.get("max-age"));
|
|
160
|
+
if (maxAge !== null)
|
|
161
|
+
return maxAge * 1_000;
|
|
162
|
+
// No explicit directive: fall back to the configured ttl (undefined marker).
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
function defaultKey(ctx, varyHeaders) {
|
|
166
|
+
const url = new URL(ctx.request.url);
|
|
167
|
+
let key = `${ctx.request.method} ${url.pathname}${url.search}`;
|
|
168
|
+
for (const name of varyHeaders) {
|
|
169
|
+
key += `\n${name}: ${ctx.request.headers.get(name) ?? ""}`;
|
|
170
|
+
}
|
|
171
|
+
return key;
|
|
172
|
+
}
|
|
173
|
+
function buildResponseFromCache(entry, outcome, statusHeaderName, isHead) {
|
|
174
|
+
const headers = new Headers();
|
|
175
|
+
for (const [name, value] of entry.headers)
|
|
176
|
+
headers.set(name, value);
|
|
177
|
+
const ageSeconds = Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1_000));
|
|
178
|
+
headers.set("age", String(ageSeconds));
|
|
179
|
+
if (statusHeaderName)
|
|
180
|
+
headers.set(statusHeaderName, outcome);
|
|
181
|
+
const body = isHead || entry.body === "" ? null : base64ToBytes(entry.body);
|
|
182
|
+
return new Response(body, { status: entry.status, headers });
|
|
183
|
+
}
|
|
184
|
+
function isPromiseLike(value) {
|
|
185
|
+
return (value !== null &&
|
|
186
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
187
|
+
typeof value.then === "function");
|
|
188
|
+
}
|
|
189
|
+
// ---------- Middleware ----------
|
|
190
|
+
/**
|
|
191
|
+
* Server-side response cache middleware. Mount it ahead of the read endpoints
|
|
192
|
+
* whose rendered bodies are safe to reuse for a short window.
|
|
193
|
+
*
|
|
194
|
+
* Behavior for an eligible method (see {@link ResponseCacheOptions.methods}):
|
|
195
|
+
*
|
|
196
|
+
* - **Fresh hit** → the stored response is served and the handler does not run
|
|
197
|
+
* (`X-Cache: HIT`, plus an `Age` header).
|
|
198
|
+
* - **Stale hit within the SWR window** (requires
|
|
199
|
+
* {@link ResponseCacheOptions.revalidate}) → the stale response is served
|
|
200
|
+
* immediately (`X-Cache: STALE`) while a single background refresh runs.
|
|
201
|
+
* - **Miss** → the handler runs; a cacheable response is stored
|
|
202
|
+
* (`X-Cache: MISS`).
|
|
203
|
+
*
|
|
204
|
+
* Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
|
|
205
|
+
* bypasses the read but still refreshes the stored entry. Responses marked
|
|
206
|
+
* `no-store` / `private` / `no-cache`, carrying `Set-Cookie`, failing
|
|
207
|
+
* {@link ResponseCacheOptions.cacheableStatus}, or larger than
|
|
208
|
+
* {@link ResponseCacheOptions.maxBodyBytes} are never cached.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* import { App, responseCache } from "@daloyjs/core";
|
|
213
|
+
*
|
|
214
|
+
* const app = new App();
|
|
215
|
+
* app.use(responseCache({ ttlSeconds: 30 }));
|
|
216
|
+
*
|
|
217
|
+
* // stale-while-revalidate, wired to the app itself:
|
|
218
|
+
* app.use(
|
|
219
|
+
* responseCache({
|
|
220
|
+
* ttlSeconds: 30,
|
|
221
|
+
* staleWhileRevalidateSeconds: 300,
|
|
222
|
+
* revalidate: (req) => app.fetch(req),
|
|
223
|
+
* }),
|
|
224
|
+
* );
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @param opts - Response-cache configuration.
|
|
228
|
+
* @returns A {@link Hooks} bundle ready for `app.use(...)`.
|
|
229
|
+
* @since 0.37.0
|
|
230
|
+
*/
|
|
231
|
+
export function responseCache(opts = {}) {
|
|
232
|
+
const ttlSeconds = opts.ttlSeconds ?? 60;
|
|
233
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
|
234
|
+
throw new Error("responseCache(): ttlSeconds must be a positive integer.");
|
|
235
|
+
}
|
|
236
|
+
const swrSeconds = opts.staleWhileRevalidateSeconds ?? 0;
|
|
237
|
+
if (!Number.isInteger(swrSeconds) || swrSeconds < 0) {
|
|
238
|
+
throw new Error("responseCache(): staleWhileRevalidateSeconds must be a non-negative integer.");
|
|
239
|
+
}
|
|
240
|
+
if (swrSeconds > 0 && typeof opts.revalidate !== "function") {
|
|
241
|
+
throw new Error("responseCache(): staleWhileRevalidateSeconds requires a revalidate callback (e.g. (req) => app.fetch(req)).");
|
|
242
|
+
}
|
|
243
|
+
const maxBodyBytes = opts.maxBodyBytes ?? 1_048_576;
|
|
244
|
+
if (!Number.isInteger(maxBodyBytes) || maxBodyBytes <= 0) {
|
|
245
|
+
throw new Error("responseCache(): maxBodyBytes must be a positive integer.");
|
|
246
|
+
}
|
|
247
|
+
const methods = new Set((opts.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
|
|
248
|
+
const cacheableStatus = opts.cacheableStatus ?? ((status) => status === 200);
|
|
249
|
+
const varyHeaders = (opts.varyHeaders ?? []).map((h) => h.toLowerCase());
|
|
250
|
+
const statusHeaderName = opts.statusHeaderName === null ? null : (opts.statusHeaderName ?? "x-cache").toLowerCase();
|
|
251
|
+
const ttlMs = ttlSeconds * 1_000;
|
|
252
|
+
const swrMs = swrSeconds * 1_000;
|
|
253
|
+
const revalidate = opts.revalidate;
|
|
254
|
+
let store;
|
|
255
|
+
if (opts.store) {
|
|
256
|
+
store = opts.store;
|
|
257
|
+
}
|
|
258
|
+
else if (opts.groupId) {
|
|
259
|
+
let shared = SHARED_RESPONSE_CACHE_STORES.get(opts.groupId);
|
|
260
|
+
if (!shared) {
|
|
261
|
+
shared = new MemoryResponseCacheStore();
|
|
262
|
+
SHARED_RESPONSE_CACHE_STORES.set(opts.groupId, shared);
|
|
263
|
+
}
|
|
264
|
+
store = shared;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
store = new MemoryResponseCacheStore();
|
|
268
|
+
}
|
|
269
|
+
const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
|
|
270
|
+
// De-duplicate concurrent background refreshes per key.
|
|
271
|
+
const refreshing = new Set();
|
|
272
|
+
function backgroundRefresh(key, request) {
|
|
273
|
+
if (!revalidate || refreshing.has(key))
|
|
274
|
+
return;
|
|
275
|
+
refreshing.add(key);
|
|
276
|
+
const refreshReq = new Request(request.url, {
|
|
277
|
+
method: request.method,
|
|
278
|
+
headers: new Headers(request.headers),
|
|
279
|
+
});
|
|
280
|
+
// Force a read-bypass so the refresh re-runs the handler and re-stores.
|
|
281
|
+
refreshReq.headers.set("cache-control", "no-cache");
|
|
282
|
+
void Promise.resolve()
|
|
283
|
+
.then(() => revalidate(refreshReq))
|
|
284
|
+
.catch(() => undefined)
|
|
285
|
+
.finally(() => refreshing.delete(key));
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
async beforeHandle(ctx) {
|
|
289
|
+
const method = ctx.request.method.toUpperCase();
|
|
290
|
+
if (!methods.has(method))
|
|
291
|
+
return undefined;
|
|
292
|
+
const reqCc = parseCacheControl(ctx.request.headers.get("cache-control"));
|
|
293
|
+
if (reqCc.has("no-store"))
|
|
294
|
+
return undefined;
|
|
295
|
+
const rawKey = opts.keyGenerator
|
|
296
|
+
? opts.keyGenerator(ctx)
|
|
297
|
+
: defaultKey(ctx, varyHeaders);
|
|
298
|
+
if (rawKey === null)
|
|
299
|
+
return undefined;
|
|
300
|
+
const key = `${keyPrefix}${rawKey}`;
|
|
301
|
+
// `no-cache` bypasses the read but still allows a fresh write below.
|
|
302
|
+
const bypassRead = reqCc.has("no-cache");
|
|
303
|
+
if (!bypassRead) {
|
|
304
|
+
const getResult = store.get(key);
|
|
305
|
+
const entry = isPromiseLike(getResult) ? await getResult : getResult;
|
|
306
|
+
if (entry) {
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
if (now < entry.freshUntil) {
|
|
309
|
+
return buildResponseFromCache(entry, "HIT", statusHeaderName, method === "HEAD");
|
|
310
|
+
}
|
|
311
|
+
if (revalidate && now < entry.staleUntil) {
|
|
312
|
+
backgroundRefresh(key, ctx.request);
|
|
313
|
+
return buildResponseFromCache(entry, "STALE", statusHeaderName, method === "HEAD");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
ctx.state[PENDING_STATE_KEY] = {
|
|
318
|
+
key,
|
|
319
|
+
freshnessOverrideMs: null,
|
|
320
|
+
};
|
|
321
|
+
return undefined;
|
|
322
|
+
},
|
|
323
|
+
async onSend(res, ctx) {
|
|
324
|
+
if (!ctx)
|
|
325
|
+
return undefined;
|
|
326
|
+
const state = ctx.state;
|
|
327
|
+
const pending = state[PENDING_STATE_KEY];
|
|
328
|
+
if (!pending)
|
|
329
|
+
return undefined;
|
|
330
|
+
delete state[PENDING_STATE_KEY];
|
|
331
|
+
if (!cacheableStatus(res.status)) {
|
|
332
|
+
if (statusHeaderName)
|
|
333
|
+
res.headers.set(statusHeaderName, "MISS");
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const freshness = freshnessFromResponse(res);
|
|
337
|
+
if (freshness === null) {
|
|
338
|
+
// Response opted out of caching (no-store / private / Set-Cookie ...).
|
|
339
|
+
if (statusHeaderName)
|
|
340
|
+
res.headers.set(statusHeaderName, "MISS");
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const buf = new Uint8Array(await res.clone().arrayBuffer());
|
|
344
|
+
if (buf.byteLength > maxBodyBytes) {
|
|
345
|
+
if (statusHeaderName)
|
|
346
|
+
res.headers.set(statusHeaderName, "MISS");
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
const headers = [];
|
|
350
|
+
res.headers.forEach((value, name) => {
|
|
351
|
+
// `Age` is recomputed on every serve; never persist a stale one.
|
|
352
|
+
if (name === "age")
|
|
353
|
+
return;
|
|
354
|
+
headers.push([name, value]);
|
|
355
|
+
});
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const freshMs = freshness ?? ttlMs;
|
|
358
|
+
const entry = {
|
|
359
|
+
status: res.status,
|
|
360
|
+
headers,
|
|
361
|
+
body: buf.byteLength ? bytesToBase64(buf) : "",
|
|
362
|
+
storedAt: now,
|
|
363
|
+
freshUntil: now + freshMs,
|
|
364
|
+
staleUntil: now + freshMs + swrMs,
|
|
365
|
+
};
|
|
366
|
+
const setResult = store.set(pending.key, entry, freshMs + swrMs);
|
|
367
|
+
if (isPromiseLike(setResult))
|
|
368
|
+
await setResult;
|
|
369
|
+
if (statusHeaderName)
|
|
370
|
+
res.headers.set(statusHeaderName, "MISS");
|
|
371
|
+
return undefined;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
package/dist/router.d.ts
CHANGED
|
@@ -30,7 +30,29 @@ export declare class Router<T> {
|
|
|
30
30
|
private operationIds;
|
|
31
31
|
/** Static (no-param/no-wildcard) routes for O(1) lookup. */
|
|
32
32
|
private staticTable;
|
|
33
|
+
/**
|
|
34
|
+
* Register a handler for the given method and path. Static paths land in the
|
|
35
|
+
* O(1) `staticTable`; paths with `:param`/`*wildcard` segments are inserted
|
|
36
|
+
* into the trie. Wildcards must be the terminal segment.
|
|
37
|
+
*
|
|
38
|
+
* @param method - HTTP method to register the handler under.
|
|
39
|
+
* @param path - Route path; supports `:param` and a trailing `*wildcard`.
|
|
40
|
+
* @param handler - Value returned by {@link Router.find} on a match.
|
|
41
|
+
* @param operationId - Optional unique id; tracked to reject duplicates.
|
|
42
|
+
* @throws Error on a duplicate route, duplicate `operationId`, or conflicting
|
|
43
|
+
* param names at the same trie position.
|
|
44
|
+
*/
|
|
33
45
|
add(method: HttpMethod, path: string, handler: T, operationId?: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* Look up the handler registered for the given method and path. Tries the
|
|
48
|
+
* static fast path first, then walks the trie, extracting and decoding path
|
|
49
|
+
* params. Path-traversal lookups (`..`, `//`) are rejected up front.
|
|
50
|
+
*
|
|
51
|
+
* @param method - HTTP method to match.
|
|
52
|
+
* @param path - Request path to resolve, including any dynamic segments.
|
|
53
|
+
* @returns The matched handler with decoded params, or `undefined` if no
|
|
54
|
+
* route matches the method + path.
|
|
55
|
+
*/
|
|
34
56
|
find(method: HttpMethod, path: string): RouteMatch<T> | undefined;
|
|
35
57
|
/** Returns the set of methods registered at this exact path (for 405 responses). */
|
|
36
58
|
allowedMethods(path: string): HttpMethod[];
|
package/dist/router.js
CHANGED
|
@@ -25,6 +25,18 @@ export class Router {
|
|
|
25
25
|
operationIds = new Set();
|
|
26
26
|
/** Static (no-param/no-wildcard) routes for O(1) lookup. */
|
|
27
27
|
staticTable = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Register a handler for the given method and path. Static paths land in the
|
|
30
|
+
* O(1) `staticTable`; paths with `:param`/`*wildcard` segments are inserted
|
|
31
|
+
* into the trie. Wildcards must be the terminal segment.
|
|
32
|
+
*
|
|
33
|
+
* @param method - HTTP method to register the handler under.
|
|
34
|
+
* @param path - Route path; supports `:param` and a trailing `*wildcard`.
|
|
35
|
+
* @param handler - Value returned by {@link Router.find} on a match.
|
|
36
|
+
* @param operationId - Optional unique id; tracked to reject duplicates.
|
|
37
|
+
* @throws Error on a duplicate route, duplicate `operationId`, or conflicting
|
|
38
|
+
* param names at the same trie position.
|
|
39
|
+
*/
|
|
28
40
|
add(method, path, handler, operationId) {
|
|
29
41
|
const segments = splitPath(path);
|
|
30
42
|
if (operationId && this.operationIds.has(operationId))
|
|
@@ -75,6 +87,16 @@ export class Router {
|
|
|
75
87
|
throw new Error(`Duplicate route: ${method} ${path}`);
|
|
76
88
|
node.handlers[method] = handler;
|
|
77
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Look up the handler registered for the given method and path. Tries the
|
|
92
|
+
* static fast path first, then walks the trie, extracting and decoding path
|
|
93
|
+
* params. Path-traversal lookups (`..`, `//`) are rejected up front.
|
|
94
|
+
*
|
|
95
|
+
* @param method - HTTP method to match.
|
|
96
|
+
* @param path - Request path to resolve, including any dynamic segments.
|
|
97
|
+
* @returns The matched handler with decoded params, or `undefined` if no
|
|
98
|
+
* route matches the method + path.
|
|
99
|
+
*/
|
|
78
100
|
find(method, path) {
|
|
79
101
|
// Reject path traversal attempts before walking.
|
|
80
102
|
if (path.includes("/../") || path.endsWith("/..") || path.includes("//")) {
|
|
@@ -117,19 +139,54 @@ export class Router {
|
|
|
117
139
|
return r;
|
|
118
140
|
}
|
|
119
141
|
if (node.paramChild) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
const decoded = safeDecodeURIComponent(seg);
|
|
143
|
+
if (decoded !== undefined) {
|
|
144
|
+
params[node.paramChild.name] = decoded;
|
|
145
|
+
const r = this.walk(node.paramChild.node, segs, i + 1, params);
|
|
146
|
+
if (r)
|
|
147
|
+
return r;
|
|
148
|
+
delete params[node.paramChild.name];
|
|
149
|
+
}
|
|
125
150
|
}
|
|
126
151
|
if (node.wildcardChild) {
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
const rest = decodeSegments(segs, i);
|
|
153
|
+
if (rest !== undefined) {
|
|
154
|
+
params[node.wildcardChild.name] = rest;
|
|
155
|
+
return node.wildcardChild.node;
|
|
156
|
+
}
|
|
129
157
|
}
|
|
130
158
|
return undefined;
|
|
131
159
|
}
|
|
132
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Decode a single path segment, returning `undefined` instead of throwing when
|
|
163
|
+
* the segment contains a malformed percent-escape (e.g. `%zz` or a lone `%`).
|
|
164
|
+
* A malformed segment therefore fails to match and yields a clean 404 rather
|
|
165
|
+
* than letting a `URIError` bubble up as a generic 500.
|
|
166
|
+
*/
|
|
167
|
+
function safeDecodeURIComponent(segment) {
|
|
168
|
+
try {
|
|
169
|
+
return decodeURIComponent(segment);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Decode and join the wildcard tail starting at `index`. Returns `undefined`
|
|
177
|
+
* if any captured segment carries a malformed percent-escape, so the lookup
|
|
178
|
+
* misses cleanly instead of throwing.
|
|
179
|
+
*/
|
|
180
|
+
function decodeSegments(segs, index) {
|
|
181
|
+
const parts = [];
|
|
182
|
+
for (let i = index; i < segs.length; i++) {
|
|
183
|
+
const decoded = safeDecodeURIComponent(segs[i]);
|
|
184
|
+
if (decoded === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
parts.push(decoded);
|
|
187
|
+
}
|
|
188
|
+
return parts.join("/");
|
|
189
|
+
}
|
|
133
190
|
function splitPath(path) {
|
|
134
191
|
const clean = path.replace(/\/+$/, "") || "/";
|
|
135
192
|
if (clean === "/")
|
package/dist/safe-redirect.d.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* });
|
|
39
39
|
* ```
|
|
40
40
|
*
|
|
41
|
-
* @since 0.35.
|
|
41
|
+
* @since 0.35.0
|
|
42
42
|
*/
|
|
43
43
|
/** Reason an open-redirect candidate was refused. */
|
|
44
44
|
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
@@ -86,6 +86,6 @@ export interface SafeRedirectOptions {
|
|
|
86
86
|
* @param target - User-supplied URL candidate (path or absolute URL).
|
|
87
87
|
* @param options - Allowlist + response configuration.
|
|
88
88
|
*
|
|
89
|
-
* @since 0.35.
|
|
89
|
+
* @since 0.35.0
|
|
90
90
|
*/
|
|
91
91
|
export declare function safeRedirect(target: string, options?: SafeRedirectOptions): Response;
|
package/dist/safe-redirect.js
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* });
|
|
39
39
|
* ```
|
|
40
40
|
*
|
|
41
|
-
* @since 0.35.
|
|
41
|
+
* @since 0.35.0
|
|
42
42
|
*/
|
|
43
43
|
/** Thrown when {@link safeRedirect} refuses a candidate URL and no `fallback` is configured. */
|
|
44
44
|
export class OpenRedirectBlockedError extends Error {
|
|
@@ -51,12 +51,7 @@ export class OpenRedirectBlockedError extends Error {
|
|
|
51
51
|
this.target = target;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
const FORBIDDEN_SCHEMES = new Set([
|
|
55
|
-
"javascript:",
|
|
56
|
-
"data:",
|
|
57
|
-
"vbscript:",
|
|
58
|
-
"file:",
|
|
59
|
-
]);
|
|
54
|
+
const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "vbscript:", "file:"]);
|
|
60
55
|
const ALLOWED_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
61
56
|
// Reject NUL, CR, LF, and other C0/C1 control characters; they enable
|
|
62
57
|
// response-splitting via the `Location` header.
|
|
@@ -126,7 +121,7 @@ function classify(target, allowedPaths, allowedOrigins) {
|
|
|
126
121
|
* @param target - User-supplied URL candidate (path or absolute URL).
|
|
127
122
|
* @param options - Allowlist + response configuration.
|
|
128
123
|
*
|
|
129
|
-
* @since 0.35.
|
|
124
|
+
* @since 0.35.0
|
|
130
125
|
*/
|
|
131
126
|
export function safeRedirect(target, options = {}) {
|
|
132
127
|
const allowedPaths = options.allowedPaths ?? [];
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:99cd0a75-d472-56c5-bd8e-c5247eb9e1df",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-05-
|
|
7
|
+
"timestamp": "2026-05-31T20:28:21.245Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.37.0"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@0.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@0.37.0",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "0.
|
|
24
|
+
"version": "0.37.0",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@0.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@0.37.0",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-0.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-0.37.0",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.37.0",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@0.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@0.37.0",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-0.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.
|
|
5
|
+
"name": "@daloyjs/core-0.37.0",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.37.0-99cd0a75-d472-56c5-bd8e-c5247eb9e1df",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-05-
|
|
8
|
+
"created": "2026-05-31T20:28:21.245Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "0.
|
|
19
|
+
"versionInfo": "0.37.0",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@0.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@0.37.0"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|