vector-framework 1.1.1 → 1.2.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.
Files changed (98) hide show
  1. package/README.md +87 -635
  2. package/dist/auth/protected.d.ts.map +1 -1
  3. package/dist/auth/protected.js.map +1 -1
  4. package/dist/cache/manager.d.ts.map +1 -1
  5. package/dist/cache/manager.js +2 -7
  6. package/dist/cache/manager.js.map +1 -1
  7. package/dist/cli/index.js +17 -62
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/option-resolution.d.ts +4 -0
  10. package/dist/cli/option-resolution.d.ts.map +1 -0
  11. package/dist/cli/option-resolution.js +28 -0
  12. package/dist/cli/option-resolution.js.map +1 -0
  13. package/dist/cli.js +2721 -617
  14. package/dist/constants/index.d.ts +3 -0
  15. package/dist/constants/index.d.ts.map +1 -1
  16. package/dist/constants/index.js +6 -0
  17. package/dist/constants/index.js.map +1 -1
  18. package/dist/core/config-loader.d.ts.map +1 -1
  19. package/dist/core/config-loader.js +2 -0
  20. package/dist/core/config-loader.js.map +1 -1
  21. package/dist/core/router.d.ts +41 -17
  22. package/dist/core/router.d.ts.map +1 -1
  23. package/dist/core/router.js +432 -153
  24. package/dist/core/router.js.map +1 -1
  25. package/dist/core/server.d.ts +14 -1
  26. package/dist/core/server.d.ts.map +1 -1
  27. package/dist/core/server.js +250 -30
  28. package/dist/core/server.js.map +1 -1
  29. package/dist/core/vector.d.ts +4 -3
  30. package/dist/core/vector.d.ts.map +1 -1
  31. package/dist/core/vector.js +21 -12
  32. package/dist/core/vector.js.map +1 -1
  33. package/dist/dev/route-generator.d.ts.map +1 -1
  34. package/dist/dev/route-generator.js.map +1 -1
  35. package/dist/dev/route-scanner.d.ts.map +1 -1
  36. package/dist/dev/route-scanner.js +1 -5
  37. package/dist/dev/route-scanner.js.map +1 -1
  38. package/dist/http.d.ts +14 -14
  39. package/dist/http.d.ts.map +1 -1
  40. package/dist/http.js +34 -41
  41. package/dist/http.js.map +1 -1
  42. package/dist/index.js +1314 -8
  43. package/dist/index.mjs +1314 -8
  44. package/dist/middleware/manager.d.ts.map +1 -1
  45. package/dist/middleware/manager.js +4 -0
  46. package/dist/middleware/manager.js.map +1 -1
  47. package/dist/openapi/docs-ui.d.ts +2 -0
  48. package/dist/openapi/docs-ui.d.ts.map +1 -0
  49. package/dist/openapi/docs-ui.js +1313 -0
  50. package/dist/openapi/docs-ui.js.map +1 -0
  51. package/dist/openapi/generator.d.ts +12 -0
  52. package/dist/openapi/generator.d.ts.map +1 -0
  53. package/dist/openapi/generator.js +273 -0
  54. package/dist/openapi/generator.js.map +1 -0
  55. package/dist/types/index.d.ts +70 -11
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/standard-schema.d.ts +118 -0
  58. package/dist/types/standard-schema.d.ts.map +1 -0
  59. package/dist/types/standard-schema.js +2 -0
  60. package/dist/types/standard-schema.js.map +1 -0
  61. package/dist/utils/cors.d.ts +13 -0
  62. package/dist/utils/cors.d.ts.map +1 -0
  63. package/dist/utils/cors.js +89 -0
  64. package/dist/utils/cors.js.map +1 -0
  65. package/dist/utils/path.d.ts +6 -0
  66. package/dist/utils/path.d.ts.map +1 -1
  67. package/dist/utils/path.js +5 -0
  68. package/dist/utils/path.js.map +1 -1
  69. package/dist/utils/schema-validation.d.ts +31 -0
  70. package/dist/utils/schema-validation.d.ts.map +1 -0
  71. package/dist/utils/schema-validation.js +77 -0
  72. package/dist/utils/schema-validation.js.map +1 -0
  73. package/dist/utils/validation.d.ts.map +1 -1
  74. package/dist/utils/validation.js +1 -0
  75. package/dist/utils/validation.js.map +1 -1
  76. package/package.json +13 -12
  77. package/src/auth/protected.ts +3 -13
  78. package/src/cache/manager.ts +4 -18
  79. package/src/cli/index.ts +19 -75
  80. package/src/cli/option-resolution.ts +40 -0
  81. package/src/constants/index.ts +7 -0
  82. package/src/core/config-loader.ts +3 -3
  83. package/src/core/router.ts +502 -156
  84. package/src/core/server.ts +327 -32
  85. package/src/core/vector.ts +49 -29
  86. package/src/dev/route-generator.ts +1 -3
  87. package/src/dev/route-scanner.ts +2 -9
  88. package/src/http.ts +85 -125
  89. package/src/middleware/manager.ts +4 -0
  90. package/src/openapi/assets/tailwindcdn.js +83 -0
  91. package/src/openapi/docs-ui.ts +1317 -0
  92. package/src/openapi/generator.ts +359 -0
  93. package/src/types/index.ts +104 -17
  94. package/src/types/standard-schema.ts +147 -0
  95. package/src/utils/cors.ts +101 -0
  96. package/src/utils/path.ts +6 -0
  97. package/src/utils/schema-validation.ts +123 -0
  98. package/src/utils/validation.ts +1 -0
