@onebun/core 0.1.1 → 0.1.2

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
@@ -291,6 +291,170 @@ return this.error('User not found', 404);
291
291
 
292
292
  Errors thrown in controller methods are automatically caught and converted to standardized error responses.
293
293
 
294
+ ## WebSocket Gateway
295
+
296
+ OneBun provides WebSocket support with a Gateway pattern similar to NestJS, with full Socket.IO protocol compatibility.
297
+
298
+ ### Basic Gateway
299
+
300
+ ```typescript
301
+ import {
302
+ WebSocketGateway,
303
+ BaseWebSocketGateway,
304
+ OnConnect,
305
+ OnDisconnect,
306
+ OnMessage,
307
+ Client,
308
+ MessageData,
309
+ } from '@onebun/core';
310
+ import type { WsClientData } from '@onebun/core';
311
+
312
+ @WebSocketGateway({ path: '/ws' })
313
+ export class ChatGateway extends BaseWebSocketGateway {
314
+ @OnConnect()
315
+ handleConnect(@Client() client: WsClientData) {
316
+ console.log(`Client ${client.id} connected`);
317
+ return { event: 'welcome', data: { clientId: client.id } };
318
+ }
319
+
320
+ @OnDisconnect()
321
+ handleDisconnect(@Client() client: WsClientData) {
322
+ console.log(`Client ${client.id} disconnected`);
323
+ }
324
+
325
+ @OnMessage('chat:message')
326
+ handleMessage(
327
+ @Client() client: WsClientData,
328
+ @MessageData() data: { text: string }
329
+ ) {
330
+ // Broadcast to all clients
331
+ this.broadcast('chat:message', {
332
+ userId: client.id,
333
+ text: data.text,
334
+ });
335
+ }
336
+ }
337
+ ```
338
+
339
+ ### Pattern Matching
340
+
341
+ Event patterns support wildcards and named parameters:
342
+
343
+ ```typescript
344
+ // Wildcard: matches chat:general, chat:private, etc.
345
+ @OnMessage('chat:*')
346
+ handleAnyChat(@MessageData() data: unknown) {}
347
+
348
+ // Named parameter: extracts roomId
349
+ @OnMessage('chat:{roomId}:message')
350
+ handleRoomMessage(@PatternParams() params: { roomId: string }) {}
351
+ ```
352
+
353
+ ### Room Management
354
+
355
+ ```typescript
356
+ @WebSocketGateway({ path: '/ws' })
357
+ export class RoomGateway extends BaseWebSocketGateway {
358
+ @OnJoinRoom('room:{roomId}')
359
+ async handleJoin(
360
+ @Client() client: WsClientData,
361
+ @RoomName() room: string,
362
+ @PatternParams() params: { roomId: string }
363
+ ) {
364
+ await this.joinRoom(client.id, room);
365
+ this.emitToRoom(room, 'user:joined', { userId: client.id });
366
+ }
367
+
368
+ @OnLeaveRoom('room:*')
369
+ async handleLeave(@Client() client: WsClientData, @RoomName() room: string) {
370
+ await this.leaveRoom(client.id, room);
371
+ this.emitToRoom(room, 'user:left', { userId: client.id });
372
+ }
373
+ }
374
+ ```
375
+
376
+ ### Guards
377
+
378
+ Protect WebSocket handlers with guards:
379
+
380
+ ```typescript
381
+ import { UseWsGuards, WsAuthGuard, WsPermissionGuard } from '@onebun/core';
382
+
383
+ @UseWsGuards(WsAuthGuard)
384
+ @OnMessage('protected:*')
385
+ handleProtected(@Client() client: WsClientData) {
386
+ // Only authenticated clients can access
387
+ }
388
+
389
+ @UseWsGuards(new WsPermissionGuard('admin'))
390
+ @OnMessage('admin:*')
391
+ handleAdmin(@Client() client: WsClientData) {
392
+ // Only clients with 'admin' permission
393
+ }
394
+ ```
395
+
396
+ ### Typed Client
397
+
398
+ Generate a type-safe WebSocket client:
399
+
400
+ ```typescript
401
+ import { createWsServiceDefinition, createWsClient } from '@onebun/core';
402
+ import { AppModule } from './app.module';
403
+
404
+ const definition = createWsServiceDefinition(AppModule);
405
+ const client = createWsClient(definition, {
406
+ url: 'ws://localhost:3000/ws',
407
+ auth: { token: 'your-token' },
408
+ });
409
+
410
+ await client.connect();
411
+
412
+ // Type-safe event emission
413
+ await client.ChatGateway.emit('chat:message', { text: 'Hello!' });
414
+
415
+ // Subscribe to events
416
+ client.ChatGateway.on('chat:message', (data) => {
417
+ console.log('Received:', data);
418
+ });
419
+
420
+ client.disconnect();
421
+ ```
422
+
423
+ ### Storage Options
424
+
425
+ Configure client/room storage:
426
+
427
+ ```typescript
428
+ const app = new OneBunApplication(AppModule, {
429
+ websocket: {
430
+ storage: {
431
+ type: 'memory', // or 'redis' for multi-instance
432
+ redis: {
433
+ url: 'redis://localhost:6379',
434
+ prefix: 'ws:',
435
+ },
436
+ },
437
+ },
438
+ });
439
+ ```
440
+
441
+ ### Socket.IO Compatibility
442
+
443
+ OneBun WebSocket Gateway is fully compatible with `socket.io-client`:
444
+
445
+ ```typescript
446
+ import { io } from 'socket.io-client';
447
+
448
+ const socket = io('ws://localhost:3000/ws', {
449
+ auth: { token: 'your-token' },
450
+ });
451
+
452
+ socket.on('chat:message', (data) => console.log(data));
453
+ socket.emit('chat:message', { text: 'Hello!' });
454
+ ```
455
+
456
+ For complete API documentation, see [docs/api/websocket.md](../../docs/api/websocket.md).
457
+
294
458
  ## Application
