vector-framework 1.0.0 → 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 (104) hide show
  1. package/README.md +87 -634
  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 +5 -2
  5. package/dist/cache/manager.d.ts.map +1 -1
  6. package/dist/cache/manager.js +21 -12
  7. package/dist/cache/manager.js.map +1 -1
  8. package/dist/cli/index.js +60 -126
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/option-resolution.d.ts +4 -0
  11. package/dist/cli/option-resolution.d.ts.map +1 -0
  12. package/dist/cli/option-resolution.js +28 -0
  13. package/dist/cli/option-resolution.js.map +1 -0
  14. package/dist/cli.js +2774 -599
  15. package/dist/constants/index.d.ts +3 -0
  16. package/dist/constants/index.d.ts.map +1 -1
  17. package/dist/constants/index.js +6 -0
  18. package/dist/constants/index.js.map +1 -1
  19. package/dist/core/config-loader.d.ts +2 -2
  20. package/dist/core/config-loader.d.ts.map +1 -1
  21. package/dist/core/config-loader.js +18 -18
  22. package/dist/core/config-loader.js.map +1 -1
  23. package/dist/core/router.d.ts +41 -15
  24. package/dist/core/router.d.ts.map +1 -1
  25. package/dist/core/router.js +465 -150
  26. package/dist/core/router.js.map +1 -1
  27. package/dist/core/server.d.ts +17 -3
  28. package/dist/core/server.d.ts.map +1 -1
  29. package/dist/core/server.js +274 -33
  30. package/dist/core/server.js.map +1 -1
  31. package/dist/core/vector.d.ts +9 -8
  32. package/dist/core/vector.d.ts.map +1 -1
  33. package/dist/core/vector.js +40 -32
  34. package/dist/core/vector.js.map +1 -1
  35. package/dist/dev/route-generator.d.ts.map +1 -1
  36. package/dist/dev/route-generator.js.map +1 -1
  37. package/dist/dev/route-scanner.d.ts +1 -1
  38. package/dist/dev/route-scanner.d.ts.map +1 -1
  39. package/dist/dev/route-scanner.js +37 -43
  40. package/dist/dev/route-scanner.js.map +1 -1
  41. package/dist/http.d.ts +14 -14
  42. package/dist/http.d.ts.map +1 -1
  43. package/dist/http.js +84 -84
  44. package/dist/http.js.map +1 -1
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.js +1314 -8
  47. package/dist/index.mjs +1314 -8
  48. package/dist/middleware/manager.d.ts +1 -1
  49. package/dist/middleware/manager.d.ts.map +1 -1
  50. package/dist/middleware/manager.js +4 -0
  51. package/dist/middleware/manager.js.map +1 -1
  52. package/dist/openapi/docs-ui.d.ts +2 -0
  53. package/dist/openapi/docs-ui.d.ts.map +1 -0
  54. package/dist/openapi/docs-ui.js +1313 -0
  55. package/dist/openapi/docs-ui.js.map +1 -0
  56. package/dist/openapi/generator.d.ts +12 -0
  57. package/dist/openapi/generator.d.ts.map +1 -0
  58. package/dist/openapi/generator.js +273 -0
  59. package/dist/openapi/generator.js.map +1 -0
  60. package/dist/types/index.d.ts +70 -11
  61. package/dist/types/index.d.ts.map +1 -1
  62. package/dist/types/standard-schema.d.ts +118 -0
  63. package/dist/types/standard-schema.d.ts.map +1 -0
  64. package/dist/types/standard-schema.js +2 -0
  65. package/dist/types/standard-schema.js.map +1 -0
  66. package/dist/utils/cors.d.ts +13 -0
  67. package/dist/utils/cors.d.ts.map +1 -0
  68. package/dist/utils/cors.js +89 -0
  69. package/dist/utils/cors.js.map +1 -0
  70. package/dist/utils/path.d.ts +7 -0
  71. package/dist/utils/path.d.ts.map +1 -1
  72. package/dist/utils/path.js +14 -3
  73. package/dist/utils/path.js.map +1 -1
  74. package/dist/utils/schema-validation.d.ts +31 -0
  75. package/dist/utils/schema-validation.d.ts.map +1 -0
  76. package/dist/utils/schema-validation.js +77 -0
  77. package/dist/utils/schema-validation.js.map +1 -0
  78. package/dist/utils/validation.d.ts.map +1 -1
  79. package/dist/utils/validation.js +1 -0
  80. package/dist/utils/validation.js.map +1 -1
  81. package/package.json +24 -19
  82. package/src/auth/protected.ts +3 -13
  83. package/src/cache/manager.ts +25 -30
  84. package/src/cli/index.ts +62 -141
  85. package/src/cli/option-resolution.ts +40 -0
  86. package/src/constants/index.ts +7 -0
  87. package/src/core/config-loader.ts +20 -22
  88. package/src/core/router.ts +535 -155
  89. package/src/core/server.ts +354 -45
  90. package/src/core/vector.ts +71 -61
  91. package/src/dev/route-generator.ts +1 -3
  92. package/src/dev/route-scanner.ts +38 -51
  93. package/src/http.ts +117 -187
  94. package/src/index.ts +3 -3
  95. package/src/middleware/manager.ts +8 -11
  96. package/src/openapi/assets/tailwindcdn.js +83 -0
  97. package/src/openapi/docs-ui.ts +1317 -0
  98. package/src/openapi/generator.ts +359 -0
  99. package/src/types/index.ts +104 -17
  100. package/src/types/standard-schema.ts +147 -0
  101. package/src/utils/cors.ts +101 -0
  102. package/src/utils/path.ts +19 -4
  103. package/src/utils/schema-validation.ts +123 -0
  104. package/src/utils/validation.ts +1 -0
