bxo 0.0.5-dev.2 → 0.0.5-dev.21
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 +520 -183
- package/package.json +1 -1
- package/plugins/index.ts +0 -2
- package/example.ts +0 -183
- package/plugins/auth.ts +0 -119
- package/plugins/logger.ts +0 -109
package/index.ts
CHANGED
@@ -3,13 +3,40 @@ 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
|
+
// Cookie interface
|
7
|
+
interface Cookie {
|
8
|
+
name: string;
|
9
|
+
value: string;
|
10
|
+
domain?: string;
|
11
|
+
path?: string;
|
12
|
+
expires?: Date;
|
13
|
+
maxAge?: number;
|
14
|
+
secure?: boolean;
|
15
|
+
httpOnly?: boolean;
|
16
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
17
|
+
}
|
18
|
+
|
19
|
+
// OpenAPI detail information
|
20
|
+
interface RouteDetail {
|
21
|
+
summary?: string;
|
22
|
+
description?: string;
|
23
|
+
tags?: string[];
|
24
|
+
operationId?: string;
|
25
|
+
deprecated?: boolean;
|
26
|
+
produces?: string[];
|
27
|
+
consumes?: string[];
|
28
|
+
[key: string]: any; // Allow additional OpenAPI properties
|
29
|
+
}
|
30
|
+
|
6
31
|
// Configuration interface for route handlers
|
7
32
|
interface RouteConfig {
|
8
33
|
params?: z.ZodSchema<any>;
|
9
34
|
query?: z.ZodSchema<any>;
|
10
35
|
body?: z.ZodSchema<any>;
|
11
36
|
headers?: z.ZodSchema<any>;
|
37
|
+
cookies?: z.ZodSchema<any>;
|
12
38
|
response?: z.ZodSchema<any>;
|
39
|
+
detail?: RouteDetail;
|
13
40
|
}
|
14
41
|
|
15
42
|
// Context type that's fully typed based on the route configuration
|
@@ -18,18 +45,19 @@ export type Context<TConfig extends RouteConfig = {}> = {
|
|
18
45
|
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
19
46
|
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
20
47
|
headers: TConfig['headers'] extends z.ZodSchema<any> ? InferZodType<TConfig['headers']> : Record<string, string>;
|
48
|
+
cookies: TConfig['cookies'] extends z.ZodSchema<any> ? InferZodType<TConfig['cookies']> : Record<string, string>;
|
49
|
+
path: string;
|
21
50
|
request: Request;
|
22
51
|
set: {
|
23
52
|
status?: number;
|
24
53
|
headers?: Record<string, string>;
|
54
|
+
cookies?: Cookie[];
|
25
55
|
};
|
26
|
-
// Extended properties that can be added by plugins
|
27
|
-
user?: any;
|
28
56
|
[key: string]: any;
|
29
57
|
};
|
30
58
|
|
31
59
|
// Handler function type
|
32
|
-
type Handler<TConfig extends RouteConfig = {}> = (ctx: Context<TConfig>) => Promise<any> | any;
|
60
|
+
type Handler<TConfig extends RouteConfig = {}, EC = {}> = (ctx: Context<TConfig> & EC) => Promise<any> | any;
|
33
61
|
|
34
62
|
// Route definition
|
35
63
|
interface Route {
|
@@ -39,73 +67,77 @@ interface Route {
|
|
39
67
|
config?: RouteConfig;
|
40
68
|
}
|
41
69
|
|
70
|
+
// WebSocket handler interface
|
71
|
+
interface WebSocketHandler {
|
72
|
+
onOpen?: (ws: any) => void;
|
73
|
+
onMessage?: (ws: any, message: string | Buffer) => void;
|
74
|
+
onClose?: (ws: any, code?: number, reason?: string) => void;
|
75
|
+
onError?: (ws: any, error: Error) => void;
|
76
|
+
}
|
77
|
+
|
78
|
+
// WebSocket route definition
|
79
|
+
interface WSRoute {
|
80
|
+
path: string;
|
81
|
+
handler: WebSocketHandler;
|
82
|
+
}
|
83
|
+
|
42
84
|
// Lifecycle hooks
|
43
85
|
interface LifecycleHooks {
|
44
|
-
onBeforeStart?: () => Promise<void> | void;
|
45
|
-
onAfterStart?: () => Promise<void> | void;
|
46
|
-
onBeforeStop?: () => Promise<void> | void;
|
47
|
-
onAfterStop?: () => Promise<void> | void;
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
52
|
-
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
86
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
87
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
88
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
89
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
90
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<void> | void;
|
91
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
92
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
53
93
|
}
|
54
94
|
|
55
95
|
export default class BXO {
|
56
96
|
private _routes: Route[] = [];
|
97
|
+
private _wsRoutes: WSRoute[] = [];
|
57
98
|
private plugins: BXO[] = [];
|
58
99
|
private hooks: LifecycleHooks = {};
|
59
100
|
private server?: any;
|
60
101
|
private isRunning: boolean = false;
|
61
|
-
private
|
62
|
-
private
|
63
|
-
private watchedExclude: Set<string> = new Set();
|
102
|
+
private serverPort?: number;
|
103
|
+
private serverHostname?: string;
|
64
104
|
|
65
105
|
constructor() { }
|
66
106
|
|
67
107
|
// Lifecycle hook methods
|
68
|
-
onBeforeStart(handler: () => Promise<void> | void): this {
|
108
|
+
onBeforeStart(handler: (instance: BXO) => Promise<void> | void): this {
|
69
109
|
this.hooks.onBeforeStart = handler;
|
70
110
|
return this;
|
71
111
|
}
|
72
112
|
|
73
|
-
onAfterStart(handler: () => Promise<void> | void): this {
|
113
|
+
onAfterStart(handler: (instance: BXO) => Promise<void> | void): this {
|
74
114
|
this.hooks.onAfterStart = handler;
|
75
115
|
return this;
|
76
116
|
}
|
77
117
|
|
78
|
-
onBeforeStop(handler: () => Promise<void> | void): this {
|
118
|
+
onBeforeStop(handler: (instance: BXO) => Promise<void> | void): this {
|
79
119
|
this.hooks.onBeforeStop = handler;
|
80
120
|
return this;
|
81
121
|
}
|
82
122
|
|
83
|
-
onAfterStop(handler: () => Promise<void> | void): this {
|
123
|
+
onAfterStop(handler: (instance: BXO) => Promise<void> | void): this {
|
84
124
|
this.hooks.onAfterStop = handler;
|
85
125
|
return this;
|
86
126
|
}
|
87
127
|
|
88
|
-
onBeforeRestart(handler: () => Promise<void> | void): this {
|
89
|
-
this.hooks.onBeforeRestart = handler;
|
90
|
-
return this;
|
91
|
-
}
|
92
128
|
|
93
|
-
onAfterRestart(handler: () => Promise<void> | void): this {
|
94
|
-
this.hooks.onAfterRestart = handler;
|
95
|
-
return this;
|
96
|
-
}
|
97
129
|
|
98
|
-
onRequest(handler: (ctx: Context) => Promise<void> | void): this {
|
130
|
+
onRequest(handler: (ctx: Context, instance: BXO) => Promise<void> | void): this {
|
99
131
|
this.hooks.onRequest = handler;
|
100
132
|
return this;
|
101
133
|
}
|
102
134
|
|
103
|
-
onResponse(handler: (ctx: Context, response: any) => Promise<any> | any): this {
|
135
|
+
onResponse(handler: (ctx: Context, response: any, instance: BXO) => Promise<any> | any): this {
|
104
136
|
this.hooks.onResponse = handler;
|
105
137
|
return this;
|
106
138
|
}
|
107
139
|
|
108
|
-
onError(handler: (ctx: Context, error: Error) => Promise<any> | any): this {
|
140
|
+
onError(handler: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any): this {
|
109
141
|
this.hooks.onError = handler;
|
110
142
|
return this;
|
111
143
|
}
|
@@ -207,24 +239,135 @@ export default class BXO {
|
|
207
239
|
return this;
|
208
240
|
}
|
209
241
|
|
242
|
+
// WebSocket route handler
|
243
|
+
ws(path: string, handler: WebSocketHandler): this {
|
244
|
+
this._wsRoutes.push({ path, handler });
|
245
|
+
return this;
|
246
|
+
}
|
247
|
+
|
248
|
+
// Helper methods to get all routes including plugin routes
|
249
|
+
private getAllRoutes(): Route[] {
|
250
|
+
const allRoutes = [...this._routes];
|
251
|
+
for (const plugin of this.plugins) {
|
252
|
+
allRoutes.push(...plugin._routes);
|
253
|
+
}
|
254
|
+
return allRoutes;
|
255
|
+
}
|
256
|
+
|
257
|
+
private getAllWSRoutes(): WSRoute[] {
|
258
|
+
const allWSRoutes = [...this._wsRoutes];
|
259
|
+
for (const plugin of this.plugins) {
|
260
|
+
allWSRoutes.push(...plugin._wsRoutes);
|
261
|
+
}
|
262
|
+
return allWSRoutes;
|
263
|
+
}
|
264
|
+
|
210
265
|
// Route matching utility
|
211
266
|
private matchRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | null {
|
212
|
-
|
267
|
+
const allRoutes = this.getAllRoutes();
|
268
|
+
|
269
|
+
for (const route of allRoutes) {
|
213
270
|
if (route.method !== method) continue;
|
214
271
|
|
215
272
|
const routeSegments = route.path.split('/').filter(Boolean);
|
216
273
|
const pathSegments = pathname.split('/').filter(Boolean);
|
217
274
|
|
218
|
-
|
275
|
+
const params: Record<string, string> = {};
|
276
|
+
let isMatch = true;
|
277
|
+
|
278
|
+
// Handle wildcard at the end (catch-all)
|
279
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
280
|
+
|
281
|
+
if (hasWildcardAtEnd) {
|
282
|
+
// For catch-all wildcard, path must have at least as many segments as route (minus the wildcard)
|
283
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
284
|
+
} else {
|
285
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
286
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
287
|
+
}
|
288
|
+
|
289
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
290
|
+
const routeSegment = routeSegments[i];
|
291
|
+
const pathSegment = pathSegments[i];
|
292
|
+
|
293
|
+
if (!routeSegment) {
|
294
|
+
isMatch = false;
|
295
|
+
break;
|
296
|
+
}
|
297
|
+
|
298
|
+
// Handle catch-all wildcard at the end
|
299
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
300
|
+
// Wildcard at end matches remaining path segments
|
301
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
302
|
+
params['*'] = remainingPath;
|
303
|
+
break;
|
304
|
+
}
|
305
|
+
|
306
|
+
if (!pathSegment) {
|
307
|
+
isMatch = false;
|
308
|
+
break;
|
309
|
+
}
|
310
|
+
|
311
|
+
if (routeSegment.startsWith(':')) {
|
312
|
+
const paramName = routeSegment.slice(1);
|
313
|
+
params[paramName] = decodeURIComponent(pathSegment);
|
314
|
+
} else if (routeSegment === '*') {
|
315
|
+
// Single segment wildcard
|
316
|
+
params['*'] = decodeURIComponent(pathSegment);
|
317
|
+
} else if (routeSegment !== pathSegment) {
|
318
|
+
isMatch = false;
|
319
|
+
break;
|
320
|
+
}
|
321
|
+
}
|
322
|
+
|
323
|
+
if (isMatch) {
|
324
|
+
return { route, params };
|
325
|
+
}
|
326
|
+
}
|
327
|
+
|
328
|
+
return null;
|
329
|
+
}
|
330
|
+
|
331
|
+
// WebSocket route matching utility
|
332
|
+
private matchWSRoute(pathname: string): { route: WSRoute; params: Record<string, string> } | null {
|
333
|
+
const allWSRoutes = this.getAllWSRoutes();
|
334
|
+
|
335
|
+
for (const route of allWSRoutes) {
|
336
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
337
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
219
338
|
|
220
339
|
const params: Record<string, string> = {};
|
221
340
|
let isMatch = true;
|
222
341
|
|
342
|
+
// Handle wildcard at the end (catch-all)
|
343
|
+
const hasWildcardAtEnd = routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === '*';
|
344
|
+
|
345
|
+
if (hasWildcardAtEnd) {
|
346
|
+
// For catch-all wildcard, path must have at least as many segments as route (minus the wildcard)
|
347
|
+
if (pathSegments.length < routeSegments.length - 1) continue;
|
348
|
+
} else {
|
349
|
+
// For exact matching (with possible single-segment wildcards), lengths must match
|
350
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
351
|
+
}
|
352
|
+
|
223
353
|
for (let i = 0; i < routeSegments.length; i++) {
|
224
354
|
const routeSegment = routeSegments[i];
|
225
355
|
const pathSegment = pathSegments[i];
|
226
356
|
|
227
|
-
if (!routeSegment
|
357
|
+
if (!routeSegment) {
|
358
|
+
isMatch = false;
|
359
|
+
break;
|
360
|
+
}
|
361
|
+
|
362
|
+
// Handle catch-all wildcard at the end
|
363
|
+
if (routeSegment === '*' && i === routeSegments.length - 1) {
|
364
|
+
// Wildcard at end matches remaining path segments
|
365
|
+
const remainingPath = pathSegments.slice(i).join('/');
|
366
|
+
params['*'] = remainingPath;
|
367
|
+
break;
|
368
|
+
}
|
369
|
+
|
370
|
+
if (!pathSegment) {
|
228
371
|
isMatch = false;
|
229
372
|
break;
|
230
373
|
}
|
@@ -232,6 +375,9 @@ export default class BXO {
|
|
232
375
|
if (routeSegment.startsWith(':')) {
|
233
376
|
const paramName = routeSegment.slice(1);
|
234
377
|
params[paramName] = decodeURIComponent(pathSegment);
|
378
|
+
} else if (routeSegment === '*') {
|
379
|
+
// Single segment wildcard
|
380
|
+
params['*'] = decodeURIComponent(pathSegment);
|
235
381
|
} else if (routeSegment !== pathSegment) {
|
236
382
|
isMatch = false;
|
237
383
|
break;
|
@@ -264,6 +410,23 @@ export default class BXO {
|
|
264
410
|
return headerObj;
|
265
411
|
}
|
266
412
|
|
413
|
+
// Parse cookies from Cookie header
|
414
|
+
private parseCookies(cookieHeader: string | null): Record<string, string> {
|
415
|
+
const cookies: Record<string, string> = {};
|
416
|
+
|
417
|
+
if (!cookieHeader) return cookies;
|
418
|
+
|
419
|
+
const cookiePairs = cookieHeader.split(';');
|
420
|
+
for (const pair of cookiePairs) {
|
421
|
+
const [name, value] = pair.trim().split('=');
|
422
|
+
if (name && value) {
|
423
|
+
cookies[decodeURIComponent(name)] = decodeURIComponent(value);
|
424
|
+
}
|
425
|
+
}
|
426
|
+
|
427
|
+
return cookies;
|
428
|
+
}
|
429
|
+
|
267
430
|
// Validate data against Zod schema
|
268
431
|
private validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
269
432
|
if (!schema) return data;
|
@@ -271,11 +434,30 @@ export default class BXO {
|
|
271
434
|
}
|
272
435
|
|
273
436
|
// Main request handler
|
274
|
-
private async handleRequest(request: Request): Promise<Response> {
|
437
|
+
private async handleRequest(request: Request, server?: any): Promise<Response | undefined> {
|
275
438
|
const url = new URL(request.url);
|
276
439
|
const method = request.method;
|
277
440
|
const pathname = url.pathname;
|
278
441
|
|
442
|
+
// Check for WebSocket upgrade
|
443
|
+
if (request.headers.get('upgrade') === 'websocket') {
|
444
|
+
const wsMatchResult = this.matchWSRoute(pathname);
|
445
|
+
if (wsMatchResult && server) {
|
446
|
+
const success = server.upgrade(request, {
|
447
|
+
data: {
|
448
|
+
handler: wsMatchResult.route.handler,
|
449
|
+
params: wsMatchResult.params,
|
450
|
+
pathname
|
451
|
+
}
|
452
|
+
});
|
453
|
+
|
454
|
+
if (success) {
|
455
|
+
return; // undefined response means upgrade was successful
|
456
|
+
}
|
457
|
+
}
|
458
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
459
|
+
}
|
460
|
+
|
279
461
|
const matchResult = this.matchRoute(method, pathname);
|
280
462
|
if (!matchResult) {
|
281
463
|
return new Response('Not Found', { status: 404 });
|
@@ -284,6 +466,7 @@ export default class BXO {
|
|
284
466
|
const { route, params } = matchResult;
|
285
467
|
const query = this.parseQuery(url.searchParams);
|
286
468
|
const headers = this.parseHeaders(request.headers);
|
469
|
+
const cookies = this.parseCookies(request.headers.get('cookie'));
|
287
470
|
|
288
471
|
let body: any;
|
289
472
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
@@ -298,30 +481,78 @@ export default class BXO {
|
|
298
481
|
const formData = await request.formData();
|
299
482
|
body = Object.fromEntries(formData.entries());
|
300
483
|
} else {
|
301
|
-
|
484
|
+
// Try to parse as JSON if it looks like JSON, otherwise treat as text
|
485
|
+
const textBody = await request.text();
|
486
|
+
try {
|
487
|
+
// Check if the text looks like JSON
|
488
|
+
if (textBody.trim().startsWith('{') || textBody.trim().startsWith('[')) {
|
489
|
+
body = JSON.parse(textBody);
|
490
|
+
} else {
|
491
|
+
body = textBody;
|
492
|
+
}
|
493
|
+
} catch {
|
494
|
+
body = textBody;
|
495
|
+
}
|
302
496
|
}
|
303
497
|
}
|
304
498
|
|
305
|
-
// Create context
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
499
|
+
// Create context with validation
|
500
|
+
let ctx: Context;
|
501
|
+
try {
|
502
|
+
// Validate each part separately to get better error messages
|
503
|
+
const validatedParams = route.config?.params ? this.validateData(route.config.params, params) : params;
|
504
|
+
const validatedQuery = route.config?.query ? this.validateData(route.config.query, query) : query;
|
505
|
+
const validatedBody = route.config?.body ? this.validateData(route.config.body, body) : body;
|
506
|
+
const validatedHeaders = route.config?.headers ? this.validateData(route.config.headers, headers) : headers;
|
507
|
+
const validatedCookies = route.config?.cookies ? this.validateData(route.config.cookies, cookies) : cookies;
|
508
|
+
|
509
|
+
ctx = {
|
510
|
+
params: validatedParams,
|
511
|
+
query: validatedQuery,
|
512
|
+
body: validatedBody,
|
513
|
+
headers: validatedHeaders,
|
514
|
+
cookies: validatedCookies,
|
515
|
+
path: pathname,
|
516
|
+
request,
|
517
|
+
set: {}
|
518
|
+
};
|
519
|
+
} catch (validationError) {
|
520
|
+
// Validation failed - return error response
|
521
|
+
|
522
|
+
// Extract detailed validation errors from Zod
|
523
|
+
let validationDetails = undefined;
|
524
|
+
if (validationError instanceof Error) {
|
525
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
526
|
+
validationDetails = validationError.errors;
|
527
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
528
|
+
validationDetails = validationError.issues;
|
529
|
+
}
|
530
|
+
}
|
531
|
+
|
532
|
+
// Create a clean error message
|
533
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
534
|
+
? `Validation failed for ${validationDetails.length} field(s)`
|
535
|
+
: 'Validation failed';
|
536
|
+
|
537
|
+
return new Response(JSON.stringify({
|
538
|
+
error: errorMessage,
|
539
|
+
details: validationDetails
|
540
|
+
}), {
|
541
|
+
status: 400,
|
542
|
+
headers: { 'Content-Type': 'application/json' }
|
543
|
+
});
|
544
|
+
}
|
314
545
|
|
315
546
|
try {
|
316
547
|
// Run global onRequest hook
|
317
548
|
if (this.hooks.onRequest) {
|
318
|
-
await this.hooks.onRequest(ctx);
|
549
|
+
await this.hooks.onRequest(ctx, this);
|
319
550
|
}
|
320
551
|
|
321
552
|
// Run BXO instance onRequest hooks
|
322
553
|
for (const bxoInstance of this.plugins) {
|
323
554
|
if (bxoInstance.hooks.onRequest) {
|
324
|
-
await bxoInstance.hooks.onRequest(ctx);
|
555
|
+
await bxoInstance.hooks.onRequest(ctx, this);
|
325
556
|
}
|
326
557
|
}
|
327
558
|
|
@@ -330,24 +561,43 @@ export default class BXO {
|
|
330
561
|
|
331
562
|
// Run global onResponse hook
|
332
563
|
if (this.hooks.onResponse) {
|
333
|
-
response = await this.hooks.onResponse(ctx, response) || response;
|
564
|
+
response = await this.hooks.onResponse(ctx, response, this) || response;
|
334
565
|
}
|
335
566
|
|
336
567
|
// Run BXO instance onResponse hooks
|
337
568
|
for (const bxoInstance of this.plugins) {
|
338
569
|
if (bxoInstance.hooks.onResponse) {
|
339
|
-
response = await bxoInstance.hooks.onResponse(ctx, response) || response;
|
570
|
+
response = await bxoInstance.hooks.onResponse(ctx, response, this) || response;
|
340
571
|
}
|
341
572
|
}
|
342
573
|
|
343
574
|
// Validate response against schema if provided
|
344
575
|
if (route.config?.response && !(response instanceof Response)) {
|
345
576
|
try {
|
577
|
+
console.log('response', response);
|
346
578
|
response = this.validateData(route.config.response, response);
|
347
579
|
} catch (validationError) {
|
348
580
|
// Response validation failed
|
349
|
-
|
350
|
-
|
581
|
+
|
582
|
+
// Extract detailed validation errors from Zod
|
583
|
+
let validationDetails = undefined;
|
584
|
+
if (validationError instanceof Error) {
|
585
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
586
|
+
validationDetails = validationError.errors;
|
587
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
588
|
+
validationDetails = validationError.issues;
|
589
|
+
}
|
590
|
+
}
|
591
|
+
|
592
|
+
// Create a clean error message
|
593
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
594
|
+
? `Response validation failed for ${validationDetails.length} field(s)`
|
595
|
+
: 'Response validation failed';
|
596
|
+
|
597
|
+
return new Response(JSON.stringify({
|
598
|
+
error: errorMessage,
|
599
|
+
details: validationDetails
|
600
|
+
}), {
|
351
601
|
status: 500,
|
352
602
|
headers: { 'Content-Type': 'application/json' }
|
353
603
|
});
|
@@ -359,9 +609,63 @@ export default class BXO {
|
|
359
609
|
return response;
|
360
610
|
}
|
361
611
|
|
612
|
+
// Handle File response (like Elysia)
|
613
|
+
if (response instanceof File || (typeof Bun !== 'undefined' && response instanceof Bun.file('').constructor)) {
|
614
|
+
const file = response as File;
|
615
|
+
const responseInit: ResponseInit = {
|
616
|
+
status: ctx.set.status || 200,
|
617
|
+
headers: {
|
618
|
+
'Content-Type': file.type || 'application/octet-stream',
|
619
|
+
'Content-Length': file.size.toString(),
|
620
|
+
...ctx.set.headers
|
621
|
+
}
|
622
|
+
};
|
623
|
+
return new Response(file, responseInit);
|
624
|
+
}
|
625
|
+
|
626
|
+
// Handle Bun.file() response
|
627
|
+
if (typeof response === 'object' && response && 'stream' in response && 'size' in response) {
|
628
|
+
const bunFile = response as any;
|
629
|
+
const responseInit: ResponseInit = {
|
630
|
+
status: ctx.set.status || 200,
|
631
|
+
headers: {
|
632
|
+
'Content-Type': bunFile.type || 'application/octet-stream',
|
633
|
+
'Content-Length': bunFile.size?.toString() || '',
|
634
|
+
...ctx.set.headers,
|
635
|
+
...(bunFile.headers || {}) // Support custom headers from file helper
|
636
|
+
}
|
637
|
+
};
|
638
|
+
return new Response(bunFile, responseInit);
|
639
|
+
}
|
640
|
+
|
641
|
+
// Prepare headers with cookies
|
642
|
+
let responseHeaders = ctx.set.headers ? { ...ctx.set.headers } : {};
|
643
|
+
|
644
|
+
// Handle cookies if any are set
|
645
|
+
if (ctx.set.cookies && ctx.set.cookies.length > 0) {
|
646
|
+
const cookieHeaders = ctx.set.cookies.map(cookie => {
|
647
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
648
|
+
|
649
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
650
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
651
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
652
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
653
|
+
if (cookie.secure) cookieString += `; Secure`;
|
654
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
655
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
656
|
+
|
657
|
+
return cookieString;
|
658
|
+
});
|
659
|
+
|
660
|
+
// Add Set-Cookie headers
|
661
|
+
cookieHeaders.forEach((cookieHeader, index) => {
|
662
|
+
responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
|
663
|
+
});
|
664
|
+
}
|
665
|
+
|
362
666
|
const responseInit: ResponseInit = {
|
363
667
|
status: ctx.set.status || 200,
|
364
|
-
headers:
|
668
|
+
headers: responseHeaders
|
365
669
|
};
|
366
670
|
|
367
671
|
if (typeof response === 'string') {
|
@@ -381,12 +685,12 @@ export default class BXO {
|
|
381
685
|
let errorResponse: any;
|
382
686
|
|
383
687
|
if (this.hooks.onError) {
|
384
|
-
errorResponse = await this.hooks.onError(ctx, error as Error);
|
688
|
+
errorResponse = await this.hooks.onError(ctx, error as Error, this);
|
385
689
|
}
|
386
690
|
|
387
691
|
for (const bxoInstance of this.plugins) {
|
388
692
|
if (bxoInstance.hooks.onError) {
|
389
|
-
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error) || errorResponse;
|
693
|
+
errorResponse = await bxoInstance.hooks.onError(ctx, error as Error, this) || errorResponse;
|
390
694
|
}
|
391
695
|
}
|
392
696
|
|
@@ -409,77 +713,7 @@ export default class BXO {
|
|
409
713
|
}
|
410
714
|
}
|
411
715
|
|
412
|
-
// Hot reload functionality
|
413
|
-
enableHotReload(watchPaths: string[] = ['./'], excludePatterns: string[] = []): this {
|
414
|
-
this.hotReloadEnabled = true;
|
415
|
-
watchPaths.forEach(path => this.watchedFiles.add(path));
|
416
|
-
excludePatterns.forEach(pattern => this.watchedExclude.add(pattern));
|
417
|
-
return this;
|
418
|
-
}
|
419
|
-
|
420
|
-
private shouldExcludeFile(filename: string): boolean {
|
421
|
-
for (const pattern of this.watchedExclude) {
|
422
|
-
// Handle exact match
|
423
|
-
if (pattern === filename) {
|
424
|
-
return true;
|
425
|
-
}
|
426
|
-
|
427
|
-
// Handle directory patterns (e.g., "node_modules/", "dist/")
|
428
|
-
if (pattern.endsWith('/')) {
|
429
|
-
if (filename.startsWith(pattern) || filename.includes(`/${pattern}`)) {
|
430
|
-
return true;
|
431
|
-
}
|
432
|
-
}
|
433
|
-
|
434
|
-
// Handle wildcard patterns (e.g., "*.log", "temp*")
|
435
|
-
if (pattern.includes('*')) {
|
436
|
-
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
437
|
-
if (regex.test(filename)) {
|
438
|
-
return true;
|
439
|
-
}
|
440
|
-
}
|
441
|
-
|
442
|
-
// Handle file extension patterns (e.g., ".log", ".tmp")
|
443
|
-
if (pattern.startsWith('.') && filename.endsWith(pattern)) {
|
444
|
-
return true;
|
445
|
-
}
|
446
|
-
|
447
|
-
// Handle substring matches for directories
|
448
|
-
if (filename.includes(pattern)) {
|
449
|
-
return true;
|
450
|
-
}
|
451
|
-
}
|
452
|
-
|
453
|
-
return false;
|
454
|
-
}
|
455
|
-
|
456
|
-
private async setupFileWatcher(port: number, hostname: string): Promise<void> {
|
457
|
-
if (!this.hotReloadEnabled) return;
|
458
716
|
|
459
|
-
const fs = require('fs');
|
460
|
-
|
461
|
-
for (const watchPath of this.watchedFiles) {
|
462
|
-
try {
|
463
|
-
fs.watch(watchPath, { recursive: true }, async (eventType: string, filename: string) => {
|
464
|
-
if (filename && (filename.endsWith('.ts') || filename.endsWith('.js'))) {
|
465
|
-
// Check if file should be excluded
|
466
|
-
if (this.shouldExcludeFile(filename)) {
|
467
|
-
return;
|
468
|
-
}
|
469
|
-
|
470
|
-
console.log(`🔄 File changed: ${filename}, restarting server...`);
|
471
|
-
await this.restart(port, hostname);
|
472
|
-
}
|
473
|
-
});
|
474
|
-
console.log(`👀 Watching ${watchPath} for changes...`);
|
475
|
-
if (this.watchedExclude.size > 0) {
|
476
|
-
console.log(`🚫 Excluding patterns: ${Array.from(this.watchedExclude).join(', ')}`);
|
477
|
-
}
|
478
|
-
} catch (error) {
|
479
|
-
console.warn(`⚠️ Could not watch ${watchPath}:`, error);
|
480
|
-
}
|
481
|
-
}
|
482
|
-
}
|
483
717
|
|
484
718
|
// Server management methods
|
485
719
|
async start(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
@@ -491,27 +725,49 @@ export default class BXO {
|
|
491
725
|
try {
|
492
726
|
// Before start hook
|
493
727
|
if (this.hooks.onBeforeStart) {
|
494
|
-
await this.hooks.onBeforeStart();
|
728
|
+
await this.hooks.onBeforeStart(this);
|
495
729
|
}
|
496
730
|
|
497
731
|
this.server = Bun.serve({
|
498
732
|
port,
|
499
733
|
hostname,
|
500
|
-
fetch: (request) => this.handleRequest(request),
|
734
|
+
fetch: (request, server) => this.handleRequest(request, server),
|
735
|
+
websocket: {
|
736
|
+
message: (ws: any, message: any) => {
|
737
|
+
const handler = ws.data?.handler;
|
738
|
+
if (handler?.onMessage) {
|
739
|
+
handler.onMessage(ws, message);
|
740
|
+
}
|
741
|
+
},
|
742
|
+
open: (ws: any) => {
|
743
|
+
const handler = ws.data?.handler;
|
744
|
+
if (handler?.onOpen) {
|
745
|
+
handler.onOpen(ws);
|
746
|
+
}
|
747
|
+
},
|
748
|
+
close: (ws: any, code?: number, reason?: string) => {
|
749
|
+
const handler = ws.data?.handler;
|
750
|
+
if (handler?.onClose) {
|
751
|
+
handler.onClose(ws, code, reason);
|
752
|
+
}
|
753
|
+
}
|
754
|
+
}
|
501
755
|
});
|
502
756
|
|
503
|
-
|
757
|
+
// Verify server was created successfully
|
758
|
+
if (!this.server) {
|
759
|
+
throw new Error('Failed to create server instance');
|
760
|
+
}
|
504
761
|
|
505
|
-
|
762
|
+
this.isRunning = true;
|
763
|
+
this.serverPort = port;
|
764
|
+
this.serverHostname = hostname;
|
506
765
|
|
507
766
|
// After start hook
|
508
767
|
if (this.hooks.onAfterStart) {
|
509
|
-
await this.hooks.onAfterStart();
|
768
|
+
await this.hooks.onAfterStart(this);
|
510
769
|
}
|
511
770
|
|
512
|
-
// Setup hot reload
|
513
|
-
await this.setupFileWatcher(port, hostname);
|
514
|
-
|
515
771
|
// Handle graceful shutdown
|
516
772
|
const shutdownHandler = async () => {
|
517
773
|
await this.stop();
|
@@ -536,55 +792,49 @@ export default class BXO {
|
|
536
792
|
try {
|
537
793
|
// Before stop hook
|
538
794
|
if (this.hooks.onBeforeStop) {
|
539
|
-
await this.hooks.onBeforeStop();
|
795
|
+
await this.hooks.onBeforeStop(this);
|
540
796
|
}
|
541
797
|
|
542
798
|
if (this.server) {
|
543
|
-
|
544
|
-
|
799
|
+
try {
|
800
|
+
// Try to stop the server gracefully
|
801
|
+
if (typeof this.server.stop === 'function') {
|
802
|
+
this.server.stop();
|
803
|
+
} else {
|
804
|
+
console.warn('⚠️ Server stop method not available');
|
805
|
+
}
|
806
|
+
} catch (stopError) {
|
807
|
+
console.error('❌ Error calling server.stop():', stopError);
|
808
|
+
}
|
809
|
+
|
810
|
+
// Clear the server reference
|
811
|
+
this.server = undefined;
|
545
812
|
}
|
546
813
|
|
814
|
+
// Reset state regardless of server.stop() success
|
547
815
|
this.isRunning = false;
|
548
|
-
|
549
|
-
|
816
|
+
this.serverPort = undefined;
|
817
|
+
this.serverHostname = undefined;
|
550
818
|
|
551
819
|
// After stop hook
|
552
820
|
if (this.hooks.onAfterStop) {
|
553
|
-
await this.hooks.onAfterStop();
|
821
|
+
await this.hooks.onAfterStop(this);
|
554
822
|
}
|
555
823
|
|
824
|
+
console.log('✅ Server stopped successfully');
|
825
|
+
|
556
826
|
} catch (error) {
|
557
827
|
console.error('❌ Error stopping server:', error);
|
828
|
+
// Even if there's an error, reset the state
|
829
|
+
this.isRunning = false;
|
830
|
+
this.server = undefined;
|
831
|
+
this.serverPort = undefined;
|
832
|
+
this.serverHostname = undefined;
|
558
833
|
throw error;
|
559
834
|
}
|
560
835
|
}
|
561
836
|
|
562
|
-
async restart(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
563
|
-
try {
|
564
|
-
// Before restart hook
|
565
|
-
if (this.hooks.onBeforeRestart) {
|
566
|
-
await this.hooks.onBeforeRestart();
|
567
|
-
}
|
568
|
-
|
569
|
-
console.log('🔄 Restarting BXO server...');
|
570
837
|
|
571
|
-
await this.stop();
|
572
|
-
|
573
|
-
// Small delay to ensure cleanup
|
574
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
575
|
-
|
576
|
-
await this.start(port, hostname);
|
577
|
-
|
578
|
-
// After restart hook
|
579
|
-
if (this.hooks.onAfterRestart) {
|
580
|
-
await this.hooks.onAfterRestart();
|
581
|
-
}
|
582
|
-
|
583
|
-
} catch (error) {
|
584
|
-
console.error('❌ Error restarting server:', error);
|
585
|
-
throw error;
|
586
|
-
}
|
587
|
-
}
|
588
838
|
|
589
839
|
// Backward compatibility
|
590
840
|
async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
@@ -593,51 +843,138 @@ export default class BXO {
|
|
593
843
|
|
594
844
|
// Server status
|
595
845
|
isServerRunning(): boolean {
|
596
|
-
return this.isRunning;
|
846
|
+
return this.isRunning && this.server !== undefined;
|
597
847
|
}
|
598
848
|
|
599
|
-
getServerInfo(): { running: boolean
|
849
|
+
getServerInfo(): { running: boolean } {
|
600
850
|
return {
|
601
|
-
running: this.isRunning
|
602
|
-
hotReload: this.hotReloadEnabled,
|
603
|
-
watchedFiles: Array.from(this.watchedFiles),
|
604
|
-
excludePatterns: Array.from(this.watchedExclude)
|
851
|
+
running: this.isRunning
|
605
852
|
};
|
606
853
|
}
|
607
854
|
|
608
855
|
// Get server information (alias for getServerInfo)
|
609
856
|
get info() {
|
857
|
+
// Calculate total routes including plugins
|
858
|
+
const totalRoutes = this._routes.length + this.plugins.reduce((total, plugin) => total + plugin._routes.length, 0);
|
859
|
+
const totalWsRoutes = this._wsRoutes.length + this.plugins.reduce((total, plugin) => total + plugin._wsRoutes.length, 0);
|
860
|
+
|
610
861
|
return {
|
611
|
-
|
612
|
-
|
862
|
+
// Server status
|
863
|
+
running: this.isRunning,
|
864
|
+
server: this.server ? 'Bun' : null,
|
865
|
+
|
866
|
+
// Connection details
|
867
|
+
hostname: this.serverHostname,
|
868
|
+
port: this.serverPort,
|
869
|
+
url: this.isRunning && this.serverHostname && this.serverPort
|
870
|
+
? `http://${this.serverHostname}:${this.serverPort}`
|
871
|
+
: null,
|
872
|
+
|
873
|
+
// Application statistics
|
874
|
+
totalRoutes,
|
875
|
+
totalWsRoutes,
|
613
876
|
totalPlugins: this.plugins.length,
|
614
|
-
|
877
|
+
|
878
|
+
// System information
|
879
|
+
runtime: 'Bun',
|
880
|
+
version: typeof Bun !== 'undefined' ? Bun.version : 'unknown',
|
881
|
+
pid: process.pid,
|
882
|
+
uptime: this.isRunning ? process.uptime() : 0
|
615
883
|
};
|
616
884
|
}
|
617
885
|
|
618
886
|
// Get all routes information
|
619
887
|
get routes() {
|
620
|
-
|
888
|
+
// Get routes from main instance
|
889
|
+
const mainRoutes = this._routes.map((route: Route) => ({
|
621
890
|
method: route.method,
|
622
891
|
path: route.path,
|
623
892
|
hasConfig: !!route.config,
|
624
|
-
config: route.config
|
625
|
-
|
626
|
-
hasQuery: !!route.config.query,
|
627
|
-
hasBody: !!route.config.body,
|
628
|
-
hasHeaders: !!route.config.headers,
|
629
|
-
hasResponse: !!route.config.response
|
630
|
-
} : null
|
893
|
+
config: route.config || null,
|
894
|
+
source: 'main' as const
|
631
895
|
}));
|
896
|
+
|
897
|
+
// Get routes from all plugins
|
898
|
+
const pluginRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
899
|
+
plugin._routes.map((route: Route) => ({
|
900
|
+
method: route.method,
|
901
|
+
path: route.path,
|
902
|
+
hasConfig: !!route.config,
|
903
|
+
config: route.config || null,
|
904
|
+
source: 'plugin' as const,
|
905
|
+
pluginIndex
|
906
|
+
}))
|
907
|
+
);
|
908
|
+
|
909
|
+
return [...mainRoutes, ...pluginRoutes];
|
632
910
|
}
|
911
|
+
|
912
|
+
// Get all WebSocket routes information
|
913
|
+
get wsRoutes() {
|
914
|
+
// Get WebSocket routes from main instance
|
915
|
+
const mainWsRoutes = this._wsRoutes.map((route: WSRoute) => ({
|
916
|
+
path: route.path,
|
917
|
+
hasHandlers: {
|
918
|
+
onOpen: !!route.handler.onOpen,
|
919
|
+
onMessage: !!route.handler.onMessage,
|
920
|
+
onClose: !!route.handler.onClose,
|
921
|
+
onError: !!route.handler.onError
|
922
|
+
},
|
923
|
+
source: 'main' as const
|
924
|
+
}));
|
925
|
+
|
926
|
+
// Get WebSocket routes from all plugins
|
927
|
+
const pluginWsRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
|
928
|
+
plugin._wsRoutes.map((route: WSRoute) => ({
|
929
|
+
path: route.path,
|
930
|
+
hasHandlers: {
|
931
|
+
onOpen: !!route.handler.onOpen,
|
932
|
+
onMessage: !!route.handler.onMessage,
|
933
|
+
onClose: !!route.handler.onClose,
|
934
|
+
onError: !!route.handler.onError
|
935
|
+
},
|
936
|
+
source: 'plugin' as const,
|
937
|
+
pluginIndex
|
938
|
+
}))
|
939
|
+
);
|
940
|
+
|
941
|
+
return [...mainWsRoutes, ...pluginWsRoutes];
|
942
|
+
}
|
943
|
+
}
|
944
|
+
|
945
|
+
const error = (error: Error | string, status: number = 500) => {
|
946
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : error }), { status });
|
633
947
|
}
|
634
948
|
|
635
|
-
|
636
|
-
|
949
|
+
// File helper function (like Elysia)
|
950
|
+
const file = (path: string, options?: { type?: string; headers?: Record<string, string> }) => {
|
951
|
+
const bunFile = Bun.file(path);
|
952
|
+
|
953
|
+
if (options?.type) {
|
954
|
+
// Create a wrapper to override the MIME type
|
955
|
+
return {
|
956
|
+
...bunFile,
|
957
|
+
type: options.type,
|
958
|
+
headers: options.headers
|
959
|
+
};
|
960
|
+
}
|
961
|
+
|
962
|
+
return bunFile;
|
637
963
|
}
|
638
964
|
|
639
965
|
// Export Zod for convenience
|
640
|
-
export { z, error };
|
966
|
+
export { z, error, file };
|
641
967
|
|
642
968
|
// Export types for external use
|
643
|
-
export type { RouteConfig, Handler };
|
969
|
+
export type { RouteConfig, RouteDetail, Handler, WebSocketHandler, WSRoute, Cookie };
|
970
|
+
|
971
|
+
// Helper function to create a cookie
|
972
|
+
export const createCookie = (
|
973
|
+
name: string,
|
974
|
+
value: string,
|
975
|
+
options: Omit<Cookie, 'name' | 'value'> = {}
|
976
|
+
): Cookie => ({
|
977
|
+
name,
|
978
|
+
value,
|
979
|
+
...options
|
980
|
+
});
|