@orkestrel/router 0.0.12 → 0.0.14

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
@@ -1,9 +1,14 @@
1
1
  # @orkestrel/router
2
2
 
3
- A typed request router for the `@orkestrel` line the first `@orkestrel`
4
- package to ship both server and browser environments alongside its shared
5
- core. Built to sit beside `@orkestrel/contract` (validation) and
6
- `@orkestrel/emitter` (observable lifecycle), reusing both as it takes shape.
3
+ > The typed request router: a path-matching engine (`Router`) that compiles route patterns,
4
+ > extracts URL-decoded params, and resolves the most specific match, with a fetch-standard,
5
+ > method-dimensioned dispatcher (`Dispatcher`), a headless History or hash `Navigator`, and a
6
+ > `node:http` adapter all composing that same engine.
7
+
8
+ Register routes on a `Router`, or hand them to a `Dispatcher` where they are
9
+ method-dimensioned; reach for `createNavigator` in the browser and `createListener` behind
10
+ `node:http`. Part of the `@orkestrel` line, built on `@orkestrel/contract` for validation,
11
+ `@orkestrel/emitter` for the observable surface, and `@orkestrel/abort` for cancellation.
7
12
 
8
13
  ## Install
9
14
 
@@ -13,8 +18,8 @@ npm install @orkestrel/router
13
18
 
14
19
  ## Requirements
15
20
 
16
- - Node.js >= 24
17
- - ESM-only (no CommonJS build)
21
+ - Node.js >= 22.12.0
22
+ - ESM and CommonJS for the core and `./server` entries; the `./browser` entry is ESM only.
18
23
  - Server and browser environments both supported
19
24
 
20
25
  ## Usage
