bxo 0.0.5-dev.51 → 0.0.5-dev.53
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/index.ts +4 -1549
- package/package.json +9 -1
- package/src/core/bxo.ts +438 -0
- package/src/handlers/request-handler.ts +229 -0
- package/src/index.ts +54 -0
- package/src/types/index.ts +170 -0
- package/src/utils/context-factory.ts +158 -0
- package/src/utils/helpers.ts +40 -0
- package/src/utils/index.ts +258 -0
- package/src/utils/response-handler.ts +216 -0
- package/src/utils/route-matcher.ts +191 -0
- package/tests/README.md +359 -0
- package/tests/integration/bxo.test.ts +413 -0
- package/tests/run-tests.ts +44 -0
- package/tests/unit/context-factory.test.ts +386 -0
- package/tests/unit/helpers.test.ts +253 -0
- package/tests/unit/response-handler.test.ts +301 -0
- package/tests/unit/route-matcher.test.ts +181 -0
- package/tests/unit/utils.test.ts +310 -0
package/index.ts
CHANGED
|
@@ -1,1550 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// Re-export everything from the refactored source
|
|
2
|
+
export * from './src/index';
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
-
|
|
29
|
-
// OpenAPI detail information
|
|
30
|
-
interface RouteDetail {
|
|
31
|
-
summary?: string;
|
|
32
|
-
description?: string;
|
|
33
|
-
tags?: string[];
|
|
34
|
-
operationId?: string;
|
|
35
|
-
deprecated?: boolean;
|
|
36
|
-
produces?: string[];
|
|
37
|
-
consumes?: string[];
|
|
38
|
-
[key: string]: any; // Allow additional OpenAPI properties
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Configuration interface for route handlers
|
|
42
|
-
interface RouteConfig {
|
|
43
|
-
params?: z.ZodSchema<any>;
|
|
44
|
-
query?: z.ZodSchema<any>;
|
|
45
|
-
body?: z.ZodSchema<any>;
|
|
46
|
-
headers?: z.ZodSchema<any>;
|
|
47
|
-
cookies?: z.ZodSchema<any>;
|
|
48
|
-
response?: ResponseConfig;
|
|
49
|
-
detail?: RouteDetail;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Helper type to extract status codes from response config
|
|
53
|
-
type StatusCodes<T> = T extends Record<number, any> ? keyof T : never;
|
|
54
|
-
|
|
55
|
-
// Context type that's fully typed based on the route configuration
|
|
56
|
-
export type Context<TConfig extends RouteConfig = {}> = {
|
|
57
|
-
params: TConfig['params'] extends z.ZodSchema<any> ? InferZodType<TConfig['params']> : Record<string, string>;
|
|
58
|
-
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
|
59
|
-
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
|
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;
|
|
63
|
-
request: Request;
|
|
64
|
-
set: {
|
|
65
|
-
status: number;
|
|
66
|
-
headers: Record<string, string>;
|
|
67
|
-
cookies: (name: string, value: string, options?: CookieOptions) => void;
|
|
68
|
-
redirect?: { location: string; status?: number };
|
|
69
|
-
};
|
|
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;
|
|
90
|
-
[key: string]: any;
|
|
91
|
-
};
|
|
92
|
-
|
|
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;
|
|
108
|
-
|
|
109
|
-
// Route definition
|
|
110
|
-
interface Route {
|
|
111
|
-
method: string;
|
|
112
|
-
path: string;
|
|
113
|
-
handler: Handler<any>;
|
|
114
|
-
config?: RouteConfig;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// WebSocket handler interface
|
|
118
|
-
interface WebSocketHandler {
|
|
119
|
-
onOpen?: (ws: any) => void;
|
|
120
|
-
onMessage?: (ws: any, message: string | Buffer) => void;
|
|
121
|
-
onClose?: (ws: any, code?: number, reason?: string) => void;
|
|
122
|
-
onError?: (ws: any, error: Error) => void;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// WebSocket route definition
|
|
126
|
-
interface WSRoute {
|
|
127
|
-
path: string;
|
|
128
|
-
handler: WebSocketHandler;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Lifecycle hooks
|
|
132
|
-
interface LifecycleHooks {
|
|
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;
|
|
151
|
-
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
|
152
|
-
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export default class BXO {
|
|
156
|
-
private _routes: Route[] = [];
|
|
157
|
-
private _wsRoutes: WSRoute[] = [];
|
|
158
|
-
private plugins: BXO[] = [];
|
|
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
|
-
} = {};
|
|
171
|
-
private server?: any;
|
|
172
|
-
private isRunning: boolean = false;
|
|
173
|
-
private serverPort?: number;
|
|
174
|
-
private serverHostname?: string;
|
|
175
|
-
private enableValidation: boolean = true;
|
|
176
|
-
|
|
177
|
-
constructor(options?: BXOOptions) {
|
|
178
|
-
this.enableValidation = options?.enableValidation ?? true;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Lifecycle hook methods
|
|
182
|
-
onBeforeStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
183
|
-
this.hooks.onBeforeStart = handler;
|
|
184
|
-
return this;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
onAfterStart(handler: (instance: BXO) => Promise<void> | void): this {
|
|
188
|
-
this.hooks.onAfterStart = handler;
|
|
189
|
-
return this;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
onBeforeStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
193
|
-
this.hooks.onBeforeStop = handler;
|
|
194
|
-
return this;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
onAfterStop(handler: (instance: BXO) => Promise<void> | void): this {
|
|
198
|
-
this.hooks.onAfterStop = handler;
|
|
199
|
-
return this;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
onRequest(handler: (ctx: Context, instance: BXO) => Promise<void> | void): this {
|
|
205
|
-
this.hooks.onRequest = handler;
|
|
206
|
-
return this;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
onResponse(handler: (ctx: Context, response: any, instance: BXO) => Promise<any> | any): this {
|
|
210
|
-
this.hooks.onResponse = handler;
|
|
211
|
-
return this;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
onError(handler: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any): this {
|
|
215
|
-
this.hooks.onError = handler;
|
|
216
|
-
return this;
|
|
217
|
-
}
|
|
218
|
-
|
|
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
|
-
}
|
|
228
|
-
return this;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// HTTP method handlers with overloads for type safety
|
|
232
|
-
get<TConfig extends RouteConfig = {}>(
|
|
233
|
-
path: string,
|
|
234
|
-
handler: Handler<TConfig>
|
|
235
|
-
): this;
|
|
236
|
-
get<TConfig extends RouteConfig = {}>(
|
|
237
|
-
path: string,
|
|
238
|
-
handler: Handler<TConfig>,
|
|
239
|
-
config: TConfig
|
|
240
|
-
): this;
|
|
241
|
-
get<TConfig extends RouteConfig = {}>(
|
|
242
|
-
path: string,
|
|
243
|
-
handler: Handler<TConfig>,
|
|
244
|
-
config?: TConfig
|
|
245
|
-
): this {
|
|
246
|
-
this._routes.push({ method: 'GET', path, handler, config });
|
|
247
|
-
return this;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
post<TConfig extends RouteConfig = {}>(
|
|
251
|
-
path: string,
|
|
252
|
-
handler: Handler<TConfig>
|
|
253
|
-
): this;
|
|
254
|
-
post<TConfig extends RouteConfig = {}>(
|
|
255
|
-
path: string,
|
|
256
|
-
handler: Handler<TConfig>,
|
|
257
|
-
config: TConfig
|
|
258
|
-
): this;
|
|
259
|
-
post<TConfig extends RouteConfig = {}>(
|
|
260
|
-
path: string,
|
|
261
|
-
handler: Handler<TConfig>,
|
|
262
|
-
config?: TConfig
|
|
263
|
-
): this {
|
|
264
|
-
this._routes.push({ method: 'POST', path, handler, config });
|
|
265
|
-
return this;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
put<TConfig extends RouteConfig = {}>(
|
|
269
|
-
path: string,
|
|
270
|
-
handler: Handler<TConfig>
|
|
271
|
-
): this;
|
|
272
|
-
put<TConfig extends RouteConfig = {}>(
|
|
273
|
-
path: string,
|
|
274
|
-
handler: Handler<TConfig>,
|
|
275
|
-
config: TConfig
|
|
276
|
-
): this;
|
|
277
|
-
put<TConfig extends RouteConfig = {}>(
|
|
278
|
-
path: string,
|
|
279
|
-
handler: Handler<TConfig>,
|
|
280
|
-
config?: TConfig
|
|
281
|
-
): this {
|
|
282
|
-
this._routes.push({ method: 'PUT', path, handler, config });
|
|
283
|
-
return this;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
delete<TConfig extends RouteConfig = {}>(
|
|
287
|
-
path: string,
|
|
288
|
-
handler: Handler<TConfig>
|
|
289
|
-
): this;
|
|
290
|
-
delete<TConfig extends RouteConfig = {}>(
|
|
291
|
-
path: string,
|
|
292
|
-
handler: Handler<TConfig>,
|
|
293
|
-
config: TConfig
|
|
294
|
-
): this;
|
|
295
|
-
delete<TConfig extends RouteConfig = {}>(
|
|
296
|
-
path: string,
|
|
297
|
-
handler: Handler<TConfig>,
|
|
298
|
-
config?: TConfig
|
|
299
|
-
): this {
|
|
300
|
-
this._routes.push({ method: 'DELETE', path, handler, config });
|
|
301
|
-
return this;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
patch<TConfig extends RouteConfig = {}>(
|
|
305
|
-
path: string,
|
|
306
|
-
handler: Handler<TConfig>
|
|
307
|
-
): this;
|
|
308
|
-
patch<TConfig extends RouteConfig = {}>(
|
|
309
|
-
path: string,
|
|
310
|
-
handler: Handler<TConfig>,
|
|
311
|
-
config: TConfig
|
|
312
|
-
): this;
|
|
313
|
-
patch<TConfig extends RouteConfig = {}>(
|
|
314
|
-
path: string,
|
|
315
|
-
handler: Handler<TConfig>,
|
|
316
|
-
config?: TConfig
|
|
317
|
-
): this {
|
|
318
|
-
this._routes.push({ method: 'PATCH', path, handler, config });
|
|
319
|
-
return this;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// WebSocket route handler
|
|
323
|
-
ws(path: string, handler: WebSocketHandler): this {
|
|
324
|
-
this._wsRoutes.push({ path, handler });
|
|
325
|
-
return this;
|
|
326
|
-
}
|
|
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
|
-
|
|
345
|
-
// Route matching utility
|
|
346
|
-
private matchRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | null {
|
|
347
|
-
const allRoutes = this.getAllRoutes();
|
|
348
|
-
|
|
349
|
-
for (const route of allRoutes) {
|
|
350
|
-
if (route.method !== method) continue;
|
|
351
|
-
|
|
352
|
-
const routeSegments = route.path.split('/').filter(Boolean);
|
|
353
|
-
const pathSegments = pathname.split('/').filter(Boolean);
|
|
354
|
-
|
|
355
|
-
const params: Record<string, string> = {};
|
|
356
|
-
let isMatch = true;
|
|
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
|
-
|
|
378
|
-
for (let i = 0; i < routeSegments.length; i++) {
|
|
379
|
-
const routeSegment = routeSegments[i];
|
|
380
|
-
const pathSegment = pathSegments[i];
|
|
381
|
-
|
|
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) {
|
|
465
|
-
isMatch = false;
|
|
466
|
-
break;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (routeSegment.startsWith(':')) {
|
|
470
|
-
const paramName = routeSegment.slice(1);
|
|
471
|
-
params[paramName] = pathSegment;
|
|
472
|
-
} else if (routeSegment === '*') {
|
|
473
|
-
// Single segment wildcard
|
|
474
|
-
params['*'] = pathSegment;
|
|
475
|
-
} else if (routeSegment !== pathSegment) {
|
|
476
|
-
isMatch = false;
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (isMatch) {
|
|
482
|
-
return { route, params };
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// WebSocket route matching utility
|
|
490
|
-
private matchWSRoute(pathname: string): { route: WSRoute; params: Record<string, string> } | null {
|
|
491
|
-
const allWSRoutes = this.getAllWSRoutes();
|
|
492
|
-
|
|
493
|
-
for (const route of allWSRoutes) {
|
|
494
|
-
const routeSegments = route.path.split('/').filter(Boolean);
|
|
495
|
-
const pathSegments = pathname.split('/').filter(Boolean);
|
|
496
|
-
|
|
497
|
-
const params: Record<string, string> = {};
|
|
498
|
-
let isMatch = true;
|
|
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
|
-
|
|
520
|
-
for (let i = 0; i < routeSegments.length; i++) {
|
|
521
|
-
const routeSegment = routeSegments[i];
|
|
522
|
-
const pathSegment = pathSegments[i];
|
|
523
|
-
|
|
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) {
|
|
607
|
-
isMatch = false;
|
|
608
|
-
break;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (routeSegment.startsWith(':')) {
|
|
612
|
-
const paramName = routeSegment.slice(1);
|
|
613
|
-
params[paramName] = pathSegment;
|
|
614
|
-
} else if (routeSegment === '*') {
|
|
615
|
-
// Single segment wildcard
|
|
616
|
-
params['*'] = pathSegment;
|
|
617
|
-
} else if (routeSegment !== pathSegment) {
|
|
618
|
-
isMatch = false;
|
|
619
|
-
break;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (isMatch) {
|
|
624
|
-
return { route, params };
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Parse query string
|
|
632
|
-
private parseQuery(searchParams: URLSearchParams): Record<string, string | undefined> {
|
|
633
|
-
const query: Record<string, string | undefined> = {};
|
|
634
|
-
searchParams.forEach((value, key) => {
|
|
635
|
-
query[key] = value;
|
|
636
|
-
});
|
|
637
|
-
return query;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Parse headers
|
|
641
|
-
private parseHeaders(headers: Headers): Record<string, string> {
|
|
642
|
-
const headerObj: Record<string, string> = {};
|
|
643
|
-
headers.forEach((value, key) => {
|
|
644
|
-
headerObj[key] = value;
|
|
645
|
-
});
|
|
646
|
-
return headerObj;
|
|
647
|
-
}
|
|
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
|
-
|
|
666
|
-
// Validate data against Zod schema
|
|
667
|
-
private validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
|
668
|
-
if (!schema) return data;
|
|
669
|
-
return schema.parse(data);
|
|
670
|
-
}
|
|
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
|
-
|
|
704
|
-
// Main request handler
|
|
705
|
-
private async handleRequest(request: Request, server?: any): Promise<Response | undefined> {
|
|
706
|
-
const url = new URL(request.url);
|
|
707
|
-
const method = request.method;
|
|
708
|
-
const rawPathname = url.pathname;
|
|
709
|
-
let pathname: string;
|
|
710
|
-
try {
|
|
711
|
-
pathname = decodeURI(rawPathname);
|
|
712
|
-
} catch {
|
|
713
|
-
pathname = rawPathname;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Check for WebSocket upgrade
|
|
717
|
-
if (request.headers.get('upgrade') === 'websocket') {
|
|
718
|
-
const wsMatchResult = this.matchWSRoute(pathname);
|
|
719
|
-
if (wsMatchResult && server) {
|
|
720
|
-
const success = server.upgrade(request, {
|
|
721
|
-
data: {
|
|
722
|
-
handler: wsMatchResult.route.handler,
|
|
723
|
-
params: wsMatchResult.params,
|
|
724
|
-
pathname
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
if (success) {
|
|
729
|
-
return; // undefined response means upgrade was successful
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
733
|
-
}
|
|
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
|
-
|
|
823
|
-
const matchResult = this.matchRoute(method, pathname);
|
|
824
|
-
if (!matchResult) {
|
|
825
|
-
return new Response('Not Found', { status: 404 });
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const { route, params } = matchResult;
|
|
829
|
-
const query = this.parseQuery(url.searchParams);
|
|
830
|
-
const headers = this.parseHeaders(request.headers);
|
|
831
|
-
const cookies = this.parseCookies(request.headers.get('cookie'));
|
|
832
|
-
|
|
833
|
-
let body: any;
|
|
834
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
835
|
-
const contentType = request.headers.get('content-type');
|
|
836
|
-
if (contentType?.includes('application/json')) {
|
|
837
|
-
try {
|
|
838
|
-
body = await request.json();
|
|
839
|
-
} catch {
|
|
840
|
-
body = {};
|
|
841
|
-
}
|
|
842
|
-
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
|
843
|
-
const formData = await request.formData();
|
|
844
|
-
body = Object.fromEntries(formData.entries());
|
|
845
|
-
} else {
|
|
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
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
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
|
-
}
|
|
985
|
-
|
|
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
|
-
|
|
994
|
-
// Run global onRequest hook
|
|
995
|
-
if (this.hooks.onRequest) {
|
|
996
|
-
await this.hooks.onRequest(ctx, this);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Run BXO instance onRequest hooks
|
|
1000
|
-
for (const bxoInstance of this.plugins) {
|
|
1001
|
-
if (bxoInstance.hooks.onRequest) {
|
|
1002
|
-
await bxoInstance.hooks.onRequest(ctx, this);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Execute route handler
|
|
1007
|
-
let response = await route.handler(ctx);
|
|
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
|
-
|
|
1016
|
-
// Run global onResponse hook
|
|
1017
|
-
if (this.hooks.onResponse) {
|
|
1018
|
-
response = await this.hooks.onResponse(ctx, response, this) || response;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// Run BXO instance onResponse hooks
|
|
1022
|
-
for (const bxoInstance of this.plugins) {
|
|
1023
|
-
if (bxoInstance.hooks.onResponse) {
|
|
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
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// Validate response against schema if provided
|
|
1081
|
-
if (this.enableValidation && route.config?.response && !(response instanceof Response)) {
|
|
1082
|
-
try {
|
|
1083
|
-
const status = ctx.set.status || 200;
|
|
1084
|
-
response = this.validateResponse(route.config.response, response, status);
|
|
1085
|
-
} catch (validationError) {
|
|
1086
|
-
// Response validation failed
|
|
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
|
-
}), {
|
|
1107
|
-
status: 500,
|
|
1108
|
-
headers: { 'Content-Type': 'application/json' }
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// Convert response to Response object
|
|
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
|
-
|
|
1152
|
-
return response;
|
|
1153
|
-
}
|
|
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
|
|
1229
|
-
const responseInit: ResponseInit = {
|
|
1230
|
-
status: ctx.set.status || 200,
|
|
1231
|
-
headers: responseHeaders
|
|
1232
|
-
};
|
|
1233
|
-
|
|
1234
|
-
if (typeof response === 'string') {
|
|
1235
|
-
return new Response(response, responseInit);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
return new Response(JSON.stringify(response), {
|
|
1239
|
-
...responseInit,
|
|
1240
|
-
headers: {
|
|
1241
|
-
'Content-Type': 'application/json',
|
|
1242
|
-
...responseInit.headers
|
|
1243
|
-
}
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
// Run error hooks
|
|
1248
|
-
let errorResponse: any;
|
|
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
|
-
|
|
1257
|
-
if (this.hooks.onError) {
|
|
1258
|
-
errorResponse = await this.hooks.onError(ctx, error as Error, this);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
for (const bxoInstance of this.plugins) {
|
|
1262
|
-
if (bxoInstance.hooks.onError) {
|
|
1263
|
-
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error, this) || errorResponse;
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
if (errorResponse) {
|
|
1268
|
-
if (errorResponse instanceof Response) {
|
|
1269
|
-
return errorResponse;
|
|
1270
|
-
}
|
|
1271
|
-
return new Response(JSON.stringify(errorResponse), {
|
|
1272
|
-
status: 500,
|
|
1273
|
-
headers: { 'Content-Type': 'application/json' }
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
// Default error response
|
|
1278
|
-
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
|
1279
|
-
return new Response(JSON.stringify({ error: errorMessage }), {
|
|
1280
|
-
status: 500,
|
|
1281
|
-
headers: { 'Content-Type': 'application/json' }
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
// Server management methods
|
|
1289
|
-
async start(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
1290
|
-
if (this.isRunning) {
|
|
1291
|
-
return;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
try {
|
|
1295
|
-
// Before start hook
|
|
1296
|
-
if (this.hooks.onBeforeStart) {
|
|
1297
|
-
await this.hooks.onBeforeStart(this);
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
this.server = Bun.serve({
|
|
1301
|
-
port,
|
|
1302
|
-
hostname,
|
|
1303
|
-
fetch: (request, server) => this.handleRequest(request, server),
|
|
1304
|
-
websocket: {
|
|
1305
|
-
message: (ws: any, message: any) => {
|
|
1306
|
-
const handler = ws.data?.handler;
|
|
1307
|
-
if (handler?.onMessage) {
|
|
1308
|
-
handler.onMessage(ws, message);
|
|
1309
|
-
}
|
|
1310
|
-
},
|
|
1311
|
-
open: (ws: any) => {
|
|
1312
|
-
const handler = ws.data?.handler;
|
|
1313
|
-
if (handler?.onOpen) {
|
|
1314
|
-
handler.onOpen(ws);
|
|
1315
|
-
}
|
|
1316
|
-
},
|
|
1317
|
-
close: (ws: any, code?: number, reason?: string) => {
|
|
1318
|
-
const handler = ws.data?.handler;
|
|
1319
|
-
if (handler?.onClose) {
|
|
1320
|
-
handler.onClose(ws, code, reason);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
});
|
|
1325
|
-
|
|
1326
|
-
// Verify server was created successfully
|
|
1327
|
-
if (!this.server) {
|
|
1328
|
-
throw new Error('Failed to create server instance');
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
this.isRunning = true;
|
|
1332
|
-
this.serverPort = port;
|
|
1333
|
-
this.serverHostname = hostname;
|
|
1334
|
-
|
|
1335
|
-
// After start hook
|
|
1336
|
-
if (this.hooks.onAfterStart) {
|
|
1337
|
-
await this.hooks.onAfterStart(this);
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
// Handle graceful shutdown
|
|
1341
|
-
const shutdownHandler = async () => {
|
|
1342
|
-
await this.stop();
|
|
1343
|
-
process.exit(0);
|
|
1344
|
-
};
|
|
1345
|
-
|
|
1346
|
-
process.on('SIGINT', shutdownHandler);
|
|
1347
|
-
process.on('SIGTERM', shutdownHandler);
|
|
1348
|
-
|
|
1349
|
-
} catch (error) {
|
|
1350
|
-
console.error('❌ Failed to start server:', error);
|
|
1351
|
-
throw error;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
async stop(): Promise<void> {
|
|
1356
|
-
if (!this.isRunning) {
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
try {
|
|
1361
|
-
// Before stop hook
|
|
1362
|
-
if (this.hooks.onBeforeStop) {
|
|
1363
|
-
await this.hooks.onBeforeStop(this);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
if (this.server) {
|
|
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;
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// Reset state regardless of server.stop() success
|
|
1383
|
-
this.isRunning = false;
|
|
1384
|
-
this.serverPort = undefined;
|
|
1385
|
-
this.serverHostname = undefined;
|
|
1386
|
-
|
|
1387
|
-
// After stop hook
|
|
1388
|
-
if (this.hooks.onAfterStop) {
|
|
1389
|
-
await this.hooks.onAfterStop(this);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
} catch (error) {
|
|
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;
|
|
1399
|
-
throw error;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
// Backward compatibility
|
|
1406
|
-
async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
|
1407
|
-
return this.start(port, hostname);
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// Server status
|
|
1411
|
-
isServerRunning(): boolean {
|
|
1412
|
-
return this.isRunning && this.server !== undefined;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
getServerInfo(): { running: boolean } {
|
|
1416
|
-
return {
|
|
1417
|
-
running: this.isRunning
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
// Get server information (alias for getServerInfo)
|
|
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
|
-
|
|
1427
|
-
return {
|
|
1428
|
-
// Server status
|
|
1429
|
-
running: this.isRunning,
|
|
1430
|
-
server: this.server ? 'Bun' : null,
|
|
1431
|
-
|
|
1432
|
-
// Connection details
|
|
1433
|
-
hostname: this.serverHostname,
|
|
1434
|
-
port: this.serverPort,
|
|
1435
|
-
url: this.isRunning && this.serverHostname && this.serverPort
|
|
1436
|
-
? `http://${this.serverHostname}:${this.serverPort}`
|
|
1437
|
-
: null,
|
|
1438
|
-
|
|
1439
|
-
// Application statistics
|
|
1440
|
-
totalRoutes,
|
|
1441
|
-
totalWsRoutes,
|
|
1442
|
-
totalPlugins: this.plugins.length,
|
|
1443
|
-
|
|
1444
|
-
// System information
|
|
1445
|
-
runtime: 'Bun',
|
|
1446
|
-
version: typeof Bun !== 'undefined' ? Bun.version : 'unknown',
|
|
1447
|
-
pid: process.pid,
|
|
1448
|
-
uptime: this.isRunning ? process.uptime() : 0
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
// Get all routes information
|
|
1453
|
-
get routes() {
|
|
1454
|
-
// Get routes from main instance
|
|
1455
|
-
const mainRoutes = this._routes.map((route: Route) => ({
|
|
1456
|
-
method: route.method,
|
|
1457
|
-
path: route.path,
|
|
1458
|
-
hasConfig: !!route.config,
|
|
1459
|
-
config: route.config || null,
|
|
1460
|
-
source: 'main' as const
|
|
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];
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
// Get all WebSocket routes information
|
|
1479
|
-
get wsRoutes() {
|
|
1480
|
-
// Get WebSocket routes from main instance
|
|
1481
|
-
const mainWsRoutes = this._wsRoutes.map((route: WSRoute) => ({
|
|
1482
|
-
path: route.path,
|
|
1483
|
-
hasHandlers: {
|
|
1484
|
-
onOpen: !!route.handler.onOpen,
|
|
1485
|
-
onMessage: !!route.handler.onMessage,
|
|
1486
|
-
onClose: !!route.handler.onClose,
|
|
1487
|
-
onError: !!route.handler.onError
|
|
1488
|
-
},
|
|
1489
|
-
source: 'main' as const
|
|
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
|
-
};
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
return bunFile;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
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
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
// Export Zod for convenience
|
|
1540
|
-
export { z, error, file, redirect };
|
|
1541
|
-
|
|
1542
|
-
// Export types for external use
|
|
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
|
-
});
|
|
4
|
+
// Also export the default BXO class for backward compatibility
|
|
5
|
+
export { default } from './src/index';
|