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.
- package/README.md +87 -635
- package/dist/auth/protected.d.ts.map +1 -1
- package/dist/auth/protected.js.map +1 -1
- package/dist/cache/manager.d.ts.map +1 -1
- package/dist/cache/manager.js +2 -7
- package/dist/cache/manager.js.map +1 -1
- package/dist/cli/index.js +17 -62
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/option-resolution.d.ts +4 -0
- package/dist/cli/option-resolution.d.ts.map +1 -0
- package/dist/cli/option-resolution.js +28 -0
- package/dist/cli/option-resolution.js.map +1 -0
- package/dist/cli.js +2721 -617
- package/dist/constants/index.d.ts +3 -0
- package/dist/constants/index.d.ts.map +1 -1
- package/dist/constants/index.js +6 -0
- package/dist/constants/index.js.map +1 -1
- package/dist/core/config-loader.d.ts.map +1 -1
- package/dist/core/config-loader.js +2 -0
- package/dist/core/config-loader.js.map +1 -1
- package/dist/core/router.d.ts +41 -17
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +432 -153
- package/dist/core/router.js.map +1 -1
- package/dist/core/server.d.ts +14 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +250 -30
- package/dist/core/server.js.map +1 -1
- package/dist/core/vector.d.ts +4 -3
- package/dist/core/vector.d.ts.map +1 -1
- package/dist/core/vector.js +21 -12
- package/dist/core/vector.js.map +1 -1
- package/dist/dev/route-generator.d.ts.map +1 -1
- package/dist/dev/route-generator.js.map +1 -1
- package/dist/dev/route-scanner.d.ts.map +1 -1
- package/dist/dev/route-scanner.js +1 -5
- package/dist/dev/route-scanner.js.map +1 -1
- package/dist/http.d.ts +14 -14
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +34 -41
- package/dist/http.js.map +1 -1
- package/dist/index.js +1314 -8
- package/dist/index.mjs +1314 -8
- package/dist/middleware/manager.d.ts.map +1 -1
- package/dist/middleware/manager.js +4 -0
- package/dist/middleware/manager.js.map +1 -1
- package/dist/openapi/docs-ui.d.ts +2 -0
- package/dist/openapi/docs-ui.d.ts.map +1 -0
- package/dist/openapi/docs-ui.js +1313 -0
- package/dist/openapi/docs-ui.js.map +1 -0
- package/dist/openapi/generator.d.ts +12 -0
- package/dist/openapi/generator.d.ts.map +1 -0
- package/dist/openapi/generator.js +273 -0
- package/dist/openapi/generator.js.map +1 -0
- package/dist/types/index.d.ts +70 -11
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/standard-schema.d.ts +118 -0
- package/dist/types/standard-schema.d.ts.map +1 -0
- package/dist/types/standard-schema.js +2 -0
- package/dist/types/standard-schema.js.map +1 -0
- package/dist/utils/cors.d.ts +13 -0
- package/dist/utils/cors.d.ts.map +1 -0
- package/dist/utils/cors.js +89 -0
- package/dist/utils/cors.js.map +1 -0
- package/dist/utils/path.d.ts +6 -0
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js +5 -0
- package/dist/utils/path.js.map +1 -1
- package/dist/utils/schema-validation.d.ts +31 -0
- package/dist/utils/schema-validation.d.ts.map +1 -0
- package/dist/utils/schema-validation.js +77 -0
- package/dist/utils/schema-validation.js.map +1 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +1 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +13 -12
- package/src/auth/protected.ts +3 -13
- package/src/cache/manager.ts +4 -18
- package/src/cli/index.ts +19 -75
- package/src/cli/option-resolution.ts +40 -0
- package/src/constants/index.ts +7 -0
- package/src/core/config-loader.ts +3 -3
- package/src/core/router.ts +502 -156
- package/src/core/server.ts +327 -32
- package/src/core/vector.ts +49 -29
- package/src/dev/route-generator.ts +1 -3
- package/src/dev/route-scanner.ts +2 -9
- package/src/http.ts +85 -125
- package/src/middleware/manager.ts +4 -0
- package/src/openapi/assets/tailwindcdn.js +83 -0
- package/src/openapi/docs-ui.ts +1317 -0
- package/src/openapi/generator.ts +359 -0
- package/src/types/index.ts +104 -17
- package/src/types/standard-schema.ts +147 -0
- package/src/utils/cors.ts +101 -0
- package/src/utils/path.ts +6 -0
- package/src/utils/schema-validation.ts +123 -0
- package/src/utils/validation.ts +1 -0
package/src/core/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
20
|
-
private
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
62
|
+
setCorsHeaders(entries: [string, string][] | null): void {
|
|
63
|
+
this.corsHeadersEntries = entries;
|
|
64
|
+
}
|
|
35
65
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const EXACT_MATCH_BONUS = 10000;
|
|
66
|
+
setCorsHandler(handler: ((response: Response, request: Request) => Response) | null): void {
|
|
67
|
+
this.corsHandler = handler;
|
|
68
|
+
}
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
setRouteBooleanDefaults(defaults?: RouteBooleanDefaults): void {
|
|
71
|
+
this.routeBooleanDefaults = { ...defaults };
|
|
72
|
+
}
|
|
43
73
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
78
|
+
private applyRouteBooleanDefaults(options: RouteOptions<TTypes>): RouteOptions<TTypes> {
|
|
79
|
+
const resolved = { ...options };
|
|
80
|
+
const defaults = this.routeBooleanDefaults;
|
|
55
81
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
return score;
|
|
90
|
+
return resolved;
|
|
62
91
|
}
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
130
|
+
bulkAddRoutes(entries: LegacyRouteEntry[]): void {
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
this.addRoute(entry);
|
|
133
|
+
}
|
|
74
134
|
}
|
|
75
135
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
const pathB = this.extractPath(b);
|
|
145
|
+
getRouteTable(): BunRouteTable {
|
|
146
|
+
return this.routeTable;
|
|
147
|
+
}
|
|
84
148
|
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
return route[PATH_INDEX] || '';
|
|
163
|
+
getRouteDefinitions(): RegisteredRouteDefinition<TTypes>[] {
|
|
164
|
+
return [...this.routeDefinitions];
|
|
95
165
|
}
|
|
96
166
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
request.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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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(
|
|
356
|
+
const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
|
|
203
357
|
if (beforeResult instanceof Response) {
|
|
204
358
|
return beforeResult;
|
|
205
359
|
}
|
|
206
|
-
|
|
360
|
+
const req = beforeResult as VectorRequest<TTypes>;
|
|
207
361
|
|
|
208
362
|
if (options.auth) {
|
|
209
363
|
try {
|
|
210
|
-
await this.authManager.authenticate(
|
|
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 &&
|
|
373
|
+
if (!options.rawRequest && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
374
|
+
let parsedContent: unknown = null;
|
|
220
375
|
try {
|
|
221
|
-
const contentType =
|
|
222
|
-
if (contentType?.
|
|
223
|
-
|
|
224
|
-
} else if (contentType?.
|
|
225
|
-
|
|
226
|
-
} else if (contentType?.
|
|
227
|
-
|
|
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
|
-
|
|
384
|
+
parsedContent = await req.text();
|
|
230
385
|
}
|
|
231
386
|
} catch {
|
|
232
|
-
|
|
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(
|
|
256
|
-
authUser:
|
|
401
|
+
const cacheKey = this.cacheManager.generateKey(req as any, {
|
|
402
|
+
authUser: req.authUser,
|
|
257
403
|
});
|
|
258
|
-
result = await this.cacheManager.get(
|
|
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(
|
|
263
|
-
authUser:
|
|
423
|
+
this.cacheManager.generateKey(req as any, {
|
|
424
|
+
authUser: req.authUser,
|
|
264
425
|
});
|
|
265
|
-
result = await this.cacheManager.get(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
303
|
-
this.
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
let url: URL;
|
|
568
|
+
private setBodyAlias(request: VectorRequest<TTypes>, value: unknown): void {
|
|
320
569
|
try {
|
|
321
|
-
|
|
570
|
+
request.body = value as any;
|
|
322
571
|
} catch {
|
|
323
|
-
|
|
572
|
+
// Keep request.content as source of truth when body alias is readonly.
|
|
324
573
|
}
|
|
325
|
-
|
|
326
|
-
const pathname = url.pathname;
|
|
574
|
+
}
|
|
327
575
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
route: path || pathname,
|
|
338
|
-
});
|
|
582
|
+
if (!inputSchema) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
339
585
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
this.
|
|
353
|
-
|
|
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
|
}
|