bxo 0.0.5-dev.4 → 0.0.5-dev.41
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 +99 -2
- package/index.ts +886 -197
- package/package.json +3 -5
- package/plugins/cors.ts +95 -51
- package/plugins/index.ts +0 -2
- package/plugins/ratelimit.ts +54 -58
- package/example.ts +0 -183
- package/plugins/auth.ts +0 -119
- package/plugins/logger.ts +0 -109
package/index.ts
CHANGED
|
@@ -3,6 +3,31 @@ import { z } from 'zod';
|
|
|
3
3
|
// Type utilities for extracting types from Zod schemas
|
|
4
4
|
type InferZodType<T> = T extends z.ZodType<infer U> ? U : never;
|
|
5
5
|
|
|
6
|
+
// Response configuration types
|
|
7
|
+
type ResponseSchema = z.ZodSchema<any>;
|
|
8
|
+
type StatusResponseSchema = Record<number, ResponseSchema>;
|
|
9
|
+
type ResponseConfig = ResponseSchema | StatusResponseSchema;
|
|
10
|
+
|
|
11
|
+
// Type utility to extract response type from response config
|
|
12
|
+
type InferResponseType<T> = T extends ResponseSchema
|
|
13
|
+
? InferZodType<T>
|
|
14
|
+
: T extends StatusResponseSchema
|
|
15
|
+
? { [K in keyof T]: InferZodType<T[K]> }[keyof T]
|
|
16
|
+
: never;
|
|
17
|
+
|
|
18
|
+
// Cookie interface
|
|
19
|
+
interface Cookie {
|
|
20
|
+
name: string;
|
|
21
|
+
value: string;
|
|
22
|
+
domain?: string;
|
|
23
|
+
path?: string;
|
|
24
|
+
expires?: Date;
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
secure?: boolean;
|
|
27
|
+
httpOnly?: boolean;
|
|
28
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
29
|
+
}
|
|
30
|
+
|
|
6
31
|
// OpenAPI detail information
|
|
7
32
|
interface RouteDetail {
|
|
8
33
|
summary?: string;
|
|
@@ -21,28 +46,54 @@ interface RouteConfig {
|
|
|
21
46
|
query?: z.ZodSchema<any>;
|
|
22
47
|
body?: z.ZodSchema<any>;
|
|
23
48
|
headers?: z.ZodSchema<any>;
|
|
24
|
-
|
|
49
|
+
cookies?: z.ZodSchema<any>;
|
|
50
|
+
response?: ResponseConfig;
|
|
25
51
|
detail?: RouteDetail;
|
|
26
52
|
}
|
|
27
53
|
|
|
54
|
+
// Helper type to extract status codes from response config
|
|
55
|
+
type StatusCodes<T> = T extends Record<number, any> ? keyof T : never;
|
|
56
|
+
|
|
28
57
|
// Context type that's fully typed based on the route configuration
|
|
29
58
|
export type Context<TConfig extends RouteConfig = {}> = {
|
|
30
59
|
params: TConfig['params'] extends z.ZodSchema<any> ? InferZodType<TConfig['params']> : Record<string, string>;
|
|
31
60
|
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
|
32
61
|
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
|
33
62
|
headers: TConfig['headers'] extends z.ZodSchema<any> ? InferZodType<TConfig['headers']> : Record<string, string>;
|
|
63
|
+
cookies: TConfig['cookies'] extends z.ZodSchema<any> ? InferZodType<TConfig['cookies']> : Record<string, string>;
|
|
64
|
+
path: string;
|
|
34
65
|
request: Request;
|
|
35
66
|
set: {
|
|
36
67
|
status?: number;
|
|
37
68
|
headers?: Record<string, string>;
|
|
69
|
+
cookies?: Cookie[];
|
|
70
|
+
redirect?: { location: string; status?: number };
|
|
38
71
|
};
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
status: <T extends number>(
|
|
73
|
+
code: TConfig['response'] extends StatusResponseSchema
|
|
74
|
+
? StatusCodes<TConfig['response']> | number
|
|
75
|
+
: T,
|
|
76
|
+
data?: TConfig['response'] extends StatusResponseSchema
|
|
77
|
+
? T extends keyof TConfig['response']
|
|
78
|
+
? InferZodType<TConfig['response'][T]>
|
|
79
|
+
: any
|
|
80
|
+
: TConfig['response'] extends ResponseSchema
|
|
81
|
+
? InferZodType<TConfig['response']>
|
|
82
|
+
: any
|
|
83
|
+
) => TConfig['response'] extends StatusResponseSchema
|
|
84
|
+
? T extends keyof TConfig['response']
|
|
85
|
+
? InferZodType<TConfig['response'][T]>
|
|
86
|
+
: any
|
|
87
|
+
: TConfig['response'] extends ResponseSchema
|
|
88
|
+
? InferZodType<TConfig['response']>
|
|
89
|
+
: any;
|
|
90
|
+
redirect: (location: string, status?: number) => Response;
|
|
91
|
+
clearRedirect: () => void;
|
|
41
92
|
[key: string]: any;
|
|
42
93
|
};
|
|
43
94
|
|
|
44
|
-
// Handler function type
|
|
45
|
-
type Handler<TConfig extends RouteConfig = {}> = (ctx: Context<TConfig>) => Promise<any> | any;
|
|
95
|
+
// Handler function type with proper response typing
|
|
96
|
+
type Handler<TConfig extends RouteConfig = {}, EC = {}> = (ctx: Context<TConfig> & EC) => Promise<InferResponseType<TConfig['response']> | any> | InferResponseType<TConfig['response']> | any;
|
|
46
97
|
|
|
47
98
|
// Route definition
|
|
48
99
|
interface Route {
|
|
@@ -52,14 +103,39 @@ interface Route {
|
|
|
52
103
|
config?: RouteConfig;
|
|
53
104
|
}
|
|
54
105
|
|
|
106
|
+
// WebSocket handler interface
|
|
107
|
+
interface WebSocketHandler {
|
|
108
|
+
onOpen?: (ws: any) => void;
|
|
109
|
+
onMessage?: (ws: any, message: string | Buffer) => void;
|
|
110
|
+
onClose?: (ws: any, code?: number, reason?: string) => void;
|
|
111
|
+
onError?: (ws: any, error: Error) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// WebSocket route definition
|
|
115
|
+
interface WSRoute {
|
|
116
|
+
path: string;
|
|
117
|
+
handler: WebSocketHandler;
|
|
118
|
+
}
|
|
119
|
+
|
|
55
120
|
// Lifecycle hooks
|
|
56
121
|
interface LifecycleHooks {
|
|
57
|
-
onBeforeStart?: () => Promise<void> | void;
|
|
58
|
-
onAfterStart?: () => Promise<void> | void;
|
|
59
|
-
onBeforeStop?: () => Promise<void> | void;
|
|
60
|
-
onAfterStop?: () => Promise<void> | void;
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
|
123
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
|
124
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
|
125
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
|
126
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<void> | void;
|
|
127
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
|
128
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Add global options for BXO
|
|
132
|
+
interface BXOOptions {
|
|
133
|
+
enableValidation?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Plugin interface for middleware-style plugins
|
|
137
|
+
interface Plugin {
|
|
138
|
+
name?: string;
|
|
63
139
|
onRequest?: (ctx: Context) => Promise<void> | void;
|
|
64
140
|
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
|
65
141
|
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
|
@@ -67,67 +143,77 @@ interface LifecycleHooks {
|
|
|
67
143
|
|
|
68
144
|
export default class BXO {
|
|
69
145
|
private _routes: Route[] = [];
|
|
146
|
+
private _wsRoutes: WSRoute[] = [];
|
|
70
147
|
private plugins: BXO[] = [];
|
|
71
|
-
private
|
|
148
|
+
private middleware: Plugin[] = []; // New middleware array
|
|
149
|
+
private hooks: {
|
|
150
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
|
151
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
|
152
|
+
onBeforeRestart?: (instance: BXO) => Promise<void> | void;
|
|
153
|
+
onAfterRestart?: (instance: BXO) => Promise<void> | void;
|
|
154
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
|
155
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
|
156
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<void> | void;
|
|
157
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
|
158
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
|
159
|
+
} = {};
|
|
72
160
|
private server?: any;
|
|
73
161
|
private isRunning: boolean = false;
|
|
74
|
-
private hotReloadEnabled: boolean = false;
|
|
75
|
-
private watchedFiles: Set<string> = new Set();
|
|
76
|
-
private watchedExclude: Set<string> = new Set();
|
|
77
162
|
private serverPort?: number;
|
|
78
163
|
private serverHostname?: string;
|
|
164
|
+
private enableValidation: boolean = true;
|
|
79
165
|
|
|
80
|
-
constructor() {
|
|
166
|
+
constructor(options?: BXOOptions) {
|
|
167
|
+
this.enableValidation = options?.enableValidation ?? true;
|
|
168
|
+
}
|
|
81
169
|
|
|
82
170
|
// Lifecycle hook methods
|
|
83
|
-
onBeforeStart(handler: () => Promise<void> | void): this {
|
|
171
|
+
onBeforeStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
84
172
|
this.hooks.onBeforeStart = handler;
|
|
85
173
|
return this;
|
|
86
174
|
}
|
|
87
175
|
|
|
88
|
-
onAfterStart(handler: () => Promise<void> | void): this {
|
|
176
|
+
onAfterStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
89
177
|
this.hooks.onAfterStart = handler;
|
|
90
178
|
return this;
|
|
91
179
|
}
|
|
92
180
|
|
|
93
|
-
onBeforeStop(handler: () => Promise<void> | void): this {
|
|
181
|
+
onBeforeStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
94
182
|
this.hooks.onBeforeStop = handler;
|
|
95
183
|
return this;
|
|
96
184
|
}
|
|
97
185
|
|
|
98
|
-
onAfterStop(handler: () => Promise<void> | void): this {
|
|
186
|
+
onAfterStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
99
187
|
this.hooks.onAfterStop = handler;
|
|
100
188
|
return this;
|
|
101
189
|
}
|
|
102
190
|
|
|
103
|
-
onBeforeRestart(handler: () => Promise<void> | void): this {
|
|
104
|
-
this.hooks.onBeforeRestart = handler;
|
|
105
|
-
return this;
|
|
106
|
-
}
|
|
107
191
|
|
|
108
|
-
onAfterRestart(handler: () => Promise<void> | void): this {
|
|
109
|
-
this.hooks.onAfterRestart = handler;
|
|
110
|
-
return this;
|
|
111
|
-
}
|
|
112
192
|
|
|
113
|
-
onRequest(handler: (ctx: Context) => Promise<void> | void): this {
|
|
193
|
+
onRequest(handler: (ctx: Context, instance: BXO) => Promise<void> | void): this {
|
|
114
194
|
this.hooks.onRequest = handler;
|
|
115
195
|
return this;
|
|
116
196
|
}
|
|
117
197
|
|
|
118
|
-
onResponse(handler: (ctx: Context, response: any) => Promise<any> | any): this {
|
|
198
|
+
onResponse(handler: (ctx: Context, response: any, instance: BXO) => Promise<any> | any): this {
|
|
119
199
|
this.hooks.onResponse = handler;
|
|
120
200
|
return this;
|
|
121
201
|
}
|
|
122
202
|
|
|
123
|
-
onError(handler: (ctx: Context, error: Error) => Promise<any> | any): this {
|
|
203
|
+
onError(handler: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any): this {
|
|
124
204
|
this.hooks.onError = handler;
|
|
125
205
|
return this;
|
|
126
206
|
}
|
|
127
207
|
|
|
128
|
-
// Plugin system - now accepts
|
|
129
|
-
use(
|
|
130
|
-
|
|
208
|
+
// Plugin system - now accepts both BXO instances and middleware plugins
|
|
209
|
+
use(plugin: BXO | Plugin): this {
|
|
210
|
+
if ('_routes' in plugin) {
|
|
211
|
+
// It's a BXO instance
|
|
212
|
+
this.plugins.push(plugin);
|
|
213
|
+
} else {
|
|
214
|
+
// It's a middleware plugin
|
|
215
|
+
this.middleware.push(plugin);
|
|
216
|
+
}
|
|
131
217
|
return this;
|
|
132
218
|
}
|
|
133
219
|
|
|
@@ -222,31 +308,301 @@ export default class BXO {
|
|
|
222
308
|
return this;
|
|
223
309
|
}
|
|
224
310
|
|
|
311
|
+
// WebSocket route handler
|
|
312
|
+
ws(path: string, handler: WebSocketHandler): this {
|
|
313
|
+
this._wsRoutes.push({ path, handler });
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Helper methods to get all routes including plugin routes
|
|
318
|
+
private getAllRoutes(): Route[] {
|
|
319
|
+
const allRoutes = [...this._routes];
|
|
320
|
+
for (const plugin of this.plugins) {
|
|
321
|
+
allRoutes.push(...plugin._routes);
|
|
322
|
+
}
|
|
323
|
+
return allRoutes;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private getAllWSRoutes(): WSRoute[] {
|
|
327
|
+
const allWSRoutes = [...this._wsRoutes];
|
|
328
|
+
for (const plugin of this.plugins) {
|
|
329
|
+
allWSRoutes.push(...plugin._wsRoutes);
|
|
330
|
+
}
|
|
331
|
+
return allWSRoutes;
|
|
332
|
+
}
|
|
333
|
+
|
|
225
334
|
// Route matching utility
|
|
226
335
|
private matchRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | null {
|
|
227
|
-
|
|
336
|
+
const allRoutes = this.getAllRoutes();
|
|
337
|
+
|
|
338
|
+
for (const route of allRoutes) {
|
|
228
339
|
if (route.method !== method) continue;
|
|
229
340
|
|
|
230
341
|
const routeSegments = route.path.split('/').filter(Boolean);
|
|
231
342
|
const pathSegments = pathname.split('/').filter(Boolean);
|
|
232
343
|
|
|
233
|
-
|
|
344
|
+
const params: Record<string, string> = {};
|
|
345
|
+
let isMatch = true;
|
|
346
|
+
|
|
347
|
+
// Check for double wildcard (**) in the route
|
|
348
|
+
const hasDoubleWildcard = routeSegments.some(segment => segment === '**');
|
|
349
|
+
|
|
350
|
+
// Handle double wildcard at the end (catch-all with slashes)
|
|
351
|
+
const hasDoubleWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '**';
|
|
352
|
+
|
|
353
|
+
// Handle single wildcard at the end (catch-all)
|
|
354
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
|
355
|
+
|
|
356
|
+
if (hasDoubleWildcardAtEnd) {
|
|
357
|
+
// For double wildcard at end, path must have at least as many segments as route (minus the double wildcard)
|
|
358
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
359
|
+
} else if (hasWildcardAtEnd) {
|
|
360
|
+
// For single wildcard at end, path must have at least as many segments as route (minus the wildcard)
|
|
361
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
362
|
+
} else if (!hasDoubleWildcard) {
|
|
363
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
|
364
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
368
|
+
const routeSegment = routeSegments[i];
|
|
369
|
+
const pathSegment = pathSegments[i];
|
|
370
|
+
|
|
371
|
+
if (!routeSegment) {
|
|
372
|
+
isMatch = false;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Handle double wildcard at the end (matches everything including slashes)
|
|
377
|
+
if (routeSegment === '**' && i === routeSegments.length - 1) {
|
|
378
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
379
|
+
params['**'] = remainingPath;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Handle single wildcard at the end (catch-all)
|
|
384
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
|
385
|
+
// Wildcard at end matches remaining path segments
|
|
386
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
387
|
+
params['*'] = remainingPath;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Handle double wildcard in the middle (matches everything including slashes)
|
|
392
|
+
if (routeSegment === '**') {
|
|
393
|
+
// Find the next non-wildcard segment to match against
|
|
394
|
+
let nextNonWildcardIndex = i + 1;
|
|
395
|
+
while (nextNonWildcardIndex < routeSegments.length &&
|
|
396
|
+
(routeSegments[nextNonWildcardIndex] === '*' || routeSegments[nextNonWildcardIndex] === '**')) {
|
|
397
|
+
nextNonWildcardIndex++;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (nextNonWildcardIndex >= routeSegments.length) {
|
|
401
|
+
// Double wildcard is at the end or followed by other wildcards
|
|
402
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
403
|
+
params['**'] = remainingPath;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Find the next matching segment in the path
|
|
408
|
+
const nextRouteSegment = routeSegments[nextNonWildcardIndex];
|
|
409
|
+
if (!nextRouteSegment) {
|
|
410
|
+
isMatch = false;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let foundMatch = false;
|
|
415
|
+
let matchedPath = '';
|
|
416
|
+
|
|
417
|
+
for (let j = i; j < pathSegments.length; j++) {
|
|
418
|
+
const currentPathSegment = pathSegments[j];
|
|
419
|
+
|
|
420
|
+
// Check if this path segment matches the next route segment
|
|
421
|
+
if (nextRouteSegment.startsWith(':')) {
|
|
422
|
+
// Param segment - always matches
|
|
423
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
424
|
+
params['**'] = matchedPath;
|
|
425
|
+
i = j - 1; // Adjust index for the next iteration
|
|
426
|
+
foundMatch = true;
|
|
427
|
+
break;
|
|
428
|
+
} else if (nextRouteSegment === '*') {
|
|
429
|
+
// Single wildcard - always matches
|
|
430
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
431
|
+
params['**'] = matchedPath;
|
|
432
|
+
i = j - 1; // Adjust index for the next iteration
|
|
433
|
+
foundMatch = true;
|
|
434
|
+
break;
|
|
435
|
+
} else if (nextRouteSegment === currentPathSegment) {
|
|
436
|
+
// Exact match
|
|
437
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
438
|
+
params['**'] = matchedPath;
|
|
439
|
+
i = j - 1; // Adjust index for the next iteration
|
|
440
|
+
foundMatch = true;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!foundMatch) {
|
|
446
|
+
isMatch = false;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!pathSegment) {
|
|
454
|
+
isMatch = false;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (routeSegment.startsWith(':')) {
|
|
459
|
+
const paramName = routeSegment.slice(1);
|
|
460
|
+
params[paramName] = pathSegment;
|
|
461
|
+
} else if (routeSegment === '*') {
|
|
462
|
+
// Single segment wildcard
|
|
463
|
+
params['*'] = pathSegment;
|
|
464
|
+
} else if (routeSegment !== pathSegment) {
|
|
465
|
+
isMatch = false;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (isMatch) {
|
|
471
|
+
return { route, params };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// WebSocket route matching utility
|
|
479
|
+
private matchWSRoute(pathname: string): { route: WSRoute; params: Record<string, string> } | null {
|
|
480
|
+
const allWSRoutes = this.getAllWSRoutes();
|
|
481
|
+
|
|
482
|
+
for (const route of allWSRoutes) {
|
|
483
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
|
484
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
|
234
485
|
|
|
235
486
|
const params: Record<string, string> = {};
|
|
236
487
|
let isMatch = true;
|
|
237
488
|
|
|
489
|
+
// Check for double wildcard (**) in the route
|
|
490
|
+
const hasDoubleWildcard = routeSegments.some(segment => segment === '**');
|
|
491
|
+
|
|
492
|
+
// Handle double wildcard at the end (catch-all with slashes)
|
|
493
|
+
const hasDoubleWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '**';
|
|
494
|
+
|
|
495
|
+
// Handle single wildcard at the end (catch-all)
|
|
496
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
|
497
|
+
|
|
498
|
+
if (hasDoubleWildcardAtEnd) {
|
|
499
|
+
// For double wildcard at end, path must have at least as many segments as route (minus the double wildcard)
|
|
500
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
501
|
+
} else if (hasWildcardAtEnd) {
|
|
502
|
+
// For single wildcard at end, path must have at least as many segments as route (minus the wildcard)
|
|
503
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
504
|
+
} else if (!hasDoubleWildcard) {
|
|
505
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
|
506
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
238
509
|
for (let i = 0; i < routeSegments.length; i++) {
|
|
239
510
|
const routeSegment = routeSegments[i];
|
|
240
511
|
const pathSegment = pathSegments[i];
|
|
241
512
|
|
|
242
|
-
if (!routeSegment
|
|
513
|
+
if (!routeSegment) {
|
|
514
|
+
isMatch = false;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Handle double wildcard at the end (matches everything including slashes)
|
|
519
|
+
if (routeSegment === '**' && i === routeSegments.length - 1) {
|
|
520
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
521
|
+
params['**'] = remainingPath;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Handle single wildcard at the end (catch-all)
|
|
526
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
|
527
|
+
// Wildcard at end matches remaining path segments
|
|
528
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
529
|
+
params['*'] = remainingPath;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Handle double wildcard in the middle (matches everything including slashes)
|
|
534
|
+
if (routeSegment === '**') {
|
|
535
|
+
// Find the next non-wildcard segment to match against
|
|
536
|
+
let nextNonWildcardIndex = i + 1;
|
|
537
|
+
while (nextNonWildcardIndex < routeSegments.length &&
|
|
538
|
+
(routeSegments[nextNonWildcardIndex] === '*' || routeSegments[nextNonWildcardIndex] === '**')) {
|
|
539
|
+
nextNonWildcardIndex++;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (nextNonWildcardIndex >= routeSegments.length) {
|
|
543
|
+
// Double wildcard is at the end or followed by other wildcards
|
|
544
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
545
|
+
params['**'] = remainingPath;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Find the next matching segment in the path
|
|
550
|
+
const nextRouteSegment = routeSegments[nextNonWildcardIndex];
|
|
551
|
+
if (!nextRouteSegment) {
|
|
552
|
+
isMatch = false;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let foundMatch = false;
|
|
557
|
+
let matchedPath = '';
|
|
558
|
+
|
|
559
|
+
for (let j = i; j < pathSegments.length; j++) {
|
|
560
|
+
const currentPathSegment = pathSegments[j];
|
|
561
|
+
|
|
562
|
+
// Check if this path segment matches the next route segment
|
|
563
|
+
if (nextRouteSegment.startsWith(':')) {
|
|
564
|
+
// Param segment - always matches
|
|
565
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
566
|
+
params['**'] = matchedPath;
|
|
567
|
+
i = j - 1; // Adjust index for the next iteration
|
|
568
|
+
foundMatch = true;
|
|
569
|
+
break;
|
|
570
|
+
} else if (nextRouteSegment === '*') {
|
|
571
|
+
// Single wildcard - always matches
|
|
572
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
573
|
+
params['**'] = matchedPath;
|
|
574
|
+
i = j - 1; // Adjust index for the next iteration
|
|
575
|
+
foundMatch = true;
|
|
576
|
+
break;
|
|
577
|
+
} else if (nextRouteSegment === currentPathSegment) {
|
|
578
|
+
// Exact match
|
|
579
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
580
|
+
params['**'] = matchedPath;
|
|
581
|
+
i = j - 1; // Adjust index for the next iteration
|
|
582
|
+
foundMatch = true;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!foundMatch) {
|
|
588
|
+
isMatch = false;
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!pathSegment) {
|
|
243
596
|
isMatch = false;
|
|
244
597
|
break;
|
|
245
598
|
}
|
|
246
599
|
|
|
247
600
|
if (routeSegment.startsWith(':')) {
|
|
248
601
|
const paramName = routeSegment.slice(1);
|
|
249
|
-
params[paramName] =
|
|
602
|
+
params[paramName] = pathSegment;
|
|
603
|
+
} else if (routeSegment === '*') {
|
|
604
|
+
// Single segment wildcard
|
|
605
|
+
params['*'] = pathSegment;
|
|
250
606
|
} else if (routeSegment !== pathSegment) {
|
|
251
607
|
isMatch = false;
|
|
252
608
|
break;
|
|
@@ -264,32 +620,106 @@ export default class BXO {
|
|
|
264
620
|
// Parse query string
|
|
265
621
|
private parseQuery(searchParams: URLSearchParams): Record<string, string | undefined> {
|
|
266
622
|
const query: Record<string, string | undefined> = {};
|
|
267
|
-
|
|
623
|
+
searchParams.forEach((value, key) => {
|
|
268
624
|
query[key] = value;
|
|
269
|
-
}
|
|
625
|
+
});
|
|
270
626
|
return query;
|
|
271
627
|
}
|
|
272
628
|
|
|
273
629
|
// Parse headers
|
|
274
630
|
private parseHeaders(headers: Headers): Record<string, string> {
|
|
275
631
|
const headerObj: Record<string, string> = {};
|
|
276
|
-
|
|
632
|
+
headers.forEach((value, key) => {
|
|
277
633
|
headerObj[key] = value;
|
|
278
|
-
}
|
|
634
|
+
});
|
|
279
635
|
return headerObj;
|
|
280
636
|
}
|
|
281
637
|
|
|
638
|
+
// Parse cookies from Cookie header
|
|
639
|
+
private parseCookies(cookieHeader: string | null): Record<string, string> {
|
|
640
|
+
const cookies: Record<string, string> = {};
|
|
641
|
+
|
|
642
|
+
if (!cookieHeader) return cookies;
|
|
643
|
+
|
|
644
|
+
const cookiePairs = cookieHeader.split(';');
|
|
645
|
+
for (const pair of cookiePairs) {
|
|
646
|
+
const [name, value] = pair.trim().split('=');
|
|
647
|
+
if (name && value) {
|
|
648
|
+
cookies[decodeURIComponent(name)] = decodeURIComponent(value);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return cookies;
|
|
653
|
+
}
|
|
654
|
+
|
|
282
655
|
// Validate data against Zod schema
|
|
283
656
|
private validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
|
284
657
|
if (!schema) return data;
|
|
285
658
|
return schema.parse(data);
|
|
286
659
|
}
|
|
287
660
|
|
|
661
|
+
// Validate response against response config (supports both simple and status-based schemas)
|
|
662
|
+
private validateResponse(responseConfig: ResponseConfig | undefined, data: any, status: number = 200): any {
|
|
663
|
+
if (!responseConfig || !this.enableValidation) return data;
|
|
664
|
+
|
|
665
|
+
// If it's a simple schema (not status-based)
|
|
666
|
+
if ('parse' in responseConfig && typeof responseConfig.parse === 'function') {
|
|
667
|
+
return responseConfig.parse(data);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// If it's a status-based schema
|
|
671
|
+
if (typeof responseConfig === 'object' && !('parse' in responseConfig)) {
|
|
672
|
+
const statusSchema = responseConfig[status];
|
|
673
|
+
if (statusSchema) {
|
|
674
|
+
return statusSchema.parse(data);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// If no specific status schema found, try to find a fallback
|
|
678
|
+
// Common fallback statuses: 200, 201, 400, 500
|
|
679
|
+
const fallbackStatuses = [200, 201, 400, 500];
|
|
680
|
+
for (const fallbackStatus of fallbackStatuses) {
|
|
681
|
+
if (responseConfig[fallbackStatus]) {
|
|
682
|
+
return responseConfig[fallbackStatus]?.parse(data);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// If no schema found for the status, return data as-is
|
|
687
|
+
return data;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return data;
|
|
691
|
+
}
|
|
692
|
+
|
|
288
693
|
// Main request handler
|
|
289
|
-
private async handleRequest(request: Request): Promise<Response> {
|
|
694
|
+
private async handleRequest(request: Request, server?: any): Promise<Response | undefined> {
|
|
290
695
|
const url = new URL(request.url);
|
|
291
696
|
const method = request.method;
|
|
292
|
-
const
|
|
697
|
+
const rawPathname = url.pathname;
|
|
698
|
+
let pathname: string;
|
|
699
|
+
try {
|
|
700
|
+
pathname = decodeURI(rawPathname);
|
|
701
|
+
} catch {
|
|
702
|
+
pathname = rawPathname;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Check for WebSocket upgrade
|
|
706
|
+
if (request.headers.get('upgrade') === 'websocket') {
|
|
707
|
+
const wsMatchResult = this.matchWSRoute(pathname);
|
|
708
|
+
if (wsMatchResult && server) {
|
|
709
|
+
const success = server.upgrade(request, {
|
|
710
|
+
data: {
|
|
711
|
+
handler: wsMatchResult.route.handler,
|
|
712
|
+
params: wsMatchResult.params,
|
|
713
|
+
pathname
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (success) {
|
|
718
|
+
return; // undefined response means upgrade was successful
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
722
|
+
}
|
|
293
723
|
|
|
294
724
|
const matchResult = this.matchRoute(method, pathname);
|
|
295
725
|
if (!matchResult) {
|
|
@@ -299,6 +729,7 @@ export default class BXO {
|
|
|
299
729
|
const { route, params } = matchResult;
|
|
300
730
|
const query = this.parseQuery(url.searchParams);
|
|
301
731
|
const headers = this.parseHeaders(request.headers);
|
|
732
|
+
const cookies = this.parseCookies(request.headers.get('cookie'));
|
|
302
733
|
|
|
303
734
|
let body: any;
|
|
304
735
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
@@ -313,56 +744,231 @@ export default class BXO {
|
|
|
313
744
|
const formData = await request.formData();
|
|
314
745
|
body = Object.fromEntries(formData.entries());
|
|
315
746
|
} else {
|
|
316
|
-
|
|
747
|
+
// Try to parse as JSON if it looks like JSON, otherwise treat as text
|
|
748
|
+
const textBody = await request.text();
|
|
749
|
+
try {
|
|
750
|
+
// Check if the text looks like JSON
|
|
751
|
+
if (textBody.trim().startsWith('{') || textBody.trim().startsWith('[')) {
|
|
752
|
+
body = JSON.parse(textBody);
|
|
753
|
+
} else {
|
|
754
|
+
body = textBody;
|
|
755
|
+
}
|
|
756
|
+
} catch {
|
|
757
|
+
body = textBody;
|
|
758
|
+
}
|
|
317
759
|
}
|
|
318
760
|
}
|
|
319
761
|
|
|
320
|
-
// Create context
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
762
|
+
// Create context with validation
|
|
763
|
+
let ctx: Context;
|
|
764
|
+
try {
|
|
765
|
+
// Validate each part separately to get better error messages
|
|
766
|
+
const validatedParams = this.enableValidation && route.config?.params ? this.validateData(route.config.params, params) : params;
|
|
767
|
+
const validatedQuery = this.enableValidation && route.config?.query ? this.validateData(route.config.query, query) : query;
|
|
768
|
+
const validatedBody = this.enableValidation && route.config?.body ? this.validateData(route.config.body, body) : body;
|
|
769
|
+
const validatedHeaders = this.enableValidation && route.config?.headers ? this.validateData(route.config.headers, headers) : headers;
|
|
770
|
+
const validatedCookies = this.enableValidation && route.config?.cookies ? this.validateData(route.config.cookies, cookies) : cookies;
|
|
771
|
+
|
|
772
|
+
ctx = {
|
|
773
|
+
params: validatedParams,
|
|
774
|
+
query: validatedQuery,
|
|
775
|
+
body: validatedBody,
|
|
776
|
+
headers: validatedHeaders,
|
|
777
|
+
cookies: validatedCookies,
|
|
778
|
+
path: pathname,
|
|
779
|
+
request,
|
|
780
|
+
set: {},
|
|
781
|
+
status: ((code: number, data?: any) => {
|
|
782
|
+
ctx.set.status = code;
|
|
783
|
+
return data;
|
|
784
|
+
}) as any,
|
|
785
|
+
redirect: ((location: string, status: number = 302) => {
|
|
786
|
+
// Record redirect intent only; avoid mutating generic status/headers so it can be canceled later
|
|
787
|
+
ctx.set.redirect = { location, status };
|
|
788
|
+
|
|
789
|
+
// Prepare headers for immediate Response return without persisting to ctx.set.headers
|
|
790
|
+
const responseHeaders: Record<string, string> = {
|
|
791
|
+
Location: location,
|
|
792
|
+
...(ctx.set.headers || {})
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// Handle cookies if any are set on context
|
|
796
|
+
if (ctx.set.cookies && ctx.set.cookies.length > 0) {
|
|
797
|
+
const cookieHeaders = ctx.set.cookies.map(cookie => {
|
|
798
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
799
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
800
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
801
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
802
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
803
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
804
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
805
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
806
|
+
return cookieString;
|
|
807
|
+
});
|
|
808
|
+
cookieHeaders.forEach((cookieHeader, index) => {
|
|
809
|
+
responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return new Response(null, {
|
|
814
|
+
status,
|
|
815
|
+
headers: responseHeaders
|
|
816
|
+
});
|
|
817
|
+
}) as any,
|
|
818
|
+
clearRedirect: (() => {
|
|
819
|
+
// Clear explicit redirect intent
|
|
820
|
+
delete ctx.set.redirect;
|
|
821
|
+
// Remove any Location header if present
|
|
822
|
+
if (ctx.set.headers) {
|
|
823
|
+
for (const key of Object.keys(ctx.set.headers)) {
|
|
824
|
+
if (key.toLowerCase() === 'location') {
|
|
825
|
+
delete ctx.set.headers[key];
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Reset status if it is a redirect
|
|
830
|
+
if (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400) {
|
|
831
|
+
delete ctx.set.status;
|
|
832
|
+
}
|
|
833
|
+
}) as any
|
|
834
|
+
};
|
|
835
|
+
} catch (validationError) {
|
|
836
|
+
// Validation failed - return error response
|
|
837
|
+
|
|
838
|
+
// Extract detailed validation errors from Zod
|
|
839
|
+
let validationDetails = undefined;
|
|
840
|
+
if (validationError instanceof Error) {
|
|
841
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
842
|
+
validationDetails = validationError.errors;
|
|
843
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
844
|
+
validationDetails = validationError.issues;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Create a clean error message
|
|
849
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
850
|
+
? `Validation failed for ${validationDetails.length} field(s)`
|
|
851
|
+
: 'Validation failed';
|
|
852
|
+
|
|
853
|
+
return new Response(JSON.stringify({
|
|
854
|
+
error: errorMessage,
|
|
855
|
+
details: validationDetails
|
|
856
|
+
}), {
|
|
857
|
+
status: 400,
|
|
858
|
+
headers: { 'Content-Type': 'application/json' }
|
|
859
|
+
});
|
|
860
|
+
}
|
|
329
861
|
|
|
330
862
|
try {
|
|
863
|
+
// Run middleware onRequest hooks
|
|
864
|
+
for (const plugin of this.middleware) {
|
|
865
|
+
if (plugin.onRequest) {
|
|
866
|
+
await plugin.onRequest(ctx);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
331
870
|
// Run global onRequest hook
|
|
332
871
|
if (this.hooks.onRequest) {
|
|
333
|
-
await this.hooks.onRequest(ctx);
|
|
872
|
+
await this.hooks.onRequest(ctx, this);
|
|
334
873
|
}
|
|
335
874
|
|
|
336
875
|
// Run BXO instance onRequest hooks
|
|
337
876
|
for (const bxoInstance of this.plugins) {
|
|
338
877
|
if (bxoInstance.hooks.onRequest) {
|
|
339
|
-
await bxoInstance.hooks.onRequest(ctx);
|
|
878
|
+
await bxoInstance.hooks.onRequest(ctx, this);
|
|
340
879
|
}
|
|
341
880
|
}
|
|
342
881
|
|
|
343
882
|
// Execute route handler
|
|
344
883
|
let response = await route.handler(ctx);
|
|
345
884
|
|
|
885
|
+
// Run middleware onResponse hooks
|
|
886
|
+
for (const plugin of this.middleware) {
|
|
887
|
+
if (plugin.onResponse) {
|
|
888
|
+
response = await plugin.onResponse(ctx, response) || response;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
346
892
|
// Run global onResponse hook
|
|
347
893
|
if (this.hooks.onResponse) {
|
|
348
|
-
response = await this.hooks.onResponse(ctx, response) || response;
|
|
894
|
+
response = await this.hooks.onResponse(ctx, response, this) || response;
|
|
349
895
|
}
|
|
350
896
|
|
|
351
897
|
// Run BXO instance onResponse hooks
|
|
352
898
|
for (const bxoInstance of this.plugins) {
|
|
353
899
|
if (bxoInstance.hooks.onResponse) {
|
|
354
|
-
response = await bxoInstance.hooks.onResponse(ctx, response) || response;
|
|
900
|
+
response = await bxoInstance.hooks.onResponse(ctx, response, this) || response;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// If the handler did not return a response, but a redirect was configured via ctx.set,
|
|
905
|
+
// automatically create a redirect Response so users can call ctx.redirect(...) or set headers without returning.
|
|
906
|
+
const hasImplicitRedirectIntent = !!ctx.set.redirect
|
|
907
|
+
|| (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400);
|
|
908
|
+
if ((response === undefined || response === null) && hasImplicitRedirectIntent) {
|
|
909
|
+
const locationFromHeaders = ctx.set.headers && Object.entries(ctx.set.headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
|
|
910
|
+
const location = ctx.set.redirect?.location || locationFromHeaders;
|
|
911
|
+
if (location) {
|
|
912
|
+
// Build headers, ensuring Location is present
|
|
913
|
+
let responseHeaders: Record<string, string> = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
914
|
+
if (!Object.keys(responseHeaders).some(k => k.toLowerCase() === 'location')) {
|
|
915
|
+
responseHeaders['Location'] = location;
|
|
916
|
+
}
|
|
917
|
+
// Determine status precedence: redirect.status > set.status > 302
|
|
918
|
+
const status = ctx.set.redirect?.status ?? ctx.set.status ?? 302;
|
|
919
|
+
|
|
920
|
+
// Handle cookies if any are set
|
|
921
|
+
if (ctx.set.cookies && ctx.set.cookies.length > 0) {
|
|
922
|
+
const cookieHeaders = ctx.set.cookies.map(cookie => {
|
|
923
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
924
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
925
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
926
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
927
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
928
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
929
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
930
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
931
|
+
return cookieString;
|
|
932
|
+
});
|
|
933
|
+
cookieHeaders.forEach((cookieHeader, index) => {
|
|
934
|
+
responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return new Response(null, {
|
|
939
|
+
status,
|
|
940
|
+
headers: responseHeaders
|
|
941
|
+
});
|
|
355
942
|
}
|
|
356
943
|
}
|
|
357
944
|
|
|
358
945
|
// Validate response against schema if provided
|
|
359
|
-
if (route.config?.response && !(response instanceof Response)) {
|
|
946
|
+
if (this.enableValidation && route.config?.response && !(response instanceof Response)) {
|
|
360
947
|
try {
|
|
361
|
-
|
|
948
|
+
const status = ctx.set.status || 200;
|
|
949
|
+
response = this.validateResponse(route.config.response, response, status);
|
|
362
950
|
} catch (validationError) {
|
|
363
951
|
// Response validation failed
|
|
364
|
-
|
|
365
|
-
|
|
952
|
+
|
|
953
|
+
// Extract detailed validation errors from Zod
|
|
954
|
+
let validationDetails = undefined;
|
|
955
|
+
if (validationError instanceof Error) {
|
|
956
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
957
|
+
validationDetails = validationError.errors;
|
|
958
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
959
|
+
validationDetails = validationError.issues;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Create a clean error message
|
|
964
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
965
|
+
? `Response validation failed for ${validationDetails.length} field(s)`
|
|
966
|
+
: 'Response validation failed';
|
|
967
|
+
|
|
968
|
+
return new Response(JSON.stringify({
|
|
969
|
+
error: errorMessage,
|
|
970
|
+
details: validationDetails
|
|
971
|
+
}), {
|
|
366
972
|
status: 500,
|
|
367
973
|
headers: { 'Content-Type': 'application/json' }
|
|
368
974
|
});
|
|
@@ -374,9 +980,63 @@ export default class BXO {
|
|
|
374
980
|
return response;
|
|
375
981
|
}
|
|
376
982
|
|
|
983
|
+
// Handle File response (like Elysia)
|
|
984
|
+
if (response instanceof File || (typeof Bun !== 'undefined' && response instanceof Bun.file('').constructor)) {
|
|
985
|
+
const file = response as File;
|
|
986
|
+
const responseInit: ResponseInit = {
|
|
987
|
+
status: ctx.set.status || 200,
|
|
988
|
+
headers: {
|
|
989
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
990
|
+
'Content-Length': file.size.toString(),
|
|
991
|
+
...ctx.set.headers
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
return new Response(file, responseInit);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Handle Bun.file() response
|
|
998
|
+
if (typeof response === 'object' && response && 'stream' in response && 'size' in response) {
|
|
999
|
+
const bunFile = response as any;
|
|
1000
|
+
const responseInit: ResponseInit = {
|
|
1001
|
+
status: ctx.set.status || 200,
|
|
1002
|
+
headers: {
|
|
1003
|
+
'Content-Type': bunFile.type || 'application/octet-stream',
|
|
1004
|
+
'Content-Length': bunFile.size?.toString() || '',
|
|
1005
|
+
...ctx.set.headers,
|
|
1006
|
+
...(bunFile.headers || {}) // Support custom headers from file helper
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
return new Response(bunFile, responseInit);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Prepare headers with cookies
|
|
1013
|
+
let responseHeaders = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
1014
|
+
|
|
1015
|
+
// Handle cookies if any are set
|
|
1016
|
+
if (ctx.set.cookies && ctx.set.cookies.length > 0) {
|
|
1017
|
+
const cookieHeaders = ctx.set.cookies.map(cookie => {
|
|
1018
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
1019
|
+
|
|
1020
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
1021
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
1022
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
1023
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
1024
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
1025
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
1026
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
1027
|
+
|
|
1028
|
+
return cookieString;
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// Add Set-Cookie headers
|
|
1032
|
+
cookieHeaders.forEach((cookieHeader, index) => {
|
|
1033
|
+
responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
377
1037
|
const responseInit: ResponseInit = {
|
|
378
1038
|
status: ctx.set.status || 200,
|
|
379
|
-
headers:
|
|
1039
|
+
headers: responseHeaders
|
|
380
1040
|
};
|
|
381
1041
|
|
|
382
1042
|
if (typeof response === 'string') {
|
|
@@ -395,13 +1055,20 @@ export default class BXO {
|
|
|
395
1055
|
// Run error hooks
|
|
396
1056
|
let errorResponse: any;
|
|
397
1057
|
|
|
1058
|
+
// Run middleware onError hooks
|
|
1059
|
+
for (const plugin of this.middleware) {
|
|
1060
|
+
if (plugin.onError) {
|
|
1061
|
+
errorResponse = await plugin.onError(ctx, error as Error) || errorResponse;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
398
1065
|
if (this.hooks.onError) {
|
|
399
|
-
errorResponse = await this.hooks.onError(ctx, error as Error);
|
|
1066
|
+
errorResponse = await this.hooks.onError(ctx, error as Error, this);
|
|
400
1067
|
}
|
|
401
1068
|
|
|
402
1069
|
for (const bxoInstance of this.plugins) {
|
|
403
1070
|
if (bxoInstance.hooks.onError) {
|
|
404
|
-
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error) || errorResponse;
|
|
1071
|
+
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error, this) || errorResponse;
|
|
405
1072
|
}
|
|
406
1073
|
}
|
|
407
1074
|
|
|
@@ -424,77 +1091,7 @@ export default class BXO {
|
|
|
424
1091
|
}
|
|
425
1092
|
}
|
|
426
1093
|
|
|
427
|
-
// Hot reload functionality
|
|
428
|
-
enableHotReload(watchPaths: string[] = ['./'], excludePatterns: string[] = []): this {
|
|
429
|
-
this.hotReloadEnabled = true;
|
|
430
|
-
watchPaths.forEach(path => this.watchedFiles.add(path));
|
|
431
|
-
excludePatterns.forEach(pattern => this.watchedExclude.add(pattern));
|
|
432
|
-
return this;
|
|
433
|
-
}
|
|
434
1094
|
|
|
435
|
-
private shouldExcludeFile(filename: string): boolean {
|
|
436
|
-
for (const pattern of this.watchedExclude) {
|
|
437
|
-
// Handle exact match
|
|
438
|
-
if (pattern === filename) {
|
|
439
|
-
return true;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Handle directory patterns (e.g., "node_modules/", "dist/")
|
|
443
|
-
if (pattern.endsWith('/')) {
|
|
444
|
-
if (filename.startsWith(pattern) || filename.includes(`/${pattern}`)) {
|
|
445
|
-
return true;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Handle wildcard patterns (e.g., "*.log", "temp*")
|
|
450
|
-
if (pattern.includes('*')) {
|
|
451
|
-
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
|
452
|
-
if (regex.test(filename)) {
|
|
453
|
-
return true;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Handle file extension patterns (e.g., ".log", ".tmp")
|
|
458
|
-
if (pattern.startsWith('.') && filename.endsWith(pattern)) {
|
|
459
|
-
return true;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Handle substring matches for directories
|
|
463
|
-
if (filename.includes(pattern)) {
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return false;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
private async setupFileWatcher(port: number, hostname: string): Promise<void> {
|
|
472
|
-
if (!this.hotReloadEnabled) return;
|
|
473
|
-
|
|
474
|
-
const fs = require('fs');
|
|
475
|
-
|
|
476
|
-
for (const watchPath of this.watchedFiles) {
|
|
477
|
-
try {
|
|
478
|
-
fs.watch(watchPath, { recursive: true }, async (eventType: string, filename: string) => {
|
|
479
|
-
if (filename && (filename.endsWith('.ts') || filename.endsWith('.js'))) {
|
|
480
|
-
// Check if file should be excluded
|
|
481
|
-
if (this.shouldExcludeFile(filename)) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
console.log(`🔄 File changed: ${filename}, restarting server...`);
|
|
486
|
-
await this.restart(port, hostname);
|
|
487
|
-
}
|
|
488
|
-
});
|
|
489
|
-
console.log(`👀 Watching ${watchPath} for changes...`);
|
|
490
|
-
if (this.watchedExclude.size > 0) {
|
|
491
|
-
console.log(`🚫 Excluding patterns: ${Array.from(this.watchedExclude).join(', ')}`);
|
|
492
|
-
}
|
|
493
|
-
} catch (error) {
|
|
494
|
-
console.warn(`⚠️ Could not watch ${watchPath}:`, error);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
1095
|
|
|
499
1096
|
// Server management methods
|
|
500
1097
|
async start(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
@@ -506,29 +1103,49 @@ export default class BXO {
|
|
|
506
1103
|
try {
|
|
507
1104
|
// Before start hook
|
|
508
1105
|
if (this.hooks.onBeforeStart) {
|
|
509
|
-
await this.hooks.onBeforeStart();
|
|
1106
|
+
await this.hooks.onBeforeStart(this);
|
|
510
1107
|
}
|
|
511
1108
|
|
|
512
1109
|
this.server = Bun.serve({
|
|
513
1110
|
port,
|
|
514
1111
|
hostname,
|
|
515
|
-
fetch: (request) => this.handleRequest(request),
|
|
1112
|
+
fetch: (request, server) => this.handleRequest(request, server),
|
|
1113
|
+
websocket: {
|
|
1114
|
+
message: (ws: any, message: any) => {
|
|
1115
|
+
const handler = ws.data?.handler;
|
|
1116
|
+
if (handler?.onMessage) {
|
|
1117
|
+
handler.onMessage(ws, message);
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
open: (ws: any) => {
|
|
1121
|
+
const handler = ws.data?.handler;
|
|
1122
|
+
if (handler?.onOpen) {
|
|
1123
|
+
handler.onOpen(ws);
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
close: (ws: any, code?: number, reason?: string) => {
|
|
1127
|
+
const handler = ws.data?.handler;
|
|
1128
|
+
if (handler?.onClose) {
|
|
1129
|
+
handler.onClose(ws, code, reason);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
516
1133
|
});
|
|
517
1134
|
|
|
1135
|
+
// Verify server was created successfully
|
|
1136
|
+
if (!this.server) {
|
|
1137
|
+
throw new Error('Failed to create server instance');
|
|
1138
|
+
}
|
|
1139
|
+
|
|
518
1140
|
this.isRunning = true;
|
|
519
1141
|
this.serverPort = port;
|
|
520
1142
|
this.serverHostname = hostname;
|
|
521
1143
|
|
|
522
|
-
console.log(`🦊 BXO server running at http://${hostname}:${port}`);
|
|
523
|
-
|
|
524
1144
|
// After start hook
|
|
525
1145
|
if (this.hooks.onAfterStart) {
|
|
526
|
-
await this.hooks.onAfterStart();
|
|
1146
|
+
await this.hooks.onAfterStart(this);
|
|
527
1147
|
}
|
|
528
1148
|
|
|
529
|
-
// Setup hot reload
|
|
530
|
-
await this.setupFileWatcher(port, hostname);
|
|
531
|
-
|
|
532
1149
|
// Handle graceful shutdown
|
|
533
1150
|
const shutdownHandler = async () => {
|
|
534
1151
|
await this.stop();
|
|
@@ -553,57 +1170,49 @@ export default class BXO {
|
|
|
553
1170
|
try {
|
|
554
1171
|
// Before stop hook
|
|
555
1172
|
if (this.hooks.onBeforeStop) {
|
|
556
|
-
await this.hooks.onBeforeStop();
|
|
1173
|
+
await this.hooks.onBeforeStop(this);
|
|
557
1174
|
}
|
|
558
1175
|
|
|
559
1176
|
if (this.server) {
|
|
560
|
-
|
|
561
|
-
|
|
1177
|
+
try {
|
|
1178
|
+
// Try to stop the server gracefully
|
|
1179
|
+
if (typeof this.server.stop === 'function') {
|
|
1180
|
+
this.server.stop();
|
|
1181
|
+
} else {
|
|
1182
|
+
console.warn('⚠️ Server stop method not available');
|
|
1183
|
+
}
|
|
1184
|
+
} catch (stopError) {
|
|
1185
|
+
console.error('❌ Error calling server.stop():', stopError);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Clear the server reference
|
|
1189
|
+
this.server = undefined;
|
|
562
1190
|
}
|
|
563
1191
|
|
|
1192
|
+
// Reset state regardless of server.stop() success
|
|
564
1193
|
this.isRunning = false;
|
|
565
1194
|
this.serverPort = undefined;
|
|
566
1195
|
this.serverHostname = undefined;
|
|
567
1196
|
|
|
568
|
-
console.log('🛑 BXO server stopped');
|
|
569
|
-
|
|
570
1197
|
// After stop hook
|
|
571
1198
|
if (this.hooks.onAfterStop) {
|
|
572
|
-
await this.hooks.onAfterStop();
|
|
1199
|
+
await this.hooks.onAfterStop(this);
|
|
573
1200
|
}
|
|
574
1201
|
|
|
1202
|
+
console.log('✅ Server stopped successfully');
|
|
1203
|
+
|
|
575
1204
|
} catch (error) {
|
|
576
1205
|
console.error('❌ Error stopping server:', error);
|
|
1206
|
+
// Even if there's an error, reset the state
|
|
1207
|
+
this.isRunning = false;
|
|
1208
|
+
this.server = undefined;
|
|
1209
|
+
this.serverPort = undefined;
|
|
1210
|
+
this.serverHostname = undefined;
|
|
577
1211
|
throw error;
|
|
578
1212
|
}
|
|
579
1213
|
}
|
|
580
1214
|
|
|
581
|
-
async restart(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
582
|
-
try {
|
|
583
|
-
// Before restart hook
|
|
584
|
-
if (this.hooks.onBeforeRestart) {
|
|
585
|
-
await this.hooks.onBeforeRestart();
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
console.log('🔄 Restarting BXO server...');
|
|
589
|
-
|
|
590
|
-
await this.stop();
|
|
591
1215
|
|
|
592
|
-
// Small delay to ensure cleanup
|
|
593
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
594
|
-
|
|
595
|
-
await this.start(port, hostname);
|
|
596
|
-
|
|
597
|
-
// After restart hook
|
|
598
|
-
if (this.hooks.onAfterRestart) {
|
|
599
|
-
await this.hooks.onAfterRestart();
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
} catch (error) {
|
|
603
|
-
console.error('❌ Error restarting server:', error);
|
|
604
|
-
throw error;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
1216
|
|
|
608
1217
|
// Backward compatibility
|
|
609
1218
|
async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
@@ -612,41 +1221,38 @@ export default class BXO {
|
|
|
612
1221
|
|
|
613
1222
|
// Server status
|
|
614
1223
|
isServerRunning(): boolean {
|
|
615
|
-
return this.isRunning;
|
|
1224
|
+
return this.isRunning && this.server !== undefined;
|
|
616
1225
|
}
|
|
617
1226
|
|
|
618
|
-
getServerInfo(): { running: boolean
|
|
1227
|
+
getServerInfo(): { running: boolean } {
|
|
619
1228
|
return {
|
|
620
|
-
running: this.isRunning
|
|
621
|
-
hotReload: this.hotReloadEnabled,
|
|
622
|
-
watchedFiles: Array.from(this.watchedFiles),
|
|
623
|
-
excludePatterns: Array.from(this.watchedExclude)
|
|
1229
|
+
running: this.isRunning
|
|
624
1230
|
};
|
|
625
1231
|
}
|
|
626
1232
|
|
|
627
1233
|
// Get server information (alias for getServerInfo)
|
|
628
1234
|
get info() {
|
|
1235
|
+
// Calculate total routes including plugins
|
|
1236
|
+
const totalRoutes = this._routes.length + this.plugins.reduce((total, plugin) => total + plugin._routes.length, 0);
|
|
1237
|
+
const totalWsRoutes = this._wsRoutes.length + this.plugins.reduce((total, plugin) => total + plugin._wsRoutes.length, 0);
|
|
1238
|
+
|
|
629
1239
|
return {
|
|
630
1240
|
// Server status
|
|
631
1241
|
running: this.isRunning,
|
|
632
1242
|
server: this.server ? 'Bun' : null,
|
|
633
|
-
|
|
1243
|
+
|
|
634
1244
|
// Connection details
|
|
635
1245
|
hostname: this.serverHostname,
|
|
636
1246
|
port: this.serverPort,
|
|
637
|
-
url: this.isRunning && this.serverHostname && this.serverPort
|
|
638
|
-
? `http://${this.serverHostname}:${this.serverPort}`
|
|
1247
|
+
url: this.isRunning && this.serverHostname && this.serverPort
|
|
1248
|
+
? `http://${this.serverHostname}:${this.serverPort}`
|
|
639
1249
|
: null,
|
|
640
|
-
|
|
1250
|
+
|
|
641
1251
|
// Application statistics
|
|
642
|
-
totalRoutes
|
|
1252
|
+
totalRoutes,
|
|
1253
|
+
totalWsRoutes,
|
|
643
1254
|
totalPlugins: this.plugins.length,
|
|
644
|
-
|
|
645
|
-
// Hot reload configuration
|
|
646
|
-
hotReload: this.hotReloadEnabled,
|
|
647
|
-
watchedFiles: Array.from(this.watchedFiles),
|
|
648
|
-
excludePatterns: Array.from(this.watchedExclude),
|
|
649
|
-
|
|
1255
|
+
|
|
650
1256
|
// System information
|
|
651
1257
|
runtime: 'Bun',
|
|
652
1258
|
version: typeof Bun !== 'undefined' ? Bun.version : 'unknown',
|
|
@@ -657,21 +1263,104 @@ export default class BXO {
|
|
|
657
1263
|
|
|
658
1264
|
// Get all routes information
|
|
659
1265
|
get routes() {
|
|
660
|
-
|
|
1266
|
+
// Get routes from main instance
|
|
1267
|
+
const mainRoutes = this._routes.map((route: Route) => ({
|
|
661
1268
|
method: route.method,
|
|
662
1269
|
path: route.path,
|
|
663
1270
|
hasConfig: !!route.config,
|
|
664
|
-
config: route.config || null
|
|
1271
|
+
config: route.config || null,
|
|
1272
|
+
source: 'main' as const
|
|
1273
|
+
}));
|
|
1274
|
+
|
|
1275
|
+
// Get routes from all plugins
|
|
1276
|
+
const pluginRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
|
1277
|
+
plugin._routes.map((route: Route) => ({
|
|
1278
|
+
method: route.method,
|
|
1279
|
+
path: route.path,
|
|
1280
|
+
hasConfig: !!route.config,
|
|
1281
|
+
config: route.config || null,
|
|
1282
|
+
source: 'plugin' as const,
|
|
1283
|
+
pluginIndex
|
|
1284
|
+
}))
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
return [...mainRoutes, ...pluginRoutes];
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Get all WebSocket routes information
|
|
1291
|
+
get wsRoutes() {
|
|
1292
|
+
// Get WebSocket routes from main instance
|
|
1293
|
+
const mainWsRoutes = this._wsRoutes.map((route: WSRoute) => ({
|
|
1294
|
+
path: route.path,
|
|
1295
|
+
hasHandlers: {
|
|
1296
|
+
onOpen: !!route.handler.onOpen,
|
|
1297
|
+
onMessage: !!route.handler.onMessage,
|
|
1298
|
+
onClose: !!route.handler.onClose,
|
|
1299
|
+
onError: !!route.handler.onError
|
|
1300
|
+
},
|
|
1301
|
+
source: 'main' as const
|
|
665
1302
|
}));
|
|
1303
|
+
|
|
1304
|
+
// Get WebSocket routes from all plugins
|
|
1305
|
+
const pluginWsRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
|
1306
|
+
plugin._wsRoutes.map((route: WSRoute) => ({
|
|
1307
|
+
path: route.path,
|
|
1308
|
+
hasHandlers: {
|
|
1309
|
+
onOpen: !!route.handler.onOpen,
|
|
1310
|
+
onMessage: !!route.handler.onMessage,
|
|
1311
|
+
onClose: !!route.handler.onClose,
|
|
1312
|
+
onError: !!route.handler.onError
|
|
1313
|
+
},
|
|
1314
|
+
source: 'plugin' as const,
|
|
1315
|
+
pluginIndex
|
|
1316
|
+
}))
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
return [...mainWsRoutes, ...pluginWsRoutes];
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const error = (error: Error | string, status: number = 500) => {
|
|
1324
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : error }), { status });
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// File helper function (like Elysia)
|
|
1328
|
+
const file = (path: string, options?: { type?: string; headers?: Record<string, string> }) => {
|
|
1329
|
+
const bunFile = Bun.file(path);
|
|
1330
|
+
|
|
1331
|
+
if (options?.type) {
|
|
1332
|
+
// Create a wrapper to override the MIME type
|
|
1333
|
+
return {
|
|
1334
|
+
...bunFile,
|
|
1335
|
+
type: options.type,
|
|
1336
|
+
headers: options.headers
|
|
1337
|
+
};
|
|
666
1338
|
}
|
|
1339
|
+
|
|
1340
|
+
return bunFile;
|
|
667
1341
|
}
|
|
668
1342
|
|
|
669
|
-
|
|
670
|
-
|
|
1343
|
+
// Redirect helper function (like Elysia)
|
|
1344
|
+
const redirect = (location: string, status: number = 302) => {
|
|
1345
|
+
return new Response(null, {
|
|
1346
|
+
status,
|
|
1347
|
+
headers: { Location: location }
|
|
1348
|
+
});
|
|
671
1349
|
}
|
|
672
1350
|
|
|
673
1351
|
// Export Zod for convenience
|
|
674
|
-
export { z, error };
|
|
1352
|
+
export { z, error, file, redirect };
|
|
675
1353
|
|
|
676
1354
|
// Export types for external use
|
|
677
|
-
export type { RouteConfig, RouteDetail, Handler };
|
|
1355
|
+
export type { RouteConfig, RouteDetail, Handler, WebSocketHandler, WSRoute, Cookie, BXOOptions };
|
|
1356
|
+
|
|
1357
|
+
// Helper function to create a cookie
|
|
1358
|
+
export const createCookie = (
|
|
1359
|
+
name: string,
|
|
1360
|
+
value: string,
|
|
1361
|
+
options: Omit<Cookie, 'name' | 'value'> = {}
|
|
1362
|
+
): Cookie => ({
|
|
1363
|
+
name,
|
|
1364
|
+
value,
|
|
1365
|
+
...options
|
|
1366
|
+
});
|