router-kit 2.0.1 → 2.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/README.md +4 -4
- package/dist/context/RouterProvider.js +228 -39
- package/dist/core/createRouter.js +13 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/dist/ssr/StaticRouter.js +36 -12
- package/dist/ssr/serverUtils.js +31 -10
- package/dist/types/index.d.ts +36 -3
- package/dist/utils/middleware.d.ts +81 -0
- package/dist/utils/middleware.js +141 -0
- package/package.json +3 -7
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Router-Kit
|
|
2
2
|
|
|
3
|
-
A professional React routing library with guards, loaders, and navigation blocking.
|
|
3
|
+
A professional React routing library with guards, loaders, loading components, middlewares, and navigation blocking.
|
|
4
4
|
|
|
5
|
-
**Version:** 2.
|
|
5
|
+
**Version:** 2.1.0 | **License:** MIT
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -80,7 +80,7 @@ function App() {
|
|
|
80
80
|
```tsx
|
|
81
81
|
const authGuard = async () => {
|
|
82
82
|
const isAuth = await checkAuth();
|
|
83
|
-
return isAuth ||
|
|
83
|
+
return isAuth || "/login";
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
const routes = createRouter([
|
|
@@ -134,6 +134,7 @@ const ctx = useOutletContext(); // Outlet context
|
|
|
134
134
|
## 🎭 Outlet (Nested Layouts)
|
|
135
135
|
|
|
136
136
|
```tsx
|
|
137
|
+
import { useState } from "react";
|
|
137
138
|
import { Outlet, useOutletContext } from "router-kit";
|
|
138
139
|
|
|
139
140
|
// Parent layout with Outlet
|
|
@@ -193,7 +194,6 @@ const routes = createRouter([
|
|
|
193
194
|
| ---------------------------------------- | ----------------- |
|
|
194
195
|
| [Documentation](./docs/DOCUMENTATION.md) | Complete guide |
|
|
195
196
|
| [API Reference](./docs/API_REFERENCE.md) | Full API docs |
|
|
196
|
-
| [Examples](./docs/EXAMPLES.md) | Code examples |
|
|
197
197
|
| [Architecture](./docs/ARCHITECTURE.md) | Technical details |
|
|
198
198
|
| [Changelog](./docs/CHANGELOG.md) | Version history |
|
|
199
199
|
|
|
@@ -3,6 +3,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransit
|
|
|
3
3
|
import join from "url-join";
|
|
4
4
|
import Page404 from "../pages/404";
|
|
5
5
|
import { createRouterError, RouterErrorCode, RouterErrors, } from "../utils/error/errors";
|
|
6
|
+
import { executeMiddlewareChain } from "../utils/middleware";
|
|
6
7
|
import { OutletProvider } from "./OutletContext";
|
|
7
8
|
import RouterContext from "./RouterContext";
|
|
8
9
|
/**
|
|
@@ -22,6 +23,8 @@ const validateUrl = (url) => {
|
|
|
22
23
|
* Preserves '/' as a special case for root path
|
|
23
24
|
*/
|
|
24
25
|
const normalizePath = (path) => {
|
|
26
|
+
if (path === undefined)
|
|
27
|
+
return "";
|
|
25
28
|
if (Array.isArray(path)) {
|
|
26
29
|
return path
|
|
27
30
|
.map((p) => {
|
|
@@ -41,6 +44,8 @@ const normalizePath = (path) => {
|
|
|
41
44
|
* Get the first path from a path (string or array)
|
|
42
45
|
*/
|
|
43
46
|
const getFirstPath = (path) => {
|
|
47
|
+
if (path === undefined)
|
|
48
|
+
return "";
|
|
44
49
|
if (Array.isArray(path)) {
|
|
45
50
|
return path[0] || "";
|
|
46
51
|
}
|
|
@@ -80,7 +85,7 @@ const getCurrentLocation = () => {
|
|
|
80
85
|
/**
|
|
81
86
|
* Extracts params from a path using a pattern
|
|
82
87
|
*/
|
|
83
|
-
const extractParams = (pattern, pathname) => {
|
|
88
|
+
const extractParams = (pattern, pathname, partialMatch = false) => {
|
|
84
89
|
// Special case: root path matching
|
|
85
90
|
const normalizedPattern = pattern === "/" ? "" : pattern;
|
|
86
91
|
const normalizedPathname = pathname === "/" ? "" : pathname;
|
|
@@ -90,7 +95,14 @@ const extractParams = (pattern, pathname) => {
|
|
|
90
95
|
if (patternParts.length === 0 && pathParts.length === 0) {
|
|
91
96
|
return {};
|
|
92
97
|
}
|
|
93
|
-
|
|
98
|
+
// If partial match is allowed, we only need to match up to the pattern length
|
|
99
|
+
// The route matches if it consumes a prefix of the URL
|
|
100
|
+
if (partialMatch) {
|
|
101
|
+
if (patternParts.length > pathParts.length)
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
else if (patternParts.length !== pathParts.length) {
|
|
105
|
+
// Exact match logic (unless catch-all)
|
|
94
106
|
// Check for catch-all pattern
|
|
95
107
|
const hasCatchAll = patternParts.some((p) => p.startsWith("*"));
|
|
96
108
|
if (!hasCatchAll)
|
|
@@ -103,6 +115,9 @@ const extractParams = (pattern, pathname) => {
|
|
|
103
115
|
// Catch-all segment (*splat or **)
|
|
104
116
|
if (patternPart.startsWith("*")) {
|
|
105
117
|
const paramName = patternPart.slice(1) || "splat";
|
|
118
|
+
// For partial match, catch-all consumes everything remaining?
|
|
119
|
+
// Or just the rest of what was requested?
|
|
120
|
+
// Usually catch-all consumes everything, so it behaves like exact match + capture
|
|
106
121
|
params[paramName] = pathParts.slice(i).join("/");
|
|
107
122
|
return params;
|
|
108
123
|
}
|
|
@@ -129,12 +144,12 @@ const extractParams = (pattern, pathname) => {
|
|
|
129
144
|
/**
|
|
130
145
|
* Match a single path pattern against current pathname (pure function)
|
|
131
146
|
*/
|
|
132
|
-
const matchPathPattern = (routePattern, currentPath) => {
|
|
147
|
+
const matchPathPattern = (routePattern, currentPath, partialMatch = false) => {
|
|
133
148
|
const patterns = routePattern.split("|");
|
|
134
149
|
for (const pat of patterns) {
|
|
135
150
|
// Handle root path pattern
|
|
136
151
|
const normalizedPat = pat === "" ? "/" : pat;
|
|
137
|
-
const extractedParams = extractParams(normalizedPat, currentPath);
|
|
152
|
+
const extractedParams = extractParams(normalizedPat, currentPath, partialMatch);
|
|
138
153
|
if (extractedParams !== null) {
|
|
139
154
|
return { match: true, params: extractedParams, pattern: normalizedPat };
|
|
140
155
|
}
|
|
@@ -142,19 +157,21 @@ const matchPathPattern = (routePattern, currentPath) => {
|
|
|
142
157
|
return null;
|
|
143
158
|
};
|
|
144
159
|
/**
|
|
145
|
-
*
|
|
160
|
+
* Async function to match routes with middleware and guard support
|
|
161
|
+
* Handles both sync and async guards/middleware
|
|
146
162
|
*/
|
|
147
|
-
const
|
|
163
|
+
const matchRoutesAsync = async (routesList, currentPath, parentPath = "/", searchString = "", collectedMatches = [], request, signal) => {
|
|
148
164
|
const staticRoutes = [];
|
|
149
165
|
const dynamicRoutes = [];
|
|
150
166
|
const catchAllRoutes = [];
|
|
151
167
|
let page404Component = null;
|
|
152
168
|
for (const route of routesList) {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
const rawPath = route.path || "";
|
|
170
|
+
const pathArray = Array.isArray(rawPath)
|
|
171
|
+
? rawPath
|
|
172
|
+
: rawPath.includes("|")
|
|
173
|
+
? rawPath.split("|")
|
|
174
|
+
: [rawPath];
|
|
158
175
|
const is404 = pathArray.some((p) => p === "404" || p === "/404");
|
|
159
176
|
if (is404) {
|
|
160
177
|
page404Component = route.component;
|
|
@@ -193,7 +210,9 @@ const matchRoutes = (routesList, currentPath, parentPath = "/", searchString = "
|
|
|
193
210
|
})
|
|
194
211
|
.join("|")
|
|
195
212
|
: fullPath;
|
|
196
|
-
|
|
213
|
+
// Enable partial matching if route has children
|
|
214
|
+
const isParent = route.children && route.children.length > 0;
|
|
215
|
+
const matchResult = matchPathPattern(fullPattern, currentPath, isParent);
|
|
197
216
|
if (matchResult) {
|
|
198
217
|
// Handle redirects
|
|
199
218
|
if (route.redirectTo) {
|
|
@@ -207,26 +226,91 @@ const matchRoutes = (routesList, currentPath, parentPath = "/", searchString = "
|
|
|
207
226
|
page404Component,
|
|
208
227
|
};
|
|
209
228
|
}
|
|
210
|
-
//
|
|
211
|
-
if (route.
|
|
212
|
-
const
|
|
229
|
+
// Execute middleware chain (Chain of Responsibility pattern)
|
|
230
|
+
if (route.middleware && route.middleware.length > 0) {
|
|
231
|
+
const middlewareContext = {
|
|
213
232
|
pathname: currentPath,
|
|
214
233
|
params: matchResult.params,
|
|
215
234
|
search: searchString,
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
request,
|
|
236
|
+
signal,
|
|
237
|
+
};
|
|
238
|
+
try {
|
|
239
|
+
const middlewareResult = await executeMiddlewareChain(route.middleware, middlewareContext);
|
|
240
|
+
// Handle middleware redirect
|
|
241
|
+
if (middlewareResult.type === "redirect") {
|
|
242
|
+
return {
|
|
243
|
+
component: null,
|
|
244
|
+
pattern: matchResult.pattern,
|
|
245
|
+
params: matchResult.params,
|
|
246
|
+
matches: collectedMatches,
|
|
247
|
+
meta: null,
|
|
248
|
+
redirect: middlewareResult.to || "/",
|
|
249
|
+
page404Component,
|
|
250
|
+
errorElement: route.errorElement,
|
|
251
|
+
middlewareResult,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// Handle middleware block
|
|
255
|
+
if (middlewareResult.type === "block") {
|
|
256
|
+
continue; // Skip this route
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
// Middleware threw an error - return error result
|
|
218
261
|
return {
|
|
219
262
|
component: null,
|
|
220
263
|
pattern: matchResult.pattern,
|
|
221
264
|
params: matchResult.params,
|
|
222
265
|
matches: collectedMatches,
|
|
223
266
|
meta: null,
|
|
224
|
-
|
|
267
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
225
268
|
page404Component,
|
|
269
|
+
errorElement: route.errorElement,
|
|
226
270
|
};
|
|
227
271
|
}
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
}
|
|
273
|
+
// Handle guards (supports async)
|
|
274
|
+
if (route.guard) {
|
|
275
|
+
const guardArgs = {
|
|
276
|
+
pathname: currentPath,
|
|
277
|
+
params: matchResult.params,
|
|
278
|
+
search: searchString,
|
|
279
|
+
request,
|
|
280
|
+
signal,
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
// Handle both sync and async guards
|
|
284
|
+
const guardResult = await Promise.resolve(route.guard(guardArgs));
|
|
285
|
+
// Guard can return string (redirect), boolean, or Promise of either
|
|
286
|
+
if (typeof guardResult === "string") {
|
|
287
|
+
return {
|
|
288
|
+
component: null,
|
|
289
|
+
pattern: matchResult.pattern,
|
|
290
|
+
params: matchResult.params,
|
|
291
|
+
matches: collectedMatches,
|
|
292
|
+
meta: null,
|
|
293
|
+
redirect: guardResult,
|
|
294
|
+
page404Component,
|
|
295
|
+
errorElement: route.errorElement,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (guardResult === false) {
|
|
299
|
+
continue; // Skip this route
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
// Guard threw an error - return error result
|
|
304
|
+
return {
|
|
305
|
+
component: null,
|
|
306
|
+
pattern: matchResult.pattern,
|
|
307
|
+
params: matchResult.params,
|
|
308
|
+
matches: collectedMatches,
|
|
309
|
+
meta: null,
|
|
310
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
311
|
+
page404Component,
|
|
312
|
+
errorElement: route.errorElement,
|
|
313
|
+
};
|
|
230
314
|
}
|
|
231
315
|
}
|
|
232
316
|
// Build match object
|
|
@@ -240,7 +324,7 @@ const matchRoutes = (routesList, currentPath, parentPath = "/", searchString = "
|
|
|
240
324
|
const newMatches = [...collectedMatches, newMatch];
|
|
241
325
|
// Handle nested routes with Outlet support
|
|
242
326
|
if (route.children && route.children.length > 0) {
|
|
243
|
-
const childResult =
|
|
327
|
+
const childResult = await matchRoutesAsync(route.children, currentPath, fullPath, searchString, newMatches, request, signal);
|
|
244
328
|
if (childResult.component || childResult.redirect) {
|
|
245
329
|
// Wrap parent component with OutletProvider to render children via Outlet
|
|
246
330
|
return {
|
|
@@ -253,24 +337,37 @@ const matchRoutes = (routesList, currentPath, parentPath = "/", searchString = "
|
|
|
253
337
|
? { fn: route.loader, params: matchResult.params }
|
|
254
338
|
: childResult.loader,
|
|
255
339
|
page404Component: page404Component || childResult.page404Component,
|
|
340
|
+
errorElement: route.errorElement || childResult.errorElement,
|
|
256
341
|
};
|
|
257
342
|
}
|
|
258
343
|
}
|
|
259
|
-
return
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
344
|
+
// If we are here, children were checked but none matched (or didn't return a component).
|
|
345
|
+
// We must check if THIS route is an EXACT match for the current path.
|
|
346
|
+
// If it was only a partial match (prefix), and no children matched, then this route is NOT the correct match.
|
|
347
|
+
// Exceptions:
|
|
348
|
+
// 1. If it's a catch-all route (handled by exact match logic usually, or explicitly)
|
|
349
|
+
// 2. If the user intentionally wants to map a prefix to a component without children (unlikely if children prop exists)
|
|
350
|
+
const isExactMatch = matchPathPattern(fullPattern, currentPath, false);
|
|
351
|
+
if (isExactMatch) {
|
|
352
|
+
return {
|
|
353
|
+
component: route.component,
|
|
354
|
+
pattern: matchResult.pattern,
|
|
355
|
+
params: matchResult.params,
|
|
356
|
+
matches: newMatches,
|
|
357
|
+
meta: route.meta || null,
|
|
358
|
+
loader: route.loader
|
|
359
|
+
? { fn: route.loader, params: matchResult.params }
|
|
360
|
+
: undefined,
|
|
361
|
+
page404Component,
|
|
362
|
+
errorElement: route.errorElement,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// If not exact match and no children matched, this partial match is invalid.
|
|
366
|
+
// Fall through to continue loop.
|
|
270
367
|
}
|
|
271
368
|
// Check children routes (for routes without matching parent)
|
|
272
369
|
if (route.children) {
|
|
273
|
-
const childResult =
|
|
370
|
+
const childResult = await matchRoutesAsync(route.children, currentPath, fullPath, searchString, collectedMatches, request, signal);
|
|
274
371
|
if (childResult.component || childResult.redirect) {
|
|
275
372
|
return {
|
|
276
373
|
...childResult,
|
|
@@ -305,9 +402,21 @@ const RouterProvider = ({ routes, basename = "", fallbackElement, }) => {
|
|
|
305
402
|
var _a;
|
|
306
403
|
const [location, setLocation] = useState(getCurrentLocation);
|
|
307
404
|
const [loaderData, setLoaderData] = useState(null);
|
|
405
|
+
const [error, setError] = useState(null);
|
|
406
|
+
const [matchResult, setMatchResult] = useState({
|
|
407
|
+
component: null,
|
|
408
|
+
pattern: "",
|
|
409
|
+
params: {},
|
|
410
|
+
matches: [],
|
|
411
|
+
meta: null,
|
|
412
|
+
page404Component: null,
|
|
413
|
+
});
|
|
414
|
+
// Track initial route resolution to prevent 404 flash
|
|
415
|
+
const [isResolving, setIsResolving] = useState(true);
|
|
308
416
|
const [isPending, startTransition] = useTransition();
|
|
309
417
|
const scrollPositions = useRef(new Map());
|
|
310
418
|
const isNavigatingRef = useRef(false);
|
|
419
|
+
const abortControllerRef = useRef(null);
|
|
311
420
|
/**
|
|
312
421
|
* Normalize pathname by removing basename
|
|
313
422
|
*/
|
|
@@ -415,10 +524,58 @@ const RouterProvider = ({ routes, basename = "", fallbackElement, }) => {
|
|
|
415
524
|
};
|
|
416
525
|
}, []);
|
|
417
526
|
/**
|
|
418
|
-
* Compute matched route result
|
|
527
|
+
* Compute matched route result with async middleware and guard support
|
|
419
528
|
*/
|
|
420
529
|
const normalizedPath = normalizePathname(location.pathname);
|
|
421
|
-
|
|
530
|
+
// Async route matching with middleware and guards
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
// Abort previous matching if still in progress
|
|
533
|
+
if (abortControllerRef.current) {
|
|
534
|
+
abortControllerRef.current.abort();
|
|
535
|
+
}
|
|
536
|
+
// Create new abort controller for this matching
|
|
537
|
+
const abortController = new AbortController();
|
|
538
|
+
abortControllerRef.current = abortController;
|
|
539
|
+
// Create request object for middleware/guards
|
|
540
|
+
const request = typeof window !== "undefined"
|
|
541
|
+
? new Request(window.location.href)
|
|
542
|
+
: undefined;
|
|
543
|
+
// Execute async route matching
|
|
544
|
+
// Only set resolving for the first time to prevent layout unmounting on navigation
|
|
545
|
+
// setIsResolving(true); // Removed to prevent flicker
|
|
546
|
+
matchRoutesAsync(routes, normalizedPath, "/", location.search, [], request, abortController.signal)
|
|
547
|
+
.then((result) => {
|
|
548
|
+
// Only update if not aborted
|
|
549
|
+
if (!abortController.signal.aborted) {
|
|
550
|
+
setMatchResult(result);
|
|
551
|
+
// If the new route has a loader, we handle data clearing
|
|
552
|
+
if (result.loader) {
|
|
553
|
+
setLoaderData(null);
|
|
554
|
+
}
|
|
555
|
+
setError(null); // Clear any previous errors on successful match
|
|
556
|
+
setIsResolving(false);
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
.catch((error) => {
|
|
560
|
+
// Ignore abort errors
|
|
561
|
+
if (error.name !== "AbortError") {
|
|
562
|
+
console.error("[router-kit] Route matching error:", error);
|
|
563
|
+
setError(error);
|
|
564
|
+
setMatchResult({
|
|
565
|
+
component: null,
|
|
566
|
+
pattern: "",
|
|
567
|
+
params: {},
|
|
568
|
+
matches: [],
|
|
569
|
+
meta: null,
|
|
570
|
+
page404Component: null,
|
|
571
|
+
});
|
|
572
|
+
setIsResolving(false);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
return () => {
|
|
576
|
+
abortController.abort();
|
|
577
|
+
};
|
|
578
|
+
}, [routes, normalizedPath, location.search]);
|
|
422
579
|
// Handle redirects
|
|
423
580
|
useEffect(() => {
|
|
424
581
|
if (matchResult.redirect) {
|
|
@@ -433,18 +590,50 @@ const RouterProvider = ({ routes, basename = "", fallbackElement, }) => {
|
|
|
433
590
|
params: matchResult.loader.params,
|
|
434
591
|
request: new Request(window.location.href),
|
|
435
592
|
signal: abortController.signal,
|
|
436
|
-
}))
|
|
593
|
+
}))
|
|
594
|
+
.then((data) => {
|
|
595
|
+
setLoaderData(data);
|
|
596
|
+
setError(null); // Clear error on successful loader
|
|
597
|
+
})
|
|
598
|
+
.catch((error) => {
|
|
599
|
+
if (error.name !== "AbortError") {
|
|
600
|
+
console.error("[router-kit] Loader error:", error);
|
|
601
|
+
setError(error);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
437
604
|
return () => abortController.abort();
|
|
438
605
|
}
|
|
606
|
+
else {
|
|
607
|
+
setLoaderData(null);
|
|
608
|
+
}
|
|
439
609
|
}, [matchResult.loader]);
|
|
440
610
|
// Handle meta/title updates
|
|
441
611
|
useEffect(() => {
|
|
442
|
-
var _a;
|
|
612
|
+
var _a, _b;
|
|
443
613
|
if (((_a = matchResult.meta) === null || _a === void 0 ? void 0 : _a.title) && typeof document !== "undefined") {
|
|
444
614
|
document.title = matchResult.meta.title;
|
|
445
615
|
}
|
|
616
|
+
if (((_b = matchResult.meta) === null || _b === void 0 ? void 0 : _b.description) && typeof document !== "undefined") {
|
|
617
|
+
let descriptionTag = document.querySelector('meta[name="description"]');
|
|
618
|
+
if (!descriptionTag) {
|
|
619
|
+
descriptionTag = document.createElement("meta");
|
|
620
|
+
descriptionTag.name = "description";
|
|
621
|
+
document.head.appendChild(descriptionTag);
|
|
622
|
+
}
|
|
623
|
+
descriptionTag.content = matchResult.meta.description;
|
|
624
|
+
}
|
|
446
625
|
}, [matchResult.meta]);
|
|
447
|
-
|
|
626
|
+
// Determine the loading component to show (if any)
|
|
627
|
+
const routeLoadingComponent = matchResult.matches.length > 0
|
|
628
|
+
? matchResult.matches[matchResult.matches.length - 1].route.loading
|
|
629
|
+
: null;
|
|
630
|
+
// Only show loading if:
|
|
631
|
+
// 1. Initial resolution (isResolving)
|
|
632
|
+
// 2. We have a match with a loader but no data yet (loaderData is null)
|
|
633
|
+
// We removed isPending to prevent "flash" of loading state during standard navigation transitions
|
|
634
|
+
const showLoading = isResolving || (matchResult.loader && !loaderData);
|
|
635
|
+
// Prioritize error -> loading -> content -> 404
|
|
636
|
+
const component = (error || matchResult.error) && matchResult.errorElement ? (matchResult.errorElement) : showLoading ? (_jsx(Suspense, { fallback: fallbackElement || null, children: routeLoadingComponent || fallbackElement || null })) : ((_a = matchResult.component) !== null && _a !== void 0 ? _a : (matchResult.page404Component || _jsx(Page404, {})));
|
|
448
637
|
/**
|
|
449
638
|
* Build context value with memoization
|
|
450
639
|
*/
|
|
@@ -481,6 +670,6 @@ const RouterProvider = ({ routes, basename = "", fallbackElement, }) => {
|
|
|
481
670
|
loaderData,
|
|
482
671
|
matchResult.meta,
|
|
483
672
|
]);
|
|
484
|
-
return (_jsx(RouterContext.Provider, { value: contextValue, children:
|
|
673
|
+
return (_jsx(RouterContext.Provider, { value: contextValue, children: _jsx(Suspense, { fallback: fallbackElement || null, children: component }) }));
|
|
485
674
|
};
|
|
486
675
|
export default RouterProvider;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Preserves "/" as empty string for root path matching
|
|
4
4
|
*/
|
|
5
5
|
const normalizePath = (path) => {
|
|
6
|
+
if (path === undefined)
|
|
7
|
+
return "";
|
|
6
8
|
const pathArray = Array.isArray(path) ? path : [path];
|
|
7
9
|
const normalized = pathArray.map((p) => {
|
|
8
10
|
if (!p)
|
|
@@ -29,15 +31,17 @@ const validateRoute = (route, path) => {
|
|
|
29
31
|
console.warn(`[router-kit] Route "${path}" has both component and lazy defined. Component will take precedence.`);
|
|
30
32
|
}
|
|
31
33
|
// Validate path patterns
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
if (route.path) {
|
|
35
|
+
const pathArray = Array.isArray(route.path) ? route.path : [route.path];
|
|
36
|
+
for (const p of pathArray) {
|
|
37
|
+
// Check for invalid characters
|
|
38
|
+
if (/[<>"|\\]/.test(p)) {
|
|
39
|
+
console.warn(`[router-kit] Route path "${p}" contains invalid characters.`);
|
|
40
|
+
}
|
|
41
|
+
// Warn about potential issues with catch-all routes
|
|
42
|
+
if (p.includes("*") && !p.endsWith("*") && !p.includes("*/")) {
|
|
43
|
+
console.warn(`[router-kit] Catch-all (*) should typically be at the end of a path: "${p}"`);
|
|
44
|
+
}
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export { useDynamicComponents } from "./hooks/useDynamicComponents";
|
|
|
18
18
|
export { useIsNavigating, useLoaderData, useRouteMeta, } from "./hooks/useLoaderData";
|
|
19
19
|
export type { OutletProps } from "./components/Outlet";
|
|
20
20
|
export type { RouteProps } from "./components/route";
|
|
21
|
-
export type { Blocker, BlockerFunction, DynamicComponents, GetComponent, GuardArgs, HistoryAction, LinkProps, LoaderArgs, Location, NavigateFunction, NavigateOptions, NavLinkProps, RouteGuard, RouteLoader, RouteMatch, RouteMeta, RouterContextType, RouterError, RouterKitError, RouterProviderProps, Routes, Route as RouteType, ScrollRestorationProps, } from "./types/index";
|
|
21
|
+
export type { Blocker, BlockerFunction, DynamicComponents, GetComponent, GuardArgs, HistoryAction, LinkProps, LoaderArgs, Location, Middleware, MiddlewareContext, MiddlewareResult, NavigateFunction, NavigateOptions, NavLinkProps, RouteGuard, RouteLoader, RouteMatch, RouteMeta, RouterContextType, RouterError, RouterKitError, RouterProviderProps, Routes, Route as RouteType, ScrollRestorationProps, } from "./types/index";
|
|
22
22
|
export { createRouterError, RouterErrorCode, RouterErrors, RouterKitError as RouterKitErrorClass, } from "./utils/error/errors";
|
|
23
|
+
export { executeMiddlewareChain, createAuthMiddleware, createRoleMiddleware, createDataMiddleware, createLoggingMiddleware, } from "./utils/middleware";
|
|
23
24
|
export { createRequestFromNode, getHydratedLoaderData, getLoaderDataScript, hydrateRouter, isBrowser, isServerRendered, matchServerRoutes, prefetchLoaderData, StaticRouter, } from "./ssr";
|
|
24
25
|
export type { HydrateRouterOptions, ServerLoaderResult, ServerMatchResult, StaticRouterContext, StaticRouterProps, } from "./ssr";
|
package/dist/index.js
CHANGED
|
@@ -24,5 +24,7 @@ export { useDynamicComponents } from "./hooks/useDynamicComponents";
|
|
|
24
24
|
export { useIsNavigating, useLoaderData, useRouteMeta, } from "./hooks/useLoaderData";
|
|
25
25
|
// Error utilities
|
|
26
26
|
export { createRouterError, RouterErrorCode, RouterErrors, RouterKitError as RouterKitErrorClass, } from "./utils/error/errors";
|
|
27
|
+
// Middleware utilities
|
|
28
|
+
export { executeMiddlewareChain, createAuthMiddleware, createRoleMiddleware, createDataMiddleware, createLoggingMiddleware, } from "./utils/middleware";
|
|
27
29
|
// SSR - Server-Side Rendering
|
|
28
30
|
export { createRequestFromNode, getHydratedLoaderData, getLoaderDataScript, hydrateRouter, isBrowser, isServerRendered, matchServerRoutes, prefetchLoaderData, StaticRouter, } from "./ssr";
|
package/dist/ssr/StaticRouter.js
CHANGED
|
@@ -25,10 +25,14 @@ const parseUrl = (url) => {
|
|
|
25
25
|
/**
|
|
26
26
|
* Extract params from a path using a pattern
|
|
27
27
|
*/
|
|
28
|
-
const extractParams = (pattern, pathname) => {
|
|
28
|
+
const extractParams = (pattern, pathname, partialMatch = false) => {
|
|
29
29
|
const patternParts = pattern.split("/").filter(Boolean);
|
|
30
30
|
const pathParts = pathname.split("/").filter(Boolean);
|
|
31
|
-
if (
|
|
31
|
+
if (partialMatch) {
|
|
32
|
+
if (patternParts.length > pathParts.length)
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
else if (patternParts.length !== pathParts.length) {
|
|
32
36
|
const hasCatchAll = patternParts.some((p) => p.startsWith("*"));
|
|
33
37
|
if (!hasCatchAll)
|
|
34
38
|
return null;
|
|
@@ -71,6 +75,8 @@ const joinPaths = (parent, child) => {
|
|
|
71
75
|
* Normalize path to string (handles array paths)
|
|
72
76
|
*/
|
|
73
77
|
const normalizePath = (path) => {
|
|
78
|
+
if (path === undefined)
|
|
79
|
+
return "";
|
|
74
80
|
if (Array.isArray(path)) {
|
|
75
81
|
return path.map((p) => (p.startsWith("/") ? p.slice(1) : p)).join("|");
|
|
76
82
|
}
|
|
@@ -80,6 +86,8 @@ const normalizePath = (path) => {
|
|
|
80
86
|
* Get the first path from a path (string or array)
|
|
81
87
|
*/
|
|
82
88
|
const getFirstPath = (path) => {
|
|
89
|
+
if (path === undefined)
|
|
90
|
+
return "";
|
|
83
91
|
if (Array.isArray(path)) {
|
|
84
92
|
return path[0] || "";
|
|
85
93
|
}
|
|
@@ -137,10 +145,10 @@ const StaticRouter = ({ routes, location: locationString, basename = "", loaderD
|
|
|
137
145
|
key: "static",
|
|
138
146
|
};
|
|
139
147
|
// Match path helper
|
|
140
|
-
const matchPath = (routePattern, currentPath) => {
|
|
148
|
+
const matchPath = (routePattern, currentPath, partialMatch = false) => {
|
|
141
149
|
const patterns = routePattern.split("|");
|
|
142
150
|
for (const pat of patterns) {
|
|
143
|
-
const extractedParams = extractParams(pat, currentPath);
|
|
151
|
+
const extractedParams = extractParams(pat, currentPath, partialMatch);
|
|
144
152
|
if (extractedParams !== null) {
|
|
145
153
|
return { match: true, params: extractedParams, pattern: pat };
|
|
146
154
|
}
|
|
@@ -160,7 +168,11 @@ const StaticRouter = ({ routes, location: locationString, basename = "", loaderD
|
|
|
160
168
|
page404Component = route.component;
|
|
161
169
|
continue;
|
|
162
170
|
}
|
|
163
|
-
const pathArray = Array.isArray(route.path)
|
|
171
|
+
const pathArray = Array.isArray(route.path)
|
|
172
|
+
? route.path
|
|
173
|
+
: route.path
|
|
174
|
+
? [route.path]
|
|
175
|
+
: [];
|
|
164
176
|
const hasCatchAll = pathArray.some((p) => p.includes("*"));
|
|
165
177
|
const hasDynamicParams = pathArray.some((p) => p.includes(":"));
|
|
166
178
|
if (hasCatchAll) {
|
|
@@ -182,12 +194,13 @@ const StaticRouter = ({ routes, location: locationString, basename = "", loaderD
|
|
|
182
194
|
const normalizedRoutePath = normalizePath(route.path);
|
|
183
195
|
const firstPath = getFirstPath(route.path);
|
|
184
196
|
const fullPath = joinPaths(parentPath, firstPath);
|
|
197
|
+
const isParent = route.children && route.children.length > 0;
|
|
185
198
|
const matchResult = matchPath(normalizedRoutePath.includes("|")
|
|
186
199
|
? normalizedRoutePath
|
|
187
200
|
.split("|")
|
|
188
201
|
.map((p) => joinPaths(parentPath, p))
|
|
189
202
|
.join("|")
|
|
190
|
-
: fullPath, currentPath);
|
|
203
|
+
: fullPath, currentPath, isParent);
|
|
191
204
|
if (matchResult) {
|
|
192
205
|
// Handle redirects
|
|
193
206
|
if (route.redirectTo) {
|
|
@@ -239,12 +252,23 @@ const StaticRouter = ({ routes, location: locationString, basename = "", loaderD
|
|
|
239
252
|
}
|
|
240
253
|
context.action = "OK";
|
|
241
254
|
context.statusCode = 200;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
255
|
+
// If no children matched, check if this is an exact match
|
|
256
|
+
const isExactMatch = matchPath(normalizedRoutePath.includes("|")
|
|
257
|
+
? normalizedRoutePath
|
|
258
|
+
.split("|")
|
|
259
|
+
.map((p) => joinPaths(parentPath, p))
|
|
260
|
+
.join("|")
|
|
261
|
+
: fullPath, currentPath, false // Force exact match check
|
|
262
|
+
);
|
|
263
|
+
if (isExactMatch) {
|
|
264
|
+
return {
|
|
265
|
+
component: route.component,
|
|
266
|
+
match: routeMatch,
|
|
267
|
+
pattern: matchResult.pattern,
|
|
268
|
+
params: matchResult.params,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// If not exact and no children matched, continue loop matching other routes
|
|
248
272
|
}
|
|
249
273
|
// Check children routes
|
|
250
274
|
if (route.children) {
|
package/dist/ssr/serverUtils.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extract params from a path using a pattern
|
|
3
3
|
*/
|
|
4
|
-
const extractParams = (pattern, pathname) => {
|
|
4
|
+
const extractParams = (pattern, pathname, partialMatch = false) => {
|
|
5
5
|
const patternParts = pattern.split("/").filter(Boolean);
|
|
6
6
|
const pathParts = pathname.split("/").filter(Boolean);
|
|
7
|
-
if (
|
|
7
|
+
if (partialMatch) {
|
|
8
|
+
if (patternParts.length > pathParts.length)
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
else if (patternParts.length !== pathParts.length) {
|
|
8
12
|
const hasCatchAll = patternParts.some((p) => p.startsWith("*"));
|
|
9
13
|
if (!hasCatchAll)
|
|
10
14
|
return null;
|
|
@@ -47,6 +51,8 @@ const joinPaths = (parent, child) => {
|
|
|
47
51
|
* Normalize path to string (handles array paths)
|
|
48
52
|
*/
|
|
49
53
|
const normalizePath = (path) => {
|
|
54
|
+
if (path === undefined)
|
|
55
|
+
return "";
|
|
50
56
|
if (Array.isArray(path)) {
|
|
51
57
|
return path.map((p) => (p.startsWith("/") ? p.slice(1) : p)).join("|");
|
|
52
58
|
}
|
|
@@ -56,6 +62,8 @@ const normalizePath = (path) => {
|
|
|
56
62
|
* Get the first path from a path (string or array)
|
|
57
63
|
*/
|
|
58
64
|
const getFirstPath = (path) => {
|
|
65
|
+
if (path === undefined)
|
|
66
|
+
return "";
|
|
59
67
|
if (Array.isArray(path)) {
|
|
60
68
|
return path[0] || "";
|
|
61
69
|
}
|
|
@@ -87,7 +95,11 @@ export function matchServerRoutes(routes, pathname, parentPath = "/") {
|
|
|
87
95
|
const is404 = route.path === "404" || route.path === "/404";
|
|
88
96
|
if (is404)
|
|
89
97
|
continue;
|
|
90
|
-
const pathArray = Array.isArray(route.path)
|
|
98
|
+
const pathArray = Array.isArray(route.path)
|
|
99
|
+
? route.path
|
|
100
|
+
: route.path
|
|
101
|
+
? [route.path]
|
|
102
|
+
: [];
|
|
91
103
|
const hasCatchAll = pathArray.some((p) => p.includes("*"));
|
|
92
104
|
const hasDynamicParams = pathArray.some((p) => p.includes(":"));
|
|
93
105
|
if (hasCatchAll) {
|
|
@@ -108,7 +120,8 @@ export function matchServerRoutes(routes, pathname, parentPath = "/") {
|
|
|
108
120
|
const patterns = normalizedRoutePath.split("|");
|
|
109
121
|
for (const pattern of patterns) {
|
|
110
122
|
const fullPattern = joinPaths(parentPath, pattern);
|
|
111
|
-
const
|
|
123
|
+
const isParent = route.children && route.children.length > 0;
|
|
124
|
+
const extractedParams = extractParams(fullPattern, pathname, isParent);
|
|
112
125
|
if (extractedParams !== null) {
|
|
113
126
|
// Handle redirects
|
|
114
127
|
if (route.redirectTo) {
|
|
@@ -143,12 +156,20 @@ export function matchServerRoutes(routes, pathname, parentPath = "/") {
|
|
|
143
156
|
};
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
// If no children matched, check for exact match
|
|
160
|
+
// Rethink fullPattern logic: patterns contains split parts.
|
|
161
|
+
// We need to re-verify the specific pattern that matched partially.
|
|
162
|
+
const isExactMatch = extractParams(fullPattern, pathname, false);
|
|
163
|
+
if (isExactMatch !== null) {
|
|
164
|
+
return {
|
|
165
|
+
matches,
|
|
166
|
+
params: extractedParams,
|
|
167
|
+
statusCode: 200,
|
|
168
|
+
meta: route.meta,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// If not exact and no children matched, continue loop matching other routes (pop from matches implicitly by not returning)
|
|
172
|
+
matches.pop(); // Remove the partial match from matches array if we are continuing
|
|
152
173
|
}
|
|
153
174
|
}
|
|
154
175
|
// Check children even if parent doesn't match
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,13 +4,15 @@ import { ComponentType, JSX, LazyExoticComponent, ReactNode } from "react";
|
|
|
4
4
|
*/
|
|
5
5
|
export interface Route {
|
|
6
6
|
/** Path pattern(s) for the route */
|
|
7
|
-
path
|
|
7
|
+
path?: string | string[];
|
|
8
8
|
/** Component to render */
|
|
9
9
|
component: JSX.Element;
|
|
10
10
|
/** Nested child routes */
|
|
11
11
|
children?: Route[];
|
|
12
12
|
/** Index route flag - renders when parent path matches exactly */
|
|
13
13
|
index?: boolean;
|
|
14
|
+
/** Component to render while loading data or performing async tasks */
|
|
15
|
+
loading?: JSX.Element;
|
|
14
16
|
/** Lazy-loaded component */
|
|
15
17
|
lazy?: LazyExoticComponent<ComponentType<any>>;
|
|
16
18
|
/** Route loader function for data fetching */
|
|
@@ -21,6 +23,8 @@ export interface Route {
|
|
|
21
23
|
redirectTo?: string;
|
|
22
24
|
/** Route guard function */
|
|
23
25
|
guard?: RouteGuard;
|
|
26
|
+
/** Middleware chain for route processing (Chain of Responsibility pattern) */
|
|
27
|
+
middleware?: Middleware[];
|
|
24
28
|
/** Route metadata */
|
|
25
29
|
meta?: RouteMeta;
|
|
26
30
|
}
|
|
@@ -37,9 +41,36 @@ export interface LoaderArgs {
|
|
|
37
41
|
signal: AbortSignal;
|
|
38
42
|
}
|
|
39
43
|
/**
|
|
40
|
-
*
|
|
44
|
+
* Middleware context passed to middleware functions
|
|
41
45
|
*/
|
|
42
|
-
export
|
|
46
|
+
export interface MiddlewareContext {
|
|
47
|
+
pathname: string;
|
|
48
|
+
params: Record<string, string>;
|
|
49
|
+
search: string;
|
|
50
|
+
request?: Request;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Middleware result - can redirect, block, or continue
|
|
55
|
+
*/
|
|
56
|
+
export type MiddlewareResult = {
|
|
57
|
+
type: "continue";
|
|
58
|
+
} | {
|
|
59
|
+
type: "redirect";
|
|
60
|
+
to: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: "block";
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Middleware function type - supports both sync and async
|
|
66
|
+
* Returns MiddlewareResult or Promise<MiddlewareResult>
|
|
67
|
+
*/
|
|
68
|
+
export type Middleware = (context: MiddlewareContext, next: () => Promise<MiddlewareResult>) => MiddlewareResult | Promise<MiddlewareResult>;
|
|
69
|
+
/**
|
|
70
|
+
* Route guard function type - supports both sync and async
|
|
71
|
+
* Can return boolean, Promise<boolean>, or redirect string
|
|
72
|
+
*/
|
|
73
|
+
export type RouteGuard = (args: GuardArgs) => boolean | Promise<boolean> | string | Promise<string>;
|
|
43
74
|
/**
|
|
44
75
|
* Guard function arguments
|
|
45
76
|
*/
|
|
@@ -47,6 +78,8 @@ export interface GuardArgs {
|
|
|
47
78
|
pathname: string;
|
|
48
79
|
params: Record<string, string>;
|
|
49
80
|
search: string;
|
|
81
|
+
request?: Request;
|
|
82
|
+
signal?: AbortSignal;
|
|
50
83
|
}
|
|
51
84
|
/**
|
|
52
85
|
* Route metadata
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Middleware, MiddlewareContext, MiddlewareResult } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Creates a middleware chain executor using Chain of Responsibility pattern
|
|
4
|
+
* Each middleware can either:
|
|
5
|
+
* - Continue to the next middleware (return { type: "continue" })
|
|
6
|
+
* - Redirect (return { type: "redirect", to: string })
|
|
7
|
+
* - Block the request (return { type: "block" })
|
|
8
|
+
*
|
|
9
|
+
* @param middlewares - Array of middleware functions
|
|
10
|
+
* @param context - Middleware context with route information
|
|
11
|
+
* @returns Promise resolving to middleware result
|
|
12
|
+
*/
|
|
13
|
+
export declare function executeMiddlewareChain(middlewares: Middleware[], context: MiddlewareContext): Promise<MiddlewareResult>;
|
|
14
|
+
/**
|
|
15
|
+
* Helper to create a middleware that checks authentication
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const authMiddleware: Middleware = createAuthMiddleware({
|
|
19
|
+
* checkAuth: async () => {
|
|
20
|
+
* const token = localStorage.getItem('token');
|
|
21
|
+
* return !!token;
|
|
22
|
+
* },
|
|
23
|
+
* redirectTo: '/login'
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function createAuthMiddleware(options: {
|
|
28
|
+
checkAuth: (context: MiddlewareContext) => boolean | Promise<boolean>;
|
|
29
|
+
redirectTo?: string;
|
|
30
|
+
}): Middleware;
|
|
31
|
+
/**
|
|
32
|
+
* Helper to create a middleware that checks permissions/roles
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const adminMiddleware: Middleware = createRoleMiddleware({
|
|
36
|
+
* checkRole: async (context) => {
|
|
37
|
+
* const user = await getCurrentUser();
|
|
38
|
+
* return user?.role === 'admin';
|
|
39
|
+
* },
|
|
40
|
+
* redirectTo: '/unauthorized'
|
|
41
|
+
* });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function createRoleMiddleware(options: {
|
|
45
|
+
checkRole: (context: MiddlewareContext) => boolean | Promise<boolean>;
|
|
46
|
+
redirectTo?: string;
|
|
47
|
+
}): Middleware;
|
|
48
|
+
/**
|
|
49
|
+
* Helper to create a middleware that fetches data before route loads
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const dataMiddleware: Middleware = createDataMiddleware({
|
|
53
|
+
* fetchData: async (context) => {
|
|
54
|
+
* const response = await fetch(`/api/data/${context.params.id}`);
|
|
55
|
+
* return response.json();
|
|
56
|
+
* },
|
|
57
|
+
* onData: (data) => {
|
|
58
|
+
* // Store data in context or global state
|
|
59
|
+
* }
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare function createDataMiddleware<T = any>(options: {
|
|
64
|
+
fetchData: (context: MiddlewareContext) => Promise<T> | T;
|
|
65
|
+
onData?: (data: T, context: MiddlewareContext) => void | Promise<void>;
|
|
66
|
+
onError?: (error: Error, context: MiddlewareContext) => void;
|
|
67
|
+
}): Middleware;
|
|
68
|
+
/**
|
|
69
|
+
* Helper to create a middleware that logs route access
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const loggingMiddleware: Middleware = createLoggingMiddleware({
|
|
73
|
+
* log: (context) => {
|
|
74
|
+
* console.log(`Accessing: ${context.pathname}`);
|
|
75
|
+
* }
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export declare function createLoggingMiddleware(options: {
|
|
80
|
+
log: (context: MiddlewareContext) => void | Promise<void>;
|
|
81
|
+
}): Middleware;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a middleware chain executor using Chain of Responsibility pattern
|
|
3
|
+
* Each middleware can either:
|
|
4
|
+
* - Continue to the next middleware (return { type: "continue" })
|
|
5
|
+
* - Redirect (return { type: "redirect", to: string })
|
|
6
|
+
* - Block the request (return { type: "block" })
|
|
7
|
+
*
|
|
8
|
+
* @param middlewares - Array of middleware functions
|
|
9
|
+
* @param context - Middleware context with route information
|
|
10
|
+
* @returns Promise resolving to middleware result
|
|
11
|
+
*/
|
|
12
|
+
export async function executeMiddlewareChain(middlewares, context) {
|
|
13
|
+
if (middlewares.length === 0) {
|
|
14
|
+
return { type: "continue" };
|
|
15
|
+
}
|
|
16
|
+
let index = 0;
|
|
17
|
+
/**
|
|
18
|
+
* Next function - calls the next middleware in the chain
|
|
19
|
+
*/
|
|
20
|
+
const next = async () => {
|
|
21
|
+
if (index >= middlewares.length) {
|
|
22
|
+
return { type: "continue" };
|
|
23
|
+
}
|
|
24
|
+
const middleware = middlewares[index++];
|
|
25
|
+
try {
|
|
26
|
+
// Execute middleware - handle both sync and async
|
|
27
|
+
const result = await Promise.resolve(middleware(context, next));
|
|
28
|
+
// Normalize result
|
|
29
|
+
if (result && typeof result === "object" && "type" in result) {
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
// Fallback to continue if invalid result
|
|
33
|
+
return { type: "continue" };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// On error, block the request
|
|
37
|
+
console.error("[router-kit] Middleware error:", error);
|
|
38
|
+
return { type: "block" };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return next();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Helper to create a middleware that checks authentication
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const authMiddleware: Middleware = createAuthMiddleware({
|
|
48
|
+
* checkAuth: async () => {
|
|
49
|
+
* const token = localStorage.getItem('token');
|
|
50
|
+
* return !!token;
|
|
51
|
+
* },
|
|
52
|
+
* redirectTo: '/login'
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function createAuthMiddleware(options) {
|
|
57
|
+
return async (context, next) => {
|
|
58
|
+
const isAuthenticated = await Promise.resolve(options.checkAuth(context));
|
|
59
|
+
if (!isAuthenticated) {
|
|
60
|
+
return {
|
|
61
|
+
type: "redirect",
|
|
62
|
+
to: options.redirectTo || "/login",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return next();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Helper to create a middleware that checks permissions/roles
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const adminMiddleware: Middleware = createRoleMiddleware({
|
|
73
|
+
* checkRole: async (context) => {
|
|
74
|
+
* const user = await getCurrentUser();
|
|
75
|
+
* return user?.role === 'admin';
|
|
76
|
+
* },
|
|
77
|
+
* redirectTo: '/unauthorized'
|
|
78
|
+
* });
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function createRoleMiddleware(options) {
|
|
82
|
+
return async (context, next) => {
|
|
83
|
+
const hasRole = await Promise.resolve(options.checkRole(context));
|
|
84
|
+
if (!hasRole) {
|
|
85
|
+
return {
|
|
86
|
+
type: "redirect",
|
|
87
|
+
to: options.redirectTo || "/unauthorized",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Helper to create a middleware that fetches data before route loads
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const dataMiddleware: Middleware = createDataMiddleware({
|
|
98
|
+
* fetchData: async (context) => {
|
|
99
|
+
* const response = await fetch(`/api/data/${context.params.id}`);
|
|
100
|
+
* return response.json();
|
|
101
|
+
* },
|
|
102
|
+
* onData: (data) => {
|
|
103
|
+
* // Store data in context or global state
|
|
104
|
+
* }
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function createDataMiddleware(options) {
|
|
109
|
+
return async (context, next) => {
|
|
110
|
+
try {
|
|
111
|
+
const data = await Promise.resolve(options.fetchData(context));
|
|
112
|
+
if (options.onData) {
|
|
113
|
+
await Promise.resolve(options.onData(data, context));
|
|
114
|
+
}
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (options.onError) {
|
|
119
|
+
options.onError(error instanceof Error ? error : new Error(String(error)), context);
|
|
120
|
+
}
|
|
121
|
+
return { type: "block" };
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Helper to create a middleware that logs route access
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* const loggingMiddleware: Middleware = createLoggingMiddleware({
|
|
130
|
+
* log: (context) => {
|
|
131
|
+
* console.log(`Accessing: ${context.pathname}`);
|
|
132
|
+
* }
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function createLoggingMiddleware(options) {
|
|
137
|
+
return async (context, next) => {
|
|
138
|
+
await Promise.resolve(options.log(context));
|
|
139
|
+
return next();
|
|
140
|
+
};
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"email": "mohammed.bencheikh.dev@gmail.com",
|
|
6
6
|
"url": "https://mohammedbencheikh.com/"
|
|
7
7
|
},
|
|
8
|
-
"version": "2.0
|
|
8
|
+
"version": "2.1.0",
|
|
9
9
|
"description": "A professional React routing library with guards, loaders, and navigation blocking",
|
|
10
10
|
"main": "dist/index.js",
|
|
11
11
|
"types": "dist/index.d.ts",
|
|
@@ -35,17 +35,13 @@
|
|
|
35
35
|
"audit:fix": "npm audit fix",
|
|
36
36
|
"validate:deps": "npm ls"
|
|
37
37
|
},
|
|
38
|
-
"peerDependencies": {
|
|
39
|
-
"react": ">=16 <20",
|
|
40
|
-
"react-dom": ">=16 <20"
|
|
41
|
-
},
|
|
42
38
|
"dependencies": {
|
|
43
39
|
"url-join": "^5.0.0"
|
|
44
40
|
},
|
|
45
41
|
"devDependencies": {
|
|
46
|
-
"typescript": "^5.2.0",
|
|
47
42
|
"@types/react": "^19.2.2",
|
|
48
|
-
"@types/react-dom": "^19.2.2"
|
|
43
|
+
"@types/react-dom": "^19.2.2",
|
|
44
|
+
"typescript": "^5.2.0"
|
|
49
45
|
},
|
|
50
46
|
"overrides": {
|
|
51
47
|
"minimist": "^1.2.6",
|