@@ -1,22 +1,53 @@
1
- import type { RouteEntry } from 'itty-router';
2
- import { withCookies } from 'itty-router';
3
1
  import type { AuthManager } from '../auth/protected';
4
2
  import type { CacheManager } from '../cache/manager';
5
3
  import { APIError, createResponse } from '../http';
6
4
  import type { MiddlewareManager } from '../middleware/manager';
5
+ import { STATIC_RESPONSES } from '../constants';
6
+ import { buildRouteRegex } from '../utils/path';
7
7
  import type {
8
+ BunMethodMap,
9
+ BunRouteTable,
8
10
  DefaultVectorTypes,
11
+ InferRouteInputFromSchemaDefinition,
12
+ RouteBooleanDefaults,
13
+ LegacyRouteEntry,
9
14
  RouteHandler,
10
15
  RouteOptions,
16
+ RouteSchemaDefinition,
11
17
  VectorRequest,
12
18
  VectorTypes,
13
19
  } from '../types';
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[] = [];
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;
20
51
 
21
52
  constructor(
22
53
  middlewareManager: MiddlewareManager<TTypes>,
@@ -28,90 +59,155 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
28
59
  this.cacheManager = cacheManager;
29
60
  }
30
61
 
31
- private getRouteSpecificity(path: string): number {
32
- const STATIC_SEGMENT_WEIGHT = 1000;
33
- const PARAM_SEGMENT_WEIGHT = 10;
34
- const WILDCARD_WEIGHT = 1;
35
- const EXACT_MATCH_BONUS = 10000;
62
+ setCorsHeaders(entries: [string, string][] | null): void {
63
+ this.corsHeadersEntries = entries;
64
+ }
36
65
 
37
- let score = 0;
38
- const segments = path.split('/').filter(Boolean);
66
+ setCorsHandler(handler: ((response: Response, request: Request) => Response) | null): void {
67
+ this.corsHandler = handler;
68
+ }
39
69
 
40
- for (const segment of segments) {
41
- if (this.isStaticSegment(segment)) {
42
- score += STATIC_SEGMENT_WEIGHT;
43
- } else if (this.isParamSegment(segment)) {
44
- score += PARAM_SEGMENT_WEIGHT;
45
- } else if (this.isWildcardSegment(segment)) {
46
- score += WILDCARD_WEIGHT;
47
- }
48
- }
70
+ setRouteBooleanDefaults(defaults?: RouteBooleanDefaults): void {
71
+ this.routeBooleanDefaults = { ...defaults };
72
+ }
49
73
 
50
- score += path.length;
74
+ setDevelopmentMode(mode?: boolean): void {
75
+ this.developmentMode = mode;
76
+ }
51
77
 
52
- if (this.isExactPath(path)) {
53
- score += EXACT_MATCH_BONUS;
78
+ private applyRouteBooleanDefaults(options: RouteOptions<TTypes>): RouteOptions<TTypes> {
79
+ const resolved = { ...options };
80
+ const defaults = this.routeBooleanDefaults;
81
+
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
+ }
54
88
  }
55
89
 
56
- return score;
90
+ return resolved;
57
91
  }
