@orkestrel/router 0.0.1 → 0.0.3

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
@@ -17,11 +17,40 @@ npm install @orkestrel/router
17
17
  - ESM-only (no CommonJS build)
18
18
  - Server and browser environments both supported
19
19
 
20
- ## Status
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { createDispatcher, createRouter } from '@orkestrel/router'
24
+
25
+ const router = createRouter<{ readonly page: string }>()
26
+ router.add({ path: '/users/:id', meta: { page: 'profile' } })
27
+ router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
28
+
29
+ const dispatcher = createDispatcher<{ readonly userId: string }>({
30
+ routes: [
31
+ {
32
+ method: 'GET',
33
+ path: '/users/:id',
34
+ handler: (_request, context) => Response.json(context.params),
35
+ },
36
+ ],
37
+ })
38
+ const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
39
+ ```
40
+
41
+ `Router` is the shared registry-and-match engine — literal-over-param-over-wildcard
42
+ precedence, trailing-slash folding, and tolerant percent-decoding — that both `Dispatcher`
43
+ (fetch-standard, method-dimensioned) and the browser `Navigator` compose. Path params are
44
+ inferred at the type level from the literal pattern via `PathParams`, and `route()` pins a
45
+ `RouteInput`'s path so literal inference survives across call sites. The `./browser` entry
46
+ adds `createNavigator` for headless History/hash navigation; the `./server` entry adds
47
+ `buildRequest` / `sendResponse` / `createListener` for `node:http`.
48
+
49
+ ## Guide
21
50
 
22
- The public API is under design and not yet implemented this package
23
- currently ships no runtime code. This README will gain an install snippet,
24
- usage examples, and a guide link once the design lands.
51
+ For the full surface the core `Router`, the `Dispatcher`, the browser `Navigator`, and the
52
+ `node:http` server adapter see
53
+ [`guides/src/router.md`](guides/src/router.md).
25
54
 
26
55
  ## Package
27
56
 
@@ -1,4 +1,283 @@
1
- export type * from './types.js';
2
- export * from './helpers.js';
3
- export * from './Navigator.js';
4
- export * from './factories.js';
1
+ import { EmitterErrorHandler } from '@orkestrel/emitter';
2
+ import { EmitterHooks } from '@orkestrel/emitter';
3
+ import { EmitterInterface } from '@orkestrel/emitter';
4
+ import { RouteEntry } from '../core/index.ts';
5
+ import { RouterInterface } from '../core/index.ts';
6
+ import { RouterMatch } from '../core/index.ts';
7
+
8
+ /**
9
+ * Create a {@link NavigatorInterface} — the headless History/hash navigation
10
+ * entity composing one core `Router<RouteEntry<Meta>>`.
11
+ *
12
+ * @remarks
13
+ * Prefer this over `new Navigator(...)` at call sites that only need the
14
+ * interface.
15
+ *
16
+ * @typeParam Meta - The opaque per-route payload a match carries back
17
+ * @param options - The `routes` to register, the `history` toggle (default
18
+ * `false`, hash mode), an optional `base` (history mode), an optional
19
+ * `fallback` path, an optional `guard` hook, opt-in link `intercept`
20
+ * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
21
+ * `on`/`error` wiring
22
+ * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
23
+ * dispatching
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { createNavigator } from '@src/browser'
28
+ *
29
+ * const navigator = createNavigator({
30
+ * routes: [
31
+ * { path: '/users/:id', meta: { title: 'User' } },
32
+ * { path: '/tokens', meta: { title: 'Tokens' } },
33
+ * ],
34
+ * on: { navigate: (match) => (document.title = match.meta.title) },
35
+ * })
36
+ * navigator.start()
37
+ * ```
38
+ */
39
+ export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta>;
40
+
41
+ /**
42
+ * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
43
+ * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
44
+ *
45
+ * @remarks
46
+ * The grammar this package matches everywhere is `/`-prefixed (§4 path
47
+ * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
48
+ * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
49
+ * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
50
+ * back). Total — never throws.
51
+ *
52
+ * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
53
+ * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * extractHashPath('#/users/7?x') // '/users/7'
58
+ * extractHashPath('#/tokens') // '/tokens'
59
+ * extractHashPath('') // '' — the Navigator falls back
60
+ * extractHashPath('#other') // '' — not a `#/` route hash
61
+ * ```
62
+ */
63
+ export declare function extractHashPath(hash: string): string;
64
+
65
+ /**
66
+ * Find the nearest enclosing `<a>` element a DOM event originated from, by
67
+ * walking its composed path — the pure lookup behind history-mode link
68
+ * interception.
69
+ *
70
+ * @remarks
71
+ * Uses `event.composedPath()` (not `event.target`) so a click on a styled
72
+ * child INSIDE an anchor (an icon, a span) still resolves to the anchor.
73
+ * Total — never throws; returns `undefined` when no anchor is found on the
74
+ * path.
75
+ *
76
+ * @param event - The DOM event to search (typically a `click`)
77
+ * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * document.addEventListener('click', (event) => {
82
+ * const anchor = findAnchor(event)
83
+ * if (anchor !== undefined) console.log(anchor.href)
84
+ * })
85
+ * ```
86
+ */
87
+ export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
88
+
89
+ /**
90
+ * The headless History/hash navigation entity — composes one core
91
+ * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
92
+ * every subsequent navigation event, tracking `active`, and emitting
93
+ * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
94
+ * `outlet` — the consumer owns rendering.
95
+ *
96
+ * @typeParam Meta - The opaque per-route payload a match carries back
97
+ *
98
+ * @remarks
99
+ * - **One shared engine.** Each `route.path` is registered on the SAME
100
+ * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
101
+ * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
102
+ * over-param precedence, trailing-slash insensitivity, and
103
+ * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
104
+ * §21).
105
+ * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
106
+ * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
107
+ * `fallback` through the SAME engine → a fallback that ALSO matches nothing
108
+ * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
109
+ * newer navigation) and leaves `active` `undefined`, emitting nothing
110
+ * (§21-honest: no phantom match is fabricated) → the optional `guard` may
111
+ * veto → on a verdict, `active` is set and `navigate` emitted.
112
+ * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
113
+ * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
114
+ * that resolves after its navigation was superseded (`signal.aborted`) is
115
+ * discarded, same as a `false`/rejected verdict. A guard throw routes to
116
+ * the `error` handler and vetoes. `stop()`/`destroy()` also abort the
117
+ * pending handle.
118
+ * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds
119
+ * `hashchange`; history mode (`history: true`) binds `popstate` and, when
120
+ * `intercept` is set, same-origin `<a>` click interception (a plain
121
+ * left-click with no modifier keys, `target`, or `download` attribute).
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const navigator = new Navigator<{ readonly title: string }>({
126
+ * routes: [
127
+ * { path: '/users/:id', meta: { title: 'User' } },
128
+ * { path: '/tokens', meta: { title: 'Tokens' } },
129
+ * ],
130
+ * })
131
+ * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))
132
+ * navigator.start() // resolves the current hash now, and on every hashchange
133
+ * navigator.navigate('/tokens')
134
+ * ```
135
+ */
136
+ declare class Navigator_2<Meta> implements NavigatorInterface<Meta> {
137
+ #private;
138
+ constructor(options: NavigatorOptions<Meta>);
139
+ get router(): RouterInterface<RouteEntry<Meta>>;
140
+ get emitter(): EmitterInterface<NavigatorEventMap<Meta>>;
141
+ get active(): RouterMatch<Meta> | undefined;
142
+ start(): void;
143
+ stop(): void;
144
+ navigate(path: string): void;
145
+ match(path: string): RouterMatch<Meta> | undefined;
146
+ destroy(): void;
147
+ }
148
+ export { Navigator_2 as Navigator }
149
+
150
+ /**
151
+ * The `Navigator`'s event map (AGENTS §13) — the single `navigate` signal a
152
+ * consumer observes.
153
+ *
154
+ * @typeParam Meta - The opaque per-route payload the resolved match carries
155
+ *
156
+ * @remarks
157
+ * `navigate` fires once per successful resolution (start, hashchange/popstate,
158
+ * `navigate()`, link interception) — never for a vetoed or superseded navigation
159
+ * ({@link NavigatorOptions.guard}), and never when a miss's fallback also
160
+ * misses (§21-honest: `active` is left `undefined`, nothing emitted).
161
+ */
162
+ export declare type NavigatorEventMap<Meta> = {
163
+ readonly navigate: readonly [match: RouterMatch<Meta>];
164
+ };
165
+
166
+ /**
167
+ * The headless History/hash navigation entity contract (the §4.5 behavioral-
168
+ * interface role for the one-class-per-file `Navigator`). Composes a core
169
+ * `Router<RouteEntry<Meta>>`, resolves the current location on `start()` and
170
+ * on every subsequent navigation event, tracks `active`, and emits
171
+ * `navigate` through the AGENTS §13 {@link EmitterInterface}.
172
+ *
173
+ * @typeParam Meta - The opaque per-route payload a match carries back
174
+ *
175
+ * @remarks
176
+ * - `router` — the underlying registry, exposed READONLY for introspection
177
+ * (the same object routes were registered on).
178
+ * - `emitter` — the AGENTS §13 observable surface for {@link NavigatorEventMap}.
179
+ * - `active` — the currently-resolved {@link RouterMatch}, or `undefined`
180
+ * before the first resolve (or when a miss's fallback also misses).
181
+ * - `start()` — begin listening (`hashchange` in hash mode; `popstate` +
182
+ * optional link interception in history mode) and resolve the current
183
+ * location now. Idempotent — a second call is a no-op.
184
+ * - `stop()` — stop listening. Idempotent.
185
+ * - `navigate(path)` — navigate programmatically: sets `location.hash` (hash
186
+ * mode) or calls `history.pushState` (history mode), then resolves. A
187
+ * no-op hash navigation (already the active hash) resolves directly, since
188
+ * no `hashchange` would otherwise fire.
189
+ * - `match(path)` — a PURE lookup through the underlying `Router`: no
190
+ * location read, no fallback, no guard, no emit.
191
+ * - `destroy()` — `stop()` plus tear down the `#emitter` (AGENTS §13).
192
+ */
193
+ export declare interface NavigatorInterface<Meta> {
194
+ readonly router: RouterInterface<RouteEntry<Meta>>;
195
+ readonly emitter: EmitterInterface<NavigatorEventMap<Meta>>;
196
+ readonly active: RouterMatch<Meta> | undefined;
197
+ start(): void;
198
+ stop(): void;
199
+ navigate(path: string): void;
200
+ match(path: string): RouterMatch<Meta> | undefined;
201
+ destroy(): void;
202
+ }
203
+
204
+ /**
205
+ * Options for `createNavigator` — the `routes` to dispatch between, the
206
+ * navigation substrate, the optional guard hook, and the AGENTS §13 emitter
207
+ * wiring.
208
+ *
209
+ * @typeParam Meta - The opaque payload each route may carry
210
+ *
211
+ * @remarks
212
+ * - `routes` — the route entries to register once with the shared core
213
+ * `Router` (each `path` compiled once); registration order does NOT decide
214
+ * precedence — specificity does (literal-over-param-over-wildcard).
215
+ * - `history` — `false` (default, hash mode: `#/…` + `hashchange`, zero
216
+ * server configuration) or `true` (history mode: `pushState`/`popstate`).
217
+ * - `base` — a history-mode path prefix stripped from `location.pathname`
218
+ * before matching, and prepended when navigating (`navigate`, link
219
+ * interception). Ignored unless `history` is set.
220
+ * - `fallback` — the route PATTERN to resolve when the current location
221
+ * matches NOTHING. Omitted ⇒ the first route's path. A `fallback` that
222
+ * itself matches no registered route leaves `active` `undefined` and emits
223
+ * nothing (§21-honest: no phantom match is fabricated).
224
+ * - `guard` — `(to, from, signal) => boolean | Promise<boolean>`, called
225
+ * before a navigation commits; a `false`/rejected verdict, or one arriving
226
+ * after the navigation was SUPERSEDED (`signal.aborted`), is discarded —
227
+ * `active` stays unchanged and nothing is emitted. `signal` fires when a
228
+ * NEWER navigation starts (or on `stop`/`destroy`), so a slow async guard
229
+ * can cancel its own work off it. A throw routes to the `error` handler
230
+ * below and vetoes the navigation.
231
+ * - `intercept` — opt-in same-origin `<a>` click interception (history mode
232
+ * only): a plain left-click on a same-origin link with no modifier keys,
233
+ * no `target`, and no `download` attribute is intercepted into `navigate`.
234
+ * - `sensitive` — forwarded to the underlying `Router` (default `true`).
235
+ * - `on` — initial `NavigatorEventMap` listeners (AGENTS §8/§13).
236
+ * - `error` — the emitter's listener-error handler (AGENTS §13); ALSO the
237
+ * handler a thrown {@link guard} routes to (the Navigator's own pipeline,
238
+ * not a listener throw, so it is surfaced through the same channel).
239
+ */
240
+ export declare interface NavigatorOptions<Meta> {
241
+ readonly routes: readonly RouteEntry<Meta>[];
242
+ readonly history?: boolean;
243
+ readonly base?: string;
244
+ readonly fallback?: string;
245
+ readonly guard?: (to: RouterMatch<Meta>, from: RouterMatch<Meta> | undefined, signal: AbortSignal) => boolean | Promise<boolean>;
246
+ readonly intercept?: boolean;
247
+ readonly sensitive?: boolean;
248
+ readonly on?: EmitterHooks<NavigatorEventMap<Meta>>;
249
+ readonly error?: EmitterErrorHandler;
250
+ }
251
+
252
+ /**
253
+ * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
254
+ * either navigation mode — the one seam `extractHashPath` (hash mode) and
255
+ * history-mode base-stripping share.
256
+ *
257
+ * @remarks
258
+ * Hash mode (`history: false`) reads `location.hash` through
259
+ * {@link extractHashPath}. History mode (`history: true`) reads
260
+ * `location.pathname` and strips a leading `base` prefix when one is
261
+ * configured: `base` itself maps to the root `'/'`; a pathname that is not
262
+ * under `base` is returned unchanged (a base mismatch is not this helper's
263
+ * concern — the `Navigator`'s match then simply misses). Total — never throws.
264
+ *
265
+ * @param location - The `hash` + `pathname` pair to resolve from (accepts a
266
+ * real `Location` or any object shaped the same, for pure unit testing)
267
+ * @param history - The navigation substrate: `false` for hash mode, `true`
268
+ * for history mode
269
+ * @param base - The history-mode path prefix to strip (ignored in hash mode;
270
+ * omit for no prefix)
271
+ * @returns The `/`-prefixed pathname to match
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'
276
+ * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'
277
+ * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'
278
+ * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'
279
+ * ```
280
+ */
281
+ export declare function resolveLocationPath(location: Pick<Location, 'hash' | 'pathname'>, history: boolean, base?: string): string;
282
+
283
+ export { }
@@ -1,4 +1,7 @@
1
- import { canonicalizePath, createRouter, joinPaths } from "../core/index.cjs";
1
+ import { createAbort } from "@orkestrel/abort";
2
+ import { Emitter } from "@orkestrel/emitter";
3
+ import { isFunction, isString } from "@orkestrel/contract";
4
+ import { canonicalizePath, createRouter, joinPaths } from "../core/index.js";
2
5
  //#region src/browser/helpers.ts