@@ -1,23 +1,53 @@
1
- import type { RouteEntry } from 'itty-router';
2
1
  import type { AuthManager } from '../auth/protected';
3
2
  import type { CacheManager } from '../cache/manager';
4
3
  import { APIError, createResponse } from '../http';
5
4
  import type { MiddlewareManager } from '../middleware/manager';
5
+ import { STATIC_RESPONSES } from '../constants';
6
+ import { buildRouteRegex } from '../utils/path';
6
7
  import type {
8
+ BunMethodMap,
9
+ BunRouteTable,
7
10
  DefaultVectorTypes,
11
+ InferRouteInputFromSchemaDefinition,
12
+ RouteBooleanDefaults,
13
+ LegacyRouteEntry,
8
14
  RouteHandler,
9
15
  RouteOptions,
16
+ RouteSchemaDefinition,
10
17
  VectorRequest,
11
18
  VectorTypes,
12
19
  } from '../types';
13
- import { buildRouteRegex } from '../utils/path';
20
+ import {
21
+ createValidationErrorPayload,
22
+ extractThrownIssues,
23
+ isStandardRouteSchema,
24
+ normalizeValidationIssues,
25
+ runStandardValidation,
26
+ } from '../utils/schema-validation';
27
+
28
+ export interface RegisteredRouteDefinition<TTypes extends VectorTypes = DefaultVectorTypes> {
29
+ method: string;
30
+ path: string;
31
+ options: RouteOptions<TTypes>;
32
+ }
33
+
34
+ interface RouteMatcher {
35
+ path: string;
36
+ regex: RegExp;
37
+ specificity: number;
38
+ }
14
39
 
15
40
  export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
16
41
  private middlewareManager: MiddlewareManager<TTypes>;
17
42
  private authManager: AuthManager<TTypes>;
18
43
  private cacheManager: CacheManager<TTypes>;
19
- private routes: RouteEntry[] = [];
20
- private specificityCache: Map<string, number> = new Map();
44
+ private routeBooleanDefaults: RouteBooleanDefaults = {};
45
+ private developmentMode: boolean | undefined = undefined;
46
+ private routeDefinitions: RegisteredRouteDefinition<TTypes>[] = [];
47
+ private routeTable: BunRouteTable = Object.create(null) as BunRouteTable;
48
+ private routeMatchers: RouteMatcher[] = [];
49
+ private corsHeadersEntries: [string, string][] | null = null;
50
+ private corsHandler: ((response: Response, request: Request) => Response) | null = null;
21
51
 
22
52
  constructor(
23
53
  middlewareManager: MiddlewareManager<TTypes>,
@@ -29,87 +59,155 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
29
59
  this.cacheManager = cacheManager;
30
60
  }
31
61
 
