@real-router/validation-plugin 0.0.1
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 +148 -0
- package/dist/cjs/index.d.ts +6 -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 +6 -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 +65 -0
- package/src/helpers.ts +11 -0
- package/src/index.ts +5 -0
- package/src/validationPlugin.ts +330 -0
- package/src/validators/dependencies.ts +159 -0
- package/src/validators/eventBus.ts +48 -0
- package/src/validators/forwardTo.ts +301 -0
- package/src/validators/lifecycle.ts +94 -0
- package/src/validators/navigation.ts +57 -0
- package/src/validators/options.ts +305 -0
- package/src/validators/plugins.ts +109 -0
- package/src/validators/retrospective.ts +540 -0
- package/src/validators/routes.ts +578 -0
- package/src/validators/state.ts +34 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/forwardTo.ts
|
|
2
|
+
|
|
3
|
+
import { resolveForwardChain } from "@real-router/core";
|
|
4
|
+
import { getSegmentsByName } from "route-tree";
|
|
5
|
+
import { getTypeDescription } from "type-guards";
|
|
6
|
+
|
|
7
|
+
import type { Route, DefaultDependencies } from "@real-router/core";
|
|
8
|
+
import type { RouteTree } from "route-tree";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Route Property Validation
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
function validateForwardToProperty(forwardTo: unknown, fullName: string): void {
|
|
15
|
+
if (forwardTo === undefined) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof forwardTo === "function") {
|
|
20
|
+
const isNativeAsync =
|
|
21
|
+
(forwardTo as { constructor: { name: string } }).constructor.name ===
|
|
22
|
+
"AsyncFunction";
|
|
23
|
+
const isTranspiledAsync = forwardTo.toString().includes("__awaiter");
|
|
24
|
+
|
|
25
|
+
if (isNativeAsync || isTranspiledAsync) {
|
|
26
|
+
throw new TypeError(
|
|
27
|
+
`[router.addRoute] forwardTo callback cannot be async for route "${fullName}". ` +
|
|
28
|
+
`Async functions break matchPath/buildPath.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateRouteProperties<
|
|
35
|
+
Dependencies extends DefaultDependencies,
|
|
36
|
+
>(route: Route<Dependencies>, fullName: string): void {
|
|
37
|
+
if (
|
|
38
|
+
route.canActivate !== undefined &&
|
|
39
|
+
typeof route.canActivate !== "function"
|
|
40
|
+
) {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
`[router.addRoute] canActivate must be a function for route "${fullName}", ` +
|
|
43
|
+
`got ${getTypeDescription(route.canActivate)}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (
|
|
48
|
+
route.canDeactivate !== undefined &&
|
|
49
|
+
typeof route.canDeactivate !== "function"
|
|
50
|
+
) {
|
|
51
|
+
throw new TypeError(
|
|
52
|
+
`[router.addRoute] canDeactivate must be a function for route "${fullName}", ` +
|
|
53
|
+
`got ${getTypeDescription(route.canDeactivate)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (route.defaultParams !== undefined) {
|
|
58
|
+
const params: unknown = route.defaultParams;
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
params === null ||
|
|
62
|
+
typeof params !== "object" ||
|
|
63
|
+
Array.isArray(params)
|
|
64
|
+
) {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`[router.addRoute] defaultParams must be an object for route "${fullName}", ` +
|
|
67
|
+
`got ${getTypeDescription(route.defaultParams)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (route.decodeParams?.constructor.name === "AsyncFunction") {
|
|
73
|
+
throw new TypeError(
|
|
74
|
+
`[router.addRoute] decodeParams cannot be async for route "${fullName}". Async functions break matchPath/buildPath.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (route.encodeParams?.constructor.name === "AsyncFunction") {
|
|
79
|
+
throw new TypeError(
|
|
80
|
+
`[router.addRoute] encodeParams cannot be async for route "${fullName}". Async functions break matchPath/buildPath.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
validateForwardToProperty(route.forwardTo, fullName);
|
|
85
|
+
|
|
86
|
+
if (route.children) {
|
|
87
|
+
for (const child of route.children) {
|
|
88
|
+
const childFullName = `${fullName}.${child.name}`;
|
|
89
|
+
|
|
90
|
+
validateRouteProperties(child, childFullName);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// ForwardTo Validation
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
function extractParamsFromPath(path: string): Set<string> {
|
|
100
|
+
const params = new Set<string>();
|
|
101
|
+
const paramRegex = /[*:]([A-Z_a-z]\w*)/g;
|
|
102
|
+
let match;
|
|
103
|
+
|
|
104
|
+
while ((match = paramRegex.exec(path)) !== null) {
|
|
105
|
+
params.add(match[1]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return params;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractParamsFromPaths(paths: readonly string[]): Set<string> {
|
|
112
|
+
const params = new Set<string>();
|
|
113
|
+
|
|
114
|
+
for (const path of paths) {
|
|
115
|
+
for (const param of extractParamsFromPath(path)) {
|
|
116
|
+
params.add(param);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return params;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function collectPathsToRoute<Dependencies extends DefaultDependencies>(
|
|
124
|
+
routes: readonly Route<Dependencies>[],
|
|
125
|
+
routeName: string,
|
|
126
|
+
parentName = "",
|
|
127
|
+
paths: string[] = [],
|
|
128
|
+
): string[] {
|
|
129
|
+
for (const route of routes) {
|
|
130
|
+
const fullName = parentName ? `${parentName}.${route.name}` : route.name;
|
|
131
|
+
const currentPaths = [...paths, route.path];
|
|
132
|
+
|
|
133
|
+
if (fullName === routeName) {
|
|
134
|
+
return currentPaths;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (route.children && routeName.startsWith(`${fullName}.`)) {
|
|
138
|
+
return collectPathsToRoute(
|
|
139
|
+
route.children,
|
|
140
|
+
routeName,
|
|
141
|
+
fullName,
|
|
142
|
+
currentPaths,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* v8 ignore next -- @preserve unreachable: callers validate existence */
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[internal] collectPathsToRoute: route "${routeName}" not found`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function collectRouteNames<Dependencies extends DefaultDependencies>(
|
|
154
|
+
routes: readonly Route<Dependencies>[],
|
|
155
|
+
parentName = "",
|
|
156
|
+
): Set<string> {
|
|
157
|
+
const names = new Set<string>();
|
|
158
|
+
|
|
159
|
+
for (const route of routes) {
|
|
160
|
+
const fullName = parentName ? `${parentName}.${route.name}` : route.name;
|
|
161
|
+
|
|
162
|
+
names.add(fullName);
|
|
163
|
+
|
|
164
|
+
if (route.children) {
|
|
165
|
+
for (const childName of collectRouteNames(route.children, fullName)) {
|
|
166
|
+
names.add(childName);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return names;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectForwardMappings<Dependencies extends DefaultDependencies>(
|
|
175
|
+
routes: readonly Route<Dependencies>[],
|
|
176
|
+
parentName = "",
|
|
177
|
+
): Map<string, string> {
|
|
178
|
+
const mappings = new Map<string, string>();
|
|
179
|
+
|
|
180
|
+
for (const route of routes) {
|
|
181
|
+
const fullName = parentName ? `${parentName}.${route.name}` : route.name;
|
|
182
|
+
|
|
183
|
+
if (route.forwardTo && typeof route.forwardTo === "string") {
|
|
184
|
+
mappings.set(fullName, route.forwardTo);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (route.children) {
|
|
188
|
+
for (const [key, value] of collectForwardMappings(
|
|
189
|
+
route.children,
|
|
190
|
+
fullName,
|
|
191
|
+
)) {
|
|
192
|
+
mappings.set(key, value);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return mappings;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getRequiredParams(segments: readonly RouteTree[]): Set<string> {
|
|
201
|
+
const params = new Set<string>();
|
|
202
|
+
|
|
203
|
+
for (const segment of segments) {
|
|
204
|
+
for (const param of segment.paramMeta.urlParams) {
|
|
205
|
+
params.add(param);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const param of segment.paramMeta.spatParams) {
|
|
209
|
+
params.add(param);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return params;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function routeExistsInTree(tree: RouteTree, routeName: string): boolean {
|
|
217
|
+
const segments = routeName.split(".");
|
|
218
|
+
let current: RouteTree | undefined = tree;
|
|
219
|
+
|
|
220
|
+
for (const segment of segments) {
|
|
221
|
+
current = current.children.get(segment);
|
|
222
|
+
|
|
223
|
+
if (!current) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getTargetParams<Dependencies extends DefaultDependencies>(
|
|
232
|
+
targetRoute: string,
|
|
233
|
+
existsInTree: boolean,
|
|
234
|
+
tree: RouteTree,
|
|
235
|
+
routes: readonly Route<Dependencies>[],
|
|
236
|
+
): Set<string> {
|
|
237
|
+
if (existsInTree) {
|
|
238
|
+
/* v8 ignore next -- @preserve: ?? fallback unreachable — existsInTree guarantees non-null */
|
|
239
|
+
return getRequiredParams(getSegmentsByName(tree, targetRoute) ?? []);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return extractParamsFromPaths(collectPathsToRoute(routes, targetRoute));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateSingleForward<Dependencies extends DefaultDependencies>(
|
|
246
|
+
fromRoute: string,
|
|
247
|
+
targetRoute: string,
|
|
248
|
+
routes: readonly Route<Dependencies>[],
|
|
249
|
+
batchNames: Set<string>,
|
|
250
|
+
tree: RouteTree,
|
|
251
|
+
): void {
|
|
252
|
+
const existsInTree = routeExistsInTree(tree, targetRoute);
|
|
253
|
+
const existsInBatch = batchNames.has(targetRoute);
|
|
254
|
+
|
|
255
|
+
if (!existsInTree && !existsInBatch) {
|
|
256
|
+
throw new ReferenceError(
|
|
257
|
+
`[router.addRoute] forwardTo target "${targetRoute}" does not exist ` +
|
|
258
|
+
`for route "${fromRoute}"`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const fromParams = extractParamsFromPaths(
|
|
263
|
+
collectPathsToRoute(routes, fromRoute),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const toParams = getTargetParams(targetRoute, existsInTree, tree, routes);
|
|
267
|
+
|
|
268
|
+
const missingParams = [...toParams].filter((param) => !fromParams.has(param));
|
|
269
|
+
|
|
270
|
+
if (missingParams.length > 0) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`[router.addRoute] forwardTo target "${targetRoute}" requires params ` +
|
|
273
|
+
`[${missingParams.join(", ")}] that are not available in source route "${fromRoute}"`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function validateForwardToTargets<
|
|
279
|
+
Dependencies extends DefaultDependencies,
|
|
280
|
+
>(
|
|
281
|
+
routes: readonly Route<Dependencies>[],
|
|
282
|
+
existingForwardMap: Record<string, string>,
|
|
283
|
+
tree: RouteTree,
|
|
284
|
+
): void {
|
|
285
|
+
const batchNames = collectRouteNames(routes);
|
|
286
|
+
const batchForwards = collectForwardMappings(routes);
|
|
287
|
+
|
|
288
|
+
const combinedForwardMap: Record<string, string> = { ...existingForwardMap };
|
|
289
|
+
|
|
290
|
+
for (const [from, to] of batchForwards) {
|
|
291
|
+
combinedForwardMap[from] = to;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const [fromRoute, targetRoute] of batchForwards) {
|
|
295
|
+
validateSingleForward(fromRoute, targetRoute, routes, batchNames, tree);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const fromRoute of Object.keys(combinedForwardMap)) {
|
|
299
|
+
resolveForwardChain(fromRoute, combinedForwardMap);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/lifecycle.ts
|
|
2
|
+
|
|
3
|
+
import { logger } from "@real-router/logger";
|
|
4
|
+
import { isBoolean, getTypeDescription } from "type-guards";
|
|
5
|
+
|
|
6
|
+
import { computeThresholds } from "../helpers";
|
|
7
|
+
|
|
8
|
+
import type { GuardFnFactory, DefaultDependencies } from "@real-router/core";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_LIFECYCLE_HANDLERS = 200;
|
|
11
|
+
|
|
12
|
+
export function validateHandler<D extends DefaultDependencies>(
|
|
13
|
+
handler: unknown,
|
|
14
|
+
methodName: string,
|
|
15
|
+
): asserts handler is GuardFnFactory<D> | boolean {
|
|
16
|
+
if (!isBoolean(handler) && typeof handler !== "function") {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
`[router.${methodName}] Handler must be a boolean or factory function, ` +
|
|
19
|
+
`got ${getTypeDescription(handler)}`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateNotRegistering(
|
|
25
|
+
isRegistering: boolean,
|
|
26
|
+
name: string,
|
|
27
|
+
methodName: string,
|
|
28
|
+
): void {
|
|
29
|
+
if (isRegistering) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[router.${methodName}] Cannot modify route "${name}" during its own registration`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateHandlerLimit(
|
|
37
|
+
currentCount: number,
|
|
38
|
+
methodName: string,
|
|
39
|
+
maxLifecycleHandlers: number = DEFAULT_MAX_LIFECYCLE_HANDLERS,
|
|
40
|
+
): void {
|
|
41
|
+
if (maxLifecycleHandlers === 0) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (currentCount >= maxLifecycleHandlers) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`[router.${methodName}] Lifecycle handler limit exceeded (${maxLifecycleHandlers}). ` +
|
|
48
|
+
`This indicates too many routes with individual handlers. ` +
|
|
49
|
+
`Consider using plugins for cross-cutting concerns.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateLifecycleCountThresholds(
|
|
55
|
+
count: number,
|
|
56
|
+
methodName: string,
|
|
57
|
+
maxHandlers: number,
|
|
58
|
+
): void {
|
|
59
|
+
if (maxHandlers === 0) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { warn, error } = computeThresholds(maxHandlers);
|
|
64
|
+
|
|
65
|
+
if (count >= error) {
|
|
66
|
+
logger.error(
|
|
67
|
+
`router.${methodName}`,
|
|
68
|
+
`${count} lifecycle handlers registered! This is excessive. Hard limit at ${maxHandlers}.`,
|
|
69
|
+
);
|
|
70
|
+
} else if (count >= warn) {
|
|
71
|
+
logger.warn(
|
|
72
|
+
`router.${methodName}`,
|
|
73
|
+
`${count} lifecycle handlers registered. Consider consolidating logic.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function warnOverwrite(
|
|
79
|
+
name: string,
|
|
80
|
+
type: string,
|
|
81
|
+
methodName: string,
|
|
82
|
+
): void {
|
|
83
|
+
logger.warn(
|
|
84
|
+
`router.${methodName}`,
|
|
85
|
+
`Overwriting existing ${type} handler for route "${name}"`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function warnAsyncGuardSync(name: string, methodName: string): void {
|
|
90
|
+
logger.warn(
|
|
91
|
+
`router.${methodName}`,
|
|
92
|
+
`Guard for "${name}" returned a Promise. Sync check cannot resolve async guards — returning false.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/navigation.ts
|
|
2
|
+
|
|
3
|
+
import { getTypeDescription, isNavigationOptions, isParams } from "type-guards";
|
|
4
|
+
|
|
5
|
+
import type { NavigationOptions } from "@real-router/core";
|
|
6
|
+
|
|
7
|
+
export function validateNavigateArgs(name: unknown): asserts name is string {
|
|
8
|
+
if (typeof name !== "string") {
|
|
9
|
+
throw new TypeError(
|
|
10
|
+
`[router.navigate] Invalid route name: expected string, got ${getTypeDescription(name)}`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateNavigateToDefaultArgs(opts: unknown): void {
|
|
16
|
+
if (opts !== undefined && (typeof opts !== "object" || opts === null)) {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
`[router.navigateToDefault] Invalid options: ${getTypeDescription(opts)}. Expected NavigationOptions object.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateNavigationOptions(
|
|
24
|
+
opts: unknown,
|
|
25
|
+
methodName: string,
|
|
26
|
+
): asserts opts is NavigationOptions {
|
|
27
|
+
if (!isNavigationOptions(opts)) {
|
|
28
|
+
throw new TypeError(
|
|
29
|
+
`[router.${methodName}] Invalid options: ${getTypeDescription(opts)}. Expected NavigationOptions object.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateNavigateParams(
|
|
35
|
+
params: unknown,
|
|
36
|
+
methodName: string,
|
|
37
|
+
): void {
|
|
38
|
+
if (params !== undefined && !isParams(params)) {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
`[router.${methodName}] params must be a plain object, got ${getTypeDescription(params)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateStartArgs(path: unknown): void {
|
|
46
|
+
// undefined is allowed — browser-plugin injects path via interceptor AFTER facade validation
|
|
47
|
+
if (path !== undefined && typeof path !== "string") {
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
`[router.start] path must be a string, got ${getTypeDescription(path)}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (typeof path === "string" && path !== "" && !path.startsWith("/")) {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
`[router.start] path must start with "/", got "${path}".`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|