bxo 0.0.5-dev.3 → 0.0.5-dev.30

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,56 +554,136 @@ 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
 
330
636
  // Execute route handler
331
637
  let response = await route.handler(ctx);
332
638
 
639
+ // If handler returned a Response, expose its status and headers to hooks
640
+ if (response instanceof Response) {
641
+ if (ctx.set.status === undefined) {
642
+ ctx.set.status = response.status;
643
+ }
644
+ const existingHeaders = Object.fromEntries(response.headers);
645
+ ctx.set.headers = { ...(existingHeaders || {}), ...(ctx.set.headers || {}) };
646
+ }
647
+
333
648
  // Run global onResponse hook
334
649
  if (this.hooks.onResponse) {
335
- response = await this.hooks.onResponse(ctx, response) || response;
650
+ response = await this.hooks.onResponse(ctx, response, this) || response;
336
651
  }
337
652
 
338
653
  // Run BXO instance onResponse hooks
339
654
  for (const bxoInstance of this.plugins) {
340
655
  if (bxoInstance.hooks.onResponse) {
341
- response = await bxoInstance.hooks.onResponse(ctx, response) || response;
656
+ response = await bxoInstance.hooks.onResponse(ctx, response, this) || response;
342
657
  }
343
658
  }
344
659
 
345
660
  // Validate response against schema if provided
