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 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.0.0 | **License:** MIT
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 || { redirect: "/login" };
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
- if (patternParts.length !== pathParts.length) {
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
- * Pure function to match routes and return result without side effects
160
+ * Async function to match routes with middleware and guard support
161
+ * Handles both sync and async guards/middleware
146
162
  */
147
- const matchRoutes = (routesList, currentPath, parentPath = "/", searchString = "", collectedMatches = []) => {
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 pathArray = Array.isArray(route.path)
154
- ? route.path
155
- : route.path.includes("|")
156
- ? route.path.split("|")
157
- : [route.path];
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
- const matchResult = matchPathPattern(fullPattern, currentPath);
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
- // Handle guards
211
- if (route.guard) {
212
- const guardResult = route.guard({
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
- if (typeof guardResult === "string") {
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
- redirect: guardResult,
267
+ error: error instanceof Error ? error : new Error(String(error)),
225
268
  page404Component,
269
+ errorElement: route.errorElement,
226
270
  };
227
271
  }
228
- if (guardResult === false) {
229
- continue; // Skip this route
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 = matchRoutes(route.children, currentPath, fullPath, searchString, newMatches);
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
- component: route.component,
261
- pattern: matchResult.pattern,
262
- params: matchResult.params,
263
- matches: newMatches,
264
- meta: route.meta || null,
265
- loader: route.loader
266
- ? { fn: route.loader, params: matchResult.params }
267
- : undefined,
268
- page404Component,
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 = matchRoutes(route.children, currentPath, fullPath, searchString, collectedMatches);
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 (pure computation)
527
+ * Compute matched route result with async middleware and guard support
419
528
  */
420
529
  const normalizedPath = normalizePathname(location.pathname);
421
- const matchResult = useMemo(() => matchRoutes(routes, normalizedPath, "/", location.search), [routes, normalizedPath, location.search]);
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
- })).then(setLoaderData);
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
- const component = (_a = matchResult.component) !== null && _a !== void 0 ? _a : (matchResult.page404Component || _jsx(Page404, {}));
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: fallbackElement && isPending ? (_jsx(Suspense, { fallback: fallbackElement, children: component })) : (component) }));
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
- const pathArray = Array.isArray(route.path) ? route.path : [route.path];
33
- for (const p of pathArray) {
34
- // Check for invalid characters
35
- if (/[<>"|\\]/.test(p)) {
36
- console.warn(`[router-kit] Route path "${p}" contains invalid characters.`);
37
- }
38
- // Warn about potential issues with catch-all routes
39
- if (p.includes("*") && !p.endsWith("*") && !p.includes("*/")) {
40
- console.warn(`[router-kit] Catch-all (*) should typically be at the end of a path: "${p}"`);
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";
@@ -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 (patternParts.length !== pathParts.length) {
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) ? route.path : [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
- return {
243
- component: route.component,
244
- match: routeMatch,
245
- pattern: matchResult.pattern,
246
- params: matchResult.params,
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) {
@@ -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 (patternParts.length !== pathParts.length) {
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) ? route.path : [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 extractedParams = extractParams(fullPattern, pathname);
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
- return {
147
- matches,
148
- params: extractedParams,
149
- statusCode: 200,
150
- meta: route.meta,
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
@@ -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: string | string[];
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
- * Route guard function type
44
+ * Middleware context passed to middleware functions
41
45
  */
42
- export type RouteGuard = (args: GuardArgs) => boolean | Promise<boolean> | string;
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.1",
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",