@marianmeres/simple-router 3.1.0 → 3.3.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/AGENTS.md +56 -15
- package/API.md +34 -7
- package/CLAUDE.md +3 -0
- package/README.md +50 -7
- package/dist/mod.d.ts +1 -1
- package/dist/route.d.ts +18 -2
- package/dist/route.js +65 -17
- package/dist/router.d.ts +12 -0
- package/dist/router.js +39 -8
- package/package.json +10 -2
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
|
|
|
6
6
|
|
|
7
7
|
```yaml
|
|
8
8
|
name: "@marianmeres/simple-router"
|
|
9
|
-
version: "
|
|
9
|
+
version: "3.2.0"
|
|
10
10
|
runtime: ["deno", "node"]
|
|
11
11
|
module_system: "ESM"
|
|
12
12
|
entry_point: "src/mod.ts"
|
|
@@ -38,11 +38,11 @@ key_features:
|
|
|
38
38
|
source_files:
|
|
39
39
|
- path: "src/mod.ts"
|
|
40
40
|
purpose: "Main entry point, re-exports public API"
|
|
41
|
-
exports: ["SimpleRoute", "SimpleRouter", "RouteConfig", "RouteParams", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
|
|
41
|
+
exports: ["SimpleRoute", "SimpleRouter", "RouteConfig", "RouteParams", "SimpleRouteOptions", "Logger", "RouteCallback", "RouterConfig", "RouterCurrent", "RouterOnOptions", "RouterOptions", "RouterSubscriber", "RouterUnsubscribe"]
|
|
42
42
|
|
|
43
43
|
- path: "src/route.ts"
|
|
44
44
|
purpose: "Low-level pattern parser"
|
|
45
|
-
exports: ["SimpleRoute", "RouteConfig", "RouteParams"]
|
|
45
|
+
exports: ["SimpleRoute", "RouteConfig", "RouteParams", "SimpleRouteOptions"]
|
|
46
46
|
class: "SimpleRoute"
|
|
47
47
|
|
|
48
48
|
- path: "src/router.ts"
|
|
@@ -52,12 +52,10 @@ source_files:
|
|
|
52
52
|
|
|
53
53
|
test_files:
|
|
54
54
|
- path: "tests/route.test.ts"
|
|
55
|
-
|
|
56
|
-
coverage: "Pattern matching, query params, errors"
|
|
55
|
+
coverage: "Pattern matching, query params, regex-with-parens constraints, strict mode, optional-position rules"
|
|
57
56
|
|
|
58
57
|
- path: "tests/router.test.ts"
|
|
59
|
-
|
|
60
|
-
coverage: "Router lifecycle, subscriptions, logger, edge cases"
|
|
58
|
+
coverage: "Router lifecycle, subscriptions, logger, strict propagation, specificity-shadowing warning"
|
|
61
59
|
|
|
62
60
|
build_files:
|
|
63
61
|
- path: "scripts/build-npm.ts"
|
|
@@ -102,7 +100,7 @@ constructor:
|
|
|
102
100
|
- name: "config"
|
|
103
101
|
type: "RouterConfig<T> | RouterOptions<T> | null"
|
|
104
102
|
optional: true
|
|
105
|
-
description: "Route config or options object with routes and
|
|
103
|
+
description: "Route config or options object with routes, logger, and strict"
|
|
106
104
|
|
|
107
105
|
methods:
|
|
108
106
|
- name: "on"
|
|
@@ -155,7 +153,11 @@ constructor:
|
|
|
155
153
|
- name: "route"
|
|
156
154
|
type: "string"
|
|
157
155
|
description: "Pattern string"
|
|
158
|
-
|
|
156
|
+
- name: "options"
|
|
157
|
+
type: "SimpleRouteOptions"
|
|
158
|
+
optional: true
|
|
159
|
+
description: "e.g. { strict: true } to disable empty-segment collapsing"
|
|
160
|
+
throws: "Error if pattern invalid (multiple spread segments, invalid regex, invalid param name, optional-followed-by-required)"
|
|
159
161
|
|
|
160
162
|
static_methods:
|
|
161
163
|
- name: "parseQueryString"
|
|
@@ -187,12 +189,13 @@ patterns:
|
|
|
187
189
|
syntax: "[name(regex)]"
|
|
188
190
|
example: "[id([0-9]+)]"
|
|
189
191
|
captures: "{ id: '123' }"
|
|
190
|
-
constraint: "
|
|
192
|
+
constraint: "name must match /^\\w+$/. Regex may contain parentheses (capturing/non-capturing groups, alternation) — balanced-paren splitting is used."
|
|
191
193
|
|
|
192
194
|
optional:
|
|
193
195
|
syntax: "[name]?"
|
|
194
196
|
example: "[id]?"
|
|
195
197
|
captures: "{} or { id: 'value' }"
|
|
198
|
+
constraint: "Only allowed in trailing position (or directly before wildcard). Optional-followed-by-required throws at construction."
|
|
196
199
|
|
|
197
200
|
spread:
|
|
198
201
|
syntax: "[...name]"
|
|
@@ -229,6 +232,7 @@ interface Logger {
|
|
|
229
232
|
interface RouterOptions<T = unknown> {
|
|
230
233
|
routes?: RouterConfig<T> | null;
|
|
231
234
|
logger?: Logger | null;
|
|
235
|
+
strict?: boolean; // propagates to every registered route
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
interface RouterCurrent {
|
|
@@ -240,6 +244,11 @@ interface RouterCurrent {
|
|
|
240
244
|
interface RouterOnOptions {
|
|
241
245
|
label?: string | null;
|
|
242
246
|
allowQueryParams?: boolean;
|
|
247
|
+
strict?: boolean; // overrides router-level default
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface SimpleRouteOptions {
|
|
251
|
+
strict?: boolean; // disables empty-segment collapsing
|
|
243
252
|
}
|
|
244
253
|
|
|
245
254
|
type RouterSubscriber = (current: RouterCurrent) => void;
|
|
@@ -298,11 +307,14 @@ const router = new SimpleRouter({
|
|
|
298
307
|
behaviors:
|
|
299
308
|
- name: "First Match Wins"
|
|
300
309
|
description: "Routes matched in registration order"
|
|
301
|
-
implication: "Register specific routes before generic ones"
|
|
310
|
+
implication: "Register specific routes before generic ones. When SimpleRouter.debug is true, shadowing is detected at registration and warned via the logger (or console.warn)."
|
|
302
311
|
|
|
303
312
|
- name: "Separator Normalization"
|
|
304
|
-
description: "
|
|
305
|
-
example: "//foo//bar// matches foo/bar"
|
|
313
|
+
description: "Non-strict (default): multiple separators are collapsed and trimmed from ends. Strict (opt-in): internal empty segments are preserved."
|
|
314
|
+
example: "non-strict: '//foo//bar//' matches 'foo/bar'. strict: '/a//b' does NOT match '/a/b'."
|
|
315
|
+
|
|
316
|
+
- name: "Optional Segment Position"
|
|
317
|
+
description: "Optional segments ('[x]?') may only appear trailing (or immediately before wildcard '*'). Optional-followed-by-required throws at construction — register separate routes instead."
|
|
306
318
|
|
|
307
319
|
- name: "Query String Priority"
|
|
308
320
|
description: "Path params override same-name query params"
|
|
@@ -312,6 +324,9 @@ behaviors:
|
|
|
312
324
|
|
|
313
325
|
- name: "Immediate Subscription Call"
|
|
314
326
|
description: "subscribe() calls callback immediately with current state"
|
|
327
|
+
|
|
328
|
+
- name: "Parameter Names Are Literal"
|
|
329
|
+
description: "Parameter names in the pattern are NOT URI-decoded. The pattern '[id%20x]' produces the key 'id%20x'. Parameter values ARE decoded."
|
|
315
330
|
```
|
|
316
331
|
|
|
317
332
|
## Error Conditions
|
|
@@ -330,6 +345,12 @@ errors:
|
|
|
330
345
|
- condition: "Invalid regex in pattern"
|
|
331
346
|
message: "Invalid regex in route pattern '{segment}': {error}"
|
|
332
347
|
|
|
348
|
+
- condition: "Invalid parameter name in named constraint"
|
|
349
|
+
message: "Invalid parameter name '{name}' in route pattern '{segment}' (must match /^\\w+$/)"
|
|
350
|
+
|
|
351
|
+
- condition: "Optional segment followed by a required segment"
|
|
352
|
+
message: "Invalid route '{route}': optional segment '{segment}?' must not be followed by a required segment ..."
|
|
353
|
+
|
|
333
354
|
- condition: "Non-function subscriber"
|
|
334
355
|
throws: "TypeError: Subscription is not a function"
|
|
335
356
|
```
|
|
@@ -352,7 +373,27 @@ guidelines:
|
|
|
352
373
|
- "First-match-wins is core behavior - do not change"
|
|
353
374
|
- "Query param parsing must remain toggleable per-route"
|
|
354
375
|
- "Keep catch-all ('*') handling separate from regular routes"
|
|
355
|
-
- "URL encoding/decoding
|
|
376
|
+
- "URL encoding/decoding: param VALUES are decoded; param NAMES are not"
|
|
356
377
|
- "Logger interface must remain compatible with @marianmeres/clog"
|
|
357
|
-
- "
|
|
378
|
+
- "Strict mode is opt-in and propagates router → route; per-route can override"
|
|
379
|
+
- "Shadowing warning runs only when SimpleRouter.debug is true (no perf cost otherwise)"
|
|
380
|
+
- "Run `deno task test` before any changes"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## Breaking Changes (3.2.0)
|
|
384
|
+
|
|
385
|
+
```yaml
|
|
386
|
+
breaking_changes:
|
|
387
|
+
- id: "optional-middle-rejected"
|
|
388
|
+
description: "Patterns like '/foo/[bar]?/baz' now throw at construction. Previously they parsed but silently treated the optional marker as required. Register two separate routes instead."
|
|
389
|
+
|
|
390
|
+
- id: "param-names-not-decoded"
|
|
391
|
+
description: "Parameter names are taken literally from the pattern. '/foo/[id%20x]' used to produce the key 'id x'; now produces 'id%20x'. Parameter values are still URI-decoded."
|
|
392
|
+
|
|
393
|
+
- id: "named-constraint-name-validated"
|
|
394
|
+
description: "In a named regex constraint '[name(regex)]', 'name' must match /^\\w+$/. Previously this was not validated."
|
|
395
|
+
|
|
396
|
+
bug_fixes:
|
|
397
|
+
- id: "regex-constraint-with-parens"
|
|
398
|
+
description: "Constraints containing parentheses (e.g. '[s((?:a|b)+)]', '[x((\\d+)-(\\d+))]') used to produce garbage regexes or throw obscure errors due to greedy regex-based splitting. Now uses balanced-paren splitting and works correctly."
|
|
358
399
|
```
|
package/API.md
CHANGED
|
@@ -136,6 +136,7 @@ Registers one or more route patterns with a callback function.
|
|
|
136
136
|
|------|------|---------|-------------|
|
|
137
137
|
| `label` | `string \| null` | `null` | Label for debugging/identification |
|
|
138
138
|
| `allowQueryParams` | `boolean` | `true` | Whether to parse query parameters |
|
|
139
|
+
| `strict` | `boolean` | _inherits from router_ | Strict segment matching (see `RouterOptions.strict`) |
|
|
139
140
|
|
|
140
141
|
**Example:**
|
|
141
142
|
|
|
@@ -164,6 +165,8 @@ router.on("*", () => NotFoundPage);
|
|
|
164
165
|
|
|
165
166
|
**Important:** Routes are matched in registration order. First match wins!
|
|
166
167
|
|
|
168
|
+
**Shadowing warning:** When `SimpleRouter.debug` is enabled, registering a route that is strictly more specific than an already-registered route emits a `warn`-level log message (via the provided `Logger` if any, otherwise `console.warn`). The check is skipped when `debug` is off, so there is no runtime cost in production.
|
|
169
|
+
|
|
167
170
|
---
|
|
168
171
|
|
|
169
172
|
#### `exec()`
|
|
@@ -297,7 +300,7 @@ Low-level route pattern parser. Use this directly only when you need pattern mat
|
|
|
297
300
|
### Constructor
|
|
298
301
|
|
|
299
302
|
```ts
|
|
300
|
-
new SimpleRoute(pattern: string)
|
|
303
|
+
new SimpleRoute(pattern: string, options?: SimpleRouteOptions)
|
|
301
304
|
```
|
|
302
305
|
|
|
303
306
|
Creates a new route pattern parser.
|
|
@@ -307,14 +310,18 @@ Creates a new route pattern parser.
|
|
|
307
310
|
| Name | Type | Description |
|
|
308
311
|
|------|------|-------------|
|
|
309
312
|
| `pattern` | `string` | Route pattern to match against |
|
|
313
|
+
| `options` | `SimpleRouteOptions` | Optional configuration (currently `{ strict?: boolean }`) |
|
|
310
314
|
|
|
311
|
-
**Throws:** `Error` if pattern is invalid
|
|
315
|
+
**Throws:** `Error` if pattern is invalid — multiple spread segments, invalid regex, invalid parameter name in a named constraint, or an optional segment followed by a required one.
|
|
312
316
|
|
|
313
317
|
**Example:**
|
|
314
318
|
|
|
315
319
|
```ts
|
|
316
320
|
const route = new SimpleRoute("/user/[id([0-9]+)]");
|
|
317
321
|
const route2 = new SimpleRoute("/files/[...path]");
|
|
322
|
+
|
|
323
|
+
// Strict mode — '/a//b' will NOT match '/a/b'
|
|
324
|
+
const strict = new SimpleRoute("/a/b", { strict: true });
|
|
318
325
|
```
|
|
319
326
|
|
|
320
327
|
<a id="simpleroute-static-properties"></a>
|
|
@@ -462,11 +469,22 @@ Current router state object.
|
|
|
462
469
|
interface RouterOnOptions {
|
|
463
470
|
label?: string | null; // Optional label for debugging
|
|
464
471
|
allowQueryParams?: boolean; // Whether to parse query params (default: true)
|
|
472
|
+
strict?: boolean; // Per-route strict mode (overrides router default)
|
|
465
473
|
}
|
|
466
474
|
```
|
|
467
475
|
|
|
468
476
|
Options for the `on()` method.
|
|
469
477
|
|
|
478
|
+
### SimpleRouteOptions
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
interface SimpleRouteOptions {
|
|
482
|
+
strict?: boolean; // Disable empty-segment collapsing (default: false)
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Options for the `SimpleRoute` constructor.
|
|
487
|
+
|
|
470
488
|
### Logger
|
|
471
489
|
|
|
472
490
|
```ts
|
|
@@ -486,6 +504,7 @@ Logger interface compatible with `@marianmeres/clog`. Provides console-compatibl
|
|
|
486
504
|
interface RouterOptions<T = unknown> {
|
|
487
505
|
routes?: RouterConfig<T> | null; // Route configuration
|
|
488
506
|
logger?: Logger | null; // Optional logger instance
|
|
507
|
+
strict?: boolean; // Strict mode; propagates to every route
|
|
489
508
|
}
|
|
490
509
|
```
|
|
491
510
|
|
|
@@ -494,6 +513,8 @@ Options object for the constructor (alternative to plain `RouterConfig`).
|
|
|
494
513
|
**Type Parameters:**
|
|
495
514
|
- `T` - The return type of route callbacks (default: `unknown`)
|
|
496
515
|
|
|
516
|
+
**`strict` mode:** when `true`, empty segments in both patterns and inputs (e.g. `/a//b`) are preserved rather than collapsed. This means `/a//b` will NOT match the canonical pattern `/a/b` — useful for URL canonicalization and preventing accidental shadow-matching in environments where concatenation bugs can produce doubled separators. Per-route `RouterOnOptions.strict` overrides this default.
|
|
517
|
+
|
|
497
518
|
### RouterSubscriber
|
|
498
519
|
|
|
499
520
|
```ts
|
|
@@ -541,11 +562,11 @@ Internal configuration for a parsed route segment. Exported for advanced use cas
|
|
|
541
562
|
|
|
542
563
|
### Key Behaviors
|
|
543
564
|
|
|
544
|
-
1. **Separator Normalization:** Multiple separators
|
|
545
|
-
2. **First Match Wins:** Routes matched in registration order
|
|
546
|
-
3. **Query String Parsing:** Enabled by default, can disable per-route
|
|
547
|
-
4. **Path Params Priority:** Path params override query params with same name
|
|
548
|
-
5. **URL Decoding:**
|
|
565
|
+
1. **Separator Normalization:** Multiple separators are collapsed in non-strict mode (default); trimmed from ends. Enable `strict: true` to preserve them.
|
|
566
|
+
2. **First Match Wins:** Routes matched in registration order. Enable `SimpleRouter.debug` for shadowing warnings at registration time.
|
|
567
|
+
3. **Query String Parsing:** Enabled by default, can disable per-route.
|
|
568
|
+
4. **Path Params Priority:** Path params override query params with the same name.
|
|
569
|
+
5. **URL Decoding:** Parameter _values_ are URL-decoded. Parameter _names_ are taken literally from the pattern (not decoded).
|
|
549
570
|
|
|
550
571
|
### Pattern Constraints
|
|
551
572
|
|
|
@@ -553,6 +574,12 @@ Internal configuration for a parsed route segment. Exported for advanced use cas
|
|
|
553
574
|
- Only one spread segment allowed per route
|
|
554
575
|
- Wildcard `*` must be the last segment
|
|
555
576
|
- Regex in `[name(regex)]` must be valid JavaScript regex
|
|
577
|
+
- In a named constraint `[name(regex)]`, `name` must match `/^\w+$/`
|
|
578
|
+
- An optional segment (`[x]?`) may not be followed by a required segment — optional segments are only allowed in trailing position (or directly before a wildcard `*`)
|
|
579
|
+
|
|
580
|
+
### Security (regex constraints)
|
|
581
|
+
|
|
582
|
+
Regex constraints are compiled as ordinary JavaScript regexes. Some patterns (notably nested quantifiers like `(a+)+`) can exhibit catastrophic backtracking on crafted inputs. Route patterns are author-controlled — **never** accept them from untrusted input. Prefer simple character classes (`[0-9]+`, `[a-z]+`) over nested quantifiers.
|
|
556
583
|
|
|
557
584
|
### Examples
|
|
558
585
|
|
package/CLAUDE.md
ADDED
package/README.md
CHANGED
|
@@ -77,7 +77,35 @@ window.onhashchange = () => {
|
|
|
77
77
|
|
|
78
78
|
### Separators
|
|
79
79
|
|
|
80
|
-
The default separator is `/`.
|
|
80
|
+
The default separator is `/`. By default (non-strict mode), multiple consecutive separators are collapsed into a single one, and separators are trimmed from both ends before matching. So `/foo//bar` and `/foo/bar/` both resolve to `/foo/bar`.
|
|
81
|
+
|
|
82
|
+
If you need to treat consecutive separators as significant (for example, to canonicalize URLs and prevent accidental shadow-matching), enable **strict mode**:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// At router level (propagates to every route):
|
|
86
|
+
const router = new SimpleRouter({ strict: true, routes: { "/a/b": handler } });
|
|
87
|
+
router.exec("/a/b"); // matches
|
|
88
|
+
router.exec("/a//b"); // false (no match)
|
|
89
|
+
|
|
90
|
+
// Per-route override:
|
|
91
|
+
router.on("/legacy", handler, { strict: true });
|
|
92
|
+
|
|
93
|
+
// Or directly on SimpleRoute:
|
|
94
|
+
new SimpleRoute("/a/b", { strict: true });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Optional segments
|
|
98
|
+
|
|
99
|
+
Optional segments (`[name]?`) are only allowed in **trailing position** (or directly before a trailing wildcard `*`). Optional-followed-by-required patterns are rejected at construction:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
new SimpleRoute("/foo/[bar]?/baz");
|
|
103
|
+
// Throws: optional segment '[bar]?' must not be followed by a required segment
|
|
104
|
+
|
|
105
|
+
// Instead, register two routes:
|
|
106
|
+
router.on("/foo/[bar]/baz", handler);
|
|
107
|
+
router.on("/foo/baz", handler);
|
|
108
|
+
```
|
|
81
109
|
|
|
82
110
|
## Pattern Examples
|
|
83
111
|
|
|
@@ -96,7 +124,6 @@ The default separator is `/`. Multiple separators are normalized to single, and
|
|
|
96
124
|
| `/foo/[bar]/[id([0-9]+)]` | `/foo/baz/123` | `{ bar: "baz", id: "123" }` |
|
|
97
125
|
| `/foo/[bar]?` | `/foo` | `{}` |
|
|
98
126
|
| `/foo/[bar]?` | `/foo/bar` | `{ bar: "bar" }` |
|
|
99
|
-
| `/foo/[bar]?/baz` | `/foo/bar/baz` | `{ bar: "bar" }` |
|
|
100
127
|
| `/[...path]/[file]` | `/foo/bar/baz.js` | `{ path: "foo/bar", file: "baz.js" }` |
|
|
101
128
|
| `/foo/*` | `/foo/bar/baz.js` | `{}` |
|
|
102
129
|
| `/[foo]/*` | `/foo/bar` | `{ foo: "foo" }` |
|
|
@@ -126,7 +153,7 @@ const router = new SimpleRouter({
|
|
|
126
153
|
});
|
|
127
154
|
```
|
|
128
155
|
|
|
129
|
-
- `config` - Either a `RouterConfig` object mapping route patterns to callbacks, or a `RouterOptions` object with `routes` and `
|
|
156
|
+
- `config` - Either a `RouterConfig` object mapping route patterns to callbacks, or a `RouterOptions` object with `routes`, `logger`, and `strict` properties
|
|
130
157
|
|
|
131
158
|
#### Methods
|
|
132
159
|
|
|
@@ -156,8 +183,9 @@ router.on("/raw", (params) => RawPage, { allowQueryParams: false });
|
|
|
156
183
|
**Options:**
|
|
157
184
|
- `label` - Optional label for debugging (visible via `info()`)
|
|
158
185
|
- `allowQueryParams` - Whether to parse query parameters (default: `true`)
|
|
186
|
+
- `strict` - Per-route strict matching (overrides router default)
|
|
159
187
|
|
|
160
|
-
**Important:** Routes are matched in registration order. First match wins!
|
|
188
|
+
**Important:** Routes are matched in registration order. First match wins! When `SimpleRouter.debug` is enabled, a warning is logged at registration time if a new route would be shadowed by an already-registered one.
|
|
161
189
|
|
|
162
190
|
##### `exec(url, fallbackFn?)`
|
|
163
191
|
|
|
@@ -287,13 +315,14 @@ const router2 = new SimpleRouter({
|
|
|
287
315
|
import type {
|
|
288
316
|
Logger,
|
|
289
317
|
RouteParams,
|
|
290
|
-
RouteCallback,
|
|
291
|
-
RouterConfig,
|
|
318
|
+
RouteCallback, // RouteCallback<T = unknown>
|
|
319
|
+
RouterConfig, // RouterConfig<T = unknown>
|
|
292
320
|
RouterCurrent,
|
|
293
321
|
RouterOnOptions,
|
|
294
|
-
RouterOptions,
|
|
322
|
+
RouterOptions, // RouterOptions<T = unknown>
|
|
295
323
|
RouterSubscriber,
|
|
296
324
|
RouterUnsubscribe,
|
|
325
|
+
SimpleRouteOptions, // { strict?: boolean }
|
|
297
326
|
} from "@marianmeres/simple-router";
|
|
298
327
|
```
|
|
299
328
|
|
|
@@ -394,5 +423,19 @@ dotRouter.exec("app.settings.profile");
|
|
|
394
423
|
// Returns: "Settings: profile"
|
|
395
424
|
```
|
|
396
425
|
|
|
426
|
+
## Security notes
|
|
427
|
+
|
|
428
|
+
Regex constraints inside a route pattern (e.g. `[id([0-9]+)]`) are compiled with `RegExp`, so any valid JavaScript regex is accepted. A handful of patterns — typically nested quantifiers such as `(a+)+` — can exhibit catastrophic backtracking (ReDoS) on crafted inputs.
|
|
429
|
+
|
|
430
|
+
- Route patterns are author-controlled: **never** accept them from untrusted input.
|
|
431
|
+
- Prefer simple character classes (`[0-9]+`, `[a-z]+`) over nested quantifiers.
|
|
432
|
+
- If you need a complex constraint, test it against adversarial inputs first.
|
|
433
|
+
|
|
434
|
+
## Breaking changes
|
|
435
|
+
|
|
436
|
+
### 3.2.0
|
|
397
437
|
|
|
438
|
+
- **Optional-followed-by-required is now rejected at construction.** Patterns like `/foo/[bar]?/baz` previously parsed but silently treated the optional marker as required — `/foo/baz` never matched. Such patterns now throw with a clear error. Register two separate routes instead (`/foo/[bar]/baz` and `/foo/baz`).
|
|
439
|
+
- **Parameter names are no longer URI-decoded.** A pattern like `/foo/[id%20x]` used to produce the key `id x` in the params object; it now produces `id%20x`. Param values are still decoded. Parameter names in a named constraint (`[name(regex)]`) must now match `\w+`.
|
|
440
|
+
- **Regex constraints with parentheses now work correctly.** Patterns like `/[id((?:a|b)+)]` previously produced garbage regexes or threw obscure "Invalid regular expression" errors; they now parse and match as expected.
|
|
398
441
|
|
package/dist/mod.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { SimpleRoute } from "./route.js";
|
|
2
2
|
export { SimpleRouter } from "./router.js";
|
|
3
|
-
export type { RouteConfig, RouteParams } from "./route.js";
|
|
3
|
+
export type { RouteConfig, RouteParams, SimpleRouteOptions } from "./route.js";
|
|
4
4
|
export type { Logger, RouteCallback, RouterConfig, RouterCurrent, RouterOnOptions, RouterOptions, RouterSubscriber, RouterUnsubscribe, } from "./router.js";
|
package/dist/route.d.ts
CHANGED
|
@@ -13,6 +13,17 @@ export interface RouteConfig {
|
|
|
13
13
|
* Route parameters extracted from URL segments and query strings.
|
|
14
14
|
*/
|
|
15
15
|
export type RouteParams = Record<string, any>;
|
|
16
|
+
/**
|
|
17
|
+
* Options for the `SimpleRoute` constructor.
|
|
18
|
+
*/
|
|
19
|
+
export interface SimpleRouteOptions {
|
|
20
|
+
/**
|
|
21
|
+
* When true, empty segments (caused by consecutive separators) are preserved
|
|
22
|
+
* rather than silently collapsed. In strict mode, inputs like `/a//b` will
|
|
23
|
+
* NOT match a pattern `/a/b`. Default: false.
|
|
24
|
+
*/
|
|
25
|
+
strict?: boolean;
|
|
26
|
+
}
|
|
16
27
|
export declare class SimpleRoute {
|
|
17
28
|
#private;
|
|
18
29
|
readonly route: string;
|
|
@@ -30,7 +41,9 @@ export declare class SimpleRoute {
|
|
|
30
41
|
* Creates a new SimpleRoute instance.
|
|
31
42
|
*
|
|
32
43
|
* @param route - Pattern string to match against (URL, file path, command, or any string identifier)
|
|
33
|
-
* @
|
|
44
|
+
* @param options - Optional configuration (e.g. `{ strict: true }` to disable empty-segment collapsing)
|
|
45
|
+
* @throws {Error} If route pattern is invalid (multiple spread segments, optional
|
|
46
|
+
* segment followed by a required one, invalid regex constraint, etc.)
|
|
34
47
|
*
|
|
35
48
|
* @example
|
|
36
49
|
* ```ts
|
|
@@ -44,9 +57,12 @@ export declare class SimpleRoute {
|
|
|
44
57
|
*
|
|
45
58
|
* // Command patterns
|
|
46
59
|
* new SimpleRoute("user:delete:[id]");
|
|
60
|
+
*
|
|
61
|
+
* // Strict mode: '/a//b' will NOT match '/a/b'
|
|
62
|
+
* new SimpleRoute("/a/b", { strict: true });
|
|
47
63
|
* ```
|
|
48
64
|
*/
|
|
49
|
-
constructor(route: string);
|
|
65
|
+
constructor(route: string, options?: SimpleRouteOptions);
|
|
50
66
|
/**
|
|
51
67
|
* Parses a query string into an object of key-value pairs.
|
|
52
68
|
* Handles URL decoding of both keys and values.
|
package/dist/route.js
CHANGED
|
@@ -11,11 +11,14 @@ export class SimpleRoute {
|
|
|
11
11
|
*/
|
|
12
12
|
static WILDCARD = "*";
|
|
13
13
|
#parsed;
|
|
14
|
+
#strict;
|
|
14
15
|
/**
|
|
15
16
|
* Creates a new SimpleRoute instance.
|
|
16
17
|
*
|
|
17
18
|
* @param route - Pattern string to match against (URL, file path, command, or any string identifier)
|
|
18
|
-
* @
|
|
19
|
+
* @param options - Optional configuration (e.g. `{ strict: true }` to disable empty-segment collapsing)
|
|
20
|
+
* @throws {Error} If route pattern is invalid (multiple spread segments, optional
|
|
21
|
+
* segment followed by a required one, invalid regex constraint, etc.)
|
|
19
22
|
*
|
|
20
23
|
* @example
|
|
21
24
|
* ```ts
|
|
@@ -29,31 +32,54 @@ export class SimpleRoute {
|
|
|
29
32
|
*
|
|
30
33
|
* // Command patterns
|
|
31
34
|
* new SimpleRoute("user:delete:[id]");
|
|
35
|
+
*
|
|
36
|
+
* // Strict mode: '/a//b' will NOT match '/a/b'
|
|
37
|
+
* new SimpleRoute("/a/b", { strict: true });
|
|
32
38
|
* ```
|
|
33
39
|
*/
|
|
34
|
-
constructor(route) {
|
|
40
|
+
constructor(route, options = {}) {
|
|
35
41
|
this.route = route;
|
|
36
|
-
this.#
|
|
42
|
+
this.#strict = options.strict === true;
|
|
43
|
+
this.#parsed = SimpleRoute.#parse(route, this.#strict);
|
|
37
44
|
}
|
|
38
45
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
|
|
39
46
|
static #escapeRegExp(str) {
|
|
40
47
|
return str.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
|
|
41
48
|
}
|
|
42
|
-
static #sanitizeAndSplit(str) {
|
|
49
|
+
static #sanitizeAndSplit(str, strict) {
|
|
43
50
|
const s = SimpleRoute.#escapeRegExp(SimpleRoute.SPLITTER);
|
|
44
|
-
|
|
51
|
+
let out = `${str}`
|
|
45
52
|
.trim()
|
|
46
53
|
// Trim splitters from left and right
|
|
47
54
|
.replace(new RegExp(`^(${s})+`), "")
|
|
48
55
|
.replace(new RegExp(`(${s})+$`), "")
|
|
49
|
-
.split(SimpleRoute.SPLITTER)
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
.split(SimpleRoute.SPLITTER);
|
|
57
|
+
// In non-strict mode, empty (internal) segments are silently collapsed.
|
|
58
|
+
// In strict mode, they are preserved and treated as literal empty segments
|
|
59
|
+
// (which generally will not match any parameterized pattern position).
|
|
60
|
+
if (!strict)
|
|
61
|
+
out = out.filter(Boolean);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Splits a bracketed definition like "name(regex)" into its two parts.
|
|
66
|
+
* Uses balanced-paren tracking so nested groups in the regex are preserved.
|
|
67
|
+
* Returns null if the input is not in the name(...) shape.
|
|
68
|
+
*/
|
|
69
|
+
static #splitNamedConstraint(inner) {
|
|
70
|
+
const open = inner.indexOf("(");
|
|
71
|
+
if (open <= 0)
|
|
72
|
+
return null;
|
|
73
|
+
if (!inner.endsWith(")"))
|
|
74
|
+
return null;
|
|
75
|
+
const name = inner.slice(0, open);
|
|
76
|
+
const regex = inner.slice(open + 1, -1);
|
|
77
|
+
return { name, regex };
|
|
52
78
|
}
|
|
53
|
-
static #parse(route) {
|
|
79
|
+
static #parse(route, strict) {
|
|
54
80
|
let wasSpread = false;
|
|
55
81
|
let wasWildcard = false;
|
|
56
|
-
|
|
82
|
+
const parsed = SimpleRoute.#sanitizeAndSplit(route, strict).reduce((memo, segment, idx, all) => {
|
|
57
83
|
let name = null;
|
|
58
84
|
// If optional, remove trailing '?' marker
|
|
59
85
|
let isOptional = segment.endsWith("?");
|
|
@@ -84,12 +110,16 @@ export class SimpleRoute {
|
|
|
84
110
|
if (m) {
|
|
85
111
|
name = m[1];
|
|
86
112
|
test = /.+/;
|
|
87
|
-
// id([0-9]+)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
// Named constraint: [id([0-9]+)], [s((?:a|b)+)], ...
|
|
114
|
+
// Split on first '(' (balanced-paren-safe).
|
|
115
|
+
const split = SimpleRoute.#splitNamedConstraint(m[1]);
|
|
116
|
+
if (split) {
|
|
117
|
+
if (!/^\w+$/.test(split.name)) {
|
|
118
|
+
throw new Error(`Invalid parameter name '${split.name}' in route pattern '${segment}' (must match /^\\w+$/)`);
|
|
119
|
+
}
|
|
120
|
+
name = split.name;
|
|
91
121
|
try {
|
|
92
|
-
test = new RegExp("^" +
|
|
122
|
+
test = new RegExp("^" + split.regex + "$");
|
|
93
123
|
}
|
|
94
124
|
catch (e) {
|
|
95
125
|
throw new Error(`Invalid regex in route pattern '${segment}': ${e.message}`);
|
|
@@ -104,6 +134,24 @@ export class SimpleRoute {
|
|
|
104
134
|
memo.push({ segment, name, test, isOptional, isSpread });
|
|
105
135
|
return memo;
|
|
106
136
|
}, []);
|
|
137
|
+
// An optional segment may only appear in trailing position (or directly
|
|
138
|
+
// before a wildcard, which itself is optional). Otherwise the 'optional'
|
|
139
|
+
// marker is misleading — it can never actually be skipped.
|
|
140
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
141
|
+
const p = parsed[i];
|
|
142
|
+
if (!p.isOptional)
|
|
143
|
+
continue;
|
|
144
|
+
// segment is optional — all following segments must also be optional
|
|
145
|
+
for (let j = i + 1; j < parsed.length; j++) {
|
|
146
|
+
if (!parsed[j].isOptional) {
|
|
147
|
+
throw new Error(`Invalid route '${route}': optional segment '${p.segment}?' must not be followed by a required segment ('${parsed[j].segment}'). ` +
|
|
148
|
+
`Optional segments are only allowed in trailing position. ` +
|
|
149
|
+
`Register separate routes instead.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
107
155
|
}
|
|
108
156
|
/**
|
|
109
157
|
* Parses a query string into an object of key-value pairs.
|
|
@@ -186,7 +234,7 @@ export class SimpleRoute {
|
|
|
186
234
|
url = _backup.slice(0, qPos);
|
|
187
235
|
matched = SimpleRoute.parseQueryString(_backup.slice(qPos + 1));
|
|
188
236
|
}
|
|
189
|
-
let segments = SimpleRoute.#sanitizeAndSplit(url);
|
|
237
|
+
let segments = SimpleRoute.#sanitizeAndSplit(url, this.#strict);
|
|
190
238
|
// If there are spread definitions, we need to adjust input by grouping
|
|
191
239
|
// (joining) segments that were initially split
|
|
192
240
|
const hasSpread = this.#parsed.some((v) => v.isSpread);
|
|
@@ -238,7 +286,7 @@ export class SimpleRoute {
|
|
|
238
286
|
}
|
|
239
287
|
if (p.name) {
|
|
240
288
|
try {
|
|
241
|
-
matched[
|
|
289
|
+
matched[p.name] = decodeURIComponent(s);
|
|
242
290
|
}
|
|
243
291
|
catch {
|
|
244
292
|
// Fallback for malformed URI components
|
package/dist/router.d.ts
CHANGED
|
@@ -46,6 +46,11 @@ export interface RouterOnOptions {
|
|
|
46
46
|
label?: string | null;
|
|
47
47
|
/** Whether to parse query parameters (default: true) */
|
|
48
48
|
allowQueryParams?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Strict matching mode for this route (overrides router-level default).
|
|
51
|
+
* See `RouterOptions.strict`.
|
|
52
|
+
*/
|
|
53
|
+
strict?: boolean;
|
|
49
54
|
}
|
|
50
55
|
/**
|
|
51
56
|
* Configuration object for initializing the router with routes.
|
|
@@ -87,6 +92,13 @@ export interface RouterOptions<T = unknown> {
|
|
|
87
92
|
routes?: RouterConfig<T> | null;
|
|
88
93
|
/** Optional logger instance for debug output (compatible with @marianmeres/clog) */
|
|
89
94
|
logger?: Logger | null;
|
|
95
|
+
/**
|
|
96
|
+
* Strict matching mode. When true, empty segments in inputs (caused by
|
|
97
|
+
* consecutive separators like `/a//b`) are NOT silently collapsed — such
|
|
98
|
+
* inputs will not match the canonical pattern `/a/b`. Propagates to every
|
|
99
|
+
* route registered via this router. Default: false.
|
|
100
|
+
*/
|
|
101
|
+
strict?: boolean;
|
|
90
102
|
}
|
|
91
103
|
/**
|
|
92
104
|
* Unsubscribe function returned by the `subscribe()` method.
|
package/dist/router.js
CHANGED
|
@@ -42,6 +42,7 @@ export class SimpleRouter {
|
|
|
42
42
|
static debug = false;
|
|
43
43
|
#routes = [];
|
|
44
44
|
#catchAll = null;
|
|
45
|
+
#strict = false;
|
|
45
46
|
#current = {
|
|
46
47
|
route: null,
|
|
47
48
|
params: null,
|
|
@@ -76,11 +77,12 @@ export class SimpleRouter {
|
|
|
76
77
|
constructor(config = null) {
|
|
77
78
|
let routes = null;
|
|
78
79
|
if (config) {
|
|
79
|
-
// Check if config is RouterOptions (has 'routes' or '
|
|
80
|
-
if ("routes" in config || "logger" in config) {
|
|
80
|
+
// Check if config is RouterOptions (has 'routes', 'logger' or 'strict' key)
|
|
81
|
+
if ("routes" in config || "logger" in config || "strict" in config) {
|
|
81
82
|
const options = config;
|
|
82
83
|
routes = options.routes ?? null;
|
|
83
84
|
this.#logger = options.logger ?? null;
|
|
85
|
+
this.#strict = options.strict === true;
|
|
84
86
|
}
|
|
85
87
|
else {
|
|
86
88
|
// Backwards compatible: treat as RouterConfig
|
|
@@ -180,6 +182,7 @@ export class SimpleRouter {
|
|
|
180
182
|
*/
|
|
181
183
|
on(routes, cb, options = {}) {
|
|
182
184
|
const { label = null, allowQueryParams = true } = options;
|
|
185
|
+
const strict = options.strict ?? this.#strict;
|
|
183
186
|
if (!Array.isArray(routes))
|
|
184
187
|
routes = [routes];
|
|
185
188
|
routes.forEach((route) => {
|
|
@@ -187,15 +190,43 @@ export class SimpleRouter {
|
|
|
187
190
|
this.#catchAll = cb;
|
|
188
191
|
}
|
|
189
192
|
else {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
const sr = new SimpleRoute(route, { strict });
|
|
194
|
+
// Dev-only shadowing warning: if an earlier route would consume the
|
|
195
|
+
// literal form of this one, the new route is unreachable. Only runs
|
|
196
|
+
// when debug logging is enabled to avoid per-registration overhead
|
|
197
|
+
// in production.
|
|
198
|
+
if (SimpleRouter.debug)
|
|
199
|
+
this.#warnIfShadowed(route);
|
|
200
|
+
this.#routes.push([sr, cb, allowQueryParams, label]);
|
|
196
201
|
}
|
|
197
202
|
});
|
|
198
203
|
}
|
|
204
|
+
#warnIfShadowed(route) {
|
|
205
|
+
for (const [existing] of this.#routes) {
|
|
206
|
+
if (existing.route === route)
|
|
207
|
+
continue;
|
|
208
|
+
// If the existing pattern matches the literal text of the new route,
|
|
209
|
+
// any real input intended for the new route will also match the
|
|
210
|
+
// existing one first (first-match-wins).
|
|
211
|
+
let matched = null;
|
|
212
|
+
try {
|
|
213
|
+
matched = existing.parse(route);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// ignore — some patterns may not be parseable as inputs
|
|
217
|
+
}
|
|
218
|
+
if (matched !== null) {
|
|
219
|
+
const msg = `[SimpleRouter] route '${route}' is shadowed by earlier ` +
|
|
220
|
+
`route '${existing.route}' (first-match-wins). ` +
|
|
221
|
+
`Register the more specific route first.`;
|
|
222
|
+
if (this.#logger)
|
|
223
|
+
this.#logger.warn(msg);
|
|
224
|
+
else
|
|
225
|
+
console.warn(msg);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
199
230
|
/**
|
|
200
231
|
* Executes pattern matching against the provided string.
|
|
201
232
|
* Returns the value returned by the matched route's callback (type `T`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/simple-router",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
"import": "./dist/mod.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"API.md",
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"CLAUDE.md"
|
|
20
|
+
],
|
|
13
21
|
"author": "Marian Meres",
|
|
14
22
|
"license": "MIT",
|
|
15
23
|
"dependencies": {
|
|
16
|
-
"@marianmeres/pubsub": "^
|
|
24
|
+
"@marianmeres/pubsub": "^3.0.0"
|
|
17
25
|
},
|
|
18
26
|
"repository": {
|
|
19
27
|
"type": "git",
|