@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,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
- params[node.paramChild.name] = decodeURIComponent(seg);
121
- const r = this.walk(node.paramChild.node, segs, i + 1, params);
122
- if (r)
123
- return r;
124
- delete params[node.paramChild.name];
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
- params[node.wildcardChild.name] = segs.slice(i).map(decodeURIComponent).join("/");
128
- return node.wildcardChild.node;
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 === "/")
@@ -38,7 +38,7 @@
38
38
  * });
39
39
  * ```
40
40
  *
41
- * @since 0.36.0
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.36.0
89
+ * @since 0.35.0
90
90
  */
91
91
  export declare function safeRedirect(target: string, options?: SafeRedirectOptions): Response;
@@ -38,7 +38,7 @@
38
38
  * });
39
39
  * ```
40
40
  *
41
- * @since 0.36.0
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.36.0
124
+ * @since 0.35.0
130
125
  */
131
126
  export function safeRedirect(target, options = {}) {
132
127
  const allowedPaths = options.allowedPaths ?? [];
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:7079f846-98da-5195-89ad-4f54c8938289",
4
+ "serialNumber": "urn:uuid:99cd0a75-d472-56c5-bd8e-c5247eb9e1df",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-28T20:50:52.884Z",
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.36.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.36.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@0.37.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.36.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.36.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.36.0",
49
+ "tagId": "swidtag--daloyjs-core-0.37.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.36.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.36.0",
60
+ "ref": "pkg:npm/@daloyjs/core@0.37.0",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.36.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.36.0-7079f846-98da-5195-89ad-4f54c8938289",
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-28T20:50:52.884Z",
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.36.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.36.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@0.37.0"
31
31
  }
32
32
  ]
33
33
  }