@real-router/core 0.45.1 → 0.45.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.
Files changed (87) hide show
  1. package/dist/cjs/Router-Dh1xgFLI.d.ts.map +1 -0
  2. package/dist/cjs/RouterValidator-TUi8eT8Q.d.ts.map +1 -0
  3. package/dist/cjs/api.d.ts.map +1 -0
  4. package/dist/cjs/index.d.ts.map +1 -0
  5. package/dist/cjs/utils.d.ts.map +1 -0
  6. package/dist/cjs/validation.d.ts.map +1 -0
  7. package/dist/esm/Router-BPkXwb1J.d.mts.map +1 -0
  8. package/dist/esm/RouterValidator-DphcVMEp.d.mts.map +1 -0
  9. package/dist/esm/api.d.mts.map +1 -0
  10. package/dist/esm/index.d.mts.map +1 -0
  11. package/dist/esm/utils.d.mts.map +1 -0
  12. package/dist/esm/validation.d.mts.map +1 -0
  13. package/package.json +9 -12
  14. package/src/Router.ts +684 -0
  15. package/src/RouterError.ts +324 -0
  16. package/src/api/cloneRouter.ts +77 -0
  17. package/src/api/getDependenciesApi.ts +168 -0
  18. package/src/api/getLifecycleApi.ts +65 -0
  19. package/src/api/getPluginApi.ts +167 -0
  20. package/src/api/getRoutesApi.ts +573 -0
  21. package/src/api/helpers.ts +10 -0
  22. package/src/api/index.ts +16 -0
  23. package/src/api/types.ts +12 -0
  24. package/src/constants.ts +87 -0
  25. package/src/createRouter.ts +32 -0
  26. package/src/fsm/index.ts +5 -0
  27. package/src/fsm/routerFSM.ts +120 -0
  28. package/src/getNavigator.ts +30 -0
  29. package/src/guards.ts +46 -0
  30. package/src/helpers.ts +179 -0
  31. package/src/index.ts +50 -0
  32. package/src/internals.ts +173 -0
  33. package/src/namespaces/DependenciesNamespace/dependenciesStore.ts +30 -0
  34. package/src/namespaces/DependenciesNamespace/index.ts +5 -0
  35. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +311 -0
  36. package/src/namespaces/EventBusNamespace/index.ts +5 -0
  37. package/src/namespaces/EventBusNamespace/types.ts +11 -0
  38. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +405 -0
  39. package/src/namespaces/NavigationNamespace/constants.ts +55 -0
  40. package/src/namespaces/NavigationNamespace/index.ts +5 -0
  41. package/src/namespaces/NavigationNamespace/transition/completeTransition.ts +100 -0
  42. package/src/namespaces/NavigationNamespace/transition/errorHandling.ts +124 -0
  43. package/src/namespaces/NavigationNamespace/transition/guardPhase.ts +221 -0
  44. package/src/namespaces/NavigationNamespace/types.ts +100 -0
  45. package/src/namespaces/OptionsNamespace/OptionsNamespace.ts +28 -0
  46. package/src/namespaces/OptionsNamespace/constants.ts +19 -0
  47. package/src/namespaces/OptionsNamespace/helpers.ts +50 -0
  48. package/src/namespaces/OptionsNamespace/index.ts +7 -0
  49. package/src/namespaces/OptionsNamespace/validators.ts +13 -0
  50. package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +291 -0
  51. package/src/namespaces/PluginsNamespace/constants.ts +34 -0
  52. package/src/namespaces/PluginsNamespace/index.ts +7 -0
  53. package/src/namespaces/PluginsNamespace/types.ts +22 -0
  54. package/src/namespaces/PluginsNamespace/validators.ts +28 -0
  55. package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +377 -0
  56. package/src/namespaces/RouteLifecycleNamespace/index.ts +5 -0
  57. package/src/namespaces/RouteLifecycleNamespace/types.ts +10 -0
  58. package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +81 -0
  59. package/src/namespaces/RouterLifecycleNamespace/constants.ts +25 -0
  60. package/src/namespaces/RouterLifecycleNamespace/index.ts +5 -0
  61. package/src/namespaces/RouterLifecycleNamespace/types.ts +26 -0
  62. package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +535 -0
  63. package/src/namespaces/RoutesNamespace/constants.ts +6 -0
  64. package/src/namespaces/RoutesNamespace/forwardChain.ts +34 -0
  65. package/src/namespaces/RoutesNamespace/helpers.ts +126 -0
  66. package/src/namespaces/RoutesNamespace/index.ts +11 -0
  67. package/src/namespaces/RoutesNamespace/routeGuards.ts +62 -0
  68. package/src/namespaces/RoutesNamespace/routesStore.ts +346 -0
  69. package/src/namespaces/RoutesNamespace/types.ts +81 -0
  70. package/src/namespaces/StateNamespace/StateNamespace.ts +211 -0
  71. package/src/namespaces/StateNamespace/helpers.ts +24 -0
  72. package/src/namespaces/StateNamespace/index.ts +5 -0
  73. package/src/namespaces/StateNamespace/types.ts +15 -0
  74. package/src/namespaces/index.ts +35 -0
  75. package/src/stateMetaStore.ts +15 -0
  76. package/src/transitionPath.ts +436 -0
  77. package/src/typeGuards.ts +59 -0
  78. package/src/types/RouterValidator.ts +154 -0
  79. package/src/types.ts +69 -0
  80. package/src/utils/getStaticPaths.ts +50 -0
  81. package/src/utils/index.ts +5 -0
  82. package/src/utils/serializeState.ts +22 -0
  83. package/src/validation.ts +12 -0
  84. package/src/wiring/RouterWiringBuilder.ts +261 -0
  85. package/src/wiring/index.ts +7 -0
  86. package/src/wiring/types.ts +47 -0
  87. package/src/wiring/wireRouter.ts +26 -0