32
- private getRouteSpecificity(path: string): number {
33
- const cached = this.specificityCache.get(path);
34
- if (cached !== undefined) return cached;
62
+ setCorsHeaders(entries: [string, string][] | null): void {
63
+ this.corsHeadersEntries = entries;
64
+ }
35
65
 
36
- const STATIC_SEGMENT_WEIGHT = 1000;
37
- const PARAM_SEGMENT_WEIGHT = 10;
38
- const WILDCARD_WEIGHT = 1;
39
- const EXACT_MATCH_BONUS = 10000;
66
+ setCorsHandler(handler: ((response: Response, request: Request) => Response) | null): void {
67
+ this.corsHandler = handler;
68
+ }
40
69
 
41
- let score = 0;
42
- const segments = path.split('/').filter(Boolean);
70
+ setRouteBooleanDefaults(defaults?: RouteBooleanDefaults): void {
71
+ this.routeBooleanDefaults = { ...defaults };
72
+ }
43
73
 
44
- for (const segment of segments) {
45
- if (this.isStaticSegment(segment)) {
46
- score += STATIC_SEGMENT_WEIGHT;
47
- } else if (this.isParamSegment(segment)) {
48
- score += PARAM_SEGMENT_WEIGHT;
49
- } else if (this.isWildcardSegment(segment)) {
50
- score += WILDCARD_WEIGHT;
51
- }
52
- }
74
+ setDevelopmentMode(mode?: boolean): void {
75
+ this.developmentMode = mode;
76
+ }
53
77
 
54
- score += path.length;
78
+ private applyRouteBooleanDefaults(options: RouteOptions<TTypes>): RouteOptions<TTypes> {
79
+ const resolved = { ...options };
80
+ const defaults = this.routeBooleanDefaults;
55
81
 
56
- if (this.isExactPath(path)) {
57
- score += EXACT_MATCH_BONUS;
82
+ const keys: (keyof RouteBooleanDefaults)[] = ['auth', 'expose', 'rawRequest', 'validate', 'rawResponse'];
83
+
84
+ for (const key of keys) {
85
+ if (resolved[key] === undefined && defaults[key] !== undefined) {
86
+ (resolved as any)[key] = defaults[key];
87
+ }
58
88
  }
59
89
 
60
- this.specificityCache.set(path, score);
61
- return score;
90
+ return resolved;
62
91
  }
63
92
 
64
- private isStaticSegment(segment: string): boolean {
65
- return !segment.startsWith(':') && !segment.includes('*');
93
+ route<TSchemaDef extends RouteSchemaDefinition | undefined>(
94
+ options: Omit<RouteOptions<TTypes>, 'schema'> & { schema?: TSchemaDef },
95
+ handler: RouteHandler<TTypes, InferRouteInputFromSchemaDefinition<TSchemaDef>>
96
+ ): void;
97
+ route(options: RouteOptions<TTypes>, handler: RouteHandler<TTypes>): void {
98
+ const resolvedOptions = this.applyRouteBooleanDefaults(options);
99
+ const method = resolvedOptions.method.toUpperCase();
100
+ const path = resolvedOptions.path;
101
+ const wrappedHandler = this.wrapHandler(resolvedOptions, handler);
102
+ const methodMap = this.getOrCreateMethodMap(path);
103
+ methodMap[method] = wrappedHandler;
104
+
105
+ this.routeDefinitions.push({
106
+ method,
107
+ path,
108
+ options: resolvedOptions,
109
+ });
66
110
  }
67
111
 
68
- private isParamSegment(segment: string): boolean {
69
- return segment.startsWith(':');
112
+ addRoute(entry: LegacyRouteEntry): void {
113
+ const [method, , handlers, path] = entry;
114
+ if (!path) return;
115
+ const methodMap = this.getOrCreateMethodMap(path);
116
+ methodMap[method.toUpperCase()] = handlers[0];
117
+
118
+ const normalizedMethod = method.toUpperCase();
119
+ this.routeDefinitions.push({
120
+ method: normalizedMethod,
121
+ path,
122
+ options: {
123
+ method: normalizedMethod,
124
+ path,
125
+ expose: true,
126
+ } as RouteOptions<TTypes>,
127
+ });
70
128
  }
71
129
 
72
- private isWildcardSegment(segment: string): boolean {
73
- return segment.includes('*');
130
+ bulkAddRoutes(entries: LegacyRouteEntry[]): void {
131
+ for (const entry of entries) {
132
+ this.addRoute(entry);
133
+ }
74
134
  }
75
135
 
76
- private isExactPath(path: string): boolean {
77
- return !path.includes(':') && !path.includes('*');
136
+ addStaticRoute(path: string, response: Response): void {
137
+ const existing = this.routeTable[path];
138
+ if (existing && !(existing instanceof Response)) {
139
+ throw new Error(`Cannot register static route for path "${path}" because method routes already exist.`);
140
+ }
141
+ this.routeTable[path] = response;
142
+ this.removeRouteMatcher(path);
78
143
  }
79
144
 
80
- sortRoutes(): void {
81
- this.routes.sort((a, b) => {
82
- const pathA = this.extractPath(a);
83
- const pathB = this.extractPath(b);
145
+ getRouteTable(): BunRouteTable {
146
+ return this.routeTable;
147
+ }
84
148
 
85
- const scoreA = this.getRouteSpecificity(pathA);
86
- const scoreB = this.getRouteSpecificity(pathB);
149
+ // Legacy compatibility: returns route entries in a flat list for tests
150
+ getRoutes(): LegacyRouteEntry[] {
151
+ const routes: LegacyRouteEntry[] = [];
152
+ for (const matcher of this.routeMatchers) {
153
+ const value = this.routeTable[matcher.path];
154
+ if (!value || value instanceof Response) continue;
87
155
 
88
- return scoreB - scoreA;
89
- });
156
+ for (const [method, handler] of Object.entries(value as BunMethodMap)) {
157
+ routes.push([method, matcher.regex, [handler], matcher.path]);
158
+ }
159
+ }
160
+ return routes;
90
161
  }
91
162
 
92
- private extractPath(route: RouteEntry): string {
93
- const PATH_INDEX = 3;
94
- return route[PATH_INDEX] || '';
163
+ getRouteDefinitions(): RegisteredRouteDefinition<TTypes>[] {
164
+ return [...this.routeDefinitions];
95
165
  }
96
166
 
97
- route(options: RouteOptions<TTypes>, handler: RouteHandler<TTypes>): RouteEntry {
98
- const wrappedHandler = this.wrapHandler(options, handler);
99
- const routeEntry: RouteEntry = [
100
- options.method.toUpperCase(),
101
- this.createRouteRegex(options.path),
102
- [wrappedHandler],
103
- options.path,
104
- ];
105
-
106
- this.routes.push(routeEntry);
107
- this.sortRoutes(); // Sort routes after adding
108
- return routeEntry;
167
+ clearRoutes(): void {
168
+ this.routeTable = Object.create(null) as BunRouteTable;
169
+ this.routeMatchers = [];
170
+ this.routeDefinitions = [];
109
171
  }
110
172
 
111
- private createRouteRegex(path: string): RegExp {
112
- return buildRouteRegex(path);
173
+ // Legacy shim — no-op (Bun handles route priority natively)
174
+ sortRoutes(): void {}
175
+
176
+ // Compatibility handle() for unit tests — mirrors Bun's native routing without a server
177
+ async handle(request: Request): Promise<Response> {
178
+ let url: URL;
179
+ try {
180
+ url = new URL(request.url);
181
+ } catch {
182
+ return APIError.badRequest('Malformed request URL');
183
+ }
184
+ (request as any)._parsedUrl = url;
185
+ const pathname = url.pathname;
186
+
187
+ for (const matcher of this.routeMatchers) {
188
+ const path = matcher.path;
189
+ const value = this.routeTable[path];
190
+ if (!value) continue;
191
+ if (value instanceof Response) continue;
192
+ const methodMap = value as BunMethodMap;
193
+ if (request.method === 'OPTIONS' || request.method in methodMap) {
194
+ const match = pathname.match(matcher.regex);
195
+ if (match) {
196
+ try {
197
+ (request as any).params = match.groups ?? {};
198
+ } catch {
199
+ // Request.params can be readonly on Bun-native requests.
200
+ }
201
+ const handler = methodMap[request.method] ?? methodMap['GET'];
202
+ if (handler) {
203
+ const response = await handler(request);
204
+ if (response) return response;
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return STATIC_RESPONSES.NOT_FOUND.clone() as unknown as Response;
113
211
  }
114
212
 
115
213
  private prepareRequest(
@@ -120,14 +218,22 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
120
218
  metadata?: any;
121
219
  }
122
220
  ): void {
123
- // Initialize context if not present
124
221
  if (!request.context) {
125
222
  request.context = {} as any;
126
223
  }
127
224
 
128
- // Set params and route if provided
129
- if (options?.params !== undefined) {
130
- request.params = options.params;
225
+ const hasEmptyParamsObject =
226
+ !!request.params &&
227
+ typeof request.params === 'object' &&
228
+ !Array.isArray(request.params) &&
229
+ Object.keys(request.params as Record<string, unknown>).length === 0;
230
+
231
+ if (options?.params !== undefined && (request.params === undefined || hasEmptyParamsObject)) {
232
+ try {
233
+ request.params = options.params;
234
+ } catch {
235
+ // params is readonly (set by Bun natively) — use as-is
236
+ }
131
237
  }
132
238
  if (options?.route !== undefined) {
133
239
  request.route = options.route;
@@ -136,25 +242,41 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
136
242
  request.metadata = options.metadata;
137
243
  }
138
244
 
139
- // Parse query parameters from URL if not already parsed
140
- if (!request.query && request.url) {
141
- const url = (request as any)._parsedUrl ?? new URL(request.url);
142
- const query: Record<string, string | string[]> = {};
143
- for (const [key, value] of url.searchParams) {
144
- if (key in query) {
145
- if (Array.isArray(query[key])) {
146
- (query[key] as string[]).push(value);
147
- } else {
148
- query[key] = [query[key] as string, value];
149
- }
150
- } else {
151
- query[key] = value;
245
+ if (request.query == null && request.url) {
246
+ try {
247
+ Object.defineProperty(request, 'query', {
248
+ get() {
249
+ const url = (this as any)._parsedUrl ?? new URL(this.url);
250
+ const query = VectorRouter.parseQuery(url);
251
+ Object.defineProperty(this, 'query', {
252
+ value: query,
253
+ writable: true,
254
+ configurable: true,
255
+ enumerable: true,
256
+ });
257
+ return query;
258
+ },
259
+ set(value) {
260
+ Object.defineProperty(this, 'query', {
261
+ value,
262
+ writable: true,
263
+ configurable: true,
264
+ enumerable: true,
265
+ });
266
+ },
267
+ configurable: true,
268
+ enumerable: true,
269
+ });
270
+ } catch {
271
+ const url = (request as any)._parsedUrl ?? new URL(request.url);
272
+ try {
273
+ request.query = VectorRouter.parseQuery(url);
274
+ } catch {
275
+ // Leave query as-is when request shape is non-extensible.
152
276
  }
153
277
  }
154
- request.query = query;
155
278
  }
156
279
 
157
- // Lazy cookie parsing — only parse the Cookie header when first accessed
158
280
  if (!Object.getOwnPropertyDescriptor(request, 'cookies')) {
159
281
  Object.defineProperty(request, 'cookies', {
160
282
  get() {
@@ -182,32 +304,64 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
182
304
  }
183
305
  }
184
306
 
307
+ private resolveFallbackParams(request: Request, routeMatcher: RegExp | null): Record<string, string> | undefined {
308
+ if (!routeMatcher) {
309
+ return undefined;
310
+ }
311
+
312
+ const currentParams = (request as any).params;
313
+ if (
314
+ currentParams &&
315
+ typeof currentParams === 'object' &&
316
+ !Array.isArray(currentParams) &&
317
+ Object.keys(currentParams as Record<string, unknown>).length > 0
318
+ ) {
319
+ return undefined;
320
+ }
321
+
322
+ let pathname: string;
323
+ try {
324
+ pathname = ((request as any)._parsedUrl ?? new URL(request.url)).pathname;
325
+ } catch {
326
+ return undefined;
327
+ }
328
+
329
+ const matched = pathname.match(routeMatcher);
330
+ if (!matched?.groups) {
331
+ return undefined;
332
+ }
333
+
334
+ return matched.groups as Record<string, string>;
335
+ }
336
+
185
337
  private wrapHandler(options: RouteOptions<TTypes>, handler: RouteHandler<TTypes>) {
186
- return async (request: any) => {
187
- // Ensure request has required properties
188
- const vectorRequest = request as VectorRequest<TTypes>;
338
+ const routePath = options.path;
339
+ const routeMatcher = routePath.includes(':') ? buildRouteRegex(routePath) : null;
340
+
341
+ return async (request: Request) => {
342
+ const vectorRequest = request as unknown as VectorRequest<TTypes>;
343
+ const fallbackParams = this.resolveFallbackParams(request, routeMatcher);
189
344
 
190
- // Prepare the request with common logic
191
345
  this.prepareRequest(vectorRequest, {
346
+ params: fallbackParams,
347
+ route: routePath,
192
348
  metadata: options.metadata,
193
349
  });
194
350
 
195
- request = vectorRequest;
196
351
  try {
197
- // Default expose to true if not specified
198
352
  if (options.expose === false) {
199
353
  return APIError.forbidden('Forbidden');
200
354
  }
201
355
 
202
- const beforeResult = await this.middlewareManager.executeBefore(request);
356
+ const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
203
357
  if (beforeResult instanceof Response) {
204
358
  return beforeResult;
205
359
  }
206
- request = beforeResult as any;
360
+ const req = beforeResult as VectorRequest<TTypes>;
207
361
 
208
362
  if (options.auth) {
209
363
  try {
210
- await this.authManager.authenticate(request);
364
+ await this.authManager.authenticate(req);
211
365
  } catch (error) {
212
366
  return APIError.unauthorized(
213
367
  error instanceof Error ? error.message : 'Authentication failed',
@@ -216,58 +370,79 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
216
370
  }
217
371
  }
218
372
 
219
- if (!options.rawRequest && request.method !== 'GET' && request.method !== 'HEAD') {
373
+ if (!options.rawRequest && req.method !== 'GET' && req.method !== 'HEAD') {
374
+ let parsedContent: unknown = null;
220
375
  try {
221
- const contentType = request.headers.get('content-type');
222
- if (contentType?.includes('application/json')) {
223
- request.content = await request.json();
224
- } else if (contentType?.includes('application/x-www-form-urlencoded')) {
225
- request.content = Object.fromEntries(await request.formData());
226
- } else if (contentType?.includes('multipart/form-data')) {
227
- request.content = await request.formData();
376
+ const contentType = req.headers.get('content-type');
377
+ if (contentType?.startsWith('application/json')) {
378
+ parsedContent = await req.json();
379
+ } else if (contentType?.startsWith('application/x-www-form-urlencoded')) {
380
+ parsedContent = Object.fromEntries(await req.formData());
381
+ } else if (contentType?.startsWith('multipart/form-data')) {
382
+ parsedContent = await req.formData();
228
383
  } else {
229
- request.content = await request.text();
384
+ parsedContent = await req.text();
230
385
  }
231
386
  } catch {
232
- request.content = null;
387
+ parsedContent = null;
233
388
  }
389
+ this.setContentAndBodyAlias(req, parsedContent);
390
+ }
391
+
392
+ const inputValidationResponse = await this.validateInputSchema(req, options);
393
+ if (inputValidationResponse) {
394
+ return inputValidationResponse;
234
395
  }
235
396
 
236
397
  let result;
237
398
  const cacheOptions = options.cache;
238
399
 
239
- // Create cache factory that handles Response objects
240
- const cacheFactory = async () => {
241
- const res = await handler(request);
242
- // If Response, extract data for caching
243
- if (res instanceof Response) {
244
- return {
245
- _isResponse: true,
246
- body: await res.text(),
247
- status: res.status,
248
- headers: Object.fromEntries(res.headers.entries()),
249
- };
250
- }
251
- return res;
252
- };
253
-
254
400
  if (cacheOptions && typeof cacheOptions === 'number' && cacheOptions > 0) {
255
- const cacheKey = this.cacheManager.generateKey(request as any, {
256
- authUser: request.authUser,
401
+ const cacheKey = this.cacheManager.generateKey(req as any, {
402
+ authUser: req.authUser,
257
403
  });
258
- result = await this.cacheManager.get(cacheKey, cacheFactory, cacheOptions);
404
+ result = await this.cacheManager.get(
405
+ cacheKey,
406
+ async () => {
407
+ const res = await handler(req);
408
+ if (res instanceof Response) {
409
+ return {
410
+ _isResponse: true,
411
+ body: await res.text(),
412
+ status: res.status,
413
+ headers: Object.fromEntries(res.headers.entries()),
414
+ };
415
+ }
416
+ return res;
417
+ },
418
+ cacheOptions
419
+ );
259
420
  } else if (cacheOptions && typeof cacheOptions === 'object' && cacheOptions.ttl) {
260
421
  const cacheKey =
261
422
  cacheOptions.key ||
262
- this.cacheManager.generateKey(request as any, {
263
- authUser: request.authUser,
423
+ this.cacheManager.generateKey(req as any, {
424
+ authUser: req.authUser,
264
425
  });
265
- result = await this.cacheManager.get(cacheKey, cacheFactory, cacheOptions.ttl);
426
+ result = await this.cacheManager.get(
427
+ cacheKey,
428
+ async () => {
429
+ const res = await handler(req);
430
+ if (res instanceof Response) {
431
+ return {
432
+ _isResponse: true,
433
+ body: await res.text(),
434
+ status: res.status,
435
+ headers: Object.fromEntries(res.headers.entries()),
436
+ };
437
+ }
438
+ return res;
439
+ },
440
+ cacheOptions.ttl
441
+ );
266
442
  } else {
267
- result = await handler(request);
443
+ result = await handler(req);
268
444
  }
269
445
 
270
- // Reconstruct Response if it was cached
271
446
  if (result && typeof result === 'object' && result._isResponse === true) {
272
447
  result = new Response(result.body, {
273
448
  status: result.status,
@@ -282,7 +457,20 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
282
457
  response = createResponse(200, result, options.responseContentType);
283
458
  }
284
459
 
285
- response = await this.middlewareManager.executeFinally(response, request);
460
+ response = await this.middlewareManager.executeFinally(response, req);
461
+
462
+ // Apply pre-built CORS headers if configured
463
+ const entries = this.corsHeadersEntries;
464
+ if (entries) {
465
+ for (const [k, v] of entries) {
466
+ response.headers.set(k, v);
467
+ }
468
+ } else {
469
+ const dynamicCors = this.corsHandler;
470
+ if (dynamicCors) {
471
+ response = dynamicCors(response, req as unknown as Request);
472
+ }
473
+ }
286
474
 
287
475
  return response;
288
476
  } catch (error) {
@@ -299,57 +487,215 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
299
487
  };
300
488
  }
301
489
 
302
- addRoute(routeEntry: RouteEntry) {
303
- this.routes.push(routeEntry);
304
- this.sortRoutes(); // Sort routes after adding a new one
490
+ private isDevelopmentMode(): boolean {
491
+ if (this.developmentMode !== undefined) {
492
+ return this.developmentMode;
493
+ }
494
+
495
+ const nodeEnv = typeof Bun !== 'undefined' ? Bun.env.NODE_ENV : process.env.NODE_ENV;
496
+ return nodeEnv !== 'production';
497
+ }
498
+
499
+ private async buildInputValidationPayload(
500
+ request: VectorRequest<TTypes>,
501
+ options: RouteOptions<TTypes>
502
+ ): Promise<Record<string, unknown>> {
503
+ let body = request.content;
504
+
505
+ if (options.rawRequest && request.method !== 'GET' && request.method !== 'HEAD') {
506
+ try {
507
+ body = await (request as unknown as Request).clone().text();
508
+ } catch {
509
+ body = null;
510
+ }
511
+ }
512
+
513
+ return {
514
+ params: request.params ?? {},
515
+ query: request.query ?? {},
516
+ headers: Object.fromEntries(request.headers.entries()),
517
+ cookies: request.cookies ?? {},
518
+ body,
519
+ };
305
520
  }
306
521
 
307
- bulkAddRoutes(entries: RouteEntry[]): void {
308
- for (const entry of entries) {
309
- this.routes.push(entry);
522
+ private applyValidatedInput(request: VectorRequest<TTypes>, validatedValue: unknown): void {
523
+ request.validatedInput = validatedValue;
524
+
525
+ if (!validatedValue || typeof validatedValue !== 'object') {
526
+ return;
527
+ }
528
+
529
+ const validated = validatedValue as Record<string, unknown>;
530
+
531
+ if ('params' in validated) {
532
+ try {
533
+ request.params = validated.params as any;
534
+ } catch {
535
+ // Request.params can be readonly on Bun-native requests.
536
+ }
537
+ }
538
+ if ('query' in validated) {
539
+ try {
540
+ request.query = validated.query as any;
541
+ } catch {
542
+ // Request.query can be readonly/non-configurable on some request objects.
543
+ }
544
+ }
545
+ if ('cookies' in validated) {
546
+ try {
547
+ request.cookies = validated.cookies as any;
548
+ } catch {
549
+ // Request.cookies can be readonly/non-configurable on some request objects.
550
+ }
551
+ }
552
+ if ('body' in validated) {
553
+ this.setContentAndBodyAlias(request, validated.body);
310
554
  }
311
- this.sortRoutes(); // Sort once after all routes are added — O(n log n) instead of O(n²)
312
555
  }
313
556
 
314
- getRoutes(): RouteEntry[] {
315
- return this.routes;
557
+ private setContentAndBodyAlias(request: VectorRequest<TTypes>, value: unknown): void {
558
+ try {
559
+ request.content = value;
560
+ } catch {
561
+ // Request.content can be readonly/non-configurable on some request objects.
562
+ return;
563
+ }
564
+
565
+ this.setBodyAlias(request, value);
316
566
  }
317
567
 
318
- async handle(request: Request): Promise<Response> {
319
- let url: URL;
568
+ private setBodyAlias(request: VectorRequest<TTypes>, value: unknown): void {
320
569
  try {
321
- url = new URL(request.url);
570
+ request.body = value as any;
322
571
  } catch {
323
- return APIError.badRequest('Malformed request URL');
572
+ // Keep request.content as source of truth when body alias is readonly.
324
573
  }
325
- (request as any)._parsedUrl = url;
326
- const pathname = url.pathname;
574
+ }
327
575
 
328
- for (const [method, regex, handlers, path] of this.routes) {
329
- if (request.method === 'OPTIONS' || request.method === method) {
330
- const match = pathname.match(regex);
331
- if (match) {
332
- const req = request as any as VectorRequest<TTypes>;
576
+ private async validateInputSchema(
577
+ request: VectorRequest<TTypes>,
578
+ options: RouteOptions<TTypes>
579
+ ): Promise<Response | null> {
580
+ const inputSchema = options.schema?.input;
333
581
 
334
- // Prepare the request with common logic
335
- this.prepareRequest(req, {
336
- params: match.groups || {},
337
- route: path || pathname,
338
- });
582
+ if (!inputSchema) {
583
+ return null;
584
+ }
339
585
 
340
- for (const handler of handlers) {
341
- const response = await handler(req as any);
342
- if (response) return response;
343
- }
344
- }
586
+ if (options.validate === false) {
587
+ return null;
588
+ }
589
+
590
+ if (!isStandardRouteSchema(inputSchema)) {
591
+ return APIError.internalServerError('Invalid route schema configuration', options.responseContentType);
592
+ }
593
+
594
+ const includeRawIssues = this.isDevelopmentMode();
595
+ const payload = await this.buildInputValidationPayload(request, options);
596
+
597
+ try {
598
+ const validation = await runStandardValidation(inputSchema, payload);
599
+ if (validation.success === false) {
600
+ const issues = normalizeValidationIssues(validation.issues, includeRawIssues);
601
+ return createResponse(422, createValidationErrorPayload('input', issues), options.responseContentType);
602
+ }
603
+
604
+ this.applyValidatedInput(request, validation.value);
605
+ return null;
606
+ } catch (error) {
607
+ const thrownIssues = extractThrownIssues(error);
608
+ if (thrownIssues) {
609
+ const issues = normalizeValidationIssues(thrownIssues, includeRawIssues);
610
+ return createResponse(422, createValidationErrorPayload('input', issues), options.responseContentType);
345
611
  }
612
+
613
+ return APIError.internalServerError(
614
+ error instanceof Error ? error.message : 'Validation failed',
615
+ options.responseContentType
616
+ );
617
+ }
618
+ }
619
+
620
+ private getOrCreateMethodMap(path: string): BunMethodMap {
621
+ const existing = this.routeTable[path];
622
+ if (existing instanceof Response) {
623
+ throw new Error(`Cannot register method route for path "${path}" because a static route already exists.`);
624
+ }
625
+ if (existing) {
626
+ return existing as BunMethodMap;
346
627
  }
347
628
 
348
- return APIError.notFound('Route not found');
629
+ const methodMap = Object.create(null) as BunMethodMap;
630
+ this.routeTable[path] = methodMap;
631
+ this.addRouteMatcher(path);
632
+ return methodMap;
349
633
  }
350
634
 
351
- clearRoutes(): void {
352
- this.routes = [];
353
- this.specificityCache.clear();
635
+ private addRouteMatcher(path: string): void {
636
+ if (this.routeMatchers.some((matcher) => matcher.path === path)) {
637
+ return;
638
+ }
639
+
640
+ this.routeMatchers.push({
641
+ path,
642
+ regex: buildRouteRegex(path),
643
+ specificity: this.routeSpecificityScore(path),
644
+ });
645
+
646
+ this.routeMatchers.sort((a, b) => {
647
+ if (a.specificity !== b.specificity) {
648
+ return b.specificity - a.specificity;
649
+ }
650
+ return a.path.localeCompare(b.path);
651
+ });
652
+ }
653
+
654
+ private removeRouteMatcher(path: string): void {
655
+ this.routeMatchers = this.routeMatchers.filter((matcher) => matcher.path !== path);
656
+ }
657
+
658
+ private static parseQuery(url: URL): Record<string, string | string[]> {
659
+ const query: Record<string, string | string[]> = {};
660
+ for (const [key, value] of url.searchParams) {
661
+ if (key in query) {
662
+ const existing = query[key];
663
+ if (Array.isArray(existing)) {
664
+ existing.push(value);
665
+ } else {
666
+ query[key] = [existing as string, value];
667
+ }
668
+ } else {
669
+ query[key] = value;
670
+ }
671
+ }
672
+ return query;
673
+ }
674
+
675
+ private routeSpecificityScore(path: string): number {
676
+ const STATIC_SEGMENT_WEIGHT = 1000;
677
+ const PARAM_SEGMENT_WEIGHT = 10;
678
+ const WILDCARD_WEIGHT = 1;
679
+ const EXACT_MATCH_BONUS = 10000;
680
+
681
+ const segments = path.split('/').filter(Boolean);
682
+ let score = 0;
683
+
684
+ for (const segment of segments) {
685
+ if (segment.includes('*')) {
686
+ score += WILDCARD_WEIGHT;
687
+ } else if (segment.startsWith(':')) {
688
+ score += PARAM_SEGMENT_WEIGHT;
689
+ } else {
690
+ score += STATIC_SEGMENT_WEIGHT;
691
+ }
692
+ }
693
+
694
+ score += path.length;
695
+ if (!path.includes(':') && !path.includes('*')) {
696
+ score += EXACT_MATCH_BONUS;
697
+ }
698
+
699
+ return score;
354
700
  }
355
701
  }