295
459
 
296
460
  Create and start an OneBun application:
@@ -313,6 +477,75 @@ app.start()
313
477
 
314
478
  The application automatically creates a logger based on NODE_ENV (development or production) and handles all Effect.js calls internally.
315
479
 
480
+ ## Graceful Shutdown
481
+
482
+ OneBun supports graceful shutdown to cleanly close connections and release resources when the application stops. **Graceful shutdown is enabled by default.**
483
+
484
+ ### Default Behavior
485
+
486
+ By default, the application automatically handles SIGTERM and SIGINT signals:
487
+
488
+ ```typescript
489
+ const app = new OneBunApplication(AppModule, {
490
+ port: 3000,
491
+ // gracefulShutdown: true is the default
492
+ });
493
+
494
+ await app.start();
495
+ // Application will automatically handle shutdown signals
496
+ ```
497
+
498
+ ### Disabling Graceful Shutdown
499
+
500
+ If you need to manage shutdown manually, you can disable automatic handling:
501
+
502
+ ```typescript
503
+ const app = new OneBunApplication(AppModule, {
504
+ gracefulShutdown: false, // Disable automatic SIGTERM/SIGINT handling
505
+ });
506
+
507
+ await app.start();
508
+
509
+ // Now you must handle shutdown manually:
510
+ // Option 1: Enable signal handlers later
511
+ app.enableGracefulShutdown();
512
+
513
+ // Option 2: Stop programmatically
514
+ await app.stop();
515
+
516
+ // Option 3: Stop but keep shared Redis connection open (for other consumers)
517
+ await app.stop({ closeSharedRedis: false });
518
+ ```
519
+
520
+ ### What Gets Cleaned Up
521
+
522
+ When the application stops, the following resources are cleaned up:
523
+
524
+ 1. **HTTP Server** - Bun server is stopped
525
+ 2. **WebSocket Handler** - All WebSocket connections are closed
526
+ 3. **Shared Redis** - If using SharedRedisProvider, the connection is closed (unless `closeSharedRedis: false`)
527
+
528
+ ### Shared Redis Connection
529
+
530
+ When using `SharedRedisProvider` for cache, WebSocket storage, or other features, the connection is automatically closed on shutdown:
531
+
532
+ ```typescript
533
+ import { SharedRedisProvider, OneBunApplication } from '@onebun/core';
534
+
535
+ // Configure shared Redis at startup
536
+ SharedRedisProvider.configure({
537
+ url: 'redis://localhost:6379',
538
+ keyPrefix: 'myapp:',
539
+ });
540
+
541
+ const app = new OneBunApplication(AppModule, {
542
+ gracefulShutdown: true,
543
+ });
544
+
545
+ await app.start();
546
+ // Shared Redis will be closed when app receives SIGTERM/SIGINT
547
+ ```
548
+
316
549
  ## License
