@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.
@@ -0,0 +1,540 @@
1
+ // packages/validation-plugin/src/validators/retrospective.ts
2
+
3
+ import { resolveForwardChain as coreResolveForwardChain } from "@real-router/core";
4
+
5
+ /**
6
+ * Retrospective validators — run AFTER the route tree is already built.
7
+ * Called by the validation plugin at usePlugin() time, in a try/catch with rollback.
8
+ *
9
+ * The plugin is registered AFTER the constructor, so all routes are already in the store.
10
+ * These functions receive store objects as parameters and cast internally using
11
+ * local structural interfaces to avoid tight coupling to core internal types.
12
+ *
13
+ * All parameters are typed as `unknown` — cast internally as needed.
14
+ */
15
+
16
+ // =============================================================================
17
+ // Local structural interfaces (cast-only, not imported from core internals)
18
+ // =============================================================================
19
+
20
+ interface LocalSegmentParamMeta {
21
+ urlParams: readonly string[];
22
+ spatParams: readonly string[];
23
+ }
24
+
25
+ interface LocalRouteSegment {
26
+ paramMeta: LocalSegmentParamMeta;
27
+ }
28
+
29
+ interface LocalRouteTree {
30
+ children: Map<string, LocalRouteTree>;
31
+ paramMeta: LocalSegmentParamMeta;
32
+ }
33
+
34
+ interface LocalRouteMatcher {
35
+ getSegmentsByName: (
36
+ name: string,
37
+ ) => readonly LocalRouteSegment[] | null | undefined;
38
+ }
39
+
40
+ interface LocalRouteConfig {
41
+ forwardMap: Record<string, string>;
42
+ forwardFnMap: Record<string, unknown>;
43
+ defaultParams: Record<string, unknown>;
44
+ decoders: Record<string, unknown>;
45
+ encoders: Record<string, unknown>;
46
+ }
47
+
48
+ interface LocalRouteDefinition {
49
+ name: string;
50
+ path: string;
51
+ children?: LocalRouteDefinition[];
52
+ }
53
+
54
+ interface LocalRoutesStore {
55
+ definitions: LocalRouteDefinition[];
56
+ config: LocalRouteConfig;
57
+ tree: LocalRouteTree;
58
+ matcher: LocalRouteMatcher;
59
+ }
60
+
61
+ interface LocalDependencyLimits {
62
+ maxDependencies: number;
63
+ maxPlugins: number;
64
+ maxListeners: number;
65
+ warnListeners: number;
66
+ maxEventDepth: number;
67
+ maxLifecycleHandlers: number;
68
+ }
69
+
70
+ // =============================================================================
71
+ // Private helpers
72
+ // =============================================================================
73
+
74
+ function assertRoutesStore(store: unknown, fnName: string): LocalRoutesStore {
75
+ if (!store || typeof store !== "object") {
76
+ throw new TypeError(
77
+ `[validation-plugin] ${fnName}: store must be an object`,
78
+ );
79
+ }
80
+
81
+ const storeRecord = store as Record<string, unknown>;
82
+
83
+ if (!Array.isArray(storeRecord.definitions)) {
84
+ throw new TypeError(
85
+ `[validation-plugin] ${fnName}: store.definitions must be an array`,
86
+ );
87
+ }
88
+
89
+ if (!storeRecord.config || typeof storeRecord.config !== "object") {
90
+ throw new TypeError(
91
+ `[validation-plugin] ${fnName}: store.config must be an object`,
92
+ );
93
+ }
94
+
95
+ if (!storeRecord.tree || typeof storeRecord.tree !== "object") {
96
+ throw new TypeError(
97
+ `[validation-plugin] ${fnName}: store.tree must be an object`,
98
+ );
99
+ }
100
+
101
+ return storeRecord as unknown as LocalRoutesStore;
102
+ }
103
+
104
+ function walkDefinitions(
105
+ definitions: LocalRouteDefinition[],
106
+ callback: (def: LocalRouteDefinition, fullName: string) => void,
107
+ parentName = "",
108
+ ): void {
109
+ for (const def of definitions) {
110
+ const fullName = parentName ? `${parentName}.${def.name}` : def.name;
111
+
112
+ callback(def, fullName);
113
+
114
+ if (def.children) {
115
+ walkDefinitions(def.children, callback, fullName);
116
+ }
117
+ }
118
+ }
119
+
120
+ function routeExistsInTree(tree: LocalRouteTree, routeName: string): boolean {
121
+ const segments = routeName.split(".");
122
+ let current: LocalRouteTree | undefined = tree;
123
+
124
+ for (const segment of segments) {
125
+ current = current.children.get(segment);
126
+
127
+ if (!current) {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Wraps core's resolveForwardChain with [validation-plugin] prefix on errors.
137
+ * Core's version throws plain Error messages; retrospective validation
138
+ * needs the [validation-plugin] prefix for consistency.
139
+ */
140
+ function resolveForwardChainWithPrefix(
141
+ startRoute: string,
142
+ forwardMap: Record<string, string>,
143
+ ): string {
144
+ try {
145
+ return coreResolveForwardChain(startRoute, forwardMap);
146
+ } catch (error) {
147
+ throw new Error(`[validation-plugin] ${(error as Error).message}`, {
148
+ cause: error,
149
+ });
150
+ }
151
+ }
152
+
153
+ function collectUrlParams(segments: readonly LocalRouteSegment[]): Set<string> {
154
+ const params = new Set<string>();
155
+
156
+ for (const segment of segments) {
157
+ for (const param of segment.paramMeta.urlParams) {
158
+ params.add(param);
159
+ }
160
+
161
+ for (const param of segment.paramMeta.spatParams) {
162
+ params.add(param);
163
+ }
164
+ }
165
+
166
+ return params;
167
+ }
168
+
169
+ /**
170
+ * Asserts that a function value is not async (native or transpiled).
171
+ * Adapted from: assertNotAsync() in RoutesNamespace/validators.ts
172
+ */
173
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- needs constructor.name access
174
+ function assertNotAsync(fn: Function, label: string, routeName: string): void {
175
+ const function_ = fn as {
176
+ constructor: { name: string };
177
+ toString: () => string;
178
+ };
179
+
180
+ if (
181
+ function_.constructor.name === "AsyncFunction" ||
182
+ function_.toString().includes("__awaiter")
183
+ ) {
184
+ throw new TypeError(
185
+ `[validation-plugin] Route "${routeName}" ${label} cannot be async`,
186
+ );
187
+ }
188
+ }
189
+
190
+ // =============================================================================
191
+ // 1. validateExistingRoutes
192
+ // =============================================================================
193
+
194
+ /**
195
+ * Validates the existing route tree/definitions for structural integrity.
196
+ * Walks all route definitions, checks for duplicate names and invalid structure.
197
+ * Adapted from: validateRoutes() in RoutesNamespace/validators.ts
198
+ *
199
+ * @param store - RoutesStore instance (typed as unknown to avoid core coupling)
200
+ * @throws {TypeError} If store shape is invalid or definitions have structural issues
201
+ * @throws {Error} If duplicate route names are detected
202
+ */
203
+ export function validateExistingRoutes(store: unknown): void {
204
+ const routesStore = assertRoutesStore(store, "validateExistingRoutes");
205
+ const seenNames = new Set<string>();
206
+
207
+ walkDefinitions(routesStore.definitions, (def, fullName) => {
208
+ if (typeof def.name !== "string" || !def.name) {
209
+ throw new TypeError(
210
+ `[validation-plugin] validateExistingRoutes: route has invalid name: ${def.name}`,
211
+ );
212
+ }
213
+
214
+ if (typeof def.path !== "string") {
215
+ throw new TypeError(
216
+ `[validation-plugin] validateExistingRoutes: route "${fullName}" has non-string path (${typeof def.path})`,
217
+ );
218
+ }
219
+
220
+ if (seenNames.has(fullName)) {
221
+ throw new Error(
222
+ `[validation-plugin] validateExistingRoutes: duplicate route name detected: "${fullName}"`,
223
+ );
224
+ }
225
+
226
+ seenNames.add(fullName);
227
+ });
228
+ }
229
+
230
+ // =============================================================================
231
+ // 2. validateForwardToConsistency
232
+ // =============================================================================
233
+
234
+ /**
235
+ * Validates forwardTo consistency across all chains in the store.
236
+ * Checks target existence, param compatibility, and circular chain detection.
237
+ * Adapted from: validateForwardToTargets() in forwardToValidation.ts
238
+ *
239
+ * @param store - RoutesStore instance (typed as unknown to avoid core coupling)
240
+ * @throws {Error} If any forwardTo target does not exist in the tree
241
+ * @throws {Error} If param incompatibility is detected across a forwardTo pair
242
+ * @throws {Error} If a circular forwardTo chain is detected
243
+ */
244
+ export function validateForwardToConsistency(store: unknown): void {
245
+ const routesStore = assertRoutesStore(store, "validateForwardToConsistency");
246
+ const { config, tree, matcher } = routesStore;
247
+
248
+ // Check target existence and param compatibility for each static mapping
249
+ for (const [fromRoute, targetRoute] of Object.entries(config.forwardMap)) {
250
+ if (!routeExistsInTree(tree, targetRoute)) {
251
+ throw new Error(
252
+ `[validation-plugin] validateForwardToConsistency: forwardTo target "${targetRoute}" ` +
253
+ `does not exist in tree (source route: "${fromRoute}")`,
254
+ );
255
+ }
256
+
257
+ // Validate param compatibility: target must not require params absent in source
258
+ const sourceSegments = matcher.getSegmentsByName(fromRoute);
259
+ const targetSegments = matcher.getSegmentsByName(targetRoute);
260
+
261
+ if (sourceSegments && targetSegments) {
262
+ const sourceParams = collectUrlParams(sourceSegments);
263
+ const targetParams = collectUrlParams(targetSegments);
264
+ const missingParams = [...targetParams].filter(
265
+ (param) => !sourceParams.has(param),
266
+ );
267
+
268
+ if (missingParams.length > 0) {
269
+ throw new Error(
270
+ `[validation-plugin] validateForwardToConsistency: forwardTo target "${targetRoute}" ` +
271
+ `requires params [${missingParams.join(", ")}] not available in source route "${fromRoute}"`,
272
+ );
273
+ }
274
+ }
275
+ }
276
+
277
+ // Detect cycles in the full forwardMap (catches multi-hop cycles)
278
+ for (const fromRoute of Object.keys(config.forwardMap)) {
279
+ resolveForwardChainWithPrefix(fromRoute, config.forwardMap);
280
+ }
281
+ }
282
+
283
+ // =============================================================================
284
+ // 3. validateRouteProperties
285
+ // =============================================================================
286
+
287
+ /**
288
+ * Validates route properties for all registered routes in the store.
289
+ * Checks decoder/encoder types, defaultParams structure, and async forwardTo callbacks.
290
+ * Adapted from: validateRouteProperties() in forwardToValidation.ts
291
+ *
292
+ * @param store - RoutesStore instance (typed as unknown to avoid core coupling)
293
+ * @throws {TypeError} If any registered decoder/encoder is not a valid sync function
294
+ * @throws {TypeError} If any defaultParams is not a plain object
295
+ * @throws {TypeError} If any forwardTo callback is async
296
+ */
297
+ export function validateRoutePropertiesStore(store: unknown): void {
298
+ const routesStore = assertRoutesStore(store, "validateRoutePropertiesStore");
299
+ const { config } = routesStore;
300
+
301
+ // Validate decoders — must be non-async functions (sync required for matchPath/buildPath)
302
+ for (const [routeName, decoder] of Object.entries(config.decoders)) {
303
+ if (typeof decoder !== "function") {
304
+ throw new TypeError(
305
+ `[validation-plugin] validateRoutePropertiesStore: route "${routeName}" decoder must be a function, got ${typeof decoder}`,
306
+ );
307
+ }
308
+
309
+ assertNotAsync(decoder, "decoder", routeName);
310
+ }
311
+
312
+ // Validate encoders — must be non-async functions (sync required for matchPath/buildPath)
313
+ for (const [routeName, encoder] of Object.entries(config.encoders)) {
314
+ if (typeof encoder !== "function") {
315
+ throw new TypeError(
316
+ `[validation-plugin] validateRoutePropertiesStore: route "${routeName}" encoder must be a function, got ${typeof encoder}`,
317
+ );
318
+ }
319
+
320
+ assertNotAsync(encoder, "encoder", routeName);
321
+ }
322
+
323
+ // Validate defaultParams — must be plain objects (not null, array, or other types)
324
+ for (const [routeName, params] of Object.entries(config.defaultParams)) {
325
+ if (
326
+ params === null ||
327
+ typeof params !== "object" ||
328
+ Array.isArray(params)
329
+ ) {
330
+ throw new TypeError(
331
+ `[validation-plugin] validateRoutePropertiesStore: route "${routeName}" defaultParams must be a plain object, got ${Array.isArray(params) ? "array" : typeof params}`,
332
+ );
333
+ }
334
+ }
335
+
336
+ // Validate forwardTo function callbacks — must be non-async functions
337
+ for (const [routeName, callback] of Object.entries(config.forwardFnMap)) {
338
+ if (typeof callback !== "function") {
339
+ throw new TypeError(
340
+ `[validation-plugin] validateRoutePropertiesStore: route "${routeName}" forwardTo callback must be a function, got ${typeof callback}`,
341
+ );
342
+ }
343
+
344
+ assertNotAsync(callback, "forwardTo callback", routeName);
345
+ }
346
+ }
347
+
348
+ // =============================================================================
349
+ // 4. validateForwardToTargets
350
+ // =============================================================================
351
+
352
+ /**
353
+ * Validates that all static forwardTo targets exist in the route tree.
354
+ * This is a focused existence-only check (param compat is in validateForwardToConsistency).
355
+ * Adapted from: validateForwardToTargets() in forwardToValidation.ts
356
+ *
357
+ * @param store - RoutesStore instance (typed as unknown to avoid core coupling)
358
+ * @throws {Error} If any forwardTo target route does not exist in the tree
359
+ */
360
+ export function validateForwardToTargetsStore(store: unknown): void {
361
+ const routesStore = assertRoutesStore(store, "validateForwardToTargetsStore");
362
+ const { config, tree } = routesStore;
363
+
364
+ for (const [fromRoute, targetRoute] of Object.entries(config.forwardMap)) {
365
+ if (!routeExistsInTree(tree, targetRoute)) {
366
+ throw new Error(
367
+ `[validation-plugin] validateForwardToTargetsStore: forwardTo target "${targetRoute}" ` +
368
+ `does not exist for route "${fromRoute}"`,
369
+ );
370
+ }
371
+ }
372
+ }
373
+
374
+ // =============================================================================
375
+ // 5. validateDependenciesStructure
376
+ // =============================================================================
377
+
378
+ /**
379
+ * Validates the full structure of the dependencies store.
380
+ * Checks that the dependencies object is valid, has no getters, and limits are well-formed.
381
+ * Adapted from: validateDependenciesObject() in DependenciesNamespace/validators.ts
382
+ *
383
+ * @param deps - DependenciesStore instance (typed as unknown to avoid core coupling)
384
+ * @throws {TypeError} If deps is not an object
385
+ * @throws {TypeError} If deps.dependencies is not a valid plain object (or has getters)
386
+ * @throws {TypeError} If deps.limits is missing or has non-numeric limit values
387
+ */
388
+ export function validateDependenciesStructure(deps: unknown): void {
389
+ if (!deps || typeof deps !== "object") {
390
+ throw new TypeError(
391
+ "[validation-plugin] validateDependenciesStructure: deps must be an object",
392
+ );
393
+ }
394
+
395
+ const depsRecord = deps as Record<string, unknown>;
396
+
397
+ // Validate dependencies field exists and is an object
398
+ if (!depsRecord.dependencies || typeof depsRecord.dependencies !== "object") {
399
+ throw new TypeError(
400
+ "[validation-plugin] validateDependenciesStructure: deps.dependencies must be an object",
401
+ );
402
+ }
403
+
404
+ const dependencies = depsRecord.dependencies as Record<string, unknown>;
405
+
406
+ // Getters can throw, return different values, or have side effects — reject them
407
+ for (const key of Object.keys(dependencies)) {
408
+ if (Object.getOwnPropertyDescriptor(dependencies, key)?.get) {
409
+ throw new TypeError(
410
+ `[validation-plugin] validateDependenciesStructure: dependency "${key}" must not use a getter`,
411
+ );
412
+ }
413
+ }
414
+
415
+ // Validate limits field exists and is an object
416
+ if (!depsRecord.limits || typeof depsRecord.limits !== "object") {
417
+ throw new TypeError(
418
+ "[validation-plugin] validateDependenciesStructure: deps.limits must be an object",
419
+ );
420
+ }
421
+
422
+ const limits = depsRecord.limits as Record<string, unknown>;
423
+ const expectedLimitKeys: (keyof LocalDependencyLimits)[] = [
424
+ "maxDependencies",
425
+ "maxPlugins",
426
+ "maxListeners",
427
+ "warnListeners",
428
+ "maxEventDepth",
429
+ "maxLifecycleHandlers",
430
+ ];
431
+
432
+ for (const key of expectedLimitKeys) {
433
+ if (typeof limits[key] !== "number") {
434
+ throw new TypeError(
435
+ `[validation-plugin] validateDependenciesStructure: deps.limits.${key} must be a number, got ${typeof limits[key]}`,
436
+ );
437
+ }
438
+ }
439
+ }
440
+
441
+ // =============================================================================
442
+ // 6. validateLimitsConsistency
443
+ // =============================================================================
444
+
445
+ /**
446
+ * Validates that actual resource counts don't exceed configured limits.
447
+ * Compares dependency count vs maxDependencies limit from the deps store.
448
+ * Any route-level limit configured in options is also checked against definitions.
449
+ * Adapted from: validateLimits() in OptionsNamespace/validators.ts
450
+ *
451
+ * @param options - Router options (typed as unknown to avoid core coupling)
452
+ * @throws {RangeError} If dependency count reaches or exceeds maxDependencies limit
453
+ * @throws {RangeError} If route count exceeds a configured maxRoutes limit in options
454
+ */
455
+ function extractConfiguredLimits(options: unknown): Record<string, unknown> {
456
+ const opts =
457
+ options && typeof options === "object"
458
+ ? (options as Record<string, unknown>)
459
+ : {};
460
+
461
+ return opts.limits && typeof opts.limits === "object"
462
+ ? (opts.limits as Record<string, unknown>)
463
+ : {};
464
+ }
465
+
466
+ function checkRouteCountLimit(
467
+ store: unknown,
468
+ configuredLimits: Record<string, unknown>,
469
+ ): void {
470
+ if (!store || typeof store !== "object") {
471
+ return;
472
+ }
473
+
474
+ const storeRecord = store as Record<string, unknown>;
475
+
476
+ if (!Array.isArray(storeRecord.definitions)) {
477
+ return;
478
+ }
479
+
480
+ const routeCount = (storeRecord.definitions as unknown[]).length;
481
+ const maxRoutes = configuredLimits.maxRoutes;
482
+
483
+ if (
484
+ typeof maxRoutes === "number" &&
485
+ maxRoutes > 0 &&
486
+ routeCount > maxRoutes
487
+ ) {
488
+ throw new RangeError(
489
+ `[validation-plugin] validateLimitsConsistency: route count (${routeCount}) exceeds configured limit (${maxRoutes})`,
490
+ );
491
+ }
492
+ }
493
+
494
+ function checkDepCountLimit(
495
+ deps: unknown,
496
+ configuredLimits: Record<string, unknown>,
497
+ ): void {
498
+ if (!deps || typeof deps !== "object") {
499
+ return;
500
+ }
501
+
502
+ const depsRecord = deps as Record<string, unknown>;
503
+ const dependencies = depsRecord.dependencies;
504
+ const depsLimits = depsRecord.limits;
505
+
506
+ if (
507
+ !dependencies ||
508
+ typeof dependencies !== "object" ||
509
+ !depsLimits ||
510
+ typeof depsLimits !== "object"
511
+ ) {
512
+ return;
513
+ }
514
+
515
+ const depCount = Object.keys(dependencies).length;
516
+ const limitsRecord = depsLimits as Record<string, unknown>;
517
+ const maxDepsFromOptions = configuredLimits.maxDependencies;
518
+ const maxDepsFromStore = limitsRecord.maxDependencies;
519
+ const maxDeps =
520
+ typeof maxDepsFromOptions === "number"
521
+ ? maxDepsFromOptions
522
+ : maxDepsFromStore;
523
+
524
+ if (typeof maxDeps === "number" && maxDeps > 0 && depCount >= maxDeps) {
525
+ throw new RangeError(
526
+ `[validation-plugin] validateLimitsConsistency: dependency count (${depCount}) reaches or exceeds maxDependencies limit (${maxDeps})`,
527
+ );
528
+ }
529
+ }
530
+
531
+ export function validateLimitsConsistency(
532
+ options: unknown,
533
+ store: unknown,
534
+ deps: unknown,
535
+ ): void {
536
+ const configuredLimits = extractConfiguredLimits(options);
537
+
538
+ checkRouteCountLimit(store, configuredLimits);
539
+ checkDepCountLimit(deps, configuredLimits);
540
+ }