@@ -38,23 +43,21 @@ const dispatcher = createDispatcher<{ readonly userId: string }>({
38
43
  const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
39
44
  ```
40
45
 
41
- `Router` is the shared registry-and-match engine literal-over-param-over-wildcard
42
- precedence, trailing-slash folding, and tolerant percent-decoding that both `Dispatcher`
43
- (fetch-standard, method-dimensioned) and the browser `Navigator` compose. Path params are
44
- inferred at the type level from the literal pattern via `PathParams`, and `route()` pins a
45
- `RouteInput`'s path so literal inference survives across call sites. The `./browser` entry
46
- adds `createNavigator` for headless History/hash navigation; the `./server` entry adds
47
- `buildRequest` / `sendResponse` / `createListener` for `node:http`.
46
+ Path params are inferred at the type level from the literal pattern through the
47
+ `PathParams` type, and the `defineRoute` function pins a `RouteInput`'s path so literal
48
+ inference survives across call sites. The `./browser` entry adds the `createNavigator`
49
+ function for headless History or hash navigation; the `./server` entry adds the
50
+ `buildRequest`, `sendResponse`, and `createListener` functions for `node:http`.
48
51
 
49
52
  ## Guide
50
53
 
51
54
  For the full surface — the core `Router`, the `Dispatcher`, the browser `Navigator`, and the
52
55
  `node:http` server adapter — see
53
- [`guides/src/router.md`](guides/src/router.md).
56
+ [`guides/router.md`](guides/router.md).
54
57
 
55
58
  ## Package
56
59
 
57
- Published as three environment-scoped entry points per the `exports` field in
60
+ Published as environment-scoped entry points per the `exports` field in
58
61
  `package.json`: a shared core, `./browser`, and `./server`.
59
62
 
60
63
  ## License
@@ -1,33 +1,33 @@
1
- import { EmitterErrorHandler } from '@orkestrel/emitter';
2
- import { EmitterHooks } from '@orkestrel/emitter';
3
- import { EmitterInterface } from '@orkestrel/emitter';
4
- import { RouteEntry } from '@orkestrel/router';
5
- import { RouterInterface } from '@orkestrel/router';
6
- import { RouterMatch } from '@orkestrel/router';
1
+ import type { EmitterErrorHandler } from '@orkestrel/emitter';
2
+ import type { EmitterHooks } from '@orkestrel/emitter';
3
+ import type { EmitterInterface } from '@orkestrel/emitter';
4
+ import type { RouteEntry } from '@orkestrel/router';
5
+ import type { RouterInterface } from '@orkestrel/router';
6
+ import type { RouterMatch } from '@orkestrel/router';
7
7
 
8
8
  /**
9
- * Compute the registry key for a browser navigation route.
9
+ * Computes the canonical path key a `Navigator` registers a browser navigation
10
+ * route under.
10
11
  *
11
12
  * @remarks
12
- * Projects the nested route's path through the core engine's canonical
13
- * trailing-slash identity, so `/users` and `/users/` replace one another in
14
- * the Navigator's shared Router.
13
+ * Projects the route's path through the core engine's canonical trailing-slash
14
+ * identity, so `/users` and `/users/` replace one another in the Navigator's
15
+ * shared Router. The entry's `meta` payload is never read, so any payload type
16
+ * is accepted.
15
17
  *
16
- * @param entry - The outer Router entry carrying the Navigator route
17
- * @returns The nested route's canonical path
18
+ * @param entry - The Router entry carrying the Navigator route
19
+ * @returns The route's canonical path
18
20
  *
19
21
  * @example
20
22
  * ```ts
21
- * computeNavigationKey({ path: '/users/', meta: { path: '/users/' } }) // '/users'
23
+ * computeNavigationKey({ path: '/users/', meta: {} }) // '/users'
22
24
  * ```
23
25
  */
24
- export declare function computeNavigationKey(entry: RouteEntry<{
25
- readonly path: string;
26
- }>): string;
26
+ export declare function computeNavigationKey(entry: RouteEntry<unknown>): string;
27
27
 
28
28
  /**
29
- * Create a {@link NavigatorInterface} — the headless History/hash navigation
30
- * entity composing one core `Router<RouteEntry<Meta>>`.
29
+ * Creates a {@link NavigatorInterface} — the headless History/hash navigation
30
+ * entity composing one core `Router<Meta>`.
31
31
  *
32
32
  * @remarks
33
33
  * Prefer this over `new Navigator(...)` at call sites that only need the
@@ -37,7 +37,7 @@ export declare function computeNavigationKey(entry: RouteEntry<{
37
37
  * @param options - The `routes` to register, the `history` toggle (default
38
38
  * `false`, hash mode), an optional `base` (history mode), an optional
39
39
  * `fallback` path, an optional `guard` hook, opt-in link `intercept`
40
- * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
40
+ * (history mode), the `sensitive` case toggle, and the Emitter pattern's
41
41
  * `on`/`error` wiring
42
42
  * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
43
43
  * dispatching
@@ -59,17 +59,17 @@ export declare function computeNavigationKey(entry: RouteEntry<{
59
59
  export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta>;
60
60
 
61
61
  /**
62
- * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
62
+ * Extracts the `/`-prefixed pathname from a `location.hash` value — strips the
63
63
  * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
64
64
  *
65
65
  * @remarks
66
- * The grammar this package matches everywhere is `/`-prefixed (§4 path
67
- * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
66
+ * The grammar this package matches everywhere is `/`-prefixed, so a hash-mode
67
+ * location's `'#/users/7?x'` becomes `'/users/7'`
68
68
  * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
69
69
  * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
70
70
  * back). Total — never throws.
71
71
  *
72
- * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
72
+ * @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)
73
73
  * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
74
74
  *
75
75
  * @example
@@ -83,7 +83,7 @@ export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>):
83
83
  export declare function extractHashPath(hash: string): string;
84
84
 
85
85
  /**
86
- * Find the nearest enclosing `<a>` element a DOM event originated from, by
86
+ * Finds the nearest enclosing `<a>` element a DOM event originated from, by
87
87
  * walking its composed path — the pure lookup behind history-mode link
88
88
  * interception.
89
89
  *
@@ -107,10 +107,10 @@ export declare function extractHashPath(hash: string): string;
107
107
  export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
108
108
 
109
109
  /**
110
- * The headless History/hash navigation entity — composes one core
111
- * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
110
+ * Represents the headless History/hash navigation entity — composes one core
111
+ * `Router<Meta>`, resolving the current location on `start()` and
112
112
  * every subsequent navigation event, tracking `active`, and emitting
113
- * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
113
+ * `navigate` through the core {@link Emitter}. No `render` /
114
114
  * `outlet` — the consumer owns rendering.
115
115
  *
116
116
  * @typeParam Meta - The opaque per-route payload a match carries back
@@ -120,14 +120,13 @@ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
120
120
  * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
121
121
  * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
122
122
  * over-param precedence, trailing-slash insensitivity, and
123
- * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
124
- * §21).
123
+ * `:param`/`*wildcard` extraction all come from that one shared engine.
125
124
  * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
126
125
  * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
127
126
  * `fallback` through the SAME engine → a fallback that ALSO matches nothing
128
127
  * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
129
- * newer navigation) and leaves `active` `undefined`, emitting nothing
130
- * (§21-honest: no phantom match is fabricated) → the optional `guard` may
128
+ * newer navigation) and leaves `active` `undefined`, emitting nothing
129
+ * honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may
131
130
  * veto → on a verdict, `active` is set and `navigate` emitted.
132
131
  * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
133
132
  * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
@@ -156,7 +155,7 @@ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
156
155
  declare class Navigator_2<Meta> implements NavigatorInterface<Meta> {
157
156
  #private;
158
157
  constructor(options: NavigatorOptions<Meta>);
159
- get router(): RouterInterface<RouteEntry<Meta>>;
158
+ get router(): RouterInterface<Meta>;
160
159
  get emitter(): EmitterInterface<NavigatorEventMap<Meta>>;
161
160
  get active(): RouterMatch<Meta> | undefined;
162
161
  start(): void;
@@ -168,7 +167,7 @@ declare class Navigator_2<Meta> implements NavigatorInterface<Meta> {
168
167
  export { Navigator_2 as Navigator }
169
168
 
170
169
  /**
171
- * The `Navigator`'s event map (AGENTS §13) — the single `navigate` signal a
170
+ * Represents the `Navigator`'s event map — the single `navigate` signal a
172
171
  * consumer observes.
173
172
  *
174
173
  * @typeParam Meta - The opaque per-route payload the resolved match carries
@@ -177,53 +176,70 @@ export { Navigator_2 as Navigator }
177
176
  * `navigate` fires once per successful resolution (start, hashchange/popstate,
178
177
  * `navigate()`, link interception) — never for a vetoed or superseded navigation
179
178
  * ({@link NavigatorOptions.guard}), and never when a miss's fallback also
180
- * misses (§21-honest: `active` is left `undefined`, nothing emitted).
179
+ * misses — honest to the one-shared-engine rule: `active` is left `undefined`, nothing emitted.
181
180
  */
182
181
  export declare type NavigatorEventMap<Meta> = {
183
182
  readonly navigate: readonly [match: RouterMatch<Meta>];
184
183
  };
185
184
 
186
185
  /**
187
- * The headless History/hash navigation entity contract (the §4.5 behavioral-
188
- * interface role for the one-class-per-file `Navigator`). Composes a core
189
- * `Router<RouteEntry<Meta>>`, resolves the current location on `start()` and
190
- * on every subsequent navigation event, tracks `active`, and emits
191
- * `navigate` through the AGENTS §13 {@link EmitterInterface}.
186
+ * Represents the headless History/hash navigation entity contract (the
187
+ * behavioral-interface role for the one-class-per-file `Navigator`). Composes a core
188
+ * `Router<Meta>`, resolves the current location on `start()` and on every subsequent
189
+ * navigation event, tracks `active`, and emits `navigate` through the
190
+ * {@link EmitterInterface}.
192
191
  *
193
192
  * @typeParam Meta - The opaque per-route payload a match carries back
194
193
  *
195
194
  * @remarks
196
- * - `router` the underlying registry, exposed READONLY for introspection
197
- * (the same object routes were registered on).
198
- * - `emitter` — the AGENTS §13 observable surface for {@link NavigatorEventMap}.
199
- * - `active` — the currently-resolved {@link RouterMatch}, or `undefined`
200
- * before the first resolve (or when a miss's fallback also misses).
201
- * - `start()` — begin listening (`hashchange` in hash mode; `popstate` +
202
- * optional link interception in history mode) and resolve the current
203
- * location now. Idempotent — a second call is a no-op.
204
- * - `stop()` — stop listening. Idempotent.
205
- * - `navigate(path)` — navigate programmatically: sets `location.hash` (hash
206
- * mode) or calls `history.pushState` (history mode), then resolves. A
207
- * no-op hash navigation (already the active hash) resolves directly, since
208
- * no `hashchange` would otherwise fire.
209
- * - `match(path)` — a PURE lookup through the underlying `Router`: no
210
- * location read, no fallback, no guard, no emit.
211
- * - `destroy()` — `stop()` plus tear down the `#emitter` (AGENTS §13).
195
+ * Rendering stays outside this contract: a consumer subscribes to `navigate` and
196
+ * renders whatever the resolved match names.
212
197
  */
213
198
  export declare interface NavigatorInterface<Meta> {
214
- readonly router: RouterInterface<RouteEntry<Meta>>;
199
+ /**
200
+ * Holds the underlying registry, exposed readonly for introspection — the same
201
+ * object the routes were registered on.
202
+ */
203
+ readonly router: RouterInterface<Meta>;
204
+ /** Holds the observable surface for {@link NavigatorEventMap}. */
215
205
  readonly emitter: EmitterInterface<NavigatorEventMap<Meta>>;
206
+ /**
207
+ * Holds the resolved {@link RouterMatch}, or `undefined` before the first resolve
208
+ * and whenever a miss's fallback also misses.
209
+ */
216
210
  readonly active: RouterMatch<Meta> | undefined;
211
+ /**
212
+ * Begins listening and resolves the current location — idempotent, so a second call
213
+ * is a no-op.
214
+ *
215
+ * @remarks
216
+ * Hash mode binds `hashchange`; history mode binds `popstate` and, where `intercept`
217
+ * is set, same-origin link interception.
218
+ */
217
219
  start(): void;
220
+ /** Stops listening and aborts any pending guard — idempotent. */
218
221
  stop(): void;
222
+ /**
223
+ * Navigates programmatically — sets `location.hash` in hash mode or calls
224
+ * `history.pushState` in history mode, then resolves.
225
+ *
226
+ * @remarks
227
+ * A hash navigation to the active hash resolves directly, because no `hashchange`
228
+ * would otherwise fire.
229
+ */
219
230
  navigate(path: string): void;
231
+ /**
232
+ * Looks one path up through the underlying `Router` — a pure lookup with no location
233
+ * read, no fallback, no guard, and no emit.
234
+ */
220
235
  match(path: string): RouterMatch<Meta> | undefined;
236
+ /** Stops listening and tears down the emitter. */
221
237
  destroy(): void;
222
238
  }
223
239
 
224
240
  /**
225
- * Options for `createNavigator` — the `routes` to dispatch between, the
226
- * navigation substrate, the optional guard hook, and the AGENTS §13 emitter
241
+ * Represents the options for `createNavigator` — the `routes` to dispatch between, the
242
+ * navigation substrate, the optional guard hook, and the Emitter pattern's
227
243
  * wiring.
228
244
  *
229
245
  * @typeParam Meta - The opaque payload each route may carry
@@ -240,20 +256,21 @@ export declare interface NavigatorInterface<Meta> {
240
256
  * - `fallback` — the route PATTERN to resolve when the current location
241
257
  * matches NOTHING. Omitted ⇒ the first route's path. A `fallback` that
242
258
  * itself matches no registered route leaves `active` `undefined` and emits
243
- * nothing (§21-honest: no phantom match is fabricated).
259
+ * nothing — honest to the one-shared-engine rule: no phantom match is fabricated.
244
260
  * - `guard` — `(to, from, signal) => boolean | Promise<boolean>`, called
245
261
  * before a navigation commits; a `false`/rejected verdict, or one arriving
246
262
  * after the navigation was SUPERSEDED (`signal.aborted`), is discarded —
247
263
  * `active` stays unchanged and nothing is emitted. `signal` fires when a
248
264
  * NEWER navigation starts (or on `stop`/`destroy`), so a slow async guard
249
265
  * can cancel its own work off it. A throw routes to the `error` handler
250
- * below and vetoes the navigation.
266
+ * described later in this list and vetoes the navigation.
251
267
  * - `intercept` — opt-in same-origin `<a>` click interception (history mode
252
268
  * only): a plain left-click on a same-origin link with no modifier keys,
253
269
  * no `target`, and no `download` attribute is intercepted into `navigate`.
254
270
  * - `sensitive` — forwarded to the underlying `Router` (default `true`).
255
- * - `on` — initial `NavigatorEventMap` listeners (AGENTS §8/§13).
256
- * - `error` — the emitter's listener-error handler (AGENTS §13); ALSO the
271
+ * - `on` — initial `NavigatorEventMap` listeners (the Emitter pattern's
272
+ * similar-surface pin).
273
+ * - `error` — the emitter's listener-error handler; ALSO the
257
274
  * handler a thrown {@link guard} routes to (the Navigator's own pipeline,
258
275
  * not a listener throw, so it is surfaced through the same channel).
259
276
  */
@@ -270,7 +287,7 @@ export declare interface NavigatorOptions<Meta> {
270
287
  }
271
288
 
272
289
  /**
273
- * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
290
+ * Resolves the `/`-prefixed pathname to match for the current location, in
274
291
  * either navigation mode — the one seam `extractHashPath` (hash mode) and
275
292
  * history-mode base-stripping share.
276
293
  *
@@ -280,12 +297,12 @@ export declare interface NavigatorOptions<Meta> {
280
297
  * `location.pathname` and strips a leading `base` prefix when one is
281
298
  * configured: `base` itself maps to the root `'/'`; a pathname that is not
282
299
  * under `base` is returned unchanged (a base mismatch is not this helper's
283
- * concern — the `Navigator`'s match then simply misses). Total — never throws.
300
+ * concern — the `Navigator`'s match then misses). Total — never throws.
284
301
  *
285
302
  * @param location - The `hash` + `pathname` pair to resolve from (accepts a
286
303
  * real `Location` or any object shaped the same, for pure unit testing)
287
- * @param history - The navigation substrate: `false` for hash mode, `true`
288
- * for history mode
304
+ * @param history - If `true`, the pathname is read from `location.pathname`
305
+ * with `base` stripped; if `false`, it is read from `location.hash`
289
306
  * @param base - The history-mode path prefix to strip (ignored in hash mode;
290
307
  * omit for no prefix)
291
308
  * @returns The `/`-prefixed pathname to match
@@ -1,39 +1,41 @@
1
1
  import { canonicalizePath, createRouter, joinPaths } from "../core/index.js";
2
2
  import { createAbort } from "@orkestrel/abort";
3
3
  import { Emitter } from "@orkestrel/emitter";
4
- import { isFunction, isString } from "@orkestrel/contract";
4
+ import { ContractError, isFunction, isString, preview } from "@orkestrel/contract";
5
5
  //#region src/browser/helpers.ts
6
6
  /**
7
- * Compute the registry key for a browser navigation route.
7
+ * Computes the canonical path key a `Navigator` registers a browser navigation
8
+ * route under.
8
9
  *
9
10
  * @remarks
10
- * Projects the nested route's path through the core engine's canonical
11
- * trailing-slash identity, so `/users` and `/users/` replace one another in
12
- * the Navigator's shared Router.
11
+ * Projects the route's path through the core engine's canonical trailing-slash
12
+ * identity, so `/users` and `/users/` replace one another in the Navigator's
13
+ * shared Router. The entry's `meta` payload is never read, so any payload type
14
+ * is accepted.
13
15
  *
14
- * @param entry - The outer Router entry carrying the Navigator route
15
- * @returns The nested route's canonical path
16
+ * @param entry - The Router entry carrying the Navigator route
17
+ * @returns The route's canonical path
16
18
  *
17
19
  * @example
18
20
  * ```ts
19
- * computeNavigationKey({ path: '/users/', meta: { path: '/users/' } }) // '/users'
21
+ * computeNavigationKey({ path: '/users/', meta: {} }) // '/users'
20
22
  * ```
21
23
  */
22
24
  function computeNavigationKey(entry) {
23
- return canonicalizePath(entry.meta.path);
25
+ return canonicalizePath(entry.path);
24
26
  }
25
27
  /**
26
- * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
28
+ * Extracts the `/`-prefixed pathname from a `location.hash` value — strips the
27
29
  * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
28
30
  *
29
31
  * @remarks
30
- * The grammar this package matches everywhere is `/`-prefixed (§4 path
31
- * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
32
+ * The grammar this package matches everywhere is `/`-prefixed, so a hash-mode
33
+ * location's `'#/users/7?x'` becomes `'/users/7'`
32
34
  * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
33
35
  * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
34
36
  * back). Total — never throws.
35
37
  *
36
- * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
38
+ * @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)
37
39
  * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
38
40
  *
39
41
  * @example
@@ -51,7 +53,7 @@ function extractHashPath(hash) {
51
53
  return queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
52
54
  }
53
55
  /**
54
- * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
56
+ * Resolves the `/`-prefixed pathname to match for the current location, in
55
57
  * either navigation mode — the one seam `extractHashPath` (hash mode) and
56
58
  * history-mode base-stripping share.
57
59
  *
@@ -61,12 +63,12 @@ function extractHashPath(hash) {
61
63
  * `location.pathname` and strips a leading `base` prefix when one is
62
64
  * configured: `base` itself maps to the root `'/'`; a pathname that is not
63
65
  * under `base` is returned unchanged (a base mismatch is not this helper's
64
- * concern — the `Navigator`'s match then simply misses). Total — never throws.
66
+ * concern — the `Navigator`'s match then misses). Total — never throws.
65
67
  *
66
68
  * @param location - The `hash` + `pathname` pair to resolve from (accepts a
67
69
  * real `Location` or any object shaped the same, for pure unit testing)
68
- * @param history - The navigation substrate: `false` for hash mode, `true`
69
- * for history mode
70
+ * @param history - If `true`, the pathname is read from `location.pathname`
71
+ * with `base` stripped; if `false`, it is read from `location.hash`
70
72
  * @param base - The history-mode path prefix to strip (ignored in hash mode;
71
73
  * omit for no prefix)
72
74
  * @returns The `/`-prefixed pathname to match
@@ -89,7 +91,7 @@ function resolveLocationPath(location, history, base) {
89
91
  return pathname;
90
92
  }
91
93
  /**
92
- * Find the nearest enclosing `<a>` element a DOM event originated from, by
94
+ * Finds the nearest enclosing `<a>` element a DOM event originated from, by
93
95
  * walking its composed path — the pure lookup behind history-mode link
94
96
  * interception.
95
97
  *
@@ -116,10 +118,10 @@ function findAnchor(event) {
116
118
  //#endregion
117
119
  //#region src/browser/Navigator.ts
118
120
  /**
119
- * The headless History/hash navigation entity — composes one core
120
- * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
121
+ * Represents the headless History/hash navigation entity — composes one core
122
+ * `Router<Meta>`, resolving the current location on `start()` and
121
123
  * every subsequent navigation event, tracking `active`, and emitting
122
- * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
124
+ * `navigate` through the core {@link Emitter}. No `render` /
123
125
  * `outlet` — the consumer owns rendering.
124
126
  *
125
127
  * @typeParam Meta - The opaque per-route payload a match carries back
@@ -129,14 +131,13 @@ function findAnchor(event) {
129
131
  * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
130
132
  * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
131
133
  * over-param precedence, trailing-slash insensitivity, and
132
- * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
133
- * §21).
134
+ * `:param`/`*wildcard` extraction all come from that one shared engine.
134
135
  * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
135
136
  * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
136
137
  * `fallback` through the SAME engine → a fallback that ALSO matches nothing
137
138
  * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
138
- * newer navigation) and leaves `active` `undefined`, emitting nothing
139
- * (§21-honest: no phantom match is fabricated) → the optional `guard` may
139
+ * newer navigation) and leaves `active` `undefined`, emitting nothing
140
+ * honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may
140
141
  * veto → on a verdict, `active` is set and `navigate` emitted.
141
142
  * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
142
143
  * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
@@ -171,16 +172,36 @@ var Navigator = class {
171
172
  #guard;
172
173
  #error;
173
174
  #intercept;
174
- #hashListener;
175
- #popListener;
175
+ #listener;
176
176
  #clickListener;
177
177
  #active;
178
178
  #started = false;
179
179
  #current;
180
180
  constructor(options) {
181
- if (options.guard !== void 0 && !isFunction(options.guard)) throw new TypeError(`a navigator guard must be a function, got ${JSON.stringify(options.guard)}`);
182
- if (options.fallback !== void 0 && !isString(options.fallback)) throw new TypeError(`a navigator fallback must be a string, got ${JSON.stringify(options.fallback)}`);
183
- if (options.base !== void 0 && !isString(options.base)) throw new TypeError(`a navigator base must be a string, got ${JSON.stringify(options.base)}`);
181
+ if (options.guard !== void 0 && !isFunction(options.guard)) throw new ContractError("a navigator guard must be a function when defined", {
182
+ code: "literal",
183
+ context: {
184
+ path: ["options", "guard"],
185
+ limit: "function or undefined",
186
+ received: preview(options.guard)
187
+ }
188
+ });
189
+ if (options.fallback !== void 0 && !isString(options.fallback)) throw new ContractError("a navigator fallback must be a string when defined", {
190
+ code: "literal",
191
+ context: {
192
+ path: ["options", "fallback"],
193
+ limit: "string or undefined",
194
+ received: preview(options.fallback)
195
+ }
196
+ });
197
+ if (options.base !== void 0 && !isString(options.base)) throw new ContractError("a navigator base must be a string when defined", {
198
+ code: "literal",
199
+ context: {
200
+ path: ["options", "base"],
201
+ limit: "string or undefined",
202
+ received: preview(options.base)
203
+ }
204
+ });
184
205
  this.#history = options.history ?? false;
185
206
  this.#base = options.base;
186
207
  this.#intercept = options.intercept ?? false;
@@ -191,17 +212,12 @@ var Navigator = class {
191
212
  ...options.error === void 0 ? {} : { error: options.error }
192
213
  });
193
214
  this.#router = createRouter({
194
- entries: options.routes.map((route) => ({
195
- path: route.path,
196
- meta: route,
197
- ...route.name === void 0 ? {} : { name: route.name }
198
- })),
215
+ entries: options.routes,
199
216
  ...options.sensitive === void 0 ? {} : { sensitive: options.sensitive },
200
217
  key: computeNavigationKey
201
218
  });
202
219
  this.#fallback = options.fallback ?? options.routes[0]?.path;
203
- this.#hashListener = this.#resolve.bind(this);
204
- this.#popListener = this.#resolve.bind(this);
220
+ this.#listener = this.#resolve.bind(this);
205
221
  this.#clickListener = this.#intercepted.bind(this);
206
222
  }
207
223
  get router() {
@@ -216,9 +232,9 @@ var Navigator = class {
216
232
  start() {
217
233
  if (this.#started) return;
218
234
  this.#started = true;
219
- if (!this.#history) window.addEventListener("hashchange", this.#hashListener);
235
+ if (!this.#history) window.addEventListener("hashchange", this.#listener);
220
236
  else {
221
- window.addEventListener("popstate", this.#popListener);
237
+ window.addEventListener("popstate", this.#listener);
222
238
  if (this.#intercept) document.addEventListener("click", this.#clickListener);
223
239
  }
224
240
  this.#resolve();
@@ -226,9 +242,9 @@ var Navigator = class {
226
242
  stop() {
227
243
  if (!this.#started) return;
228
244
  this.#started = false;
229
- if (!this.#history) window.removeEventListener("hashchange", this.#hashListener);
245
+ if (!this.#history) window.removeEventListener("hashchange", this.#listener);
230
246
  else {
231
- window.removeEventListener("popstate", this.#popListener);
247
+ window.removeEventListener("popstate", this.#listener);
232
248
  if (this.#intercept) document.removeEventListener("click", this.#clickListener);
233
249
  }
234
250
  this.#current?.abort();
@@ -245,14 +261,7 @@ var Navigator = class {
245
261
  this.#resolve();
246
262
  }
247
263
  match(path) {
248
- const hit = this.#router.match(path);
249
- if (hit === void 0) return void 0;
250
- return {
251
- path: hit.path,
252
- params: hit.params,
253
- meta: hit.meta.meta,
254
- ...hit.meta.name === void 0 ? {} : { name: hit.meta.name }
255
- };
264
+ return this.#router.match(path);
256
265
  }
257
266
  destroy() {
258
267
  this.stop();
@@ -327,8 +336,8 @@ var Navigator = class {
327
336
  //#endregion
328
337
  //#region src/browser/factories.ts
329
338
  /**
330
- * Create a {@link NavigatorInterface} — the headless History/hash navigation
331
- * entity composing one core `Router<RouteEntry<Meta>>`.
339
+ * Creates a {@link NavigatorInterface} — the headless History/hash navigation
340
+ * entity composing one core `Router<Meta>`.
332
341
  *
333
342
  * @remarks
334
343
  * Prefer this over `new Navigator(...)` at call sites that only need the
@@ -338,7 +347,7 @@ var Navigator = class {
338
347
  * @param options - The `routes` to register, the `history` toggle (default
339
348
  * `false`, hash mode), an optional `base` (history mode), an optional
340
349
  * `fallback` path, an optional `guard` hook, opt-in link `intercept`
341
- * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
350
+ * (history mode), the `sensitive` case toggle, and the Emitter pattern's
342
351
  * `on`/`error` wiring
343
352
  * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
344
353
  * dispatching