317
550
 
318
551
  [LGPL-3.0](../../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -1102,4 +1102,123 @@ describe('OneBunApplication', () => {
1102
1102
  expect(mockTraceService.extractFromHeaders).not.toHaveBeenCalled();
1103
1103
  });
1104
1104
  });
1105
+
1106
+ describe('Graceful shutdown', () => {
1107
+ let originalServe: typeof Bun.serve;
1108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1109
+ let mockServer: any;
1110
+
1111
+ beforeEach(() => {
1112
+ register.clear();
1113
+
1114
+ mockServer = {
1115
+ stop: mock(),
1116
+ hostname: 'localhost',
1117
+ port: 3000,
1118
+ };
1119
+
1120
+ originalServe = Bun.serve;
1121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1122
+ (Bun as any).serve = mock(() => mockServer);
1123
+ });
1124
+
1125
+ afterEach(() => {
1126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1127
+ (Bun as any).serve = originalServe;
1128
+ });
1129
+
1130
+ test('should stop server and cleanup resources on stop()', async () => {
1131
+ @Module({})
1132
+ class TestModule {}
1133
+
1134
+ const app = createTestApp(TestModule);
1135
+ await app.start();
1136
+
1137
+ await app.stop();
1138
+
1139
+ expect(mockServer.stop).toHaveBeenCalled();
1140
+ });
1141
+
1142
+ test('should accept closeSharedRedis option in stop()', async () => {
1143
+ @Module({})
1144
+ class TestModule {}
1145
+
1146
+ const app = createTestApp(TestModule);
1147
+ await app.start();
1148
+
1149
+ // Stop without closing Redis
1150
+ await app.stop({ closeSharedRedis: false });
1151
+
1152
+ expect(mockServer.stop).toHaveBeenCalled();
1153
+ });
1154
+
1155
+ test('should enable graceful shutdown by default', async () => {
1156
+ @Module({})
1157
+ class TestModule {}
1158
+
1159
+ // Track process.on calls
1160
+ const processOnMock = mock();
1161
+ const originalProcessOn = process.on.bind(process);
1162
+ process.on = processOnMock as typeof process.on;
1163
+
1164
+ // No gracefulShutdown option - should be enabled by default
1165
+ const app = createTestApp(TestModule);
1166
+ await app.start();
1167
+
1168
+ // Verify handlers were registered
1169
+ expect(processOnMock).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
1170
+ expect(processOnMock).toHaveBeenCalledWith('SIGINT', expect.any(Function));
1171
+
1172
+ // Cleanup
1173
+ process.on = originalProcessOn;
1174
+ await app.stop();
1175
+ });
1176
+
1177
+ test('should disable graceful shutdown when option is false', async () => {
1178
+ @Module({})
1179
+ class TestModule {}
1180
+
1181
+ // Track process.on calls
1182
+ const processOnMock = mock();
1183
+ const originalProcessOn = process.on.bind(process);
1184
+ process.on = processOnMock as typeof process.on;
1185
+
1186
+ // Explicitly disable graceful shutdown
1187
+ const app = createTestApp(TestModule, { gracefulShutdown: false });
1188
+ await app.start();
1189
+
1190
+ // Verify handlers were NOT registered
1191
+ expect(processOnMock).not.toHaveBeenCalledWith('SIGTERM', expect.any(Function));
1192
+ expect(processOnMock).not.toHaveBeenCalledWith('SIGINT', expect.any(Function));
1193
+
1194
+ // Cleanup
1195
+ process.on = originalProcessOn;
1196
+ await app.stop();
1197
+ });
1198
+
1199
+ test('should provide enableGracefulShutdown method for manual setup', async () => {
1200
+ @Module({})
1201
+ class TestModule {}
1202
+
1203
+ // Disable automatic graceful shutdown
1204
+ const app = createTestApp(TestModule, { gracefulShutdown: false });
1205
+ await app.start();
1206
+
1207
+ // Track process.on calls
1208
+ const processOnMock = mock();
1209
+ const originalProcessOn = process.on.bind(process);
1210
+ process.on = processOnMock as typeof process.on;
1211
+
1212
+ // Manually enable graceful shutdown
1213
+ app.enableGracefulShutdown();
1214
+
1215
+ // Verify handlers were registered
1216
+ expect(processOnMock).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
1217
+ expect(processOnMock).toHaveBeenCalledWith('SIGINT', expect.any(Function));
1218
+
1219
+ // Cleanup
1220
+ process.on = originalProcessOn;
1221
+ await app.stop();
1222
+ });
1223
+ });
1105
1224
  });
