bxo 0.0.5-dev.3 → 0.0.5-dev.31

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