@real-router/browser-plugin 0.4.0 → 0.5.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/types.ts ADDED
@@ -0,0 +1,222 @@
1
+ // packages/browser-plugin/modules/types.ts
2
+
3
+ import type { State } from "@real-router/core";
4
+
5
+ /**
6
+ * Common options shared between hash and history modes
7
+ */
8
+ interface BaseBrowserPluginOptions {
9
+ /**
10
+ * Force deactivation of current route even if canDeactivate returns false.
11
+ *
12
+ * @default true
13
+ */
14
+ forceDeactivate?: boolean;
15
+
16
+ /**
17
+ * Base path for all routes (e.g., "/app" for hosted at /app/).
18
+ *
19
+ * @default ""
20
+ */
21
+ base?: string;
22
+
23
+ /**
24
+ * Merge new state with existing history.state when updating.
25
+ * Useful for preserving external state set by other code.
26
+ *
27
+ * @default false
28
+ */
29
+ mergeState?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Hash-based routing configuration.
34
+ * Uses URL hash for navigation (e.g., example.com/#/path).
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * // Standard hash routing
39
+ * browserPluginFactory({ useHash: true })
40
+ * // → example.com/#/users
41
+ *
42
+ * // Hash routing with prefix
43
+ * browserPluginFactory({ useHash: true, hashPrefix: "!" })
44
+ * // → example.com/#!/users
45
+ * ```
46
+ */
47
+ export interface HashModeOptions extends BaseBrowserPluginOptions {
48
+ /**
49
+ * Enable hash-based routing
50
+ */
51
+ useHash: true;
52
+
53
+ /**
54
+ * Prefix for hash (e.g., "!" for "#!/path").
55
+ * Only valid when useHash is true.
56
+ *
57
+ * @default ""
58
+ */
59
+ hashPrefix?: string;
60
+
61
+ /**
62
+ * Not available in hash mode.
63
+ * Hash preservation only works with HTML5 History API.
64
+ * Use `useHash: false` to enable this option.
65
+ */
66
+ preserveHash?: never;
67
+ }
68
+
69
+ /**
70
+ * HTML5 History API routing configuration.
71
+ * Uses pushState/replaceState for navigation (e.g., example.com/path).
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * // Standard history routing
76
+ * browserPluginFactory({ useHash: false })
77
+ * // → example.com/users
78
+ *
79
+ * // Preserve URL hash fragments
80
+ * browserPluginFactory({ useHash: false, preserveHash: true })
81
+ * // → example.com/users#section
82
+ * ```
83
+ */
84
+ export interface HistoryModeOptions extends BaseBrowserPluginOptions {
85
+ /**
86
+ * Disable hash-based routing (use HTML5 History API)
87
+ *
88
+ * @default false
89
+ */
90
+ useHash?: false;
91
+
92
+ /**
93
+ * Preserve URL hash fragment on initial navigation.
94
+ * Only valid when useHash is false.
95
+ *
96
+ * @default true
97
+ */
98
+ preserveHash?: boolean;
99
+
100
+ /**
101
+ * Not available in history mode.
102
+ * Hash prefix only works with hash-based routing.
103
+ * Use `useHash: true` to enable this option.
104
+ */
105
+ hashPrefix?: never;
106
+ }
107
+
108
+ /**
109
+ * Type-safe browser plugin configuration.
110
+ *
111
+ * Uses discriminated union to prevent conflicting options:
112
+ * - Hash mode (useHash: true): allows hashPrefix, forbids preserveHash
113
+ * - History mode (useHash: false): allows preserveHash, forbids hashPrefix
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * // ✅ Valid: Hash mode with prefix
118
+ * const config1: BrowserPluginOptions = {
119
+ * useHash: true,
120
+ * hashPrefix: "!"
121
+ * };
122
+ *
123
+ * // ✅ Valid: History mode with hash preservation
124
+ * const config2: BrowserPluginOptions = {
125
+ * useHash: false,
126
+ * preserveHash: true
127
+ * };
128
+ *
129
+ * // ❌ Error: Cannot use preserveHash with hash mode
130
+ * const config3: BrowserPluginOptions = {
131
+ * useHash: true,
132
+ * preserveHash: true // Type error!
133
+ * };
134
+ *
135
+ * // ❌ Error: Cannot use hashPrefix with history mode
136
+ * const config4: BrowserPluginOptions = {
137
+ * useHash: false,
138
+ * hashPrefix: "!" // Type error!
139
+ * };
140
+ * ```
141
+ */
142
+ export type BrowserPluginOptions = HashModeOptions | HistoryModeOptions;
143
+
144
+ /**
145
+ * Browser API abstraction for cross-environment compatibility.
146
+ * Provides same interface in browser and SSR contexts.
147
+ */
148
+ export interface Browser {
149
+ /**
150
+ * Gets base path from current browser location
151
+ *
152
+ * @returns Current pathname
153
+ */
154
+ getBase: () => string;
155
+
156
+ /**
157
+ * Pushes new state to browser history
158
+ *
159
+ * @param state - History state object
160
+ * @param title - Document title (usually ignored by browsers)
161
+ * @param path - URL path
162
+ */
163
+ pushState: (state: HistoryState, title: string | null, path: string) => void;
164
+
165
+ /**
166
+ * Replaces current history state
167
+ *
168
+ * @param state - History state object
169
+ * @param title - Document title (usually ignored by browsers)
170
+ * @param path - URL path
171
+ */
172
+ replaceState: (
173
+ state: HistoryState,
174
+ title: string | null,
175
+ path: string,
176
+ ) => void;
177
+
178
+ /**
179
+ * Adds popstate/hashchange event listeners.
180
+ * Overloaded to support both PopStateEvent and HashChangeEvent.
181
+ *
182
+ * @param fn - Event handler
183
+ * @param opts - Plugin options
184
+ * @returns Cleanup function to remove listeners
185
+ */
186
+ addPopstateListener: ((
187
+ fn: (evt: PopStateEvent) => void,
188
+ opts: BrowserPluginOptions,
189
+ ) => () => void) &
190
+ ((
191
+ fn: (evt: HashChangeEvent) => void,
192
+ opts: BrowserPluginOptions,
193
+ ) => () => void);
194
+
195
+ /**
196
+ * Gets current location path respecting plugin options
197
+ *
198
+ * @param opts - Plugin options
199
+ * @returns Current path string
200
+ */
201
+ getLocation: (opts: BrowserPluginOptions) => string;
202
+
203
+ /**
204
+ * Gets current history state with validation
205
+ *
206
+ * @returns Valid history state or undefined
207
+ */
208
+ getState: () => HistoryState | undefined;
209
+
210
+ /**
211
+ * Gets current URL hash
212
+ *
213
+ * @returns Hash string (including #)
214
+ */
215
+ getHash: () => string;
216
+ }
217
+
218
+ /**
219
+ * History state object stored in browser history.
220
+ * Extends real-router State with additional properties that may be set by external code.
221
+ */
222
+ export type HistoryState = State & Record<string, unknown>;
package/src/utils.ts ADDED
@@ -0,0 +1,292 @@
1
+ // packages/browser-plugin/modules/utils.ts
2
+
3
+ import { errorCodes } from "@real-router/core";
4
+ import { isStateStrict as isState } from "type-guards";
5
+
6
+ import { type DefaultBrowserPluginOptions, LOGGER_CONTEXT } from "./constants";
7
+
8
+ import type { BrowserPluginOptions, HistoryState, Browser } from "./types";
9
+ import type {
10
+ Router,
11
+ NavigationOptions,
12
+ RouterError,
13
+ State,
14
+ } from "@real-router/core";
15
+
16
+ /**
17
+ * No-op function for default callbacks
18
+ */
19
+ export const noop = (): void => undefined;
20
+
21
+ /**
22
+ * Cache for escaped RegExp strings
23
+ */
24
+ const escapeRegExpCache = new Map<string, string>();
25
+
26
+ /**
27
+ * Escapes special RegExp characters in a string.
28
+ * Used to safely build RegExp from user-provided strings (hashPrefix, base).
29
+ *
30
+ * @param str - String to escape
31
+ * @returns Escaped string safe for RegExp construction
32
+ */
33
+ export const escapeRegExp = (str: string): string => {
34
+ const cached = escapeRegExpCache.get(str);
35
+
36
+ if (cached !== undefined) {
37
+ return cached;
38
+ }
39
+
40
+ const escaped = str.replaceAll(/[$()*+.?[\\\]^{|}-]/g, String.raw`\$&`);
41
+
42
+ escapeRegExpCache.set(str, escaped);
43
+
44
+ return escaped;
45
+ };
46
+
47
+ /**
48
+ * Creates state from popstate event
49
+ *
50
+ * @param evt - PopStateEvent from browser
51
+ * @param router - Router instance
52
+ * @param browser - Browser API instance
53
+ * @param options - Browser plugin options
54
+ * @returns Router state or undefined
55
+ */
56
+ export function createStateFromEvent(
57
+ evt: PopStateEvent,
58
+ router: Router,
59
+ browser: Browser,
60
+ options: BrowserPluginOptions,
61
+ ): State | undefined {
62
+ const isNewState = !isState(evt.state);
63
+
64
+ if (isNewState) {
65
+ return router.matchPath(browser.getLocation(options));
66
+ }
67
+
68
+ return router.makeState(
69
+ evt.state.name,
70
+ evt.state.params,
71
+ evt.state.path,
72
+ {
73
+ ...evt.state.meta,
74
+ params: evt.state.meta?.params ?? {},
75
+ options: evt.state.meta?.options ?? {},
76
+ },
77
+ evt.state.meta?.id,
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Checks if transition should be skipped (same states)
83
+ *
84
+ * @param newState - New state from event
85
+ * @param currentState - Current router state
86
+ * @param router - Router instance
87
+ * @returns true if transition should be skipped
88
+ */
89
+ export function shouldSkipTransition(
90
+ newState: State | undefined,
91
+ currentState: State | undefined,
92
+ router: Router,
93
+ ): boolean {
94
+ if (!newState) {
95
+ return true;
96
+ }
97
+
98
+ return !!(
99
+ currentState && router.areStatesEqual(newState, currentState, false)
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Handles missing state by navigating to default route
105
+ *
106
+ * @param router - Router instance
107
+ * @param transitionOptions - Options for transition
108
+ * @returns true if handled, false if no default route
109
+ */
110
+ export function handleMissingState(
111
+ router: Router,
112
+ transitionOptions: NavigationOptions,
113
+ ): boolean {
114
+ const routerOptions = router.getOptions();
115
+ const { defaultRoute } = routerOptions;
116
+
117
+ if (!defaultRoute) {
118
+ return false;
119
+ }
120
+
121
+ void router.navigateToDefault({
122
+ ...transitionOptions,
123
+ reload: true,
124
+ replace: true,
125
+ });
126
+
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Updates browser state (pushState or replaceState)
132
+ *
133
+ * @param state - Router state
134
+ * @param url - URL to set
135
+ * @param replace - Whether to replace instead of push
136
+ * @param browser - Browser API instance
137
+ * @param options - Browser plugin options
138
+ */
139
+ export function updateBrowserState(
140
+ state: State,
141
+ url: string,
142
+ replace: boolean,
143
+ browser: Browser,
144
+ options: BrowserPluginOptions,
145
+ ): void {
146
+ const trimmedState: HistoryState = {
147
+ meta: state.meta,
148
+ name: state.name,
149
+ params: state.params,
150
+ path: state.path,
151
+ };
152
+
153
+ const finalState: HistoryState =
154
+ options.mergeState && browser.getState()
155
+ ? { ...browser.getState(), ...trimmedState }
156
+ : trimmedState;
157
+
158
+ if (replace) {
159
+ browser.replaceState(finalState, "", url);
160
+ } else {
161
+ browser.pushState(finalState, "", url);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Handles transition result (success or error)
167
+ *
168
+ * Success case is handled by the router FSM chain (TRANSITION_SUCCESS event).
169
+ * This function only handles error cases that need URL restoration.
170
+ *
171
+ * @param err - Router error or undefined if successful
172
+ * @param toState - Target state
173
+ * @param fromState - Source state
174
+ * @param isNewState - Whether this is a new state (not from history)
175
+ * @param router - Router instance
176
+ * @param browser - Browser API instance
177
+ * @param options - Browser plugin options
178
+ */
179
+ export function handleTransitionResult(
180
+ err: RouterError | undefined,
181
+ toState: State | undefined,
182
+ fromState: State | undefined,
183
+ isNewState: boolean,
184
+ router: Router,
185
+ browser: Browser,
186
+ options: BrowserPluginOptions,
187
+ ): void {
188
+ // Success case handled by the router FSM chain (TRANSITION_SUCCESS event)
189
+ if (!err) {
190
+ return;
191
+ }
192
+
193
+ // Handle CANNOT_DEACTIVATE - restore previous URL
194
+ if (
195
+ err.code === errorCodes.CANNOT_DEACTIVATE &&
196
+ toState &&
197
+ fromState &&
198
+ !isNewState
199
+ ) {
200
+ const url = router.buildUrl(fromState.name, fromState.params);
201
+
202
+ updateBrowserState(fromState, url, true, browser, options);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Type guard to check if a key exists in default options
208
+ */
209
+ function isDefaultOptionKey(
210
+ key: string,
211
+ defaults: DefaultBrowserPluginOptions,
212
+ ): key is keyof DefaultBrowserPluginOptions {
213
+ return key in defaults;
214
+ }
215
+
216
+ /**
217
+ * Validates that an option value has the correct type
218
+ */
219
+ function validateOptionType(
220
+ key: keyof DefaultBrowserPluginOptions,
221
+ value: unknown,
222
+ expectedType: string,
223
+ ): boolean {
224
+ const actualType = typeof value;
225
+
226
+ if (actualType !== expectedType && value !== undefined) {
227
+ console.warn(
228
+ `[${LOGGER_CONTEXT}] Invalid type for '${key}': expected ${expectedType}, got ${actualType}`,
229
+ );
230
+
231
+ return false;
232
+ }
233
+
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Validates browser plugin options and warns about conflicting configurations.
239
+ * TypeScript types prevent conflicts at compile-time, but runtime validation
240
+ * is needed for JavaScript users and dynamic configurations.
241
+ *
242
+ * IMPORTANT: This validates only user-provided options, not merged defaults.
243
+ *
244
+ * @returns true if invalid types detected, false otherwise
245
+ */
246
+ export function validateOptions(
247
+ opts: Partial<BrowserPluginOptions> | undefined,
248
+ defaultOptions: DefaultBrowserPluginOptions,
249
+ ): boolean {
250
+ if (!opts) {
251
+ return false;
252
+ }
253
+
254
+ let hasInvalidTypes = false;
255
+
256
+ // Validate option types against defaults
257
+ // Using Object.keys ensures we only check properties that actually exist
258
+ for (const key of Object.keys(opts)) {
259
+ if (isDefaultOptionKey(key, defaultOptions)) {
260
+ const expectedType = typeof defaultOptions[key];
261
+ const value = opts[key];
262
+ const isValid = validateOptionType(key, value, expectedType);
263
+
264
+ if (!isValid) {
265
+ hasInvalidTypes = true;
266
+ }
267
+ }
268
+ }
269
+
270
+ // Check for hash mode conflicts
271
+ // Runtime validation for JS users - TypeScript prevents this at compile time
272
+
273
+ if (opts.useHash === true && "preserveHash" in opts) {
274
+ console.warn(`[${LOGGER_CONTEXT}] preserveHash ignored in hash mode`);
275
+ }
276
+
277
+ // Check for history mode conflicts
278
+ // Runtime validation for JS users - TypeScript prevents this at compile time
279
+
280
+ if (opts.useHash === false && "hashPrefix" in opts) {
281
+ // Single type assertion needed: TypeScript narrows opts to HistoryModeOptions
282
+ // where hashPrefix is 'never', but we need to check it at runtime for JS users
283
+ const optsRecord = opts as unknown as Record<string, unknown>;
284
+ const hashPrefix = optsRecord.hashPrefix;
285
+
286
+ if (hashPrefix !== undefined && hashPrefix !== "") {
287
+ console.warn(`[${LOGGER_CONTEXT}] hashPrefix ignored in history mode`);
288
+ }
289
+ }
290
+
291
+ return hasInvalidTypes;
292
+ }