@real-router/browser-plugin 0.6.3 → 0.7.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.
package/src/plugin.ts CHANGED
@@ -1,248 +1,153 @@
1
- // packages/browser-plugin/modules/plugin.ts
2
-
3
- import { getPluginApi } from "@real-router/core";
4
- import { isStateStrict as isState } from "type-guards";
5
-
6
- import { createSafeBrowser } from "./browser";
7
- import { defaultOptions, LOGGER_CONTEXT, source } from "./constants";
8
- import {
9
- escapeRegExp,
10
- createStateFromEvent,
11
- shouldSkipTransition,
12
- handleMissingState,
13
- updateBrowserState,
14
- handleTransitionResult,
15
- validateOptions,
16
- } from "./utils";
17
-
18
- import type { BrowserPluginOptions, Browser, HistoryState } from "./types";
1
+ import { RouterError } from "@real-router/core";
2
+
3
+ import { LOGGER_CONTEXT } from "./constants";
4
+ import { getRouteFromEvent, updateBrowserState } from "./popstate-utils";
5
+ import { buildUrl, urlToPath } from "./url-utils";
6
+
7
+ import type {
8
+ Browser,
9
+ BrowserPluginOptions,
10
+ RegExpCache,
11
+ SharedFactoryState,
12
+ URLParseOptions,
13
+ } from "./types";
19
14
  import type {
20
- PluginFactory,
15
+ NavigationOptions,
16
+ PluginApi,
21
17
  Router,
22
- RouterError,
23
18
  State,
19
+ Plugin,
24
20
  } from "@real-router/core";
25
21
 
