@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,305 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/options.ts
|
|
2
|
+
|
|
3
|
+
import { isObjKey } from "type-guards";
|
|
4
|
+
|
|
5
|
+
const VALID_OPTION_VALUES = {
|
|
6
|
+
trailingSlash: ["strict", "never", "always", "preserve"] as const,
|
|
7
|
+
queryParamsMode: ["default", "strict", "loose"] as const,
|
|
8
|
+
urlParamsEncoding: ["default", "uri", "uriComponent", "none"] as const,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
const VALID_QUERY_PARAMS = {
|
|
12
|
+
arrayFormat: ["none", "brackets", "index", "comma"] as const,
|
|
13
|
+
booleanFormat: ["none", "string", "empty-true"] as const,
|
|
14
|
+
nullFormat: ["default", "hidden"] as const,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const VALID_LOGGER_LEVELS = [
|
|
18
|
+
"all",
|
|
19
|
+
"warn-error",
|
|
20
|
+
"error-only",
|
|
21
|
+
"none",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const KNOWN_OPTIONS = new Set<string>([
|
|
25
|
+
"defaultRoute",
|
|
26
|
+
"defaultParams",
|
|
27
|
+
"trailingSlash",
|
|
28
|
+
"queryParamsMode",
|
|
29
|
+
"queryParams",
|
|
30
|
+
"urlParamsEncoding",
|
|
31
|
+
"allowNotFound",
|
|
32
|
+
"rewritePathOnMatch",
|
|
33
|
+
"logger",
|
|
34
|
+
"limits",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Local type - mirrors LimitsConfig from @real-router/types
|
|
38
|
+
// (@real-router/types is not a direct dependency of this package)
|
|
39
|
+
interface LimitsConfig {
|
|
40
|
+
maxDependencies: number;
|
|
41
|
+
maxPlugins: number;
|
|
42
|
+
maxListeners: number;
|
|
43
|
+
warnListeners: number;
|
|
44
|
+
maxEventDepth: number;
|
|
45
|
+
maxLifecycleHandlers: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Local constant - mirrors LIMIT_BOUNDS from @real-router/core/constants
|
|
49
|
+
// (not exported from @real-router/core public API)
|
|
50
|
+
const LIMIT_BOUNDS = {
|
|
51
|
+
maxDependencies: { min: 0, max: 10_000 },
|
|
52
|
+
maxPlugins: { min: 0, max: 1000 },
|
|
53
|
+
maxListeners: { min: 0, max: 100_000 },
|
|
54
|
+
warnListeners: { min: 0, max: 100_000 },
|
|
55
|
+
maxEventDepth: { min: 0, max: 100 },
|
|
56
|
+
maxLifecycleHandlers: { min: 0, max: 10_000 },
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export function validateLimitValue(
|
|
60
|
+
limitName: keyof LimitsConfig,
|
|
61
|
+
value: unknown,
|
|
62
|
+
methodName: string,
|
|
63
|
+
): void {
|
|
64
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`[router.${methodName}] limit "${limitName}" must be an integer, got ${String(value)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const bounds = LIMIT_BOUNDS[limitName];
|
|
71
|
+
|
|
72
|
+
if (value < bounds.min || value > bounds.max) {
|
|
73
|
+
throw new RangeError(
|
|
74
|
+
`[router.${methodName}] limit "${limitName}" must be between ${bounds.min} and ${bounds.max}, got ${value}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateLimits(
|
|
80
|
+
limits: unknown,
|
|
81
|
+
methodName: string,
|
|
82
|
+
): asserts limits is Partial<LimitsConfig> {
|
|
83
|
+
if (!limits || typeof limits !== "object" || limits.constructor !== Object) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
`[router.${methodName}] invalid limits: expected plain object, got ${typeof limits}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
90
|
+
if (!Object.hasOwn(LIMIT_BOUNDS, key)) {
|
|
91
|
+
throw new TypeError(`[router.${methodName}] unknown limit: "${key}"`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
validateLimitValue(key as keyof LimitsConfig, value, methodName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateStringEnum(
|
|
103
|
+
value: unknown,
|
|
104
|
+
optionName: string,
|
|
105
|
+
validValues: readonly string[],
|
|
106
|
+
methodName: string,
|
|
107
|
+
): void {
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof value !== "string" || !validValues.includes(value)) {
|
|
113
|
+
const validList = validValues.map((val) => `"${val}"`).join(", ");
|
|
114
|
+
const display = typeof value === "string" ? value : `(${typeof value})`;
|
|
115
|
+
|
|
116
|
+
throw new TypeError(
|
|
117
|
+
`[router.${methodName}] Invalid "${optionName}": "${display}". Must be one of: ${validList}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateDefaultRoute(defaultRoute: unknown, methodName: string): void {
|
|
123
|
+
if (defaultRoute === undefined) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof defaultRoute !== "string" && typeof defaultRoute !== "function") {
|
|
128
|
+
throw new TypeError(
|
|
129
|
+
`[router.${methodName}] Invalid "defaultRoute": expected string or function, got ${typeof defaultRoute}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateDefaultParams(
|
|
135
|
+
defaultParams: unknown,
|
|
136
|
+
methodName: string,
|
|
137
|
+
): void {
|
|
138
|
+
if (defaultParams === undefined) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof defaultParams === "function") {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
!defaultParams ||
|
|
148
|
+
typeof defaultParams !== "object" ||
|
|
149
|
+
Array.isArray(defaultParams) ||
|
|
150
|
+
defaultParams.constructor !== Object
|
|
151
|
+
) {
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
`[router.${methodName}] Invalid "defaultParams": expected plain object or function, got ${typeof defaultParams}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateQueryParamsOptions(
|
|
159
|
+
queryParams: unknown,
|
|
160
|
+
methodName: string,
|
|
161
|
+
): void {
|
|
162
|
+
if (queryParams === undefined) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
!queryParams ||
|
|
168
|
+
typeof queryParams !== "object" ||
|
|
169
|
+
Array.isArray(queryParams)
|
|
170
|
+
) {
|
|
171
|
+
throw new TypeError(
|
|
172
|
+
`[router.${methodName}] Invalid "queryParams": expected plain object`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const qp = queryParams as Record<string, unknown>;
|
|
177
|
+
|
|
178
|
+
for (const key of Object.keys(qp)) {
|
|
179
|
+
if (!isObjKey(key, VALID_QUERY_PARAMS)) {
|
|
180
|
+
throw new TypeError(
|
|
181
|
+
`[router.${methodName}] Invalid "queryParams.${key}": unknown option`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
validateStringEnum(
|
|
186
|
+
qp[key],
|
|
187
|
+
`queryParams.${key}`,
|
|
188
|
+
VALID_QUERY_PARAMS[key],
|
|
189
|
+
methodName,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateLoggerOption(loggerOpt: unknown, methodName: string): void {
|
|
195
|
+
if (loggerOpt === undefined) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!loggerOpt || typeof loggerOpt !== "object" || Array.isArray(loggerOpt)) {
|
|
200
|
+
throw new TypeError(
|
|
201
|
+
`[router.${methodName}] Invalid "logger": expected plain object`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const loggerOptions = loggerOpt as Record<string, unknown>;
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
loggerOptions.level !== undefined &&
|
|
209
|
+
!VALID_LOGGER_LEVELS.includes(
|
|
210
|
+
loggerOptions.level as (typeof VALID_LOGGER_LEVELS)[number],
|
|
211
|
+
)
|
|
212
|
+
) {
|
|
213
|
+
const validLevelList = VALID_LOGGER_LEVELS.map((val) => `"${val}"`).join(
|
|
214
|
+
", ",
|
|
215
|
+
);
|
|
216
|
+
const levelDisplay =
|
|
217
|
+
typeof loggerOptions.level === "string"
|
|
218
|
+
? loggerOptions.level
|
|
219
|
+
: `(${typeof loggerOptions.level})`;
|
|
220
|
+
|
|
221
|
+
throw new TypeError(
|
|
222
|
+
`[router.${methodName}] Invalid "logger.level": "${levelDisplay}". Must be one of: ${validLevelList}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
loggerOptions.callback !== undefined &&
|
|
228
|
+
typeof loggerOptions.callback !== "function"
|
|
229
|
+
) {
|
|
230
|
+
throw new TypeError(
|
|
231
|
+
`[router.${methodName}] Invalid "logger.callback": expected function`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
loggerOptions.callbackIgnoresLevel !== undefined &&
|
|
237
|
+
typeof loggerOptions.callbackIgnoresLevel !== "boolean"
|
|
238
|
+
) {
|
|
239
|
+
throw new TypeError(
|
|
240
|
+
`[router.${methodName}] Invalid "logger.callbackIgnoresLevel": expected boolean`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function validateOptions(options: unknown, methodName: string): void {
|
|
246
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
247
|
+
throw new TypeError(
|
|
248
|
+
`[router.${methodName}] Invalid options: expected plain object`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const opts = options as Record<string, unknown>;
|
|
253
|
+
|
|
254
|
+
for (const key of Object.keys(opts)) {
|
|
255
|
+
if (!KNOWN_OPTIONS.has(key)) {
|
|
256
|
+
throw new TypeError(`[router.${methodName}] Unknown option: "${key}"`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
validateDefaultRoute(opts.defaultRoute, methodName);
|
|
261
|
+
validateDefaultParams(opts.defaultParams, methodName);
|
|
262
|
+
validateStringEnum(
|
|
263
|
+
opts.trailingSlash,
|
|
264
|
+
"trailingSlash",
|
|
265
|
+
VALID_OPTION_VALUES.trailingSlash,
|
|
266
|
+
methodName,
|
|
267
|
+
);
|
|
268
|
+
validateStringEnum(
|
|
269
|
+
opts.queryParamsMode,
|
|
270
|
+
"queryParamsMode",
|
|
271
|
+
VALID_OPTION_VALUES.queryParamsMode,
|
|
272
|
+
methodName,
|
|
273
|
+
);
|
|
274
|
+
validateStringEnum(
|
|
275
|
+
opts.urlParamsEncoding,
|
|
276
|
+
"urlParamsEncoding",
|
|
277
|
+
VALID_OPTION_VALUES.urlParamsEncoding,
|
|
278
|
+
methodName,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
opts.allowNotFound !== undefined &&
|
|
283
|
+
typeof opts.allowNotFound !== "boolean"
|
|
284
|
+
) {
|
|
285
|
+
throw new TypeError(
|
|
286
|
+
`[router.${methodName}] Invalid "allowNotFound": expected boolean, got ${typeof opts.allowNotFound}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (
|
|
291
|
+
opts.rewritePathOnMatch !== undefined &&
|
|
292
|
+
typeof opts.rewritePathOnMatch !== "boolean"
|
|
293
|
+
) {
|
|
294
|
+
throw new TypeError(
|
|
295
|
+
`[router.${methodName}] Invalid "rewritePathOnMatch": expected boolean, got ${typeof opts.rewritePathOnMatch}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
validateQueryParamsOptions(opts.queryParams, methodName);
|
|
300
|
+
validateLoggerOption(opts.logger, methodName);
|
|
301
|
+
|
|
302
|
+
if (opts.limits !== undefined) {
|
|
303
|
+
validateLimits(opts.limits, methodName);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// packages/validation-plugin/src/validators/plugins.ts
|
|
2
|
+
|
|
3
|
+
import { logger } from "@real-router/logger";
|
|
4
|
+
|
|
5
|
+
import { computeThresholds } from "../helpers";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_PLUGINS = 50;
|
|
8
|
+
|
|
9
|
+
const LOGGER_CTX = "router.usePlugin";
|
|
10
|
+
|
|
11
|
+
const PLUGIN_EVENTS_MAP: Record<string, true> = {
|
|
12
|
+
onStart: true,
|
|
13
|
+
onStop: true,
|
|
14
|
+
onTransitionStart: true,
|
|
15
|
+
onTransitionSuccess: true,
|
|
16
|
+
onTransitionError: true,
|
|
17
|
+
onTransitionCancel: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function validatePluginLimit(
|
|
21
|
+
currentCount: number,
|
|
22
|
+
newCount: number,
|
|
23
|
+
maxPlugins: number = DEFAULT_MAX_PLUGINS,
|
|
24
|
+
): void {
|
|
25
|
+
if (maxPlugins === 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const totalCount = currentCount + newCount;
|
|
30
|
+
|
|
31
|
+
if (totalCount > maxPlugins) {
|
|
32
|
+
throw new RangeError(
|
|
33
|
+
`[router.usePlugin] Plugin limit exceeded (${maxPlugins}). ` +
|
|
34
|
+
`Current: ${currentCount}, Attempting to add: ${newCount}. ` +
|
|
35
|
+
`This indicates an architectural problem. Consider consolidating plugins.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateCountThresholds(
|
|
41
|
+
count: number,
|
|
42
|
+
maxPlugins: number,
|
|
43
|
+
): void {
|
|
44
|
+
if (maxPlugins === 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { warn, error } = computeThresholds(maxPlugins);
|
|
49
|
+
|
|
50
|
+
if (count >= error) {
|
|
51
|
+
logger.error(
|
|
52
|
+
LOGGER_CTX,
|
|
53
|
+
`${count} plugins registered! This is excessive. Hard limit at ${maxPlugins}.`,
|
|
54
|
+
);
|
|
55
|
+
} else if (count >= warn) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
LOGGER_CTX,
|
|
58
|
+
`${count} plugins registered. Consider if all are necessary.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validatePluginKeys(plugin: unknown): void {
|
|
64
|
+
for (const key in plugin as Record<string, unknown>) {
|
|
65
|
+
if (!(key === "teardown" || key in PLUGIN_EVENTS_MAP)) {
|
|
66
|
+
throw new TypeError(
|
|
67
|
+
`[router.usePlugin] Unknown property '${key}'. Plugin must only contain event handlers and optional teardown.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function warnBatchDuplicates(): void {
|
|
74
|
+
logger.warn(
|
|
75
|
+
LOGGER_CTX,
|
|
76
|
+
"Duplicate factory in batch, will be registered once",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function warnPluginMethodType(methodName: string): void {
|
|
81
|
+
logger.warn(
|
|
82
|
+
LOGGER_CTX,
|
|
83
|
+
`Property '${methodName}' is not a function, skipping`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function warnPluginAfterStart(methodName: string): void {
|
|
88
|
+
if (methodName === "onStart") {
|
|
89
|
+
logger.warn(
|
|
90
|
+
LOGGER_CTX,
|
|
91
|
+
"Router already started, onStart will not be called",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateAddInterceptorArgs(method: unknown, fn: unknown): void {
|
|
97
|
+
const validMethods = ["start", "buildPath", "forwardState"];
|
|
98
|
+
|
|
99
|
+
if (typeof method !== "string" || !validMethods.includes(method)) {
|
|
100
|
+
throw new TypeError(
|
|
101
|
+
`[router.addInterceptor] Invalid method: "${String(method)}". Must be one of: ${validMethods.join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (typeof fn !== "function") {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
`[router.addInterceptor] interceptor must be a function, got ${typeof fn}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|