workstar-router 0.1.0-alpha.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Workstar Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Workstar Router
2
+
3
+ An experimental isomorphic URL matcher for Workstar applications. It has no DOM dependency and does not intercept links. The same route manifest can choose a server-rendered page and build browser links.
4
+
5
+ ```ts
6
+ import { createRouter } from 'workstar-router';
7
+
8
+ const router = createRouter([
9
+ {
10
+ name: 'home',
11
+ path: '/:locale?',
12
+ validate: { locale: (value) => ['en', 'de'].includes(value) },
13
+ },
14
+ { name: 'service', path: '/:locale?/services/:service' },
15
+ ] as const);
16
+
17
+ router.match('/de/services/audit');
18
+ router.href('service', { locale: 'de', service: 'audit' });
19
+ ```
20
+
21
+ Static segments rank ahead of parameters. Optional parameters use `:name?`; final rest parameters use `*name`. Path parameters are decoded safely and link parameters are encoded. Native `<a href>` navigation remains the default, preserving server rendering and no-JavaScript behavior.
22
+ Use `validate` for constrained parameters such as supported locales; otherwise any non-empty segment is accepted.
23
+
24
+ This is not yet a client-side navigation system or a server framework. Request handling, data loading, form submissions, and page metadata belong to the application layer. MIT licensed.
@@ -0,0 +1,14 @@
1
+ export interface RouteDefinition<Name extends string = string> {
2
+ readonly name: Name;
3
+ readonly path: string;
4
+ readonly validate?: Readonly<Record<string, (value: string) => boolean>>;
5
+ }
6
+ export interface RouteMatch<Name extends string = string> {
7
+ readonly name: Name;
8
+ readonly path: string;
9
+ readonly params: Readonly<Record<string, string>>;
10
+ }
11
+ export declare function createRouter<const Name extends string>(definitions: readonly RouteDefinition<Name>[]): {
12
+ match(pathname: string): RouteMatch<Name> | null;
13
+ href(name: Name, params?: Readonly<Record<string, string | undefined>>): string;
14
+ };
package/dist/index.js ADDED
@@ -0,0 +1,206 @@
1
+ function splitPath(path, pattern = false) {
2
+ if (!path.startsWith('/') ||
3
+ (!pattern && path.includes('?')) ||
4
+ path.includes('#')) {
5
+ throw new TypeError('Route paths must be absolute pathnames.');
6
+ }
7
+ if (path === '/')
8
+ return [];
9
+ if (path.includes('//'))
10
+ throw new TypeError('Route paths cannot contain empty segments.');
11
+ return path.replace(/\/$/, '').slice(1).split('/');
12
+ }
13
+ function compile(definition) {
14
+ const names = new Set();
15
+ const segments = splitPath(definition.path, true).map((part, index, parts) => {
16
+ const parameter = /^:([A-Za-z][A-Za-z0-9_]*)(\?)?$/.exec(part);
17
+ const rest = /^\*([A-Za-z][A-Za-z0-9_]*)$/.exec(part);
18
+ if (parameter || rest) {
19
+ const name = (parameter ?? rest)?.[1];
20
+ if (!name || names.has(name))
21
+ throw new TypeError('Duplicate or invalid route parameter.');
22
+ names.add(name);
23
+ if (rest) {
24
+ if (index !== parts.length - 1)
25
+ throw new TypeError('Rest parameter must be final.');
26
+ return { kind: 'rest', name };
27
+ }
28
+ return { kind: 'parameter', name, optional: Boolean(parameter?.[2]) };
29
+ }
30
+ if (part.startsWith(':') ||
31
+ part.startsWith('*') ||
32
+ !/^[A-Za-z0-9._~-]+$/.test(part)) {
33
+ throw new TypeError(`Invalid static route segment: ${part}`);
34
+ }
35
+ return { kind: 'static', value: part };
36
+ });
37
+ const shape = segments
38
+ .map((segment) => segment.kind === 'static'
39
+ ? `s:${segment.value}`
40
+ : segment.kind === 'rest'
41
+ ? '*'
42
+ : segment.optional
43
+ ? ':?'
44
+ : ':')
45
+ .join('/');
46
+ for (const name of Object.keys(definition.validate ?? {})) {
47
+ if (!names.has(name))
48
+ throw new TypeError(`Unknown route validator ${name}.`);
49
+ }
50
+ return { definition, segments, shape };
51
+ }
52
+ function specificity(segment) {
53
+ if (!segment)
54
+ return 2.5;
55
+ if (segment.kind === 'static')
56
+ return 4;
57
+ if (segment.kind === 'rest')
58
+ return 1;
59
+ return segment.optional ? 2 : 3;
60
+ }
61
+ function compareRoutes(left, right) {
62
+ const length = Math.max(left.segments.length, right.segments.length);
63
+ for (let index = 0; index < length; index++) {
64
+ const difference = specificity(right.segments[index]) - specificity(left.segments[index]);
65
+ if (difference !== 0)
66
+ return difference;
67
+ }
68
+ return 0;
69
+ }
70
+ function validParameters(route, params) {
71
+ for (const [name, isValid] of Object.entries(route.definition.validate ?? {})) {
72
+ const value = params[name];
73
+ if (value !== undefined && !isValid(value))
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ function decodePath(pathname) {
79
+ let parts;
80
+ try {
81
+ parts = splitPath(pathname);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ try {
87
+ return parts.map((part) => {
88
+ const decoded = decodeURIComponent(part);
89
+ if (decoded === '.' ||
90
+ decoded === '..' ||
91
+ /[\/\\\u0000-\u001f]/.test(decoded)) {
92
+ throw new TypeError('Unsafe pathname segment.');
93
+ }
94
+ return decoded;
95
+ });
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function matchSegments(pattern, pathname, patternIndex, pathIndex, params) {
102
+ if (patternIndex === pattern.length)
103
+ return pathIndex === pathname.length ? params : null;
104
+ const segment = pattern[patternIndex];
105
+ if (!segment)
106
+ return null;
107
+ if (segment.kind === 'rest') {
108
+ if (pathIndex === pathname.length)
109
+ return null;
110
+ return { ...params, [segment.name]: pathname.slice(pathIndex).join('/') };
111
+ }
112
+ const current = pathname[pathIndex];
113
+ if (segment.kind === 'static') {
114
+ return current === segment.value
115
+ ? matchSegments(pattern, pathname, patternIndex + 1, pathIndex + 1, params)
116
+ : null;
117
+ }
118
+ if (current !== undefined) {
119
+ const consumed = matchSegments(pattern, pathname, patternIndex + 1, pathIndex + 1, {
120
+ ...params,
121
+ [segment.name]: current,
122
+ });
123
+ if (consumed)
124
+ return consumed;
125
+ }
126
+ return segment.optional
127
+ ? matchSegments(pattern, pathname, patternIndex + 1, pathIndex, params)
128
+ : null;
129
+ }
130
+ function encodeParameter(value, name) {
131
+ if (!value ||
132
+ value === '.' ||
133
+ value === '..' ||
134
+ /[\/\\\u0000-\u001f]/.test(value)) {
135
+ throw new TypeError(`Invalid value for route parameter ${name}.`);
136
+ }
137
+ return encodeURIComponent(value);
138
+ }
139
+ export function createRouter(definitions) {
140
+ const routes = definitions.map(compile);
141
+ const names = new Set();
142
+ const shapes = new Set();
143
+ for (const route of routes) {
144
+ if (names.has(route.definition.name) || shapes.has(route.shape)) {
145
+ throw new TypeError('Duplicate route name or path pattern.');
146
+ }
147
+ names.add(route.definition.name);
148
+ shapes.add(route.shape);
149
+ }
150
+ const ranked = [...routes].sort(compareRoutes);
151
+ return {
152
+ match(pathname) {
153
+ const parts = decodePath(pathname);
154
+ if (!parts)
155
+ return null;
156
+ for (const route of ranked) {
157
+ const params = matchSegments(route.segments, parts, 0, 0, {});
158
+ if (params && validParameters(route, params)) {
159
+ return {
160
+ name: route.definition.name,
161
+ path: route.definition.path,
162
+ params: Object.freeze(params),
163
+ };
164
+ }
165
+ }
166
+ return null;
167
+ },
168
+ href(name, params = {}) {
169
+ const route = routes.find((candidate) => candidate.definition.name === name);
170
+ if (!route)
171
+ throw new TypeError(`Unknown route: ${name}`);
172
+ const used = new Set();
173
+ const parts = [];
174
+ for (const segment of route.segments) {
175
+ if (segment.kind === 'static') {
176
+ parts.push(segment.value);
177
+ continue;
178
+ }
179
+ used.add(segment.name);
180
+ const value = params[segment.name];
181
+ if (value === undefined &&
182
+ segment.kind === 'parameter' &&
183
+ segment.optional)
184
+ continue;
185
+ if (value === undefined)
186
+ throw new TypeError(`Missing route parameter ${segment.name}.`);
187
+ if (segment.kind === 'rest') {
188
+ parts.push(...value
189
+ .split('/')
190
+ .map((part) => encodeParameter(part, segment.name)));
191
+ }
192
+ else {
193
+ parts.push(encodeParameter(value, segment.name));
194
+ }
195
+ }
196
+ for (const key of Object.keys(params)) {
197
+ if (!used.has(key))
198
+ throw new TypeError(`Unexpected route parameter ${key}.`);
199
+ }
200
+ if (!validParameters(route, params)) {
201
+ throw new TypeError('Route parameter failed validation.');
202
+ }
203
+ return parts.length ? `/${parts.join('/')}` : '/';
204
+ },
205
+ };
206
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "workstar-router",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "A small isomorphic URL matcher for Workstar applications.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wslab-ai/workstar.git",
10
+ "directory": "packages/router"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "check": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }