bxo 0.0.5-dev.5 → 0.0.5-dev.51

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