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.
- package/README.md +87 -634
- package/dist/auth/protected.d.ts.map +1 -1
- package/dist/auth/protected.js.map +1 -1
- package/dist/cache/manager.d.ts +5 -2
- package/dist/cache/manager.d.ts.map +1 -1
- package/dist/cache/manager.js +21 -12
- package/dist/cache/manager.js.map +1 -1
- package/dist/cli/index.js +60 -126
- 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 +2774 -599
- 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 +2 -2
- package/dist/core/config-loader.d.ts.map +1 -1
- package/dist/core/config-loader.js +18 -18
- package/dist/core/config-loader.js.map +1 -1
- package/dist/core/router.d.ts +41 -15
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +465 -150
- package/dist/core/router.js.map +1 -1
- package/dist/core/server.d.ts +17 -3
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +274 -33
- package/dist/core/server.js.map +1 -1
- package/dist/core/vector.d.ts +9 -8
- package/dist/core/vector.d.ts.map +1 -1
- package/dist/core/vector.js +40 -32
- 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 +1 -1
- package/dist/dev/route-scanner.d.ts.map +1 -1
- package/dist/dev/route-scanner.js +37 -43
- 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 +84 -84
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1314 -8
- package/dist/index.mjs +1314 -8
- package/dist/middleware/manager.d.ts +1 -1
- 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 +7 -0
- package/dist/utils/path.d.ts.map +1 -1
- package/dist/utils/path.js +14 -3
- 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 +24 -19
- package/src/auth/protected.ts +3 -13
- package/src/cache/manager.ts +25 -30
- package/src/cli/index.ts +62 -141
- package/src/cli/option-resolution.ts +40 -0
- package/src/constants/index.ts +7 -0
- package/src/core/config-loader.ts +20 -22
- package/src/core/router.ts +535 -155
- package/src/core/server.ts +354 -45
- package/src/core/vector.ts +71 -61
- package/src/dev/route-generator.ts +1 -3
- package/src/dev/route-scanner.ts +38 -51
- package/src/http.ts +117 -187
- package/src/index.ts +3 -3
- package/src/middleware/manager.ts +8 -11
- 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 +19 -4
- package/src/utils/schema-validation.ts +123 -0
- package/src/utils/validation.ts +1 -0
package/src/core/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
66
|
+
setCorsHandler(handler: ((response: Response, request: Request) => Response) | null): void {
|
|
67
|
+
this.corsHandler = handler;
|
|
68
|
+
}
|
|
39
69
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
74
|
+
setDevelopmentMode(mode?: boolean): void {
|
|
75
|
+
this.developmentMode = mode;
|
|
76
|
+
}
|
|
51
77
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
90
|
+
return resolved;
|
|
57
91
|
}
|
|
58
92
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
130
|
+
bulkAddRoutes(entries: LegacyRouteEntry[]): void {
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
this.addRoute(entry);
|
|
133
|
+
}
|
|
69
134
|
}
|
|
70
135
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
const pathB = this.extractPath(b);
|
|
145
|
+
getRouteTable(): BunRouteTable {
|
|
146
|
+
return this.routeTable;
|
|
147
|
+
}
|
|
79
148
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
return route[PATH_INDEX] || '';
|
|
163
|
+
getRouteDefinitions(): RegisteredRouteDefinition<TTypes>[] {
|
|
164
|
+
return [...this.routeDefinitions];
|
|
90
165
|
}
|
|
91
166
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
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
|
+
}
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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(
|
|
356
|
+
const beforeResult = await this.middlewareManager.executeBefore(vectorRequest);
|
|
183
357
|
if (beforeResult instanceof Response) {
|
|
184
358
|
return beforeResult;
|
|
185
359
|
}
|
|
186
|
-
|
|
360
|
+
const req = beforeResult as VectorRequest<TTypes>;
|
|
187
361
|
|
|
188
362
|
if (options.auth) {
|
|
189
363
|
try {
|
|
190
|
-
await this.authManager.authenticate(
|
|
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 &&
|
|
373
|
+
if (!options.rawRequest && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
374
|
+
let parsedContent: unknown = null;
|
|
200
375
|
try {
|
|
201
|
-
const contentType =
|
|
202
|
-
if (contentType?.
|
|
203
|
-
|
|
204
|
-
} else if (contentType?.
|
|
205
|
-
|
|
206
|
-
} else if (contentType?.
|
|
207
|
-
|
|
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
|
-
|
|
384
|
+
parsedContent = await req.text();
|
|
210
385
|
}
|
|
211
386
|
} catch {
|
|
212
|
-
|
|
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(
|
|
236
|
-
authUser:
|
|
401
|
+
const cacheKey = this.cacheManager.generateKey(req as any, {
|
|
402
|
+
authUser: req.authUser,
|
|
237
403
|
});
|
|
238
|
-
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
|
+
);
|
|
239
420
|
} else if (cacheOptions && typeof cacheOptions === 'object' && cacheOptions.ttl) {
|
|
240
421
|
const cacheKey =
|
|
241
422
|
cacheOptions.key ||
|
|
242
|
-
this.cacheManager.generateKey(
|
|
243
|
-
authUser:
|
|
423
|
+
this.cacheManager.generateKey(req as any, {
|
|
424
|
+
authUser: req.authUser,
|
|
244
425
|
});
|
|
245
|
-
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
|
+
);
|
|
246
442
|
} else {
|
|
247
|
-
result = await handler(
|
|
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,
|
|
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
|
-
|
|
283
|
-
this.
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
route: path || pathname
|
|
305
|
-
});
|
|
582
|
+
if (!inputSchema) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
306
585
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
this.
|
|
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
|
}
|