@orkestrel/router 0.0.13 → 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 +14 -14
- package/dist/src/browser/index.d.ts +47 -29
- package/dist/src/browser/index.js +3 -2
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +44 -33
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +206 -108
- package/dist/src/core/index.d.ts +206 -108
- package/dist/src/core/index.js +44 -33
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +14 -5
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +17 -8
- package/dist/src/server/index.d.ts +17 -8
- package/dist/src/server/index.js +14 -5
- package/dist/src/server/index.js.map +1 -1
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @orkestrel/router
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
186
|
-
* interface role for the one-class-per-file `Navigator`). Composes a core
|
|
187
|
-
* `Router<Meta>`, resolves the current location on `start()` and
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
-
*
|
|
195
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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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"}
|
package/dist/src/core/index.cjs
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
* {@link import('./types.js').Method} type, {@link METHODS}, and
|
|
9
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
308
|
-
* {@link compilePath} rewrites: a syntactically valid `:name` head is a
|
|
309
|
-
* segment, a final `*name` is a
|
|
310
|
-
* literal segment that merely
|
|
311
|
-
*
|
|
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
|
|
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
|
|
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`
|
|
445
|
-
* to the
|
|
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
|
|
@@ -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
|
-
*
|
|
558
|
-
*
|
|
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
|
*
|
|
@@ -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
|
-
*
|
|
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
|
|
@@ -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
|
|
724
|
-
* dispatch and web-standard `Request`/`Response` handling over one internal
|
|
725
|
-
* `Router<RouteRecord<TState>>`. The core machine the
|
|
726
|
-
*
|
|
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
|
*
|
|
@@ -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 '@
|
|
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,
|
|
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
|