26
- /**
27
- * Browser plugin factory for real-router.
28
- * Integrates router with browser history API.
29
- *
30
- * Features:
31
- * - Syncs router state with browser history (pushState/replaceState)
32
- * - Handles popstate events for browser back/forward navigation
33
- * - Supports hash-based routing for legacy browsers
34
- * - Provides URL building and matching utilities
35
- * - SSR-safe with graceful fallbacks
36
- * - Runtime validation warns about conflicting options
37
- *
38
- * @param opts - Plugin configuration options
39
- * @param browser - Browser API abstraction (for testing/SSR)
40
- * @returns Plugin factory function
41
- *
42
- * @example
43
- * ```ts
44
- * // Hash routing
45
- * router.usePlugin(browserPluginFactory({ useHash: true, hashPrefix: "!" }));
46
- *
47
- * // History routing with hash preservation
48
- * router.usePlugin(browserPluginFactory({ useHash: false, preserveHash: true }));
49
- * ```
50
- */
51
- export function browserPluginFactory(
52
- opts?: Partial<BrowserPluginOptions>,
53
- browser: Browser = createSafeBrowser(),
54
- ): PluginFactory {
55
- // Validate user-provided options before merging with defaults
56
- const hasInvalidTypes = validateOptions(opts, defaultOptions);
57
-
58
- let options = { ...defaultOptions, ...opts } as BrowserPluginOptions;
59
-
60
- // Skip normalization if invalid types detected (prevents runtime errors)
61
- if (hasInvalidTypes) {
62
- console.warn(
63
- `[${LOGGER_CONTEXT}] Using default options due to invalid types`,
22
+ export class BrowserPlugin {
23
+ readonly #router: Router;
24
+ readonly #api: PluginApi;
25
+ readonly #options: BrowserPluginOptions;
26
+ readonly #browser: Browser;
27
+ readonly #regExpCache: RegExpCache;
28
+ readonly #prefix: string;
29
+ readonly #transitionOptions: {
30
+ source: string;
31
+ replace: true;
32
+ forceDeactivate?: boolean;
33
+ };
34
+ readonly #shared: SharedFactoryState;
35
+
36
+ #isTransitioning = false;
37
+ #deferredPopstateEvent: PopStateEvent | null = null;
38
+ readonly #removeStartInterceptor: () => void;
39
+
40
+ constructor(
41
+ router: Router,
42
+ api: PluginApi,
43
+ options: BrowserPluginOptions,
44
+ browser: Browser,
45
+ regExpCache: RegExpCache,
46
+ transitionOptions: {
47
+ source: string;
48
+ replace: true;
49
+ forceDeactivate?: boolean;
50
+ },
51
+ shared: SharedFactoryState,
52
+ ) {
53
+ this.#router = router;
54
+ this.#api = api;
55
+ this.#options = options;
56
+ this.#browser = browser;
57
+ this.#regExpCache = regExpCache;
58
+ this.#transitionOptions = transitionOptions;
59
+ this.#shared = shared;
60
+
61
+ const normalizedOptions = options as URLParseOptions;
62
+
63
+ this.#prefix = options.useHash ? `#${normalizedOptions.hashPrefix}` : "";
64
+
65
+ this.#removeStartInterceptor = this.#api.addInterceptor(
66
+ "start",
67
+ (next, path) => next(path ?? this.#browser.getLocation(this.#options)),
64
68
  );
65
- options = { ...defaultOptions } as BrowserPluginOptions;
66
- }
67
-
68
- // Remove conflicting properties based on mode to prevent misuse
69
- // This ensures options object is clean even if JS users pass invalid config
70
- if (options.useHash === true) {
71
- // Hash mode: remove history-only options
72
- delete (options as unknown as Record<string, unknown>).preserveHash;
73
- } else {
74
- // History mode (default): remove hash-only options
75
- delete (options as unknown as Record<string, unknown>).hashPrefix;
76
- }
77
-
78
- // Normalize base path to prevent common configuration errors
79
- // Type check needed for runtime safety (JS users may pass wrong types)
80
- if (options.base && typeof options.base === "string") {
81
- // Ensure leading slash for absolute paths
82
- if (!options.base.startsWith("/")) {
83
- options.base = `/${options.base}`;
84
- }
85
69
 
86
- // Remove trailing slash to prevent double slashes
87
- if (options.base.endsWith("/")) {
88
- options.base = options.base.slice(0, -1);
89
- }
70
+ this.#augmentRouter();
90
71
  }
91
72
 
92
- // Cache RegExp patterns at plugin creation for performance
93
- const regExpCache = new Map<string, RegExp>();
94
- const getCachedRegExp = (pattern: string): RegExp => {
95
- const cached = regExpCache.get(pattern);
96
-
97
- if (cached !== undefined) {
98
- return cached;
99
- }
100
-
101
- const newRegExp = new RegExp(pattern);
102
-
103
- regExpCache.set(pattern, newRegExp);
73
+ getPlugin(): Plugin {
74
+ return {
75
+ onStart: () => {
76
+ if (this.#shared.removePopStateListener) {
77
+ this.#shared.removePopStateListener();
78
+ }
104
79
 
105
- return newRegExp;
106
- };
80
+ this.#shared.removePopStateListener = this.#browser.addPopstateListener(
81
+ (evt: PopStateEvent) => void this.#onPopState(evt),
82
+ );
83
+ },
107
84
 
108
- // Create transition options with proper typing for exactOptionalPropertyTypes
109
- // replace: true is needed because popstate means URL already changed (back/forward)
110
- const forceDeactivate = options.forceDeactivate;
111
- /* v8 ignore next 4 -- @preserve both branches tested, coverage tool limitation */
112
- const transitionOptions =
113
- forceDeactivate === undefined
114
- ? { source, replace: true }
115
- : { forceDeactivate, source, replace: true };
116
-
117
- let removePopStateListener: (() => void) | undefined;
118
-
119
- return function browserPlugin(routerBase) {
120
- // Cast to augmented Router (class + module augmentation: buildUrl, matchUrl, etc.)
121
- const router = routerBase as Router;
122
- const api = getPluginApi(routerBase);
123
-
124
- // Store original methods for restoration on teardown
125
-
126
- const routerStart = router.start;
127
-
128
- // Transition state management
129
- let isTransitioning = false;
130
-
131
- // Deferred popstate event queue (stores only the last event)
132
- let deferredPopstateEvent: PopStateEvent | null = null;
133
-
134
- // Frozen copy of lastKnownState for immutability
135
- let cachedFrozenState: State | undefined;
136
-
137
- // Options can be changed at runtime in onStart
138
- /* v8 ignore next -- @preserve fallback for undefined base */
139
- const getBase = () => options.base ?? "";
140
- /* v8 ignore next -- @preserve fallback for undefined hashPrefix */
141
- const hashPrefix = options.hashPrefix ?? "";
142
- const escapedHashPrefix = escapeRegExp(hashPrefix);
143
- const prefix = options.useHash ? `#${hashPrefix}` : "";
144
-
145
- // Pre-compute RegExp patterns
146
- const hashPrefixRegExp = escapedHashPrefix
147
- ? getCachedRegExp(`^#${escapedHashPrefix}`)
148
- : null;
149
-
150
- /**
151
- * Parses URL and extracts path using native URL API.
152
- * More robust than regex parsing - handles IPv6, Unicode, edge cases.
153
- *
154
- * @param url - URL to parse
155
- * @returns Path string or null on parse error
156
- */
157
- const urlToPath = (url: string): string | null => {
158
- try {
159
- // Use URL API for reliable parsing
160
- const parsedUrl = new URL(url, globalThis.location.origin);
161
- const pathname = parsedUrl.pathname;
162
- const hash = parsedUrl.hash;
163
- const search = parsedUrl.search;
164
- const base = getBase();
165
-
166
- if (!["http:", "https:"].includes(parsedUrl.protocol)) {
167
- console.warn(`[${LOGGER_CONTEXT}] Invalid URL protocol in ${url}`);
168
-
169
- return null;
85
+ onStop: () => {
86
+ if (this.#shared.removePopStateListener) {
87
+ this.#shared.removePopStateListener();
88
+ this.#shared.removePopStateListener = undefined;
170
89
  }
90
+ },
171
91
 
172
- if (options.useHash) {
173
- // Use cached RegExp or simple slice if no prefix
174
- const path = hashPrefixRegExp
175
- ? hash.replace(hashPrefixRegExp, "")
176
- : hash.slice(1);
92
+ onTransitionSuccess: (
93
+ toState: State,
94
+ fromState: State | undefined,
95
+ navOptions: NavigationOptions,
96
+ ) => {
97
+ const shouldReplaceHistory =
98
+ (navOptions.replace ?? !fromState) ||
99
+ (!!navOptions.reload &&
100
+ this.#router.areStatesEqual(toState, fromState, false));
177
101
 
178
- return path + search;
179
- } else if (base) {
180
- // Remove base prefix
181
- const escapedBase = escapeRegExp(base);
182
- const baseRegExp = getCachedRegExp(`^${escapedBase}`);
183
- const stripped = pathname.replace(baseRegExp, "");
102
+ const url = this.#router.buildUrl(toState.name, toState.params);
184
103
 
185
- return (stripped.startsWith("/") ? "" : "/") + stripped + search;
186
- }
104
+ const shouldPreserveHash =
105
+ !!this.#options.preserveHash &&
106
+ (!fromState || fromState.path === toState.path);
187
107
 
188
- return pathname + search;
189
- } catch (error) {
190
- // Graceful fallback instead of throw
191
- console.warn(`[${LOGGER_CONTEXT}] Could not parse url ${url}`, error);
108
+ const finalUrl = shouldPreserveHash
109
+ ? url + this.#browser.getHash()
110
+ : url;
192
111
 
193
- return null;
194
- }
195
- };
112
+ updateBrowserState(
113
+ toState,
114
+ finalUrl,
115
+ shouldReplaceHistory,
116
+ this.#browser,
117
+ );
118
+ },
196
119
 
197
- /**
198
- * Overrides router.start to integrate with browser location.
199
- * When no path is provided, resolves current browser URL automatically.
200
- */
201
- router.start = (path?: string) => {
202
- return routerStart(path ?? browser.getLocation(options));
120
+ teardown: () => {
121
+ this.#cleanupAugmentation();
122
+ },
203
123
  };
124
+ }
125
+
126
+ #augmentRouter(): void {
127
+ const router = this.#router;
204
128
 
205
- /**
206
- * Builds URL from route name and params.
207
- * Adds base path and hash prefix according to options.
208
- *
209
- * @security
210
- * When using buildUrl output in templates:
211
- * - ✅ SAFE: Modern frameworks (React, Vue, Angular) auto-escape in templates
212
- * - ✅ SAFE: Setting href attribute via DOM API (element.href = url)
213
- * - ❌ UNSAFE: Using innerHTML or similar without escaping
214
- *
215
- * @example
216
- * // Safe - React auto-escapes
217
- * <Link to={router.buildUrl('users', params)} />
218
- *
219
- * // Safe - Vue auto-escapes
220
- * <router-link :to="router.buildUrl('users', params)" />
221
- *
222
- * // Unsafe - manual HTML construction
223
- * element.innerHTML = `<a href="${router.buildUrl('users', params)}">Link</a>`; // ❌ DON'T
224
- */
225
129
  router.buildUrl = (route, params) => {
226
130
  const path = router.buildPath(route, params);
227
131
 
228
- return getBase() + prefix + path;
132
+ return buildUrl(
133
+ path,
134
+ (this.#options as URLParseOptions).base,
135
+ this.#prefix,
136
+ );
229
137
  };
230
138
 
231
- /**
232
- * Matches URL and returns corresponding state
233
- */
234
139
  router.matchUrl = (url) => {
235
- const path = urlToPath(url);
140
+ const path = urlToPath(
141
+ url,
142
+ this.#options as URLParseOptions,
143
+ this.#regExpCache,
144
+ );
236
145
 
237
- return path ? api.matchPath(path) : undefined;
146
+ return path ? this.#api.matchPath(path) : undefined;
238
147
  };
239
148
 
240
- /**
241
- * Replaces current history state without triggering navigation.
242
- * Useful for updating URL without causing a full transition.
243
- */
244
149
  router.replaceHistoryState = (name, params = {}) => {
245
- const state = api.buildState(name, params);
150
+ const state = this.#api.buildState(name, params);
246
151
 
247
152
  if (!state) {
248
153
  throw new Error(
@@ -250,7 +155,7 @@ export function browserPluginFactory(
250
155
  );
251
156
  }
252
157
 
253
- const builtState = api.makeState(
158
+ const builtState = this.#api.makeState(
254
159
  state.name,
255
160
  state.params,
256
161
  router.buildPath(state.name, state.params),
@@ -261,223 +166,97 @@ export function browserPluginFactory(
261
166
  );
262
167
  const url = router.buildUrl(name, params);
263
168
 
264
- updateBrowserState(builtState, url, true, browser, options);
169
+ updateBrowserState(builtState, url, true, this.#browser);
265
170
  };
171
+ }
266
172
 
267
- /**
268
- * lastKnownState: Immutable reference to last successful state.
269
- * Uses caching to avoid creating new objects on every read.
270
- * Optimized: Single copy + freeze operation instead of double copying.
271
- */
272
- Object.defineProperty(router, "lastKnownState", {
273
- get() {
274
- // Note: After teardown, this property is deleted from router,
275
- // so this getter is only called while plugin is active
276
- return cachedFrozenState;
277
- },
278
- set(value?: State) {
279
- // Create frozen copy in one operation (no double copying)
280
- cachedFrozenState = value ? Object.freeze({ ...value }) : undefined;
281
- },
282
- enumerable: true,
283
- configurable: true,
284
- });
285
-
286
- /**
287
- * Processes a deferred popstate event if one exists.
288
- * Called after transition completes.
289
- */
290
- function processDeferredEvent() {
291
- if (deferredPopstateEvent) {
292
- const event = deferredPopstateEvent;
293
-
294
- deferredPopstateEvent = null; // Clear before processing
295
- console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
296
- void onPopState(event);
297
- }
173
+ #cleanupAugmentation(): void {
174
+ if (this.#shared.removePopStateListener) {
175
+ this.#shared.removePopStateListener();
176
+ this.#shared.removePopStateListener = undefined;
298
177
  }
299
178
 
300
- /**
301
- * Main popstate event handler.
302
- * Protected against concurrent transitions and handles errors gracefully.
303
- * Defers events during transitions to prevent browser history desync.
304
- */
305
- async function onPopState(evt: PopStateEvent) {
306
- // Race condition protection: defer event if transition in progress
307
- if (isTransitioning) {
308
- console.warn(
309
- `[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
310
- );
311
- // Store only the latest event (skip intermediate states)
312
- deferredPopstateEvent = evt;
313
-
314
- return;
315
- }
316
-
317
- // Top-level error recovery
318
- try {
319
- const routerState = router.getState();
320
- const state = createStateFromEvent(evt, api, browser, options);
321
- const isNewState = !isState(evt.state);
179
+ this.#removeStartInterceptor();
322
180
 
323
- // Handle missing state
324
- if (!state && handleMissingState(router, api, transitionOptions)) {
325
- return;
326
- }
327
-
328
- // Skip if states are equal
329
- if (shouldSkipTransition(state, routerState, router)) {
330
- return;
331
- }
332
-
333
- // Execute transition with race protection
334
- // state is guaranteed to be defined here because:
335
- // 1. handleMissingState handles !state case (line 339)
336
- // 2. shouldSkipTransition returns true when !state (utils.ts:129)
337
- /* v8 ignore start: defensive guard - state guaranteed defined by control flow above */
338
- if (!state) {
339
- return;
340
- }
341
- /* v8 ignore stop */
342
-
343
- isTransitioning = true;
344
-
345
- try {
346
- // transitionOptions includes replace: true, which is passed to TRANSITION_SUCCESS
347
- const toState = await api.navigateToState(
348
- state,
349
- routerState,
350
- transitionOptions,
351
- );
352
-
353
- handleTransitionResult(
354
- undefined,
355
- toState,
356
- routerState,
357
- isNewState,
358
- router,
359
- browser,
360
- options,
361
- );
362
- } catch (error) {
363
- handleTransitionResult(
364
- error as RouterError,
365
- undefined,
366
- routerState,
367
- isNewState,
368
- router,
369
- browser,
370
- options,
371
- );
372
- } finally {
373
- isTransitioning = false;
374
- // Process any deferred popstate events after transition completes
375
- processDeferredEvent();
376
- }
377
- } catch (error) {
378
- isTransitioning = false;
379
- console.error(
380
- `[${LOGGER_CONTEXT}] Critical error in onPopState`,
381
- error,
382
- );
181
+ delete (this.#router as Partial<Router>).buildUrl;
182
+ delete (this.#router as Partial<Router>).matchUrl;
183
+ delete (this.#router as Partial<Router>).replaceHistoryState;
184
+ }
383
185
 
384
- // Attempt recovery: sync browser with router state
385
- try {
386
- const currentState = router.getState();
387
-
388
- if (currentState) {
389
- const url = router.buildUrl(currentState.name, currentState.params);
390
-
391
- browser.replaceState(currentState as HistoryState, "", url);
392
- }
393
- } catch (recoveryError) {
394
- // If recovery fails, there's nothing more we can do
395
- console.error(
396
- `[${LOGGER_CONTEXT}] Failed to recover from critical error`,
397
- recoveryError,
398
- );
399
- }
186
+ #processDeferredEvent(): void {
187
+ if (this.#deferredPopstateEvent) {
188
+ const event = this.#deferredPopstateEvent;
400
189
 
401
- // Process any deferred events even after error
402
- processDeferredEvent();
403
- }
190
+ this.#deferredPopstateEvent = null;
191
+ console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
192
+ void this.#onPopState(event);
404
193
  }
194
+ }
405
195
 
406
- return {
407
- /**
408
- * Called when router.start() is invoked.
409
- * Sets up browser history integration.
410
- */
411
- onStart: () => {
412
- if (removePopStateListener) {
413
- removePopStateListener();
414
- }
415
-
416
- removePopStateListener = browser.addPopstateListener(
417
- (evt: PopStateEvent) => void onPopState(evt),
418
- options,
419
- );
420
- },
421
-
422
- /**
423
- * Called when router.stop() is invoked.
424
- * Cleans up event listeners.
425
- */
426
- onStop: () => {
427
- if (removePopStateListener) {
428
- removePopStateListener();
429
- removePopStateListener = undefined;
430
- }
431
- },
432
-
433
- /**
434
- * Called after successful navigation.
435
- * Updates browser history with new state.
436
- */
437
- onTransitionSuccess: (toState, fromState, navOptions) => {
438
- router.lastKnownState = toState;
196
+ async #onPopState(evt: PopStateEvent): Promise<void> {
197
+ if (this.#isTransitioning) {
198
+ console.warn(
199
+ `[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
200
+ );
201
+ this.#deferredPopstateEvent = evt;
439
202
 
440
- // Determine if we should replace or push history entry
441
- const replaceHistory =
442
- (navOptions.replace ?? !fromState) ||
443
- (!!navOptions.reload &&
444
- router.areStatesEqual(toState, fromState, false));
203
+ return;
204
+ }
445
205
 
446
- // Build URL with base and hash prefix
447
- const url = router.buildUrl(toState.name, toState.params);
206
+ this.#isTransitioning = true;
448
207
 
449
- // Preserve hash fragment if configured
450
- // Note: preserveHash is deleted in hash mode, so it's always undefined there
451
- const shouldPreserveHash =
452
- options.preserveHash &&
453
- (!fromState || fromState.path === toState.path);
208
+ try {
209
+ const route = getRouteFromEvent(
210
+ evt,
211
+ this.#api,
212
+ this.#browser,
213
+ this.#options,
214
+ );
454
215
 
455
- const finalUrl = shouldPreserveHash ? url + browser.getHash() : url;
216
+ // eslint-disable-next-line unicorn/prefer-ternary
217
+ if (route) {
218
+ await this.#router.navigate(
219
+ route.name,
220
+ route.params,
221
+ this.#transitionOptions,
222
+ );
223
+ } else {
224
+ await this.#router.navigateToDefault({
225
+ ...this.#transitionOptions,
226
+ reload: true,
227
+ replace: true,
228
+ });
229
+ }
230
+ } catch (error) {
231
+ if (!(error instanceof RouterError)) {
232
+ this.#recoverFromCriticalError(error);
233
+ }
234
+ } finally {
235
+ this.#isTransitioning = false;
236
+ this.#processDeferredEvent();
237
+ }
238
+ }
456
239
 
457
- // Update browser history
458
- updateBrowserState(toState, finalUrl, replaceHistory, browser, options);
459
- },
240
+ #recoverFromCriticalError(error: unknown): void {
241
+ console.error(`[${LOGGER_CONTEXT}] Critical error in onPopState`, error);
460
242
 
461
- /**
462
- * Called when plugin is unsubscribed.
463
- * Restores original router state for clean teardown.
464
- */
465
- teardown: () => {
466
- // Remove event listeners
467
- if (removePopStateListener) {
468
- removePopStateListener();
469
- removePopStateListener = undefined;
470
- }
243
+ try {
244
+ const currentState = this.#router.getState();
471
245
 
472
- // Restore original router methods
473
- router.start = routerStart;
246
+ /* v8 ignore next -- @preserve: router always has state after start(); defensive guard for edge cases */
247
+ if (currentState) {
248
+ const url = this.#router.buildUrl(
249
+ currentState.name,
250
+ currentState.params,
251
+ );
474
252
 
475
- // Clean up added properties
476
- delete (router as Partial<Router>).buildUrl;
477
- delete (router as Partial<Router>).matchUrl;
478
- delete (router as Partial<Router>).replaceHistoryState;
479
- delete (router as Partial<Router>).lastKnownState;
480
- },
481
- };
482
- };
253
+ this.#browser.replaceState(currentState, url);
254
+ }
255
+ } catch (recoveryError) {
256
+ console.error(
257
+ `[${LOGGER_CONTEXT}] Failed to recover from critical error`,
258
+ recoveryError,
259
+ );
260
+ }
261
+ }
483
262
  }