@real-router/core 0.22.0 → 0.23.0

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 (80) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/index.d.ts +1 -1
  3. package/dist/cjs/index.js +1 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/metafile-cjs.json +1 -1
  6. package/dist/esm/index.d.mts +1 -1
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/index.mjs.map +1 -1
  9. package/dist/esm/metafile-esm.json +1 -1
  10. package/package.json +7 -5
  11. package/src/Router.ts +1174 -0
  12. package/src/RouterError.ts +324 -0
  13. package/src/constants.ts +112 -0
  14. package/src/createRouter.ts +32 -0
  15. package/src/fsm/index.ts +5 -0
  16. package/src/fsm/routerFSM.ts +129 -0
  17. package/src/getNavigator.ts +15 -0
  18. package/src/helpers.ts +194 -0
  19. package/src/index.ts +46 -0
  20. package/src/namespaces/CloneNamespace/CloneNamespace.ts +120 -0
  21. package/src/namespaces/CloneNamespace/index.ts +3 -0
  22. package/src/namespaces/CloneNamespace/types.ts +46 -0
  23. package/src/namespaces/DependenciesNamespace/DependenciesNamespace.ts +250 -0
  24. package/src/namespaces/DependenciesNamespace/index.ts +3 -0
  25. package/src/namespaces/DependenciesNamespace/validators.ts +105 -0
  26. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +272 -0
  27. package/src/namespaces/EventBusNamespace/index.ts +5 -0
  28. package/src/namespaces/EventBusNamespace/types.ts +11 -0
  29. package/src/namespaces/MiddlewareNamespace/MiddlewareNamespace.ts +206 -0
  30. package/src/namespaces/MiddlewareNamespace/index.ts +5 -0
  31. package/src/namespaces/MiddlewareNamespace/types.ts +28 -0
  32. package/src/namespaces/MiddlewareNamespace/validators.ts +96 -0
  33. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +308 -0
  34. package/src/namespaces/NavigationNamespace/index.ts +5 -0
  35. package/src/namespaces/NavigationNamespace/transition/executeLifecycleHooks.ts +84 -0
  36. package/src/namespaces/NavigationNamespace/transition/executeMiddleware.ts +56 -0
  37. package/src/namespaces/NavigationNamespace/transition/index.ts +107 -0
  38. package/src/namespaces/NavigationNamespace/transition/makeError.ts +37 -0
  39. package/src/namespaces/NavigationNamespace/transition/mergeStates.ts +54 -0
  40. package/src/namespaces/NavigationNamespace/transition/processLifecycleResult.ts +81 -0
  41. package/src/namespaces/NavigationNamespace/transition/wrapSyncError.ts +82 -0
  42. package/src/namespaces/NavigationNamespace/types.ts +129 -0
  43. package/src/namespaces/NavigationNamespace/validators.ts +87 -0
  44. package/src/namespaces/OptionsNamespace/OptionsNamespace.ts +50 -0
  45. package/src/namespaces/OptionsNamespace/constants.ts +41 -0
  46. package/src/namespaces/OptionsNamespace/helpers.ts +51 -0
  47. package/src/namespaces/OptionsNamespace/index.ts +11 -0
  48. package/src/namespaces/OptionsNamespace/validators.ts +252 -0
  49. package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +325 -0
  50. package/src/namespaces/PluginsNamespace/constants.ts +35 -0
  51. package/src/namespaces/PluginsNamespace/index.ts +7 -0
  52. package/src/namespaces/PluginsNamespace/types.ts +32 -0
  53. package/src/namespaces/PluginsNamespace/validators.ts +79 -0
  54. package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +389 -0
  55. package/src/namespaces/RouteLifecycleNamespace/index.ts +5 -0
  56. package/src/namespaces/RouteLifecycleNamespace/types.ts +17 -0
  57. package/src/namespaces/RouteLifecycleNamespace/validators.ts +65 -0
  58. package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +140 -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 +23 -0
  62. package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +1482 -0
  63. package/src/namespaces/RoutesNamespace/constants.ts +14 -0
  64. package/src/namespaces/RoutesNamespace/helpers.ts +532 -0
  65. package/src/namespaces/RoutesNamespace/index.ts +9 -0
  66. package/src/namespaces/RoutesNamespace/stateBuilder.ts +70 -0
  67. package/src/namespaces/RoutesNamespace/types.ts +82 -0
  68. package/src/namespaces/RoutesNamespace/validators.ts +331 -0
  69. package/src/namespaces/StateNamespace/StateNamespace.ts +317 -0
  70. package/src/namespaces/StateNamespace/helpers.ts +43 -0
  71. package/src/namespaces/StateNamespace/index.ts +5 -0
  72. package/src/namespaces/StateNamespace/types.ts +15 -0
  73. package/src/namespaces/index.ts +42 -0
  74. package/src/transitionPath.ts +441 -0
  75. package/src/typeGuards.ts +74 -0
  76. package/src/types.ts +194 -0
  77. package/src/wiring/RouterWiringBuilder.ts +235 -0
  78. package/src/wiring/index.ts +7 -0
  79. package/src/wiring/types.ts +53 -0
  80. package/src/wiring/wireRouter.ts +29 -0
