@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,578 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/routes.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DX-only validator functions for RoutesNamespace.
|
|
5
|
+
* Copied from packages/core/src/namespaces/RoutesNamespace/validators.ts
|
|
6
|
+
* (excludes validateRemoveRoute/validateClearRoutes — those are in routeGuards.ts)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveForwardChain } from "@real-router/core";
|
|
10
|
+
import { validateRoute } from "route-tree";
|
|
11
|
+
import {
|
|
12
|
+
isString,
|
|
13
|
+
validateRouteName,
|
|
14
|
+
isParams,
|
|
15
|
+
getTypeDescription,
|
|
16
|
+
} from "type-guards";
|
|
17
|
+
|
|
18
|
+
import { validateForwardToTargets, validateRouteProperties } from "./forwardTo";
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
Route,
|
|
22
|
+
RouteConfigUpdate,
|
|
23
|
+
DefaultDependencies,
|
|
24
|
+
Params,
|
|
25
|
+
} from "@real-router/core";
|
|
26
|
+
import type { Matcher, RouteTree } from "route-tree";
|
|
27
|
+
|
|
28
|
+
// Internal constant (matches core's INTERNAL_ROUTE_PREFIX)
|
|
29
|
+
const INTERNAL_ROUTE_PREFIX = "@@";
|
|
30
|
+
|
|
31
|
+
// Minimal local type — only the forwardMap field used by this file
|
|
32
|
+
// (RouteConfig from core is not exported from @real-router/core public API)
|
|
33
|
+
interface RouteConfigLike {
|
|
34
|
+
forwardMap: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Local type - matches ForwardToCallback from @real-router/types
|
|
38
|
+
// (@real-router/types is not a direct dependency of this package)
|
|
39
|
+
type ForwardToCallback<
|
|
40
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
41
|
+
> = (
|
|
42
|
+
getDependency: <K extends keyof Dependencies>(name: K) => Dependencies[K],
|
|
43
|
+
params: Params,
|
|
44
|
+
) => string;
|
|
45
|
+
|
|
46
|
+
export function throwIfInternalRoute(name: string, methodName: string): void {
|
|
47
|
+
if (name.startsWith(INTERNAL_ROUTE_PREFIX)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[router.${methodName}] Route name "${name}" uses the reserved "${INTERNAL_ROUTE_PREFIX}" prefix. Routes with this prefix are internal and cannot be modified through the public API.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function throwIfInternalRouteInArray(
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Route type
|
|
56
|
+
routes: readonly Route<any>[],
|
|
57
|
+
methodName: string,
|
|
58
|
+
): void {
|
|
59
|
+
for (const route of routes) {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety
|
|
61
|
+
if (route && typeof route === "object" && typeof route.name === "string") {
|
|
62
|
+
throwIfInternalRoute(route.name, methodName);
|
|
63
|
+
|
|
64
|
+
if (route.children) {
|
|
65
|
+
throwIfInternalRouteInArray(route.children, methodName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates removeRoute arguments.
|
|
73
|
+
*/
|
|
74
|
+
export function validateRemoveRouteArgs(name: unknown): asserts name is string {
|
|
75
|
+
validateRouteName(name, "removeRoute");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateSetRootPathArgs(
|
|
79
|
+
rootPath: unknown,
|
|
80
|
+
): asserts rootPath is string {
|
|
81
|
+
if (typeof rootPath !== "string") {
|
|
82
|
+
throw new TypeError(
|
|
83
|
+
`[router.setRootPath] rootPath must be a string, got ${getTypeDescription(rootPath)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isAsyncFunction(fn: unknown): boolean {
|
|
89
|
+
return (
|
|
90
|
+
(fn as { constructor: { name: string } }).constructor.name ===
|
|
91
|
+
"AsyncFunction" || String(fn).includes("__awaiter")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function guardRouteCallbacks(route: unknown): void {
|
|
96
|
+
const routeObj = route as { canActivate?: unknown; canDeactivate?: unknown };
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
routeObj.canActivate !== undefined &&
|
|
100
|
+
typeof routeObj.canActivate !== "function"
|
|
101
|
+
) {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
`[router.addRoute] canActivate must be a function, got ${getTypeDescription(routeObj.canActivate)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
routeObj.canDeactivate !== undefined &&
|
|
109
|
+
typeof routeObj.canDeactivate !== "function"
|
|
110
|
+
) {
|
|
111
|
+
throw new TypeError(
|
|
112
|
+
`[router.addRoute] canDeactivate must be a function, got ${getTypeDescription(routeObj.canDeactivate)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function guardNoAsyncCallbacks(route: unknown): void {
|
|
118
|
+
const routeObj = route as {
|
|
119
|
+
decodeParams?: unknown;
|
|
120
|
+
encodeParams?: unknown;
|
|
121
|
+
forwardTo?: unknown;
|
|
122
|
+
name?: unknown;
|
|
123
|
+
};
|
|
124
|
+
const routeName = routeObj.name;
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
routeObj.decodeParams !== undefined &&
|
|
128
|
+
isAsyncFunction(routeObj.decodeParams)
|
|
129
|
+
) {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
`[router.addRoute] decodeParams cannot be async for route "${String(routeName)}"`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
routeObj.encodeParams !== undefined &&
|
|
137
|
+
isAsyncFunction(routeObj.encodeParams)
|
|
138
|
+
) {
|
|
139
|
+
throw new TypeError(
|
|
140
|
+
`[router.addRoute] encodeParams cannot be async for route "${String(routeName)}"`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
typeof routeObj.forwardTo === "function" &&
|
|
146
|
+
isAsyncFunction(routeObj.forwardTo)
|
|
147
|
+
) {
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
`[router.addRoute] forwardTo callback cannot be async for route "${String(routeName)}"`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates addRoute arguments (route structure and properties).
|
|
156
|
+
* State-dependent validation (duplicates, tree) happens in instance method.
|
|
157
|
+
*/
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Route type
|
|
159
|
+
export function validateAddRouteArgs(routes: readonly Route<any>[]): void {
|
|
160
|
+
for (const route of routes) {
|
|
161
|
+
// First check if route is an object (before accessing route.name)
|
|
162
|
+
// Runtime check for invalid types passed via `as any`
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check
|
|
164
|
+
if (route === null || typeof route !== "object" || Array.isArray(route)) {
|
|
165
|
+
throw new TypeError(
|
|
166
|
+
`[router.addRoute] Route must be an object, got ${getTypeDescription(route)}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate route properties (canActivate, canDeactivate, defaultParams, async checks)
|
|
171
|
+
// Note: validateRouteProperties handles children recursively
|
|
172
|
+
validateRouteProperties(route, route.name);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Validates parent option for addRoute.
|
|
178
|
+
*/
|
|
179
|
+
export function validateParentOption(
|
|
180
|
+
parent: unknown,
|
|
181
|
+
): asserts parent is string {
|
|
182
|
+
if (typeof parent !== "string" || parent === "") {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
`[router.addRoute] parent option must be a non-empty string, got ${getTypeDescription(parent)}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Validate parent is a valid route name format (can contain dots — it's a fullName reference)
|
|
189
|
+
validateRouteName(parent, "addRoute");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validates isActiveRoute arguments.
|
|
194
|
+
*/
|
|
195
|
+
export function validateIsActiveRouteArgs(
|
|
196
|
+
name: unknown,
|
|
197
|
+
params: unknown,
|
|
198
|
+
strictEquality: unknown,
|
|
199
|
+
ignoreQueryParams: unknown,
|
|
200
|
+
): void {
|
|
201
|
+
// Validate name - non-string throws
|
|
202
|
+
if (!isString(name)) {
|
|
203
|
+
throw new TypeError(
|
|
204
|
+
`[router.isActiveRoute] name must be a string, got ${typeof name}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Validate params if provided
|
|
209
|
+
if (params !== undefined && !isParams(params)) {
|
|
210
|
+
throw new TypeError(
|
|
211
|
+
`[router.isActiveRoute] params must be a plain object, got ${getTypeDescription(params)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Validate strictEquality if provided
|
|
216
|
+
if (strictEquality !== undefined && typeof strictEquality !== "boolean") {
|
|
217
|
+
throw new TypeError(
|
|
218
|
+
`[router.isActiveRoute] strictEquality must be a boolean, got ${typeof strictEquality}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate ignoreQueryParams if provided
|
|
223
|
+
if (
|
|
224
|
+
ignoreQueryParams !== undefined &&
|
|
225
|
+
typeof ignoreQueryParams !== "boolean"
|
|
226
|
+
) {
|
|
227
|
+
throw new TypeError(
|
|
228
|
+
`[router.isActiveRoute] ignoreQueryParams must be a boolean, got ${typeof ignoreQueryParams}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validates forwardState/buildState arguments.
|
|
235
|
+
*/
|
|
236
|
+
export function validateStateBuilderArgs(
|
|
237
|
+
routeName: unknown,
|
|
238
|
+
routeParams: unknown,
|
|
239
|
+
methodName: string,
|
|
240
|
+
): void {
|
|
241
|
+
if (!isString(routeName)) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
`[router.${methodName}] Invalid routeName: ${getTypeDescription(routeName)}. Expected string.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!isParams(routeParams)) {
|
|
248
|
+
throw new TypeError(
|
|
249
|
+
`[router.${methodName}] Invalid routeParams: ${getTypeDescription(routeParams)}. Expected plain object.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validates updateRoute basic arguments (name and updates object structure).
|
|
256
|
+
* Does NOT read property values to allow caller to cache them first.
|
|
257
|
+
*/
|
|
258
|
+
export function validateUpdateRouteBasicArgs<
|
|
259
|
+
Dependencies extends DefaultDependencies,
|
|
260
|
+
>(
|
|
261
|
+
name: unknown,
|
|
262
|
+
updates: unknown,
|
|
263
|
+
): asserts updates is RouteConfigUpdate<Dependencies> {
|
|
264
|
+
// Validate name
|
|
265
|
+
validateRouteName(name, "updateRoute");
|
|
266
|
+
|
|
267
|
+
if (name === "") {
|
|
268
|
+
throw new ReferenceError(
|
|
269
|
+
`[router.updateRoute] Invalid name: empty string. Cannot update root node.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Validate updates is not null
|
|
274
|
+
|
|
275
|
+
if (updates === null) {
|
|
276
|
+
throw new TypeError(
|
|
277
|
+
`[router.updateRoute] updates must be an object, got null`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Validate updates is an object (not array)
|
|
282
|
+
if (typeof updates !== "object" || Array.isArray(updates)) {
|
|
283
|
+
throw new TypeError(
|
|
284
|
+
`[router.updateRoute] updates must be an object, got ${getTypeDescription(updates)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Asserts that a function is not async (native or transpiled).
|
|
291
|
+
* Checks both constructor name and toString() for __awaiter pattern.
|
|
292
|
+
*/
|
|
293
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- needs constructor.name access
|
|
294
|
+
function assertNotAsync(value: Function, paramName: string): void {
|
|
295
|
+
if (
|
|
296
|
+
(value as { constructor: { name: string } }).constructor.name ===
|
|
297
|
+
"AsyncFunction" ||
|
|
298
|
+
(value as { toString: () => string }).toString().includes("__awaiter")
|
|
299
|
+
) {
|
|
300
|
+
throw new TypeError(
|
|
301
|
+
`[router.updateRoute] ${paramName} cannot be an async function`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validates that a value is a non-async function, if provided.
|
|
308
|
+
*/
|
|
309
|
+
function validateFunctionParam(value: unknown, paramName: string): void {
|
|
310
|
+
if (value === undefined || value === null) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (typeof value !== "function") {
|
|
315
|
+
throw new TypeError(
|
|
316
|
+
`[router.updateRoute] ${paramName} must be a function or null, got ${typeof value}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
assertNotAsync(value, paramName);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Validates updateRoute property types using pre-cached values.
|
|
325
|
+
* Called AFTER properties are cached to ensure getters are called only once.
|
|
326
|
+
*/
|
|
327
|
+
export function validateUpdateRoutePropertyTypes(
|
|
328
|
+
forwardTo: unknown,
|
|
329
|
+
defaultParams: unknown,
|
|
330
|
+
decodeParams: unknown,
|
|
331
|
+
encodeParams: unknown,
|
|
332
|
+
): void {
|
|
333
|
+
// Validate forwardTo type (existence check is done by instance method)
|
|
334
|
+
if (forwardTo !== undefined && forwardTo !== null) {
|
|
335
|
+
if (typeof forwardTo !== "string" && typeof forwardTo !== "function") {
|
|
336
|
+
throw new TypeError(
|
|
337
|
+
`[router.updateRoute] forwardTo must be a string, function, or null, got ${getTypeDescription(forwardTo)}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof forwardTo === "function") {
|
|
342
|
+
assertNotAsync(forwardTo, "forwardTo callback");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Validate defaultParams
|
|
347
|
+
if (
|
|
348
|
+
defaultParams !== undefined &&
|
|
349
|
+
defaultParams !== null &&
|
|
350
|
+
(typeof defaultParams !== "object" || Array.isArray(defaultParams))
|
|
351
|
+
) {
|
|
352
|
+
throw new TypeError(
|
|
353
|
+
`[router.updateRoute] defaultParams must be an object or null, got ${getTypeDescription(defaultParams)}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
validateFunctionParam(decodeParams, "decodeParams");
|
|
358
|
+
validateFunctionParam(encodeParams, "encodeParams");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Validates buildPath arguments.
|
|
363
|
+
*/
|
|
364
|
+
export function validateBuildPathArgs(route: unknown): asserts route is string {
|
|
365
|
+
if (!isString(route) || route === "") {
|
|
366
|
+
throw new TypeError(
|
|
367
|
+
`[router.buildPath] route must be a non-empty string, got ${typeof route === "string" ? '""' : typeof route}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Validates matchPath arguments.
|
|
374
|
+
*/
|
|
375
|
+
export function validateMatchPathArgs(path: unknown): asserts path is string {
|
|
376
|
+
if (!isString(path)) {
|
|
377
|
+
throw new TypeError(
|
|
378
|
+
`[router.matchPath] path must be a string, got ${typeof path}`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validates shouldUpdateNode arguments.
|
|
385
|
+
*/
|
|
386
|
+
export function validateShouldUpdateNodeArgs(
|
|
387
|
+
nodeName: unknown,
|
|
388
|
+
): asserts nodeName is string {
|
|
389
|
+
if (!isString(nodeName)) {
|
|
390
|
+
throw new TypeError(
|
|
391
|
+
`[router.shouldUpdateNode] nodeName must be a string, got ${typeof nodeName}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Validates routes for addition to the router.
|
|
398
|
+
* Checks parent existence, duplicates, and forwardTo targets/cycles.
|
|
399
|
+
*
|
|
400
|
+
* @param routes - Routes to validate
|
|
401
|
+
* @param tree - Current route tree (optional for initial validation)
|
|
402
|
+
* @param forwardMap - Current forward map for cycle detection
|
|
403
|
+
* @param parentName - Optional parent route fullName for nesting via addRoute({ parent })
|
|
404
|
+
*/
|
|
405
|
+
export function validateRoutes<Dependencies extends DefaultDependencies>(
|
|
406
|
+
routes: Route<Dependencies>[],
|
|
407
|
+
tree?: RouteTree,
|
|
408
|
+
forwardMap?: Record<string, string>,
|
|
409
|
+
parentName?: string,
|
|
410
|
+
): void {
|
|
411
|
+
// Validate parent route exists in tree
|
|
412
|
+
if (parentName && tree) {
|
|
413
|
+
let node: RouteTree | undefined = tree;
|
|
414
|
+
|
|
415
|
+
for (const segment of parentName.split(".")) {
|
|
416
|
+
node = node.children.get(segment);
|
|
417
|
+
|
|
418
|
+
if (!node) {
|
|
419
|
+
throw new ReferenceError(
|
|
420
|
+
`[router.addRoute] Parent route "${parentName}" does not exist`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Tracking sets for duplicate detection
|
|
427
|
+
const seenNames = new Set<string>();
|
|
428
|
+
const seenPathsByParent = new Map<string, Set<string>>();
|
|
429
|
+
|
|
430
|
+
for (const route of routes) {
|
|
431
|
+
validateRoute(
|
|
432
|
+
route,
|
|
433
|
+
"addRoute",
|
|
434
|
+
tree,
|
|
435
|
+
parentName ?? "",
|
|
436
|
+
seenNames,
|
|
437
|
+
seenPathsByParent,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (tree && forwardMap) {
|
|
442
|
+
validateForwardToTargets(routes, forwardMap, tree);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ============================================================================
|
|
447
|
+
// Instance-level validators (moved from routesCrud.ts)
|
|
448
|
+
// ============================================================================
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Collects URL params from segments into a Set.
|
|
452
|
+
*/
|
|
453
|
+
function collectUrlParams(segments: readonly RouteTree[]): Set<string> {
|
|
454
|
+
const params = new Set<string>();
|
|
455
|
+
|
|
456
|
+
for (const segment of segments) {
|
|
457
|
+
for (const param of segment.paramMeta.urlParams) {
|
|
458
|
+
params.add(param);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return params;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Validates that forwardTo target doesn't require params that source doesn't have.
|
|
467
|
+
*
|
|
468
|
+
* @param sourceName - Source route name
|
|
469
|
+
* @param targetName - Target route name
|
|
470
|
+
* @param matcher - Current route matcher
|
|
471
|
+
*/
|
|
472
|
+
export function validateForwardToParamCompatibility(
|
|
473
|
+
sourceName: string,
|
|
474
|
+
targetName: string,
|
|
475
|
+
matcher: Matcher,
|
|
476
|
+
): void {
|
|
477
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
478
|
+
const sourceSegments = matcher.getSegmentsByName(
|
|
479
|
+
sourceName,
|
|
480
|
+
)! as readonly RouteTree[];
|
|
481
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
482
|
+
const targetSegments = matcher.getSegmentsByName(
|
|
483
|
+
targetName,
|
|
484
|
+
)! as readonly RouteTree[];
|
|
485
|
+
|
|
486
|
+
// Get source URL params as a Set for O(1) lookup
|
|
487
|
+
const sourceParams = collectUrlParams(sourceSegments);
|
|
488
|
+
|
|
489
|
+
// Build target URL params array (inline — no separate helper needed)
|
|
490
|
+
const targetParams: string[] = [];
|
|
491
|
+
|
|
492
|
+
for (const segment of targetSegments) {
|
|
493
|
+
for (const param of segment.paramMeta.urlParams) {
|
|
494
|
+
targetParams.push(param);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Check if target requires params that source doesn't have
|
|
499
|
+
const missingParams = targetParams.filter(
|
|
500
|
+
(param) => !sourceParams.has(param),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
if (missingParams.length > 0) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`[router.addRoute] forwardTo target "${targetName}" requires params ` +
|
|
506
|
+
`[${missingParams.join(", ")}] that are not available in source route "${sourceName}"`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Validates that adding forwardTo doesn't create a cycle.
|
|
513
|
+
* Creates a test map with the new entry and uses resolveForwardChain
|
|
514
|
+
* to detect cycles before any mutation happens.
|
|
515
|
+
*
|
|
516
|
+
* @param sourceName - Source route name
|
|
517
|
+
* @param targetName - Target route name
|
|
518
|
+
* @param config - Current route config (forwardMap read-only in this call)
|
|
519
|
+
*/
|
|
520
|
+
export function validateForwardToCycle(
|
|
521
|
+
sourceName: string,
|
|
522
|
+
targetName: string,
|
|
523
|
+
config: RouteConfigLike,
|
|
524
|
+
): void {
|
|
525
|
+
// Create a test map with the new entry to validate BEFORE mutation
|
|
526
|
+
const testMap = {
|
|
527
|
+
...config.forwardMap,
|
|
528
|
+
[sourceName]: targetName,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// resolveForwardChain will throw if cycle is detected or max depth exceeded
|
|
532
|
+
resolveForwardChain(sourceName, testMap);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Validates updateRoute instance-level constraints (route existence, forwardTo).
|
|
537
|
+
*
|
|
538
|
+
* @param name - Route name (already validated by static method)
|
|
539
|
+
* @param forwardTo - Cached forwardTo value
|
|
540
|
+
* @param hasRoute - Function to check route existence
|
|
541
|
+
* @param matcher - Current route matcher
|
|
542
|
+
* @param config - Current route config
|
|
543
|
+
*/
|
|
544
|
+
export function validateUpdateRoute<
|
|
545
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
546
|
+
>(
|
|
547
|
+
name: string,
|
|
548
|
+
forwardTo: string | ForwardToCallback<Dependencies> | null | undefined,
|
|
549
|
+
hasRoute: (n: string) => boolean,
|
|
550
|
+
matcher: Matcher,
|
|
551
|
+
config: RouteConfigLike,
|
|
552
|
+
): void {
|
|
553
|
+
// Validate route exists
|
|
554
|
+
if (!hasRoute(name)) {
|
|
555
|
+
throw new ReferenceError(
|
|
556
|
+
`[router.updateRoute] route "${name}" does not exist`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Validate forwardTo target exists and is valid (only for string forwardTo)
|
|
561
|
+
if (
|
|
562
|
+
forwardTo !== undefined &&
|
|
563
|
+
forwardTo !== null &&
|
|
564
|
+
typeof forwardTo === "string"
|
|
565
|
+
) {
|
|
566
|
+
if (!hasRoute(forwardTo)) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`[router.updateRoute] forwardTo target "${forwardTo}" does not exist`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check forwardTo param compatibility
|
|
573
|
+
validateForwardToParamCompatibility(name, forwardTo, matcher);
|
|
574
|
+
|
|
575
|
+
// Check for cycle detection
|
|
576
|
+
validateForwardToCycle(name, forwardTo, config);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/state.ts
|
|
2
|
+
|
|
3
|
+
import { isString, isParams, getTypeDescription } from "type-guards";
|
|
4
|
+
|
|
5
|
+
export function validateMakeStateArgs(
|
|
6
|
+
name: unknown,
|
|
7
|
+
params: unknown,
|
|
8
|
+
path: unknown,
|
|
9
|
+
forceId: unknown,
|
|
10
|
+
): void {
|
|
11
|
+
if (!isString(name)) {
|
|
12
|
+
throw new TypeError(
|
|
13
|
+
`[router.makeState] Invalid name: ${getTypeDescription(name)}. Expected string.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (params !== undefined && !isParams(params)) {
|
|
18
|
+
throw new TypeError(
|
|
19
|
+
`[router.makeState] Invalid params: ${getTypeDescription(params)}. Expected plain object.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (path !== undefined && !isString(path)) {
|
|
24
|
+
throw new TypeError(
|
|
25
|
+
`[router.makeState] Invalid path: ${getTypeDescription(path)}. Expected string.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (forceId !== undefined && typeof forceId !== "number") {
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
`[router.makeState] Invalid forceId: ${getTypeDescription(forceId)}. Expected number.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|