58
92
 
59
- private isStaticSegment(segment: string): boolean {
60
- 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
+ });
61
110
  }
62
111
 
63
- private isParamSegment(segment: string): boolean {
64
- 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
+ });
65
128
  }
66
129
 
67
- private isWildcardSegment(segment: string): boolean {
68
- return segment.includes('*');
130
+ bulkAddRoutes(entries: LegacyRouteEntry[]): void {
131
+ for (const entry of entries) {
132
+ this.addRoute(entry);
133
+ }
69
134
  }
70
135
 
71
- private isExactPath(path: string): boolean {
72
- 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);
73
143
  }
74
144
 
75
- sortRoutes(): void {
76
- this.routes.sort((a, b) => {
77
- const pathA = this.extractPath(a);
78
- const pathB = this.extractPath(b);
145
+ getRouteTable(): BunRouteTable {
146
+ return this.routeTable;
147
+ }
79
148
 
80
- const scoreA = this.getRouteSpecificity(pathA);
81
- 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;
82
155
 
83
- return scoreB - scoreA;
84
- });
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;
85
161
  }
86
162
 
87
- private extractPath(route: RouteEntry): string {
88
- const PATH_INDEX = 3;
89
- return route[PATH_INDEX] || '';
163
+ getRouteDefinitions(): RegisteredRouteDefinition<TTypes>[] {
164
+ return [...this.routeDefinitions];
90
165
  }
91
166
 
92
- route(options: RouteOptions<TTypes>, handler: RouteHandler<TTypes>): RouteEntry {
93
- const wrappedHandler = this.wrapHandler(options, handler);
94
- const routeEntry: RouteEntry = [
95
- options.method.toUpperCase(),
96
- this.createRouteRegex(options.path),
97
- [wrappedHandler],
98
- options.path,
99
- ];
100
-
101
- this.routes.push(routeEntry);
102
- this.sortRoutes(); // Sort routes after adding
103
- return routeEntry;
167
+ clearRoutes(): void {
168
+ this.routeTable = Object.create(null) as BunRouteTable;
169
+ this.routeMatchers = [];
170
+ this.routeDefinitions = [];
104
171
  }
105
172
 
106
- private createRouteRegex(path: string): RegExp {
107
- return RegExp(
108
- `^${path
109
- .replace(/\/+(\/|$)/g, '$1')
110
- .replace(/(\/?\.?):(\w+)\+/g, '($1(?<$2>*))')
111
- .replace(/(\/?\.?):(\w+)/g, '($1(?<$2>[^$1/]+?))')
112
- .replace(/\./g, '\\.')
113
- .replace(/(\/?)\*/g, '($1.*)?')}/*$`
114
- );
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;
115
211
  }
116
212
 
117
213
  private prepareRequest(
@@ -122,14 +218,22 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
122
218
  metadata?: any;
123
219
  }
124
220
  ): void {
125
- // Initialize context if not present
126
221
  if (!request.context) {
127
222
  request.context = {} as any;
128
223
  }
129
224
 
130
- // Set params and route if provided
131
- if (options?.params !== undefined) {
132
- 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
+ }
133
237
  }
134
238
  if (options?.route !== undefined) {
135
239
  request.route = options.route;
@@ -138,56 +242,126 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
138
242
  request.metadata = options.metadata;
139
243
  }
140
244
 
141
- // Parse query parameters from URL if not already parsed
142
- if (!request.query && request.url) {
143
- const url = new URL(request.url);
144
- const query: Record<string, string | string[]> = {};
145
- for (const [key, value] of url.searchParams) {
146
- if (key in query) {
147
- if (Array.isArray(query[key])) {
148
- (query[key] as string[]).push(value);
149
- } else {
150
- query[key] = [query[key] as string, value];
151
- }
152
- } else {
153
- 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.
154
276
  }
155
277
  }
156
- request.query = query;
157
278
  }
158
279
 
159
- // Parse cookies if not already parsed
160
- if (!request.cookies) {
161
- withCookies(request as any);
280
+ if (!Object.getOwnPropertyDescriptor(request, 'cookies')) {
281
+ Object.defineProperty(request, 'cookies', {
282
+ get() {
283
+ const cookieHeader = this.headers.get('cookie') ?? '';
284
+ const cookies: Record<string, string> = {};
285
+ if (cookieHeader) {
286
+ for (const pair of cookieHeader.split(';')) {
287
+ const idx = pair.indexOf('=');
288
+ if (idx > 0) {
289
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
290
+ }
291
+ }
292
+ }
293
+ Object.defineProperty(this, 'cookies', {
294
+ value: cookies,
295
+ writable: true,
296
+ configurable: true,
297
+ enumerable: true,
298
+ });
299
+ return cookies;
300
+ },
301
+ configurable: true,
302
+ enumerable: true,
303
+ });
304
+ }
305
+ }
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;
162
332
  }
