bxo 0.0.5-dev.5 → 0.0.5-dev.51
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 +972 -210
- package/package.json +3 -5
- package/plugins/README.md +160 -0
- package/plugins/cors.ts +81 -57
- package/plugins/index.ts +4 -6
- package/plugins/ratelimit.ts +55 -59
- package/example.ts +0 -183
- package/plugins/auth.ts +0 -119
- package/plugins/logger.ts +0 -109
package/index.ts
CHANGED
|
@@ -3,6 +3,29 @@ 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 options interface for setting cookies
|
|
19
|
+
interface CookieOptions {
|
|
20
|
+
domain?: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
expires?: Date;
|
|
23
|
+
maxAge?: number;
|
|
24
|
+
secure?: boolean;
|
|
25
|
+
httpOnly?: boolean;
|
|
26
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
// OpenAPI detail information
|
|
7
30
|
interface RouteDetail {
|
|
8
31
|
summary?: string;
|
|
@@ -21,28 +44,67 @@ interface RouteConfig {
|
|
|
21
44
|
query?: z.ZodSchema<any>;
|
|
22
45
|
body?: z.ZodSchema<any>;
|
|
23
46
|
headers?: z.ZodSchema<any>;
|
|
24
|
-
|
|
47
|
+
cookies?: z.ZodSchema<any>;
|
|
48
|
+
response?: ResponseConfig;
|
|
25
49
|
detail?: RouteDetail;
|
|
26
50
|
}
|
|
27
51
|
|
|
52
|
+
// Helper type to extract status codes from response config
|
|
53
|
+
type StatusCodes<T> = T extends Record<number, any> ? keyof T : never;
|
|
54
|
+
|
|
28
55
|
// Context type that's fully typed based on the route configuration
|
|
29
56
|
export type Context<TConfig extends RouteConfig = {}> = {
|
|
30
57
|
params: TConfig['params'] extends z.ZodSchema<any> ? InferZodType<TConfig['params']> : Record<string, string>;
|
|
31
58
|
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
|
32
59
|
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
|
33
60
|
headers: TConfig['headers'] extends z.ZodSchema<any> ? InferZodType<TConfig['headers']> : Record<string, string>;
|
|
61
|
+
cookies: TConfig['cookies'] extends z.ZodSchema<any> ? InferZodType<TConfig['cookies']> : Record<string, string>;
|
|
62
|
+
path: string;
|
|
34
63
|
request: Request;
|
|
35
64
|
set: {
|
|
36
|
-
status
|
|
37
|
-
headers
|
|
65
|
+
status: number;
|
|
66
|
+
headers: Record<string, string>;
|
|
67
|
+
cookies: (name: string, value: string, options?: CookieOptions) => void;
|
|
68
|
+
redirect?: { location: string; status?: number };
|
|
38
69
|
};
|
|
39
|
-
|
|
40
|
-
|
|
70
|
+
status: <T extends number>(
|
|
71
|
+
code: TConfig['response'] extends StatusResponseSchema
|
|
72
|
+
? StatusCodes<TConfig['response']> | number
|
|
73
|
+
: T,
|
|
74
|
+
data?: TConfig['response'] extends StatusResponseSchema
|
|
75
|
+
? T extends keyof TConfig['response']
|
|
76
|
+
? InferZodType<TConfig['response'][T]>
|
|
77
|
+
: any
|
|
78
|
+
: TConfig['response'] extends ResponseSchema
|
|
79
|
+
? InferZodType<TConfig['response']>
|
|
80
|
+
: any
|
|
81
|
+
) => TConfig['response'] extends StatusResponseSchema
|
|
82
|
+
? T extends keyof TConfig['response']
|
|
83
|
+
? InferZodType<TConfig['response'][T]>
|
|
84
|
+
: any
|
|
85
|
+
: TConfig['response'] extends ResponseSchema
|
|
86
|
+
? InferZodType<TConfig['response']>
|
|
87
|
+
: any;
|
|
88
|
+
redirect: (location: string, status?: number) => Response;
|
|
89
|
+
clearRedirect: () => void;
|
|
41
90
|
[key: string]: any;
|
|
42
91
|
};
|
|
43
92
|
|
|
44
|
-
//
|
|
45
|
-
|
|
93
|
+
// Internal cookie storage interface
|
|
94
|
+
interface InternalCookie {
|
|
95
|
+
name: string;
|
|
96
|
+
value: string;
|
|
97
|
+
domain?: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
expires?: Date;
|
|
100
|
+
maxAge?: number;
|
|
101
|
+
secure?: boolean;
|
|
102
|
+
httpOnly?: boolean;
|
|
103
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Handler function type with proper response typing
|
|
107
|
+
type Handler<TConfig extends RouteConfig = {}, EC = {}> = (ctx: Context<TConfig> & EC) => Promise<InferResponseType<TConfig['response']> | any> | InferResponseType<TConfig['response']> | any;
|
|
46
108
|
|
|
47
109
|
// Route definition
|
|
48
110
|
interface Route {
|
|
@@ -68,13 +130,24 @@ interface WSRoute {
|
|
|
68
130
|
|
|
69
131
|
// Lifecycle hooks
|
|
70
132
|
interface LifecycleHooks {
|
|
71
|
-
onBeforeStart?: () => Promise<void> | void;
|
|
72
|
-
onAfterStart?: () => Promise<void> | void;
|
|
73
|
-
onBeforeStop?: () => Promise<void> | void;
|
|
74
|
-
onAfterStop?: () => Promise<void> | void;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
133
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
|
134
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
|
135
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
|
136
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
|
137
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<void> | void;
|
|
138
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
|
139
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add global options for BXO
|
|
143
|
+
interface BXOOptions {
|
|
144
|
+
enableValidation?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Plugin interface for middleware-style plugins
|
|
148
|
+
interface Plugin {
|
|
149
|
+
name?: string;
|
|
150
|
+
onRequest?: (ctx: Context) => Promise<Response | void> | Response | void;
|
|
78
151
|
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
|
79
152
|
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
|
80
153
|
}
|
|
@@ -83,66 +156,75 @@ export default class BXO {
|
|
|
83
156
|
private _routes: Route[] = [];
|
|
84
157
|
private _wsRoutes: WSRoute[] = [];
|
|
85
158
|
private plugins: BXO[] = [];
|
|
86
|
-
private
|
|
159
|
+
private middleware: Plugin[] = []; // New middleware array
|
|
160
|
+
private hooks: {
|
|
161
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
|
162
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
|
163
|
+
onBeforeRestart?: (instance: BXO) => Promise<void> | void;
|
|
164
|
+
onAfterRestart?: (instance: BXO) => Promise<void> | void;
|
|
165
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
|
166
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
|
167
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<Response | void> | Response | void;
|
|
168
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
|
169
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
|
170
|
+
} = {};
|
|
87
171
|
private server?: any;
|
|
88
172
|
private isRunning: boolean = false;
|
|
89
|
-
private hotReloadEnabled: boolean = false;
|
|
90
|
-
private watchedFiles: Set<string> = new Set();
|
|
91
|
-
private watchedExclude: Set<string> = new Set();
|
|
92
173
|
private serverPort?: number;
|
|
93
174
|
private serverHostname?: string;
|
|
175
|
+
private enableValidation: boolean = true;
|
|
94
176
|
|
|
95
|
-
constructor() {
|
|
177
|
+
constructor(options?: BXOOptions) {
|
|
178
|
+
this.enableValidation = options?.enableValidation ?? true;
|
|
179
|
+
}
|
|
96
180
|
|
|
97
181
|
// Lifecycle hook methods
|
|
98
|
-
onBeforeStart(handler: () => Promise<void> | void): this {
|
|
182
|
+
onBeforeStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
99
183
|
this.hooks.onBeforeStart = handler;
|
|
100
184
|
return this;
|
|
101
185
|
}
|
|
102
186
|
|
|
103
|
-
onAfterStart(handler: () => Promise<void> | void): this {
|
|
187
|
+
onAfterStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
104
188
|
this.hooks.onAfterStart = handler;
|
|
105
189
|
return this;
|
|
106
190
|
}
|
|
107
191
|
|
|
108
|
-
onBeforeStop(handler: () => Promise<void> | void): this {
|
|
192
|
+
onBeforeStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
109
193
|
this.hooks.onBeforeStop = handler;
|
|
110
194
|
return this;
|
|
111
195
|
}
|
|
112
196
|
|
|
113
|
-
onAfterStop(handler: () => Promise<void> | void): this {
|
|
197
|
+
onAfterStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
114
198
|
this.hooks.onAfterStop = handler;
|
|
115
199
|
return this;
|
|
116
200
|
}
|
|
117
201
|
|
|
118
|
-
onBeforeRestart(handler: () => Promise<void> | void): this {
|
|
119
|
-
this.hooks.onBeforeRestart = handler;
|
|
120
|
-
return this;
|
|
121
|
-
}
|
|
122
202
|
|
|
123
|
-
onAfterRestart(handler: () => Promise<void> | void): this {
|
|
124
|
-
this.hooks.onAfterRestart = handler;
|
|
125
|
-
return this;
|
|
126
|
-
}
|
|
127
203
|
|
|
128
|
-
onRequest(handler: (ctx: Context) => Promise<void> | void): this {
|
|
204
|
+
onRequest(handler: (ctx: Context, instance: BXO) => Promise<void> | void): this {
|
|
129
205
|
this.hooks.onRequest = handler;
|
|
130
206
|
return this;
|
|
131
207
|
}
|
|
132
208
|
|
|
133
|
-
onResponse(handler: (ctx: Context, response: any) => Promise<any> | any): this {
|
|
209
|
+
onResponse(handler: (ctx: Context, response: any, instance: BXO) => Promise<any> | any): this {
|
|
134
210
|
this.hooks.onResponse = handler;
|
|
135
211
|
return this;
|
|
136
212
|
}
|
|
137
213
|
|
|
138
|
-
onError(handler: (ctx: Context, error: Error) => Promise<any> | any): this {
|
|
214
|
+
onError(handler: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any): this {
|
|
139
215
|
this.hooks.onError = handler;
|
|
140
216
|
return this;
|
|
141
217
|
}
|
|
142
218
|
|
|
143
|
-
// Plugin system - now accepts
|
|
144
|
-
use(
|
|
145
|
-
|
|
219
|
+
// Plugin system - now accepts both BXO instances and middleware plugins
|
|
220
|
+
use(plugin: BXO | Plugin): this {
|
|
221
|
+
if ('_routes' in plugin) {
|
|
222
|
+
// It's a BXO instance
|
|
223
|
+
this.plugins.push(plugin);
|
|
224
|
+
} else {
|
|
225
|
+
// It's a middleware plugin
|
|
226
|
+
this.middleware.push(plugin);
|
|
227
|
+
}
|
|
146
228
|
return this;
|
|
147
229
|
}
|
|
148
230
|
|
|
@@ -243,31 +325,153 @@ export default class BXO {
|
|
|
243
325
|
return this;
|
|
244
326
|
}
|
|
245
327
|
|
|
328
|
+
// Helper methods to get all routes including plugin routes
|
|
329
|
+
private getAllRoutes(): Route[] {
|
|
330
|
+
const allRoutes = [...this._routes];
|
|
331
|
+
for (const plugin of this.plugins) {
|
|
332
|
+
allRoutes.push(...plugin._routes);
|
|
333
|
+
}
|
|
334
|
+
return allRoutes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private getAllWSRoutes(): WSRoute[] {
|
|
338
|
+
const allWSRoutes = [...this._wsRoutes];
|
|
339
|
+
for (const plugin of this.plugins) {
|
|
340
|
+
allWSRoutes.push(...plugin._wsRoutes);
|
|
341
|
+
}
|
|
342
|
+
return allWSRoutes;
|
|
343
|
+
}
|
|
344
|
+
|
|
246
345
|
// Route matching utility
|
|
247
346
|
private matchRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | null {
|
|
248
|
-
|
|
347
|
+
const allRoutes = this.getAllRoutes();
|
|
348
|
+
|
|
349
|
+
for (const route of allRoutes) {
|
|
249
350
|
if (route.method !== method) continue;
|
|
250
351
|
|
|
251
352
|
const routeSegments = route.path.split('/').filter(Boolean);
|
|
252
353
|
const pathSegments = pathname.split('/').filter(Boolean);
|
|
253
354
|
|
|
254
|
-
if (routeSegments.length !== pathSegments.length) continue;
|
|
255
|
-
|
|
256
355
|
const params: Record<string, string> = {};
|
|
257
356
|
let isMatch = true;
|
|
258
357
|
|
|
358
|
+
// Check for double wildcard (**) in the route
|
|
359
|
+
const hasDoubleWildcard = routeSegments.some(segment => segment === '**');
|
|
360
|
+
|
|
361
|
+
// Handle double wildcard at the end (catch-all with slashes)
|
|
362
|
+
const hasDoubleWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '**';
|
|
363
|
+
|
|
364
|
+
// Handle single wildcard at the end (catch-all)
|
|
365
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
|
366
|
+
|
|
367
|
+
if (hasDoubleWildcardAtEnd) {
|
|
368
|
+
// For double wildcard at end, path must have at least as many segments as route (minus the double wildcard)
|
|
369
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
370
|
+
} else if (hasWildcardAtEnd) {
|
|
371
|
+
// For single wildcard at end, path must have at least as many segments as route (minus the wildcard)
|
|
372
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
373
|
+
} else if (!hasDoubleWildcard) {
|
|
374
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
|
375
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
259
378
|
for (let i = 0; i < routeSegments.length; i++) {
|
|
260
379
|
const routeSegment = routeSegments[i];
|
|
261
380
|
const pathSegment = pathSegments[i];
|
|
262
381
|
|
|
263
|
-
if (!routeSegment
|
|
382
|
+
if (!routeSegment) {
|
|
383
|
+
isMatch = false;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Handle double wildcard at the end (matches everything including slashes)
|
|
388
|
+
if (routeSegment === '**' && i === routeSegments.length - 1) {
|
|
389
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
390
|
+
params['**'] = remainingPath;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Handle single wildcard at the end (catch-all)
|
|
395
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
|
396
|
+
// Wildcard at end matches remaining path segments
|
|
397
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
398
|
+
params['*'] = remainingPath;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Handle double wildcard in the middle (matches everything including slashes)
|
|
403
|
+
if (routeSegment === '**') {
|
|
404
|
+
// Find the next non-wildcard segment to match against
|
|
405
|
+
let nextNonWildcardIndex = i + 1;
|
|
406
|
+
while (nextNonWildcardIndex < routeSegments.length &&
|
|
407
|
+
(routeSegments[nextNonWildcardIndex] === '*' || routeSegments[nextNonWildcardIndex] === '**')) {
|
|
408
|
+
nextNonWildcardIndex++;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (nextNonWildcardIndex >= routeSegments.length) {
|
|
412
|
+
// Double wildcard is at the end or followed by other wildcards
|
|
413
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
414
|
+
params['**'] = remainingPath;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Find the next matching segment in the path
|
|
419
|
+
const nextRouteSegment = routeSegments[nextNonWildcardIndex];
|
|
420
|
+
if (!nextRouteSegment) {
|
|
421
|
+
isMatch = false;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let foundMatch = false;
|
|
426
|
+
let matchedPath = '';
|
|
427
|
+
|
|
428
|
+
for (let j = i; j < pathSegments.length; j++) {
|
|
429
|
+
const currentPathSegment = pathSegments[j];
|
|
430
|
+
|
|
431
|
+
// Check if this path segment matches the next route segment
|
|
432
|
+
if (nextRouteSegment.startsWith(':')) {
|
|
433
|
+
// Param segment - always matches
|
|
434
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
435
|
+
params['**'] = matchedPath;
|
|
436
|
+
i = j - 1; // Adjust index for the next iteration
|
|
437
|
+
foundMatch = true;
|
|
438
|
+
break;
|
|
439
|
+
} else if (nextRouteSegment === '*') {
|
|
440
|
+
// Single wildcard - always matches
|
|
441
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
442
|
+
params['**'] = matchedPath;
|
|
443
|
+
i = j - 1; // Adjust index for the next iteration
|
|
444
|
+
foundMatch = true;
|
|
445
|
+
break;
|
|
446
|
+
} else if (nextRouteSegment === currentPathSegment) {
|
|
447
|
+
// Exact match
|
|
448
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
449
|
+
params['**'] = matchedPath;
|
|
450
|
+
i = j - 1; // Adjust index for the next iteration
|
|
451
|
+
foundMatch = true;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!foundMatch) {
|
|
457
|
+
isMatch = false;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!pathSegment) {
|
|
264
465
|
isMatch = false;
|
|
265
466
|
break;
|
|
266
467
|
}
|
|
267
468
|
|
|
268
469
|
if (routeSegment.startsWith(':')) {
|
|
269
470
|
const paramName = routeSegment.slice(1);
|
|
270
|
-
params[paramName] =
|
|
471
|
+
params[paramName] = pathSegment;
|
|
472
|
+
} else if (routeSegment === '*') {
|
|
473
|
+
// Single segment wildcard
|
|
474
|
+
params['*'] = pathSegment;
|
|
271
475
|
} else if (routeSegment !== pathSegment) {
|
|
272
476
|
isMatch = false;
|
|
273
477
|
break;
|
|
@@ -284,27 +488,132 @@ export default class BXO {
|
|
|
284
488
|
|
|
285
489
|
// WebSocket route matching utility
|
|
286
490
|
private matchWSRoute(pathname: string): { route: WSRoute; params: Record<string, string> } | null {
|
|
287
|
-
|
|
491
|
+
const allWSRoutes = this.getAllWSRoutes();
|
|
492
|
+
|
|
493
|
+
for (const route of allWSRoutes) {
|
|
288
494
|
const routeSegments = route.path.split('/').filter(Boolean);
|
|
289
495
|
const pathSegments = pathname.split('/').filter(Boolean);
|
|
290
496
|
|
|
291
|
-
if (routeSegments.length !== pathSegments.length) continue;
|
|
292
|
-
|
|
293
497
|
const params: Record<string, string> = {};
|
|
294
498
|
let isMatch = true;
|
|
295
499
|
|
|
500
|
+
// Check for double wildcard (**) in the route
|
|
501
|
+
const hasDoubleWildcard = routeSegments.some(segment => segment === '**');
|
|
502
|
+
|
|
503
|
+
// Handle double wildcard at the end (catch-all with slashes)
|
|
504
|
+
const hasDoubleWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '**';
|
|
505
|
+
|
|
506
|
+
// Handle single wildcard at the end (catch-all)
|
|
507
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
|
508
|
+
|
|
509
|
+
if (hasDoubleWildcardAtEnd) {
|
|
510
|
+
// For double wildcard at end, path must have at least as many segments as route (minus the double wildcard)
|
|
511
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
512
|
+
} else if (hasWildcardAtEnd) {
|
|
513
|
+
// For single wildcard at end, path must have at least as many segments as route (minus the wildcard)
|
|
514
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
|
515
|
+
} else if (!hasDoubleWildcard) {
|
|
516
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
|
517
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
296
520
|
for (let i = 0; i < routeSegments.length; i++) {
|
|
297
521
|
const routeSegment = routeSegments[i];
|
|
298
522
|
const pathSegment = pathSegments[i];
|
|
299
523
|
|
|
300
|
-
if (!routeSegment
|
|
524
|
+
if (!routeSegment) {
|
|
525
|
+
isMatch = false;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Handle double wildcard at the end (matches everything including slashes)
|
|
530
|
+
if (routeSegment === '**' && i === routeSegments.length - 1) {
|
|
531
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
532
|
+
params['**'] = remainingPath;
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Handle single wildcard at the end (catch-all)
|
|
537
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
|
538
|
+
// Wildcard at end matches remaining path segments
|
|
539
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
540
|
+
params['*'] = remainingPath;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Handle double wildcard in the middle (matches everything including slashes)
|
|
545
|
+
if (routeSegment === '**') {
|
|
546
|
+
// Find the next non-wildcard segment to match against
|
|
547
|
+
let nextNonWildcardIndex = i + 1;
|
|
548
|
+
while (nextNonWildcardIndex < routeSegments.length &&
|
|
549
|
+
(routeSegments[nextNonWildcardIndex] === '*' || routeSegments[nextNonWildcardIndex] === '**')) {
|
|
550
|
+
nextNonWildcardIndex++;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (nextNonWildcardIndex >= routeSegments.length) {
|
|
554
|
+
// Double wildcard is at the end or followed by other wildcards
|
|
555
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
|
556
|
+
params['**'] = remainingPath;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Find the next matching segment in the path
|
|
561
|
+
const nextRouteSegment = routeSegments[nextNonWildcardIndex];
|
|
562
|
+
if (!nextRouteSegment) {
|
|
563
|
+
isMatch = false;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let foundMatch = false;
|
|
568
|
+
let matchedPath = '';
|
|
569
|
+
|
|
570
|
+
for (let j = i; j < pathSegments.length; j++) {
|
|
571
|
+
const currentPathSegment = pathSegments[j];
|
|
572
|
+
|
|
573
|
+
// Check if this path segment matches the next route segment
|
|
574
|
+
if (nextRouteSegment.startsWith(':')) {
|
|
575
|
+
// Param segment - always matches
|
|
576
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
577
|
+
params['**'] = matchedPath;
|
|
578
|
+
i = j - 1; // Adjust index for the next iteration
|
|
579
|
+
foundMatch = true;
|
|
580
|
+
break;
|
|
581
|
+
} else if (nextRouteSegment === '*') {
|
|
582
|
+
// Single wildcard - always matches
|
|
583
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
584
|
+
params['**'] = matchedPath;
|
|
585
|
+
i = j - 1; // Adjust index for the next iteration
|
|
586
|
+
foundMatch = true;
|
|
587
|
+
break;
|
|
588
|
+
} else if (nextRouteSegment === currentPathSegment) {
|
|
589
|
+
// Exact match
|
|
590
|
+
matchedPath = pathSegments.slice(i, j).join('/');
|
|
591
|
+
params['**'] = matchedPath;
|
|
592
|
+
i = j - 1; // Adjust index for the next iteration
|
|
593
|
+
foundMatch = true;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!foundMatch) {
|
|
599
|
+
isMatch = false;
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!pathSegment) {
|
|
301
607
|
isMatch = false;
|
|
302
608
|
break;
|
|
303
609
|
}
|
|
304
610
|
|
|
305
611
|
if (routeSegment.startsWith(':')) {
|
|
306
612
|
const paramName = routeSegment.slice(1);
|
|
307
|
-
params[paramName] =
|
|
613
|
+
params[paramName] = pathSegment;
|
|
614
|
+
} else if (routeSegment === '*') {
|
|
615
|
+
// Single segment wildcard
|
|
616
|
+
params['*'] = pathSegment;
|
|
308
617
|
} else if (routeSegment !== pathSegment) {
|
|
309
618
|
isMatch = false;
|
|
310
619
|
break;
|
|
@@ -322,32 +631,87 @@ export default class BXO {
|
|
|
322
631
|
// Parse query string
|
|
323
632
|
private parseQuery(searchParams: URLSearchParams): Record<string, string | undefined> {
|
|
324
633
|
const query: Record<string, string | undefined> = {};
|
|
325
|
-
|
|
634
|
+
searchParams.forEach((value, key) => {
|
|
326
635
|
query[key] = value;
|
|
327
|
-
}
|
|
636
|
+
});
|
|
328
637
|
return query;
|
|
329
638
|
}
|
|
330
639
|
|
|
331
640
|
// Parse headers
|
|
332
641
|
private parseHeaders(headers: Headers): Record<string, string> {
|
|
333
642
|
const headerObj: Record<string, string> = {};
|
|
334
|
-
|
|
643
|
+
headers.forEach((value, key) => {
|
|
335
644
|
headerObj[key] = value;
|
|
336
|
-
}
|
|
645
|
+
});
|
|
337
646
|
return headerObj;
|
|
338
647
|
}
|
|
339
648
|
|
|
649
|
+
// Parse cookies from Cookie header
|
|
650
|
+
private parseCookies(cookieHeader: string | null): Record<string, string> {
|
|
651
|
+
const cookies: Record<string, string> = {};
|
|
652
|
+
|
|
653
|
+
if (!cookieHeader) return cookies;
|
|
654
|
+
|
|
655
|
+
const cookiePairs = cookieHeader.split(';');
|
|
656
|
+
for (const pair of cookiePairs) {
|
|
657
|
+
const [name, value] = pair.trim().split('=');
|
|
658
|
+
if (name && value) {
|
|
659
|
+
cookies[decodeURIComponent(name)] = decodeURIComponent(value);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return cookies;
|
|
664
|
+
}
|
|
665
|
+
|
|
340
666
|
// Validate data against Zod schema
|
|
341
667
|
private validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
|
342
668
|
if (!schema) return data;
|
|
343
669
|
return schema.parse(data);
|
|
344
670
|
}
|
|
345
671
|
|
|
672
|
+
// Validate response against response config (supports both simple and status-based schemas)
|
|
673
|
+
private validateResponse(responseConfig: ResponseConfig | undefined, data: any, status: number = 200): any {
|
|
674
|
+
if (!responseConfig || !this.enableValidation) return data;
|
|
675
|
+
|
|
676
|
+
// If it's a simple schema (not status-based)
|
|
677
|
+
if ('parse' in responseConfig && typeof responseConfig.parse === 'function') {
|
|
678
|
+
return responseConfig.parse(data);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// If it's a status-based schema
|
|
682
|
+
if (typeof responseConfig === 'object' && !('parse' in responseConfig)) {
|
|
683
|
+
const statusSchema = responseConfig[status];
|
|
684
|
+
if (statusSchema) {
|
|
685
|
+
return statusSchema.parse(data);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// If no specific status schema found, try to find a fallback
|
|
689
|
+
// Common fallback statuses: 200, 201, 400, 500
|
|
690
|
+
const fallbackStatuses = [200, 201, 400, 500];
|
|
691
|
+
for (const fallbackStatus of fallbackStatuses) {
|
|
692
|
+
if (responseConfig[fallbackStatus]) {
|
|
693
|
+
return responseConfig[fallbackStatus]?.parse(data);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// If no schema found for the status, return data as-is
|
|
698
|
+
return data;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return data;
|
|
702
|
+
}
|
|
703
|
+
|
|
346
704
|
// Main request handler
|
|
347
705
|
private async handleRequest(request: Request, server?: any): Promise<Response | undefined> {
|
|
348
706
|
const url = new URL(request.url);
|
|
349
707
|
const method = request.method;
|
|
350
|
-
const
|
|
708
|
+
const rawPathname = url.pathname;
|
|
709
|
+
let pathname: string;
|
|
710
|
+
try {
|
|
711
|
+
pathname = decodeURI(rawPathname);
|
|
712
|
+
} catch {
|
|
713
|
+
pathname = rawPathname;
|
|
714
|
+
}
|
|
351
715
|
|
|
352
716
|
// Check for WebSocket upgrade
|
|
353
717
|
if (request.headers.get('upgrade') === 'websocket') {
|
|
@@ -360,7 +724,7 @@ export default class BXO {
|
|
|
360
724
|
pathname
|
|
361
725
|
}
|
|
362
726
|
});
|
|
363
|
-
|
|
727
|
+
|
|
364
728
|
if (success) {
|
|
365
729
|
return; // undefined response means upgrade was successful
|
|
366
730
|
}
|
|
@@ -368,6 +732,94 @@ export default class BXO {
|
|
|
368
732
|
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
369
733
|
}
|
|
370
734
|
|
|
735
|
+
// Handle OPTIONS requests for CORS preflight before route matching
|
|
736
|
+
if (method === 'OPTIONS') {
|
|
737
|
+
// Create a minimal context for OPTIONS requests
|
|
738
|
+
const ctx: Context = {
|
|
739
|
+
params: {},
|
|
740
|
+
query: {},
|
|
741
|
+
body: {},
|
|
742
|
+
headers: this.parseHeaders(request.headers),
|
|
743
|
+
cookies: {},
|
|
744
|
+
path: pathname,
|
|
745
|
+
request,
|
|
746
|
+
set: {
|
|
747
|
+
status: 200,
|
|
748
|
+
headers: {},
|
|
749
|
+
cookies: (name: string, value: string, options?: CookieOptions) => {
|
|
750
|
+
// This is a placeholder for setting cookies.
|
|
751
|
+
// In a real Bun.serve context, you'd use Bun.serve's cookie handling.
|
|
752
|
+
// For now, we'll just log it or throw an error if not Bun.serve.
|
|
753
|
+
console.warn(`Setting cookie '${name}' with value '${value}' via ctx.set.cookies is not directly supported by Bun.serve. Use Bun.serve's cookie handling.`);
|
|
754
|
+
},
|
|
755
|
+
redirect: undefined
|
|
756
|
+
},
|
|
757
|
+
status: ((code: number, data?: any) => {
|
|
758
|
+
ctx.set.status = code;
|
|
759
|
+
return data;
|
|
760
|
+
}) as any,
|
|
761
|
+
redirect: ((location: string, status: number = 302) => {
|
|
762
|
+
ctx.set.redirect = { location, status };
|
|
763
|
+
const responseHeaders: Record<string, string> = {
|
|
764
|
+
Location: location,
|
|
765
|
+
...(ctx.set.headers || {})
|
|
766
|
+
};
|
|
767
|
+
return new Response(null, {
|
|
768
|
+
status,
|
|
769
|
+
headers: responseHeaders
|
|
770
|
+
});
|
|
771
|
+
}) as any,
|
|
772
|
+
clearRedirect: (() => {
|
|
773
|
+
delete ctx.set.redirect;
|
|
774
|
+
if (ctx.set.headers) {
|
|
775
|
+
for (const key of Object.keys(ctx.set.headers)) {
|
|
776
|
+
if (key.toLowerCase() === 'location') {
|
|
777
|
+
delete ctx.set.headers[key];
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400) {
|
|
782
|
+
ctx.set.status = 200;
|
|
783
|
+
}
|
|
784
|
+
}) as any
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// Run middleware onRequest hooks for OPTIONS requests
|
|
788
|
+
for (const plugin of this.middleware) {
|
|
789
|
+
if (plugin.onRequest) {
|
|
790
|
+
const result = await plugin.onRequest(ctx);
|
|
791
|
+
// If middleware returns a response, return it immediately
|
|
792
|
+
if (result instanceof Response) {
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Run global onRequest hook
|
|
799
|
+
if (this.hooks.onRequest) {
|
|
800
|
+
const result = await this.hooks.onRequest(ctx, this);
|
|
801
|
+
if (result instanceof Response) {
|
|
802
|
+
return result;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Run BXO instance onRequest hooks
|
|
807
|
+
for (const bxoInstance of this.plugins) {
|
|
808
|
+
if (bxoInstance.hooks.onRequest) {
|
|
809
|
+
const result = await bxoInstance.hooks.onRequest(ctx, this);
|
|
810
|
+
if (result instanceof Response) {
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// If no middleware handled the OPTIONS request, return a default response
|
|
817
|
+
return new Response(null, {
|
|
818
|
+
status: 204,
|
|
819
|
+
headers: ctx.set.headers || {}
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
371
823
|
const matchResult = this.matchRoute(method, pathname);
|
|
372
824
|
if (!matchResult) {
|
|
373
825
|
return new Response('Not Found', { status: 404 });
|
|
@@ -376,6 +828,7 @@ export default class BXO {
|
|
|
376
828
|
const { route, params } = matchResult;
|
|
377
829
|
const query = this.parseQuery(url.searchParams);
|
|
378
830
|
const headers = this.parseHeaders(request.headers);
|
|
831
|
+
const cookies = this.parseCookies(request.headers.get('cookie'));
|
|
379
832
|
|
|
380
833
|
let body: any;
|
|
381
834
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
@@ -390,56 +843,267 @@ export default class BXO {
|
|
|
390
843
|
const formData = await request.formData();
|
|
391
844
|
body = Object.fromEntries(formData.entries());
|
|
392
845
|
} else {
|
|
393
|
-
|
|
846
|
+
// Try to parse as JSON if it looks like JSON, otherwise treat as text
|
|
847
|
+
const textBody = await request.text();
|
|
848
|
+
try {
|
|
849
|
+
// Check if the text looks like JSON
|
|
850
|
+
if (textBody.trim().startsWith('{') || textBody.trim().startsWith('[')) {
|
|
851
|
+
body = JSON.parse(textBody);
|
|
852
|
+
} else {
|
|
853
|
+
body = textBody;
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
body = textBody;
|
|
857
|
+
}
|
|
394
858
|
}
|
|
395
859
|
}
|
|
396
860
|
|
|
397
|
-
// Create
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
861
|
+
// Create internal cookie storage
|
|
862
|
+
const internalCookies: InternalCookie[] = [];
|
|
863
|
+
|
|
864
|
+
// Create context with validation
|
|
865
|
+
let ctx: Context;
|
|
866
|
+
try {
|
|
867
|
+
// Validate each part separately to get better error messages
|
|
868
|
+
const validatedParams = this.enableValidation && route.config?.params ? this.validateData(route.config.params, params) : params;
|
|
869
|
+
const validatedQuery = this.enableValidation && route.config?.query ? this.validateData(route.config.query, query) : query;
|
|
870
|
+
const validatedBody = this.enableValidation && route.config?.body ? this.validateData(route.config.body, body) : body;
|
|
871
|
+
const validatedHeaders = this.enableValidation && route.config?.headers ? this.validateData(route.config.headers, headers) : headers;
|
|
872
|
+
const validatedCookies = this.enableValidation && route.config?.cookies ? this.validateData(route.config.cookies, cookies) : cookies;
|
|
873
|
+
|
|
874
|
+
ctx = {
|
|
875
|
+
params: validatedParams,
|
|
876
|
+
query: validatedQuery,
|
|
877
|
+
body: validatedBody,
|
|
878
|
+
headers: validatedHeaders,
|
|
879
|
+
cookies: validatedCookies,
|
|
880
|
+
path: pathname,
|
|
881
|
+
request,
|
|
882
|
+
set: {
|
|
883
|
+
status: 200,
|
|
884
|
+
headers: {},
|
|
885
|
+
cookies: (name: string, value: string, options?: CookieOptions) => {
|
|
886
|
+
internalCookies.push({
|
|
887
|
+
name,
|
|
888
|
+
value,
|
|
889
|
+
domain: options?.domain,
|
|
890
|
+
path: options?.path,
|
|
891
|
+
expires: options?.expires,
|
|
892
|
+
maxAge: options?.maxAge,
|
|
893
|
+
secure: options?.secure,
|
|
894
|
+
httpOnly: options?.httpOnly,
|
|
895
|
+
sameSite: options?.sameSite
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
status: ((code: number, data?: any) => {
|
|
900
|
+
ctx.set.status = code;
|
|
901
|
+
return data;
|
|
902
|
+
}) as any,
|
|
903
|
+
redirect: ((location: string, status: number = 302) => {
|
|
904
|
+
// Record redirect intent only; avoid mutating generic status/headers so it can be canceled later
|
|
905
|
+
ctx.set.redirect = { location, status };
|
|
906
|
+
|
|
907
|
+
// Prepare headers for immediate Response return without persisting to ctx.set.headers
|
|
908
|
+
const responseHeaders = new Headers();
|
|
909
|
+
responseHeaders.set('Location', location);
|
|
910
|
+
|
|
911
|
+
// Add any additional headers from ctx.set.headers
|
|
912
|
+
if (ctx.set.headers) {
|
|
913
|
+
Object.entries(ctx.set.headers).forEach(([key, value]) => {
|
|
914
|
+
responseHeaders.set(key, value);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Handle cookies if any are set on context
|
|
919
|
+
if (internalCookies.length > 0) {
|
|
920
|
+
const cookieHeaders = internalCookies.map(cookie => {
|
|
921
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
922
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
923
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
924
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
925
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
926
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
927
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
928
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
929
|
+
return cookieString;
|
|
930
|
+
});
|
|
931
|
+
// Set multiple Set-Cookie headers properly
|
|
932
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
933
|
+
responseHeaders.append('Set-Cookie', cookieHeader);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return new Response(null, {
|
|
938
|
+
status,
|
|
939
|
+
headers: responseHeaders
|
|
940
|
+
});
|
|
941
|
+
}) as any,
|
|
942
|
+
clearRedirect: (() => {
|
|
943
|
+
// Clear explicit redirect intent
|
|
944
|
+
delete ctx.set.redirect;
|
|
945
|
+
// Remove any Location header if present
|
|
946
|
+
if (ctx.set.headers) {
|
|
947
|
+
for (const key of Object.keys(ctx.set.headers)) {
|
|
948
|
+
if (key.toLowerCase() === 'location') {
|
|
949
|
+
delete ctx.set.headers[key];
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// Reset status if it is a redirect
|
|
954
|
+
if (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400) {
|
|
955
|
+
ctx.set.status = 200;
|
|
956
|
+
}
|
|
957
|
+
}) as any
|
|
958
|
+
};
|
|
959
|
+
} catch (validationError) {
|
|
960
|
+
// Validation failed - return error response
|
|
961
|
+
|
|
962
|
+
// Extract detailed validation errors from Zod
|
|
963
|
+
let validationDetails = undefined;
|
|
964
|
+
if (validationError instanceof Error) {
|
|
965
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
966
|
+
validationDetails = validationError.errors;
|
|
967
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
968
|
+
validationDetails = validationError.issues;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Create a clean error message
|
|
973
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
974
|
+
? `Validation failed for ${validationDetails.length} field(s)`
|
|
975
|
+
: 'Validation failed';
|
|
976
|
+
|
|
977
|
+
return new Response(JSON.stringify({
|
|
978
|
+
error: errorMessage,
|
|
979
|
+
details: validationDetails
|
|
980
|
+
}), {
|
|
981
|
+
status: 400,
|
|
982
|
+
headers: { 'Content-Type': 'application/json' }
|
|
983
|
+
});
|
|
984
|
+
}
|
|
406
985
|
|
|
407
986
|
try {
|
|
987
|
+
// Run middleware onRequest hooks
|
|
988
|
+
for (const plugin of this.middleware) {
|
|
989
|
+
if (plugin.onRequest) {
|
|
990
|
+
await plugin.onRequest(ctx);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
408
994
|
// Run global onRequest hook
|
|
409
995
|
if (this.hooks.onRequest) {
|
|
410
|
-
await this.hooks.onRequest(ctx);
|
|
996
|
+
await this.hooks.onRequest(ctx, this);
|
|
411
997
|
}
|
|
412
998
|
|
|
413
999
|
// Run BXO instance onRequest hooks
|
|
414
1000
|
for (const bxoInstance of this.plugins) {
|
|
415
1001
|
if (bxoInstance.hooks.onRequest) {
|
|
416
|
-
await bxoInstance.hooks.onRequest(ctx);
|
|
1002
|
+
await bxoInstance.hooks.onRequest(ctx, this);
|
|
417
1003
|
}
|
|
418
1004
|
}
|
|
419
1005
|
|
|
420
1006
|
// Execute route handler
|
|
421
1007
|
let response = await route.handler(ctx);
|
|
422
1008
|
|
|
1009
|
+
// Run middleware onResponse hooks
|
|
1010
|
+
for (const plugin of this.middleware) {
|
|
1011
|
+
if (plugin.onResponse) {
|
|
1012
|
+
response = await plugin.onResponse(ctx, response) || response;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
423
1016
|
// Run global onResponse hook
|
|
424
1017
|
if (this.hooks.onResponse) {
|
|
425
|
-
response = await this.hooks.onResponse(ctx, response) || response;
|
|
1018
|
+
response = await this.hooks.onResponse(ctx, response, this) || response;
|
|
426
1019
|
}
|
|
427
1020
|
|
|
428
1021
|
// Run BXO instance onResponse hooks
|
|
429
1022
|
for (const bxoInstance of this.plugins) {
|
|
430
1023
|
if (bxoInstance.hooks.onResponse) {
|
|
431
|
-
response = await bxoInstance.hooks.onResponse(ctx, response) || response;
|
|
1024
|
+
response = await bxoInstance.hooks.onResponse(ctx, response, this) || response;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// If the handler did not return a response, but a redirect was configured via ctx.set,
|
|
1029
|
+
// automatically create a redirect Response so users can call ctx.redirect(...) or set headers without returning.
|
|
1030
|
+
const hasImplicitRedirectIntent = !!ctx.set.redirect
|
|
1031
|
+
|| (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400);
|
|
1032
|
+
if ((response === undefined || response === null) && hasImplicitRedirectIntent) {
|
|
1033
|
+
const locationFromHeaders = ctx.set.headers && Object.entries(ctx.set.headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
|
|
1034
|
+
const location = ctx.set.redirect?.location || locationFromHeaders;
|
|
1035
|
+
if (location) {
|
|
1036
|
+
// Build headers, ensuring Location is present
|
|
1037
|
+
let responseHeaders: Record<string, string> = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
1038
|
+
if (!Object.keys(responseHeaders).some(k => k.toLowerCase() === 'location')) {
|
|
1039
|
+
responseHeaders['Location'] = location;
|
|
1040
|
+
}
|
|
1041
|
+
// Determine status precedence: redirect.status > set.status > 302
|
|
1042
|
+
const status = ctx.set.redirect?.status ?? ctx.set.status ?? 302;
|
|
1043
|
+
|
|
1044
|
+
// Handle cookies if any are set
|
|
1045
|
+
if (internalCookies.length > 0) {
|
|
1046
|
+
const cookieHeaders = internalCookies.map(cookie => {
|
|
1047
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
1048
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
1049
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
1050
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
1051
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
1052
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
1053
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
1054
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
1055
|
+
return cookieString;
|
|
1056
|
+
});
|
|
1057
|
+
// Convert responseHeaders to Headers object for proper multiple Set-Cookie handling
|
|
1058
|
+
const headers = new Headers();
|
|
1059
|
+
Object.entries(responseHeaders).forEach(([key, value]) => {
|
|
1060
|
+
headers.set(key, value);
|
|
1061
|
+
});
|
|
1062
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
1063
|
+
headers.append('Set-Cookie', cookieHeader);
|
|
1064
|
+
});
|
|
1065
|
+
// Convert back to plain object for Response constructor
|
|
1066
|
+
const finalHeaders: Record<string, string> = {};
|
|
1067
|
+
headers.forEach((value, key) => {
|
|
1068
|
+
finalHeaders[key] = value;
|
|
1069
|
+
});
|
|
1070
|
+
responseHeaders = finalHeaders;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return new Response(null, {
|
|
1074
|
+
status,
|
|
1075
|
+
headers: responseHeaders
|
|
1076
|
+
});
|
|
432
1077
|
}
|
|
433
1078
|
}
|
|
434
1079
|
|
|
435
1080
|
// Validate response against schema if provided
|
|
436
|
-
if (route.config?.response && !(response instanceof Response)) {
|
|
1081
|
+
if (this.enableValidation && route.config?.response && !(response instanceof Response)) {
|
|
437
1082
|
try {
|
|
438
|
-
|
|
1083
|
+
const status = ctx.set.status || 200;
|
|
1084
|
+
response = this.validateResponse(route.config.response, response, status);
|
|
439
1085
|
} catch (validationError) {
|
|
440
1086
|
// Response validation failed
|
|
441
|
-
|
|
442
|
-
|
|
1087
|
+
|
|
1088
|
+
// Extract detailed validation errors from Zod
|
|
1089
|
+
let validationDetails = undefined;
|
|
1090
|
+
if (validationError instanceof Error) {
|
|
1091
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
1092
|
+
validationDetails = validationError.errors;
|
|
1093
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
1094
|
+
validationDetails = validationError.issues;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Create a clean error message
|
|
1099
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
1100
|
+
? `Response validation failed for ${validationDetails.length} field(s)`
|
|
1101
|
+
: 'Response validation failed';
|
|
1102
|
+
|
|
1103
|
+
return new Response(JSON.stringify({
|
|
1104
|
+
error: errorMessage,
|
|
1105
|
+
details: validationDetails
|
|
1106
|
+
}), {
|
|
443
1107
|
status: 500,
|
|
444
1108
|
headers: { 'Content-Type': 'application/json' }
|
|
445
1109
|
});
|
|
@@ -448,12 +1112,123 @@ export default class BXO {
|
|
|
448
1112
|
|
|
449
1113
|
// Convert response to Response object
|
|
450
1114
|
if (response instanceof Response) {
|
|
1115
|
+
// If there are headers set via ctx.set.headers, merge them with the Response headers
|
|
1116
|
+
if (ctx.set.headers && Object.keys(ctx.set.headers).length > 0) {
|
|
1117
|
+
const newHeaders = new Headers(response.headers);
|
|
1118
|
+
|
|
1119
|
+
// Add headers from ctx.set.headers
|
|
1120
|
+
Object.entries(ctx.set.headers).forEach(([key, value]) => {
|
|
1121
|
+
newHeaders.set(key, value);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// Handle cookies if any are set
|
|
1125
|
+
if (internalCookies.length > 0) {
|
|
1126
|
+
const cookieHeaders = internalCookies.map(cookie => {
|
|
1127
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
1128
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
1129
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
1130
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
1131
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
1132
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
1133
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
1134
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
1135
|
+
return cookieString;
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// Add Set-Cookie headers
|
|
1139
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
1140
|
+
newHeaders.append('Set-Cookie', cookieHeader);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Create new Response with merged headers
|
|
1145
|
+
return new Response(response.body, {
|
|
1146
|
+
status: ctx.set.status || response.status,
|
|
1147
|
+
statusText: response.statusText,
|
|
1148
|
+
headers: newHeaders
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
451
1152
|
return response;
|
|
452
1153
|
}
|
|
453
1154
|
|
|
1155
|
+
// Handle File response (like Elysia)
|
|
1156
|
+
if (response instanceof File || (typeof Bun !== 'undefined' && response instanceof Bun.file('').constructor)) {
|
|
1157
|
+
const file = response as File;
|
|
1158
|
+
const responseInit: ResponseInit = {
|
|
1159
|
+
status: ctx.set.status || 200,
|
|
1160
|
+
headers: {
|
|
1161
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
1162
|
+
'Content-Length': file.size.toString(),
|
|
1163
|
+
...ctx.set.headers
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
return new Response(file, responseInit);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Handle Bun.file() response
|
|
1170
|
+
if (typeof response === 'object' && response && 'stream' in response && 'size' in response) {
|
|
1171
|
+
const bunFile = response as any;
|
|
1172
|
+
const responseInit: ResponseInit = {
|
|
1173
|
+
status: ctx.set.status || 200,
|
|
1174
|
+
headers: {
|
|
1175
|
+
'Content-Type': bunFile.type || 'application/octet-stream',
|
|
1176
|
+
'Content-Length': bunFile.size?.toString() || '',
|
|
1177
|
+
...ctx.set.headers,
|
|
1178
|
+
...(bunFile.headers || {}) // Support custom headers from file helper
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
return new Response(bunFile, responseInit);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Prepare headers with cookies
|
|
1185
|
+
let responseHeaders = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
1186
|
+
|
|
1187
|
+
// Handle cookies if any are set
|
|
1188
|
+
if (internalCookies.length > 0) {
|
|
1189
|
+
const cookieHeaders = internalCookies.map(cookie => {
|
|
1190
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
1191
|
+
|
|
1192
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
1193
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
1194
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
1195
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
1196
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
1197
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
1198
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
1199
|
+
|
|
1200
|
+
return cookieString;
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// Add Set-Cookie headers
|
|
1204
|
+
// Use Headers object directly for Response constructor to handle multiple Set-Cookie headers properly
|
|
1205
|
+
const headers = new Headers();
|
|
1206
|
+
Object.entries(responseHeaders).forEach(([key, value]) => {
|
|
1207
|
+
headers.set(key, value);
|
|
1208
|
+
});
|
|
1209
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
1210
|
+
headers.append('Set-Cookie', cookieHeader);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
const responseInit: ResponseInit = {
|
|
1214
|
+
status: ctx.set.status || 200,
|
|
1215
|
+
headers: headers
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
if (typeof response === 'string') {
|
|
1219
|
+
return new Response(response, responseInit);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return new Response(JSON.stringify(response), {
|
|
1223
|
+
status: responseInit.status,
|
|
1224
|
+
headers: headers
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// If no cookies, use the original responseHeaders
|
|
454
1229
|
const responseInit: ResponseInit = {
|
|
455
1230
|
status: ctx.set.status || 200,
|
|
456
|
-
headers:
|
|
1231
|
+
headers: responseHeaders
|
|
457
1232
|
};
|
|
458
1233
|
|
|
459
1234
|
if (typeof response === 'string') {
|
|
@@ -472,13 +1247,20 @@ export default class BXO {
|
|
|
472
1247
|
// Run error hooks
|
|
473
1248
|
let errorResponse: any;
|
|
474
1249
|
|
|
1250
|
+
// Run middleware onError hooks
|
|
1251
|
+
for (const plugin of this.middleware) {
|
|
1252
|
+
if (plugin.onError) {
|
|
1253
|
+
errorResponse = await plugin.onError(ctx, error as Error) || errorResponse;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
475
1257
|
if (this.hooks.onError) {
|
|
476
|
-
errorResponse = await this.hooks.onError(ctx, error as Error);
|
|
1258
|
+
errorResponse = await this.hooks.onError(ctx, error as Error, this);
|
|
477
1259
|
}
|
|
478
1260
|
|
|
479
1261
|
for (const bxoInstance of this.plugins) {
|
|
480
1262
|
if (bxoInstance.hooks.onError) {
|
|
481
|
-
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error) || errorResponse;
|
|
1263
|
+
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error, this) || errorResponse;
|
|
482
1264
|
}
|
|
483
1265
|
}
|
|
484
1266
|
|
|
@@ -501,89 +1283,18 @@ export default class BXO {
|
|
|
501
1283
|
}
|
|
502
1284
|
}
|
|
503
1285
|
|
|
504
|
-
// Hot reload functionality
|
|
505
|
-
enableHotReload(watchPaths: string[] = ['./'], excludePatterns: string[] = []): this {
|
|
506
|
-
this.hotReloadEnabled = true;
|
|
507
|
-
watchPaths.forEach(path => this.watchedFiles.add(path));
|
|
508
|
-
excludePatterns.forEach(pattern => this.watchedExclude.add(pattern));
|
|
509
|
-
return this;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
private shouldExcludeFile(filename: string): boolean {
|
|
513
|
-
for (const pattern of this.watchedExclude) {
|
|
514
|
-
// Handle exact match
|
|
515
|
-
if (pattern === filename) {
|
|
516
|
-
return true;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// Handle directory patterns (e.g., "node_modules/", "dist/")
|
|
520
|
-
if (pattern.endsWith('/')) {
|
|
521
|
-
if (filename.startsWith(pattern) || filename.includes(`/${pattern}`)) {
|
|
522
|
-
return true;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Handle wildcard patterns (e.g., "*.log", "temp*")
|
|
527
|
-
if (pattern.includes('*')) {
|
|
528
|
-
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
|
529
|
-
if (regex.test(filename)) {
|
|
530
|
-
return true;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Handle file extension patterns (e.g., ".log", ".tmp")
|
|
535
|
-
if (pattern.startsWith('.') && filename.endsWith(pattern)) {
|
|
536
|
-
return true;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Handle substring matches for directories
|
|
540
|
-
if (filename.includes(pattern)) {
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
return false;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
private async setupFileWatcher(port: number, hostname: string): Promise<void> {
|
|
549
|
-
if (!this.hotReloadEnabled) return;
|
|
550
|
-
|
|
551
|
-
const fs = require('fs');
|
|
552
1286
|
|
|
553
|
-
for (const watchPath of this.watchedFiles) {
|
|
554
|
-
try {
|
|
555
|
-
fs.watch(watchPath, { recursive: true }, async (eventType: string, filename: string) => {
|
|
556
|
-
if (filename && (filename.endsWith('.ts') || filename.endsWith('.js'))) {
|
|
557
|
-
// Check if file should be excluded
|
|
558
|
-
if (this.shouldExcludeFile(filename)) {
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
console.log(`🔄 File changed: ${filename}, restarting server...`);
|
|
563
|
-
await this.restart(port, hostname);
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
console.log(`👀 Watching ${watchPath} for changes...`);
|
|
567
|
-
if (this.watchedExclude.size > 0) {
|
|
568
|
-
console.log(`🚫 Excluding patterns: ${Array.from(this.watchedExclude).join(', ')}`);
|
|
569
|
-
}
|
|
570
|
-
} catch (error) {
|
|
571
|
-
console.warn(`⚠️ Could not watch ${watchPath}:`, error);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
1287
|
|
|
576
1288
|
// Server management methods
|
|
577
1289
|
async start(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
578
1290
|
if (this.isRunning) {
|
|
579
|
-
console.log('⚠️ Server is already running');
|
|
580
1291
|
return;
|
|
581
1292
|
}
|
|
582
1293
|
|
|
583
1294
|
try {
|
|
584
1295
|
// Before start hook
|
|
585
1296
|
if (this.hooks.onBeforeStart) {
|
|
586
|
-
await this.hooks.onBeforeStart();
|
|
1297
|
+
await this.hooks.onBeforeStart(this);
|
|
587
1298
|
}
|
|
588
1299
|
|
|
589
1300
|
this.server = Bun.serve({
|
|
@@ -612,20 +1323,20 @@ export default class BXO {
|
|
|
612
1323
|
}
|
|
613
1324
|
});
|
|
614
1325
|
|
|
1326
|
+
// Verify server was created successfully
|
|
1327
|
+
if (!this.server) {
|
|
1328
|
+
throw new Error('Failed to create server instance');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
615
1331
|
this.isRunning = true;
|
|
616
1332
|
this.serverPort = port;
|
|
617
1333
|
this.serverHostname = hostname;
|
|
618
1334
|
|
|
619
|
-
console.log(`🦊 BXO server running at http://${hostname}:${port}`);
|
|
620
|
-
|
|
621
1335
|
// After start hook
|
|
622
1336
|
if (this.hooks.onAfterStart) {
|
|
623
|
-
await this.hooks.onAfterStart();
|
|
1337
|
+
await this.hooks.onAfterStart(this);
|
|
624
1338
|
}
|
|
625
1339
|
|
|
626
|
-
// Setup hot reload
|
|
627
|
-
await this.setupFileWatcher(port, hostname);
|
|
628
|
-
|
|
629
1340
|
// Handle graceful shutdown
|
|
630
1341
|
const shutdownHandler = async () => {
|
|
631
1342
|
await this.stop();
|
|
@@ -643,64 +1354,53 @@ export default class BXO {
|
|
|
643
1354
|
|
|
644
1355
|
async stop(): Promise<void> {
|
|
645
1356
|
if (!this.isRunning) {
|
|
646
|
-
console.log('⚠️ Server is not running');
|
|
647
1357
|
return;
|
|
648
1358
|
}
|
|
649
1359
|
|
|
650
1360
|
try {
|
|
651
1361
|
// Before stop hook
|
|
652
1362
|
if (this.hooks.onBeforeStop) {
|
|
653
|
-
await this.hooks.onBeforeStop();
|
|
1363
|
+
await this.hooks.onBeforeStop(this);
|
|
654
1364
|
}
|
|
655
1365
|
|
|
656
1366
|
if (this.server) {
|
|
657
|
-
|
|
658
|
-
|
|
1367
|
+
try {
|
|
1368
|
+
// Try to stop the server gracefully
|
|
1369
|
+
if (typeof this.server.stop === 'function') {
|
|
1370
|
+
this.server.stop();
|
|
1371
|
+
} else {
|
|
1372
|
+
console.warn('⚠️ Server stop method not available');
|
|
1373
|
+
}
|
|
1374
|
+
} catch (stopError) {
|
|
1375
|
+
console.error('❌ Error calling server.stop():', stopError);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Clear the server reference
|
|
1379
|
+
this.server = undefined;
|
|
659
1380
|
}
|
|
660
1381
|
|
|
1382
|
+
// Reset state regardless of server.stop() success
|
|
661
1383
|
this.isRunning = false;
|
|
662
1384
|
this.serverPort = undefined;
|
|
663
1385
|
this.serverHostname = undefined;
|
|
664
1386
|
|
|
665
|
-
console.log('🛑 BXO server stopped');
|
|
666
|
-
|
|
667
1387
|
// After stop hook
|
|
668
1388
|
if (this.hooks.onAfterStop) {
|
|
669
|
-
await this.hooks.onAfterStop();
|
|
1389
|
+
await this.hooks.onAfterStop(this);
|
|
670
1390
|
}
|
|
671
1391
|
|
|
672
1392
|
} catch (error) {
|
|
673
1393
|
console.error('❌ Error stopping server:', error);
|
|
1394
|
+
// Even if there's an error, reset the state
|
|
1395
|
+
this.isRunning = false;
|
|
1396
|
+
this.server = undefined;
|
|
1397
|
+
this.serverPort = undefined;
|
|
1398
|
+
this.serverHostname = undefined;
|
|
674
1399
|
throw error;
|
|
675
1400
|
}
|
|
676
1401
|
}
|
|
677
1402
|
|
|
678
|
-
async restart(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
679
|
-
try {
|
|
680
|
-
// Before restart hook
|
|
681
|
-
if (this.hooks.onBeforeRestart) {
|
|
682
|
-
await this.hooks.onBeforeRestart();
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
console.log('🔄 Restarting BXO server...');
|
|
686
|
-
|
|
687
|
-
await this.stop();
|
|
688
|
-
|
|
689
|
-
// Small delay to ensure cleanup
|
|
690
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
691
1403
|
|
|
692
|
-
await this.start(port, hostname);
|
|
693
|
-
|
|
694
|
-
// After restart hook
|
|
695
|
-
if (this.hooks.onAfterRestart) {
|
|
696
|
-
await this.hooks.onAfterRestart();
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
} catch (error) {
|
|
700
|
-
console.error('❌ Error restarting server:', error);
|
|
701
|
-
throw error;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
1404
|
|
|
705
1405
|
// Backward compatibility
|
|
706
1406
|
async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
@@ -709,42 +1409,38 @@ export default class BXO {
|
|
|
709
1409
|
|
|
710
1410
|
// Server status
|
|
711
1411
|
isServerRunning(): boolean {
|
|
712
|
-
return this.isRunning;
|
|
1412
|
+
return this.isRunning && this.server !== undefined;
|
|
713
1413
|
}
|
|
714
1414
|
|
|
715
|
-
getServerInfo(): { running: boolean
|
|
1415
|
+
getServerInfo(): { running: boolean } {
|
|
716
1416
|
return {
|
|
717
|
-
running: this.isRunning
|
|
718
|
-
hotReload: this.hotReloadEnabled,
|
|
719
|
-
watchedFiles: Array.from(this.watchedFiles),
|
|
720
|
-
excludePatterns: Array.from(this.watchedExclude)
|
|
1417
|
+
running: this.isRunning
|
|
721
1418
|
};
|
|
722
1419
|
}
|
|
723
1420
|
|
|
724
1421
|
// Get server information (alias for getServerInfo)
|
|
725
1422
|
get info() {
|
|
1423
|
+
// Calculate total routes including plugins
|
|
1424
|
+
const totalRoutes = this._routes.length + this.plugins.reduce((total, plugin) => total + plugin._routes.length, 0);
|
|
1425
|
+
const totalWsRoutes = this._wsRoutes.length + this.plugins.reduce((total, plugin) => total + plugin._wsRoutes.length, 0);
|
|
1426
|
+
|
|
726
1427
|
return {
|
|
727
1428
|
// Server status
|
|
728
1429
|
running: this.isRunning,
|
|
729
1430
|
server: this.server ? 'Bun' : null,
|
|
730
|
-
|
|
1431
|
+
|
|
731
1432
|
// Connection details
|
|
732
1433
|
hostname: this.serverHostname,
|
|
733
1434
|
port: this.serverPort,
|
|
734
|
-
url: this.isRunning && this.serverHostname && this.serverPort
|
|
735
|
-
? `http://${this.serverHostname}:${this.serverPort}`
|
|
1435
|
+
url: this.isRunning && this.serverHostname && this.serverPort
|
|
1436
|
+
? `http://${this.serverHostname}:${this.serverPort}`
|
|
736
1437
|
: null,
|
|
737
|
-
|
|
1438
|
+
|
|
738
1439
|
// Application statistics
|
|
739
|
-
totalRoutes
|
|
740
|
-
totalWsRoutes
|
|
1440
|
+
totalRoutes,
|
|
1441
|
+
totalWsRoutes,
|
|
741
1442
|
totalPlugins: this.plugins.length,
|
|
742
|
-
|
|
743
|
-
// Hot reload configuration
|
|
744
|
-
hotReload: this.hotReloadEnabled,
|
|
745
|
-
watchedFiles: Array.from(this.watchedFiles),
|
|
746
|
-
excludePatterns: Array.from(this.watchedExclude),
|
|
747
|
-
|
|
1443
|
+
|
|
748
1444
|
// System information
|
|
749
1445
|
runtime: 'Bun',
|
|
750
1446
|
version: typeof Bun !== 'undefined' ? Bun.version : 'unknown',
|
|
@@ -755,34 +1451,100 @@ export default class BXO {
|
|
|
755
1451
|
|
|
756
1452
|
// Get all routes information
|
|
757
1453
|
get routes() {
|
|
758
|
-
|
|
1454
|
+
// Get routes from main instance
|
|
1455
|
+
const mainRoutes = this._routes.map((route: Route) => ({
|
|
759
1456
|
method: route.method,
|
|
760
1457
|
path: route.path,
|
|
761
1458
|
hasConfig: !!route.config,
|
|
762
|
-
config: route.config || null
|
|
1459
|
+
config: route.config || null,
|
|
1460
|
+
source: 'main' as const
|
|
763
1461
|
}));
|
|
1462
|
+
|
|
1463
|
+
// Get routes from all plugins
|
|
1464
|
+
const pluginRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
|
1465
|
+
plugin._routes.map((route: Route) => ({
|
|
1466
|
+
method: route.method,
|
|
1467
|
+
path: route.path,
|
|
1468
|
+
hasConfig: !!route.config,
|
|
1469
|
+
config: route.config || null,
|
|
1470
|
+
source: 'plugin' as const,
|
|
1471
|
+
pluginIndex
|
|
1472
|
+
}))
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
return [...mainRoutes, ...pluginRoutes];
|
|
764
1476
|
}
|
|
765
1477
|
|
|
766
1478
|
// Get all WebSocket routes information
|
|
767
1479
|
get wsRoutes() {
|
|
768
|
-
|
|
1480
|
+
// Get WebSocket routes from main instance
|
|
1481
|
+
const mainWsRoutes = this._wsRoutes.map((route: WSRoute) => ({
|
|
769
1482
|
path: route.path,
|
|
770
1483
|
hasHandlers: {
|
|
771
1484
|
onOpen: !!route.handler.onOpen,
|
|
772
1485
|
onMessage: !!route.handler.onMessage,
|
|
773
1486
|
onClose: !!route.handler.onClose,
|
|
774
1487
|
onError: !!route.handler.onError
|
|
775
|
-
}
|
|
1488
|
+
},
|
|
1489
|
+
source: 'main' as const
|
|
776
1490
|
}));
|
|
1491
|
+
|
|
1492
|
+
// Get WebSocket routes from all plugins
|
|
1493
|
+
const pluginWsRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
|
1494
|
+
plugin._wsRoutes.map((route: WSRoute) => ({
|
|
1495
|
+
path: route.path,
|
|
1496
|
+
hasHandlers: {
|
|
1497
|
+
onOpen: !!route.handler.onOpen,
|
|
1498
|
+
onMessage: !!route.handler.onMessage,
|
|
1499
|
+
onClose: !!route.handler.onClose,
|
|
1500
|
+
onError: !!route.handler.onError
|
|
1501
|
+
},
|
|
1502
|
+
source: 'plugin' as const,
|
|
1503
|
+
pluginIndex
|
|
1504
|
+
}))
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
return [...mainWsRoutes, ...pluginWsRoutes];
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const error = (error: Error | string, status: number = 500) => {
|
|
1512
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : error }), { status });
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// File helper function (like Elysia)
|
|
1516
|
+
const file = (path: string, options?: { type?: string; headers?: Record<string, string> }) => {
|
|
1517
|
+
const bunFile = Bun.file(path);
|
|
1518
|
+
|
|
1519
|
+
if (options?.type) {
|
|
1520
|
+
// Create a wrapper to override the MIME type
|
|
1521
|
+
return {
|
|
1522
|
+
...bunFile,
|
|
1523
|
+
type: options.type,
|
|
1524
|
+
headers: options.headers
|
|
1525
|
+
};
|
|
777
1526
|
}
|
|
1527
|
+
|
|
1528
|
+
return bunFile;
|
|
778
1529
|
}
|
|
779
1530
|
|
|
780
|
-
|
|
781
|
-
|
|
1531
|
+
// Redirect helper function (like Elysia)
|
|
1532
|
+
const redirect = (location: string, status: number = 302) => {
|
|
1533
|
+
return new Response(null, {
|
|
1534
|
+
status,
|
|
1535
|
+
headers: { Location: location }
|
|
1536
|
+
});
|
|
782
1537
|
}
|
|
783
1538
|
|
|
784
1539
|
// Export Zod for convenience
|
|
785
|
-
export { z, error };
|
|
1540
|
+
export { z, error, file, redirect };
|
|
786
1541
|
|
|
787
1542
|
// Export types for external use
|
|
788
|
-
export type { RouteConfig, RouteDetail, Handler, WebSocketHandler, WSRoute };
|
|
1543
|
+
export type { RouteConfig, RouteDetail, Handler, WebSocketHandler, WSRoute, CookieOptions, BXOOptions, Plugin };
|
|
1544
|
+
|
|
1545
|
+
// Helper function to create cookie options
|
|
1546
|
+
export const createCookieOptions = (
|
|
1547
|
+
options: CookieOptions = {}
|
|
1548
|
+
): CookieOptions => ({
|
|
1549
|
+
...options
|
|
1550
|
+
});
|