3
6
  /**
4
7
  * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
@@ -92,305 +95,6 @@ function findAnchor(event) {
92
95
  for (const node of event.composedPath()) if (node instanceof HTMLAnchorElement) return node;
93
96
  }
94
97
  //#endregion
95
- //#region node_modules/@orkestrel/abort/dist/src/core/index.js
96
- /**
97
- * Link an own `AbortSignal` to an optional parent signal.
98
- *
99
- * @remarks
100
- * When `parent` is `undefined`, the own signal is returned unchanged. When a
101
- * parent is given, the result is `AbortSignal.any([own, parent])`, which fires
102
- * on EITHER the own signal aborting or the parent aborting — without
103
- * re-implementing listener wiring. A parent that has ALREADY aborted makes the
104
- * combined signal born aborted (carrying the parent's reason).
105
- *
106
- * @param own - The instance's own signal.
107
- * @param parent - An optional parent signal to link against.
108
- * @returns `own` unchanged when `parent` is `undefined`, otherwise
109
- * `AbortSignal.any([own, parent])`.
110
- *
111
- * @example
112
- * ```ts
113
- * import { linkSignal } from '@src/core'
114
- *
115
- * const controller = new AbortController()
116
- * const linked = linkSignal(controller.signal, undefined) // controller.signal
117
- * ```
118
- */
119
- function linkSignal(own, parent) {
120
- return parent === void 0 ? own : AbortSignal.any([own, parent]);
121
- }
122
- Object.freeze([
123
- "null",
124
- "boolean",
125
- "object",
126
- "array",
127
- "number",
128
- "integer",
129
- "string"
130
- ]);
131
- /** Determine whether a value is a string. */
132
- function isString$1(value) {
133
- return typeof value === "string";
134
- }
135
- /**
136
- * A cancellation handle — a thin, traceable wrapper over a native
137
- * `AbortController` whose exposed `signal` can be linked to a parent signal.
138
- *
139
- * @remarks
140
- * - **Own controller.** The instance owns a private `AbortController`; `abort`
141
- * aborts it, and `aborted` reads the exposed signal. `abort(reason)` keeps any
142
- * DEFINED reason verbatim (including a falsy `null` / `0` / `''` / `false`);
143
- * `abort()` / `abort(undefined)` defaults `signal.reason` to an `AbortError`
144
- * `DOMException`. Aborting is idempotent — the first reason sticks.
145
- * - **Parent linking.** When `options.signal` is given, the exposed `signal` is
146
- * `AbortSignal.any([own, parent])`, so it fires on EITHER the own `abort()` or
147
- * the parent aborting — without re-implementing listener wiring. A parent that
148
- * has ALREADY aborted makes the handle born aborted (carrying the parent's reason).
149
- * - **Traceable.** Each handle carries an `id` (caller-supplied or a random UUID)
150
- * for correlating cancellations across the system.
151
- * - **Event-free.** A pure functional primitive — no Emitter, no events.
152
- *
153
- * @example
154
- * ```ts
155
- * const abort = new Abort()
156
- * abort.signal.addEventListener('abort', () => stop(), { once: true })
157
- * abort.abort('cancelled') // flips `aborted`, fires `signal` with the reason
158
- * ```
159
- */
160
- var Abort = class {
161
- #controller = new AbortController();
162
- id;
163
- signal;
164
- constructor(options) {
165
- this.id = isString$1(options?.id) ? options.id : crypto.randomUUID();
166
- this.signal = linkSignal(this.#controller.signal, options?.signal);
167
- }
168
- get aborted() {
169
- return this.signal.aborted;
170
- }
171
- abort(reason) {
172
- this.#controller.abort(reason);
173
- }
174
- };
175
- /**
176
- * Create a cancellation handle — a thin, traceable wrapper over a native
177
- * `AbortController` whose `signal` can be linked to a parent signal.
178
- *
179
- * @remarks
180
- * The created handle's `signal` fires when its own `abort()` is called; when
181
- * `options.signal` is given, it ALSO fires when that parent signal aborts (linked
182
- * via `AbortSignal.any`). Pass `options.id` to label the handle for tracing, or
183
- * let it default to a random UUID.
184
- *
185
- * @param options - Optional `id` (a trace label; defaults to a random UUID) and
186
- * `signal` (a parent signal whose abort also fires the created handle's signal)
187
- * @returns A working {@link AbortInterface}
188
- *
189
- * @example
190
- * ```ts
191
- * import { createAbort } from '@src/core'
192
- *
193
- * const abort = createAbort()
194
- * const work = fetch(url, { signal: abort.signal })
195
- * abort.abort() // cancels the fetch via the linked native signal
196
- * ```
197
- *
198
- * @example
199
- * ```ts
200
- * // Link to a parent so a parent cancellation also aborts the child.
201
- * const parent = createAbort()
202
- * const child = createAbort({ signal: parent.signal })
203
- * parent.abort() // child.aborted is now true
204
- * ```
205
- */
206
- function createAbort(options) {
207
- return new Abort(options);
208
- }
209
- //#endregion
210
- //#region node_modules/@orkestrel/emitter/dist/src/core/index.js
211
- /**
212
- * Extract the own enumerable keys of a mapped object, typed as its key union.
213
- *
214
- * @remarks
215
- * `Object.keys` widens its result to `string[]`, which breaks the key↔value
216
- * correlation a mapped type (like `EmitterHooks<TMap>`) otherwise guarantees.
217
- * A `for…in` push into a `keyof`-typed array narrows the result back,
218
- * type-safely and with no assertion.
219
- *
220
- * @typeParam T - The object shape whose keys are extracted.
221
- * @param object - The object to read keys from.
222
- * @returns The object's own enumerable keys, typed as `(keyof T)[]`.
223
- *
224
- * @example
225
- * ```ts
226
- * import { extractKeys } from '@src/core'
227
- *
228
- * const hooks = { tick: () => {}, done: () => {} }
229
- * extractKeys(hooks) // ['tick', 'done']
230
- * extractKeys({}) // []
231
- * ```
232
- */
233
- function extractKeys(object) {
234
- const collected = [];
235
- for (const key in object) collected.push(key);
236
- return collected;
237
- }
238
- Object.freeze([
239
- "null",
240
- "boolean",
241
- "object",
242
- "array",
243
- "number",
244
- "integer",
245
- "string"
246
- ]);
247
- /** Determine whether a value is callable. */
248
- function isFunction$1(value) {
249
- return typeof value === "function";
250
- }
251
- /**
252
- * A typed synchronous event emitter — the foundational observable primitive of
253
- * the codebase (AGENTS §13). Stateful entities OWN one as a `#emitter` field and
254
- * expose it through `readonly emitter`; they never inherit from it.
255
- *
256
- * @typeParam TMap - The event map: each event name to the argument tuple its
257
- * listeners receive.
258
- *
259
- * @remarks
260
- * - **Synchronous.** `emit` invokes listeners in registration order, in the
261
- * current tick.
262
- * - **Listener isolation.** A throwing listener never stops its siblings: every
263
- * listener runs, and a throw is routed to the `error` handler
264
- * ({@link EmitterOptions.error}) — never rethrown. Every throwing listener
265
- * surfaces (not just the first), and with no `error` handler a throw is swallowed
266
- * silently. The `error` handler runs inside its own try/catch, so a throwing
267
- * error-handler is swallowed too (anti-recursion — it cannot escape or re-enter).
268
- * - **Per-event storage.** Listeners live in a per-event `Set`, so every public
269
- * method is precisely typed with no assertions.
270
- * - **Destroyed → no-op.** After `destroy()`, `on` / `once` / `emit` do nothing
271
- * and `destroyed` is `true`.
272
- *
273
- * @example
274
- * ```ts
275
- * type CounterEventMap = {
276
- * tick: readonly [count: number]
277
- * done: readonly []
278
- * }
279
- *
280
- * const emitter = new Emitter<CounterEventMap>({
281
- * on: { done: () => stop() },
282
- * error: (error, event) => log(`listener for ${event} threw`, error),
283
- * })
284
- * emitter.on('tick', (count) => render(count))
285
- * emitter.emit('tick', 1)
286
- * ```
287
- */
288
- var Emitter = class {
289
- #destroyed = false;
290
- #listeners = {};
291
- #wrappers = {};
292
- #error;
293
- constructor(options) {
294
- const error = options?.error;
295
- this.#error = isFunction$1(error) ? error : void 0;
296
- const hooks = options?.on;
297
- if (hooks !== void 0) this.#wire(hooks);
298
- }
299
- get destroyed() {
300
- return this.#destroyed;
301
- }
302
- on(event, handler) {
303
- if (this.#destroyed) return;
304
- (this.#listeners[event] ??= /* @__PURE__ */ new Set()).add(handler);
305
- }
306
- once(event, handler) {
307
- if (this.#destroyed) return;
308
- const pending = this.#wrappers[event] ??= /* @__PURE__ */ new Map();
309
- const wrapper = (...args) => {
310
- this.#listeners[event]?.delete(wrapper);
311
- const wrappers = pending.get(handler);
312
- wrappers?.delete(wrapper);
313
- if (wrappers !== void 0 && wrappers.size === 0) pending.delete(handler);
314
- handler(...args);
315
- };
316
- const wrappers = pending.get(handler) ?? /* @__PURE__ */ new Set();
317
- wrappers.add(wrapper);
318
- pending.set(handler, wrappers);
319
- this.on(event, wrapper);
320
- }
321
- off(event, handler) {
322
- const listeners = this.#listeners[event];
323
- const wrappers = this.#wrappers[event];
324
- const pending = wrappers?.get(handler);
325
- if (pending !== void 0) {
326
- for (const wrapper of pending) listeners?.delete(wrapper);
327
- wrappers?.delete(handler);
328
- }
329
- listeners?.delete(handler);
330
- }
331
- emit(event, ...args) {
332
- if (this.#destroyed) return;
333
- const listeners = this.#listeners[event];
334
- if (listeners === void 0) return;
335
- for (const handler of [...listeners]) try {
336
- handler(...args);
337
- } catch (error) {
338
- this.#surface(error, event);
339
- }
340
- }
341
- count(event) {
342
- if (event !== void 0) return this.#listeners[event]?.size ?? 0;
343
- let total = 0;
344
- for (const set of Object.values(this.#listeners)) total += set?.size ?? 0;
345
- return total;
346
- }
347
- clear(event) {
348
- if (event !== void 0) {
349
- delete this.#listeners[event];
350
- delete this.#wrappers[event];
351
- return;
352
- }
353
- this.#listeners = {};
354
- this.#wrappers = {};
355
- }
356
- destroy() {
357
- this.#listeners = {};
358
- this.#wrappers = {};
359
- this.#error = void 0;
360
- this.#destroyed = true;
361
- }
362
- #surface(error, event) {
363
- const handler = this.#error;
364
- if (handler === void 0) return;
365
- try {
366
- handler(error, String(event));
367
- } catch {}
368
- }
369
- #wire(hooks) {
370
- for (const event of extractKeys(hooks)) {
371
- const handler = hooks[event];
372
- if (isFunction$1(handler)) this.on(event, handler);
373
- }
374
- }
375
- };
376
- Object.freeze([
377
- "null",
378
- "boolean",
379
- "object",
380
- "array",
381
- "number",
382
- "integer",
383
- "string"
384
- ]);
385
- /** Determine whether a value is a string. */
386
- function isString(value) {
387
- return typeof value === "string";
388
- }
389
- /** Determine whether a value is callable. */
390
- function isFunction(value) {
391
- return typeof value === "function";
392
- }
393
- //#endregion
394
98
  //#region src/browser/Navigator.ts
395
99
  /**
396
100
  * The headless History/hash navigation entity — composes one core