333
+
334
+ return matched.groups as Record<string, string>;
163
335
  }
164
336
 
165
337
  private wrapHandler(options: RouteOptions<TTypes>, handler: RouteHandler<TTypes>) {
166
- return async (request: any) => {
167
- // Ensure request has required properties
168
- 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);
169
344
 
170
- // Prepare the request with common logic
171
345
  this.prepareRequest(vectorRequest, {
172
- metadata: options.metadata
346
+ params: fallbackParams,
347
+ route: routePath,
348
+ metadata: options.metadata,
173
349
  });
174
350
 
175
- request = vectorRequest;
176
351
  try {
177
- // Default expose to true if not specified
178
352
  if (options.expose === false) {
179
353
  return APIError.forbidden('Forbidden');
180
354
  }
181
355
 
182
- const beforeResult = await this.middlewareManager.executeBefore(request);
356
+ const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
183
357
  if (beforeResult instanceof Response) {
184
358
  return beforeResult;
185
359
  }
186
- request = beforeResult as any;
360
+ const req = beforeResult as VectorRequest<TTypes>;
187
361
 
188
362
  if (options.auth) {
189
363
  try {
190
- await this.authManager.authenticate(request);
364
+ await this.authManager.authenticate(req);
191
365
  } catch (error) {
192
366
  return APIError.unauthorized(
193
367
  error instanceof Error ? error.message : 'Authentication failed',
@@ -196,62 +370,83 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
196
370
  }
197
371
  }
198
372
 
199
- if (!options.rawRequest && request.method !== 'GET' && request.method !== 'HEAD') {
373
+ if (!options.rawRequest && req.method !== 'GET' && req.method !== 'HEAD') {
374
+ let parsedContent: unknown = null;
200
375
  try {
201
- const contentType = request.headers.get('content-type');
202
- if (contentType?.includes('application/json')) {
203
- request.content = await request.json();
204
- } else if (contentType?.includes('application/x-www-form-urlencoded')) {
205
- request.content = Object.fromEntries(await request.formData());
206
- } else if (contentType?.includes('multipart/form-data')) {
207
- 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();
208
383
  } else {
209
- request.content = await request.text();
384
+ parsedContent = await req.text();
210
385
  }
211
386
  } catch {
212
- request.content = null;
387
+ parsedContent = null;
213
388
  }
389
+ this.setContentAndBodyAlias(req, parsedContent);
390
+ }
391
+
392
+ const inputValidationResponse = await this.validateInputSchema(req, options);
393
+ if (inputValidationResponse) {
394
+ return inputValidationResponse;
214
395
  }
215
396
 
216
397
  let result;
217
398
  const cacheOptions = options.cache;
218
399
 
219
- // Create cache factory that handles Response objects
220
- const cacheFactory = async () => {
221
- const res = await handler(request);
222
- // If Response, extract data for caching
223
- if (res instanceof Response) {
224
- return {
225
- _isResponse: true,
226
- body: await res.text(),
227
- status: res.status,
228
- headers: Object.fromEntries(res.headers.entries())
229
- };
230
- }
231
- return res;
232
- };
233
-
234
400
  if (cacheOptions && typeof cacheOptions === 'number' && cacheOptions > 0) {
235
- const cacheKey = this.cacheManager.generateKey(request as any, {
236
- authUser: request.authUser,
401
+ const cacheKey = this.cacheManager.generateKey(req as any, {
402
+ authUser: req.authUser,
237
403
  });
238
- 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
+ );
239
420
  } else if (cacheOptions && typeof cacheOptions === 'object' && cacheOptions.ttl) {
240
421
  const cacheKey =
241
422
  cacheOptions.key ||
242
- this.cacheManager.generateKey(request as any, {
243
- authUser: request.authUser,
423
+ this.cacheManager.generateKey(req as any, {
424
+ authUser: req.authUser,
244
425
  });
245
- 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
+ );
246
442
  } else {
247
- result = await handler(request);
443
+ result = await handler(req);
248
444
  }
249
445
 
250
- // Reconstruct Response if it was cached
251
446
  if (result && typeof result === 'object' && result._isResponse === true) {
252
447
  result = new Response(result.body, {
253
448
  status: result.status,
254
- headers: result.headers
449
+ headers: result.headers,
255
450
  });
256
451
  }
257
452
 
@@ -262,7 +457,20 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
262
457
  response = createResponse(200, result, options.responseContentType);
263
458
  }
264
459
 
265
- 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
+ }
266
474
 
267
475
  return response;
268
476
  } catch (error) {
@@ -279,43 +487,215 @@ export class VectorRouter<TTypes extends VectorTypes = DefaultVectorTypes> {
279
487
  };
280
488
  }
281
489
 
282
- addRoute(routeEntry: RouteEntry) {
283
- this.routes.push(routeEntry);
284
- 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
+ };
520
+ }
521
+
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);
554
+ }
285
555
  }
