@voltts/router 0.0.0-stage → 0.1.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/package.json +15 -4
- package/src/index.ts +3 -0
- package/src/router.ts +90 -0
- package/src/trie.ts +344 -0
- package/src/types.ts +46 -0
- package/test/router.test.ts +102 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltts/router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "High-performance radix-tree and static-table HTTP router for Volt",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.ts
ADDED
package/src/router.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { TrieRouter } from './trie';
|
|
2
|
+
import { HttpMethod, RouteMatch, RouterOptions } from './types';
|
|
3
|
+
|
|
4
|
+
export class Router<T = unknown> {
|
|
5
|
+
private trie: TrieRouter<T>;
|
|
6
|
+
private prefix: string;
|
|
7
|
+
|
|
8
|
+
constructor(options: RouterOptions = {}, prefix = '') {
|
|
9
|
+
this.trie = new TrieRouter<T>(options);
|
|
10
|
+
this.prefix = prefix;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private joinPath(prefix: string, path: string): string {
|
|
14
|
+
const p1 = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
15
|
+
const p2 = path.startsWith('/') ? path : '/' + path;
|
|
16
|
+
const full = p1 + p2;
|
|
17
|
+
return full === '' ? '/' : full;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
add(method: HttpMethod | string, path: string, handler: T): this {
|
|
21
|
+
const fullPath = this.joinPath(this.prefix, path);
|
|
22
|
+
this.trie.add(method, fullPath, handler);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get(path: string, handler: T): this {
|
|
27
|
+
return this.add('GET', path, handler);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
post(path: string, handler: T): this {
|
|
31
|
+
return this.add('POST', path, handler);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
put(path: string, handler: T): this {
|
|
35
|
+
return this.add('PUT', path, handler);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
delete(path: string, handler: T): this {
|
|
39
|
+
return this.add('DELETE', path, handler);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
patch(path: string, handler: T): this {
|
|
43
|
+
return this.add('PATCH', path, handler);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
head(path: string, handler: T): this {
|
|
47
|
+
return this.add('HEAD', path, handler);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
options(path: string, handler: T): this {
|
|
51
|
+
return this.add('OPTIONS', path, handler);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
all(path: string, handler: T): this {
|
|
55
|
+
return this.add('ALL', path, handler);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
group(prefix: string, fn: (group: Router<T>) => void): this {
|
|
59
|
+
const subRouter = new Router<T>({}, this.joinPath(this.prefix, prefix));
|
|
60
|
+
fn(subRouter);
|
|
61
|
+
for (const r of subRouter.getRoutes()) {
|
|
62
|
+
this.trie.add(r.method, r.path, r.handler);
|
|
63
|
+
}
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
match(method: HttpMethod | string, path: string): RouteMatch<T> | null {
|
|
68
|
+
return this.trie.match(method, path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getAllowedMethods(path: string): string[] {
|
|
72
|
+
return this.trie.getAllowedMethods(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getRoutes(): Array<{ method: string; path: string; handler: T; pattern: string }> {
|
|
76
|
+
return this.trie.getRoutes();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getStaticGetMap(): Map<string, RouteMatch<T>> {
|
|
80
|
+
return this.trie.getStaticGetMap();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getStaticPostMap(): Map<string, RouteMatch<T>> {
|
|
84
|
+
return this.trie.getStaticPostMap();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getFastGetParamRoutes(): import('./types').FastParamRoute<T>[] {
|
|
88
|
+
return this.trie.getFastGetParamRoutes();
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/trie.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { HttpMethod, RouteConflictError, RouteMatch, FastParamRoute } from './types';
|
|
2
|
+
|
|
3
|
+
export class TrieNode<T> {
|
|
4
|
+
segment: string;
|
|
5
|
+
isParam = false;
|
|
6
|
+
paramName: string | null = null;
|
|
7
|
+
isWildcard = false;
|
|
8
|
+
wildcardName = 'wildcard';
|
|
9
|
+
staticChildren = new Map<string, TrieNode<T>>();
|
|
10
|
+
paramChild: TrieNode<T> | null = null;
|
|
11
|
+
wildcardChild: TrieNode<T> | null = null;
|
|
12
|
+
handlers = new Map<string, { handler: T; pattern: string }>();
|
|
13
|
+
|
|
14
|
+
constructor(segment = '') {
|
|
15
|
+
this.segment = segment;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
20
|
+
|
|
21
|
+
function splitSegments(path: string): string[] {
|
|
22
|
+
const segments: string[] = [];
|
|
23
|
+
let start = 0;
|
|
24
|
+
const len = path.length;
|
|
25
|
+
while (start < len && path.charCodeAt(start) === 47) start++;
|
|
26
|
+
while (start < len) {
|
|
27
|
+
let end = path.indexOf('/', start);
|
|
28
|
+
if (end === -1) end = len;
|
|
29
|
+
if (end > start) {
|
|
30
|
+
segments.push(path.slice(start, end));
|
|
31
|
+
}
|
|
32
|
+
start = end + 1;
|
|
33
|
+
}
|
|
34
|
+
return segments;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TrieRouter<T = unknown> {
|
|
38
|
+
private root = new TrieNode<T>();
|
|
39
|
+
private staticRoutes = new Map<string, { handler: T; pattern: string; method: string }>();
|
|
40
|
+
private staticGetRoutes = new Map<string, RouteMatch<T>>();
|
|
41
|
+
private staticPostRoutes = new Map<string, RouteMatch<T>>();
|
|
42
|
+
private fastGetParamRoutes: FastParamRoute<T>[] = [];
|
|
43
|
+
private allRoutes: Array<{ method: string; path: string; handler: T; pattern: string }> = [];
|
|
44
|
+
|
|
45
|
+
constructor(private options: { strictTrailingSlash?: boolean; caseSensitive?: boolean } = {}) {}
|
|
46
|
+
|
|
47
|
+
private normalizePath(path: string): string {
|
|
48
|
+
if (!path.startsWith('/')) {
|
|
49
|
+
path = '/' + path;
|
|
50
|
+
}
|
|
51
|
+
if (!this.options.strictTrailingSlash && path.length > 1 && path.endsWith('/')) {
|
|
52
|
+
path = path.slice(0, -1);
|
|
53
|
+
}
|
|
54
|
+
if (!this.options.caseSensitive) {
|
|
55
|
+
// Keep case as is for segments, but match case-insensitively if needed
|
|
56
|
+
}
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
add(method: HttpMethod | string, path: string, handler: T): void {
|
|
61
|
+
const normalized = this.normalizePath(path);
|
|
62
|
+
const m = method.toUpperCase();
|
|
63
|
+
const pattern = normalized;
|
|
64
|
+
|
|
65
|
+
this.allRoutes.push({ method: m, path: normalized, handler, pattern });
|
|
66
|
+
|
|
67
|
+
// Check if route is pure static (no : or *)
|
|
68
|
+
const isDynamic = normalized.includes(':') || normalized.includes('*');
|
|
69
|
+
if (!isDynamic) {
|
|
70
|
+
const staticKey = `${m}:${normalized}`;
|
|
71
|
+
const existing = this.staticRoutes.get(staticKey);
|
|
72
|
+
if (existing) {
|
|
73
|
+
throw new RouteConflictError(existing.pattern, normalized, m);
|
|
74
|
+
}
|
|
75
|
+
this.staticRoutes.set(staticKey, { handler, pattern, method: m });
|
|
76
|
+
|
|
77
|
+
const prebuiltMatch: RouteMatch<T> = {
|
|
78
|
+
handler,
|
|
79
|
+
params: EMPTY_PARAMS,
|
|
80
|
+
pattern,
|
|
81
|
+
method: m,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (m === 'GET') {
|
|
85
|
+
this.staticGetRoutes.set(normalized, prebuiltMatch);
|
|
86
|
+
} else if (m === 'POST') {
|
|
87
|
+
this.staticPostRoutes.set(normalized, prebuiltMatch);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Fast single suffix-param detection (e.g. /users/:id or /api/items/:slug)
|
|
91
|
+
const colonIdx = normalized.indexOf(':');
|
|
92
|
+
if (
|
|
93
|
+
colonIdx > 0 &&
|
|
94
|
+
!normalized.includes('*') &&
|
|
95
|
+
normalized.indexOf(':', colonIdx + 1) === -1 &&
|
|
96
|
+
normalized.indexOf('/', colonIdx) === -1
|
|
97
|
+
) {
|
|
98
|
+
const prefix = normalized.slice(0, colonIdx);
|
|
99
|
+
const paramName = normalized.slice(colonIdx + 1);
|
|
100
|
+
const createParams = (val: string): Record<string, string> => ({ [paramName]: val });
|
|
101
|
+
if (m === 'GET' || m === 'ALL') {
|
|
102
|
+
this.fastGetParamRoutes.push({
|
|
103
|
+
prefix,
|
|
104
|
+
prefixLen: prefix.length,
|
|
105
|
+
paramName,
|
|
106
|
+
createParams,
|
|
107
|
+
handler,
|
|
108
|
+
pattern,
|
|
109
|
+
method: m,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Always insert into Trie for uniform tree lookup and dynamic matching
|
|
116
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
117
|
+
let current = this.root;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < segments.length; i++) {
|
|
120
|
+
const seg = segments[i]!;
|
|
121
|
+
|
|
122
|
+
if (seg.startsWith('*')) {
|
|
123
|
+
const wildcardName = seg.length > 1 ? seg.slice(1) : 'wildcard';
|
|
124
|
+
if (!current.wildcardChild) {
|
|
125
|
+
const node = new TrieNode<T>(seg);
|
|
126
|
+
node.isWildcard = true;
|
|
127
|
+
node.wildcardName = wildcardName;
|
|
128
|
+
current.wildcardChild = node;
|
|
129
|
+
} else if (current.wildcardChild.wildcardName !== wildcardName) {
|
|
130
|
+
// Warning or conflict if name differs
|
|
131
|
+
}
|
|
132
|
+
current = current.wildcardChild;
|
|
133
|
+
break; // Wildcard absorbs remaining segments
|
|
134
|
+
} else if (seg.startsWith(':')) {
|
|
135
|
+
const paramName = seg.slice(1);
|
|
136
|
+
if (!current.paramChild) {
|
|
137
|
+
const node = new TrieNode<T>(seg);
|
|
138
|
+
node.isParam = true;
|
|
139
|
+
node.paramName = paramName;
|
|
140
|
+
current.paramChild = node;
|
|
141
|
+
} else if (current.paramChild.paramName !== paramName) {
|
|
142
|
+
throw new RouteConflictError(
|
|
143
|
+
`:${current.paramChild.paramName}`,
|
|
144
|
+
`:${paramName}`,
|
|
145
|
+
m
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
current = current.paramChild;
|
|
149
|
+
} else {
|
|
150
|
+
let child = current.staticChildren.get(seg);
|
|
151
|
+
if (!child) {
|
|
152
|
+
child = new TrieNode<T>(seg);
|
|
153
|
+
current.staticChildren.set(seg, child);
|
|
154
|
+
}
|
|
155
|
+
current = child;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const existingHandler = current.handlers.get(m);
|
|
160
|
+
if (existingHandler && !isDynamic) {
|
|
161
|
+
// Handled above in static check
|
|
162
|
+
} else if (existingHandler && isDynamic) {
|
|
163
|
+
throw new RouteConflictError(existingHandler.pattern, normalized, m);
|
|
164
|
+
}
|
|
165
|
+
current.handlers.set(m, { handler, pattern });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
match(method: HttpMethod | string, path: string): RouteMatch<T> | null {
|
|
169
|
+
const normalized = this.normalizePath(path);
|
|
170
|
+
|
|
171
|
+
// 1. Ultra-fast direct static route lookup without string concatenation
|
|
172
|
+
if (method === 'GET') {
|
|
173
|
+
const match = this.staticGetRoutes.get(normalized);
|
|
174
|
+
if (match) return match;
|
|
175
|
+
} else if (method === 'POST') {
|
|
176
|
+
const match = this.staticPostRoutes.get(normalized);
|
|
177
|
+
if (match) return match;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const m = method.toUpperCase();
|
|
181
|
+
const staticKey = `${m}:${normalized}`;
|
|
182
|
+
const staticMatch = this.staticRoutes.get(staticKey);
|
|
183
|
+
if (staticMatch) {
|
|
184
|
+
return {
|
|
185
|
+
handler: staticMatch.handler,
|
|
186
|
+
params: EMPTY_PARAMS,
|
|
187
|
+
pattern: staticMatch.pattern,
|
|
188
|
+
method: m,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check HEAD fallback to GET for static routes
|
|
193
|
+
if (m === 'HEAD') {
|
|
194
|
+
const getStatic = this.staticRoutes.get(`GET:${normalized}`);
|
|
195
|
+
if (getStatic) {
|
|
196
|
+
return {
|
|
197
|
+
handler: getStatic.handler,
|
|
198
|
+
params: EMPTY_PARAMS,
|
|
199
|
+
pattern: getStatic.pattern,
|
|
200
|
+
method: 'GET',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Ultra-fast single-parameter GET route match (avoids trie search and segment splitting)
|
|
206
|
+
if (m === 'GET') {
|
|
207
|
+
const fastRoutes = this.fastGetParamRoutes;
|
|
208
|
+
const count = fastRoutes.length;
|
|
209
|
+
for (let i = 0; i < count; i++) {
|
|
210
|
+
const r = fastRoutes[i]!;
|
|
211
|
+
if (normalized.length > r.prefixLen && normalized.startsWith(r.prefix) && normalized.indexOf('/', r.prefixLen) === -1) {
|
|
212
|
+
return {
|
|
213
|
+
handler: r.handler,
|
|
214
|
+
params: { [r.paramName]: normalized.slice(r.prefixLen) },
|
|
215
|
+
pattern: r.pattern,
|
|
216
|
+
method: 'GET',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 3. Dynamic Trie Search using fast segment extraction and non-closure traversal
|
|
223
|
+
const segments = splitSegments(normalized);
|
|
224
|
+
const params: Record<string, string> = {};
|
|
225
|
+
|
|
226
|
+
const result = this.matchDynamic(this.root, segments, 0, m, params);
|
|
227
|
+
if (result) {
|
|
228
|
+
const h = result.node.handlers.get(m) ?? result.node.handlers.get('ALL') ?? (m === 'HEAD' ? result.node.handlers.get('GET') : undefined);
|
|
229
|
+
if (h) {
|
|
230
|
+
return {
|
|
231
|
+
handler: h.handler,
|
|
232
|
+
params,
|
|
233
|
+
pattern: h.pattern,
|
|
234
|
+
method: m,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. Fallback for OPTIONS (discover allowed methods)
|
|
240
|
+
if (m === 'OPTIONS') {
|
|
241
|
+
const allowed = this.getAllowedMethods(normalized);
|
|
242
|
+
if (allowed.length > 0) {
|
|
243
|
+
return {
|
|
244
|
+
handler: null as unknown as T,
|
|
245
|
+
params,
|
|
246
|
+
pattern: normalized,
|
|
247
|
+
method: 'OPTIONS',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private matchDynamic(
|
|
256
|
+
node: TrieNode<T>,
|
|
257
|
+
segments: string[],
|
|
258
|
+
index: number,
|
|
259
|
+
m: string,
|
|
260
|
+
params: Record<string, string>
|
|
261
|
+
): { node: TrieNode<T>; pattern: string } | null {
|
|
262
|
+
if (index === segments.length) {
|
|
263
|
+
if (node.handlers.has(m) || node.handlers.has('ALL')) {
|
|
264
|
+
const h = node.handlers.get(m) ?? node.handlers.get('ALL');
|
|
265
|
+
return { node, pattern: h!.pattern };
|
|
266
|
+
}
|
|
267
|
+
if (m === 'HEAD' && node.handlers.has('GET')) {
|
|
268
|
+
const h = node.handlers.get('GET');
|
|
269
|
+
return { node, pattern: h!.pattern };
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const seg = segments[index]!;
|
|
275
|
+
|
|
276
|
+
// 1. Static child match
|
|
277
|
+
const staticChild = node.staticChildren.get(seg);
|
|
278
|
+
if (staticChild) {
|
|
279
|
+
const res = this.matchDynamic(staticChild, segments, index + 1, m, params);
|
|
280
|
+
if (res) return res;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 2. Param child match
|
|
284
|
+
if (node.paramChild) {
|
|
285
|
+
params[node.paramChild.paramName!] = seg;
|
|
286
|
+
const res = this.matchDynamic(node.paramChild, segments, index + 1, m, params);
|
|
287
|
+
if (res) return res;
|
|
288
|
+
delete params[node.paramChild.paramName!];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 3. Wildcard child match
|
|
292
|
+
if (node.wildcardChild) {
|
|
293
|
+
const rest = segments.slice(index).join('/');
|
|
294
|
+
params[node.wildcardChild.wildcardName] = rest;
|
|
295
|
+
if (node.wildcardChild.handlers.has(m) || node.wildcardChild.handlers.has('ALL')) {
|
|
296
|
+
const h = node.wildcardChild.handlers.get(m) ?? node.wildcardChild.handlers.get('ALL');
|
|
297
|
+
return { node: node.wildcardChild, pattern: h!.pattern };
|
|
298
|
+
}
|
|
299
|
+
if (m === 'HEAD' && node.wildcardChild.handlers.has('GET')) {
|
|
300
|
+
const h = node.wildcardChild.handlers.get('GET');
|
|
301
|
+
return { node: node.wildcardChild, pattern: h!.pattern };
|
|
302
|
+
}
|
|
303
|
+
delete params[node.wildcardChild.wildcardName];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
getAllowedMethods(path: string): string[] {
|
|
310
|
+
const normalized = this.normalizePath(path);
|
|
311
|
+
const methods = new Set<string>();
|
|
312
|
+
|
|
313
|
+
for (const r of this.allRoutes) {
|
|
314
|
+
if (r.path === normalized) {
|
|
315
|
+
methods.add(r.method);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (methods.size > 0 && methods.has('GET')) {
|
|
320
|
+
methods.add('HEAD');
|
|
321
|
+
}
|
|
322
|
+
if (methods.size > 0) {
|
|
323
|
+
methods.add('OPTIONS');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return Array.from(methods);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getRoutes(): Array<{ method: string; path: string; handler: T; pattern: string }> {
|
|
330
|
+
return [...this.allRoutes];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getStaticGetMap(): Map<string, RouteMatch<T>> {
|
|
334
|
+
return this.staticGetRoutes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
getStaticPostMap(): Map<string, RouteMatch<T>> {
|
|
338
|
+
return this.staticPostRoutes;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
getFastGetParamRoutes(): FastParamRoute<T>[] {
|
|
342
|
+
return this.fastGetParamRoutes;
|
|
343
|
+
}
|
|
344
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type HttpMethod =
|
|
2
|
+
| 'GET'
|
|
3
|
+
| 'POST'
|
|
4
|
+
| 'PUT'
|
|
5
|
+
| 'DELETE'
|
|
6
|
+
| 'PATCH'
|
|
7
|
+
| 'HEAD'
|
|
8
|
+
| 'OPTIONS'
|
|
9
|
+
| 'ALL';
|
|
10
|
+
|
|
11
|
+
export interface RouteMatch<T = unknown> {
|
|
12
|
+
handler: T;
|
|
13
|
+
params: Record<string, string>;
|
|
14
|
+
pattern: string;
|
|
15
|
+
method: HttpMethod | string;
|
|
16
|
+
isZeroArg?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FastParamRoute<T = unknown> {
|
|
20
|
+
prefix: string;
|
|
21
|
+
prefixLen: number;
|
|
22
|
+
paramName: string;
|
|
23
|
+
createParams: (val: string) => Record<string, string>;
|
|
24
|
+
handler: T;
|
|
25
|
+
pattern: string;
|
|
26
|
+
method: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RouteRecord<T = unknown> {
|
|
30
|
+
method: HttpMethod | string;
|
|
31
|
+
path: string;
|
|
32
|
+
handler: T;
|
|
33
|
+
metadata?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RouterOptions {
|
|
37
|
+
strictTrailingSlash?: boolean;
|
|
38
|
+
caseSensitive?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class RouteConflictError extends Error {
|
|
42
|
+
constructor(public existingPath: string, public newPath: string, public method: string) {
|
|
43
|
+
super(`Route conflict detected: '${method} ${newPath}' conflicts with existing route '${method} ${existingPath}'`);
|
|
44
|
+
this.name = 'RouteConflictError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { RouteConflictError, Router, TrieRouter } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('@voltts/router', () => {
|
|
5
|
+
it('matches static routes with high speed O(1) table', () => {
|
|
6
|
+
const router = new Router<() => string>();
|
|
7
|
+
router.get('/health', () => 'ok');
|
|
8
|
+
router.post('/login', () => 'logged in');
|
|
9
|
+
|
|
10
|
+
const match1 = router.match('GET', '/health');
|
|
11
|
+
expect(match1).not.toBeNull();
|
|
12
|
+
expect(match1?.pattern).toBe('/health');
|
|
13
|
+
expect(match1?.handler()).toBe('ok');
|
|
14
|
+
|
|
15
|
+
const match2 = router.match('POST', '/login');
|
|
16
|
+
expect(match2).not.toBeNull();
|
|
17
|
+
expect(match2?.pattern).toBe('/login');
|
|
18
|
+
|
|
19
|
+
const matchMiss = router.match('GET', '/not-found');
|
|
20
|
+
expect(matchMiss).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('matches dynamic parameters', () => {
|
|
24
|
+
const router = new Router();
|
|
25
|
+
router.get('/users/:id', () => 'user');
|
|
26
|
+
router.get('/posts/:postId/comments/:commentId', () => 'comment');
|
|
27
|
+
|
|
28
|
+
const m1 = router.match('GET', '/users/42');
|
|
29
|
+
expect(m1).not.toBeNull();
|
|
30
|
+
expect(m1?.params).toEqual({ id: '42' });
|
|
31
|
+
|
|
32
|
+
const m2 = router.match('GET', '/posts/10/comments/99');
|
|
33
|
+
expect(m2).not.toBeNull();
|
|
34
|
+
expect(m2?.params).toEqual({ postId: '10', commentId: '99' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('matches wildcards', () => {
|
|
38
|
+
const router = new Router();
|
|
39
|
+
router.get('/static/*', () => 'wildcard');
|
|
40
|
+
router.get('/files/*filepath', () => 'files');
|
|
41
|
+
|
|
42
|
+
const m1 = router.match('GET', '/static/css/style.css');
|
|
43
|
+
expect(m1).not.toBeNull();
|
|
44
|
+
expect(m1?.params.wildcard).toBe('css/style.css');
|
|
45
|
+
|
|
46
|
+
const m2 = router.match('GET', '/files/images/logo.png');
|
|
47
|
+
expect(m2).not.toBeNull();
|
|
48
|
+
expect(m2?.params.filepath).toBe('images/logo.png');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('handles route groups and nested prefixes', () => {
|
|
52
|
+
const router = new Router();
|
|
53
|
+
|
|
54
|
+
router.group('/api/v1', (v1) => {
|
|
55
|
+
v1.get('/users', () => 'users');
|
|
56
|
+
v1.group('/admin', (admin) => {
|
|
57
|
+
admin.post('/settings', () => 'settings');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const m1 = router.match('GET', '/api/v1/users');
|
|
62
|
+
expect(m1).not.toBeNull();
|
|
63
|
+
expect(m1?.pattern).toBe('/api/v1/users');
|
|
64
|
+
|
|
65
|
+
const m2 = router.match('POST', '/api/v1/admin/settings');
|
|
66
|
+
expect(m2).not.toBeNull();
|
|
67
|
+
expect(m2?.pattern).toBe('/api/v1/admin/settings');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('detects route conflicts', () => {
|
|
71
|
+
const trie = new TrieRouter();
|
|
72
|
+
trie.add('GET', '/users/:userId', () => {});
|
|
73
|
+
|
|
74
|
+
// Conflicting parameter name on the same segment
|
|
75
|
+
expect(() => {
|
|
76
|
+
trie.add('GET', '/users/:id', () => {});
|
|
77
|
+
}).toThrow(RouteConflictError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back HEAD requests to GET', () => {
|
|
81
|
+
const router = new Router();
|
|
82
|
+
router.get('/ping', () => 'pong');
|
|
83
|
+
|
|
84
|
+
const headMatch = router.match('HEAD', '/ping');
|
|
85
|
+
expect(headMatch).not.toBeNull();
|
|
86
|
+
expect(headMatch?.method).toBe('GET');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('discovers allowed methods for OPTIONS requests', () => {
|
|
90
|
+
const router = new Router();
|
|
91
|
+
router.get('/items', () => {});
|
|
92
|
+
router.post('/items', () => {});
|
|
93
|
+
router.delete('/items', () => {});
|
|
94
|
+
|
|
95
|
+
const allowed = router.getAllowedMethods('/items');
|
|
96
|
+
expect(allowed).toContain('GET');
|
|
97
|
+
expect(allowed).toContain('POST');
|
|
98
|
+
expect(allowed).toContain('DELETE');
|
|
99
|
+
expect(allowed).toContain('HEAD');
|
|
100
|
+
expect(allowed).toContain('OPTIONS');
|
|
101
|
+
});
|
|
102
|
+
});
|
package/README.md
DELETED