346
- if (route.config?.response && !(response instanceof Response)) {
661
+ if (this.enableValidation && route.config?.response && !(response instanceof Response)) {
347
662
  try {
348
- response = this.validateData(route.config.response, response);
663
+ const status = ctx.set.status || 200;
664
+ response = this.validateResponse(route.config.response, response, status);
349
665
  } catch (validationError) {
350
666
  // 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}` }), {
667
+
668
+ // Extract detailed validation errors from Zod
669
+ let validationDetails = undefined;
670
+ if (validationError instanceof Error) {
671
+ if ('errors' in validationError && Array.isArray(validationError.errors)) {
672
+ validationDetails = validationError.errors;
673
+ } else if ('issues' in validationError && Array.isArray(validationError.issues)) {
674
+ validationDetails = validationError.issues;
675
+ }
676
+ }
677
+
678
+ // Create a clean error message
679
+ const errorMessage = validationDetails && validationDetails.length > 0
680
+ ? `Response validation failed for ${validationDetails.length} field(s)`
681
+ : 'Response validation failed';
682
+
683
+ return new Response(JSON.stringify({
684
+ error: errorMessage,
685
+ details: validationDetails
686
+ }), {
353
687
  status: 500,
354
688
  headers: { 'Content-Type': 'application/json' }
355
689
  });
@@ -358,12 +692,95 @@ export default class BXO {
358
692
 
359
693
  // Convert response to Response object
360
694
  if (response instanceof Response) {
361
- return response;
695
+ // Merge existing response headers with any set via hooks
696
+ let responseHeaders: Record<string, string> = {
697
+ ...Object.fromEntries(response.headers),
698
+ ...(ctx.set.headers || {})
699
+ };
700
+
701
+ // Handle cookies if any are set
702
+ if (ctx.set.cookies && ctx.set.cookies.length > 0) {
703
+ const cookieHeaders = ctx.set.cookies.map(cookie => {
704
+ let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
705
+
706
+ if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
707
+ if (cookie.path) cookieString += `; Path=${cookie.path}`;
708
+ if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
709
+ if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
710
+ if (cookie.secure) cookieString += `; Secure`;
711
+ if (cookie.httpOnly) cookieString += `; HttpOnly`;
712
+ if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
713
+
714
+ return cookieString;
715
+ });
716
+
717
+ // Add Set-Cookie headers
718
+ cookieHeaders.forEach((cookieHeader, index) => {
719
+ responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
720
+ });
721
+ }
722
+
723
+ const mergedStatus = ctx.set.status || response.status || 200;
724
+ return new Response(response.body, { status: mergedStatus, headers: responseHeaders });
725
+ }
726
+
727
+ // Handle File response (like Elysia)
728
+ if (response instanceof File || (typeof Bun !== 'undefined' && response instanceof Bun.file('').constructor)) {
729
+ const file = response as File;
730
+ const responseInit: ResponseInit = {
731
+ status: ctx.set.status || 200,
732
+ headers: {
733
+ 'Content-Type': file.type || 'application/octet-stream',
734
+ 'Content-Length': file.size.toString(),
735
+ ...ctx.set.headers
736
+ }
737
+ };
738
+ return new Response(file, responseInit);
739
+ }
740
+
741
+ // Handle Bun.file() response
742
+ if (typeof response === 'object' && response && 'stream' in response && 'size' in response) {
743
+ const bunFile = response as any;
744
+ const responseInit: ResponseInit = {
745
+ status: ctx.set.status || 200,
746
+ headers: {
747
+ 'Content-Type': bunFile.type || 'application/octet-stream',
748
+ 'Content-Length': bunFile.size?.toString() || '',
749
+ ...ctx.set.headers,
750
+ ...(bunFile.headers || {}) // Support custom headers from file helper
751
+ }
752
+ };
753
+ return new Response(bunFile, responseInit);
754
+ }
755
+
756
+ // Prepare headers with cookies
757
+ let responseHeaders = ctx.set.headers ? { ...ctx.set.headers } : {};
758
+
759
+ // Handle cookies if any are set
760
+ if (ctx.set.cookies && ctx.set.cookies.length > 0) {
761
+ const cookieHeaders = ctx.set.cookies.map(cookie => {
762
+ let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
763
+
764
+ if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
765
+ if (cookie.path) cookieString += `; Path=${cookie.path}`;
766
+ if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
767
+ if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
768
+ if (cookie.secure) cookieString += `; Secure`;
769
+ if (cookie.httpOnly) cookieString += `; HttpOnly`;
770
+ if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
771
+
772
+ return cookieString;
773
+ });
774
+
775
+ // Add Set-Cookie headers
776
+ cookieHeaders.forEach((cookieHeader, index) => {
777
+ responseHeaders[index === 0 ? 'Set-Cookie' : `Set-Cookie-${index}`] = cookieHeader;
778
+ });
362
779
  }
363
780
 
364
781
  const responseInit: ResponseInit = {
365
782
  status: ctx.set.status || 200,
366
- headers: ctx.set.headers || {}
783
+ headers: responseHeaders
367
784
  };
368
785
 
369
786
  if (typeof response === 'string') {
@@ -383,12 +800,12 @@ export default class BXO {
383
800
  let errorResponse: any;
384
801
 
385
802
  if (this.hooks.onError) {
386
- errorResponse = await this.hooks.onError(ctx, error as Error);
803
+ errorResponse = await this.hooks.onError(ctx, error as Error, this);
387
804
  }
388
805
 
389
806
  for (const bxoInstance of this.plugins) {
390
807
  if (bxoInstance.hooks.onError) {
391
- errorResponse = await bxoInstance.hooks.onError(ctx, error as Error) || errorResponse;
808
+ errorResponse = await bxoInstance.hooks.onError(ctx, error as Error, this) || errorResponse;
392
809
  }
393
810
  }
394
811
 
@@ -411,77 +828,7 @@ export default class BXO {
411
828
  }
412
829
  }
413
830
 
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
-
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
831
 
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
832
 
486
833
  // Server management methods
487
834
  async start(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
@@ -493,29 +840,49 @@ export default class BXO {
493
840
  try {
494
841
  // Before start hook
495
842
  if (this.hooks.onBeforeStart) {
496
- await this.hooks.onBeforeStart();
843
+ await this.hooks.onBeforeStart(this);
497
844
  }
498
845
 
499
846
  this.server = Bun.serve({
500
847
  port,
501
848
  hostname,
502
- fetch: (request) => this.handleRequest(request),
849
+ fetch: (request, server) => this.handleRequest(request, server),
850
+ websocket: {
851
+ message: (ws: any, message: any) => {
852
+ const handler = ws.data?.handler;
853
+ if (handler?.onMessage) {
854
+ handler.onMessage(ws, message);
855
+ }
856
+ },
857
+ open: (ws: any) => {
858
+ const handler = ws.data?.handler;
859
+ if (handler?.onOpen) {
860
+ handler.onOpen(ws);
861
+ }
862
+ },
863
+ close: (ws: any, code?: number, reason?: string) => {
864
+ const handler = ws.data?.handler;
865
+ if (handler?.onClose) {
866
+ handler.onClose(ws, code, reason);
867
+ }
868
+ }
869
+ }
503
870
  });
504
871
 
872
+ // Verify server was created successfully
873
+ if (!this.server) {
874
+ throw new Error('Failed to create server instance');
875
+ }
876
+
505
877
  this.isRunning = true;
506
878
  this.serverPort = port;
507
879
  this.serverHostname = hostname;
508
880
 
509
- console.log(`🦊 BXO server running at http://${hostname}:${port}`);
510
-
511
881
  // After start hook
512
882
  if (this.hooks.onAfterStart) {
513
- await this.hooks.onAfterStart();
883
+ await this.hooks.onAfterStart(this);
514
884
  }
515
885
 
516
- // Setup hot reload
517
- await this.setupFileWatcher(port, hostname);
518
-
519
886
  // Handle graceful shutdown
520
887
  const shutdownHandler = async () => {
521
888
  await this.stop();
@@ -540,57 +907,49 @@ export default class BXO {
540
907
  try {
541
908
  // Before stop hook
542
909
  if (this.hooks.onBeforeStop) {
543
- await this.hooks.onBeforeStop();
910
+ await this.hooks.onBeforeStop(this);
544
911
  }
545
912
 
546
913
  if (this.server) {
547
- this.server.stop();
548
- this.server = null;
914
+ try {
915
+ // Try to stop the server gracefully
916
+ if (typeof this.server.stop === 'function') {
917
+ this.server.stop();
918
+ } else {
919
+ console.warn('⚠️ Server stop method not available');
920
+ }
921
+ } catch (stopError) {
922
+ console.error('❌ Error calling server.stop():', stopError);
923
+ }
924
+
925
+ // Clear the server reference
926
+ this.server = undefined;
549
927
  }
550
928
 
929
+ // Reset state regardless of server.stop() success
551
930
  this.isRunning = false;
552
931
  this.serverPort = undefined;
553
932
  this.serverHostname = undefined;
554
933
 
555
- console.log('🛑 BXO server stopped');
556
-
557
934
  // After stop hook
558
935
  if (this.hooks.onAfterStop) {
559
- await this.hooks.onAfterStop();
936
+ await this.hooks.onAfterStop(this);
560
937
  }
561
938
 
939
+ console.log('✅ Server stopped successfully');
940
+
562
941
  } catch (error) {
563
942
  console.error('❌ Error stopping server:', error);
943
+ // Even if there's an error, reset the state
944
+ this.isRunning = false;
945
+ this.server = undefined;
946
+ this.serverPort = undefined;
947
+ this.serverHostname = undefined;
564
948
  throw error;
565
949
  }
566
950
  }
567
951
 
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
-
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
952
 
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
953
 
595
954
  // Backward compatibility
596
955
  async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
@@ -599,41 +958,38 @@ export default class BXO {
599
958
 
600
959
  // Server status
601
960
  isServerRunning(): boolean {
602
- return this.isRunning;
961
+ return this.isRunning && this.server !== undefined;
603
962
  }
604
963
 
605
- getServerInfo(): { running: boolean; hotReload: boolean; watchedFiles: string[]; excludePatterns: string[] } {
964
+ getServerInfo(): { running: boolean } {
606
965
  return {
607
- running: this.isRunning,
608
- hotReload: this.hotReloadEnabled,
609
- watchedFiles: Array.from(this.watchedFiles),
610
- excludePatterns: Array.from(this.watchedExclude)
966
+ running: this.isRunning
611
967
  };
612
968
  }
613
969
 
614
970
  // Get server information (alias for getServerInfo)
615
971
  get info() {
972
+ // Calculate total routes including plugins
973
+ const totalRoutes = this._routes.length + this.plugins.reduce((total, plugin) => total + plugin._routes.length, 0);
974
+ const totalWsRoutes = this._wsRoutes.length + this.plugins.reduce((total, plugin) => total + plugin._wsRoutes.length, 0);
975
+
616
976
  return {
617
977
  // Server status
618
978
  running: this.isRunning,
619
979
  server: this.server ? 'Bun' : null,
620
-
980
+
621
981
  // Connection details
622
982
  hostname: this.serverHostname,
623
983
  port: this.serverPort,
624
- url: this.isRunning && this.serverHostname && this.serverPort
625
- ? `http://${this.serverHostname}:${this.serverPort}`
984
+ url: this.isRunning && this.serverHostname && this.serverPort
985
+ ? `http://${this.serverHostname}:${this.serverPort}`
626
986
  : null,
627
-
987
+
628
988
  // Application statistics
629
- totalRoutes: this._routes.length,
989
+ totalRoutes,
990
+ totalWsRoutes,
630
991
  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
-
992
+
637
993
  // System information
638
994
  runtime: 'Bun',
639
995
  version: typeof Bun !== 'undefined' ? Bun.version : 'unknown',
@@ -644,27 +1000,96 @@ export default class BXO {
644
1000
 
645
1001
  // Get all routes information
646
1002
  get routes() {
647
- return this._routes.map((route: Route) => ({
1003
+ // Get routes from main instance
1004
+ const mainRoutes = this._routes.map((route: Route) => ({
648
1005
  method: route.method,
649
1006
  path: route.path,
650
1007
  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
1008
+ config: route.config || null,
1009
+ source: 'main' as const
658
1010
  }));
1011
+
1012
+ // Get routes from all plugins
1013
+ const pluginRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
1014
+ plugin._routes.map((route: Route) => ({
1015
+ method: route.method,
1016
+ path: route.path,
1017
+ hasConfig: !!route.config,
1018
+ config: route.config || null,
1019
+ source: 'plugin' as const,
1020
+ pluginIndex
1021
+ }))
1022
+ );
1023
+
1024
+ return [...mainRoutes, ...pluginRoutes];
1025
+ }
1026
+
1027
+ // Get all WebSocket routes information
1028
+ get wsRoutes() {
1029
+ // Get WebSocket routes from main instance
1030
+ const mainWsRoutes = this._wsRoutes.map((route: WSRoute) => ({
1031
+ path: route.path,
1032
+ hasHandlers: {
1033
+ onOpen: !!route.handler.onOpen,
1034
+ onMessage: !!route.handler.onMessage,
1035
+ onClose: !!route.handler.onClose,
1036
+ onError: !!route.handler.onError
1037
+ },
1038
+ source: 'main' as const
1039
+ }));
1040
+
1041
+ // Get WebSocket routes from all plugins
1042
+ const pluginWsRoutes = this.plugins.flatMap((plugin, pluginIndex) =>
1043
+ plugin._wsRoutes.map((route: WSRoute) => ({
1044
+ path: route.path,
1045
+ hasHandlers: {
1046
+ onOpen: !!route.handler.onOpen,
1047
+ onMessage: !!route.handler.onMessage,
1048
+ onClose: !!route.handler.onClose,
1049
+ onError: !!route.handler.onError
1050
+ },
1051
+ source: 'plugin' as const,
1052
+ pluginIndex
1053
+ }))
1054
+ );
1055
+
1056
+ return [...mainWsRoutes, ...pluginWsRoutes];
659
1057
  }
660
1058
  }
661
1059
 
662
- const error = (error: Error, status: number = 500) => {
663
- return new Response(JSON.stringify({ error: error.message }), { status });
1060
+ const error = (error: Error | string, status: number = 500) => {
1061
+ return new Response(JSON.stringify({ error: error instanceof Error ? error.message : error }), { status });
1062
+ }
1063
+
1064
+ // File helper function (like Elysia)
1065
+ const file = (path: string, options?: { type?: string; headers?: Record<string, string> }) => {
1066
+ const bunFile = Bun.file(path);
1067
+
1068
+ if (options?.type) {
1069
+ // Create a wrapper to override the MIME type
1070
+ return {
1071
+ ...bunFile,
1072
+ type: options.type,
1073
+ headers: options.headers
1074
+ };
1075
+ }
1076
+
1077
+ return bunFile;
664
1078
  }
665
1079
 
666
1080
  // Export Zod for convenience
667
- export { z, error };
1081
+ export { z, error, file };
668
1082
 
669
1083
  // Export types for external use
670
- export type { RouteConfig, Handler };
1084
+ export type { RouteConfig, RouteDetail, Handler, WebSocketHandler, WSRoute, Cookie, BXOOptions };
1085
+
1086
+ // Helper function to create a cookie
1087
+ export const createCookie = (
1088
+ name: string,
1089
+ value: string,
1090
+ options: Omit<Cookie, 'name' | 'value'> = {}
1091
+ ): Cookie => ({
1092
+ name,
1093
+ value,
1094
+ ...options
1095
+ });