@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 CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@voltts/router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './trie';
3
+ export * from './router';
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.