@real-router/browser-plugin 0.8.0 → 0.9.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,16 +1,15 @@
1
- import { RouterError } from "@real-router/core";
1
+ import {
2
+ createPopstateHandler,
3
+ createPopstateLifecycle,
4
+ createStartInterceptor,
5
+ createReplaceHistoryState,
6
+ shouldReplaceHistory,
7
+ updateBrowserState,
8
+ } from "browser-env";
2
9
 
3
- import { LOGGER_CONTEXT } from "./constants";
4
- import { getRouteFromEvent, updateBrowserState } from "./popstate-utils";
5
10
  import { buildUrl, urlToPath } from "./url-utils";
6
11
 
7
- import type {
8
- Browser,
9
- BrowserPluginOptions,
10
- RegExpCache,
11
- SharedFactoryState,
12
- URLParseOptions,
13
- } from "./types";
12
+ import type { BrowserPluginOptions } from "./types";
14
13
  import type {
15
14
  NavigationOptions,
16
15
  Params,
@@ -19,32 +18,20 @@ import type {
19
18
  State,
20
19
  Plugin,
21
20
  } from "@real-router/core";
21
+ import type { Browser, SharedFactoryState } from "browser-env";
22
22
 
23
23
  export class BrowserPlugin {
24
24
  readonly #router: Router;
25
- readonly #api: PluginApi;
26
- readonly #options: BrowserPluginOptions;
27
25
  readonly #browser: Browser;
28
- readonly #regExpCache: RegExpCache;
29
- readonly #prefix: string;
30
- readonly #transitionOptions: {
31
- source: string;
32
- replace: true;
33
- forceDeactivate?: boolean;
34
- };
35
- readonly #shared: SharedFactoryState;
36
-
37
- #isTransitioning = false;
38
- #deferredPopstateEvent: PopStateEvent | null = null;
39
26
  readonly #removeStartInterceptor: () => void;
40
27
  readonly #removeExtensions: () => void;
28
+ readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
41
29
 
42
30
  constructor(
43
31
  router: Router,
44
32
  api: PluginApi,
45
- options: BrowserPluginOptions,
33
+ options: Required<BrowserPluginOptions>,
46
34
  browser: Browser,
47
- regExpCache: RegExpCache,
48
35
  transitionOptions: {
49
36
  source: string;
50
37
  replace: true;
@@ -53,199 +40,79 @@ export class BrowserPlugin {
53
40
  shared: SharedFactoryState,
54
41
  ) {
55
42
  this.#router = router;
56
- this.#api = api;
57
- this.#options = options;
58
43
  this.#browser = browser;
59
- this.#regExpCache = regExpCache;
60
- this.#transitionOptions = transitionOptions;
61
- this.#shared = shared;
62
-
63
- const normalizedOptions = options as URLParseOptions;
64
44
 
65
- this.#prefix = options.useHash ? `#${normalizedOptions.hashPrefix}` : "";
45
+ this.#removeStartInterceptor = createStartInterceptor(api, browser);
66
46
 
67
- this.#removeStartInterceptor = this.#api.addInterceptor(
68
- "start",
69
- (next, path) => next(path ?? this.#browser.getLocation(this.#options)),
70
- );
47
+ const pluginBuildUrl = (route: string, params?: Params) => {
48
+ const path = router.buildPath(route, params);
71
49
 
72
- this.#removeExtensions = this.#api.extendRouter({
73
- buildUrl: (route: string, params?: Params) => {
74
- const path = this.#router.buildPath(route, params);
50
+ return buildUrl(path, options.base);
51
+ };
75
52
 
76
- return buildUrl(
77
- path,
78
- (this.#options as URLParseOptions).base,
79
- this.#prefix,
80
- );
81
- },
53
+ this.#removeExtensions = api.extendRouter({
54
+ buildUrl: pluginBuildUrl,
82
55
  matchUrl: (url: string) => {
83
- const path = urlToPath(
84
- url,
85
- this.#options as URLParseOptions,
86
- this.#regExpCache,
87
- );
56
+ const path = urlToPath(url, options.base);
88
57
 
89
- return path ? this.#api.matchPath(path) : undefined;
58
+ return path ? api.matchPath(path) : undefined;
90
59
  },
91
- replaceHistoryState: (name: string, params: Params = {}) => {
92
- const state = this.#api.buildState(name, params);
93
-
94
- if (!state) {
95
- throw new Error(
96
- `[real-router] Cannot replace state: route "${name}" is not found`,
97
- );
98
- }
99
-
100
- const builtState = this.#api.makeState(
101
- state.name,
102
- state.params,
103
- this.#router.buildPath(state.name, state.params),
104
- {
105
- params: state.meta,
106
- },
107
- 1, // forceId
108
- );
109
- const url = this.#router.buildUrl(name, params);
60
+ replaceHistoryState: createReplaceHistoryState(
61
+ api,
62
+ router,
63
+ browser,
64
+ pluginBuildUrl,
65
+ ),
66
+ });
67
+
68
+ const handler = createPopstateHandler({
69
+ router,
70
+ api,
71
+ browser,
72
+ transitionOptions,
73
+ loggerContext: "browser-plugin",
74
+ buildUrl: (name: string, params?: Params) =>
75
+ router.buildUrl(name, params),
76
+ });
110
77
 
111
- updateBrowserState(builtState, url, true, this.#browser);
78
+ this.#lifecycle = createPopstateLifecycle({
79
+ browser,
80
+ shared,
81
+ handler,
82
+ cleanup: () => {
83
+ this.#removeStartInterceptor();
84
+ this.#removeExtensions();
112
85
  },
113
86
  });
114
87
  }
115
88
 
116
89
  getPlugin(): Plugin {
117
90
  return {
118
- onStart: () => {
119
- if (this.#shared.removePopStateListener) {
120
- this.#shared.removePopStateListener();
121
- }
122
-
123
- this.#shared.removePopStateListener = this.#browser.addPopstateListener(
124
- (evt: PopStateEvent) => void this.#onPopState(evt),
125
- );
126
- },
127
-
128
- onStop: () => {
129
- if (this.#shared.removePopStateListener) {
130
- this.#shared.removePopStateListener();
131
- this.#shared.removePopStateListener = undefined;
132
- }
133
- },
91
+ ...this.#lifecycle,
134
92
 
135
93
  onTransitionSuccess: (
136
94
  toState: State,
137
95
  fromState: State | undefined,
138
96
  navOptions: NavigationOptions,
139
97
  ) => {
140
- const shouldReplaceHistory =
141
- (navOptions.replace ?? !fromState) ||
142
- (!!navOptions.reload &&
143
- this.#router.areStatesEqual(toState, fromState, false));
98
+ const replaceHistory = shouldReplaceHistory(
99
+ navOptions,
100
+ toState,
101
+ fromState,
102
+ this.#router,
103
+ );
144
104
 
145
105
  const url = this.#router.buildUrl(toState.name, toState.params);
146
106
 
147
107
  const shouldPreserveHash =
148
- !!this.#options.preserveHash &&
149
- (!fromState || fromState.path === toState.path);
108
+ !fromState || fromState.path === toState.path;
150
109
 
151
110
  const finalUrl = shouldPreserveHash
152
111
  ? url + this.#browser.getHash()
153
112
  : url;
154
113
 
155
- updateBrowserState(
156
- toState,
157
- finalUrl,
158
- shouldReplaceHistory,
159
- this.#browser,
160
- );
161
- },
162
-
163
- teardown: () => {
164
- if (this.#shared.removePopStateListener) {
165
- this.#shared.removePopStateListener();
166
- this.#shared.removePopStateListener = undefined;
167
- }
168
-
169
- this.#removeStartInterceptor();
170
- this.#removeExtensions();
114
+ updateBrowserState(toState, finalUrl, replaceHistory, this.#browser);
171
115
  },
172
116
  };
173
117
  }
174
-
175
- #processDeferredEvent(): void {
176
- if (this.#deferredPopstateEvent) {
177
- const event = this.#deferredPopstateEvent;
178
-
179
- this.#deferredPopstateEvent = null;
180
- console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
181
- void this.#onPopState(event);
182
- }
183
- }
184
-
185
- async #onPopState(evt: PopStateEvent): Promise<void> {
186
- if (this.#isTransitioning) {
187
- console.warn(
188
- `[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
189
- );
190
- this.#deferredPopstateEvent = evt;
191
-
192
- return;
193
- }
194
-
195
- this.#isTransitioning = true;
196
-
197
- try {
198
- const route = getRouteFromEvent(
199
- evt,
200
- this.#api,
201
- this.#browser,
202
- this.#options,
203
- );
204
-
205
- // eslint-disable-next-line unicorn/prefer-ternary
206
- if (route) {
207
- await this.#router.navigate(
208
- route.name,
209
- route.params,
210
- this.#transitionOptions,
211
- );
212
- } else {
213
- await this.#router.navigateToDefault({
214
- ...this.#transitionOptions,
215
- reload: true,
216
- replace: true,
217
- });
218
- }
219
- } catch (error) {
220
- if (!(error instanceof RouterError)) {
221
- this.#recoverFromCriticalError(error);
222
- }
223
- } finally {
224
- this.#isTransitioning = false;
225
- this.#processDeferredEvent();
226
- }
227
- }
228
-
229
- #recoverFromCriticalError(error: unknown): void {
230
- console.error(`[${LOGGER_CONTEXT}] Critical error in onPopState`, error);
231
-
232
- try {
233
- const currentState = this.#router.getState();
234
-
235
- /* v8 ignore next -- @preserve: router always has state after start(); defensive guard for edge cases */
236
- if (currentState) {
237
- const url = this.#router.buildUrl(
238
- currentState.name,
239
- currentState.params,
240
- );
241
-
242
- this.#browser.replaceState(currentState, url);
243
- }
244
- } catch (recoveryError) {
245
- console.error(
246
- `[${LOGGER_CONTEXT}] Failed to recover from critical error`,
247
- recoveryError,
248
- );
249
- }
250
- }
251
118
  }
package/src/types.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  // packages/browser-plugin/src/types.ts
2
2
 
3
- import type { State } from "@real-router/core";
4
-
5
3
  /**
6
- * Common options shared between hash and history modes
4
+ * Browser plugin configuration.
7
5
  */
8
- interface BaseBrowserPluginOptions {
6
+ export interface BrowserPluginOptions {
9
7
  /**
10
8
  * Force deactivation of current route even if canDeactivate returns false.
11
9
  *
@@ -20,178 +18,3 @@ interface BaseBrowserPluginOptions {
20
18
  */
21
19
  base?: string;
22
20
  }
23
-
24
- /**
25
- * Hash-based routing configuration.
26
- * Uses URL hash for navigation (e.g., example.com/#/path).
27
- *
28
- * @example
29
- * ```ts
30
- * // Standard hash routing
31
- * browserPluginFactory({ useHash: true })
32
- * // → example.com/#/users
33
- *
34
- * // Hash routing with prefix
35
- * browserPluginFactory({ useHash: true, hashPrefix: "!" })
36
- * // → example.com/#!/users
37
- * ```
38
- */
39
- export interface HashModeOptions extends BaseBrowserPluginOptions {
40
- /**
41
- * Enable hash-based routing
42
- */
43
- useHash: true;
44
-
45
- /**
46
- * Prefix for hash (e.g., "!" for "#!/path").
47
- * Only valid when useHash is true.
48
- *
49
- * @default ""
50
- */
51
- hashPrefix?: string;
52
-
53
- /**
54
- * Not available in hash mode.
55
- * Hash preservation only works with HTML5 History API.
56
- * Use `useHash: false` to enable this option.
57
- */
58
- preserveHash?: never;
59
- }
60
-
61
- /**
62
- * HTML5 History API routing configuration.
63
- * Uses pushState/replaceState for navigation (e.g., example.com/path).
64
- *
65
- * @example
66
- * ```ts
67
- * // Standard history routing
68
- * browserPluginFactory({ useHash: false })
69
- * // → example.com/users
70
- *
71
- * // Preserve URL hash fragments
72
- * browserPluginFactory({ useHash: false, preserveHash: true })
73
- * // → example.com/users#section
74
- * ```
75
- */
76
- export interface HistoryModeOptions extends BaseBrowserPluginOptions {
77
- /**
78
- * Disable hash-based routing (use HTML5 History API)
79
- *
80
- * @default false
81
- */
82
- useHash?: false;
83
-
84
- /**
85
- * Preserve URL hash fragment on initial navigation.
86
- * Only valid when useHash is false.
87
- *
88
- * @default true
89
- */
90
- preserveHash?: boolean;
91
-
92
- /**
93
- * Not available in history mode.
94
- * Hash prefix only works with hash-based routing.
95
- * Use `useHash: true` to enable this option.
96
- */
97
- hashPrefix?: never;
98
- }
99
-
100
- /**
101
- * Type-safe browser plugin configuration.
102
- *
103
- * Uses discriminated union to prevent conflicting options:
104
- * - Hash mode (useHash: true): allows hashPrefix, forbids preserveHash
105
- * - History mode (useHash: false): allows preserveHash, forbids hashPrefix
106
- *
107
- * @example
108
- * ```ts
109
- * // ✅ Valid: Hash mode with prefix
110
- * const config1: BrowserPluginOptions = {
111
- * useHash: true,
112
- * hashPrefix: "!"
113
- * };
114
- *
115
- * // ✅ Valid: History mode with hash preservation
116
- * const config2: BrowserPluginOptions = {
117
- * useHash: false,
118
- * preserveHash: true
119
- * };
120
- *
121
- * // ❌ Error: Cannot use preserveHash with hash mode
122
- * const config3: BrowserPluginOptions = {
123
- * useHash: true,
124
- * preserveHash: true // Type error!
125
- * };
126
- *
127
- * // ❌ Error: Cannot use hashPrefix with history mode
128
- * const config4: BrowserPluginOptions = {
129
- * useHash: false,
130
- * hashPrefix: "!" // Type error!
131
- * };
132
- * ```
133
- */
134
- export type BrowserPluginOptions = HashModeOptions | HistoryModeOptions;
135
-
136
- /**
137
- * Browser API abstraction for cross-environment compatibility.
138
- * Provides same interface in browser and SSR contexts.
139
- */
140
- export interface Browser {
141
- /**
142
- * Pushes new state to browser history
143
- *
144
- * @param state - History state object
145
- * @param path - URL path
146
- */
147
- pushState: (state: State, path: string) => void;
148
-
149
- /**
150
- * Replaces current history state
151
- *
152
- * @param state - History state object
153
- * @param path - URL path
154
- */
155
- replaceState: (state: State, path: string) => void;
156
-
157
- addPopstateListener: (fn: (evt: PopStateEvent) => void) => () => void;
158
-
159
- /**
160
- * Gets current location path respecting plugin options
161
- *
162
- * @param opts - Plugin options
163
- * @returns Current path string
164
- */
165
- getLocation: (opts: BrowserPluginOptions) => string;
166
-
167
- /**
168
- * Gets current URL hash
169
- *
170
- * @returns Hash string (including #)
171
- */
172
- getHash: () => string;
173
- }
174
-
175
- /**
176
- * Subset of BrowserPluginOptions needed for URL parsing operations.
177
- * Intentionally a flat interface (not a discriminated union) because this is an
178
- * internal type for pure functions — the calling code in plugin.ts already works
179
- * with validated BrowserPluginOptions and passes correct values.
180
- */
181
- export interface URLParseOptions {
182
- readonly useHash: boolean;
183
- readonly base: string;
184
- readonly hashPrefix: string;
185
- }
186
-
187
- export interface RegExpCache {
188
- get: (pattern: string) => RegExp;
189
- }
190
-
191
- /**
192
- * Shared mutable state across BrowserPlugin instances created by the same factory.
193
- * Enables cleanup of a previous instance's popstate listener when the factory is reused.
194
- */
195
- export interface SharedFactoryState {
196
- removePopStateListener: (() => void) | undefined;
197
- }
package/src/url-utils.ts CHANGED
@@ -1,43 +1,12 @@
1
1
  // packages/browser-plugin/src/url-utils.ts
2
2
 
3
- import { LOGGER_CONTEXT } from "./constants";
4
-
5
- import type { URLParseOptions, RegExpCache } from "./types";
6
-
7
- const escapeRegExpCache = new Map<string, string>();
8
-
9
- export const escapeRegExp = (str: string): string => {
10
- const cached = escapeRegExpCache.get(str);
11
-
12
- if (cached !== undefined) {
13
- return cached;
14
- }
3
+ import { safeParseUrl } from "browser-env";
15
4
 
16
- const escaped = str.replaceAll(/[$()*+.?[\\\]^{|}-]/g, String.raw`\$&`);
17
-
18
- escapeRegExpCache.set(str, escaped);
19
-
20
- return escaped;
21
- };
22
-
23
- export function extractPath(
24
- pathname: string,
25
- hash: string,
26
- options: URLParseOptions,
27
- regExpCache: RegExpCache,
28
- ): string {
29
- if (options.useHash) {
30
- const escapedHashPrefix = escapeRegExp(options.hashPrefix);
31
- const path = escapedHashPrefix
32
- ? hash.replace(regExpCache.get(`^#${escapedHashPrefix}`), "")
33
- : hash.slice(1);
34
-
35
- return path || "/";
36
- }
5
+ import { LOGGER_CONTEXT } from "./constants";
37
6
 
38
- if (options.base) {
39
- const escapedBase = escapeRegExp(options.base);
40
- const stripped = pathname.replace(regExpCache.get(`^${escapedBase}`), "");
7
+ export function extractPath(pathname: string, base: string): string {
8
+ if (base && pathname.startsWith(base)) {
9
+ const stripped = pathname.slice(base.length);
41
10
 
42
11
  return stripped.startsWith("/") ? stripped : `/${stripped}`;
43
12
  }
@@ -45,51 +14,14 @@ export function extractPath(
45
14
  return pathname;
46
15
  }
47
16
 
48
- export function urlToPath(
49
- url: string,
50
- options: URLParseOptions,
51
- regExpCache: RegExpCache,
52
- ): string | null {
53
- try {
54
- const parsedUrl = new URL(url, globalThis.location.origin);
55
-
56
- if (!["http:", "https:"].includes(parsedUrl.protocol)) {
57
- console.warn(`[${LOGGER_CONTEXT}] Invalid URL protocol in ${url}`);
58
-
59
- return null;
60
- }
61
-
62
- return (
63
- extractPath(parsedUrl.pathname, parsedUrl.hash, options, regExpCache) +
64
- parsedUrl.search
65
- );
66
- } catch (error) {
67
- console.warn(`[${LOGGER_CONTEXT}] Could not parse url ${url}`, error);
68
-
69
- return null;
70
- }
17
+ export function buildUrl(path: string, base: string): string {
18
+ return base + path;
71
19
  }
72
20
 
73
- export function buildUrl(path: string, base: string, prefix: string): string {
74
- return base + prefix + path;
75
- }
76
-
77
- export function createRegExpCache(): RegExpCache {
78
- const cache = new Map<string, RegExp>();
79
-
80
- return {
81
- get(pattern: string): RegExp {
82
- const cached = cache.get(pattern);
83
-
84
- if (cached !== undefined) {
85
- return cached;
86
- }
87
-
88
- const newRegExp = new RegExp(pattern);
89
-
90
- cache.set(pattern, newRegExp);
21
+ export function urlToPath(url: string, base: string): string | null {
22
+ const parsedUrl = safeParseUrl(url, LOGGER_CONTEXT);
91
23
 
92
- return newRegExp;
93
- },
94
- };
24
+ return parsedUrl
25
+ ? extractPath(parsedUrl.pathname, base) + parsedUrl.search
26
+ : null;
95
27
  }
package/src/validation.ts CHANGED
@@ -1,66 +1,10 @@
1
- import { type DefaultBrowserPluginOptions, LOGGER_CONTEXT } from "./constants";
1
+ import { createOptionsValidator } from "browser-env";
2
2
 
3
- import type { BrowserPluginOptions } from "./types";
4
-
5
- function isDefaultOptionKey(
6
- key: string,
7
- defaults: DefaultBrowserPluginOptions,
8
- ): key is keyof DefaultBrowserPluginOptions {
9
- return key in defaults;
10
- }
11
-
12
- function validateOptionType(
13
- key: keyof DefaultBrowserPluginOptions,
14
- value: unknown,
15
- expectedType: string,
16
- ): boolean {
17
- const actualType = typeof value;
18
-
19
- if (actualType !== expectedType && value !== undefined) {
20
- console.warn(
21
- `[${LOGGER_CONTEXT}] Invalid type for '${key}': expected ${expectedType}, got ${actualType}`,
22
- );
23
-
24
- return false;
25
- }
26
-
27
- return true;
28
- }
29
-
30
- export function validateOptions(
31
- opts: Partial<BrowserPluginOptions> | undefined,
32
- defaultOptions: DefaultBrowserPluginOptions,
33
- ): boolean {
34
- if (!opts) {
35
- return false;
36
- }
3
+ import { LOGGER_CONTEXT, defaultOptions } from "./constants";
37
4
 
38
- let hasInvalidTypes = false;
39
-
40
- for (const key of Object.keys(opts)) {
41
- if (isDefaultOptionKey(key, defaultOptions)) {
42
- const expectedType = typeof defaultOptions[key];
43
- const value = opts[key];
44
- const isValid = validateOptionType(key, value, expectedType);
45
-
46
- if (!isValid) {
47
- hasInvalidTypes = true;
48
- }
49
- }
50
- }
51
-
52
- if (opts.useHash === true && "preserveHash" in opts) {
53
- console.warn(`[${LOGGER_CONTEXT}] preserveHash ignored in hash mode`);
54
- }
55
-
56
- if (opts.useHash === false && "hashPrefix" in opts) {
57
- const optsRecord = opts as unknown as Record<string, unknown>;
58
- const hashPrefix = optsRecord.hashPrefix;
59
-
60
- if (hashPrefix !== undefined && hashPrefix !== "") {
61
- console.warn(`[${LOGGER_CONTEXT}] hashPrefix ignored in history mode`);
62
- }
63
- }
5
+ import type { BrowserPluginOptions } from "./types";
64
6
 
65
- return hasInvalidTypes;
66
- }
7
+ export const validateOptions = createOptionsValidator<BrowserPluginOptions>(
8
+ defaultOptions,
9
+ LOGGER_CONTEXT,
10
+ );