package/src/Router.ts ADDED
@@ -0,0 +1,1174 @@
1
+ // packages/core/src/Router.ts
2
+ /* eslint-disable unicorn/prefer-event-target -- custom EventEmitter package, not Node.js EventEmitter */
3
+
4
+ /**
5
+ * Router class - facade with integrated namespaces.
6
+ *
7
+ * All functionality is now provided by namespace classes.
8
+ */
9
+
10
+ import { logger } from "@real-router/logger";
11
+ import { EventEmitter } from "event-emitter";
12
+ import { validateRouteName } from "type-guards";
13
+
14
+ import { errorCodes } from "./constants";
15
+ import { createRouterFSM } from "./fsm";
16
+ import { createLimits } from "./helpers";
17
+ import {
18
+ CloneNamespace,
19
+ DependenciesNamespace,
20
+ EventBusNamespace,
21
+ MiddlewareNamespace,
22
+ NavigationNamespace,
23
+ OptionsNamespace,
24
+ PluginsNamespace,
25
+ RouteLifecycleNamespace,
26
+ RouterLifecycleNamespace,
27
+ RoutesNamespace,
28
+ StateNamespace,
29
+ } from "./namespaces";
30
+ import { CACHED_ALREADY_STARTED_ERROR } from "./namespaces/RouterLifecycleNamespace/constants";
31
+ import { RouterError } from "./RouterError";
32
+ import { getTransitionPath } from "./transitionPath";
33
+ import { isLoggerConfig } from "./typeGuards";
34
+ import { RouterWiringBuilder, wireRouter } from "./wiring";
35
+
36
+ import type {
37
+ ActivationFnFactory,
38
+ EventMethodMap,
39
+ Limits,
40
+ MiddlewareFactory,
41
+ PluginFactory,
42
+ Route,
43
+ RouteConfigUpdate,
44
+ RouterEventMap,
45
+ } from "./types";
46
+ import type {
47
+ DefaultDependencies,
48
+ EventName,
49
+ NavigationOptions,
50
+ Options,
51
+ Params,
52
+ Plugin,
53
+ RouteTreeState,
54
+ SimpleState,
55
+ State,
56
+ StateMetaInput,
57
+ SubscribeFn,
58
+ Unsubscribe,
59
+ } from "@real-router/types";
60
+ import type { CreateMatcherOptions } from "route-tree";
61
+
62
+ /**
63
+ * Router class with integrated namespace architecture.
64
+ *
65
+ * All functionality is provided by namespace classes:
66
+ * - OptionsNamespace: getOptions (immutable)
67
+ * - DependenciesNamespace: get/set/remove dependencies
68
+ * - EventEmitter: event listeners, subscribe
69
+ * - StateNamespace: state storage (getState, setState, getPreviousState)
70
+ * - RoutesNamespace: route tree operations
71
+ * - RouteLifecycleNamespace: canActivate/canDeactivate guards
72
+ * - MiddlewareNamespace: middleware chain
73
+ * - PluginsNamespace: plugin lifecycle
74
+ * - NavigationNamespace: navigate, navigateToState
75
+ * - RouterLifecycleNamespace: start, stop, isStarted
76
+ *
77
+ * @internal This class implementation is internal. Use createRouter() instead.
78
+ */
79
+ export class Router<
80
+ Dependencies extends DefaultDependencies = DefaultDependencies,
81
+ > {
82
+ // Index signatures to satisfy interface
83
+ [key: string]: unknown;
84
+
85
+ // ============================================================================
86
+ // Namespaces
87
+ // ============================================================================
88
+
89
+ readonly #options: OptionsNamespace;
90
+ readonly #limits: Limits;
91
+ readonly #dependencies: DependenciesNamespace<Dependencies>;
92
+ readonly #state: StateNamespace;
93
+ readonly #routes: RoutesNamespace<Dependencies>;
94
+ readonly #routeLifecycle: RouteLifecycleNamespace<Dependencies>;
95
+ readonly #middleware: MiddlewareNamespace<Dependencies>;
96
+ readonly #plugins: PluginsNamespace<Dependencies>;
97
+ readonly #navigation: NavigationNamespace;
98
+ readonly #lifecycle: RouterLifecycleNamespace;
99
+ readonly #clone: CloneNamespace<Dependencies>;
100
+
101
+ readonly #eventBus: EventBusNamespace;
102
+
103
+ /**
104
+ * When true, skips argument validation in public methods for production performance.
105
+ * Constructor options are always validated (needed to validate noValidate itself).
106
+ */
107
+ readonly #noValidate: boolean;
108
+
109
+ // ============================================================================
110
+ // Constructor
111
+ // ============================================================================
112
+
113
+ /**
114
+ * @param routes - Route definitions
115
+ * @param options - Router options
116
+ * @param dependencies - DI dependencies
117
+ */
118
+ constructor(
119
+ routes: Route<Dependencies>[] = [],
120
+ options: Partial<Options> = {},
121
+ dependencies: Dependencies = {} as Dependencies,
122
+ ) {
123
+ // Configure logger if provided
124
+ if (options.logger && isLoggerConfig(options.logger)) {
125
+ logger.configure(options.logger);
126
+ delete options.logger;
127
+ }
128
+
129
+ // =========================================================================
130
+ // Validate inputs before creating namespaces
131
+ // =========================================================================
132
+
133
+ // Always validate options (needed to validate noValidate itself)
134
+ OptionsNamespace.validateOptions(options, "constructor");
135
+
136
+ // Extract noValidate BEFORE creating namespaces
137
+ const noValidate = options.noValidate ?? false;
138
+
139
+ // Conditional validation for dependencies
140
+ if (!noValidate) {
141
+ DependenciesNamespace.validateDependenciesObject(
142
+ dependencies,
143
+ "constructor",
144
+ );
145
+ }
146
+
147
+ // Conditional validation for initial routes - structure and batch duplicates
148
+ // Validation happens BEFORE tree is built, so tree is not passed
149
+ if (!noValidate && routes.length > 0) {
150
+ RoutesNamespace.validateAddRouteArgs(routes);
151
+ RoutesNamespace.validateRoutes(routes);
152
+ }
153
+
154
+ // =========================================================================
155
+ // Create Namespaces
156
+ // =========================================================================
157
+
158
+ this.#options = new OptionsNamespace(options);
159
+ this.#limits = createLimits(options.limits);
160
+ this.#dependencies = new DependenciesNamespace<Dependencies>(dependencies);
161
+ this.#state = new StateNamespace();
162
+ this.#routes = new RoutesNamespace<Dependencies>(
163
+ routes,
164
+ noValidate,
165
+ deriveMatcherOptions(this.#options.get()),
166
+ );
167
+ this.#routeLifecycle = new RouteLifecycleNamespace<Dependencies>();
168
+ this.#middleware = new MiddlewareNamespace<Dependencies>();
169
+ this.#plugins = new PluginsNamespace<Dependencies>();
170
+ this.#navigation = new NavigationNamespace();
171
+ this.#lifecycle = new RouterLifecycleNamespace();
172
+ this.#clone = new CloneNamespace<Dependencies>();
173
+ this.#noValidate = noValidate;
174
+
175
+ // =========================================================================
176
+ // Initialize EventBus
177
+ // =========================================================================
178
+
179
+ const routerFSM = createRouterFSM();
180
+ const emitter = new EventEmitter<RouterEventMap>({
181
+ onListenerError: (eventName, error) => {
182
+ logger.error("Router", `Error in listener for ${eventName}:`, error);
183
+ },
184
+ onListenerWarn: (eventName, count) => {
185
+ logger.warn(
186
+ "router.addEventListener",
187
+ `Event "${eventName}" has ${count} listeners — possible memory leak`,
188
+ );
189
+ },
190
+ });
191
+
192
+ this.#eventBus = new EventBusNamespace({ routerFSM, emitter });
193
+
194
+ // =========================================================================
195
+ // Wire Dependencies
196
+ // =========================================================================
197
+
198
+ wireRouter(
199
+ new RouterWiringBuilder<Dependencies>({
200
+ router: this,
201
+ options: this.#options,
202
+ limits: this.#limits,
203
+ dependencies: this.#dependencies,
204
+ state: this.#state,
205
+ routes: this.#routes,
206
+ routeLifecycle: this.#routeLifecycle,
207
+ middleware: this.#middleware,
208
+ plugins: this.#plugins,
209
+ navigation: this.#navigation,
210
+ lifecycle: this.#lifecycle,
211
+ clone: this.#clone,
212
+ eventBus: this.#eventBus,
213
+ }),
214
+ );
215
+
216
+ // =========================================================================
217
+ // Bind Public Methods
218
+ // =========================================================================
219
+ // All public methods that access private fields must be bound to preserve
220
+ // `this` context when methods are extracted as references.
221
+ // See: https://github.com/nicolo-ribaudo/tc39-proposal-bind-operator
222
+ // =========================================================================
223
+
224
+ // Route Management
225
+ this.addRoute = this.addRoute.bind(this);
226
+ this.removeRoute = this.removeRoute.bind(this);
227
+ this.clearRoutes = this.clearRoutes.bind(this);
228
+ this.getRoute = this.getRoute.bind(this);
229
+ this.hasRoute = this.hasRoute.bind(this);
230
+ this.updateRoute = this.updateRoute.bind(this);
231
+
232
+ // Path & State Building
233
+ this.isActiveRoute = this.isActiveRoute.bind(this);
234
+ this.buildPath = this.buildPath.bind(this);
235
+ this.matchPath = this.matchPath.bind(this);
236
+ this.setRootPath = this.setRootPath.bind(this);
237
+ this.getRootPath = this.getRootPath.bind(this);
238
+
239
+ // State Management
240
+ this.makeState = this.makeState.bind(this);
241
+ this.getState = this.getState.bind(this);
242
+ this.getPreviousState = this.getPreviousState.bind(this);
243
+ this.areStatesEqual = this.areStatesEqual.bind(this);
244
+ this.forwardState = this.forwardState.bind(this);
245
+ this.buildState = this.buildState.bind(this);
246
+ this.buildNavigationState = this.buildNavigationState.bind(this);
247
+ this.shouldUpdateNode = this.shouldUpdateNode.bind(this);
248
+
249
+ // Options
250
+ this.getOptions = this.getOptions.bind(this);
251
+
252
+ // Router Lifecycle
253
+ this.isActive = this.isActive.bind(this);
254
+ this.start = this.start.bind(this);
255
+ this.stop = this.stop.bind(this);
256
+ this.dispose = this.dispose.bind(this);
257
+
258
+ // Route Lifecycle (Guards)
259
+ this.addActivateGuard = this.addActivateGuard.bind(this);
260
+ this.addDeactivateGuard = this.addDeactivateGuard.bind(this);
261
+ this.removeActivateGuard = this.removeActivateGuard.bind(this);
262
+ this.removeDeactivateGuard = this.removeDeactivateGuard.bind(this);
263
+ this.canNavigateTo = this.canNavigateTo.bind(this);
264
+
265
+ // Plugins
266
+ this.usePlugin = this.usePlugin.bind(this);
267
+
268
+ // Middleware
269
+ this.useMiddleware = this.useMiddleware.bind(this);
270
+ // Dependencies
271
+ this.setDependency = this.setDependency.bind(this);
272
+ this.setDependencies = this.setDependencies.bind(this);
273
+ this.getDependency = this.getDependency.bind(this);
274
+ this.getDependencies = this.getDependencies.bind(this);
275
+ this.removeDependency = this.removeDependency.bind(this);
276
+ this.hasDependency = this.hasDependency.bind(this);
277
+ this.resetDependencies = this.resetDependencies.bind(this);
278
+
279
+ // Events
280
+ this.addEventListener = this.addEventListener.bind(this);
281
+
282
+ // Navigation
283
+ this.navigate = this.navigate.bind(this);
284
+ this.navigateToDefault = this.navigateToDefault.bind(this);
285
+ this.navigateToState = this.navigateToState.bind(this);
286
+
287
+ // Subscription
288
+ this.subscribe = this.subscribe.bind(this);
289
+
290
+ // Cloning
291
+ this.clone = this.clone.bind(this);
292
+ }
293
+
294
+ // ============================================================================
295
+ // Route Management
296
+ // ============================================================================
297
+
298
+ addRoute(
299
+ routes: Route<Dependencies>[] | Route<Dependencies>,
300
+ options?: { parent?: string },
301
+ ): this {
302
+ const routeArray = Array.isArray(routes) ? routes : [routes];
303
+ const parentName = options?.parent;
304
+
305
+ if (!this.#noValidate) {
306
+ // 1. Validate parent option format
307
+ if (parentName !== undefined) {
308
+ RoutesNamespace.validateParentOption(parentName);
309
+ }
310
+
311
+ // 2. Static validation (route structure and properties)
312
+ RoutesNamespace.validateAddRouteArgs(routeArray);
313
+
314
+ // 3. State-dependent validation (parent exists, duplicates, forwardTo)
315
+ RoutesNamespace.validateRoutes(
316
+ routeArray,
317
+ this.#routes.getTree(),
318
+ this.#routes.getForwardRecord(),
319
+ parentName,
320
+ );
321
+ }
322
+
323
+ // 4. Execute (add definitions, register handlers, rebuild tree)
324
+ this.#routes.addRoutes(routeArray, parentName);
325
+
326
+ return this;
327
+ }
328
+
329
+ removeRoute(name: string): this {
330
+ // Static validation
331
+ if (!this.#noValidate) {
332
+ RoutesNamespace.validateRemoveRouteArgs(name);
333
+ }
334
+
335
+ // Instance validation (checks active route, navigation state)
336
+ const canRemove = this.#routes.validateRemoveRoute(
337
+ name,
338
+ this.#state.get()?.name,
339
+ this.#eventBus.isTransitioning(),
340
+ );
341
+
342
+ if (!canRemove) {
343
+ return this;
344
+ }
345
+
346
+ // Perform removal
347
+ const wasRemoved = this.#routes.removeRoute(name);
348
+
349
+ if (!wasRemoved) {
350
+ logger.warn(
351
+ "router.removeRoute",
352
+ `Route "${name}" not found. No changes made.`,
353
+ );
354
+ }
355
+
356
+ return this;
357
+ }
358
+
359
+ clearRoutes(): this {
360
+ const isNavigating = this.#eventBus.isTransitioning();
361
+
362
+ // Validate operation can proceed
363
+ const canClear = this.#routes.validateClearRoutes(isNavigating);
364
+
365
+ if (!canClear) {
366
+ return this;
367
+ }
368
+
369
+ // Clear routes config (definitions, decoders, encoders, defaultParams, forwardMap)
370
+ this.#routes.clearRoutes();
371
+
372
+ // Clear all lifecycle handlers
373
+ this.#routeLifecycle.clearAll();
374
+
375
+ // Clear router state since all routes are removed
376
+ this.#state.set(undefined);
377
+
378
+ return this;
379
+ }
380
+
381
+ getRoute(name: string): Route<Dependencies> | undefined {
382
+ if (!this.#noValidate) {
383
+ validateRouteName(name, "getRoute");
384
+ }
385
+
386
+ return this.#routes.getRoute(name);
387
+ }
388
+
389
+ hasRoute(name: string): boolean {
390
+ if (!this.#noValidate) {
391
+ validateRouteName(name, "hasRoute");
392
+ }
393
+
394
+ return this.#routes.hasRoute(name);
395
+ }
396
+
397
+ updateRoute(name: string, updates: RouteConfigUpdate<Dependencies>): this {
398
+ // Validate name and updates object structure (basic checks only)
399
+ if (!this.#noValidate) {
400
+ RoutesNamespace.validateUpdateRouteBasicArgs(name, updates);
401
+ }
402
+
403
+ // Cache all property values upfront to protect against mutating getters.
404
+ // This ensures consistent behavior regardless of getter side effects.
405
+ // Must happen AFTER basic validation but BEFORE property type validation.
406
+ const {
407
+ forwardTo,
408
+ defaultParams,
409
+ decodeParams,
410
+ encodeParams,
411
+ canActivate,
412
+ canDeactivate,
413
+ } = updates;
414
+
415
+ // Validate cached property values
416
+ if (!this.#noValidate) {
417
+ RoutesNamespace.validateUpdateRoutePropertyTypes(
418
+ forwardTo,
419
+ defaultParams,
420
+ decodeParams,
421
+ encodeParams,
422
+ );
423
+ }
424
+
425
+ // Warn if navigation is in progress
426
+ if (this.#eventBus.isTransitioning()) {
427
+ logger.error(
428
+ "router.updateRoute",
429
+ `Updating route "${name}" while navigation is in progress. This may cause unexpected behavior.`,
430
+ );
431
+ }
432
+
433
+ // Instance validation (route existence, forwardTo checks) - use cached values
434
+ if (!this.#noValidate) {
435
+ this.#routes.validateUpdateRoute(name, forwardTo);
436
+ }
437
+
438
+ // Update route config
439
+ this.#routes.updateRouteConfig(name, {
440
+ forwardTo,
441
+ defaultParams,
442
+ decodeParams,
443
+ encodeParams,
444
+ });
445
+
446
+ // Handle canActivate separately (uses RouteLifecycleNamespace)
447
+ // Use facade method for proper validation
448
+ if (canActivate !== undefined) {
449
+ if (canActivate === null) {
450
+ this.#routeLifecycle.clearCanActivate(name);
451
+ } else {
452
+ this.addActivateGuard(name, canActivate);
453
+ }
454
+ }
455
+
456
+ // Handle canDeactivate separately (uses RouteLifecycleNamespace)
457
+ // Use facade method for proper validation
458
+ if (canDeactivate !== undefined) {
459
+ if (canDeactivate === null) {
460
+ this.#routeLifecycle.clearCanDeactivate(name);
461
+ } else {
462
+ this.addDeactivateGuard(name, canDeactivate);
463
+ }
464
+ }
465
+
466
+ return this;
467
+ }
468
+
469
+ // ============================================================================
470
+ // Path & State Building
471
+ // ============================================================================
472
+
473
+ isActiveRoute(
474
+ name: string,
475
+ params?: Params,
476
+ strictEquality?: boolean,
477
+ ignoreQueryParams?: boolean,
478
+ ): boolean {
479
+ if (!this.#noValidate) {
480
+ RoutesNamespace.validateIsActiveRouteArgs(
481
+ name,
482
+ params,
483
+ strictEquality,
484
+ ignoreQueryParams,
485
+ );
486
+ }
487
+
488
+ // Empty string is special case - warn and return false (root node is not a parent)
489
+ if (name === "") {
490
+ logger.warn(
491
+ "real-router",
492
+ 'isActiveRoute("") called with empty string. Root node is not considered a parent of any route.',
493
+ );
494
+
495
+ return false;
496
+ }
497
+
498
+ return this.#routes.isActiveRoute(
499
+ name,
500
+ params,
501
+ strictEquality,
502
+ ignoreQueryParams,
503
+ );
504
+ }
505
+
506
+ buildPath(route: string, params?: Params): string {
507
+ if (!this.#noValidate) {
508
+ RoutesNamespace.validateBuildPathArgs(route);
509
+ }
510
+
511
+ return this.#routes.buildPath(route, params, this.#options.get());
512
+ }
513
+
514
+ matchPath<P extends Params = Params, MP extends Params = Params>(
515
+ path: string,
516
+ ): State<P, MP> | undefined {
517
+ if (!this.#noValidate) {
518
+ RoutesNamespace.validateMatchPathArgs(path);
519
+ }
520
+
521
+ return this.#routes.matchPath<P, MP>(path, this.#options.get());
522
+ }
523
+
524
+ setRootPath(rootPath: string): void {
525
+ if (!this.#noValidate) {
526
+ RoutesNamespace.validateSetRootPathArgs(rootPath);
527
+ }
528
+
529
+ this.#routes.setRootPath(rootPath);
530
+ }
531
+
532
+ getRootPath(): string {
533
+ return this.#routes.getRootPath();
534
+ }
535
+
536
+ // ============================================================================
537
+ // State Management (delegated to StateNamespace)
538
+ // ============================================================================
539
+
540
+ makeState<P extends Params = Params, MP extends Params = Params>(
541
+ name: string,
542
+ params?: P,
543
+ path?: string,
544
+ meta?: StateMetaInput<MP>,
545
+ forceId?: number,
546
+ ): State<P, MP> {
547
+ if (!this.#noValidate) {
548
+ StateNamespace.validateMakeStateArgs(name, params, path, forceId);
549
+ }
550
+
551
+ return this.#state.makeState<P, MP>(name, params, path, meta, forceId);
552
+ }
553
+
554
+ getState<P extends Params = Params, MP extends Params = Params>():
555
+ | State<P, MP>
556
+ | undefined {
557
+ return this.#state.get<P, MP>();
558
+ }
559
+
560
+ getPreviousState(): State | undefined {
561
+ return this.#state.getPrevious();
562
+ }
563
+
564
+ areStatesEqual(
565
+ state1: State | undefined,
566
+ state2: State | undefined,
567
+ ignoreQueryParams = true,
568
+ ): boolean {
569
+ if (!this.#noValidate) {
570
+ StateNamespace.validateAreStatesEqualArgs(
571
+ state1,
572
+ state2,
573
+ ignoreQueryParams,
574
+ );
575
+ }
576
+
577
+ return this.#state.areStatesEqual(state1, state2, ignoreQueryParams);
578
+ }
579
+
580
+ forwardState<P extends Params = Params>(
581
+ routeName: string,
582
+ routeParams: P,
583
+ ): SimpleState<P> {
584
+ if (!this.#noValidate) {
585
+ RoutesNamespace.validateStateBuilderArgs(
586
+ routeName,
587
+ routeParams,
588
+ "forwardState",
589
+ );
590
+ }
591
+
592
+ return this.#routes.forwardState<P>(routeName, routeParams);
593
+ }
594
+
595
+ buildState(
596
+ routeName: string,
597
+ routeParams: Params,
598
+ ): RouteTreeState | undefined {
599
+ if (!this.#noValidate) {
600
+ RoutesNamespace.validateStateBuilderArgs(
601
+ routeName,
602
+ routeParams,
603
+ "buildState",
604
+ );
605
+ }
606
+
607
+ // Call forwardState at facade level to allow plugin interception
608
+ const { name, params } = this.forwardState(routeName, routeParams);
609
+
610
+ return this.#routes.buildStateResolved(name, params);
611
+ }
612
+
613
+ buildNavigationState(name: string, params: Params = {}): State | undefined {
614
+ if (!this.#noValidate) {
615
+ RoutesNamespace.validateStateBuilderArgs(
616
+ name,
617
+ params,
618
+ "buildNavigationState",
619
+ );
620
+ }
621
+
622
+ const routeInfo = this.buildState(name, params);
623
+
624
+ if (!routeInfo) {
625
+ return undefined;
626
+ }
627
+
628
+ return this.makeState(
629
+ routeInfo.name,
630
+ routeInfo.params,
631
+ this.buildPath(routeInfo.name, routeInfo.params),
632
+ {
633
+ params: routeInfo.meta,
634
+ options: {},
635
+ },
636
+ );
637
+ }
638
+
639
+ shouldUpdateNode(
640
+ nodeName: string,
641
+ ): (toState: State, fromState?: State) => boolean {
642
+ if (!this.#noValidate) {
643
+ RoutesNamespace.validateShouldUpdateNodeArgs(nodeName);
644
+ }
645
+
646
+ return this.#routes.shouldUpdateNode(nodeName);
647
+ }
648
+
649
+ // ============================================================================
650
+ // Options (backed by OptionsNamespace)
651
+ // ============================================================================
652
+
653
+ getOptions(): Options {
654
+ return this.#options.get();
655
+ }
656
+
657
+ // ============================================================================
658
+ // Router Lifecycle
659
+ // ============================================================================
660
+
661
+ isActive(): boolean {
662
+ return this.#eventBus.isActive();
663
+ }
664
+
665
+ async start(startPath: string): Promise<State> {
666
+ // Static validation
667
+ if (!this.#noValidate) {
668
+ RouterLifecycleNamespace.validateStartArgs([startPath]);
669
+ }
670
+
671
+ if (!this.#eventBus.canStart()) {
672
+ throw CACHED_ALREADY_STARTED_ERROR;
673
+ }
674
+
675
+ this.#eventBus.sendStart();
676
+
677
+ try {
678
+ return await this.#lifecycle.start(startPath);
679
+ } catch (error) {
680
+ if (this.#eventBus.isReady()) {
681
+ this.#lifecycle.stop();
682
+ this.#eventBus.sendStop();
683
+ }
684
+
685
+ throw error;
686
+ }
687
+ }
688
+
689
+ stop(): this {
690
+ this.#eventBus.cancelTransitionIfRunning(this.#state.get());
691
+
692
+ if (!this.#eventBus.isReady() && !this.#eventBus.isTransitioning()) {
693
+ return this;
694
+ }
695
+
696
+ this.#lifecycle.stop();
697
+ this.#eventBus.sendStop();
698
+
699
+ return this;
700
+ }
701
+
702
+ dispose(): void {
703
+ if (this.#eventBus.isDisposed()) {
704
+ return;
705
+ }
706
+
707
+ this.#eventBus.cancelTransitionIfRunning(this.#state.get());
708
+
709
+ if (this.#eventBus.isReady() || this.#eventBus.isTransitioning()) {
710
+ this.#lifecycle.stop();
711
+ this.#eventBus.sendStop();
712
+ }
713
+
714
+ this.#eventBus.sendDispose();
715
+ this.#eventBus.clearAll();
716
+
717
+ this.#plugins.disposeAll();
718
+ this.#middleware.clearAll();
719
+ this.#routes.clearRoutes();
720
+ this.#routeLifecycle.clearAll();
721
+ this.#state.reset();
722
+ this.#dependencies.reset();
723
+
724
+ this.#markDisposed();
725
+ }
726
+
727
+ // ============================================================================
728
+ // Route Lifecycle (Guards)
729
+ // ============================================================================
730
+
731
+ addDeactivateGuard(
732
+ name: string,
733
+ canDeactivateHandler: ActivationFnFactory<Dependencies> | boolean,
734
+ ): this {
735
+ if (!this.#noValidate) {
736
+ validateRouteName(name, "addDeactivateGuard");
737
+ RouteLifecycleNamespace.validateHandler(
738
+ canDeactivateHandler,
739
+ "addDeactivateGuard",
740
+ );
741
+ }
742
+
743
+ this.#routeLifecycle.addCanDeactivate(
744
+ name,
745
+ canDeactivateHandler,
746
+ this.#noValidate,
747
+ );
748
+
749
+ return this;
750
+ }
751
+
752
+ addActivateGuard(
753
+ name: string,
754
+ canActivateHandler: ActivationFnFactory<Dependencies> | boolean,
755
+ ): this {
756
+ if (!this.#noValidate) {
757
+ validateRouteName(name, "addActivateGuard");
758
+ RouteLifecycleNamespace.validateHandler(
759
+ canActivateHandler,
760
+ "addActivateGuard",
761
+ );
762
+ }
763
+
764
+ this.#routeLifecycle.addCanActivate(
765
+ name,
766
+ canActivateHandler,
767
+ this.#noValidate,
768
+ );
769
+
770
+ return this;
771
+ }
772
+
773
+ removeActivateGuard(name: string): void {
774
+ if (!this.#noValidate) {
775
+ validateRouteName(name, "removeActivateGuard");
776
+ }
777
+
778
+ this.#routeLifecycle.clearCanActivate(name);
779
+ }
780
+
781
+ removeDeactivateGuard(name: string): void {
782
+ if (!this.#noValidate) {
783
+ validateRouteName(name, "removeDeactivateGuard");
784
+ }
785
+
786
+ this.#routeLifecycle.clearCanDeactivate(name);
787
+ }
788
+
789
+ canNavigateTo(name: string, params?: Params): boolean {
790
+ if (!this.#noValidate) {
791
+ validateRouteName(name, "canNavigateTo");
792
+ }
793
+
794
+ if (!this.hasRoute(name)) {
795
+ return false;
796
+ }
797
+
798
+ const { name: resolvedName, params: resolvedParams } = this.forwardState(
799
+ name,
800
+ params ?? {},
801
+ );
802
+ const toState = this.makeState(resolvedName, resolvedParams);
803
+ const fromState = this.getState();
804
+
805
+ const { toDeactivate, toActivate } = getTransitionPath(toState, fromState);
806
+
807
+ for (const segment of toDeactivate) {
808
+ if (
809
+ !this.#routeLifecycle.checkDeactivateGuardSync(
810
+ segment,
811
+ toState,
812
+ fromState,
813
+ )
814
+ ) {
815
+ return false;
816
+ }
817
+ }
818
+
819
+ for (const segment of toActivate) {
820
+ if (
821
+ !this.#routeLifecycle.checkActivateGuardSync(
822
+ segment,
823
+ toState,
824
+ fromState,
825
+ )
826
+ ) {
827
+ return false;
828
+ }
829
+ }
830
+
831
+ return true;
832
+ }
833
+
834
+ // ============================================================================
835
+ // Plugins
836
+ // ============================================================================
837
+
838
+ usePlugin(...plugins: PluginFactory<Dependencies>[]): Unsubscribe {
839
+ if (!this.#noValidate) {
840
+ // 1. Validate input arguments
841
+ PluginsNamespace.validateUsePluginArgs<Dependencies>(plugins);
842
+
843
+ // 2. Validate limit
844
+ PluginsNamespace.validatePluginLimit(
845
+ this.#plugins.count(),
846
+ plugins.length,
847
+ this.#limits.maxPlugins,
848
+ );
849
+
850
+ // 3. Validate no duplicates with existing plugins
851
+ PluginsNamespace.validateNoDuplicatePlugins(
852
+ plugins,
853
+ this.#plugins.has.bind(this.#plugins),
854
+ );
855
+ }
856
+
857
+ // 4. Execute (warnings, deduplication, initialization, commit)
858
+ return this.#plugins.use(...plugins);
859
+ }
860
+
861
+ // ============================================================================
862
+ // Middleware
863
+ // ============================================================================
864
+
865
+ useMiddleware(
866
+ ...middlewares: MiddlewareFactory<Dependencies>[]
867
+ ): Unsubscribe {
868
+ if (!this.#noValidate) {
869
+ // 1. Validate input arguments
870
+ MiddlewareNamespace.validateUseMiddlewareArgs<Dependencies>(middlewares);
871
+
872
+ // 2. Validate no duplicates
873
+ MiddlewareNamespace.validateNoDuplicates<Dependencies>(
874
+ middlewares,
875
+ this.#middleware.getFactories(),
876
+ );
877
+
878
+ // 3. Validate limit
879
+ MiddlewareNamespace.validateMiddlewareLimit(
880
+ this.#middleware.count(),
881
+ middlewares.length,
882
+ this.#limits.maxMiddleware,
883
+ );
884
+ }
885
+
886
+ // 4. Initialize (without committing)
887
+ const initialized = this.#middleware.initialize(...middlewares);
888
+
889
+ // 5. Validate results
890
+ if (!this.#noValidate) {
891
+ for (const { middleware, factory } of initialized) {
892
+ MiddlewareNamespace.validateMiddleware<Dependencies>(
893
+ middleware,
894
+ factory,
895
+ );
896
+ }
897
+ }
898
+
899
+ // 6. Commit
900
+ return this.#middleware.commit(initialized);
901
+ }
902
+
903
+ // ============================================================================
904
+ // Dependencies (backed by DependenciesNamespace)
905
+ // ============================================================================
906
+
907
+ setDependency<K extends keyof Dependencies & string>(
908
+ dependencyName: K,
909
+ dependency: Dependencies[K],
910
+ ): this {
911
+ if (!this.#noValidate) {
912
+ DependenciesNamespace.validateSetDependencyArgs(dependencyName);
913
+ }
914
+
915
+ this.#dependencies.set(dependencyName, dependency);
916
+
917
+ return this;
918
+ }
919
+
920
+ setDependencies(deps: Dependencies): this {
921
+ if (!this.#noValidate) {
922
+ DependenciesNamespace.validateDependenciesObject(deps, "setDependencies");
923
+ DependenciesNamespace.validateDependencyLimit(
924
+ this.#dependencies.count(),
925
+ Object.keys(deps).length,
926
+ "setDependencies",
927
+ this.#limits.maxDependencies,
928
+ );
929
+ }
930
+
931
+ this.#dependencies.setMultiple(deps);
932
+
933
+ return this;
934
+ }
935
+
936
+ getDependency<K extends keyof Dependencies>(key: K): Dependencies[K] {
937
+ if (!this.#noValidate) {
938
+ DependenciesNamespace.validateName(key, "getDependency");
939
+ }
940
+
941
+ const value = this.#dependencies.get(key);
942
+
943
+ if (!this.#noValidate) {
944
+ DependenciesNamespace.validateDependencyExists(value, key as string);
945
+ }
946
+
947
+ return value;
948
+ }
949
+
950
+ getDependencies(): Partial<Dependencies> {
951
+ return this.#dependencies.getAll();
952
+ }
953
+
954
+ removeDependency(dependencyName: keyof Dependencies): this {
955
+ if (!this.#noValidate) {
956
+ DependenciesNamespace.validateName(dependencyName, "removeDependency");
957
+ }
958
+
959
+ this.#dependencies.remove(dependencyName);
960
+
961
+ return this;
962
+ }
963
+
964
+ hasDependency(dependencyName: keyof Dependencies): boolean {
965
+ if (!this.#noValidate) {
966
+ DependenciesNamespace.validateName(dependencyName, "hasDependency");
967
+ }
968
+
969
+ return this.#dependencies.has(dependencyName);
970
+ }
971
+
972
+ resetDependencies(): this {
973
+ this.#dependencies.reset();
974
+
975
+ return this;
976
+ }
977
+
978
+ // ============================================================================
979
+ // Events (backed by EventEmitter)
980
+ // ============================================================================
981
+
982
+ addEventListener<E extends EventName>(
983
+ eventName: E,
984
+ cb: Plugin[EventMethodMap[E]],
985
+ ): Unsubscribe {
986
+ if (!this.#noValidate) {
987
+ EventBusNamespace.validateListenerArgs(eventName, cb);
988
+ }
989
+
990
+ return this.#eventBus.addEventListener(eventName, cb);
991
+ }
992
+
993
+ // ============================================================================
994
+ // Subscription (backed by EventEmitter)
995
+ // ============================================================================
996
+
997
+ subscribe(listener: SubscribeFn): Unsubscribe {
998
+ if (!this.#noValidate) {
999
+ EventBusNamespace.validateSubscribeListener(listener);
1000
+ }
1001
+
1002
+ return this.#eventBus.subscribe(listener);
1003
+ }
1004
+
1005
+ // ============================================================================
1006
+ // Navigation
1007
+ // ============================================================================
1008
+
1009
+ navigate(routeName: string): Promise<State>;
1010
+ navigate(routeName: string, routeParams: Params): Promise<State>;
1011
+ navigate(
1012
+ routeName: string,
1013
+ routeParams: Params,
1014
+ options: NavigationOptions,
1015
+ ): Promise<State>;
1016
+ navigate(
1017
+ routeName: string,
1018
+ routeParams?: Params,
1019
+ options?: NavigationOptions,
1020
+ ): Promise<State> {
1021
+ // 1. Validate route name
1022
+ if (!this.#noValidate) {
1023
+ NavigationNamespace.validateNavigateArgs(routeName);
1024
+ }
1025
+
1026
+ // 2. Validate parsed options
1027
+ const opts = options ?? {};
1028
+
1029
+ if (!this.#noValidate) {
1030
+ NavigationNamespace.validateNavigationOptions(opts, "navigate");
1031
+ }
1032
+
1033
+ // 3. Execute navigation with parsed arguments
1034
+ const promiseState = this.#navigation.navigate(
1035
+ routeName,
1036
+ routeParams ?? {},
1037
+ opts,
1038
+ );
1039
+
1040
+ Router.#suppressUnhandledRejection(promiseState);
1041
+
1042
+ return promiseState;
1043
+ }
1044
+
1045
+ navigateToDefault(): Promise<State>;
1046
+ navigateToDefault(options: NavigationOptions): Promise<State>;
1047
+ navigateToDefault(options?: NavigationOptions): Promise<State> {
1048
+ // 1. Validate arguments
1049
+ if (!this.#noValidate) {
1050
+ NavigationNamespace.validateNavigateToDefaultArgs(options);
1051
+ }
1052
+
1053
+ // 2. Validate parsed options
1054
+ const opts = options ?? {};
1055
+
1056
+ if (!this.#noValidate) {
1057
+ NavigationNamespace.validateNavigationOptions(opts, "navigateToDefault");
1058
+ }
1059
+
1060
+ // 3. Execute navigation with parsed arguments
1061
+ const promiseState = this.#navigation.navigateToDefault(opts);
1062
+
1063
+ Router.#suppressUnhandledRejection(promiseState);
1064
+
1065
+ return promiseState;
1066
+ }
1067
+
1068
+ navigateToState(
1069
+ toState: State,
1070
+ fromState: State | undefined,
1071
+ opts: NavigationOptions,
1072
+ ): Promise<State> {
1073
+ if (!this.#noValidate) {
1074
+ NavigationNamespace.validateNavigateToStateArgs(toState, fromState, opts);
1075
+ }
1076
+
1077
+ return this.#navigation.navigateToState(toState, fromState, opts);
1078
+ }
1079
+
1080
+ // ============================================================================
1081
+ // Cloning
1082
+ // ============================================================================
1083
+
1084
+ clone(dependencies?: Dependencies): Router<Dependencies> {
1085
+ if (!this.#noValidate) {
1086
+ CloneNamespace.validateCloneArgs(dependencies);
1087
+ }
1088
+
1089
+ return this.#clone.clone(
1090
+ dependencies,
1091
+ (routes, options, deps) =>
1092
+ new Router<Dependencies>(routes, options, deps),
1093
+ (newRouter, config, resolvedForwardMap) => {
1094
+ const typedRouter = newRouter as unknown as Router<Dependencies>;
1095
+
1096
+ typedRouter.#routes.applyClonedConfig(config, resolvedForwardMap);
1097
+ },
1098
+ );
1099
+ }
1100
+
1101
+ /**
1102
+ * Pre-allocated callback for #suppressUnhandledRejection.
1103
+ * Avoids creating a new closure on every navigate() call.
1104
+ */
1105
+ static readonly #onSuppressedError = (error: unknown): void => {
1106
+ if (
1107
+ error instanceof RouterError &&
1108
+ (error.code === errorCodes.SAME_STATES ||
1109
+ error.code === errorCodes.TRANSITION_CANCELLED ||
1110
+ error.code === errorCodes.ROUTER_NOT_STARTED ||
1111
+ error.code === errorCodes.ROUTE_NOT_FOUND)
1112
+ ) {
1113
+ return;
1114
+ }
1115
+
1116
+ logger.error("router.navigate", "Unexpected navigation error", error);
1117
+ };
1118
+
1119
+ /**
1120
+ * Fire-and-forget safety: prevents unhandled rejection warnings
1121
+ * when navigate/navigateToDefault is called without await.
1122
+ * Expected errors are silently suppressed; unexpected ones are logged.
1123
+ */
1124
+ static #suppressUnhandledRejection(promise: Promise<State>): void {
1125
+ promise.catch(Router.#onSuppressedError);
1126
+ }
1127
+
1128
+ #markDisposed(): void {
1129
+ this.navigate = throwDisposed as never;
1130
+ this.navigateToDefault = throwDisposed as never;
1131
+ this.navigateToState = throwDisposed as never;
1132
+ this.start = throwDisposed as never;
1133
+ this.stop = throwDisposed as never;
1134
+ this.addRoute = throwDisposed as never;
1135
+ this.removeRoute = throwDisposed as never;
1136
+ this.clearRoutes = throwDisposed as never;
1137
+ this.updateRoute = throwDisposed as never;
1138
+ this.addActivateGuard = throwDisposed as never;
1139
+ this.addDeactivateGuard = throwDisposed as never;
1140
+ this.removeActivateGuard = throwDisposed as never;
1141
+ this.removeDeactivateGuard = throwDisposed as never;
1142
+ this.usePlugin = throwDisposed as never;
1143
+ this.useMiddleware = throwDisposed as never;
1144
+ this.setDependency = throwDisposed as never;
1145
+ this.setDependencies = throwDisposed as never;
1146
+ this.removeDependency = throwDisposed as never;
1147
+ this.resetDependencies = throwDisposed as never;
1148
+ this.addEventListener = throwDisposed as never;
1149
+ this.subscribe = throwDisposed as never;
1150
+ this.setRootPath = throwDisposed as never;
1151
+ this.clone = throwDisposed as never;
1152
+ this.canNavigateTo = throwDisposed as never;
1153
+ }
1154
+ }
1155
+
1156
+ function throwDisposed(): never {
1157
+ throw new RouterError(errorCodes.ROUTER_DISPOSED);
1158
+ }
1159
+
1160
+ /**
1161
+ * Derives CreateMatcherOptions from router Options.
1162
+ * Maps core option names to matcher option names.
1163
+ */
1164
+ function deriveMatcherOptions(
1165
+ options: Readonly<Options>,
1166
+ ): CreateMatcherOptions {
1167
+ return {
1168
+ strictTrailingSlash: options.trailingSlash === "strict",
1169
+ strictQueryParams: options.queryParamsMode === "strict",
1170
+ urlParamsEncoding: options.urlParamsEncoding,
1171
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1172
+ queryParams: options.queryParams!,
1173
+ };
1174
+ }