@orkestrel/router 0.0.13 → 0.0.15

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,12 +1,14 @@
1
1
  # @orkestrel/router
2
2
 
3
- A typed request router for the `@orkestrel` line. One matching engine compiles
4
- route patterns, extracts URL-decoded params, and resolves the most specific
5
- match; a `Dispatcher` layers fetch-standard, method-dimensioned dispatch over
6
- it; a headless `Navigator` drives History or hash navigation in the browser;
7
- and a `node:http` adapter converts messages in both directions. Built on
8
- `@orkestrel/contract` for validation, `@orkestrel/emitter` for the observable
9
- surface, and `@orkestrel/abort` for cancellation.
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.
10
12
 
11
13
  ## Install
12
14
 
@@ -41,13 +43,11 @@ const dispatcher = createDispatcher<{ readonly userId: string }>({
41
43
  const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
42
44
  ```
43
45
 
44
- `Router` is the shared registry-and-match engine literal-over-param-over-wildcard
45
- precedence, trailing-slash folding, and tolerant percent-decoding that both `Dispatcher`
46
- (fetch-standard, method-dimensioned) and the browser `Navigator` compose. Path params are
47
- inferred at the type level from the literal pattern through `PathParams`, and `defineRoute()`
48
- pins a `RouteInput`'s path so literal inference survives across call sites. The `./browser` entry
49
- adds `createNavigator` for headless History/hash navigation; the `./server` entry adds
50
- `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`.
51
51
 
52
52
  ## Guide
53
53
 
@@ -1,12 +1,13 @@
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
- * Computes 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
13
  * Projects the route's path through the core engine's canonical trailing-slash
@@ -182,40 +183,57 @@ export declare type NavigatorEventMap<Meta> = {
182
183
  };
183
184
 
184
185
  /**
185
- * Represents the headless History/hash navigation entity contract (the behavioral-
186
- * interface role for the one-class-per-file `Navigator`). Composes a core
187
- * `Router<Meta>`, resolves the current location on `start()` and
188
- * on every subsequent navigation event, tracks `active`, and emits
189
- * `navigate` through the {@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}.
190
191
  *
191
192
  * @typeParam Meta - The opaque per-route payload a match carries back
192
193
  *
193
194
  * @remarks
194
- * - `router` the underlying registry, exposed READONLY for introspection
195
- * (the same object routes were registered on).
196
- * - `emitter` — the observable surface for {@link NavigatorEventMap}.
197
- * - `active` — the currently-resolved {@link RouterMatch}, or `undefined`
198
- * before the first resolve (or when a miss's fallback also misses).
199
- * - `start()` — begin listening (`hashchange` in hash mode; `popstate` +
200
- * optional link interception in history mode) and resolve the current
201
- * location now. Idempotent — a second call is a no-op.
202
- * - `stop()` — stop listening. Idempotent.
203
- * - `navigate(path)` — navigate programmatically: sets `location.hash` (hash
204
- * mode) or calls `history.pushState` (history mode), then resolves. A
205
- * no-op hash navigation (already the active hash) resolves directly, since
206
- * no `hashchange` would otherwise fire.
207
- * - `match(path)` — a PURE lookup through the underlying `Router`: no
208
- * location read, no fallback, no guard, no emit.
209
- * - `destroy()` — `stop()` plus tear down the `#emitter`.
195
+ * Rendering stays outside this contract: a consumer subscribes to `navigate` and
196
+ * renders whatever the resolved match names.
210
197
  */
211
198
  export declare interface NavigatorInterface<Meta> {
199
+ /**
200
+ * Holds the underlying registry, exposed readonly for introspection — the same
201
+ * object the routes were registered on.
202
+ */
212
203
  readonly router: RouterInterface<Meta>;
204
+ /** Holds the observable surface for {@link NavigatorEventMap}. */
213
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
+ */
214
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
+ */
215
219
  start(): void;
220
+ /** Stops listening and aborts any pending guard — idempotent. */
216
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
+ */
217
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
+ */
218
235
  match(path: string): RouterMatch<Meta> | undefined;
236
+ /** Stops listening and tears down the emitter. */
219
237
  destroy(): void;
220
238
  }
221
239
 
@@ -269,7 +287,7 @@ export declare interface NavigatorOptions<Meta> {
269
287
  }
270
288
 
271
289
  /**
272
- * Resolves the `/`-prefixed pathname to match for the CURRENT location, in
290
+ * Resolves the `/`-prefixed pathname to match for the current location, in
273
291
  * either navigation mode — the one seam `extractHashPath` (hash mode) and
274
292
  * history-mode base-stripping share.
275
293
  *
@@ -4,7 +4,8 @@ import { Emitter } from "@orkestrel/emitter";
4
4
  import { ContractError, isFunction, isString, preview } from "@orkestrel/contract";
5
5
  //#region src/browser/helpers.ts
6
6
  /**
7
- * Computes 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
11
  * Projects the route's path through the core engine's canonical trailing-slash
@@ -52,7 +53,7 @@ function extractHashPath(hash) {
52
53
  return queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
53
54
  }
54
55
  /**
55
- * Resolves the `/`-prefixed pathname to match for the CURRENT location, in
56
+ * Resolves the `/`-prefixed pathname to match for the current location, in
56
57
  * either navigation mode — the one seam `extractHashPath` (hash mode) and
57
58
  * history-mode base-stripping share.
58
59
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#router","#emitter","#history","#base","#fallback","#guard","#error","#intercept","#listener","#clickListener","#resolve","#intercepted","#active","#started","#current","#matchFallback","#navigate","#commit","#guarded","#surface"],"sources":["../../../src/browser/helpers.ts","../../../src/browser/Navigator.ts","../../../src/browser/factories.ts"],"sourcesContent":["// The PURE browser-navigation primitives (self-describing helper naming —\n// module scope, no entity context). Every one is exported (the centralized-file\n// rule): the `Navigator` composes them, and each has its own unit test. NO `node:*`\n// — DOM-typed only (`Location`, `Event`, `HTMLAnchorElement`), valid under the\n// `src:browser` scoped isolation check.\n\nimport type { RouteEntry } from '@src/core'\nimport { canonicalizePath } from '@src/core'\n\n/**\n * Computes the registry key for a browser navigation route.\n *\n * @remarks\n * Projects the route's path through the core engine's canonical trailing-slash\n * identity, so `/users` and `/users/` replace one another in the Navigator's\n * shared Router. The entry's `meta` payload is never read, so any payload type\n * is accepted.\n *\n * @param entry - The Router entry carrying the Navigator route\n * @returns The route's canonical path\n *\n * @example\n * ```ts\n * computeNavigationKey({ path: '/users/', meta: {} }) // '/users'\n * ```\n */\nexport function computeNavigationKey(entry: RouteEntry<unknown>): string {\n\treturn canonicalizePath(entry.path)\n}\n\n/**\n * Extracts the `/`-prefixed pathname from a `location.hash` value — strips the\n * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.\n *\n * @remarks\n * The grammar this package matches everywhere is `/`-prefixed, so a hash-mode\n * location's `'#/users/7?x'` becomes `'/users/7'`\n * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,\n * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls\n * back). Total — never throws.\n *\n * @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)\n * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash\n *\n * @example\n * ```ts\n * extractHashPath('#/users/7?x') // '/users/7'\n * extractHashPath('#/tokens') // '/tokens'\n * extractHashPath('') // '' — the Navigator falls back\n * extractHashPath('#other') // '' — not a `#/` route hash\n * ```\n */\nexport function extractHashPath(hash: string): string {\n\tif (!hash.startsWith('#/')) return ''\n\tconst withoutHash = hash.slice(1)\n\tconst queryIndex = withoutHash.indexOf('?')\n\treturn queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)\n}\n\n/**\n * Resolves the `/`-prefixed pathname to match for the CURRENT location, in\n * either navigation mode — the one seam `extractHashPath` (hash mode) and\n * history-mode base-stripping share.\n *\n * @remarks\n * Hash mode (`history: false`) reads `location.hash` through\n * {@link extractHashPath}. History mode (`history: true`) reads\n * `location.pathname` and strips a leading `base` prefix when one is\n * configured: `base` itself maps to the root `'/'`; a pathname that is not\n * under `base` is returned unchanged (a base mismatch is not this helper's\n * concern — the `Navigator`'s match then misses). Total — never throws.\n *\n * @param location - The `hash` + `pathname` pair to resolve from (accepts a\n * real `Location` or any object shaped the same, for pure unit testing)\n * @param history - If `true`, the pathname is read from `location.pathname`\n * with `base` stripped; if `false`, it is read from `location.hash`\n * @param base - The history-mode path prefix to strip (ignored in hash mode;\n * omit for no prefix)\n * @returns The `/`-prefixed pathname to match\n *\n * @example\n * ```ts\n * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'\n * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'\n * ```\n */\nexport function resolveLocationPath(\n\tlocation: Pick<Location, 'hash' | 'pathname'>,\n\thistory: boolean,\n\tbase?: string,\n): string {\n\tif (!history) return extractHashPath(location.hash)\n\tconst pathname = location.pathname\n\tif (base === undefined || base === '') return pathname\n\tconst normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base\n\tif (pathname === normalizedBase) return '/'\n\tif (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length)\n\treturn pathname\n}\n\n/**\n * Finds the nearest enclosing `<a>` element a DOM event originated from, by\n * walking its composed path — the pure lookup behind history-mode link\n * interception.\n *\n * @remarks\n * Uses `event.composedPath()` (not `event.target`) so a click on a styled\n * child INSIDE an anchor (an icon, a span) still resolves to the anchor.\n * Total — never throws; returns `undefined` when no anchor is found on the\n * path.\n *\n * @param event - The DOM event to search (typically a `click`)\n * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`\n *\n * @example\n * ```ts\n * document.addEventListener('click', (event) => {\n * \tconst anchor = findAnchor(event)\n * \tif (anchor !== undefined) console.log(anchor.href)\n * })\n * ```\n */\nexport function findAnchor(event: Event): HTMLAnchorElement | undefined {\n\tfor (const node of event.composedPath()) {\n\t\tif (node instanceof HTMLAnchorElement) return node\n\t}\n\treturn undefined\n}\n","import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js'\nimport type { AbortInterface } from '@orkestrel/abort'\nimport type { EmitterErrorHandler, EmitterInterface } from '@orkestrel/emitter'\nimport type { RouterInterface, RouterMatch } from '@src/core'\nimport { createAbort } from '@orkestrel/abort'\nimport { Emitter } from '@orkestrel/emitter'\nimport { ContractError, isFunction, isString, preview } from '@orkestrel/contract'\nimport { createRouter, joinPaths } from '@src/core'\nimport { computeNavigationKey, findAnchor, resolveLocationPath } from './helpers.js'\n\n/**\n * Represents the headless History/hash navigation entity — composes one core\n * `Router<Meta>`, resolving the current location on `start()` and\n * every subsequent navigation event, tracking `active`, and emitting\n * `navigate` through the core {@link Emitter}. No `render` /\n * `outlet` — the consumer owns rendering.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n *\n * @remarks\n * - **One shared engine.** Each `route.path` is registered on the SAME\n * `Router` machine the core `Dispatcher` composes, keyed for dedup by its\n * {@link canonicalizePath} (last write wins, replace-in-place) — literal-\n * over-param precedence, trailing-slash insensitivity, and\n * `:param`/`*wildcard` extraction all come from that one shared engine.\n * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match\n * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the\n * `fallback` through the SAME engine → a fallback that ALSO matches nothing\n * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a\n * newer navigation) and leaves `active` `undefined`, emitting nothing —\n * honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may\n * veto → on a verdict, `active` is set and `navigate` emitted.\n * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`\n * handle, aborting the PREVIOUS navigation's handle first; a guard verdict\n * that resolves after its navigation was superseded (`signal.aborted`) is\n * discarded, same as a `false`/rejected verdict. A guard throw routes to\n * the `error` handler and vetoes. `stop()`/`destroy()` also abort the\n * pending handle.\n * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds\n * `hashchange`; history mode (`history: true`) binds `popstate` and, when\n * `intercept` is set, same-origin `<a>` click interception (a plain\n * left-click with no modifier keys, `target`, or `download` attribute).\n *\n * @example\n * ```ts\n * const navigator = new Navigator<{ readonly title: string }>({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * })\n * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))\n * navigator.start() // resolves the current hash now, and on every hashchange\n * navigator.navigate('/tokens')\n * ```\n */\nexport class Navigator<Meta> implements NavigatorInterface<Meta> {\n\treadonly #router: RouterInterface<Meta>\n\treadonly #emitter: Emitter<NavigatorEventMap<Meta>>\n\treadonly #history: boolean\n\treadonly #base: string | undefined\n\treadonly #fallback: string | undefined\n\treadonly #guard: NavigatorOptions<Meta>['guard']\n\treadonly #error: EmitterErrorHandler | undefined\n\treadonly #intercept: boolean\n\treadonly #listener: () => void\n\treadonly #clickListener: (event: MouseEvent) => void\n\t#active: RouterMatch<Meta> | undefined\n\t#started = false\n\t#current: AbortInterface | undefined\n\n\tconstructor(options: NavigatorOptions<Meta>) {\n\t\tif (options.guard !== undefined && !isFunction(options.guard))\n\t\t\tthrow new ContractError('a navigator guard must be a function when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'guard'],\n\t\t\t\t\tlimit: 'function or undefined',\n\t\t\t\t\treceived: preview(options.guard),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.fallback !== undefined && !isString(options.fallback))\n\t\t\tthrow new ContractError('a navigator fallback must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'fallback'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.fallback),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.base !== undefined && !isString(options.base))\n\t\t\tthrow new ContractError('a navigator base must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'base'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.base),\n\t\t\t\t},\n\t\t\t})\n\t\tthis.#history = options.history ?? false\n\t\tthis.#base = options.base\n\t\tthis.#intercept = options.intercept ?? false\n\t\tthis.#guard = options.guard\n\t\tthis.#error = options.error\n\t\tthis.#emitter = new Emitter<NavigatorEventMap<Meta>>({\n\t\t\t...(options.on === undefined ? {} : { on: options.on }),\n\t\t\t...(options.error === undefined ? {} : { error: options.error }),\n\t\t})\n\t\tthis.#router = createRouter<Meta>({\n\t\t\tentries: options.routes,\n\t\t\t...(options.sensitive === undefined ? {} : { sensitive: options.sensitive }),\n\t\t\tkey: computeNavigationKey,\n\t\t})\n\t\tthis.#fallback = options.fallback ?? options.routes[0]?.path\n\t\tthis.#listener = this.#resolve.bind(this)\n\t\tthis.#clickListener = this.#intercepted.bind(this)\n\t}\n\n\tget router(): RouterInterface<Meta> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<NavigatorEventMap<Meta>> {\n\t\treturn this.#emitter\n\t}\n\n\tget active(): RouterMatch<Meta> | undefined {\n\t\treturn this.#active\n\t}\n\n\tstart(): void {\n\t\tif (this.#started) return\n\t\tthis.#started = true\n\t\tif (!this.#history) {\n\t\t\twindow.addEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.addEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.addEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#resolve()\n\t}\n\n\tstop(): void {\n\t\tif (!this.#started) return\n\t\tthis.#started = false\n\t\tif (!this.#history) {\n\t\t\twindow.removeEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.removeEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.removeEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#current?.abort()\n\t}\n\n\tnavigate(path: string): void {\n\t\tif (!this.#history) {\n\t\t\tconst next = `#${path}`\n\t\t\tif (window.location.hash === next) this.#resolve()\n\t\t\telse window.location.hash = next\n\t\t\treturn\n\t\t}\n\t\tconst target = this.#base === undefined ? path : joinPaths(this.#base, path)\n\t\twindow.history.pushState(null, '', target)\n\t\tthis.#resolve()\n\t}\n\n\tmatch(path: string): RouterMatch<Meta> | undefined {\n\t\treturn this.#router.match(path)\n\t}\n\n\tdestroy(): void {\n\t\tthis.stop()\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// === Private\n\n\t// Compute the pathname to match for the CURRENT location, resolve it (falling back to the\n\t// configured fallback through the SAME engine on a miss), and either navigate or — when\n\t// neither the location nor the fallback matches anything — leave `active` `undefined` with\n\t// no emit, honest to the one-shared-engine rule: no phantom match is fabricated.\n\t#resolve(): void {\n\t\tconst pathname = resolveLocationPath(\n\t\t\t{ hash: window.location.hash, pathname: window.location.pathname },\n\t\t\tthis.#history,\n\t\t\tthis.#base,\n\t\t)\n\t\tconst to = this.match(pathname) ?? this.#matchFallback()\n\t\tif (to === undefined) {\n\t\t\tthis.#current?.abort()\n\t\t\tthis.#active = undefined\n\t\t\treturn\n\t\t}\n\t\tthis.#navigate(to)\n\t}\n\n\t#matchFallback(): RouterMatch<Meta> | undefined {\n\t\tif (this.#fallback === undefined) return undefined\n\t\treturn this.match(this.#fallback)\n\t}\n\n\t// Supersede the previous pending navigation's abort handle, mint a fresh one for this\n\t// navigation, and either commit directly (no guard configured — the synchronous fast path) or\n\t// run the guard pipeline.\n\t#navigate(to: RouterMatch<Meta>): void {\n\t\tthis.#current?.abort()\n\t\tconst handle = createAbort()\n\t\tthis.#current = handle\n\t\tconst guard = this.#guard\n\t\tif (guard === undefined) {\n\t\t\tthis.#commit(to)\n\t\t\treturn\n\t\t}\n\t\tvoid this.#guarded(guard, to, this.#active, handle)\n\t}\n\n\t#commit(to: RouterMatch<Meta>): void {\n\t\tthis.#active = to\n\t\tthis.#emitter.emit('navigate', to)\n\t}\n\n\t// Await the guard's verdict; a throw routes to the `error` handler and vetoes, a discarded\n\t// verdict (superseded through `handle.signal.aborted`, or a plain `false`/rejected verdict) leaves\n\t// `active` unchanged with no emit, and a true verdict commits.\n\tasync #guarded(\n\t\tguard: NonNullable<NavigatorOptions<Meta>['guard']>,\n\t\tto: RouterMatch<Meta>,\n\t\tfrom: RouterMatch<Meta> | undefined,\n\t\thandle: AbortInterface,\n\t): Promise<void> {\n\t\tlet verdict: boolean\n\t\ttry {\n\t\t\tverdict = await guard(to, from, handle.signal)\n\t\t} catch (error) {\n\t\t\tthis.#surface(error)\n\t\t\treturn\n\t\t}\n\t\tif (handle.signal.aborted || !verdict) return\n\t\tthis.#commit(to)\n\t}\n\n\t// Route a guard throw to the `error` handler (the Emitter pattern's own channel, not a listener\n\t// throw so it cannot flow through the emitter's `emit`), swallowing a throwing handler itself\n\t// (anti-recursion, mirroring the emitter's own contract).\n\t#surface(error: unknown): void {\n\t\tconst handler = this.#error\n\t\tif (handler === undefined) return\n\t\ttry {\n\t\t\thandler(error, 'navigate')\n\t\t} catch {\n\t\t\t// The error handler itself threw — swallow it (anti-recursion).\n\t\t}\n\t}\n\n\t// Same-origin `<a>` click interception (history mode, opt-in through `intercept`): skip an\n\t// already-handled event, a non-primary button, any modifier key, a targeted or download link,\n\t// or a cross-origin destination — otherwise prevent the native navigation and `navigate` instead.\n\t#intercepted(event: MouseEvent): void {\n\t\tif (event.defaultPrevented || event.button !== 0) return\n\t\tif (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n\t\tconst anchor = findAnchor(event)\n\t\tif (anchor === undefined) return\n\t\tif (anchor.target !== '' && anchor.target !== '_self') return\n\t\tif (anchor.hasAttribute('download')) return\n\t\tconst url = new URL(anchor.href, window.location.href)\n\t\tif (url.origin !== window.location.origin) return\n\t\tevent.preventDefault()\n\t\tthis.navigate(resolveLocationPath({ hash: url.hash, pathname: url.pathname }, true, this.#base))\n\t}\n}\n","import type { NavigatorInterface, NavigatorOptions } from './types.js'\nimport { Navigator } from './Navigator.js'\n\n/**\n * Creates a {@link NavigatorInterface} — the headless History/hash navigation\n * entity composing one core `Router<Meta>`.\n *\n * @remarks\n * Prefer this over `new Navigator(...)` at call sites that only need the\n * interface.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n * @param options - The `routes` to register, the `history` toggle (default\n * `false`, hash mode), an optional `base` (history mode), an optional\n * `fallback` path, an optional `guard` hook, opt-in link `intercept`\n * (history mode), the `sensitive` case toggle, and the Emitter pattern's\n * `on`/`error` wiring\n * @returns A live {@link NavigatorInterface} handle — call `start()` to begin\n * dispatching\n *\n * @example\n * ```ts\n * import { createNavigator } from '@src/browser'\n *\n * const navigator = createNavigator({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * \ton: { navigate: (match) => (document.title = match.meta.title) },\n * })\n * navigator.start()\n * ```\n */\nexport function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta> {\n\treturn new Navigator<Meta>(options)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/browser/helpers.ts","../../../src/browser/Navigator.ts","../../../src/browser/factories.ts"],"sourcesContent":["// The PURE browser-navigation primitives (self-describing helper naming —\n// module scope, no entity context). Every one is exported (the centralized-file\n// rule): the `Navigator` composes them, and each has its own unit test. NO `node:*`\n// — DOM-typed only (`Location`, `Event`, `HTMLAnchorElement`), valid under the\n// `src:browser` scoped isolation check.\n\nimport type { RouteEntry } from '@src/core'\nimport { canonicalizePath } from '@src/core'\n\n/**\n * Computes the canonical path key a `Navigator` registers a browser navigation\n * route under.\n *\n * @remarks\n * Projects the route's path through the core engine's canonical trailing-slash\n * identity, so `/users` and `/users/` replace one another in the Navigator's\n * shared Router. The entry's `meta` payload is never read, so any payload type\n * is accepted.\n *\n * @param entry - The Router entry carrying the Navigator route\n * @returns The route's canonical path\n *\n * @example\n * ```ts\n * computeNavigationKey({ path: '/users/', meta: {} }) // '/users'\n * ```\n */\nexport function computeNavigationKey(entry: RouteEntry<unknown>): string {\n\treturn canonicalizePath(entry.path)\n}\n\n/**\n * Extracts the `/`-prefixed pathname from a `location.hash` value — strips the\n * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.\n *\n * @remarks\n * The grammar this package matches everywhere is `/`-prefixed, so a hash-mode\n * location's `'#/users/7?x'` becomes `'/users/7'`\n * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,\n * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls\n * back). Total — never throws.\n *\n * @param hash - The raw `window.location.hash` value (for example `'#/users/7?x'`)\n * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash\n *\n * @example\n * ```ts\n * extractHashPath('#/users/7?x') // '/users/7'\n * extractHashPath('#/tokens') // '/tokens'\n * extractHashPath('') // '' — the Navigator falls back\n * extractHashPath('#other') // '' — not a `#/` route hash\n * ```\n */\nexport function extractHashPath(hash: string): string {\n\tif (!hash.startsWith('#/')) return ''\n\tconst withoutHash = hash.slice(1)\n\tconst queryIndex = withoutHash.indexOf('?')\n\treturn queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)\n}\n\n/**\n * Resolves the `/`-prefixed pathname to match for the current location, in\n * either navigation mode — the one seam `extractHashPath` (hash mode) and\n * history-mode base-stripping share.\n *\n * @remarks\n * Hash mode (`history: false`) reads `location.hash` through\n * {@link extractHashPath}. History mode (`history: true`) reads\n * `location.pathname` and strips a leading `base` prefix when one is\n * configured: `base` itself maps to the root `'/'`; a pathname that is not\n * under `base` is returned unchanged (a base mismatch is not this helper's\n * concern — the `Navigator`'s match then misses). Total — never throws.\n *\n * @param location - The `hash` + `pathname` pair to resolve from (accepts a\n * real `Location` or any object shaped the same, for pure unit testing)\n * @param history - If `true`, the pathname is read from `location.pathname`\n * with `base` stripped; if `false`, it is read from `location.hash`\n * @param base - The history-mode path prefix to strip (ignored in hash mode;\n * omit for no prefix)\n * @returns The `/`-prefixed pathname to match\n *\n * @example\n * ```ts\n * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'\n * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'\n * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'\n * ```\n */\nexport function resolveLocationPath(\n\tlocation: Pick<Location, 'hash' | 'pathname'>,\n\thistory: boolean,\n\tbase?: string,\n): string {\n\tif (!history) return extractHashPath(location.hash)\n\tconst pathname = location.pathname\n\tif (base === undefined || base === '') return pathname\n\tconst normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base\n\tif (pathname === normalizedBase) return '/'\n\tif (pathname.startsWith(`${normalizedBase}/`)) return pathname.slice(normalizedBase.length)\n\treturn pathname\n}\n\n/**\n * Finds the nearest enclosing `<a>` element a DOM event originated from, by\n * walking its composed path — the pure lookup behind history-mode link\n * interception.\n *\n * @remarks\n * Uses `event.composedPath()` (not `event.target`) so a click on a styled\n * child INSIDE an anchor (an icon, a span) still resolves to the anchor.\n * Total — never throws; returns `undefined` when no anchor is found on the\n * path.\n *\n * @param event - The DOM event to search (typically a `click`)\n * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`\n *\n * @example\n * ```ts\n * document.addEventListener('click', (event) => {\n * \tconst anchor = findAnchor(event)\n * \tif (anchor !== undefined) console.log(anchor.href)\n * })\n * ```\n */\nexport function findAnchor(event: Event): HTMLAnchorElement | undefined {\n\tfor (const node of event.composedPath()) {\n\t\tif (node instanceof HTMLAnchorElement) return node\n\t}\n\treturn undefined\n}\n","import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js'\nimport type { AbortInterface } from '@orkestrel/abort'\nimport type { EmitterErrorHandler, EmitterInterface } from '@orkestrel/emitter'\nimport type { RouterInterface, RouterMatch } from '@src/core'\nimport { createAbort } from '@orkestrel/abort'\nimport { Emitter } from '@orkestrel/emitter'\nimport { ContractError, isFunction, isString, preview } from '@orkestrel/contract'\nimport { createRouter, joinPaths } from '@src/core'\nimport { computeNavigationKey, findAnchor, resolveLocationPath } from './helpers.js'\n\n/**\n * Represents the headless History/hash navigation entity — composes one core\n * `Router<Meta>`, resolving the current location on `start()` and\n * every subsequent navigation event, tracking `active`, and emitting\n * `navigate` through the core {@link Emitter}. No `render` /\n * `outlet` — the consumer owns rendering.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n *\n * @remarks\n * - **One shared engine.** Each `route.path` is registered on the SAME\n * `Router` machine the core `Dispatcher` composes, keyed for dedup by its\n * {@link canonicalizePath} (last write wins, replace-in-place) — literal-\n * over-param precedence, trailing-slash insensitivity, and\n * `:param`/`*wildcard` extraction all come from that one shared engine.\n * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match\n * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the\n * `fallback` through the SAME engine → a fallback that ALSO matches nothing\n * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a\n * newer navigation) and leaves `active` `undefined`, emitting nothing —\n * honest to the one-shared-engine rule: no phantom match is fabricated → the optional `guard` may\n * veto → on a verdict, `active` is set and `navigate` emitted.\n * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`\n * handle, aborting the PREVIOUS navigation's handle first; a guard verdict\n * that resolves after its navigation was superseded (`signal.aborted`) is\n * discarded, same as a `false`/rejected verdict. A guard throw routes to\n * the `error` handler and vetoes. `stop()`/`destroy()` also abort the\n * pending handle.\n * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds\n * `hashchange`; history mode (`history: true`) binds `popstate` and, when\n * `intercept` is set, same-origin `<a>` click interception (a plain\n * left-click with no modifier keys, `target`, or `download` attribute).\n *\n * @example\n * ```ts\n * const navigator = new Navigator<{ readonly title: string }>({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * })\n * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))\n * navigator.start() // resolves the current hash now, and on every hashchange\n * navigator.navigate('/tokens')\n * ```\n */\nexport class Navigator<Meta> implements NavigatorInterface<Meta> {\n\treadonly #router: RouterInterface<Meta>\n\treadonly #emitter: Emitter<NavigatorEventMap<Meta>>\n\treadonly #history: boolean\n\treadonly #base: string | undefined\n\treadonly #fallback: string | undefined\n\treadonly #guard: NavigatorOptions<Meta>['guard']\n\treadonly #error: EmitterErrorHandler | undefined\n\treadonly #intercept: boolean\n\treadonly #listener: () => void\n\treadonly #clickListener: (event: MouseEvent) => void\n\t#active: RouterMatch<Meta> | undefined\n\t#started = false\n\t#current: AbortInterface | undefined\n\n\tconstructor(options: NavigatorOptions<Meta>) {\n\t\tif (options.guard !== undefined && !isFunction(options.guard))\n\t\t\tthrow new ContractError('a navigator guard must be a function when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'guard'],\n\t\t\t\t\tlimit: 'function or undefined',\n\t\t\t\t\treceived: preview(options.guard),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.fallback !== undefined && !isString(options.fallback))\n\t\t\tthrow new ContractError('a navigator fallback must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'fallback'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.fallback),\n\t\t\t\t},\n\t\t\t})\n\t\tif (options.base !== undefined && !isString(options.base))\n\t\t\tthrow new ContractError('a navigator base must be a string when defined', {\n\t\t\t\tcode: 'literal',\n\t\t\t\tcontext: {\n\t\t\t\t\tpath: ['options', 'base'],\n\t\t\t\t\tlimit: 'string or undefined',\n\t\t\t\t\treceived: preview(options.base),\n\t\t\t\t},\n\t\t\t})\n\t\tthis.#history = options.history ?? false\n\t\tthis.#base = options.base\n\t\tthis.#intercept = options.intercept ?? false\n\t\tthis.#guard = options.guard\n\t\tthis.#error = options.error\n\t\tthis.#emitter = new Emitter<NavigatorEventMap<Meta>>({\n\t\t\t...(options.on === undefined ? {} : { on: options.on }),\n\t\t\t...(options.error === undefined ? {} : { error: options.error }),\n\t\t})\n\t\tthis.#router = createRouter<Meta>({\n\t\t\tentries: options.routes,\n\t\t\t...(options.sensitive === undefined ? {} : { sensitive: options.sensitive }),\n\t\t\tkey: computeNavigationKey,\n\t\t})\n\t\tthis.#fallback = options.fallback ?? options.routes[0]?.path\n\t\tthis.#listener = this.#resolve.bind(this)\n\t\tthis.#clickListener = this.#intercepted.bind(this)\n\t}\n\n\tget router(): RouterInterface<Meta> {\n\t\treturn this.#router\n\t}\n\n\tget emitter(): EmitterInterface<NavigatorEventMap<Meta>> {\n\t\treturn this.#emitter\n\t}\n\n\tget active(): RouterMatch<Meta> | undefined {\n\t\treturn this.#active\n\t}\n\n\tstart(): void {\n\t\tif (this.#started) return\n\t\tthis.#started = true\n\t\tif (!this.#history) {\n\t\t\twindow.addEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.addEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.addEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#resolve()\n\t}\n\n\tstop(): void {\n\t\tif (!this.#started) return\n\t\tthis.#started = false\n\t\tif (!this.#history) {\n\t\t\twindow.removeEventListener('hashchange', this.#listener)\n\t\t} else {\n\t\t\twindow.removeEventListener('popstate', this.#listener)\n\t\t\tif (this.#intercept) document.removeEventListener('click', this.#clickListener)\n\t\t}\n\t\tthis.#current?.abort()\n\t}\n\n\tnavigate(path: string): void {\n\t\tif (!this.#history) {\n\t\t\tconst next = `#${path}`\n\t\t\tif (window.location.hash === next) this.#resolve()\n\t\t\telse window.location.hash = next\n\t\t\treturn\n\t\t}\n\t\tconst target = this.#base === undefined ? path : joinPaths(this.#base, path)\n\t\twindow.history.pushState(null, '', target)\n\t\tthis.#resolve()\n\t}\n\n\tmatch(path: string): RouterMatch<Meta> | undefined {\n\t\treturn this.#router.match(path)\n\t}\n\n\tdestroy(): void {\n\t\tthis.stop()\n\t\tthis.#emitter.destroy()\n\t}\n\n\t// === Private\n\n\t// Compute the pathname to match for the CURRENT location, resolve it (falling back to the\n\t// configured fallback through the SAME engine on a miss), and either navigate or — when\n\t// neither the location nor the fallback matches anything — leave `active` `undefined` with\n\t// no emit, honest to the one-shared-engine rule: no phantom match is fabricated.\n\t#resolve(): void {\n\t\tconst pathname = resolveLocationPath(\n\t\t\t{ hash: window.location.hash, pathname: window.location.pathname },\n\t\t\tthis.#history,\n\t\t\tthis.#base,\n\t\t)\n\t\tconst to = this.match(pathname) ?? this.#matchFallback()\n\t\tif (to === undefined) {\n\t\t\tthis.#current?.abort()\n\t\t\tthis.#active = undefined\n\t\t\treturn\n\t\t}\n\t\tthis.#navigate(to)\n\t}\n\n\t#matchFallback(): RouterMatch<Meta> | undefined {\n\t\tif (this.#fallback === undefined) return undefined\n\t\treturn this.match(this.#fallback)\n\t}\n\n\t// Supersede the previous pending navigation's abort handle, mint a fresh one for this\n\t// navigation, and either commit directly (no guard configured — the synchronous fast path) or\n\t// run the guard pipeline.\n\t#navigate(to: RouterMatch<Meta>): void {\n\t\tthis.#current?.abort()\n\t\tconst handle = createAbort()\n\t\tthis.#current = handle\n\t\tconst guard = this.#guard\n\t\tif (guard === undefined) {\n\t\t\tthis.#commit(to)\n\t\t\treturn\n\t\t}\n\t\tvoid this.#guarded(guard, to, this.#active, handle)\n\t}\n\n\t#commit(to: RouterMatch<Meta>): void {\n\t\tthis.#active = to\n\t\tthis.#emitter.emit('navigate', to)\n\t}\n\n\t// Await the guard's verdict; a throw routes to the `error` handler and vetoes, a discarded\n\t// verdict (superseded through `handle.signal.aborted`, or a plain `false`/rejected verdict) leaves\n\t// `active` unchanged with no emit, and a true verdict commits.\n\tasync #guarded(\n\t\tguard: NonNullable<NavigatorOptions<Meta>['guard']>,\n\t\tto: RouterMatch<Meta>,\n\t\tfrom: RouterMatch<Meta> | undefined,\n\t\thandle: AbortInterface,\n\t): Promise<void> {\n\t\tlet verdict: boolean\n\t\ttry {\n\t\t\tverdict = await guard(to, from, handle.signal)\n\t\t} catch (error) {\n\t\t\tthis.#surface(error)\n\t\t\treturn\n\t\t}\n\t\tif (handle.signal.aborted || !verdict) return\n\t\tthis.#commit(to)\n\t}\n\n\t// Route a guard throw to the `error` handler (the Emitter pattern's own channel, not a listener\n\t// throw so it cannot flow through the emitter's `emit`), swallowing a throwing handler itself\n\t// (anti-recursion, mirroring the emitter's own contract).\n\t#surface(error: unknown): void {\n\t\tconst handler = this.#error\n\t\tif (handler === undefined) return\n\t\ttry {\n\t\t\thandler(error, 'navigate')\n\t\t} catch {\n\t\t\t// The error handler itself threw — swallow it (anti-recursion).\n\t\t}\n\t}\n\n\t// Same-origin `<a>` click interception (history mode, opt-in through `intercept`): skip an\n\t// already-handled event, a non-primary button, any modifier key, a targeted or download link,\n\t// or a cross-origin destination — otherwise prevent the native navigation and `navigate` instead.\n\t#intercepted(event: MouseEvent): void {\n\t\tif (event.defaultPrevented || event.button !== 0) return\n\t\tif (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n\t\tconst anchor = findAnchor(event)\n\t\tif (anchor === undefined) return\n\t\tif (anchor.target !== '' && anchor.target !== '_self') return\n\t\tif (anchor.hasAttribute('download')) return\n\t\tconst url = new URL(anchor.href, window.location.href)\n\t\tif (url.origin !== window.location.origin) return\n\t\tevent.preventDefault()\n\t\tthis.navigate(resolveLocationPath({ hash: url.hash, pathname: url.pathname }, true, this.#base))\n\t}\n}\n","import type { NavigatorInterface, NavigatorOptions } from './types.js'\nimport { Navigator } from './Navigator.js'\n\n/**\n * Creates a {@link NavigatorInterface} — the headless History/hash navigation\n * entity composing one core `Router<Meta>`.\n *\n * @remarks\n * Prefer this over `new Navigator(...)` at call sites that only need the\n * interface.\n *\n * @typeParam Meta - The opaque per-route payload a match carries back\n * @param options - The `routes` to register, the `history` toggle (default\n * `false`, hash mode), an optional `base` (history mode), an optional\n * `fallback` path, an optional `guard` hook, opt-in link `intercept`\n * (history mode), the `sensitive` case toggle, and the Emitter pattern's\n * `on`/`error` wiring\n * @returns A live {@link NavigatorInterface} handle — call `start()` to begin\n * dispatching\n *\n * @example\n * ```ts\n * import { createNavigator } from '@src/browser'\n *\n * const navigator = createNavigator({\n * \troutes: [\n * \t\t{ path: '/users/:id', meta: { title: 'User' } },\n * \t\t{ path: '/tokens', meta: { title: 'Tokens' } },\n * \t],\n * \ton: { navigate: (match) => (document.title = match.meta.title) },\n * })\n * navigator.start()\n * ```\n */\nexport function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta> {\n\treturn new Navigator<Meta>(options)\n}\n"],"mappings":"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"}
@@ -3,10 +3,10 @@ let _orkestrel_contract = require("@orkestrel/contract");
3
3
  let _orkestrel_emitter = require("@orkestrel/emitter");
4
4
  //#region src/core/constants.ts
5
5
  /**
6
- * Lists the HTTP methods a {@link import('./types.js').DispatcherInterface}
7
- * registers routes under, in canonical order — the single source the
8
- * {@link import('./types.js').Method} type, {@link METHODS}, and
9
- * `parseMethod` are all derived from.
6
+ * Lists the HTTP methods a {@link import('./types.js').DispatcherInterface} registers
7
+ * routes under, in canonical order — a frozen literal tuple, and the single source the
8
+ * {@link import('./types.js').Method} type, {@link METHODS}, and `parseMethod` are all
9
+ * derived from.
10
10
  *
11
11
  * @remarks
12
12
  * A frozen tuple of the verbs: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,
@@ -31,10 +31,9 @@ var METHOD_LIST = Object.freeze([
31
31
  "OPTIONS"
32
32
  ]);
33
33
  /**
34
- * Holds the complete set of HTTP methods a
35
- * {@link import('./types.js').DispatcherInterface} registers routes under
36
- * backs the registration guard (`add` rejects any `method` outside this set)
37
- * and the auto-`OPTIONS` `Allow` derivation.
34
+ * Holds every HTTP method a {@link import('./types.js').DispatcherInterface} registers
35
+ * routes under as a `ReadonlySet` backs the registration guard (`add` rejects any
36
+ * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.
38
37
  *
39
38
  * @remarks
40
39
  * A `ReadonlySet` built from {@link METHOD_LIST}, so it carries exactly the
@@ -119,7 +118,7 @@ function escapeRegExp(value) {
119
118
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
119
  }
121
120
  /**
122
- * Canonicalizes a route path for REGISTRY IDENTITY — strips a single trailing
121
+ * Canonicalizes a route path for registry identity — strips a single trailing
123
122
  * slash, except the root `/` (and the empty pattern). The trailing-slash fold
124
123
  * {@link compilePath} normalizes a pattern through, so identity agrees with the
125
124
  * matcher.
@@ -146,7 +145,8 @@ function canonicalizePath(path) {
146
145
  return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
147
146
  }
148
147
  /**
149
- * Computes the registry key for a method-dimensioned dispatcher route.
148
+ * Computes the canonical `METHOD /path` registry key for a method-dimensioned
149
+ * dispatcher route.
150
150
  *
151
151
  * @remarks
152
152
  * Combines the route record's HTTP method with the outer entry's canonical
@@ -243,7 +243,7 @@ function compilePath(path, sensitive = true) {
243
243
  };
244
244
  }
245
245
  /**
246
- * URL-decodes one captured param value, tolerating a malformed percent-escape —
246
+ * Decodes one captured param value from a URL, tolerating a malformed percent-escape —
247
247
  * the decode {@link matchPath} applies to each captured group.
248
248
  *
249
249
  * @remarks
@@ -304,11 +304,11 @@ function matchPath(compiled, pathname) {
304
304
  return Object.freeze(params);
305
305
  }
306
306
  /**
307
- * Classifies one path segment into its specificity TIER — the SAME syntax
308
- * {@link compilePath} rewrites: a syntactically valid `:name` head is a PARAM
309
- * segment, a final `*name` is a WILDCARD segment, everything else (including a
310
- * literal segment that merely CONTAINS a `:` mid-string, for example `a:b`) is a
311
- * LITERAL segment.
307
+ * Classifies one path segment into its specificity tier — the same syntax
308
+ * {@link compilePath} rewrites: a syntactically valid `:name` head is a param
309
+ * segment, a final `*name` is a wildcard segment, and everything else (including a
310
+ * literal segment that merely contains a `:` mid-string, for example `a:b`) is a
311
+ * literal segment.
312
312
  *
313
313
  * @remarks
314
314
  * This is the fix over the old engine's bug: the old classifier ranked any
@@ -339,7 +339,7 @@ function classifySegment(segment, isFinal) {
339
339
  return 2;
340
340
  }
341
341
  /**
342
- * Computes a route path's SPECIFICITY VECTOR — the per-segment type ranking
342
+ * Computes a route path's specificity vector — the per-segment type ranking
343
343
  * that breaks a tie when several registered routes match the same concrete
344
344
  * pathname.
345
345
  *
@@ -373,7 +373,7 @@ function computeSpecificity(path) {
373
373
  return segments.map((segment, index) => classifySegment(segment, index === segments.length - 1));
374
374
  }
375
375
  /**
376
- * Compares two route paths by SPECIFICITY — the comparator that picks the
376
+ * Compares two route paths by specificity — the comparator that picks the
377
377
  * most-specific matching route (literal-over-param-over-wildcard,
378
378
  * registration-order-independent).
379
379
  *
@@ -441,8 +441,8 @@ function joinPaths(prefix, path) {
441
441
  return `${prefix.endsWith("/") ? prefix.slice(0, -1) : prefix}${path.startsWith("/") ? path : `/${path}`}`;
442
442
  }
443
443
  /**
444
- * Provides an identity pass-through for a {@link RouteInput} that pins its `Path` generic
445
- * to the LITERAL registration-site string, so `context.params` types
444
+ * Provides an identity pass-through for a {@link RouteInput} that pins its `Path`
445
+ * generic to the literal registration-site string, so `context.params` types
446
446
  * correctly through {@link PathParams} without an explicit type argument.
447
447
  *
448
448
  * @remarks
@@ -539,7 +539,7 @@ var Group = class Group {
539
539
  this.prefix = prefix;
540
540
  }
541
541
  add(input) {
542
- const inputs = Array.isArray(input) ? input : [input];
542
+ const inputs = (0, _orkestrel_contract.isArray)(input) ? input : [input];
543
543
  this.#parent.add(inputs.map((entry) => ({
544
544
  ...entry,
545
545
  path: joinPaths(this.prefix, entry.path)
@@ -553,9 +553,9 @@ var Group = class Group {
553
553
  //#region src/core/Router.ts
554
554
  /**
555
555
  * Represents the path-matching + registry engine — registers `{ path, meta, name? }`
556
- * entries (compiling each path once) and resolves a concrete pathname to the
557
- * MOST SPECIFIC matching entry. The shared machine both the `Navigator`
558
- * (browser) and the `Dispatcher` (core, method-dimensioned) compose.
556
+ * entries (compiling each path once) and resolves a concrete pathname to the most
557
+ * specific matching entry. The shared machine both the `Navigator` (browser) and
558
+ * the `Dispatcher` (core, method-dimensioned) compose.
559
559
  *
560
560
  * @typeParam Meta - The opaque payload each entry carries and a match returns
561
561
  *
@@ -595,7 +595,7 @@ var Router = class {
595
595
  return this.#entries.length;
596
596
  }
597
597
  add(input) {
598
- const inputs = Array.isArray(input) ? input : [input];
598
+ const inputs = (0, _orkestrel_contract.isArray)(input) ? input : [input];
599
599
  for (const entry of inputs) this.#register(entry);
600
600
  }
601
601
  match(pathname, answers) {
@@ -678,8 +678,8 @@ var Router = class {
678
678
  //#region src/core/DispatchGroup.ts
679
679
  /**
680
680
  * Represents a prefix-scoped registration handle over a
681
- * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned
682
- * counterpart of `Group` (`Group.ts`).
681
+ * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned counterpart of
682
+ * `Group`.
683
683
  *
684
684
  * @typeParam TState - The consumer's opaque per-request state type, matching
685
685
  * the owning dispatcher
@@ -707,7 +707,7 @@ var DispatchGroup = class DispatchGroup {
707
707
  this.prefix = prefix;
708
708
  }
709
709
  add(input) {
710
- const inputs = Array.isArray(input) ? input : [input];
710
+ const inputs = (0, _orkestrel_contract.isArray)(input) ? input : [input];
711
711
  this.#parent.add(inputs.map((route) => ({
712
712
  ...route,
713
713
  path: joinPaths(this.prefix, route.path)
@@ -720,10 +720,10 @@ var DispatchGroup = class DispatchGroup {
720
720
  //#endregion
721
721
  //#region src/core/Dispatcher.ts
722
722
  /**
723
- * Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP method
724
- * dispatch and web-standard `Request`/`Response` handling over one internal
725
- * `Router<RouteRecord<TState>>`. The core machine the eventual server face
726
- * and any fetch-native runtime consumes directly.
723
+ * Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP
724
+ * method dispatch and web-standard `Request`/`Response` handling over one internal
725
+ * `Router<RouteRecord<TState>>`. The core machine the server face and any
726
+ * fetch-native runtime consume directly.
727
727
  *
728
728
  * @typeParam TState - The consumer's opaque per-request state type
729
729
  *
@@ -784,7 +784,7 @@ var Dispatcher = class {
784
784
  return this.#emitter;
785
785
  }
786
786
  add(input) {
787
- const inputs = Array.isArray(input) ? input : [input];
787
+ const inputs = (0, _orkestrel_contract.isArray)(input) ? input : [input];
788
788
  for (const route of inputs) this.#register(route);
789
789
  }
790
790
  group(prefix) {
@@ -929,21 +929,32 @@ var Dispatcher = class {
929
929
  * (default `true`), and a `key` dedup identity function
930
930
  * @returns A {@link RouterInterface}
931
931
  *
932
- * @example
932
+ * @example Register and match
933
933
  * ```ts
934
- * import { createRouter } from '@src/core'
934
+ * import { createDispatcher, createRouter } from '@orkestrel/router'
935
935
  *
936
936
  * const router = createRouter<{ readonly page: string }>()
937
937
  * router.add({ path: '/users/:id', meta: { page: 'profile' } })
938
938
  * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
939
+ *
940
+ * const dispatcher = createDispatcher<{ readonly userId: string }>({
941
+ * routes: [
942
+ * {
943
+ * method: 'GET',
944
+ * path: '/users/:id',
945
+ * handler: (_request, context) => Response.json(context.params),
946
+ * },
947
+ * ],
948
+ * })
949
+ * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
939
950
  * ```
940
951
  */
941
952
  function createRouter(options) {
942
953
  return new Router(options);
943
954
  }
944
955
  /**
945
- * Creates a {@link DispatcherInterface} — the fetch-standard, method-
946
- * dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
956
+ * Creates a {@link DispatcherInterface} — the fetch-standard,
957
+ * method-dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
947
958
  *
948
959
  * @remarks
949
960
  * Prefer this over `new Dispatcher(...)` at call sites that only need the