286
556
 
287
- getRoutes(): RouteEntry[] {
288
- 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);
289
566
  }
290
567
 
291
- async handle(request: Request): Promise<Response> {
292
- const url = new URL(request.url);
293
- const pathname = url.pathname;
568
+ private setBodyAlias(request: VectorRequest<TTypes>, value: unknown): void {
569
+ try {
570
+ request.body = value as any;
571
+ } catch {
572
+ // Keep request.content as source of truth when body alias is readonly.
573
+ }
574
+ }
294
575
 
295
- for (const [method, regex, handlers, path] of this.routes) {
296
- if (request.method === 'OPTIONS' || request.method === method) {
297
- const match = pathname.match(regex);
298
- if (match) {
299
- 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;
300
581
 
301
- // Prepare the request with common logic
302
- this.prepareRequest(req, {
303
- params: match.groups || {},
304
- route: path || pathname
305
- });
582
+ if (!inputSchema) {
583
+ return null;
584
+ }
306
585
 
307
- for (const handler of handlers) {
308
- const response = await handler(req as any);
309
- if (response) return response;
310
- }
311
- }
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);
312
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;
313
627
  }
314
628
 
315
- 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;
316
633
  }
317
634
 
318
- clearRoutes(): void {
319
- this.routes = [];
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;
320
700
  }
321
701
  }