@daloyjs/core 1.3.3 → 1.3.4

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 CHANGED
@@ -491,7 +491,8 @@ miss 7,742,635 ops/sec
491
491
 
492
492
  - After traversal checks, exact static routes resolve with an allocation-free
493
493
  `Map.get` fast path — **~26M ops/sec**.
494
- - Dynamic routes walk a trie, **O(path-segments)** regardless of route count.
494
+ - Dynamic routes walk a segment trie in path-length time without backtracking;
495
+ overlapping routes can require visiting additional branches.
495
496
  - Body parsing is lazy and only runs when a route declares a body schema.
496
497
  - Path normalization and splitting use index/character scans rather than
497
498
  regular expressions.
package/dist/router.d.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Performance:
5
5
  * - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
6
- * - Dynamic paths walk a trie, O(path-segments) regardless of route count.
6
+ * - Dynamic paths walk a segment trie, visiting static branches before
7
+ * parameters and wildcards. Backtracking cost depends on overlapping routes.
7
8
  * - Path normalization and splitting avoid regular expressions.
8
9
  *
9
10
  * Safety:
@@ -23,12 +24,15 @@ export interface RouteMatch<T> {
23
24
  /**
24
25
  * Trie/radix router with a static-route fast path. Registers handlers via
25
26
  * {@link Router.add} and resolves them with {@link Router.find}. Rejects
26
- * duplicate routes, duplicate operationIds, conflicting param names, and
27
- * path-traversal lookups.
27
+ * duplicate routes, duplicate operationIds, conflicting or unsafe capture names,
28
+ * and raw path-traversal lookups.
28
29
  */
29
30
  export declare class Router<T> {
30
31
  private root;
31
32
  private operationIds;
33
+ private hasDynamicRoutes;
34
+ private revision;
35
+ private staticMethods;
32
36
  /** Static (no-param/no-wildcard) routes for O(1) lookup. */
33
37
  private staticTable;
34
38
  /**
@@ -38,16 +42,22 @@ export declare class Router<T> {
38
42
  *
39
43
  * @param method - HTTP method to register the handler under.
40
44
  * @param path - Route path; supports `:param` and a trailing `*wildcard`.
41
- * @param handler - Value returned by {@link Router.find} on a match.
45
+ * @param handler - Value returned by {@link Router.find} on a match, including
46
+ * falsy values or `undefined`.
42
47
  * @param operationId - Optional unique id; tracked to reject duplicates.
43
48
  * @throws Error on a duplicate route, duplicate `operationId`, or conflicting
44
- * param names at the same trie position.
49
+ * parameter or wildcard names at the same trie position, empty, repeated,
50
+ * or prototype-sensitive capture names, or a nonterminal wildcard.
51
+ * Failed registration does not reserve the `operationId`.
45
52
  */
46
53
  add(method: HttpMethod, path: string, handler: T, operationId?: string): void;
47
54
  /**
48
55
  * Look up the handler registered for the given method and path. Tries the
49
56
  * static fast path first, then walks the trie, extracting and decoding path
50
57
  * params. Path-traversal lookups (`..`, `//`) are rejected up front.
58
+ * Handler tables inherit only from an empty, frozen, prototype-free base:
59
+ * Object.prototype properties never count as routes, including for untyped
60
+ * runtime method values.
51
61
  *
52
62
  * @param method - HTTP method to match.
53
63
  * @param path - Request path to resolve, including any dynamic segments.
@@ -55,7 +65,15 @@ export declare class Router<T> {
55
65
  * route matches the method + path.
56
66
  */
57
67
  find(method: HttpMethod, path: string): RouteMatch<T> | undefined;
58
- /** Returns the set of methods registered at this exact path (for 405 responses). */
68
+ /**
69
+ * Return the methods registered at the matched path for 405 responses.
70
+ * Static-only routers skip trie traversal. Results are fresh arrays and
71
+ * reflect routes registered after earlier lookups. Only validated registered
72
+ * static paths are cached, bounding cache size by the static route count.
73
+ * @param path - Request path, subject to the same traversal and empty-segment
74
+ * rejection as {@link Router.find}.
75
+ * @returns Registered methods, or an empty array for rejected or unmatched paths.
76
+ */
59
77
  allowedMethods(path: string): HttpMethod[];
60
78
  private walk;
61
79
  }
package/dist/router.js CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Performance:
5
5
  * - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
6
- * - Dynamic paths walk a trie, O(path-segments) regardless of route count.
6
+ * - Dynamic paths walk a segment trie, visiting static branches before
7
+ * parameters and wildcards. Backtracking cost depends on overlapping routes.
7
8
  * - Path normalization and splitting avoid regular expressions.
8
9
  *
9
10
  * Safety:
@@ -12,18 +13,23 @@
12
13
  * - Duplicate routes and duplicate operationIds throw at registration.
13
14
  * - Wildcard segments must be terminal.
14
15
  */
16
+ import { isForbiddenObjectKey } from "./security.js";
17
+ const handlerPrototype = Object.freeze(Object.create(null));
15
18
  function createNode() {
16
- return { children: new Map(), handlers: {} };
19
+ return { children: new Map(), handlers: undefined };
17
20
  }
18
21
  /**
19
22
  * Trie/radix router with a static-route fast path. Registers handlers via
20
23
  * {@link Router.add} and resolves them with {@link Router.find}. Rejects
21
- * duplicate routes, duplicate operationIds, conflicting param names, and
22
- * path-traversal lookups.
24
+ * duplicate routes, duplicate operationIds, conflicting or unsafe capture names,
25
+ * and raw path-traversal lookups.
23
26
  */
24
27
  export class Router {
25
28
  root = createNode();
26
29
  operationIds = new Set();
30
+ hasDynamicRoutes = false;
31
+ revision = 0;
32
+ staticMethods = new Map();
27
33
  /** Static (no-param/no-wildcard) routes for O(1) lookup. */
28
34
  staticTable = new Map();
29
35
  /**
@@ -33,28 +39,47 @@ export class Router {
33
39
  *
34
40
  * @param method - HTTP method to register the handler under.
35
41
  * @param path - Route path; supports `:param` and a trailing `*wildcard`.
36
- * @param handler - Value returned by {@link Router.find} on a match.
42
+ * @param handler - Value returned by {@link Router.find} on a match, including
43
+ * falsy values or `undefined`.
37
44
  * @param operationId - Optional unique id; tracked to reject duplicates.
38
45
  * @throws Error on a duplicate route, duplicate `operationId`, or conflicting
39
- * param names at the same trie position.
46
+ * parameter or wildcard names at the same trie position, empty, repeated,
47
+ * or prototype-sensitive capture names, or a nonterminal wildcard.
48
+ * Failed registration does not reserve the `operationId`.
40
49
  */
41
50
  add(method, path, handler, operationId) {
42
51
  const segments = splitPath(path);
43
52
  if (operationId && this.operationIds.has(operationId))
44
53
  throw new Error(`Duplicate operationId: "${operationId}"`);
45
- if (operationId)
46
- this.operationIds.add(operationId);
47
- const isStatic = segments.every((s) => !s.startsWith(":") && !s.startsWith("*"));
54
+ let captureNames;
55
+ for (let index = 0; index < segments.length; index++) {
56
+ const segment = segments[index];
57
+ const wildcard = segment.startsWith("*");
58
+ if (wildcard && index !== segments.length - 1) {
59
+ throw new Error(`Wildcard must be the terminal segment: ${path}`);
60
+ }
61
+ if (wildcard || segment.startsWith(":")) {
62
+ const name = wildcard && segment.length === 1 ? "wildcard" : segment.slice(1);
63
+ if (!name || isForbiddenObjectKey(name) || captureNames?.has(name)) {
64
+ throw new Error(`Invalid or duplicate capture name: "${name}" in ${path}`);
65
+ }
66
+ (captureNames ??= new Set()).add(name);
67
+ }
68
+ }
69
+ const isStatic = captureNames === undefined;
48
70
  const normalized = "/" + segments.join("/");
49
71
  if (isStatic) {
50
72
  let entry = this.staticTable.get(normalized);
51
73
  if (!entry) {
52
- entry = {};
74
+ entry = Object.create(handlerPrototype);
53
75
  this.staticTable.set(normalized, entry);
54
76
  }
55
- if (entry[method])
77
+ if (Object.hasOwn(entry, method))
56
78
  throw new Error(`Duplicate route: ${method} ${path}`);
57
79
  entry[method] = handler;
80
+ this.revision++;
81
+ if (operationId)
82
+ this.operationIds.add(operationId);
58
83
  return;
59
84
  }
60
85
  let node = this.root;
@@ -71,7 +96,12 @@ export class Router {
71
96
  }
72
97
  else if (seg.startsWith("*")) {
73
98
  const name = seg.length > 1 ? seg.slice(1) : "wildcard";
74
- node.wildcardChild = { name, node: createNode() };
99
+ if (!node.wildcardChild) {
100
+ node.wildcardChild = { name, node: createNode() };
101
+ }
102
+ else if (node.wildcardChild.name !== name) {
103
+ throw new Error(`Conflicting wildcard names at same position: "${node.wildcardChild.name}" vs "${name}"`);
104
+ }
75
105
  node = node.wildcardChild.node;
76
106
  break;
77
107
  }
@@ -84,14 +114,22 @@ export class Router {
84
114
  node = next;
85
115
  }
86
116
  }
87
- if (node.handlers[method])
117
+ node.handlers ??= Object.create(handlerPrototype);
118
+ if (Object.hasOwn(node.handlers, method))
88
119
  throw new Error(`Duplicate route: ${method} ${path}`);
89
120
  node.handlers[method] = handler;
121
+ this.hasDynamicRoutes = true;
122
+ this.revision++;
123
+ if (operationId)
124
+ this.operationIds.add(operationId);
90
125
  }
91
126
  /**
92
127
  * Look up the handler registered for the given method and path. Tries the
93
128
  * static fast path first, then walks the trie, extracting and decoding path
94
129
  * params. Path-traversal lookups (`..`, `//`) are rejected up front.
130
+ * Handler tables inherit only from an empty, frozen, prototype-free base:
131
+ * Object.prototype properties never count as routes, including for untyped
132
+ * runtime method values.
95
133
  *
96
134
  * @param method - HTTP method to match.
97
135
  * @param path - Request path to resolve, including any dynamic segments.
@@ -109,8 +147,11 @@ export class Router {
109
147
  if (!staticEntry && path.endsWith("/")) {
110
148
  staticEntry = this.staticTable.get(trimTrailingSlashes(path));
111
149
  }
112
- if (staticEntry && staticEntry[method]) {
113
- return { handler: staticEntry[method], params: {} };
150
+ if (staticEntry) {
151
+ const handler = staticEntry[method];
152
+ if (handler !== undefined || Object.hasOwn(staticEntry, method)) {
153
+ return { handler: handler, params: {} };
154
+ }
114
155
  }
115
156
  const segments = splitPath(path);
116
157
  const params = {};
@@ -118,25 +159,51 @@ export class Router {
118
159
  if (!found)
119
160
  return undefined;
120
161
  const handler = found.handlers[method];
121
- if (!handler)
162
+ if (handler === undefined && !Object.hasOwn(found.handlers, method))
122
163
  return undefined;
123
- return { handler, params };
164
+ return { handler: handler, params };
124
165
  }
125
- /** Returns the set of methods registered at this exact path (for 405 responses). */
166
+ /**
167
+ * Return the methods registered at the matched path for 405 responses.
168
+ * Static-only routers skip trie traversal. Results are fresh arrays and
169
+ * reflect routes registered after earlier lookups. Only validated registered
170
+ * static paths are cached, bounding cache size by the static route count.
171
+ * @param path - Request path, subject to the same traversal and empty-segment
172
+ * rejection as {@link Router.find}.
173
+ * @returns Registered methods, or an empty array for rejected or unmatched paths.
174
+ */
126
175
  allowedMethods(path) {
176
+ const cached = this.staticMethods.get(path);
177
+ if (cached?.revision === this.revision)
178
+ return cached.methods.slice();
179
+ if (path.includes("/../") || path.endsWith("/..") || path.includes("//")) {
180
+ return [];
181
+ }
127
182
  let fromStatic = this.staticTable.get(path);
128
183
  if (!fromStatic && path.endsWith("/")) {
129
184
  fromStatic = this.staticTable.get(trimTrailingSlashes(path));
130
185
  }
131
- if (fromStatic)
132
- return Object.keys(fromStatic);
133
- const segments = splitPath(path);
134
- const found = this.walk(this.root, segments, 0, {});
135
- return found ? Object.keys(found.handlers) : [];
186
+ const found = this.hasDynamicRoutes
187
+ ? this.walk(this.root, splitPath(path), 0, {})
188
+ : undefined;
189
+ if (!fromStatic)
190
+ return found ? Object.keys(found.handlers) : [];
191
+ const methods = Object.keys(fromStatic);
192
+ if (found) {
193
+ for (const method of Object.keys(found.handlers)) {
194
+ if (!Object.hasOwn(fromStatic, method))
195
+ methods.push(method);
196
+ }
197
+ }
198
+ this.staticMethods.set(trimTrailingSlashes(path), {
199
+ revision: this.revision,
200
+ methods,
201
+ });
202
+ return methods.slice();
136
203
  }
137
204
  walk(node, segs, i, params) {
138
205
  if (i === segs.length)
139
- return node;
206
+ return node.handlers ? node : undefined;
140
207
  const seg = segs[i];
141
208
  const staticNext = node.children.get(seg);
142
209
  if (staticNext) {
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:6545c557-5cea-599b-8286-d910b13cb482",
4
+ "serialNumber": "urn:uuid:ea6ea660-6d6a-5827-8fe5-9def13029bef",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-09-10T10:47:58.279Z",
7
+ "timestamp": "2026-09-13T02:28:52.478Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.3.3"
12
+ "version": "1.3.4"
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@1.3.3",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.3.4",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.3.3",
24
+ "version": "1.3.4",
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@1.3.3",
26
+ "purl": "pkg:npm/@daloyjs/core@1.3.4",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.3.3",
49
+ "tagId": "swidtag--daloyjs-core-1.3.4",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.3.3",
51
+ "version": "1.3.4",
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@1.3.3",
60
+ "ref": "pkg:npm/@daloyjs/core@1.3.4",
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-1.3.3",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.3-6545c557-5cea-599b-8286-d910b13cb482",
5
+ "name": "@daloyjs/core-1.3.4",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.3.4-ea6ea660-6d6a-5827-8fe5-9def13029bef",
7
7
  "creationInfo": {
8
- "created": "2026-09-10T10:47:58.279Z",
8
+ "created": "2026-09-13T02:28:52.478Z",
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": "1.3.3",
19
+ "versionInfo": "1.3.4",
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@1.3.3"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.3.4"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "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 \u2014 distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {