bxo 0.0.5-dev.76 → 0.0.5-dev.78

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/README.md CHANGED
@@ -5,6 +5,7 @@ A fast, lightweight web framework for Bun with built-in Zod validation and lifec
5
5
  ## Features
6
6
 
7
7
  - **Type-safe routing** with Zod schema validation
8
+ - **WebSocket support** with clean API
8
9
  - **Lifecycle hooks** for middleware and plugins
9
10
  - **Plugin system** for extending functionality
10
11
  - **Built-in CORS support** via plugin
@@ -33,6 +34,76 @@ app.get("/", (ctx) => ctx.json({ message: "Hello World!" }));
33
34
  app.start();
34
35
  ```
35
36
 
37
+ ## WebSocket Support
38
+
39
+ BXO provides built-in WebSocket support with a clean, intuitive API:
40
+
41
+ ```typescript
42
+ import BXO from "./src";
43
+
44
+ const app = new BXO();
45
+
46
+ // WebSocket route
47
+ app.ws("/ws", {
48
+ open(ws) {
49
+ console.log("WebSocket connection opened");
50
+ ws.send("Welcome to BXO WebSocket!");
51
+ },
52
+
53
+ message(ws, message) {
54
+ console.log("Received message:", message);
55
+ // Echo the message back
56
+ ws.send(`Echo: ${message}`);
57
+ },
58
+
59
+ close(ws, code, reason) {
60
+ console.log(`WebSocket connection closed: ${code} ${reason}`);
61
+ },
62
+
63
+ ping(ws, data) {
64
+ console.log("Ping received:", data);
65
+ },
66
+
67
+ pong(ws, data) {
68
+ console.log("Pong received:", data);
69
+ }
70
+ });
71
+
72
+ // WebSocket with path parameters
73
+ app.ws("/chat/:room", {
74
+ open(ws) {
75
+ const room = ws.data?.room || 'unknown';
76
+ console.log(`WebSocket connection opened for room: ${room}`);
77
+ ws.send(`Welcome to chat room: ${room}`);
78
+ },
79
+
80
+ message(ws, message) {
81
+ const room = ws.data?.room || 'unknown';
82
+ console.log(`Message in room ${room}:`, message);
83
+ ws.send(`[${room}] Echo: ${message}`);
84
+ }
85
+ });
86
+
87
+ app.start();
88
+ ```
89
+
90
+ ### WebSocket Handler Events
91
+
92
+ - `open(ws)` - Called when a WebSocket connection is established
93
+ - `message(ws, message)` - Called when a message is received
94
+ - `close(ws, code, reason)` - Called when the connection is closed
95
+ - `drain(ws)` - Called when the WebSocket is ready for more data
96
+ - `ping(ws, data)` - Called when a ping is received
97
+ - `pong(ws, data)` - Called when a pong is received
98
+
99
+ ### WebSocket Features
100
+
101
+ - **Path parameters** - Support for dynamic routes like `/chat/:room`
102
+ - **Automatic upgrade** - HTTP requests to WebSocket routes are automatically upgraded
103
+ - **Type safety** - Full TypeScript support with proper typing
104
+ - **Error handling** - Built-in error handling for WebSocket events
105
+ - **Data attachment** - Access to path information via `ws.data`
106
+
36
107
  ## Lifecycle Hooks
37
108
 
38
109
  BXO provides powerful lifecycle hooks that allow you to intercept and modify requests and responses at different stages:
@@ -220,6 +291,7 @@ Check out the `example/` directory for more usage examples:
220
291
 
221
292
  - `cors-example.ts` - Demonstrates CORS plugin and lifecycle hooks
222
293
  - `openapi-example.ts` - Demonstrates OpenAPI plugin with tags and security
294
+ - `websocket-example.ts` - Demonstrates WebSocket functionality with interactive HTML client
223
295
  - `index.ts` - Basic routing example
224
296
 
225
297
  This project was created using `bun init` in bun v1.2.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,81 @@
1
+ import BXO from "../src";
2
+
3
+ async function main() {
4
+ const app = new BXO({ serve: { port: 3000 } });
5
+
6
+ // Route with space in the path
7
+ app.get("/api/resources/Workspace Item", (ctx) => {
8
+ return ctx.json({
9
+ message: "Found Workspace Item resource!",
10
+ path: ctx.request.url,
11
+ pathname: new URL(ctx.request.url).pathname
12
+ });
13
+ });
14
+
15
+ // Route with URL-encoded space
16
+ app.get("/api/resources/Workspace%20Item", (ctx) => {
17
+ return ctx.json({
18
+ message: "Found URL-encoded Workspace Item resource!",
19
+ path: ctx.request.url,
20
+ pathname: new URL(ctx.request.url).pathname
21
+ });
22
+ });
23
+
24
+ // Route with path parameter (recommended approach)
25
+ app.get("/api/resources/:resourceType", (ctx) => {
26
+ return ctx.json({
27
+ message: `Found resource type: ${ctx.params.resourceType}`,
28
+ path: ctx.request.url,
29
+ pathname: new URL(ctx.request.url).pathname,
30
+ params: ctx.params
31
+ });
32
+ });
33
+
34
+ // Test route to show the difference
35
+ app.get("/test", (ctx) => {
36
+ return ctx.text(`
37
+ <!DOCTYPE html>
38
+ <html>
39
+ <head>
40
+ <title>URL Encoding Test</title>
41
+ </head>
42
+ <body>
43
+ <h1>URL Encoding Test</h1>
44
+ <p>Test the following URLs:</p>
45
+ <ul>
46
+ <li><a href="/api/resources/Workspace Item">/api/resources/Workspace Item</a> (with space)</li>
47
+ <li><a href="/api/resources/Workspace%20Item">/api/resources/Workspace%20Item</a> (URL encoded)</li>
48
+ <li><a href="/api/resources/My%20Resource">/api/resources/My%20Resource</a> (URL encoded with params)</li>
49
+ </ul>
50
+
51
+ <h2>Test with JavaScript fetch:</h2>
52
+ <button onclick="testFetch('/api/resources/Workspace Item')">Test with space</button>
53
+ <button onclick="testFetch('/api/resources/Workspace%20Item')">Test URL encoded</button>
54
+ <button onclick="testFetch('/api/resources/My%20Resource')">Test with params</button>
55
+
56
+ <div id="result"></div>
57
+
58
+ <script>
59
+ async function testFetch(url) {
60
+ try {
61
+ const response = await fetch(url);
62
+ const data = await response.json();
63
+ document.getElementById('result').innerHTML =
64
+ '<h3>Result:</h3><pre>' + JSON.stringify(data, null, 2) + '</pre>';
65
+ } catch (error) {
66
+ document.getElementById('result').innerHTML =
67
+ '<h3>Error:</h3><pre>' + error.message + '</pre>';
68
+ }
69
+ }
70
+ </script>
71
+ `, 200, {
72
+ "Content-Type": "text/html"
73
+ });
74
+ });
75
+
76
+ app.start();
77
+ console.log(`Server is running on http://localhost:${app.server?.port}`);
78
+ console.log(`Test URL encoding at http://localhost:${app.server?.port}/test`);
79
+ }
80
+
81
+ main().catch(console.error);
@@ -0,0 +1,132 @@
1
+ import BXO from "../src";
2
+
3
+ async function main() {
4
+ const app = new BXO({ serve: { port: 3000 } });
5
+
6
+ // HTTP routes
7
+ app.get("/", (ctx) => {
8
+ return ctx.text(`
9
+ <!DOCTYPE html>
10
+ <html>
11
+ <head>
12
+ <title>BXO WebSocket Example</title>
13
+ </head>
14
+ <body>
15
+ <h1>BXO WebSocket Example</h1>
16
+ <div id="messages"></div>
17
+ <input type="text" id="messageInput" placeholder="Type a message...">
18
+ <button onclick="sendMessage()">Send</button>
19
+ <button onclick="connect()">Connect</button>
20
+ <button onclick="disconnect()">Disconnect</button>
21
+
22
+ <script>
23
+ let ws = null;
24
+
25
+ function connect() {
26
+ ws = new WebSocket('ws://localhost:3000/ws');
27
+
28
+ ws.onopen = function() {
29
+ addMessage('Connected to WebSocket');
30
+ };
31
+
32
+ ws.onmessage = function(event) {
33
+ addMessage('Received: ' + event.data);
34
+ };
35
+
36
+ ws.onclose = function() {
37
+ addMessage('Disconnected from WebSocket');
38
+ };
39
+
40
+ ws.onerror = function(error) {
41
+ addMessage('Error: ' + error);
42
+ };
43
+ }
44
+
45
+ function disconnect() {
46
+ if (ws) {
47
+ ws.close();
48
+ ws = null;
49
+ }
50
+ }
51
+
52
+ function sendMessage() {
53
+ const input = document.getElementById('messageInput');
54
+ if (ws && input.value) {
55
+ ws.send(input.value);
56
+ addMessage('Sent: ' + input.value);
57
+ input.value = '';
58
+ }
59
+ }
60
+
61
+ function addMessage(message) {
62
+ const messages = document.getElementById('messages');
63
+ const div = document.createElement('div');
64
+ div.textContent = new Date().toLocaleTimeString() + ': ' + message;
65
+ messages.appendChild(div);
66
+ messages.scrollTop = messages.scrollHeight;
67
+ }
68
+
69
+ // Allow Enter key to send message
70
+ document.getElementById('messageInput').addEventListener('keypress', function(e) {
71
+ if (e.key === 'Enter') {
72
+ sendMessage();
73
+ }
74
+ });
75
+ </script>
76
+ `, 200, {
77
+ "Content-Type": "text/html"
78
+ });
79
+ });
80
+
81
+ // WebSocket route
82
+ app.ws("/ws", {
83
+ open(ws) {
84
+ console.log("WebSocket connection opened");
85
+ ws.send("Welcome to BXO WebSocket!");
86
+ },
87
+
88
+ message(ws, message) {
89
+ console.log("Received message:", message);
90
+ // Echo the message back
91
+ ws.send(`Echo: ${message}`);
92
+ },
93
+
94
+ close(ws, code, reason) {
95
+ console.log(`WebSocket connection closed: ${code} ${reason}`);
96
+ },
97
+
98
+ ping(ws, data) {
99
+ console.log("Ping received:", data);
100
+ },
101
+
102
+ pong(ws, data) {
103
+ console.log("Pong received:", data);
104
+ }
105
+ });
106
+
107
+ // Another WebSocket route with parameters
108
+ app.ws("/chat/:room", {
109
+ open(ws) {
110
+ console.log(`WebSocket connection opened for room: ${ws.data?.room || 'unknown'}`);
111
+ ws.send(`Welcome to chat room: ${ws.data?.room || 'unknown'}`);
112
+ },
113
+
114
+ message(ws, message) {
115
+ const room = ws.data?.room || 'unknown';
116
+ console.log(`Message in room ${room}:`, message);
117
+ ws.send(`[${room}] Echo: ${message}`);
118
+ },
119
+
120
+ close(ws, code, reason) {
121
+ const room = ws.data?.room || 'unknown';
122
+ console.log(`WebSocket connection closed for room ${room}: ${code} ${reason}`);
123
+ }
124
+ });
125
+
126
+ app.start();
127
+ console.log(`Server is running on http://localhost:${app.server?.port}`);
128
+ console.log(`WebSocket available at ws://localhost:${app.server?.port}/ws`);
129
+ console.log(`Chat WebSocket available at ws://localhost:${app.server?.port}/chat/:room`);
130
+ }
131
+
132
+ main().catch(console.error);
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  ".": "./src/index.ts",
6
6
  "./plugins": "./plugins/index.ts"
7
7
  },
8
- "version": "0.0.5-dev.76",
8
+ "version": "0.0.5-dev.78",
9
9
  "type": "module",
10
10
  "devDependencies": {
11
11
  "@types/bun": "latest"
package/src/index.ts CHANGED
@@ -7,7 +7,8 @@ type Method =
7
7
  | "PATCH"
8
8
  | "DELETE"
9
9
  | "OPTIONS"
10
- | "HEAD";
10
+ | "HEAD"
11
+ | "WS";
11
12
 
12
13
  type PickWildcardName<S extends string> = S extends "" ? "wildcard" : S;
13
14
 
@@ -89,6 +90,16 @@ type Handler<P extends string, S extends RouteSchema | undefined = undefined> =
89
90
  app: BXO
90
91
  ) => Response | string | Promise<Response | string>;
91
92
 
93
+ // WebSocket handler types
94
+ export type WebSocketHandler<T = any> = {
95
+ message?(ws: Bun.ServerWebSocket<T>, message: string | Buffer): void | Promise<void>;
96
+ open?(ws: Bun.ServerWebSocket<T>): void | Promise<void>;
97
+ close?(ws: Bun.ServerWebSocket<T>, code: number, reason: string): void | Promise<void>;
98
+ drain?(ws: Bun.ServerWebSocket<T>): void | Promise<void>;
99
+ ping?(ws: Bun.ServerWebSocket<T>, data: Buffer): void | Promise<void>;
100
+ pong?(ws: Bun.ServerWebSocket<T>, data: Buffer): void | Promise<void>;
101
+ };
102
+
92
103
  type InternalRoute = {
93
104
  method: Method | "DEFAULT";
94
105
  path: string;
@@ -96,6 +107,7 @@ type InternalRoute = {
96
107
  paramNames: string[];
97
108
  schema?: RouteSchema;
98
109
  handler: AnyHandler;
110
+ websocketHandler?: WebSocketHandler;
99
111
  };
100
112
 
101
113
  type ServeOptions = Partial<Parameters<typeof Bun.serve>[0]>;
@@ -305,6 +317,10 @@ export default class BXO {
305
317
  return this.add("DELETE", path, handler as AnyHandler, schema as RouteSchema | undefined);
306
318
  }
307
319
 
320
+ ws<P extends string>(path: P, handler: WebSocketHandler): this {
321
+ return this.addWebSocket("WS", path, handler);
322
+ }
323
+
308
324
  // default can accept a handler OR static content (including Bun HTML bundle)
309
325
  default<P extends string>(path: P, handler: Handler<P, undefined>): this;
310
326
  default<P extends string, S extends RouteSchema>(path: P, handler: (req: Request) => Response, schema: S): this;
@@ -314,6 +330,9 @@ export default class BXO {
314
330
  }
315
331
 
316
332
  start(): void {
333
+ // Check if we have any WebSocket routes
334
+ const hasWebSocketRoutes = this.routes.some(r => r.method === "WS");
335
+
317
336
  // Build a basic routes map for Bun's native routes (exact paths only)
318
337
  const nativeRoutes: Record<string, Record<string, (req: Request) => Promise<Response> | Response>> = {};
319
338
 
@@ -322,6 +341,9 @@ export default class BXO {
322
341
  case "DEFAULT":
323
342
  nativeRoutes[r.path] = r.handler as any;
324
343
  break;
344
+ case "WS":
345
+ // Skip WebSocket routes in native routes - they'll be handled in websocket config
346
+ break;
325
347
  default:
326
348
  nativeRoutes[r.path] ||= {} as Record<string, (req: Request) => Promise<Response> | Response>;
327
349
  nativeRoutes[r.path][r.method] = (req: Request) => this.dispatch(r, req);
@@ -331,13 +353,61 @@ export default class BXO {
331
353
 
332
354
  this.serveOptions.port = this.serveOptions.port === undefined ? 3000 : this.serveOptions.port;
333
355
 
334
- this.server = Bun.serve({
335
- ...this.serveOptions,
336
- routes: nativeRoutes as any,
337
- fetch: (req: Request) => {
338
- return new Response("Not Found", { status: 404 });
356
+ // Create WebSocket configuration if we have WebSocket routes
357
+ const websocketConfig = hasWebSocketRoutes ? {
358
+ message: (ws: Bun.ServerWebSocket<{ path: string }>, message: string | Buffer) => {
359
+ this.handleWebSocketMessage(ws, message);
360
+ },
361
+ open: (ws: Bun.ServerWebSocket<{ path: string }>) => {
362
+ this.handleWebSocketOpen(ws);
363
+ },
364
+ close: (ws: Bun.ServerWebSocket<{ path: string }>, code: number, reason: string) => {
365
+ this.handleWebSocketClose(ws, code, reason);
366
+ },
367
+ drain: (ws: Bun.ServerWebSocket<{ path: string }>) => {
368
+ this.handleWebSocketDrain(ws);
369
+ },
370
+ ping: (ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer) => {
371
+ this.handleWebSocketPing(ws, data);
372
+ },
373
+ pong: (ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer) => {
374
+ this.handleWebSocketPong(ws, data);
339
375
  }
340
- });
376
+ } : undefined;
377
+
378
+ if (hasWebSocketRoutes) {
379
+ this.server = Bun.serve({
380
+ ...this.serveOptions,
381
+ routes: nativeRoutes as any,
382
+ websocket: websocketConfig as any,
383
+ fetch: (req: Request, server: Bun.Server) => {
384
+ // Handle WebSocket upgrade requests
385
+ if (req.headers.get("upgrade") === "websocket") {
386
+ const url = new URL(req.url);
387
+ const wsRoute = this.findWebSocketRoute(url.pathname);
388
+ if (wsRoute) {
389
+ const success = server.upgrade(req, {
390
+ data: { path: url.pathname }
391
+ });
392
+ if (success) {
393
+ return; // WebSocket upgrade successful
394
+ }
395
+ }
396
+ }
397
+
398
+ // Handle regular HTTP requests
399
+ return this.dispatchAny(req, nativeRoutes);
400
+ }
401
+ } as any);
402
+ } else {
403
+ this.server = Bun.serve({
404
+ ...this.serveOptions,
405
+ routes: nativeRoutes as any,
406
+ fetch: (req: Request) => {
407
+ return this.dispatchAny(req, nativeRoutes);
408
+ }
409
+ });
410
+ }
341
411
  }
342
412
 
343
413
  // Lifecycle hook methods
@@ -361,6 +431,93 @@ export default class BXO {
361
431
  return this;
362
432
  }
363
433
 
434
+ // WebSocket handler methods
435
+ private findWebSocketRoute(pathname: string): InternalRoute | null {
436
+ for (const route of this.routes) {
437
+ if (route.method === "WS") {
438
+ if (route.matcher === null) {
439
+ // Exact match
440
+ if (route.path === pathname) {
441
+ return route;
442
+ }
443
+ } else {
444
+ // Pattern match
445
+ const match = pathname.match(route.matcher);
446
+ if (match) {
447
+ return route;
448
+ }
449
+ }
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+
455
+ private handleWebSocketMessage(ws: Bun.ServerWebSocket<{ path: string }>, message: string | Buffer): void {
456
+ const route = this.findWebSocketRoute(ws.data?.path || "");
457
+ if (route?.websocketHandler?.message) {
458
+ try {
459
+ (route.websocketHandler as any).message(ws, message);
460
+ } catch (error) {
461
+ console.error("WebSocket message handler error:", error);
462
+ }
463
+ }
464
+ }
465
+
466
+ private handleWebSocketOpen(ws: Bun.ServerWebSocket<{ path: string }>): void {
467
+ const route = this.findWebSocketRoute(ws.data?.path || "");
468
+ if (route?.websocketHandler?.open) {
469
+ try {
470
+ (route.websocketHandler as any).open(ws);
471
+ } catch (error) {
472
+ console.error("WebSocket open handler error:", error);
473
+ }
474
+ }
475
+ }
476
+
477
+ private handleWebSocketClose(ws: Bun.ServerWebSocket<{ path: string }>, code: number, reason: string): void {
478
+ const route = this.findWebSocketRoute(ws.data?.path || "");
479
+ if (route?.websocketHandler?.close) {
480
+ try {
481
+ (route.websocketHandler as any).close(ws, code, reason);
482
+ } catch (error) {
483
+ console.error("WebSocket close handler error:", error);
484
+ }
485
+ }
486
+ }
487
+
488
+ private handleWebSocketDrain(ws: Bun.ServerWebSocket<{ path: string }>): void {
489
+ const route = this.findWebSocketRoute(ws.data?.path || "");
490
+ if (route?.websocketHandler?.drain) {
491
+ try {
492
+ (route.websocketHandler as any).drain(ws);
493
+ } catch (error) {
494
+ console.error("WebSocket drain handler error:", error);
495
+ }
496
+ }
497
+ }
498
+
499
+ private handleWebSocketPing(ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer): void {
500
+ const route = this.findWebSocketRoute(ws.data?.path || "");
501
+ if (route?.websocketHandler?.ping) {
502
+ try {
503
+ (route.websocketHandler as any).ping(ws, data);
504
+ } catch (error) {
505
+ console.error("WebSocket ping handler error:", error);
506
+ }
507
+ }
508
+ }
509
+
510
+ private handleWebSocketPong(ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer): void {
511
+ const route = this.findWebSocketRoute(ws.data?.path || "");
512
+ if (route?.websocketHandler?.pong) {
513
+ try {
514
+ (route.websocketHandler as any).pong(ws, data);
515
+ } catch (error) {
516
+ console.error("WebSocket pong handler error:", error);
517
+ }
518
+ }
519
+ }
520
+
364
521
  // Internal
365
522
  private add(method: Method | "DEFAULT", path: string, handler: AnyHandler, schema?: RouteSchema): this {
366
523
  const { regex, names } = buildMatcher(path);
@@ -368,6 +525,12 @@ export default class BXO {
368
525
  return this;
369
526
  }
370
527
 
528
+ private addWebSocket(method: "WS", path: string, handler: WebSocketHandler): this {
529
+ const { regex, names } = buildMatcher(path);
530
+ this.routes.push({ method, path, handler: () => new Response("WebSocket route", { status: 400 }), matcher: regex, paramNames: names, websocketHandler: handler });
531
+ return this;
532
+ }
533
+
371
534
  private async dispatch(route: InternalRoute, req: Request): Promise<Response> {
372
535
  // Run beforeRequest hooks
373
536
  for (const hook of this.beforeRequestHooks) {
@@ -636,6 +799,16 @@ export default class BXO {
636
799
  if (h) return await h(req);
637
800
  }
638
801
 
802
+ // 1.5) Try URL-decoded pathname for exact matches
803
+ const decodedPathname = decodeURIComponent(url.pathname);
804
+ if (decodedPathname !== url.pathname) {
805
+ const exactDecoded = nativeRoutes[decodedPathname];
806
+ if (exactDecoded) {
807
+ const h = exactDecoded[method] || exactDecoded["DEFAULT"];
808
+ if (h) return await h(req);
809
+ }
810
+ }
811
+
639
812
  // 2) Fallback to our matcher list
640
813
  for (const r of this.routes) {
641
814
  if (r.matcher === null) continue; // exact paths handled above
@@ -644,6 +817,16 @@ export default class BXO {
644
817
  if (m) return this.dispatch(r, req);
645
818
  }
646
819
 
820
+ // 2.5) Try URL-decoded pathname for pattern matches
821
+ if (decodedPathname !== url.pathname) {
822
+ for (const r of this.routes) {
823
+ if (r.matcher === null) continue; // exact paths handled above
824
+ if (r.method !== method && r.method !== "DEFAULT") continue;
825
+ const m = decodedPathname.match(r.matcher);
826
+ if (m) return this.dispatch(r, req);
827
+ }
828
+ }
829
+
647
830
  // Create 404 response
648
831
  let notFoundResponse = new Response("Not Found", { status: 404 });
649
832