@@ -1,6 +1,7 @@
1
1
  import { Effect, type Layer } from 'effect';
2
2
 
3
3
  import type { Controller } from './controller';
4
+ import type { WsClientData } from './ws.types';
4
5
 
5
6
  import { TypedEnv } from '@onebun/envs';
6
7
  import {
@@ -22,6 +23,7 @@ import { makeTraceService, TraceService } from '@onebun/trace';
22
23
  import { ConfigServiceImpl } from './config.service';
23
24
  import { getControllerMetadata } from './decorators';
24
25
  import { OneBunModule } from './module';
26
+ import { SharedRedisProvider } from './shared-redis';
25
27
  import {
26
28
  type ApplicationOptions,
27
29
  type HttpMethod,
@@ -31,6 +33,7 @@ import {
31
33
  type RouteMetadata,
32
34
  } from './types';
33
35
  import { validateOrThrow } from './validation';
36
+ import { WsHandler, isWebSocketGateway } from './ws-handler';
34
37
 
35
38
  // Conditionally import metrics
36
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -75,6 +78,7 @@ export class OneBunApplication {
75
78
  private metricsService: any = null;
76
79
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
80
  private traceService: any = null;
81
+ private wsHandler: WsHandler | null = null;
78
82
 
79
83
  /**
80
84
  * Create application instance
@@ -260,6 +264,20 @@ export class OneBunApplication {
260
264
  const controllers = this.rootModule.getControllers();
261
265
  this.logger.debug(`Loaded ${controllers.length} controllers`);
262
266
 
267
+ // Initialize WebSocket handler and detect gateways
268
+ this.wsHandler = new WsHandler(this.logger, this.options.websocket);
269
+
270
+ // Register WebSocket gateways (they are in controllers array but decorated with @WebSocketGateway)
271
+ for (const controllerClass of controllers) {
272
+ if (isWebSocketGateway(controllerClass)) {
273
+ const instance = this.rootModule.getControllerInstance?.(controllerClass);
274
+ if (instance) {
275
+ this.wsHandler.registerGateway(controllerClass, instance as import('./ws-base-gateway').BaseWebSocketGateway);
276
+ this.logger.info(`Registered WebSocket gateway: ${controllerClass.name}`);
277
+ }
278
+ }
279
+ }
280
+
263
281
  // Create a map of routes with metadata
264
282
  const routes = new Map<
265
283
  string,
@@ -359,15 +377,48 @@ export class OneBunApplication {
359
377
 
360
378
  // Create server with proper context binding
361
379
  const app = this;
362
- this.server = Bun.serve({
380
+ const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
381
+
382
+ // Prepare WebSocket handlers if gateways exist
383
+ // When no gateways, use no-op handlers (required by Bun.serve)
384
+ const wsHandlers = hasWebSocketGateways ? this.wsHandler!.createWebSocketHandlers() : {
385
+
386
+ open() { /* no-op */ },
387
+
388
+ message() { /* no-op */ },
389
+
390
+ close() { /* no-op */ },
391
+
392
+ drain() { /* no-op */ },
393
+ };
394
+
395
+ this.server = Bun.serve<WsClientData>({
363
396
  port: this.options.port,
364
397
  hostname: this.options.host,
365
- async fetch(req) {
398
+ // WebSocket handlers
399
+ websocket: wsHandlers,
400
+ async fetch(req, server) {
366
401
  const url = new URL(req.url);
367
402
  const path = url.pathname;
368
403
  const method = req.method;
369
404
  const startTime = Date.now();
370
405
 
406
+ // Handle WebSocket upgrade if gateways exist
407
+ if (hasWebSocketGateways && app.wsHandler) {
408
+ const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
409
+
410
+ // Check for WebSocket upgrade or Socket.IO polling
411
+ if (upgradeHeader === 'websocket' || path.startsWith('/socket.io')) {
412
+ const response = await app.wsHandler.handleUpgrade(req, server);
413
+ if (response === undefined) {
414
+ return undefined; // Successfully upgraded
415
+ }
416
+
417
+ // Return response if upgrade failed
418
+ return response;
419
+ }
420
+ }
421
+
371
422
  // Setup tracing context if available and enabled
372
423
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
373
424
  let traceSpan: any = null;
@@ -675,6 +726,12 @@ export class OneBunApplication {
675
726
  },
676
727
  });
677
728
 
729
+ // Initialize WebSocket gateways with server
730
+ if (hasWebSocketGateways && this.wsHandler && this.server) {
731
+ this.wsHandler.initializeGateways(this.server);
732
+ this.logger.info(`WebSocket server enabled at ws://${this.options.host}:${this.options.port}`);
733
+ }
734
+
678
735
  this.logger.info(`Server started on http://${this.options.host}:${this.options.port}`);
679
736
  if (this.metricsService) {
680
737
  this.logger.info(
@@ -685,6 +742,11 @@ export class OneBunApplication {
685
742
  'Metrics enabled but @onebun/metrics module not available. Install with: bun add @onebun/metrics',
686
743
  );
687
744
  }
745
+
746
+ // Enable graceful shutdown by default (can be disabled with gracefulShutdown: false)
747
+ if (this.options.gracefulShutdown !== false) {
748
+ this.enableGracefulShutdown();
749
+ }
688
750
  } catch (error) {
689
751
  this.logger.error(
690
752
  'Failed to start application:',
@@ -910,14 +972,59 @@ export class OneBunApplication {
910
972
  }
911
973
 
912
974
  /**
913
- * Stop the application
975
+ * Stop the application with graceful shutdown
976
+ * @param options - Shutdown options
914
977
  */
915
- stop(): void {
978
+ async stop(options?: { closeSharedRedis?: boolean }): Promise<void> {
979
+ const closeRedis = options?.closeSharedRedis ?? true;
980
+
981
+ this.logger.info('Stopping OneBun application...');
982
+
983
+ // Cleanup WebSocket resources
984
+ if (this.wsHandler) {
985
+ this.logger.debug('Cleaning up WebSocket handler');
986
+ await this.wsHandler.cleanup();
987
+ this.wsHandler = null;
988
+ }
989
+
990
+ // Stop HTTP server
916
991
  if (this.server) {
917
992
  this.server.stop();
918
993
  this.server = null;
919
- this.logger.info('OneBun application stopped');
994
+ this.logger.debug('HTTP server stopped');
995
+ }
996
+
997
+ // Close shared Redis connection if configured and requested
998
+ if (closeRedis && SharedRedisProvider.isConnected()) {
999
+ this.logger.debug('Disconnecting shared Redis');
1000
+ await SharedRedisProvider.disconnect();
920
1001
  }
1002
+
1003
+ this.logger.info('OneBun application stopped');
1004
+ }
1005
+
1006
+ /**
1007
+ * Register signal handlers for graceful shutdown
1008
+ * Call this after start() to enable automatic shutdown on SIGTERM/SIGINT
1009
+ *
1010
+ * @example
1011
+ * ```typescript
1012
+ * const app = new OneBunApplication(AppModule, options);
1013
+ * await app.start();
1014
+ * app.enableGracefulShutdown();
1015
+ * ```
1016
+ */
1017
+ enableGracefulShutdown(): void {
1018
+ const shutdown = async (signal: string) => {
1019
+ this.logger.info(`Received ${signal}, initiating graceful shutdown...`);
1020
+ await this.stop();
1021
+ process.exit(0);
1022
+ };
1023
+
1024
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1025
+ process.on('SIGINT', () => shutdown('SIGINT'));
1026
+
1027
+ this.logger.debug('Graceful shutdown handlers registered');
921
1028
  }
922
1029
 
923
1030
  /**