@real-router/search-schema-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 ADDED
@@ -0,0 +1,183 @@
1
+ # @real-router/search-schema-plugin
2
+
3
+ [![npm](https://img.shields.io/npm/v/@real-router/search-schema-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/search-schema-plugin)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@real-router/search-schema-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/search-schema-plugin)
5
+ [![bundle size](https://deno.bundlejs.com/?q=@real-router/search-schema-plugin&treeshake=[*]&badge=detailed)](https://bundlejs.com/?q=@real-router/search-schema-plugin&treeshake=[*])
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](../../LICENSE)
7
+
8
+ > Validate and sanitize route search parameters at runtime using any [Standard Schema V1](https://github.com/standard-schema/standard-schema)-compatible library in [Real-Router](https://github.com/greydragon888/real-router).
9
+
10
+ ```typescript
11
+ // Without plugin — tampered URL params reach your app unvalidated:
12
+ // User visits: /products?page=-1&limit=99999
13
+ router.getState().params; // { page: -1, limit: 99999 } — crashes pagination
14
+
15
+ // With plugin — invalid params stripped, route defaults restored automatically:
16
+ router.usePlugin(searchSchemaPlugin());
17
+ // User visits: /products?page=-1&limit=99999
18
+ router.getState().params; // { page: 1, limit: 20 } — safe defaults from defaultParams
19
+ ```
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @real-router/search-schema-plugin
25
+ ```
26
+
27
+ **Peer dependency:** `@real-router/core`
28
+
29
+ ## Quick Start
30
+
31
+ ```typescript
32
+ import { createRouter } from "@real-router/core";
33
+ import { searchSchemaPlugin } from "@real-router/search-schema-plugin";
34
+ import { z } from "zod"; // any Standard Schema V1 library — Zod 3.24+, Valibot 1.0+, ArkType
35
+
36
+ const routes = [
37
+ {
38
+ name: "products",
39
+ path: "/products?page&limit&sortBy",
40
+ defaultParams: { page: 1, limit: 20, sortBy: "price" },
41
+ searchSchema: z.object({
42
+ page: z.number().int().positive(),
43
+ limit: z.number().int().min(1).max(100),
44
+ sortBy: z.enum(["price", "name", "date"]),
45
+ }),
46
+ },
47
+ ];
48
+
49
+ const router = createRouter(routes, {
50
+ queryParams: { numberFormat: "auto" },
51
+ });
52
+ router.usePlugin(searchSchemaPlugin({ mode: "development" }));
53
+ ```
54
+
55
+ > **Schema libraries:** Any library implementing [Standard Schema V1](https://github.com/standard-schema/standard-schema) works — Zod 3.24+, Valibot 1.0+, ArkType. Install and configure your chosen library separately; the plugin has no schema-library dependency.
56
+
57
+ ## Configuration
58
+
59
+ | Option | Type | Default | Description |
60
+ | --------- | --------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
61
+ | `mode` | `"development" \| "production"` | `"development"` | In development mode, logs invalid params with `console.error`. In production mode, strips silently without logging. |
62
+ | `strict` | `boolean` | `false` | When `false`, unknown params pass through alongside schema output. When `true`, only params present in the schema output are kept — unknown keys are removed. |
63
+ | `onError` | `(routeName, params, issues) => Params` | `undefined` | Custom error handler. When set, overrides both `mode` logging and the built-in strip+merge recovery. Receives the raw validation issues; returned params are used as-is without re-validation. |
64
+
65
+ ## Behavior
66
+
67
+ ### Valid params
68
+
69
+ When schema validation succeeds, the resolved params are merged back based on `strict`:
70
+
71
+ ```typescript
72
+ // strict: false (default) — schema output merged over original, unknown keys preserved
73
+ // Original params: { page: 1, filter: "electronics", utm_source: "google" }
74
+ // Schema output: { page: 1, filter: "electronics" } (Zod strip mode removes unknowns)
75
+ // Result: { page: 1, filter: "electronics", utm_source: "google" }
76
+
77
+ // strict: true — schema output used directly, unknown keys removed
78
+ // Result: { page: 1, filter: "electronics" }
79
+ ```
80
+
81
+ ### Invalid params + recovery
82
+
83
+ When schema validation fails, the plugin strips only the keys with validation issues and restores their `defaultParams` values:
84
+
85
+ ```typescript
86
+ // Route defaultParams: { page: 1, sortBy: "price" }
87
+ // URL: /products?page=foo&sortBy=price
88
+ // Schema fails: page is not a valid number
89
+ // Step 1 — strip invalid: { sortBy: "price" }
90
+ // Step 2 — merge defaults: { page: 1, sortBy: "price" } ← page restored from defaultParams
91
+ // Result: { page: 1, sortBy: "price" }
92
+ ```
93
+
94
+ In `mode: "development"`, a `console.error` is emitted with the route name and validation issues before the recovery happens.
95
+
96
+ ### Strict mode
97
+
98
+ ```typescript
99
+ router.usePlugin(searchSchemaPlugin({ strict: true }));
100
+ // Unknown params (not described in schema) are removed on every navigation
101
+ ```
102
+
103
+ Per-route schema configuration (e.g., Zod's `.passthrough()` or `.strip()`) controls which keys appear in the schema output and effectively overrides the `strict` option for that route.
104
+
105
+ ## Use Cases
106
+
107
+ ### Form Validation — Pagination and Filters
108
+
109
+ ```typescript
110
+ const routes = [
111
+ {
112
+ name: "users",
113
+ path: "/users?page&pageSize&status",
114
+ defaultParams: { page: 1, pageSize: 25, status: "active" },
115
+ searchSchema: z.object({
116
+ page: z.number().int().positive(),
117
+ pageSize: z.number().int().min(1).max(100),
118
+ status: z.enum(["active", "inactive", "all"]),
119
+ }),
120
+ },
121
+ ];
122
+
123
+ const router = createRouter(routes, {
124
+ queryParams: { numberFormat: "auto" },
125
+ });
126
+ router.usePlugin(searchSchemaPlugin());
127
+ // /users?page=0&status=deleted → { page: 1, pageSize: 25, status: "active" }
128
+ ```
129
+
130
+ ### Search Params with Automatic Type Coercion
131
+
132
+ ```typescript
133
+ const searchSchema = z.object({
134
+ q: z.string().min(1),
135
+ page: z.number().int().positive().default(1),
136
+ });
137
+
138
+ const routes = [{ name: "search", path: "/search?q&page", searchSchema }];
139
+
140
+ // numberFormat: "auto" handles string→number coercion at the search-params layer,
141
+ // so schemas validate already-typed values (not raw URL strings)
142
+ const router = createRouter(routes, {
143
+ queryParams: { numberFormat: "auto" },
144
+ });
145
+ router.usePlugin(searchSchemaPlugin());
146
+ ```
147
+
148
+ ### Custom Error Reporting
149
+
150
+ ```typescript
151
+ router.usePlugin(
152
+ searchSchemaPlugin({
153
+ onError: (routeName, params, issues) => {
154
+ analytics.track("invalid_search_params", { routeName, issues });
155
+ return {}; // empty params — let defaultParams fill in from the route
156
+ },
157
+ }),
158
+ );
159
+ ```
160
+
161
+ ## Documentation
162
+
163
+ Full documentation: [Wiki — search-schema-plugin](https://github.com/greydragon888/real-router/wiki/search-schema-plugin)
164
+
165
+ - [Standard Schema V1 compatibility](https://github.com/greydragon888/real-router/wiki/search-schema-plugin#standard-schema)
166
+ - [Error recovery](https://github.com/greydragon888/real-router/wiki/search-schema-plugin#error-recovery)
167
+ - [Strict mode](https://github.com/greydragon888/real-router/wiki/search-schema-plugin#strict-mode)
168
+
169
+ ## Related Packages
170
+
171
+ | Package | Description |
172
+ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
173
+ | [@real-router/core](https://www.npmjs.com/package/@real-router/core) | Core router (required peer dependency) |
174
+ | [@real-router/persistent-params-plugin](https://www.npmjs.com/package/@real-router/persistent-params-plugin) | Persist query parameters across navigations |
175
+ | [@real-router/validation-plugin](https://www.npmjs.com/package/@real-router/validation-plugin) | Runtime argument validation for development |
176
+
177
+ ## Contributing
178
+
179
+ See [contributing guidelines](../../CONTRIBUTING.md) for development setup and PR process.
180
+
181
+ ## License
182
+
183
+ [MIT](../../LICENSE) © [Oleg Ivanov](https://github.com/greydragon888)
@@ -0,0 +1,81 @@
1
+ import { Params, PluginFactory } from "@real-router/core";
2
+
3
+ //#region src/types.d.ts
4
+ /** A single validation issue from Standard Schema V1. */
5
+ interface StandardSchemaV1Issue {
6
+ readonly message: string;
7
+ readonly path?: readonly (PropertyKey | {
8
+ readonly key: PropertyKey;
9
+ })[] | undefined;
10
+ }
11
+ /** Validation result — either success or failure. */
12
+ type StandardSchemaV1Result<Output = unknown> = {
13
+ readonly value: Output;
14
+ } | {
15
+ readonly issues: readonly StandardSchemaV1Issue[];
16
+ };
17
+ /**
18
+ * Standard Schema V1 interface.
19
+ *
20
+ * Supported by Zod 3.24+, Valibot 1.0+, ArkType.
21
+ * The plugin doesn't depend on any specific schema library.
22
+ */
23
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
24
+ readonly "~standard": {
25
+ readonly version: 1;
26
+ readonly vendor: string;
27
+ readonly validate: (value: unknown) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>;
28
+ readonly types?: {
29
+ readonly input: Input;
30
+ readonly output: Output;
31
+ } | undefined;
32
+ };
33
+ }
34
+ interface SearchSchemaPluginOptions {
35
+ /**
36
+ * Error handling mode.
37
+ * - "development" (default): strip invalid + console.error
38
+ * - "production": silent strip
39
+ *
40
+ * For recovery of invalid params use defaultParams (strip + merge + diagnostics).
41
+ * For filling absent params use .default() in schema (no diagnostics).
42
+ * .catch() is not recommended — suppresses errors, mode: "development" won't see the issue.
43
+ */
44
+ readonly mode?: "development" | "production";
45
+ /**
46
+ * Strip search params not described in schema.
47
+ * - false (default): unknown params pass through
48
+ * - true: unknown params are removed
49
+ *
50
+ * Per-route override: .strict() / .passthrough() in Zod schema.
51
+ */
52
+ readonly strict?: boolean;
53
+ /**
54
+ * Custom error handler (overrides mode completely).
55
+ * Must return cleaned params.
56
+ *
57
+ * Contract:
58
+ * - Returned params are used as-is, without re-validation.
59
+ * Responsibility for correctness is on the callback author.
60
+ * (Re-validation would risk infinite loops.)
61
+ * - Exceptions from onError propagate up without suppression.
62
+ * Consistent with interceptor behavior in core.
63
+ * - When onError is set, neither console.error (mode: "development"),
64
+ * nor silent strip (mode: "production") are executed.
65
+ * All responsibility for diagnostics and recovery is on the callback.
66
+ */
67
+ readonly onError?: (routeName: string, params: Params, issues: readonly StandardSchemaV1Issue[]) => Params;
68
+ }
69
+ //#endregion
70
+ //#region src/factory.d.ts
71
+ declare function searchSchemaPlugin(options?: SearchSchemaPluginOptions): PluginFactory;
72
+ //#endregion
73
+ //#region src/index.d.ts
74
+ declare module "@real-router/core" {
75
+ interface Route {
76
+ searchSchema?: StandardSchemaV1;
77
+ }
78
+ }
79
+ //#endregion
80
+ export { type SearchSchemaPluginOptions, type StandardSchemaV1, type StandardSchemaV1Issue, type StandardSchemaV1Result, searchSchemaPlugin };
81
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@real-router/core/api`);const t=`[search-schema-plugin]`;function n(e){let t=new Set;for(let n of e)if(n.path&&n.path.length>0){let e=n.path[0],r=typeof e==`object`&&`key`in e?e.key:e;t.add(String(r))}return t}function r(e,t){let n={};for(let r of Object.keys(e))t.has(r)||(n[r]=e[r]);return n}var i=class{#e;#t;#n;#r;#i;#a;#o;constructor(e,t,n){this.#e=e,this.#t=t,this.#n=n.mode??`development`,this.#r=n.strict??!1,this.#i=n.onError,this.#l(),this.#a=this.#e.addInterceptor(`forwardState`,(e,t,n)=>{let r=e(t,n);return this.#c(r)}),this.#o=this.#e.addInterceptor(`add`,(e,t,n)=>{e(t,n),this.#f(t,n?.parent)})}getPlugin(){return{teardown:()=>{this.#a(),this.#o()}}}#s(e){return this.#e.getRouteConfig(e)?.searchSchema}#c(e){let i=this.#s(e.name);if(!i)return e;let a=i[`~standard`].validate(e.params);if(a instanceof Promise)throw TypeError(`${t} Async schema validation is not supported. Route "${e.name}" returned a Promise from ~standard.validate().`);if(`value`in a){let t=this.#r?a.value:{...e.params,...a.value};return{...e,params:t}}if(this.#i)return{...e,params:this.#i(e.name,e.params,a.issues)};this.#n===`development`&&console.error(`${t} Route "${e.name}": invalid search params`,a.issues);let o=n(a.issues),s=r(e.params,o),c=this.#t.get(e.name)?.defaultParams,l=c?{...c,...s}:s;return{...e,params:l}}#l(){if(this.#n!==`development`)return;let e=this.#e.getTree();e&&this.#u(e)}#u(e){if(e.fullName&&this.#d(e.fullName),e.children instanceof Map)for(let t of e.children.values())t&&typeof t==`object`&&this.#u(t)}#d(e){let n=this.#s(e);if(!n)return;let r=this.#t.get(e)?.defaultParams;if(!r)return;let i=n[`~standard`].validate(r);i instanceof Promise||`issues`in i&&console.warn(`${t} Route "${e}": defaultParams do not pass searchSchema`,i.issues)}#f(e,t=``){if(this.#n===`development`){for(let n of e)if(n.name){let e=t?`${t}.${n.name}`:n.name;this.#d(e),n.children&&this.#f(n.children,e)}}}};const a=new Set([`development`,`production`]);function o(e){if(e.mode!==void 0&&!a.has(e.mode))throw TypeError(`${t} Invalid mode: "${e.mode}". Must be "development" or "production".`);if(e.strict!==void 0&&typeof e.strict!=`boolean`)throw TypeError(`${t} Invalid strict option: expected boolean, got ${typeof e.strict}.`);if(e.onError!==void 0&&typeof e.onError!=`function`)throw TypeError(`${t} Invalid onError: expected function, got ${typeof e.onError}.`)}function s(t={}){o(t);let n=Object.freeze({...t});return t=>new i((0,e.getPluginApi)(t),(0,e.getRoutesApi)(t),n).getPlugin()}exports.searchSchemaPlugin=s;
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["#pluginApi","#routesApi","#mode","#strict","#onError","#removeForwardStateInterceptor","#removeAddInterceptor","#validateExistingDefaultParams","#validateState","#validateRoutesDefaultParams","#getSchema","#walkTree","#validateSingleRouteDefaultParams"],"sources":["../../src/constants.ts","../../src/helpers.ts","../../src/plugin.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["// packages/search-schema-plugin/src/constants.ts\n\nexport const ERROR_PREFIX = \"[search-schema-plugin]\";\n","// packages/search-schema-plugin/src/helpers.ts\n\nimport type { StandardSchemaV1Issue } from \"./types\";\nimport type { Params } from \"@real-router/core\";\n\n/**\n * Extract top-level keys from validation issues.\n * Only processes issues with a non-empty path — issues without path\n * affect the whole object and can't be stripped by key.\n */\nexport function getInvalidKeys(\n issues: readonly StandardSchemaV1Issue[],\n): Set<string> {\n const keys = new Set<string>();\n\n for (const issue of issues) {\n if (issue.path && issue.path.length > 0) {\n const segment = issue.path[0];\n const key =\n typeof segment === \"object\" && \"key\" in segment ? segment.key : segment;\n\n keys.add(String(key));\n }\n }\n\n return keys;\n}\n\n/** Create a shallow copy of params without the specified keys. */\nexport function omitKeys(params: Params, keys: Set<string>): Params {\n const result: Params = {};\n\n for (const key of Object.keys(params)) {\n if (!keys.has(key)) {\n result[key] = params[key];\n }\n }\n\n return result;\n}\n","import { ERROR_PREFIX } from \"./constants\";\nimport { getInvalidKeys, omitKeys } from \"./helpers\";\n\nimport type {\n SearchSchemaPluginOptions,\n StandardSchemaV1,\n StandardSchemaV1Issue,\n} from \"./types\";\nimport type { Params, Plugin, Route } from \"@real-router/core\";\nimport type { PluginApi, RoutesApi } from \"@real-router/core/api\";\n\nexport class SearchSchemaPlugin {\n readonly #pluginApi: PluginApi;\n readonly #routesApi: RoutesApi;\n readonly #mode: \"development\" | \"production\";\n readonly #strict: boolean;\n readonly #onError:\n | ((\n routeName: string,\n params: Params,\n issues: readonly StandardSchemaV1Issue[],\n ) => Params)\n | undefined;\n readonly #removeForwardStateInterceptor: () => void;\n readonly #removeAddInterceptor: () => void;\n\n constructor(\n pluginApi: PluginApi,\n routesApi: RoutesApi,\n options: SearchSchemaPluginOptions,\n ) {\n this.#pluginApi = pluginApi;\n this.#routesApi = routesApi;\n this.#mode = options.mode ?? \"development\";\n this.#strict = options.strict ?? false;\n this.#onError = options.onError;\n\n this.#validateExistingDefaultParams();\n\n this.#removeForwardStateInterceptor = this.#pluginApi.addInterceptor(\n \"forwardState\",\n (next, routeName, routeParams) => {\n const result = next(routeName, routeParams);\n\n return this.#validateState(result);\n },\n );\n\n this.#removeAddInterceptor = this.#pluginApi.addInterceptor(\n \"add\",\n (next, routes, addOptions) => {\n next(routes, addOptions);\n this.#validateRoutesDefaultParams(routes, addOptions?.parent);\n },\n );\n }\n\n getPlugin(): Plugin {\n return {\n teardown: () => {\n this.#removeForwardStateInterceptor();\n this.#removeAddInterceptor();\n },\n };\n }\n\n #getSchema(routeName: string): StandardSchemaV1 | undefined {\n return this.#pluginApi.getRouteConfig(routeName)?.searchSchema as\n | StandardSchemaV1\n | undefined;\n }\n\n #validateState(result: { name: string; params: Params }): {\n name: string;\n params: Params;\n } {\n const schema = this.#getSchema(result.name);\n\n if (!schema) {\n return result;\n }\n\n const validation = schema[\"~standard\"].validate(result.params);\n\n if (validation instanceof Promise) {\n throw new TypeError(\n `${ERROR_PREFIX} Async schema validation is not supported. Route \"${result.name}\" returned a Promise from ~standard.validate().`,\n );\n }\n\n if (\"value\" in validation) {\n const params = this.#strict\n ? (validation.value as Params)\n : { ...result.params, ...(validation.value as Params) };\n\n return { ...result, params };\n }\n\n if (this.#onError) {\n return {\n ...result,\n params: this.#onError(result.name, result.params, validation.issues),\n };\n }\n\n if (this.#mode === \"development\") {\n console.error(\n `${ERROR_PREFIX} Route \"${result.name}\": invalid search params`,\n validation.issues,\n );\n }\n\n const invalidKeys = getInvalidKeys(validation.issues);\n const stripped = omitKeys(result.params, invalidKeys);\n const route = this.#routesApi.get(result.name);\n const defaults = route?.defaultParams;\n const restored = defaults ? { ...defaults, ...stripped } : stripped;\n\n return { ...result, params: restored };\n }\n\n #validateExistingDefaultParams(): void {\n if (this.#mode !== \"development\") {\n return;\n }\n\n const tree = this.#pluginApi.getTree() as unknown as\n | { fullName?: string; children?: ReadonlyMap<string, unknown> }\n | undefined;\n\n /* v8 ignore next -- @preserve: getTree() always returns a RouteTree, defensive check */\n if (!tree) {\n return;\n }\n\n this.#walkTree(tree);\n }\n\n #walkTree(node: {\n fullName?: string;\n children?: ReadonlyMap<string, unknown>;\n }): void {\n if (node.fullName) {\n this.#validateSingleRouteDefaultParams(node.fullName);\n }\n\n /* v8 ignore next 3 -- @preserve: children is always a Map in RouteTree */\n if (node.children instanceof Map) {\n for (const child of node.children.values()) {\n if (child && typeof child === \"object\") {\n this.#walkTree(\n child as {\n fullName?: string;\n children?: ReadonlyMap<string, unknown>;\n },\n );\n }\n }\n }\n }\n\n #validateSingleRouteDefaultParams(routeName: string): void {\n const schema = this.#getSchema(routeName);\n\n if (!schema) {\n return;\n }\n\n const route = this.#routesApi.get(routeName);\n const defaultParams = route?.defaultParams;\n\n if (!defaultParams) {\n return;\n }\n\n const validation = schema[\"~standard\"].validate(defaultParams);\n\n if (validation instanceof Promise) {\n return;\n }\n\n if (\"issues\" in validation) {\n console.warn(\n `${ERROR_PREFIX} Route \"${routeName}\": defaultParams do not pass searchSchema`,\n validation.issues,\n );\n }\n }\n\n #validateRoutesDefaultParams(routes: Route[], prefix = \"\"): void {\n if (this.#mode !== \"development\") {\n return;\n }\n\n for (const route of routes) {\n /* v8 ignore next -- @preserve: Route.name is always a non-empty string */\n if (route.name) {\n const fullName = prefix ? `${prefix}.${route.name}` : route.name;\n\n this.#validateSingleRouteDefaultParams(fullName);\n\n if (route.children) {\n this.#validateRoutesDefaultParams(route.children, fullName);\n }\n }\n }\n }\n}\n","import { ERROR_PREFIX } from \"./constants\";\n\nimport type { SearchSchemaPluginOptions } from \"./types\";\n\nconst VALID_MODES = new Set([\"development\", \"production\"]);\n\nexport function validateOptions(options: SearchSchemaPluginOptions): void {\n if (options.mode !== undefined && !VALID_MODES.has(options.mode)) {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid mode: \"${options.mode}\". Must be \"development\" or \"production\".`,\n );\n }\n\n if (options.strict !== undefined && typeof options.strict !== \"boolean\") {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid strict option: expected boolean, got ${typeof options.strict}.`,\n );\n }\n\n if (options.onError !== undefined && typeof options.onError !== \"function\") {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid onError: expected function, got ${typeof options.onError}.`,\n );\n }\n}\n","import { getPluginApi, getRoutesApi } from \"@real-router/core/api\";\n\nimport { SearchSchemaPlugin } from \"./plugin\";\nimport { validateOptions } from \"./validation\";\n\nimport type { SearchSchemaPluginOptions } from \"./types\";\nimport type { PluginFactory, Plugin } from \"@real-router/core\";\n\nexport function searchSchemaPlugin(\n options: SearchSchemaPluginOptions = {},\n): PluginFactory {\n validateOptions(options);\n\n const frozenOptions: SearchSchemaPluginOptions = Object.freeze({\n ...options,\n });\n\n return (router): Plugin => {\n const pluginApi = getPluginApi(router);\n const routesApi = getRoutesApi(router);\n const plugin = new SearchSchemaPlugin(pluginApi, routesApi, frozenOptions);\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ import { Params, PluginFactory } from "@real-router/core";
2
+
3
+ //#region src/types.d.ts
4
+ /** A single validation issue from Standard Schema V1. */
5
+ interface StandardSchemaV1Issue {
6
+ readonly message: string;
7
+ readonly path?: readonly (PropertyKey | {
8
+ readonly key: PropertyKey;
9
+ })[] | undefined;
10
+ }
11
+ /** Validation result — either success or failure. */
12
+ type StandardSchemaV1Result<Output = unknown> = {
13
+ readonly value: Output;
14
+ } | {
15
+ readonly issues: readonly StandardSchemaV1Issue[];
16
+ };
17
+ /**
18
+ * Standard Schema V1 interface.
19
+ *
20
+ * Supported by Zod 3.24+, Valibot 1.0+, ArkType.
21
+ * The plugin doesn't depend on any specific schema library.
22
+ */
23
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
24
+ readonly "~standard": {
25
+ readonly version: 1;
26
+ readonly vendor: string;
27
+ readonly validate: (value: unknown) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>;
28
+ readonly types?: {
29
+ readonly input: Input;
30
+ readonly output: Output;
31
+ } | undefined;
32
+ };
33
+ }
34
+ interface SearchSchemaPluginOptions {
35
+ /**
36
+ * Error handling mode.
37
+ * - "development" (default): strip invalid + console.error
38
+ * - "production": silent strip
39
+ *
40
+ * For recovery of invalid params use defaultParams (strip + merge + diagnostics).
41
+ * For filling absent params use .default() in schema (no diagnostics).
42
+ * .catch() is not recommended — suppresses errors, mode: "development" won't see the issue.
43
+ */
44
+ readonly mode?: "development" | "production";
45
+ /**
46
+ * Strip search params not described in schema.
47
+ * - false (default): unknown params pass through
48
+ * - true: unknown params are removed
49
+ *
50
+ * Per-route override: .strict() / .passthrough() in Zod schema.
51
+ */
52
+ readonly strict?: boolean;
53
+ /**
54
+ * Custom error handler (overrides mode completely).
55
+ * Must return cleaned params.
56
+ *
57
+ * Contract:
58
+ * - Returned params are used as-is, without re-validation.
59
+ * Responsibility for correctness is on the callback author.
60
+ * (Re-validation would risk infinite loops.)
61
+ * - Exceptions from onError propagate up without suppression.
62
+ * Consistent with interceptor behavior in core.
63
+ * - When onError is set, neither console.error (mode: "development"),
64
+ * nor silent strip (mode: "production") are executed.
65
+ * All responsibility for diagnostics and recovery is on the callback.
66
+ */
67
+ readonly onError?: (routeName: string, params: Params, issues: readonly StandardSchemaV1Issue[]) => Params;
68
+ }
69
+ //#endregion
70
+ //#region src/factory.d.ts
71
+ declare function searchSchemaPlugin(options?: SearchSchemaPluginOptions): PluginFactory;
72
+ //#endregion
73
+ //#region src/index.d.ts
74
+ declare module "@real-router/core" {
75
+ interface Route {
76
+ searchSchema?: StandardSchemaV1;
77
+ }
78
+ }
79
+ //#endregion
80
+ export { type SearchSchemaPluginOptions, type StandardSchemaV1, type StandardSchemaV1Issue, type StandardSchemaV1Result, searchSchemaPlugin };
81
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,2 @@
1
+ import{getPluginApi as e,getRoutesApi as t}from"@real-router/core/api";const n=`[search-schema-plugin]`;function r(e){let t=new Set;for(let n of e)if(n.path&&n.path.length>0){let e=n.path[0],r=typeof e==`object`&&`key`in e?e.key:e;t.add(String(r))}return t}function i(e,t){let n={};for(let r of Object.keys(e))t.has(r)||(n[r]=e[r]);return n}var a=class{#e;#t;#n;#r;#i;#a;#o;constructor(e,t,n){this.#e=e,this.#t=t,this.#n=n.mode??`development`,this.#r=n.strict??!1,this.#i=n.onError,this.#l(),this.#a=this.#e.addInterceptor(`forwardState`,(e,t,n)=>{let r=e(t,n);return this.#c(r)}),this.#o=this.#e.addInterceptor(`add`,(e,t,n)=>{e(t,n),this.#f(t,n?.parent)})}getPlugin(){return{teardown:()=>{this.#a(),this.#o()}}}#s(e){return this.#e.getRouteConfig(e)?.searchSchema}#c(e){let t=this.#s(e.name);if(!t)return e;let a=t[`~standard`].validate(e.params);if(a instanceof Promise)throw TypeError(`${n} Async schema validation is not supported. Route "${e.name}" returned a Promise from ~standard.validate().`);if(`value`in a){let t=this.#r?a.value:{...e.params,...a.value};return{...e,params:t}}if(this.#i)return{...e,params:this.#i(e.name,e.params,a.issues)};this.#n===`development`&&console.error(`${n} Route "${e.name}": invalid search params`,a.issues);let o=r(a.issues),s=i(e.params,o),c=this.#t.get(e.name)?.defaultParams,l=c?{...c,...s}:s;return{...e,params:l}}#l(){if(this.#n!==`development`)return;let e=this.#e.getTree();e&&this.#u(e)}#u(e){if(e.fullName&&this.#d(e.fullName),e.children instanceof Map)for(let t of e.children.values())t&&typeof t==`object`&&this.#u(t)}#d(e){let t=this.#s(e);if(!t)return;let r=this.#t.get(e)?.defaultParams;if(!r)return;let i=t[`~standard`].validate(r);i instanceof Promise||`issues`in i&&console.warn(`${n} Route "${e}": defaultParams do not pass searchSchema`,i.issues)}#f(e,t=``){if(this.#n===`development`){for(let n of e)if(n.name){let e=t?`${t}.${n.name}`:n.name;this.#d(e),n.children&&this.#f(n.children,e)}}}};const o=new Set([`development`,`production`]);function s(e){if(e.mode!==void 0&&!o.has(e.mode))throw TypeError(`${n} Invalid mode: "${e.mode}". Must be "development" or "production".`);if(e.strict!==void 0&&typeof e.strict!=`boolean`)throw TypeError(`${n} Invalid strict option: expected boolean, got ${typeof e.strict}.`);if(e.onError!==void 0&&typeof e.onError!=`function`)throw TypeError(`${n} Invalid onError: expected function, got ${typeof e.onError}.`)}function c(n={}){s(n);let r=Object.freeze({...n});return n=>new a(e(n),t(n),r).getPlugin()}export{c as searchSchemaPlugin};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["#pluginApi","#routesApi","#mode","#strict","#onError","#removeForwardStateInterceptor","#removeAddInterceptor","#validateExistingDefaultParams","#validateState","#validateRoutesDefaultParams","#getSchema","#walkTree","#validateSingleRouteDefaultParams"],"sources":["../../src/constants.ts","../../src/helpers.ts","../../src/plugin.ts","../../src/validation.ts","../../src/factory.ts"],"sourcesContent":["// packages/search-schema-plugin/src/constants.ts\n\nexport const ERROR_PREFIX = \"[search-schema-plugin]\";\n","// packages/search-schema-plugin/src/helpers.ts\n\nimport type { StandardSchemaV1Issue } from \"./types\";\nimport type { Params } from \"@real-router/core\";\n\n/**\n * Extract top-level keys from validation issues.\n * Only processes issues with a non-empty path — issues without path\n * affect the whole object and can't be stripped by key.\n */\nexport function getInvalidKeys(\n issues: readonly StandardSchemaV1Issue[],\n): Set<string> {\n const keys = new Set<string>();\n\n for (const issue of issues) {\n if (issue.path && issue.path.length > 0) {\n const segment = issue.path[0];\n const key =\n typeof segment === \"object\" && \"key\" in segment ? segment.key : segment;\n\n keys.add(String(key));\n }\n }\n\n return keys;\n}\n\n/** Create a shallow copy of params without the specified keys. */\nexport function omitKeys(params: Params, keys: Set<string>): Params {\n const result: Params = {};\n\n for (const key of Object.keys(params)) {\n if (!keys.has(key)) {\n result[key] = params[key];\n }\n }\n\n return result;\n}\n","import { ERROR_PREFIX } from \"./constants\";\nimport { getInvalidKeys, omitKeys } from \"./helpers\";\n\nimport type {\n SearchSchemaPluginOptions,\n StandardSchemaV1,\n StandardSchemaV1Issue,\n} from \"./types\";\nimport type { Params, Plugin, Route } from \"@real-router/core\";\nimport type { PluginApi, RoutesApi } from \"@real-router/core/api\";\n\nexport class SearchSchemaPlugin {\n readonly #pluginApi: PluginApi;\n readonly #routesApi: RoutesApi;\n readonly #mode: \"development\" | \"production\";\n readonly #strict: boolean;\n readonly #onError:\n | ((\n routeName: string,\n params: Params,\n issues: readonly StandardSchemaV1Issue[],\n ) => Params)\n | undefined;\n readonly #removeForwardStateInterceptor: () => void;\n readonly #removeAddInterceptor: () => void;\n\n constructor(\n pluginApi: PluginApi,\n routesApi: RoutesApi,\n options: SearchSchemaPluginOptions,\n ) {\n this.#pluginApi = pluginApi;\n this.#routesApi = routesApi;\n this.#mode = options.mode ?? \"development\";\n this.#strict = options.strict ?? false;\n this.#onError = options.onError;\n\n this.#validateExistingDefaultParams();\n\n this.#removeForwardStateInterceptor = this.#pluginApi.addInterceptor(\n \"forwardState\",\n (next, routeName, routeParams) => {\n const result = next(routeName, routeParams);\n\n return this.#validateState(result);\n },\n );\n\n this.#removeAddInterceptor = this.#pluginApi.addInterceptor(\n \"add\",\n (next, routes, addOptions) => {\n next(routes, addOptions);\n this.#validateRoutesDefaultParams(routes, addOptions?.parent);\n },\n );\n }\n\n getPlugin(): Plugin {\n return {\n teardown: () => {\n this.#removeForwardStateInterceptor();\n this.#removeAddInterceptor();\n },\n };\n }\n\n #getSchema(routeName: string): StandardSchemaV1 | undefined {\n return this.#pluginApi.getRouteConfig(routeName)?.searchSchema as\n | StandardSchemaV1\n | undefined;\n }\n\n #validateState(result: { name: string; params: Params }): {\n name: string;\n params: Params;\n } {\n const schema = this.#getSchema(result.name);\n\n if (!schema) {\n return result;\n }\n\n const validation = schema[\"~standard\"].validate(result.params);\n\n if (validation instanceof Promise) {\n throw new TypeError(\n `${ERROR_PREFIX} Async schema validation is not supported. Route \"${result.name}\" returned a Promise from ~standard.validate().`,\n );\n }\n\n if (\"value\" in validation) {\n const params = this.#strict\n ? (validation.value as Params)\n : { ...result.params, ...(validation.value as Params) };\n\n return { ...result, params };\n }\n\n if (this.#onError) {\n return {\n ...result,\n params: this.#onError(result.name, result.params, validation.issues),\n };\n }\n\n if (this.#mode === \"development\") {\n console.error(\n `${ERROR_PREFIX} Route \"${result.name}\": invalid search params`,\n validation.issues,\n );\n }\n\n const invalidKeys = getInvalidKeys(validation.issues);\n const stripped = omitKeys(result.params, invalidKeys);\n const route = this.#routesApi.get(result.name);\n const defaults = route?.defaultParams;\n const restored = defaults ? { ...defaults, ...stripped } : stripped;\n\n return { ...result, params: restored };\n }\n\n #validateExistingDefaultParams(): void {\n if (this.#mode !== \"development\") {\n return;\n }\n\n const tree = this.#pluginApi.getTree() as unknown as\n | { fullName?: string; children?: ReadonlyMap<string, unknown> }\n | undefined;\n\n /* v8 ignore next -- @preserve: getTree() always returns a RouteTree, defensive check */\n if (!tree) {\n return;\n }\n\n this.#walkTree(tree);\n }\n\n #walkTree(node: {\n fullName?: string;\n children?: ReadonlyMap<string, unknown>;\n }): void {\n if (node.fullName) {\n this.#validateSingleRouteDefaultParams(node.fullName);\n }\n\n /* v8 ignore next 3 -- @preserve: children is always a Map in RouteTree */\n if (node.children instanceof Map) {\n for (const child of node.children.values()) {\n if (child && typeof child === \"object\") {\n this.#walkTree(\n child as {\n fullName?: string;\n children?: ReadonlyMap<string, unknown>;\n },\n );\n }\n }\n }\n }\n\n #validateSingleRouteDefaultParams(routeName: string): void {\n const schema = this.#getSchema(routeName);\n\n if (!schema) {\n return;\n }\n\n const route = this.#routesApi.get(routeName);\n const defaultParams = route?.defaultParams;\n\n if (!defaultParams) {\n return;\n }\n\n const validation = schema[\"~standard\"].validate(defaultParams);\n\n if (validation instanceof Promise) {\n return;\n }\n\n if (\"issues\" in validation) {\n console.warn(\n `${ERROR_PREFIX} Route \"${routeName}\": defaultParams do not pass searchSchema`,\n validation.issues,\n );\n }\n }\n\n #validateRoutesDefaultParams(routes: Route[], prefix = \"\"): void {\n if (this.#mode !== \"development\") {\n return;\n }\n\n for (const route of routes) {\n /* v8 ignore next -- @preserve: Route.name is always a non-empty string */\n if (route.name) {\n const fullName = prefix ? `${prefix}.${route.name}` : route.name;\n\n this.#validateSingleRouteDefaultParams(fullName);\n\n if (route.children) {\n this.#validateRoutesDefaultParams(route.children, fullName);\n }\n }\n }\n }\n}\n","import { ERROR_PREFIX } from \"./constants\";\n\nimport type { SearchSchemaPluginOptions } from \"./types\";\n\nconst VALID_MODES = new Set([\"development\", \"production\"]);\n\nexport function validateOptions(options: SearchSchemaPluginOptions): void {\n if (options.mode !== undefined && !VALID_MODES.has(options.mode)) {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid mode: \"${options.mode}\". Must be \"development\" or \"production\".`,\n );\n }\n\n if (options.strict !== undefined && typeof options.strict !== \"boolean\") {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid strict option: expected boolean, got ${typeof options.strict}.`,\n );\n }\n\n if (options.onError !== undefined && typeof options.onError !== \"function\") {\n throw new TypeError(\n `${ERROR_PREFIX} Invalid onError: expected function, got ${typeof options.onError}.`,\n );\n }\n}\n","import { getPluginApi, getRoutesApi } from \"@real-router/core/api\";\n\nimport { SearchSchemaPlugin } from \"./plugin\";\nimport { validateOptions } from \"./validation\";\n\nimport type { SearchSchemaPluginOptions } from \"./types\";\nimport type { PluginFactory, Plugin } from \"@real-router/core\";\n\nexport function searchSchemaPlugin(\n options: SearchSchemaPluginOptions = {},\n): PluginFactory {\n validateOptions(options);\n\n const frozenOptions: SearchSchemaPluginOptions = Object.freeze({\n ...options,\n });\n\n return (router): Plugin => {\n const pluginApi = getPluginApi(router);\n const routesApi = getRoutesApi(router);\n const plugin = new SearchSchemaPlugin(pluginApi, routesApi, frozenOptions);\n\n return plugin.getPlugin();\n };\n}\n"],"mappings":"uEAEA,MAAa,EAAe,yBCQ5B,SAAgB,EACd,EACa,CACb,IAAM,EAAO,IAAI,IAEjB,IAAK,IAAM,KAAS,EAClB,GAAI,EAAM,MAAQ,EAAM,KAAK,OAAS,EAAG,CACvC,IAAM,EAAU,EAAM,KAAK,GACrB,EACJ,OAAO,GAAY,UAAY,QAAS,EAAU,EAAQ,IAAM,EAElE,EAAK,IAAI,OAAO,EAAI,CAAC,CAIzB,OAAO,EAIT,SAAgB,EAAS,EAAgB,EAA2B,CAClE,IAAM,EAAiB,EAAE,CAEzB,IAAK,IAAM,KAAO,OAAO,KAAK,EAAO,CAC9B,EAAK,IAAI,EAAI,GAChB,EAAO,GAAO,EAAO,IAIzB,OAAO,EC3BT,IAAa,EAAb,KAAgC,CAC9B,GACA,GACA,GACA,GACA,GAOA,GACA,GAEA,YACE,EACA,EACA,EACA,CACA,MAAA,EAAkB,EAClB,MAAA,EAAkB,EAClB,MAAA,EAAa,EAAQ,MAAQ,cAC7B,MAAA,EAAe,EAAQ,QAAU,GACjC,MAAA,EAAgB,EAAQ,QAExB,MAAA,GAAqC,CAErC,MAAA,EAAsC,MAAA,EAAgB,eACpD,gBACC,EAAM,EAAW,IAAgB,CAChC,IAAM,EAAS,EAAK,EAAW,EAAY,CAE3C,OAAO,MAAA,EAAoB,EAAO,EAErC,CAED,MAAA,EAA6B,MAAA,EAAgB,eAC3C,OACC,EAAM,EAAQ,IAAe,CAC5B,EAAK,EAAQ,EAAW,CACxB,MAAA,EAAkC,EAAQ,GAAY,OAAO,EAEhE,CAGH,WAAoB,CAClB,MAAO,CACL,aAAgB,CACd,MAAA,GAAqC,CACrC,MAAA,GAA4B,EAE/B,CAGH,GAAW,EAAiD,CAC1D,OAAO,MAAA,EAAgB,eAAe,EAAU,EAAE,aAKpD,GAAe,EAGb,CACA,IAAM,EAAS,MAAA,EAAgB,EAAO,KAAK,CAE3C,GAAI,CAAC,EACH,OAAO,EAGT,IAAM,EAAa,EAAO,aAAa,SAAS,EAAO,OAAO,CAE9D,GAAI,aAAsB,QACxB,MAAU,UACR,GAAG,EAAa,oDAAoD,EAAO,KAAK,iDACjF,CAGH,GAAI,UAAW,EAAY,CACzB,IAAM,EAAS,MAAA,EACV,EAAW,MACZ,CAAE,GAAG,EAAO,OAAQ,GAAI,EAAW,MAAkB,CAEzD,MAAO,CAAE,GAAG,EAAQ,SAAQ,CAG9B,GAAI,MAAA,EACF,MAAO,CACL,GAAG,EACH,OAAQ,MAAA,EAAc,EAAO,KAAM,EAAO,OAAQ,EAAW,OAAO,CACrE,CAGC,MAAA,IAAe,eACjB,QAAQ,MACN,GAAG,EAAa,UAAU,EAAO,KAAK,0BACtC,EAAW,OACZ,CAGH,IAAM,EAAc,EAAe,EAAW,OAAO,CAC/C,EAAW,EAAS,EAAO,OAAQ,EAAY,CAE/C,EADQ,MAAA,EAAgB,IAAI,EAAO,KAAK,EACtB,cAClB,EAAW,EAAW,CAAE,GAAG,EAAU,GAAG,EAAU,CAAG,EAE3D,MAAO,CAAE,GAAG,EAAQ,OAAQ,EAAU,CAGxC,IAAuC,CACrC,GAAI,MAAA,IAAe,cACjB,OAGF,IAAM,EAAO,MAAA,EAAgB,SAAS,CAKjC,GAIL,MAAA,EAAe,EAAK,CAGtB,GAAU,EAGD,CAMP,GALI,EAAK,UACP,MAAA,EAAuC,EAAK,SAAS,CAInD,EAAK,oBAAoB,QACtB,IAAM,KAAS,EAAK,SAAS,QAAQ,CACpC,GAAS,OAAO,GAAU,UAC5B,MAAA,EACE,EAID,CAMT,GAAkC,EAAyB,CACzD,IAAM,EAAS,MAAA,EAAgB,EAAU,CAEzC,GAAI,CAAC,EACH,OAIF,IAAM,EADQ,MAAA,EAAgB,IAAI,EAAU,EACf,cAE7B,GAAI,CAAC,EACH,OAGF,IAAM,EAAa,EAAO,aAAa,SAAS,EAAc,CAE1D,aAAsB,SAItB,WAAY,GACd,QAAQ,KACN,GAAG,EAAa,UAAU,EAAU,2CACpC,EAAW,OACZ,CAIL,GAA6B,EAAiB,EAAS,GAAU,CAC3D,SAAA,IAAe,cAInB,KAAK,IAAM,KAAS,EAElB,GAAI,EAAM,KAAM,CACd,IAAM,EAAW,EAAS,GAAG,EAAO,GAAG,EAAM,OAAS,EAAM,KAE5D,MAAA,EAAuC,EAAS,CAE5C,EAAM,UACR,MAAA,EAAkC,EAAM,SAAU,EAAS,KCtMrE,MAAM,EAAc,IAAI,IAAI,CAAC,cAAe,aAAa,CAAC,CAE1D,SAAgB,EAAgB,EAA0C,CACxE,GAAI,EAAQ,OAAS,IAAA,IAAa,CAAC,EAAY,IAAI,EAAQ,KAAK,CAC9D,MAAU,UACR,GAAG,EAAa,kBAAkB,EAAQ,KAAK,2CAChD,CAGH,GAAI,EAAQ,SAAW,IAAA,IAAa,OAAO,EAAQ,QAAW,UAC5D,MAAU,UACR,GAAG,EAAa,gDAAgD,OAAO,EAAQ,OAAO,GACvF,CAGH,GAAI,EAAQ,UAAY,IAAA,IAAa,OAAO,EAAQ,SAAY,WAC9D,MAAU,UACR,GAAG,EAAa,2CAA2C,OAAO,EAAQ,QAAQ,GACnF,CCdL,SAAgB,EACd,EAAqC,EAAE,CACxB,CACf,EAAgB,EAAQ,CAExB,IAAM,EAA2C,OAAO,OAAO,CAC7D,GAAG,EACJ,CAAC,CAEF,MAAQ,IAGS,IAAI,EAFD,EAAa,EAAO,CACpB,EAAa,EAAO,CACsB,EAAc,CAE5D,WAAW"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@real-router/search-schema-plugin",
3
+ "version": "0.0.1",
4
+ "type": "commonjs",
5
+ "description": "Runtime search parameter validation via Standard Schema for Real-Router",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.mjs",
8
+ "types": "./dist/esm/index.d.mts",
9
+ "exports": {
10
+ ".": {
11
+ "development": "./src/index.ts",
12
+ "types": {
13
+ "import": "./dist/esm/index.d.mts",
14
+ "require": "./dist/cjs/index.d.ts"
15
+ },
16
+ "import": "./dist/esm/index.mjs",
17
+ "require": "./dist/cjs/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/greydragon888/real-router.git"
27
+ },
28
+ "keywords": [
29
+ "real-router",
30
+ "search-params",
31
+ "validation",
32
+ "schema",
33
+ "standard-schema",
34
+ "zod",
35
+ "valibot"
36
+ ],
37
+ "author": {
38
+ "name": "Oleg Ivanov",
39
+ "email": "greydragon888@gmail.com",
40
+ "url": "https://github.com/greydragon888"
41
+ },
42
+ "license": "MIT",
43
+ "bugs": {
44
+ "url": "https://github.com/greydragon888/real-router/issues"
45
+ },
46
+ "homepage": "https://github.com/greydragon888/real-router",
47
+ "scripts": {
48
+ "test": "vitest",
49
+ "test:properties": "vitest run --config vitest.config.properties.mts",
50
+ "build": "tsdown --config-loader unrun",
51
+ "type-check": "tsc --noEmit",
52
+ "lint": "eslint --cache --ext .ts src/ tests/ --fix --max-warnings 0",
53
+ "lint:package": "publint",
54
+ "lint:types": "attw --pack .",
55
+ "build:dist-only": "tsdown --config-loader unrun"
56
+ },
57
+ "sideEffects": false,
58
+ "dependencies": {
59
+ "@real-router/core": "workspace:^"
60
+ }
61
+ }
@@ -0,0 +1,3 @@
1
+ // packages/search-schema-plugin/src/constants.ts
2
+
3
+ export const ERROR_PREFIX = "[search-schema-plugin]";
package/src/factory.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { getPluginApi, getRoutesApi } from "@real-router/core/api";
2
+
3
+ import { SearchSchemaPlugin } from "./plugin";
4
+ import { validateOptions } from "./validation";
5
+
6
+ import type { SearchSchemaPluginOptions } from "./types";
7
+ import type { PluginFactory, Plugin } from "@real-router/core";
8
+
9
+ export function searchSchemaPlugin(
10
+ options: SearchSchemaPluginOptions = {},
11
+ ): PluginFactory {
12
+ validateOptions(options);
13
+
14
+ const frozenOptions: SearchSchemaPluginOptions = Object.freeze({
15
+ ...options,
16
+ });
17
+
18
+ return (router): Plugin => {
19
+ const pluginApi = getPluginApi(router);
20
+ const routesApi = getRoutesApi(router);
21
+ const plugin = new SearchSchemaPlugin(pluginApi, routesApi, frozenOptions);
22
+
23
+ return plugin.getPlugin();
24
+ };
25
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,40 @@
1
+ // packages/search-schema-plugin/src/helpers.ts
2
+
3
+ import type { StandardSchemaV1Issue } from "./types";
4
+ import type { Params } from "@real-router/core";
5
+
6
+ /**
7
+ * Extract top-level keys from validation issues.
8
+ * Only processes issues with a non-empty path — issues without path
9
+ * affect the whole object and can't be stripped by key.
10
+ */
11
+ export function getInvalidKeys(
12
+ issues: readonly StandardSchemaV1Issue[],
13
+ ): Set<string> {
14
+ const keys = new Set<string>();
15
+
16
+ for (const issue of issues) {
17
+ if (issue.path && issue.path.length > 0) {
18
+ const segment = issue.path[0];
19
+ const key =
20
+ typeof segment === "object" && "key" in segment ? segment.key : segment;
21
+
22
+ keys.add(String(key));
23
+ }
24
+ }
25
+
26
+ return keys;
27
+ }
28
+
29
+ /** Create a shallow copy of params without the specified keys. */
30
+ export function omitKeys(params: Params, keys: Set<string>): Params {
31
+ const result: Params = {};
32
+
33
+ for (const key of Object.keys(params)) {
34
+ if (!keys.has(key)) {
35
+ result[key] = params[key];
36
+ }
37
+ }
38
+
39
+ return result;
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { StandardSchemaV1 } from "./types";
2
+
3
+ export { searchSchemaPlugin } from "./factory";
4
+
5
+ declare module "@real-router/core" {
6
+ interface Route {
7
+ searchSchema?: StandardSchemaV1;
8
+ }
9
+ }
10
+
11
+ export type {
12
+ SearchSchemaPluginOptions,
13
+ StandardSchemaV1,
14
+ StandardSchemaV1Issue,
15
+ StandardSchemaV1Result,
16
+ } from "./types";
package/src/plugin.ts ADDED
@@ -0,0 +1,208 @@
1
+ import { ERROR_PREFIX } from "./constants";
2
+ import { getInvalidKeys, omitKeys } from "./helpers";
3
+
4
+ import type {
5
+ SearchSchemaPluginOptions,
6
+ StandardSchemaV1,
7
+ StandardSchemaV1Issue,
8
+ } from "./types";
9
+ import type { Params, Plugin, Route } from "@real-router/core";
10
+ import type { PluginApi, RoutesApi } from "@real-router/core/api";
11
+
12
+ export class SearchSchemaPlugin {
13
+ readonly #pluginApi: PluginApi;
14
+ readonly #routesApi: RoutesApi;
15
+ readonly #mode: "development" | "production";
16
+ readonly #strict: boolean;
17
+ readonly #onError:
18
+ | ((
19
+ routeName: string,
20
+ params: Params,
21
+ issues: readonly StandardSchemaV1Issue[],
22
+ ) => Params)
23
+ | undefined;
24
+ readonly #removeForwardStateInterceptor: () => void;
25
+ readonly #removeAddInterceptor: () => void;
26
+
27
+ constructor(
28
+ pluginApi: PluginApi,
29
+ routesApi: RoutesApi,
30
+ options: SearchSchemaPluginOptions,
31
+ ) {
32
+ this.#pluginApi = pluginApi;
33
+ this.#routesApi = routesApi;
34
+ this.#mode = options.mode ?? "development";
35
+ this.#strict = options.strict ?? false;
36
+ this.#onError = options.onError;
37
+
38
+ this.#validateExistingDefaultParams();
39
+
40
+ this.#removeForwardStateInterceptor = this.#pluginApi.addInterceptor(
41
+ "forwardState",
42
+ (next, routeName, routeParams) => {
43
+ const result = next(routeName, routeParams);
44
+
45
+ return this.#validateState(result);
46
+ },
47
+ );
48
+
49
+ this.#removeAddInterceptor = this.#pluginApi.addInterceptor(
50
+ "add",
51
+ (next, routes, addOptions) => {
52
+ next(routes, addOptions);
53
+ this.#validateRoutesDefaultParams(routes, addOptions?.parent);
54
+ },
55
+ );
56
+ }
57
+
58
+ getPlugin(): Plugin {
59
+ return {
60
+ teardown: () => {
61
+ this.#removeForwardStateInterceptor();
62
+ this.#removeAddInterceptor();
63
+ },
64
+ };
65
+ }
66
+
67
+ #getSchema(routeName: string): StandardSchemaV1 | undefined {
68
+ return this.#pluginApi.getRouteConfig(routeName)?.searchSchema as
69
+ | StandardSchemaV1
70
+ | undefined;
71
+ }
72
+
73
+ #validateState(result: { name: string; params: Params }): {
74
+ name: string;
75
+ params: Params;
76
+ } {
77
+ const schema = this.#getSchema(result.name);
78
+
79
+ if (!schema) {
80
+ return result;
81
+ }
82
+
83
+ const validation = schema["~standard"].validate(result.params);
84
+
85
+ if (validation instanceof Promise) {
86
+ throw new TypeError(
87
+ `${ERROR_PREFIX} Async schema validation is not supported. Route "${result.name}" returned a Promise from ~standard.validate().`,
88
+ );
89
+ }
90
+
91
+ if ("value" in validation) {
92
+ const params = this.#strict
93
+ ? (validation.value as Params)
94
+ : { ...result.params, ...(validation.value as Params) };
95
+
96
+ return { ...result, params };
97
+ }
98
+
99
+ if (this.#onError) {
100
+ return {
101
+ ...result,
102
+ params: this.#onError(result.name, result.params, validation.issues),
103
+ };
104
+ }
105
+
106
+ if (this.#mode === "development") {
107
+ console.error(
108
+ `${ERROR_PREFIX} Route "${result.name}": invalid search params`,
109
+ validation.issues,
110
+ );
111
+ }
112
+
113
+ const invalidKeys = getInvalidKeys(validation.issues);
114
+ const stripped = omitKeys(result.params, invalidKeys);
115
+ const route = this.#routesApi.get(result.name);
116
+ const defaults = route?.defaultParams;
117
+ const restored = defaults ? { ...defaults, ...stripped } : stripped;
118
+
119
+ return { ...result, params: restored };
120
+ }
121
+
122
+ #validateExistingDefaultParams(): void {
123
+ if (this.#mode !== "development") {
124
+ return;
125
+ }
126
+
127
+ const tree = this.#pluginApi.getTree() as unknown as
128
+ | { fullName?: string; children?: ReadonlyMap<string, unknown> }
129
+ | undefined;
130
+
131
+ /* v8 ignore next -- @preserve: getTree() always returns a RouteTree, defensive check */
132
+ if (!tree) {
133
+ return;
134
+ }
135
+
136
+ this.#walkTree(tree);
137
+ }
138
+
139
+ #walkTree(node: {
140
+ fullName?: string;
141
+ children?: ReadonlyMap<string, unknown>;
142
+ }): void {
143
+ if (node.fullName) {
144
+ this.#validateSingleRouteDefaultParams(node.fullName);
145
+ }
146
+
147
+ /* v8 ignore next 3 -- @preserve: children is always a Map in RouteTree */
148
+ if (node.children instanceof Map) {
149
+ for (const child of node.children.values()) {
150
+ if (child && typeof child === "object") {
151
+ this.#walkTree(
152
+ child as {
153
+ fullName?: string;
154
+ children?: ReadonlyMap<string, unknown>;
155
+ },
156
+ );
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ #validateSingleRouteDefaultParams(routeName: string): void {
163
+ const schema = this.#getSchema(routeName);
164
+
165
+ if (!schema) {
166
+ return;
167
+ }
168
+
169
+ const route = this.#routesApi.get(routeName);
170
+ const defaultParams = route?.defaultParams;
171
+
172
+ if (!defaultParams) {
173
+ return;
174
+ }
175
+
176
+ const validation = schema["~standard"].validate(defaultParams);
177
+
178
+ if (validation instanceof Promise) {
179
+ return;
180
+ }
181
+
182
+ if ("issues" in validation) {
183
+ console.warn(
184
+ `${ERROR_PREFIX} Route "${routeName}": defaultParams do not pass searchSchema`,
185
+ validation.issues,
186
+ );
187
+ }
188
+ }
189
+
190
+ #validateRoutesDefaultParams(routes: Route[], prefix = ""): void {
191
+ if (this.#mode !== "development") {
192
+ return;
193
+ }
194
+
195
+ for (const route of routes) {
196
+ /* v8 ignore next -- @preserve: Route.name is always a non-empty string */
197
+ if (route.name) {
198
+ const fullName = prefix ? `${prefix}.${route.name}` : route.name;
199
+
200
+ this.#validateSingleRouteDefaultParams(fullName);
201
+
202
+ if (route.children) {
203
+ this.#validateRoutesDefaultParams(route.children, fullName);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ // packages/search-schema-plugin/src/types.ts
2
+
3
+ import type { Params } from "@real-router/core";
4
+
5
+ // =============================================================================
6
+ // Standard Schema V1 (inline — zero external deps)
7
+ // https://github.com/standard-schema/standard-schema
8
+ // =============================================================================
9
+
10
+ /** A single validation issue from Standard Schema V1. */
11
+ export interface StandardSchemaV1Issue {
12
+ readonly message: string;
13
+ readonly path?:
14
+ | readonly (PropertyKey | { readonly key: PropertyKey })[]
15
+ | undefined;
16
+ }
17
+
18
+ /** Validation result — either success or failure. */
19
+ export type StandardSchemaV1Result<Output = unknown> =
20
+ | { readonly value: Output }
21
+ | { readonly issues: readonly StandardSchemaV1Issue[] };
22
+
23
+ /**
24
+ * Standard Schema V1 interface.
25
+ *
26
+ * Supported by Zod 3.24+, Valibot 1.0+, ArkType.
27
+ * The plugin doesn't depend on any specific schema library.
28
+ */
29
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
30
+ readonly "~standard": {
31
+ readonly version: 1;
32
+ readonly vendor: string;
33
+ readonly validate: (
34
+ value: unknown,
35
+ ) =>
36
+ | StandardSchemaV1Result<Output>
37
+ | Promise<StandardSchemaV1Result<Output>>;
38
+ readonly types?:
39
+ | {
40
+ readonly input: Input;
41
+ readonly output: Output;
42
+ }
43
+ | undefined;
44
+ };
45
+ }
46
+
47
+ // =============================================================================
48
+ // Plugin Options
49
+ // =============================================================================
50
+
51
+ export interface SearchSchemaPluginOptions {
52
+ /**
53
+ * Error handling mode.
54
+ * - "development" (default): strip invalid + console.error
55
+ * - "production": silent strip
56
+ *
57
+ * For recovery of invalid params use defaultParams (strip + merge + diagnostics).
58
+ * For filling absent params use .default() in schema (no diagnostics).
59
+ * .catch() is not recommended — suppresses errors, mode: "development" won't see the issue.
60
+ */
61
+ readonly mode?: "development" | "production";
62
+
63
+ /**
64
+ * Strip search params not described in schema.
65
+ * - false (default): unknown params pass through
66
+ * - true: unknown params are removed
67
+ *
68
+ * Per-route override: .strict() / .passthrough() in Zod schema.
69
+ */
70
+ readonly strict?: boolean;
71
+
72
+ /**
73
+ * Custom error handler (overrides mode completely).
74
+ * Must return cleaned params.
75
+ *
76
+ * Contract:
77
+ * - Returned params are used as-is, without re-validation.
78
+ * Responsibility for correctness is on the callback author.
79
+ * (Re-validation would risk infinite loops.)
80
+ * - Exceptions from onError propagate up without suppression.
81
+ * Consistent with interceptor behavior in core.
82
+ * - When onError is set, neither console.error (mode: "development"),
83
+ * nor silent strip (mode: "production") are executed.
84
+ * All responsibility for diagnostics and recovery is on the callback.
85
+ */
86
+ readonly onError?: (
87
+ routeName: string,
88
+ params: Params,
89
+ issues: readonly StandardSchemaV1Issue[],
90
+ ) => Params;
91
+ }
@@ -0,0 +1,25 @@
1
+ import { ERROR_PREFIX } from "./constants";
2
+
3
+ import type { SearchSchemaPluginOptions } from "./types";
4
+
5
+ const VALID_MODES = new Set(["development", "production"]);
6
+
7
+ export function validateOptions(options: SearchSchemaPluginOptions): void {
8
+ if (options.mode !== undefined && !VALID_MODES.has(options.mode)) {
9
+ throw new TypeError(
10
+ `${ERROR_PREFIX} Invalid mode: "${options.mode}". Must be "development" or "production".`,
11
+ );
12
+ }
13
+
14
+ if (options.strict !== undefined && typeof options.strict !== "boolean") {
15
+ throw new TypeError(
16
+ `${ERROR_PREFIX} Invalid strict option: expected boolean, got ${typeof options.strict}.`,
17
+ );
18
+ }
19
+
20
+ if (options.onError !== undefined && typeof options.onError !== "function") {
21
+ throw new TypeError(
22
+ `${ERROR_PREFIX} Invalid onError: expected function, got ${typeof options.onError}.`,
23
+ );
24
+ }
25
+ }