@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,330 @@
1
+ // packages/validation-plugin/src/validationPlugin.ts
2
+
3
+ import { RouterError } from "@real-router/core";
4
+ import { getInternals } from "@real-router/core/validation";
5
+ import {
6
+ validateRouteName,
7
+ isState,
8
+ isBoolean,
9
+ getTypeDescription,
10
+ } from "type-guards";
11
+
12
+ import {
13
+ validateDependencyName,
14
+ validateSetDependencyArgs as validateSetDependencyArgsRaw,
15
+ validateDependenciesObject,
16
+ validateDependencyExists as validateDependencyExistsRaw,
17
+ validateDependencyCount,
18
+ validateCloneArgs,
19
+ warnOverwrite as warnDepsOverwrite,
20
+ warnBatchOverwrite,
21
+ warnRemoveNonExistent,
22
+ } from "./validators/dependencies";
23
+ import { validateEventName, validateListenerArgs } from "./validators/eventBus";
24
+ import {
25
+ validateHandler,
26
+ validateNotRegistering,
27
+ validateHandlerLimit,
28
+ validateLifecycleCountThresholds,
29
+ warnOverwrite as warnLifecycleOverwrite,
30
+ warnAsyncGuardSync,
31
+ } from "./validators/lifecycle";
32
+ import {
33
+ validateNavigateArgs,
34
+ validateNavigateToDefaultArgs,
35
+ validateNavigationOptions,
36
+ validateNavigateParams,
37
+ validateStartArgs,
38
+ } from "./validators/navigation";
39
+ import {
40
+ validateLimitValue,
41
+ validateLimits,
42
+ validateOptions,
43
+ } from "./validators/options";
44
+ import {
45
+ validatePluginLimit,
46
+ validatePluginKeys,
47
+ validateCountThresholds as validatePluginCountThresholds,
48
+ warnBatchDuplicates,
49
+ warnPluginMethodType,
50
+ warnPluginAfterStart,
51
+ validateAddInterceptorArgs,
52
+ } from "./validators/plugins";
53
+ import {
54
+ validateExistingRoutes,
55
+ validateForwardToConsistency,
56
+ validateRoutePropertiesStore,
57
+ validateForwardToTargetsStore,
58
+ validateDependenciesStructure,
59
+ validateLimitsConsistency,
60
+ } from "./validators/retrospective";
61
+ import {
62
+ validateBuildPathArgs,
63
+ validateMatchPathArgs,
64
+ validateIsActiveRouteArgs,
65
+ validateShouldUpdateNodeArgs,
66
+ validateStateBuilderArgs,
67
+ validateAddRouteArgs,
68
+ validateRoutes,
69
+ validateRemoveRouteArgs,
70
+ validateUpdateRouteBasicArgs,
71
+ validateUpdateRoutePropertyTypes,
72
+ validateUpdateRoute,
73
+ validateParentOption as validateParentOptionRaw,
74
+ throwIfInternalRoute,
75
+ throwIfInternalRouteInArray,
76
+ validateSetRootPathArgs,
77
+ guardRouteCallbacks,
78
+ guardNoAsyncCallbacks,
79
+ } from "./validators/routes";
80
+ import { validateMakeStateArgs } from "./validators/state";
81
+
82
+ import type { PluginFactory, RouterValidator } from "@real-router/core";
83
+ import type { RouterInternals } from "@real-router/core/validation";
84
+
85
+ function buildValidatorObject(ctx: RouterInternals): RouterValidator {
86
+ return {
87
+ routes: {
88
+ validateBuildPathArgs,
89
+ validateMatchPathArgs,
90
+ validateIsActiveRouteArgs,
91
+ validateShouldUpdateNodeArgs,
92
+ validateStateBuilderArgs,
93
+
94
+ validateAddRouteArgs(routes) {
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
96
+ validateAddRouteArgs(routes as any);
97
+ },
98
+
99
+ validateRoutes(routes, store) {
100
+ const typedStore = store as {
101
+ tree?: unknown;
102
+ config?: { forwardMap?: Record<string, string> };
103
+ };
104
+
105
+ validateRoutes(
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
107
+ routes as any,
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
109
+ typedStore.tree as any,
110
+ typedStore.config?.forwardMap,
111
+ );
112
+ },
113
+ validateRemoveRouteArgs,
114
+ validateUpdateRouteBasicArgs,
115
+ validateUpdateRoutePropertyTypes(_name, updates) {
116
+ const upd = updates as Record<string, unknown>;
117
+
118
+ validateUpdateRoutePropertyTypes(
119
+ upd.forwardTo,
120
+ upd.defaultParams,
121
+ upd.decodeParams,
122
+ upd.encodeParams,
123
+ );
124
+ },
125
+ validateUpdateRoute(name, updates, store) {
126
+ const typedStore = store as {
127
+ matcher: {
128
+ hasRoute: (routeName: string) => boolean;
129
+ getSegmentsByName: (routeName: string) => unknown;
130
+ };
131
+ config: { forwardMap: Record<string, string> };
132
+ };
133
+ const forwardTo = (updates as Record<string, unknown>).forwardTo;
134
+
135
+ validateUpdateRoute(
136
+ name,
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
138
+ forwardTo as any,
139
+ (routeName: string) => typedStore.matcher.hasRoute(routeName),
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
141
+ typedStore.matcher as any,
142
+ typedStore.config,
143
+ );
144
+ },
145
+ validateParentOption(parent, tree) {
146
+ validateParentOptionRaw(parent as string);
147
+ let node = tree as { children: Map<string, unknown> };
148
+
149
+ for (const segment of (parent as string).split(".")) {
150
+ const child = node.children.get(segment) as
151
+ | { children: Map<string, unknown> }
152
+ | undefined;
153
+
154
+ if (!child) {
155
+ throw new ReferenceError(
156
+ `[router.addRoute] Parent route "${parent as string}" does not exist`,
157
+ );
158
+ }
159
+
160
+ node = child;
161
+ }
162
+ },
163
+ validateRouteName(name, caller) {
164
+ validateRouteName(name, caller);
165
+ },
166
+ throwIfInternalRoute(name, caller) {
167
+ throwIfInternalRoute(name as string, caller);
168
+ },
169
+
170
+ throwIfInternalRouteInArray(routes, caller) {
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
172
+ throwIfInternalRouteInArray(routes as any, caller);
173
+ },
174
+ validateExistingRoutes,
175
+ validateForwardToConsistency,
176
+ validateSetRootPathArgs,
177
+ guardRouteCallbacks,
178
+ guardNoAsyncCallbacks,
179
+ },
180
+ options: {
181
+ validateLimitValue(name, value) {
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
183
+ validateLimitValue(name as any, value, "validate");
184
+ },
185
+ validateLimits(limits) {
186
+ validateLimits(limits, "validate");
187
+ },
188
+ validateOptions,
189
+ },
190
+ dependencies: {
191
+ validateDependencyName,
192
+ validateSetDependencyArgs(_name, _value, _caller) {
193
+ validateSetDependencyArgsRaw(_name);
194
+ },
195
+ validateDependenciesObject,
196
+ validateDependencyExists(name, store) {
197
+ const typedStore = store as { dependencies?: Record<string, unknown> };
198
+ const value = typedStore.dependencies?.[name];
199
+
200
+ validateDependencyExistsRaw(value, name);
201
+ },
202
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
203
+ validateDependencyLimit(_store, _limits) {},
204
+ validateDependenciesStructure,
205
+ validateDependencyCount,
206
+ validateCloneArgs,
207
+ warnOverwrite: warnDepsOverwrite,
208
+ warnBatchOverwrite,
209
+ warnRemoveNonExistent,
210
+ },
211
+ plugins: {
212
+ validatePluginLimit(count, limits) {
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
214
+ validatePluginLimit(count, 1, (limits as any)?.maxPlugins);
215
+ },
216
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
217
+ validateNoDuplicatePlugins(_factory, _factories) {},
218
+ validatePluginKeys,
219
+ validateCountThresholds(count) {
220
+ const maxPlugins = ctx.getOptions().limits?.maxPlugins ?? 50;
221
+
222
+ validatePluginCountThresholds(count, maxPlugins);
223
+ },
224
+ warnBatchDuplicates,
225
+ warnPluginMethodType,
226
+ warnPluginAfterStart,
227
+ validateAddInterceptorArgs,
228
+ },
229
+ lifecycle: {
230
+ validateHandler,
231
+ validateNotRegistering(name, _guards, caller) {
232
+ validateNotRegistering(false, name, caller);
233
+ },
234
+ validateHandlerLimit(count, limits, caller) {
235
+ validateHandlerLimit(
236
+ count,
237
+ caller,
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
239
+ (limits as any)?.maxLifecycleHandlers,
240
+ );
241
+ },
242
+ validateCountThresholds(count, methodName) {
243
+ const maxHandlers =
244
+ ctx.getOptions().limits?.maxLifecycleHandlers ?? 200;
245
+
246
+ validateLifecycleCountThresholds(count, methodName, maxHandlers);
247
+ },
248
+ warnOverwrite: warnLifecycleOverwrite,
249
+ warnAsyncGuardSync,
250
+ },
251
+ navigation: {
252
+ validateNavigateArgs,
253
+ validateNavigateToDefaultArgs,
254
+ validateNavigationOptions,
255
+ validateParams: validateNavigateParams,
256
+ validateStartArgs,
257
+ },
258
+ state: {
259
+ validateMakeStateArgs,
260
+ validateAreStatesEqualArgs(s1, s2, ignoreQP) {
261
+ if (!isState(s1)) {
262
+ throw new TypeError(
263
+ `[router.areStatesEqual] Invalid state1: ${getTypeDescription(s1)}. Expected State object.`,
264
+ );
265
+ }
266
+ if (!isState(s2)) {
267
+ throw new TypeError(
268
+ `[router.areStatesEqual] Invalid state2: ${getTypeDescription(s2)}. Expected State object.`,
269
+ );
270
+ }
271
+ if (ignoreQP !== undefined && !isBoolean(ignoreQP)) {
272
+ throw new TypeError(
273
+ `[router.areStatesEqual] Invalid ignoreQueryParams: ${getTypeDescription(ignoreQP)}. Expected boolean.`,
274
+ );
275
+ }
276
+ },
277
+ },
278
+ eventBus: {
279
+ validateEventName,
280
+
281
+ validateListenerArgs(name, cb) {
282
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
283
+ validateListenerArgs(name as any, cb as any);
284
+ },
285
+ },
286
+ };
287
+ }
288
+
289
+ export function validationPlugin(): PluginFactory {
290
+ // eslint-disable-next-line unicorn/consistent-function-scoping
291
+ return (router) => {
292
+ const ctx = getInternals(router);
293
+
294
+ if (router.isActive()) {
295
+ throw new RouterError("VALIDATION_PLUGIN_AFTER_START", {
296
+ message: "validation-plugin must be registered before router.start()",
297
+ });
298
+ }
299
+
300
+ // RouterInternals.validator is now mutable — direct assignment works
301
+ ctx.validator = buildValidatorObject(ctx);
302
+
303
+ try {
304
+ const store = ctx.routeGetStore();
305
+ const deps = ctx.dependenciesGetStore();
306
+ const options = ctx.getOptions();
307
+
308
+ validateExistingRoutes(store);
309
+ validateForwardToConsistency(store);
310
+ validateRoutePropertiesStore(store);
311
+ validateForwardToTargetsStore(store);
312
+ validateDependenciesStructure(deps);
313
+ validateLimitsConsistency(options, store, deps);
314
+ ctx.validator.options.validateOptions(
315
+ options as unknown,
316
+ "constructor (retrospective)",
317
+ );
318
+ } catch (error) {
319
+ ctx.validator = null;
320
+
321
+ throw error;
322
+ }
323
+
324
+ return {
325
+ teardown() {
326
+ ctx.validator = null;
327
+ },
328
+ };
329
+ };
330
+ }
@@ -0,0 +1,159 @@
1
+ // packages/validation-plugin/src/validators/dependencies.ts
2
+
3
+ import { logger } from "@real-router/logger";
4
+ import { getTypeDescription } from "type-guards";
5
+
6
+ import { computeThresholds } from "../helpers";
7
+
8
+ const DEFAULT_MAX_DEPENDENCIES = 100;
9
+
10
+ export function validateDependencyName(
11
+ name: unknown,
12
+ methodName: string,
13
+ ): asserts name is string {
14
+ if (typeof name !== "string") {
15
+ throw new TypeError(
16
+ `[router.${methodName}] dependency name must be a string, got ${typeof name}`,
17
+ );
18
+ }
19
+ }
20
+
21
+ export function validateSetDependencyArgs(
22
+ name: unknown,
23
+ ): asserts name is string {
24
+ if (typeof name !== "string") {
25
+ throw new TypeError(
26
+ `[router.setDependency] dependency name must be a string, got ${typeof name}`,
27
+ );
28
+ }
29
+ }
30
+
31
+ export function validateDependenciesObject(
32
+ deps: unknown,
33
+ methodName: string,
34
+ ): asserts deps is Record<string, unknown> {
35
+ if (!(deps && typeof deps === "object" && deps.constructor === Object)) {
36
+ throw new TypeError(
37
+ `[router.${methodName}] Invalid argument: expected plain object, received ${getTypeDescription(deps)}`,
38
+ );
39
+ }
40
+
41
+ for (const key in deps) {
42
+ if (Object.getOwnPropertyDescriptor(deps, key)?.get) {
43
+ throw new TypeError(
44
+ `[router.${methodName}] Getters not allowed: "${key}"`,
45
+ );
46
+ }
47
+ }
48
+ }
49
+
50
+ export function validateDependencyExists(
51
+ value: unknown,
52
+ dependencyName: string,
53
+ ): asserts value is NonNullable<unknown> {
54
+ if (value === undefined) {
55
+ throw new ReferenceError(
56
+ `[router.getDependency] dependency "${dependencyName}" not found`,
57
+ );
58
+ }
59
+ }
60
+
61
+ export function validateDependencyLimit(
62
+ currentCount: number,
63
+ newCount: number,
64
+ methodName: string,
65
+ maxDependencies: number = DEFAULT_MAX_DEPENDENCIES,
66
+ ): void {
67
+ if (maxDependencies === 0) {
68
+ return;
69
+ }
70
+
71
+ const totalCount = currentCount + newCount;
72
+
73
+ if (totalCount >= maxDependencies) {
74
+ throw new RangeError(
75
+ `[router.${methodName}] Dependency limit exceeded (${maxDependencies}). ` +
76
+ `Current: ${totalCount}. This is likely a bug in your code.`,
77
+ );
78
+ }
79
+ }
80
+
81
+ export function validateDependencyCount(
82
+ store: unknown,
83
+ methodName: string,
84
+ ): void {
85
+ const typedStore = store as {
86
+ dependencies: Record<string, unknown>;
87
+ limits?: { maxDependencies?: number };
88
+ };
89
+ const maxDependencies =
90
+ typedStore.limits?.maxDependencies ?? DEFAULT_MAX_DEPENDENCIES;
91
+
92
+ if (maxDependencies === 0) {
93
+ return;
94
+ }
95
+
96
+ const currentCount = Object.keys(typedStore.dependencies).length;
97
+ const { warn, error } = computeThresholds(maxDependencies);
98
+
99
+ if (currentCount >= maxDependencies) {
100
+ throw new RangeError(
101
+ `[router.${methodName}] Dependency limit exceeded (${maxDependencies}). Current: ${currentCount}.`,
102
+ );
103
+ } else if (currentCount === error) {
104
+ logger.error(
105
+ `router.${methodName}`,
106
+ `${currentCount} dependencies registered! This indicates architectural problems. Hard limit at ${maxDependencies}.`,
107
+ );
108
+ } else if (currentCount === warn) {
109
+ logger.warn(
110
+ `router.${methodName}`,
111
+ `${currentCount} dependencies registered. Consider if all are necessary.`,
112
+ );
113
+ }
114
+ }
115
+
116
+ export function validateCloneArgs(dependencies: unknown): void {
117
+ if (dependencies === undefined) {
118
+ return;
119
+ }
120
+
121
+ if (
122
+ !(
123
+ dependencies &&
124
+ typeof dependencies === "object" &&
125
+ dependencies.constructor === Object
126
+ )
127
+ ) {
128
+ throw new TypeError(
129
+ `[cloneRouter] Invalid dependencies: expected plain object or undefined, received ${typeof dependencies}`,
130
+ );
131
+ }
132
+
133
+ for (const key in dependencies as Record<string, unknown>) {
134
+ if (Object.getOwnPropertyDescriptor(dependencies, key)?.get) {
135
+ throw new TypeError(
136
+ `[cloneRouter] Getters not allowed in dependencies: "${key}"`,
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ export function warnOverwrite(name: string, methodName: string): void {
143
+ logger.warn(
144
+ `router.${methodName}`,
145
+ "Router dependency already exists and is being overwritten:",
146
+ name,
147
+ );
148
+ }
149
+
150
+ export function warnBatchOverwrite(keys: string[], methodName: string): void {
151
+ logger.warn(`router.${methodName}`, "Overwritten:", keys.join(", "));
152
+ }
153
+
154
+ export function warnRemoveNonExistent(name: unknown): void {
155
+ logger.warn(
156
+ "router.removeDependency",
157
+ `Attempted to remove non-existent dependency: "${typeof name === "string" ? name : String(name)}"`,
158
+ );
159
+ }
@@ -0,0 +1,48 @@
1
+ // packages/validation-plugin/src/validators/eventBus.ts
2
+
3
+ import type { Plugin } from "@real-router/core";
4
+
5
+ // Local types — mirrors EventName and EventMethodMap from @real-router/types
6
+ // (@real-router/types is not a direct dependency of this package)
7
+ interface EventMethodMap {
8
+ $start: "onStart";
9
+ $stop: "onStop";
10
+ $$start: "onTransitionStart";
11
+ $$cancel: "onTransitionCancel";
12
+ $$success: "onTransitionSuccess";
13
+ $$error: "onTransitionError";
14
+ }
15
+
16
+ type EventName = keyof EventMethodMap;
17
+
18
+ // Local set — mirrors validEventNames from @real-router/core/constants
19
+ // (not exported from @real-router/core public API)
20
+ const validEventNames = new Set<EventName>([
21
+ "$start",
22
+ "$stop",
23
+ "$$start",
24
+ "$$cancel",
25
+ "$$success",
26
+ "$$error",
27
+ ]);
28
+
29
+ export function validateEventName(eventName: unknown): void {
30
+ if (!validEventNames.has(eventName as EventName)) {
31
+ throw new TypeError(
32
+ `[router.addEventListener] Invalid event name: ${String(eventName)}. Must be one of: $start, $stop, $$start, $$cancel, $$success, $$error`,
33
+ );
34
+ }
35
+ }
36
+
37
+ export function validateListenerArgs<E extends EventName>(
38
+ eventName: E,
39
+ cb: Plugin[EventMethodMap[E]],
40
+ ): void {
41
+ validateEventName(eventName);
42
+
43
+ if (typeof cb !== "function") {
44
+ throw new TypeError(
45
+ `[router.addEventListener] callback must be a function, got ${typeof cb}`,
46
+ );
47
+ }
48
+ }