@real-router/browser-plugin 0.1.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/LICENSE +22 -0
- package/README.md +965 -0
- package/dist/cjs/index.d.ts +252 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/metafile-cjs.json +1 -0
- package/dist/esm/index.d.mts +252 -0
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/metafile-esm.json +1 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
# @real-router/browser-plugin
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
|
|
6
|
+
A Real-Router plugin that synchronizes router state with browser history, enabling browser navigation buttons (back/forward) and URL management for single-page applications.
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
SPAs need bidirectional synchronization between router state and browser URL.
|
|
11
|
+
Without this plugin, navigating through your application would change the internal router state but leave the browser URL unchanged.
|
|
12
|
+
Additionally, browser navigation buttons (back/forward) wouldn't affect the router, breaking the expected browser behavior.
|
|
13
|
+
|
|
14
|
+
## Solution
|
|
15
|
+
|
|
16
|
+
The browser plugin automatically synchronizes router state with browser history. When you navigate programmatically, it updates the URL.
|
|
17
|
+
When users interact with browser controls or manually change the URL, it updates the router state accordingly.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @real-router/browser-plugin
|
|
23
|
+
# or
|
|
24
|
+
pnpm add @real-router/browser-plugin
|
|
25
|
+
# or
|
|
26
|
+
yarn add @real-router/browser-plugin
|
|
27
|
+
# or
|
|
28
|
+
bun add @real-router/browser-plugin
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createRouter } from "@real-router/core";
|
|
35
|
+
import { browserPluginFactory } from "@real-router/browser-plugin";
|
|
36
|
+
|
|
37
|
+
const router = createRouter([
|
|
38
|
+
{ name: "home", path: "/" },
|
|
39
|
+
{ name: "products", path: "/products/:id" },
|
|
40
|
+
{ name: "cart", path: "/cart" },
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Basic usage
|
|
44
|
+
router.usePlugin(browserPluginFactory());
|
|
45
|
+
|
|
46
|
+
// With options
|
|
47
|
+
router.usePlugin(
|
|
48
|
+
browserPluginFactory({
|
|
49
|
+
useHash: false,
|
|
50
|
+
base: "/app",
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
router.start();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
### `browserPluginFactory(options?, browser?)`
|
|
60
|
+
|
|
61
|
+
#### Parameters
|
|
62
|
+
|
|
63
|
+
**`options`**: `BrowserPluginOptions` (optional)
|
|
64
|
+
|
|
65
|
+
- Configuration object for plugin behavior
|
|
66
|
+
|
|
67
|
+
**`browser`**: `Browser` (optional)
|
|
68
|
+
|
|
69
|
+
- Browser API abstraction for testing/SSR
|
|
70
|
+
|
|
71
|
+
#### Returns
|
|
72
|
+
|
|
73
|
+
`PluginFactory` — plugin factory for `router.usePlugin()`
|
|
74
|
+
|
|
75
|
+
### Configuration Options
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// Base options shared by both modes
|
|
79
|
+
interface BaseBrowserPluginOptions {
|
|
80
|
+
forceDeactivate?: boolean; // Force navigation even if canDeactivate returns false
|
|
81
|
+
base?: string; // Base path for all routes
|
|
82
|
+
mergeState?: boolean; // Merge with existing history.state
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hash-based routing
|
|
86
|
+
interface HashModeOptions extends BaseBrowserPluginOptions {
|
|
87
|
+
useHash: true;
|
|
88
|
+
hashPrefix?: string; // Prefix for hash routes (e.g., "!" for #!/path)
|
|
89
|
+
preserveHash?: never; // Not available in hash mode
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// HTML5 History routing
|
|
93
|
+
interface HistoryModeOptions extends BaseBrowserPluginOptions {
|
|
94
|
+
useHash?: false;
|
|
95
|
+
preserveHash?: boolean; // Preserve hash fragment on navigation
|
|
96
|
+
hashPrefix?: never; // Not available in history mode
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Type-safe discriminated union prevents conflicting options
|
|
100
|
+
type BrowserPluginOptions = HashModeOptions | HistoryModeOptions;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Type Safety:** The configuration uses a discriminated union to prevent invalid option combinations at compile-time (TypeScript) and runtime (JavaScript).
|
|
104
|
+
|
|
105
|
+
#### `forceDeactivate`
|
|
106
|
+
|
|
107
|
+
Controls whether `canDeactivate` guards can block browser-initiated navigation (back/forward buttons).
|
|
108
|
+
|
|
109
|
+
- `true` **(default)** - browser navigation always succeeds, bypassing canDeactivate guards
|
|
110
|
+
- `false` - canDeactivate guards can block browser navigation
|
|
111
|
+
|
|
112
|
+
**Example:**
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
router.usePlugin(
|
|
116
|
+
browserPluginFactory({
|
|
117
|
+
forceDeactivate: false, // Allow guards to block back button
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Route with canDeactivate guard
|
|
122
|
+
router.canDeactivate("form", () => {
|
|
123
|
+
return window.confirm("Unsaved changes. Leave anyway?");
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
When `forceDeactivate` is `false` and the user presses the browser back button, the confirmation dialog will appear. If the user cancels, the navigation is blocked and the URL is restored to match the current route. With the default `true` value, browser navigation would always proceed regardless of the guard's return value.
|
|
128
|
+
|
|
129
|
+
#### `useHash`
|
|
130
|
+
|
|
131
|
+
Enables hash-based routing for environments without HTML5 history support.
|
|
132
|
+
|
|
133
|
+
- `false` **(default)** - use HTML5 history (`/path`)
|
|
134
|
+
- `true` - use hash routing (`#/path`)
|
|
135
|
+
|
|
136
|
+
**Example:**
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
router.usePlugin(
|
|
140
|
+
browserPluginFactory({
|
|
141
|
+
useHash: true,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
router.navigate("products", { id: "123" });
|
|
146
|
+
// URL: http://example.com/#/products/123
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Browser Compatibility:**
|
|
150
|
+
|
|
151
|
+
- History mode requires HTML5 History API support
|
|
152
|
+
- Hash mode works in all browsers
|
|
153
|
+
- Plugin automatically uses `hashchange` events for old IE
|
|
154
|
+
|
|
155
|
+
#### `hashPrefix`
|
|
156
|
+
|
|
157
|
+
Adds prefix to hash routes. **Only works with `useHash: true`** (hash mode).
|
|
158
|
+
|
|
159
|
+
- `""` **(default)** - no prefix (`#/path`)
|
|
160
|
+
- Custom string - prefixed hash (`#!/path`)
|
|
161
|
+
|
|
162
|
+
**Example:**
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
router.usePlugin(
|
|
166
|
+
browserPluginFactory({
|
|
167
|
+
useHash: true, // Required for hashPrefix
|
|
168
|
+
hashPrefix: "!",
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
router.navigate("products", { id: "123" });
|
|
173
|
+
// URL: http://example.com/#!/products/123
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Common Prefixes:**
|
|
177
|
+
|
|
178
|
+
- `"!"` - Hashbang URLs for SEO (Google's deprecated AJAX crawling)
|
|
179
|
+
- `"/"` - Clear visual separation
|
|
180
|
+
- Custom app identifiers
|
|
181
|
+
|
|
182
|
+
**Note:** Using `hashPrefix` with `useHash: false` will log a warning and the option will be ignored.
|
|
183
|
+
|
|
184
|
+
#### `base`
|
|
185
|
+
|
|
186
|
+
Sets base path when app is hosted in subdirectory.
|
|
187
|
+
|
|
188
|
+
- `""` **(default)** - root hosting
|
|
189
|
+
- Path string - subdirectory hosting
|
|
190
|
+
|
|
191
|
+
**Example:**
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
// App hosted at http://example.com/app/
|
|
195
|
+
router.usePlugin(
|
|
196
|
+
browserPluginFactory({
|
|
197
|
+
base: "/app",
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
router.navigate("products", { id: "123" });
|
|
202
|
+
// URL: http://example.com/app/products/123
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Automatic Normalization:**
|
|
206
|
+
|
|
207
|
+
The plugin automatically normalizes base paths to prevent common configuration errors:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Missing leading slash - automatically added
|
|
211
|
+
router.usePlugin(browserPluginFactory({ base: "app" }));
|
|
212
|
+
// Normalized to: '/app'
|
|
213
|
+
|
|
214
|
+
// Trailing slash - automatically removed
|
|
215
|
+
router.usePlugin(browserPluginFactory({ base: "/app/" }));
|
|
216
|
+
// Normalized to: '/app'
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### `mergeState`
|
|
220
|
+
|
|
221
|
+
Preserves external properties in `history.state`.
|
|
222
|
+
|
|
223
|
+
- `false` **(default)** - replace entire state
|
|
224
|
+
- `true` - merge router state with existing state
|
|
225
|
+
|
|
226
|
+
**Example:**
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// External code sets history state
|
|
230
|
+
history.pushState({ externalData: "value" }, "", "/current");
|
|
231
|
+
|
|
232
|
+
router.usePlugin(
|
|
233
|
+
browserPluginFactory({
|
|
234
|
+
mergeState: true,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
router.navigate("products", { id: "123" });
|
|
239
|
+
// history.state contains both router state and externalData
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Use Cases:**
|
|
243
|
+
|
|
244
|
+
- Integration with analytics libraries
|
|
245
|
+
- Preserving scroll positions
|
|
246
|
+
- Third-party state management
|
|
247
|
+
|
|
248
|
+
#### `preserveHash`
|
|
249
|
+
|
|
250
|
+
Maintains URL hash fragment during navigation. **Only works with `useHash: false`** (history mode).
|
|
251
|
+
|
|
252
|
+
- `true` **(default)** - keep hash fragment
|
|
253
|
+
- `false` - remove hash fragment
|
|
254
|
+
|
|
255
|
+
**Example:**
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
// Current URL: /page#section
|
|
259
|
+
router.usePlugin(
|
|
260
|
+
browserPluginFactory({
|
|
261
|
+
useHash: false, // Required for preserveHash (default)
|
|
262
|
+
preserveHash: true,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
router.navigate("other", {});
|
|
267
|
+
// URL: /other#section (hash preserved)
|
|
268
|
+
|
|
269
|
+
router.usePlugin(
|
|
270
|
+
browserPluginFactory({
|
|
271
|
+
useHash: false,
|
|
272
|
+
preserveHash: false,
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
router.navigate("other", {});
|
|
277
|
+
// URL: /other (hash removed)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Use Cases:**
|
|
281
|
+
|
|
282
|
+
- Anchor navigation within pages
|
|
283
|
+
- Deep linking to sections
|
|
284
|
+
- Progressive enhancement
|
|
285
|
+
|
|
286
|
+
**Note:** Using `preserveHash` with `useHash: true` will log a warning and the option will be ignored.
|
|
287
|
+
|
|
288
|
+
## Added Router Methods
|
|
289
|
+
|
|
290
|
+
The plugin extends the router instance with browser-specific methods:
|
|
291
|
+
|
|
292
|
+
### `router.buildUrl(name, params?)`
|
|
293
|
+
|
|
294
|
+
Builds full URL including base path and hash prefix.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
router.buildUrl("products", { id: "123" });
|
|
298
|
+
// Returns: "/products/123"
|
|
299
|
+
|
|
300
|
+
// With base="/app", useHash=true, hashPrefix="!"
|
|
301
|
+
// Returns: "/app#!/products/123"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Security Note:**
|
|
305
|
+
|
|
306
|
+
The plugin automatically URL-encodes parameters to prevent injection attacks. When using the output in templates:
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
// ✅ SAFE: Modern frameworks auto-escape
|
|
310
|
+
// React
|
|
311
|
+
<Link to={router.buildUrl('users', { id: userInput })} />
|
|
312
|
+
|
|
313
|
+
// Vue
|
|
314
|
+
<router-link :to="router.buildUrl('users', { id: userInput })" />
|
|
315
|
+
|
|
316
|
+
// Angular
|
|
317
|
+
<a [routerLink]="router.buildUrl('users', { id: userInput })">
|
|
318
|
+
|
|
319
|
+
// ❌ UNSAFE: Don't use innerHTML
|
|
320
|
+
element.innerHTML = `<a href="${router.buildUrl('users', params)}">Link</a>`; // DON'T
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Special characters are automatically encoded:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
router.buildUrl("search", { q: '<script>alert("xss")</script>' });
|
|
327
|
+
// Returns: "/search?q=%3Cscript%3Ealert(%22xss%22)%3C%2Fscript%3E"
|
|
328
|
+
// Safe for browser APIs
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### `router.matchUrl(url)`
|
|
332
|
+
|
|
333
|
+
Parses URL and returns matching router state.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
const state = router.matchUrl("http://example.com/products/123?sort=name");
|
|
337
|
+
// Returns: {
|
|
338
|
+
// name: 'products',
|
|
339
|
+
// params: { id: '123', sort: 'name' },
|
|
340
|
+
// path: '/products/123?sort=name',
|
|
341
|
+
// meta: { ... }
|
|
342
|
+
// }
|
|
343
|
+
|
|
344
|
+
router.matchUrl("http://example.com/invalid");
|
|
345
|
+
// Returns: undefined
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Features:**
|
|
349
|
+
|
|
350
|
+
- Handles full URLs or paths
|
|
351
|
+
- Respects base path and hash settings
|
|
352
|
+
- Returns undefined for non-matching routes
|
|
353
|
+
|
|
354
|
+
### `router.replaceHistoryState(name, params?, title?)`
|
|
355
|
+
|
|
356
|
+
Updates browser URL without triggering navigation.
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Change URL without side effects
|
|
360
|
+
router.replaceHistoryState("products", { id: "456", filter: "new" });
|
|
361
|
+
// URL changes but no middleware/guards execute
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**Use Cases:**
|
|
365
|
+
|
|
366
|
+
- Update URL after async data load
|
|
367
|
+
- Reflect UI state changes
|
|
368
|
+
- Correct invalid URLs
|
|
369
|
+
|
|
370
|
+
### `router.lastKnownState`
|
|
371
|
+
|
|
372
|
+
Read-only reference to last successful navigation state.
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
const state = router.lastKnownState;
|
|
376
|
+
// Returns frozen copy of state or undefined
|
|
377
|
+
|
|
378
|
+
if (state) {
|
|
379
|
+
console.log("Last route:", state.name);
|
|
380
|
+
console.log("Parameters:", state.params);
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Characteristics:**
|
|
385
|
+
|
|
386
|
+
- Immutable (frozen object)
|
|
387
|
+
- Updated on successful navigation only
|
|
388
|
+
- Undefined before first navigation
|
|
389
|
+
- Persists through errors/cancellations
|
|
390
|
+
|
|
391
|
+
## Browser Events
|
|
392
|
+
|
|
393
|
+
### Popstate Handling
|
|
394
|
+
|
|
395
|
+
The plugin synchronizes router state with browser navigation (back/forward buttons) through the `popstate` event.
|
|
396
|
+
|
|
397
|
+
**Behavior Guarantees:**
|
|
398
|
+
|
|
399
|
+
When users interact with browser controls, the plugin ensures:
|
|
400
|
+
|
|
401
|
+
- Router state stays synchronized with browser history
|
|
402
|
+
- URL bar always reflects the actual router state
|
|
403
|
+
- Rapid navigation (e.g., clicking back multiple times) is handled correctly
|
|
404
|
+
- No state corruption from concurrent transitions
|
|
405
|
+
|
|
406
|
+
**Example:**
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
router.navigate("page1");
|
|
410
|
+
router.navigate("page2");
|
|
411
|
+
router.navigate("page3");
|
|
412
|
+
|
|
413
|
+
// User clicks back twice rapidly
|
|
414
|
+
// Plugin ensures router ends at page1
|
|
415
|
+
// URL and router state remain synchronized
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Error Recovery:**
|
|
419
|
+
|
|
420
|
+
If an error occurs during navigation, the plugin automatically synchronizes the URL with the current router state, ensuring the URL bar never displays incorrect information.
|
|
421
|
+
|
|
422
|
+
### History State Structure
|
|
423
|
+
|
|
424
|
+
The plugin stores router state in browser history:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
history.state = {
|
|
428
|
+
name: "products",
|
|
429
|
+
params: { id: "123" },
|
|
430
|
+
path: "/products/123",
|
|
431
|
+
meta: {
|
|
432
|
+
id: 1,
|
|
433
|
+
params: {},
|
|
434
|
+
options: {},
|
|
435
|
+
redirected: false,
|
|
436
|
+
source: "popstate",
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## SSR Support
|
|
442
|
+
|
|
443
|
+
The plugin works in server environments with automatic fallback:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
// Server-side rendering
|
|
447
|
+
import { browserPluginFactory } from "@real-router/browser-plugin";
|
|
448
|
+
|
|
449
|
+
const router = createRouter(routes);
|
|
450
|
+
router.usePlugin(browserPluginFactory());
|
|
451
|
+
|
|
452
|
+
// Methods return safe defaults
|
|
453
|
+
router.buildUrl("home"); // Works normally
|
|
454
|
+
router.matchUrl("/path"); // Returns undefined
|
|
455
|
+
router.start(); // No errors thrown
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Fallback Behavior:**
|
|
459
|
+
|
|
460
|
+
- Browser methods become no-ops
|
|
461
|
+
- Single warning logged per session
|
|
462
|
+
- No errors thrown
|
|
463
|
+
- Router continues functioning
|
|
464
|
+
|
|
465
|
+
**Testing Support:**
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
// Custom browser implementation for tests
|
|
469
|
+
const mockBrowser: Browser = {
|
|
470
|
+
getBase: () => "/",
|
|
471
|
+
pushState: jest.fn(),
|
|
472
|
+
replaceState: jest.fn(),
|
|
473
|
+
addPopstateListener: () => () => {},
|
|
474
|
+
getLocation: (opts) => "/test",
|
|
475
|
+
getState: () => undefined,
|
|
476
|
+
getHash: () => "",
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
router.usePlugin(browserPluginFactory({}, mockBrowser));
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## Usage Examples
|
|
483
|
+
|
|
484
|
+
### Basic SPA Setup
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const router = createRouter(routes);
|
|
488
|
+
|
|
489
|
+
router.usePlugin(
|
|
490
|
+
browserPluginFactory({
|
|
491
|
+
useHash: false,
|
|
492
|
+
preserveHash: true,
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
router.start((err, state) => {
|
|
497
|
+
if (!err) {
|
|
498
|
+
renderApp(state);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Legacy Browser Support
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
// Detect History API support
|
|
507
|
+
const supportsHistory = !!(window.history && window.history.pushState);
|
|
508
|
+
|
|
509
|
+
// Type-safe configuration based on browser support
|
|
510
|
+
router.usePlugin(
|
|
511
|
+
browserPluginFactory(
|
|
512
|
+
supportsHistory
|
|
513
|
+
? { useHash: false, preserveHash: true }
|
|
514
|
+
: { useHash: true, hashPrefix: "!" },
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Analytics Integration
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
router.usePlugin(
|
|
523
|
+
browserPluginFactory({
|
|
524
|
+
mergeState: true,
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// Analytics library can access its data
|
|
529
|
+
router.subscribe(({ route }) => {
|
|
530
|
+
const analyticsData = history.state?.analytics;
|
|
531
|
+
if (analyticsData) {
|
|
532
|
+
trackPageView(analyticsData);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Form Protection
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
router.usePlugin(
|
|
541
|
+
browserPluginFactory({
|
|
542
|
+
forceDeactivate: false,
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
router.canDeactivate("checkout", () => {
|
|
547
|
+
if (hasUnsavedChanges()) {
|
|
548
|
+
return window.confirm("Leave without saving?");
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Hash Fragment Navigation
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
// Preserve anchors during navigation
|
|
558
|
+
router.usePlugin(
|
|
559
|
+
browserPluginFactory({
|
|
560
|
+
preserveHash: true,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// Navigate to section
|
|
565
|
+
window.location.hash = "#comments";
|
|
566
|
+
|
|
567
|
+
// Route change preserves hash
|
|
568
|
+
router.navigate("other-page");
|
|
569
|
+
// URL: /other-page#comments
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
## Advanced Features
|
|
573
|
+
|
|
574
|
+
### URL Encoding
|
|
575
|
+
|
|
576
|
+
The plugin handles special characters safely:
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
router.navigate("search", { q: "hello world" });
|
|
580
|
+
// URL: /search?q=hello%20world
|
|
581
|
+
|
|
582
|
+
router.navigate("category", { name: "日本語" });
|
|
583
|
+
// URL: /category?name=%E6%97%A5%E6%9C%AC%E8%AA%9E
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**Features:**
|
|
587
|
+
|
|
588
|
+
- Automatic encoding/decoding
|
|
589
|
+
- Unicode support
|
|
590
|
+
- IPv6 URL compatibility
|
|
591
|
+
- Safe error handling
|
|
592
|
+
|
|
593
|
+
## Security
|
|
594
|
+
|
|
595
|
+
The plugin implements multiple layers of security to protect against common web vulnerabilities:
|
|
596
|
+
|
|
597
|
+
### URL Parameter Encoding
|
|
598
|
+
|
|
599
|
+
All route parameters are automatically URL-encoded by the underlying `route-tree` library, preventing XSS attacks through URL injection:
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
router.navigate("search", { q: '<script>alert("xss")</script>' });
|
|
603
|
+
// URL: /search?q=%3Cscript%3Ealert(%22xss%22)%3C%2Fscript%3E
|
|
604
|
+
// Script tags are encoded and harmless
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### Protocol Whitelist
|
|
608
|
+
|
|
609
|
+
The `matchUrl` method only accepts `http:` and `https:` protocols, blocking dangerous protocols:
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
// ✅ Allowed
|
|
613
|
+
router.matchUrl("https://example.com/path"); // Works
|
|
614
|
+
router.matchUrl("http://example.com/path"); // Works
|
|
615
|
+
|
|
616
|
+
// ❌ Blocked
|
|
617
|
+
router.matchUrl("javascript:alert(1)"); // Returns undefined
|
|
618
|
+
router.matchUrl("data:text/html,..."); // Returns undefined
|
|
619
|
+
router.matchUrl("vbscript:msgbox(1)"); // Returns undefined
|
|
620
|
+
router.matchUrl("file:///etc/passwd"); // Returns undefined
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### State Validation
|
|
624
|
+
|
|
625
|
+
Popstate events are validated using type guards before processing. Invalid or malicious state structures are rejected:
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
// Browser history manipulated by malicious code
|
|
629
|
+
history.pushState({ malicious: "data" }, "", "/");
|
|
630
|
+
|
|
631
|
+
// Plugin validates structure
|
|
632
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
633
|
+
// Invalid state is rejected, router remains in safe state
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Input Sanitization Limits
|
|
637
|
+
|
|
638
|
+
**What the plugin protects:**
|
|
639
|
+
|
|
640
|
+
- URL encoding for browser APIs (automatic)
|
|
641
|
+
- Protocol validation (whitelist)
|
|
642
|
+
- State structure validation (type guards)
|
|
643
|
+
- Prototype pollution prevention
|
|
644
|
+
|
|
645
|
+
**What the plugin does NOT protect:**
|
|
646
|
+
|
|
647
|
+
- XSS in template rendering (framework responsibility)
|
|
648
|
+
- SQL injection (backend responsibility)
|
|
649
|
+
- CSRF tokens (application responsibility)
|
|
650
|
+
|
|
651
|
+
**Best Practices:**
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
// ✅ Safe: Let frameworks handle HTML escaping
|
|
655
|
+
<Link to={router.buildUrl('users', { id: userInput })} />
|
|
656
|
+
|
|
657
|
+
// ✅ Safe: DOM API automatically escapes
|
|
658
|
+
element.setAttribute('href', router.buildUrl('users', params));
|
|
659
|
+
|
|
660
|
+
// ❌ Unsafe: Manual HTML construction
|
|
661
|
+
element.innerHTML = `<a href="${router.buildUrl('users', params)}">...</a>`;
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## Error Handling
|
|
665
|
+
|
|
666
|
+
### Navigation Errors
|
|
667
|
+
|
|
668
|
+
When navigation is blocked by a `canDeactivate` guard during browser-initiated navigation (and `forceDeactivate` is `false`), the plugin detects the `CANNOT_DEACTIVATE` error and automatically restores the browser URL to match the current router state. This ensures the URL bar stays synchronized even when navigation is prevented.
|
|
669
|
+
|
|
670
|
+
### Recovery Mechanism
|
|
671
|
+
|
|
672
|
+
The plugin includes a robust error recovery system for critical failures. If an unexpected error occurs during popstate event handling, the plugin first logs the error with full context for debugging. It then attempts to recover by synchronizing the browser's history state with the current router state. If this recovery also fails, a secondary error is logged, but the application continues running to prevent complete failure.
|
|
673
|
+
|
|
674
|
+
### Missing State
|
|
675
|
+
|
|
676
|
+
When the browser's history state is null, corrupted, or doesn't match the expected structure, the plugin handles it gracefully. It attempts to match the current URL against the router's routes to reconstruct the state. If a default route is configured and no match is found, it navigates to the default route. Invalid states are logged as warnings to aid debugging without interrupting the user experience.
|
|
677
|
+
|
|
678
|
+
## Browser Compatibility
|
|
679
|
+
|
|
680
|
+
### Modern Browsers (Full Support)
|
|
681
|
+
|
|
682
|
+
Chrome 5+, Firefox 4+, Safari 5+, Edge, Opera 11.5+
|
|
683
|
+
|
|
684
|
+
- HTML5 History API
|
|
685
|
+
- Popstate events
|
|
686
|
+
- Unicode URLs
|
|
687
|
+
- Performance optimizations
|
|
688
|
+
|
|
689
|
+
### Legacy Browsers (Hash Mode)
|
|
690
|
+
|
|
691
|
+
IE 9-11, Older mobile browsers
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
// Automatic fallback for IE
|
|
695
|
+
router.usePlugin(
|
|
696
|
+
browserPluginFactory({
|
|
697
|
+
useHash: true, // Works everywhere
|
|
698
|
+
}),
|
|
699
|
+
);
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
**IE-Specific Handling:**
|
|
703
|
+
|
|
704
|
+
- Detects Trident engine
|
|
705
|
+
- Uses hashchange events
|
|
706
|
+
- No popstate on hash changes
|
|
707
|
+
|
|
708
|
+
### Server-Side Rendering
|
|
709
|
+
|
|
710
|
+
Node.js 14+
|
|
711
|
+
|
|
712
|
+
- Automatic fallback
|
|
713
|
+
- No errors thrown
|
|
714
|
+
- Warning logged once
|
|
715
|
+
- Methods return safe defaults
|
|
716
|
+
|
|
717
|
+
## TypeScript
|
|
718
|
+
|
|
719
|
+
The plugin is fully typed with TypeScript:
|
|
720
|
+
|
|
721
|
+
```typescript
|
|
722
|
+
import {
|
|
723
|
+
browserPluginFactory,
|
|
724
|
+
type BrowserPluginOptions,
|
|
725
|
+
type Browser,
|
|
726
|
+
type HistoryState,
|
|
727
|
+
type StartRouterArguments,
|
|
728
|
+
isState,
|
|
729
|
+
isHistoryState,
|
|
730
|
+
} from "@real-router/browser-plugin";
|
|
731
|
+
|
|
732
|
+
// Type-safe configuration
|
|
733
|
+
const options: BrowserPluginOptions = {
|
|
734
|
+
useHash: false,
|
|
735
|
+
base: "/app",
|
|
736
|
+
mergeState: true,
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// Router interface is augmented automatically
|
|
740
|
+
router.usePlugin(browserPluginFactory(options));
|
|
741
|
+
router.buildUrl("home"); // TypeScript knows this method exists
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
### Type Safety & Runtime Protection
|
|
745
|
+
|
|
746
|
+
The plugin uses multiple layers of protection to prevent configuration conflicts:
|
|
747
|
+
|
|
748
|
+
#### 1. Compile-Time Safety (TypeScript)
|
|
749
|
+
|
|
750
|
+
Discriminated union types make invalid configurations impossible:
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
// ✅ Valid configurations
|
|
754
|
+
const config1: BrowserPluginOptions = {
|
|
755
|
+
useHash: true,
|
|
756
|
+
hashPrefix: "!",
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const config2: BrowserPluginOptions = {
|
|
760
|
+
useHash: false,
|
|
761
|
+
preserveHash: true,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// ❌ TypeScript errors - conflicting options
|
|
765
|
+
const invalid1: BrowserPluginOptions = {
|
|
766
|
+
useHash: true,
|
|
767
|
+
preserveHash: true, // Error: Type 'true' is not assignable to type 'never'
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const invalid2: BrowserPluginOptions = {
|
|
771
|
+
useHash: false,
|
|
772
|
+
hashPrefix: "!", // Error: Type 'string' is not assignable to type 'never'
|
|
773
|
+
};
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
#### 2. Runtime Validation (JavaScript)
|
|
777
|
+
|
|
778
|
+
For JavaScript users or dynamic configurations, the plugin validates options and warns about conflicts:
|
|
779
|
+
|
|
780
|
+
```javascript
|
|
781
|
+
// JavaScript - no compile-time checks
|
|
782
|
+
browserPluginFactory({
|
|
783
|
+
useHash: true,
|
|
784
|
+
preserveHash: true, // Conflict!
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Console warning:
|
|
788
|
+
// [browser-plugin] preserveHash ignored in hash mode
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
```javascript
|
|
792
|
+
browserPluginFactory({
|
|
793
|
+
useHash: false,
|
|
794
|
+
hashPrefix: "!", // Conflict!
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Console warning:
|
|
798
|
+
// [browser-plugin] hashPrefix ignored in history mode
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
#### 3. Physical Property Removal
|
|
802
|
+
|
|
803
|
+
After validation, conflicting properties are physically deleted from the options object, ensuring clean configuration:
|
|
804
|
+
|
|
805
|
+
```javascript
|
|
806
|
+
// User passes conflicting options
|
|
807
|
+
const options = { useHash: true, preserveHash: true, hashPrefix: "!" };
|
|
808
|
+
browserPluginFactory(options);
|
|
809
|
+
|
|
810
|
+
// Inside plugin:
|
|
811
|
+
// - preserveHash is DELETED (delete options.preserveHash)
|
|
812
|
+
// - hashPrefix is KEPT (needed for hash mode)
|
|
813
|
+
// → Impossible to accidentally use preserveHash in hash mode
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
**Result:**
|
|
817
|
+
|
|
818
|
+
- ✅ TypeScript users: compile-time errors prevent invalid configs
|
|
819
|
+
- ✅ JavaScript users: runtime warnings + automatic cleanup
|
|
820
|
+
- ✅ Internal code: guaranteed clean options without conflicts
|
|
821
|
+
|
|
822
|
+
## Common Issues
|
|
823
|
+
|
|
824
|
+
### URL Not Updating
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
// Ensure plugin is registered before start
|
|
828
|
+
router.usePlugin(browserPluginFactory()); // First
|
|
829
|
+
router.start(); // Second
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
### Back Button Not Working
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
// Ensure plugin is registered
|
|
836
|
+
router.usePlugin(browserPluginFactory());
|
|
837
|
+
|
|
838
|
+
// If you need guards to block navigation:
|
|
839
|
+
router.usePlugin(
|
|
840
|
+
browserPluginFactory({
|
|
841
|
+
forceDeactivate: false, // Allow guards to prevent back button
|
|
842
|
+
}),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// Check browser console for errors
|
|
846
|
+
// Verify history.state is valid
|
|
847
|
+
console.log(history.state);
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
### Hash Fragment Lost
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
// Enable preserveHash
|
|
854
|
+
router.usePlugin(
|
|
855
|
+
browserPluginFactory({
|
|
856
|
+
preserveHash: true,
|
|
857
|
+
useHash: false, // Only works in history mode
|
|
858
|
+
}),
|
|
859
|
+
);
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
### Conflicting Options Warning
|
|
863
|
+
|
|
864
|
+
If you see warnings about ignored options, check your configuration:
|
|
865
|
+
|
|
866
|
+
```typescript
|
|
867
|
+
// ❌ Wrong: preserveHash doesn't work with hash mode
|
|
868
|
+
router.usePlugin(
|
|
869
|
+
browserPluginFactory({
|
|
870
|
+
useHash: true,
|
|
871
|
+
preserveHash: true, // Warning: preserveHash ignored in hash mode
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
// ✅ Correct: preserveHash only works in history mode
|
|
876
|
+
router.usePlugin(
|
|
877
|
+
browserPluginFactory({
|
|
878
|
+
useHash: false,
|
|
879
|
+
preserveHash: true,
|
|
880
|
+
}),
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
// ❌ Wrong: hashPrefix doesn't work with history mode
|
|
884
|
+
router.usePlugin(
|
|
885
|
+
browserPluginFactory({
|
|
886
|
+
useHash: false,
|
|
887
|
+
hashPrefix: "!", // Warning: hashPrefix ignored in history mode
|
|
888
|
+
}),
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// ✅ Correct: hashPrefix only works in hash mode
|
|
892
|
+
router.usePlugin(
|
|
893
|
+
browserPluginFactory({
|
|
894
|
+
useHash: true,
|
|
895
|
+
hashPrefix: "!",
|
|
896
|
+
}),
|
|
897
|
+
);
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
## Related Packages
|
|
901
|
+
|
|
902
|
+
- [@real-router/core](https://www.npmjs.com/package/@real-router/core) — Core router
|
|
903
|
+
- [@real-router/react](https://www.npmjs.com/package/@real-router/react) — React integration
|
|
904
|
+
|
|
905
|
+
## Migration from router5-plugin-browser
|
|
906
|
+
|
|
907
|
+
### Import Changes
|
|
908
|
+
|
|
909
|
+
```diff
|
|
910
|
+
- import browserPlugin from 'router5-plugin-browser';
|
|
911
|
+
+ import { browserPluginFactory } from '@real-router/browser-plugin';
|
|
912
|
+
|
|
913
|
+
- router.usePlugin(browserPlugin({ useHash: true }));
|
|
914
|
+
+ router.usePlugin(browserPluginFactory({ useHash: true }));
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
### Type-Safe Options
|
|
918
|
+
|
|
919
|
+
Real-Router uses discriminated union types to prevent invalid option combinations:
|
|
920
|
+
|
|
921
|
+
```typescript
|
|
922
|
+
// ✅ Valid: hash mode with prefix
|
|
923
|
+
browserPluginFactory({ useHash: true, hashPrefix: "!" });
|
|
924
|
+
|
|
925
|
+
// ✅ Valid: history mode with hash preservation
|
|
926
|
+
browserPluginFactory({ useHash: false, preserveHash: true });
|
|
927
|
+
|
|
928
|
+
// ❌ TypeScript error: hashPrefix only works with useHash: true
|
|
929
|
+
browserPluginFactory({ useHash: false, hashPrefix: "!" });
|
|
930
|
+
|
|
931
|
+
// ❌ TypeScript error: preserveHash only works with useHash: false
|
|
932
|
+
browserPluginFactory({ useHash: true, preserveHash: true });
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
### API Changes
|
|
936
|
+
|
|
937
|
+
| Method | router5 | Real-Router |
|
|
938
|
+
|--------|---------|---------|
|
|
939
|
+
| `matchUrl()` | returns `State \| null` | returns `State \| undefined` |
|
|
940
|
+
|
|
941
|
+
```diff
|
|
942
|
+
const state = router.matchUrl('/users/123');
|
|
943
|
+
- if (state === null) {
|
|
944
|
+
+ if (state === undefined) {
|
|
945
|
+
// not found
|
|
946
|
+
}
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
### Full Migration Example
|
|
950
|
+
|
|
951
|
+
```diff
|
|
952
|
+
- import { createRouter } from 'router5';
|
|
953
|
+
- import browserPlugin from 'router5-plugin-browser';
|
|
954
|
+
+ import { createRouter } from '@real-router/core';
|
|
955
|
+
+ import { browserPluginFactory } from '@real-router/browser-plugin';
|
|
956
|
+
|
|
957
|
+
const router = createRouter(routes);
|
|
958
|
+
- router.usePlugin(browserPlugin({ useHash: false, preserveHash: true }));
|
|
959
|
+
+ router.usePlugin(browserPluginFactory({ useHash: false, preserveHash: true }));
|
|
960
|
+
router.start();
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
## License
|
|
964
|
+
|
|
965
|
+
MIT © [Oleg Ivanov](https://github.com/greydragon888)
|