@real-router/browser-plugin 0.4.0 → 0.5.1

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