package/src/Router.ts ADDED
@@ -0,0 +1,684 @@
1
+ // packages/core/src/Router.ts
2
+
3
+ /**
4
+ * Router class - facade with integrated namespaces.
5
+ *
6
+ * All functionality is now provided by namespace classes.
7
+ */
8
+
9
+ import { logger } from "@real-router/logger";
10
+ import { EventEmitter } from "event-emitter";
11
+
12
+ import { EMPTY_PARAMS, errorCodes } from "./constants";
13
+ import { createRouterFSM } from "./fsm";
14
+ import { guardDependencies, guardRouteStructure } from "./guards";
15
+ import { createLimits } from "./helpers";
16
+ import {
17
+ createInterceptable,
18
+ createInterceptable2,
19
+ getInternals,
20
+ registerInternals,
21
+ } from "./internals";
22
+ import {
23
+ EventBusNamespace,
24
+ NavigationNamespace,
25
+ OptionsNamespace,
26
+ PluginsNamespace,
27
+ RouteLifecycleNamespace,
28
+ RouterLifecycleNamespace,
29
+ RoutesNamespace,
30
+ StateNamespace,
31
+ createDependenciesStore,
32
+ } from "./namespaces";
33
+ import { CACHED_ALREADY_STARTED_ERROR } from "./namespaces/RouterLifecycleNamespace/constants";
34
+ import { RouterError } from "./RouterError";
35
+ import { getTransitionPath } from "./transitionPath";
36
+ import { isLoggerConfig } from "./typeGuards";
37
+ import { RouterWiringBuilder, wireRouter } from "./wiring";
38
+
39
+ import type { RouterInternals } from "./internals";
40
+ import type { DependenciesStore } from "./namespaces";
41
+ import type { Limits, PluginFactory, Route, RouterEventMap } from "./types";
42
+ import type {
43
+ DefaultDependencies,
44
+ LeaveFn,
45
+ NavigationOptions,
46
+ Options,
47
+ Params,
48
+ Router as RouterInterface,
49
+ State,
50
+ SubscribeFn,
51
+ Unsubscribe,
52
+ } from "@real-router/types";
53
+ import type { CreateMatcherOptions } from "route-tree";
54
+
55
+ const EMPTY_OPTS: Readonly<NavigationOptions> = Object.freeze({});
56
+
57
+ /**
58
+ * Router class with integrated namespace architecture.
59
+ *
60
+ * All functionality is provided by namespace classes:
61
+ * - OptionsNamespace: getOptions (immutable)
62
+ * - DependenciesStore: get/set/remove dependencies
63
+ * - EventEmitter: subscribe
64
+ * - StateNamespace: state storage (getState, setState, getPreviousState)
65
+ * - RoutesNamespace: route tree operations
66
+ * - RouteLifecycleNamespace: canActivate/canDeactivate guards
67
+ * - PluginsNamespace: plugin lifecycle
68
+ * - NavigationNamespace: navigate
69
+ * - RouterLifecycleNamespace: start, stop, isStarted
70
+ *
71
+ * @internal This class implementation is internal. Use createRouter() instead.
72
+ */
73
+ export class Router<
74
+ Dependencies extends DefaultDependencies = DefaultDependencies,
75
+ > implements RouterInterface<Dependencies> {
76
+ [key: string]: unknown;
77
+
78
+ // ============================================================================
79
+ // Namespaces
80
+ // ============================================================================
81
+
82
+ readonly #options: OptionsNamespace;
83
+ readonly #limits: Limits;
84
+ readonly #dependenciesStore: DependenciesStore<Dependencies>;
85
+ readonly #state: StateNamespace;
86
+ readonly #routes: RoutesNamespace<Dependencies>;
87
+ readonly #routeLifecycle: RouteLifecycleNamespace<Dependencies>;
88
+ readonly #plugins: PluginsNamespace<Dependencies>;
89
+ readonly #navigation: NavigationNamespace;
90
+ readonly #lifecycle: RouterLifecycleNamespace;
91
+
92
+ readonly #eventBus: EventBusNamespace;
93
+
94
+ // ============================================================================
95
+ // Constructor
96
+ // ============================================================================
97
+
98
+ /**
99
+ * @param routes - Route definitions
100
+ * @param options - Router options
101
+ * @param dependencies - DI dependencies
102
+ */
103
+ constructor(
104
+ routes: Route<Dependencies>[] = [],
105
+ options: Partial<Options> = {},
106
+ dependencies: Dependencies = {} as Dependencies,
107
+ ) {
108
+ // Configure logger if provided
109
+ if (options.logger && isLoggerConfig(options.logger)) {
110
+ logger.configure(options.logger);
111
+ delete options.logger;
112
+ }
113
+
114
+ // =========================================================================
115
+ // Validate inputs before creating namespaces
116
+ // =========================================================================
117
+
118
+ // Always validate options
119
+ OptionsNamespace.validateOptionsIsObject(options);
120
+
121
+ // Unconditional guard-level validation before creating namespaces
122
+ guardDependencies(dependencies);
123
+
124
+ if (routes.length > 0) {
125
+ guardRouteStructure(routes);
126
+ }
127
+
128
+ // =========================================================================
129
+ // Create Namespaces
130
+ // =========================================================================
131
+
132
+ this.#options = new OptionsNamespace(options);
133
+ this.#limits = createLimits(options.limits);
134
+ this.#dependenciesStore =
135
+ createDependenciesStore<Dependencies>(dependencies);
136
+ this.#state = new StateNamespace();
137
+ this.#routes = new RoutesNamespace<Dependencies>(
138
+ routes,
139
+ deriveMatcherOptions(this.#options.get()),
140
+ );
141
+ this.#routeLifecycle = new RouteLifecycleNamespace<Dependencies>();
142
+ this.#plugins = new PluginsNamespace<Dependencies>();
143
+ this.#navigation = new NavigationNamespace();
144
+ this.#lifecycle = new RouterLifecycleNamespace();
145
+
146
+ // =========================================================================
147
+ // Initialize EventBus
148
+ // =========================================================================
149
+
150
+ const routerFSM = createRouterFSM();
151
+ // eslint-disable-next-line unicorn/prefer-event-target
152
+ const emitter = new EventEmitter<RouterEventMap>({
153
+ onListenerError: (eventName, error) => {
154
+ logger.error("Router", `Error in listener for ${eventName}:`, error);
155
+ },
156
+ onListenerWarn: (eventName, count) => {
157
+ logger.warn(
158
+ "router.addEventListener",
159
+ `Event "${eventName}" has ${count} listeners — possible memory leak`,
160
+ );
161
+ },
162
+ });
163
+
164
+ this.#eventBus = new EventBusNamespace({ routerFSM, emitter });
165
+
166
+ // =========================================================================
167
+ // Wire Dependencies
168
+ // =========================================================================
169
+
170
+ wireRouter(
171
+ new RouterWiringBuilder<Dependencies>({
172
+ router: this,
173
+ options: this.#options,
174
+ limits: this.#limits,
175
+ dependenciesStore: this.#dependenciesStore,
176
+ state: this.#state,
177
+ routes: this.#routes,
178
+ routeLifecycle: this.#routeLifecycle,
179
+ plugins: this.#plugins,
180
+ navigation: this.#navigation,
181
+ lifecycle: this.#lifecycle,
182
+ eventBus: this.#eventBus,
183
+ }),
184
+ );
185
+
186
+ // =========================================================================
187
+ // Register Internals (WeakMap for plugin/infrastructure access)
188
+ // =========================================================================
189
+
190
+ const interceptorsMap: RouterInternals["interceptors"] = new Map();
191
+
192
+ registerInternals(this, {
193
+ makeState: (name, params, path, meta) =>
194
+ this.#state.makeState(name, params, path, meta),
195
+ forwardState: createInterceptable2(
196
+ "forwardState",
197
+ (name: string, params: Params) =>
198
+ this.#routes.forwardState(name, params),
199
+ interceptorsMap,
200
+ ) as unknown as RouterInternals["forwardState"],
201
+ buildStateResolved: (name, params) =>
202
+ this.#routes.buildStateResolved(name, params),
203
+ matchPath: (path, matchOptions) =>
204
+ this.#routes.matchPath(path, matchOptions),
205
+ getOptions: () => this.#options.get(),
206
+ addEventListener: (eventName, cb) =>
207
+ this.#eventBus.addEventListener(eventName, cb),
208
+ buildPath: createInterceptable2(
209
+ "buildPath",
210
+ (route: string, params?: Params) =>
211
+ this.#routes.buildPath(
212
+ route,
213
+ params ?? EMPTY_PARAMS,
214
+ this.#options.get(),
215
+ ),
216
+ interceptorsMap,
217
+ ) as unknown as RouterInternals["buildPath"],
218
+ start: createInterceptable(
219
+ "start",
220
+ (path: string) => {
221
+ return this.#lifecycle.start(path);
222
+ },
223
+ interceptorsMap,
224
+ ),
225
+ interceptors: interceptorsMap,
226
+ setRootPath: (rootPath) => {
227
+ this.#routes.setRootPath(rootPath);
228
+ },
229
+ getRootPath: () => this.#routes.getStore().rootPath,
230
+ getTree: () => this.#routes.getStore().tree,
231
+ isDisposed: () => this.#eventBus.isDisposed(),
232
+ validator: null,
233
+ // Dependencies (issue #172)
234
+ dependenciesGetStore: () => this.#dependenciesStore,
235
+ // Clone support (issue #173)
236
+ cloneOptions: () => ({ ...this.#options.get() }),
237
+ cloneDependencies: () =>
238
+ ({ ...this.#dependenciesStore.dependencies }) as Record<
239
+ string,
240
+ unknown
241
+ >,
242
+ getLifecycleFactories: () => this.#routeLifecycle.getFactories(),
243
+ getPluginFactories: () => this.#plugins.getAll(),
244
+ routeGetStore: () => this.#routes.getStore(),
245
+ // Cross-namespace state (issue #174)
246
+ getStateName: () => this.#state.get()?.name,
247
+ isTransitioning: () => this.#eventBus.isTransitioning(),
248
+ clearState: () => {
249
+ this.#state.set(undefined);
250
+ },
251
+ setState: (state) => {
252
+ this.#state.set(state);
253
+ },
254
+ routerExtensions: [],
255
+ });
256
+
257
+ // =========================================================================
258
+ // Bind Public Methods
259
+ // =========================================================================
260
+ // All public methods that access private fields must be bound to preserve
261
+ // `this` context when methods are extracted as references.
262
+ // See: https://github.com/tc39/proposal-bind-operator
263
+ // =========================================================================
264
+
265
+ // Path & State Building
266
+ this.isActiveRoute = this.isActiveRoute.bind(this);
267
+ this.buildPath = this.buildPath.bind(this);
268
+
269
+ // State Management
270
+ this.getState = this.getState.bind(this);
271
+ this.getPreviousState = this.getPreviousState.bind(this);
272
+ this.areStatesEqual = this.areStatesEqual.bind(this);
273
+ this.shouldUpdateNode = this.shouldUpdateNode.bind(this);
274
+
275
+ // Router Lifecycle
276
+ this.isActive = this.isActive.bind(this);
277
+ this.start = this.start.bind(this);
278
+ this.stop = this.stop.bind(this);
279
+ this.dispose = this.dispose.bind(this);
280
+
281
+ // Route Lifecycle (Guards)
282
+ this.canNavigateTo = this.canNavigateTo.bind(this);
283
+
284
+ // Plugins
285
+ this.usePlugin = this.usePlugin.bind(this);
286
+
287
+ // Navigation
288
+ this.navigate = this.navigate.bind(this);
289
+ this.navigateToDefault = this.navigateToDefault.bind(this);
290
+ this.navigateToNotFound = this.navigateToNotFound.bind(this);
291
+
292
+ // Subscription
293
+ this.subscribe = this.subscribe.bind(this);
294
+ this.subscribeLeave = this.subscribeLeave.bind(this);
295
+ this.isLeaveApproved = this.isLeaveApproved.bind(this);
296
+ }
297
+
298
+ // ============================================================================
299
+ // Path & State Building
300
+ // ============================================================================
301
+
302
+ isActiveRoute(
303
+ name: string,
304
+ params?: Params,
305
+ strictEquality?: boolean,
306
+ ignoreQueryParams?: boolean,
307
+ ): boolean {
308
+ getInternals(this).validator?.routes.validateIsActiveRouteArgs(
309
+ name,
310
+ params,
311
+ strictEquality,
312
+ ignoreQueryParams,
313
+ );
314
+
315
+ getInternals(this).validator?.routes.validateRouteName(
316
+ name,
317
+ "isActiveRoute",
318
+ );
319
+
320
+ // Empty string is special case - warn and return false (root node is not a parent)
321
+ if (name === "") {
322
+ logger.warn(
323
+ "real-router",
324
+ 'isActiveRoute("") called with empty string. Root node is not considered a parent of any route.',
325
+ );
326
+
327
+ return false;
328
+ }
329
+
330
+ return this.#routes.isActiveRoute(
331
+ name,
332
+ params,
333
+ strictEquality,
334
+ ignoreQueryParams,
335
+ );
336
+ }
337
+
338
+ buildPath(route: string, params?: Params): string {
339
+ const ctx = getInternals(this);
340
+
341
+ ctx.validator?.routes.validateBuildPathArgs(route);
342
+ ctx.validator?.navigation.validateParams(params, "buildPath");
343
+
344
+ return ctx.buildPath(route, params);
345
+ }
346
+
347
+ // ============================================================================
348
+ // State Management (delegated to StateNamespace)
349
+ // ============================================================================
350
+
351
+ getState<P extends Params = Params>(): State<P> | undefined {
352
+ return this.#state.get<P>();
353
+ }
354
+
355
+ getPreviousState(): State | undefined {
356
+ return this.#state.getPrevious();
357
+ }
358
+
359
+ areStatesEqual(
360
+ state1: State | undefined,
361
+ state2: State | undefined,
362
+ ignoreQueryParams = true,
363
+ ): boolean {
364
+ getInternals(this).validator?.state.validateAreStatesEqualArgs(
365
+ state1,
366
+ state2,
367
+ ignoreQueryParams,
368
+ );
369
+
370
+ return this.#state.areStatesEqual(state1, state2, ignoreQueryParams);
371
+ }
372
+
373
+ shouldUpdateNode(
374
+ nodeName: string,
375
+ ): (toState: State, fromState?: State) => boolean {
376
+ getInternals(this).validator?.routes.validateShouldUpdateNodeArgs(nodeName);
377
+
378
+ return RoutesNamespace.shouldUpdateNode(nodeName);
379
+ }
380
+
381
+ // ============================================================================
382
+ // Router Lifecycle
383
+ // ============================================================================
384
+
385
+ isActive(): boolean {
386
+ return this.#eventBus.isActive();
387
+ }
388
+
389
+ start(startPath: string): Promise<State> {
390
+ if (!this.#eventBus.canStart()) {
391
+ return Promise.reject(CACHED_ALREADY_STARTED_ERROR);
392
+ }
393
+
394
+ getInternals(this).validator?.navigation.validateStartArgs(startPath);
395
+
396
+ this.#eventBus.sendStart();
397
+
398
+ const promiseState = getInternals(this)
399
+ .start(startPath)
400
+ .catch((error: unknown) => {
401
+ if (this.#eventBus.isReady()) {
402
+ this.#lifecycle.stop();
403
+ this.#eventBus.sendStop();
404
+ }
405
+
406
+ throw error;
407
+ });
408
+
409
+ Router.#suppressUnhandledRejection(promiseState);
410
+
411
+ return promiseState;
412
+ }
413
+
414
+ stop(): this {
415
+ this.#navigation.abortCurrentNavigation();
416
+ this.#eventBus.sendCancelIfPossible(this.#state.get());
417
+
418
+ if (!this.#eventBus.isReady() && !this.#eventBus.isTransitioning()) {
419
+ return this;
420
+ }
421
+
422
+ this.#lifecycle.stop();
423
+ this.#eventBus.sendStop();
424
+
425
+ return this;
426
+ }
427
+
428
+ dispose(): void {
429
+ if (this.#eventBus.isDisposed()) {
430
+ return;
431
+ }
432
+
433
+ this.#navigation.abortCurrentNavigation();
434
+ this.#eventBus.sendCancelIfPossible(this.#state.get());
435
+
436
+ if (this.#eventBus.isReady() || this.#eventBus.isTransitioning()) {
437
+ this.#lifecycle.stop();
438
+ this.#eventBus.sendStop();
439
+ }
440
+
441
+ this.#eventBus.sendDispose();
442
+ this.#eventBus.clearAll();
443
+
444
+ this.#plugins.disposeAll();
445
+
446
+ // Safety net: clean up extensions plugins failed to remove in teardown
447
+ const ctx = getInternals(this);
448
+
449
+ for (const extension of ctx.routerExtensions) {
450
+ for (const key of extension.keys) {
451
+ delete (this as Record<string, unknown>)[key];
452
+ }
453
+ }
454
+
455
+ ctx.routerExtensions.length = 0;
456
+
457
+ this.#routes.clearRoutes();
458
+ this.#routeLifecycle.clearAll();
459
+ this.#state.reset();
460
+ this.#dependenciesStore.dependencies = Object.create(
461
+ null,
462
+ ) as Partial<Dependencies>;
463
+
464
+ this.#markDisposed();
465
+ }
466
+
467
+ // ============================================================================
468
+ // Route Lifecycle (Guards)
469
+ // ============================================================================
470
+
471
+ canNavigateTo(name: string, params?: Params): boolean {
472
+ const ctx = getInternals(this);
473
+
474
+ ctx.validator?.routes.validateRouteName(name, "canNavigateTo");
475
+ ctx.validator?.navigation.validateParams(params, "canNavigateTo");
476
+
477
+ if (!this.#routes.hasRoute(name)) {
478
+ return false;
479
+ }
480
+
481
+ const { name: resolvedName, params: resolvedParams } = ctx.forwardState(
482
+ name,
483
+ params ?? {},
484
+ );
485
+ const toState = this.#state.makeState(resolvedName, resolvedParams);
486
+ const fromState = this.#state.get();
487
+
488
+ const { toDeactivate, toActivate } = getTransitionPath(toState, fromState);
489
+
490
+ return this.#routeLifecycle.canNavigateTo(
491
+ toDeactivate,
492
+ toActivate,
493
+ toState,
494
+ fromState,
495
+ );
496
+ }
497
+
498
+ // ============================================================================
499
+ // Plugins
500
+ // ============================================================================
501
+
502
+ usePlugin(
503
+ ...plugins: (PluginFactory<Dependencies> | false | null | undefined)[]
504
+ ): Unsubscribe {
505
+ const filtered = plugins.filter(Boolean) as PluginFactory<Dependencies>[];
506
+
507
+ if (filtered.length === 0) {
508
+ return () => {};
509
+ }
510
+
511
+ const ctx = getInternals(this);
512
+
513
+ ctx.validator?.plugins.validatePluginLimit(
514
+ this.#plugins.count(),
515
+ this.#limits,
516
+ );
517
+ for (const plugin of filtered) {
518
+ ctx.validator?.plugins.validateNoDuplicatePlugins(
519
+ plugin,
520
+ this.#plugins.getAll(),
521
+ );
522
+ }
523
+
524
+ return this.#plugins.use(...filtered);
525
+ }
526
+
527
+ // ============================================================================
528
+ // Subscription (backed by EventEmitter)
529
+ // ============================================================================
530
+
531
+ subscribe(listener: SubscribeFn): Unsubscribe {
532
+ EventBusNamespace.validateSubscribeListener(listener);
533
+
534
+ return this.#eventBus.subscribe(listener);
535
+ }
536
+
537
+ subscribeLeave(listener: LeaveFn): Unsubscribe {
538
+ EventBusNamespace.validateSubscribeLeaveListener(listener);
539
+
540
+ return this.#eventBus.subscribeLeave(listener);
541
+ }
542
+
543
+ isLeaveApproved(): boolean {
544
+ return this.#eventBus.isLeaveApproved();
545
+ }
546
+
547
+ // ============================================================================
548
+ // Navigation
549
+ // ============================================================================
550
+
551
+ navigate(
552
+ routeName: string,
553
+ routeParams?: Params,
554
+ options?: NavigationOptions,
555
+ ): Promise<State> {
556
+ const ctx = getInternals(this);
557
+
558
+ ctx.validator?.navigation.validateNavigateArgs(routeName);
559
+ ctx.validator?.navigation.validateParams(routeParams, "navigate");
560
+
561
+ const opts = options ?? EMPTY_OPTS;
562
+
563
+ ctx.validator?.navigation.validateNavigationOptions(opts, "navigate");
564
+
565
+ const promiseState = this.#navigation.navigate(
566
+ routeName,
567
+ routeParams ?? EMPTY_PARAMS,
568
+ opts,
569
+ );
570
+
571
+ if (this.#navigation.lastSyncResolved) {
572
+ this.#navigation.lastSyncResolved = false;
573
+ } else if (this.#navigation.lastSyncRejected) {
574
+ // Cached rejection — already pre-suppressed at module load, skip .catch()
575
+ this.#navigation.lastSyncRejected = false;
576
+ } else {
577
+ Router.#suppressUnhandledRejection(promiseState);
578
+ }
579
+
580
+ return promiseState;
581
+ }
582
+
583
+ navigateToDefault(options?: NavigationOptions): Promise<State> {
584
+ const ctx = getInternals(this);
585
+
586
+ ctx.validator?.navigation.validateNavigateToDefaultArgs(options);
587
+
588
+ const opts = options ?? EMPTY_OPTS;
589
+
590
+ ctx.validator?.navigation.validateNavigationOptions(
591
+ opts,
592
+ "navigateToDefault",
593
+ );
594
+
595
+ const promiseState = this.#navigation.navigateToDefault(opts);
596
+
597
+ if (this.#navigation.lastSyncResolved) {
598
+ this.#navigation.lastSyncResolved = false;
599
+ } else if (this.#navigation.lastSyncRejected) {
600
+ this.#navigation.lastSyncRejected = false;
601
+ } else {
602
+ Router.#suppressUnhandledRejection(promiseState);
603
+ }
604
+
605
+ return promiseState;
606
+ }
607
+
608
+ navigateToNotFound(path?: string): State {
609
+ if (!this.#eventBus.isActive()) {
610
+ throw new RouterError(errorCodes.ROUTER_NOT_STARTED);
611
+ }
612
+
613
+ if (path !== undefined && typeof path !== "string") {
614
+ throw new TypeError(
615
+ `[router.navigateToNotFound] path must be a string, got ${typeof path}`,
616
+ );
617
+ }
618
+
619
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- isActive() guarantees state exists
620
+ const resolvedPath = path ?? this.#state.get()!.path;
621
+
622
+ return this.#navigation.navigateToNotFound(resolvedPath);
623
+ }
624
+
625
+ /**
626
+ * Pre-allocated callback for #suppressUnhandledRejection.
627
+ * Avoids creating a new closure on every navigate() call.
628
+ */
629
+ static readonly #onSuppressedError = (error: unknown): void => {
630
+ if (
631
+ error instanceof RouterError &&
632
+ (error.code === errorCodes.SAME_STATES ||
633
+ error.code === errorCodes.TRANSITION_CANCELLED ||
634
+ error.code === errorCodes.ROUTER_NOT_STARTED ||
635
+ error.code === errorCodes.ROUTE_NOT_FOUND)
636
+ ) {
637
+ return;
638
+ }
639
+
640
+ logger.error("router.navigate", "Unexpected navigation error", error);
641
+ };
642
+
643
+ /**
644
+ * Fire-and-forget safety: prevents unhandled rejection warnings
645
+ * when navigate/navigateToDefault is called without await.
646
+ * Expected errors are silently suppressed; unexpected ones are logged.
647
+ */
648
+ static #suppressUnhandledRejection(promise: Promise<State>): void {
649
+ promise.catch(Router.#onSuppressedError);
650
+ }
651
+
652
+ #markDisposed(): void {
653
+ this.navigate = throwDisposed as never;
654
+ this.navigateToDefault = throwDisposed as never;
655
+ this.navigateToNotFound = throwDisposed as never;
656
+ this.start = throwDisposed as never;
657
+ this.stop = throwDisposed as never;
658
+ this.usePlugin = throwDisposed as never;
659
+
660
+ this.subscribe = throwDisposed as never;
661
+ this.subscribeLeave = throwDisposed as never;
662
+ this.canNavigateTo = throwDisposed as never;
663
+ }
664
+ }
665
+
666
+ function throwDisposed(): never {
667
+ throw new RouterError(errorCodes.ROUTER_DISPOSED);
668
+ }
669
+
670
+ /**
671
+ * Derives CreateMatcherOptions from router Options.
672
+ * Maps core option names to matcher option names.
673
+ */
674
+ function deriveMatcherOptions(
675
+ options: Readonly<Options>,
676
+ ): CreateMatcherOptions {
677
+ return {
678
+ strictTrailingSlash: options.trailingSlash === "strict",
679
+ strictQueryParams: options.queryParamsMode === "strict",
680
+ urlParamsEncoding: options.urlParamsEncoding,
681
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
682
+ queryParams: options.queryParams!,
683
+ };
684
+ }