@onebun/core 0.1.20 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -554,15 +554,16 @@ export class OneBunApplication {
554
554
  // Handle WebSocket upgrade if gateways exist
555
555
  if (hasWebSocketGateways && app.wsHandler) {
556
556
  const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
557
-
558
- // Check for WebSocket upgrade or Socket.IO polling
559
- if (upgradeHeader === 'websocket' || path.startsWith('/socket.io')) {
557
+ const socketioEnabled = app.options.websocket?.socketio?.enabled ?? false;
558
+ const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
559
+
560
+ const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
561
+ if (upgradeHeader === 'websocket' || isSocketIoPath) {
560
562
  const response = await app.wsHandler.handleUpgrade(req, server);
561
563
  if (response === undefined) {
562
564
  return undefined; // Successfully upgraded
563
565
  }
564
566
 
565
- // Return response if upgrade failed
566
567
  return response;
567
568
  }
568
569
  }
@@ -920,7 +921,16 @@ export class OneBunApplication {
920
921
  // Initialize WebSocket gateways with server
921
922
  if (hasWebSocketGateways && this.wsHandler && this.server) {
922
923
  this.wsHandler.initializeGateways(this.server);
923
- this.logger.info(`WebSocket server enabled at ws://${this.options.host}:${this.options.port}`);
924
+ this.logger.info(
925
+ `WebSocket server (native) enabled at ws://${this.options.host}:${this.options.port}`,
926
+ );
927
+ const socketioEnabled = this.options.websocket?.socketio?.enabled ?? false;
928
+ if (socketioEnabled) {
929
+ const sioPath = this.options.websocket?.socketio?.path ?? '/socket.io';
930
+ this.logger.info(
931
+ `WebSocket server (Socket.IO) enabled at ws://${this.options.host}:${this.options.port}${sioPath}`,
932
+ );
933
+ }
924
934
  }
925
935
 
926
936
  this.logger.info(`Server started on http://${this.options.host}:${this.options.port}`);
@@ -2905,6 +2905,52 @@ describe('WebSocket Gateway API Documentation (docs/api/websocket.md)', () => {
2905
2905
  expect(typeof client.disconnect).toBe('function');
2906
2906
  expect(typeof client.on).toBe('function');
2907
2907
  });
2908
+
2909
+ it('should create client with protocol native (default)', () => {
2910
+ @WebSocketGateway({ path: '/ws' })
2911
+ class WsGateway extends BaseWebSocketGateway {}
2912
+
2913
+ @Module({ controllers: [WsGateway] })
2914
+ class AppModule {}
2915
+
2916
+ const definition = createWsServiceDefinition(AppModule);
2917
+ const client = createWsClient(definition, {
2918
+ url: 'ws://localhost:3000/ws',
2919
+ protocol: 'native',
2920
+ });
2921
+ expect(client).toBeDefined();
2922
+ });
2923
+
2924
+ it('should create client with protocol socketio', () => {
2925
+ @WebSocketGateway({ path: '/ws' })
2926
+ class WsGateway extends BaseWebSocketGateway {}
2927
+
2928
+ @Module({ controllers: [WsGateway] })
2929
+ class AppModule {}
2930
+
2931
+ const definition = createWsServiceDefinition(AppModule);
2932
+ const client = createWsClient(definition, {
2933
+ url: 'ws://localhost:3000/socket.io',
2934
+ protocol: 'socketio',
2935
+ });
2936
+ expect(client).toBeDefined();
2937
+ });
2938
+
2939
+ /**
2940
+ * @source docs/api/websocket.md#standalone-client-no-definition
2941
+ */
2942
+ it('should create standalone client without definition', () => {
2943
+ const client = createNativeWsClient({
2944
+ url: 'ws://localhost:3000/chat',
2945
+ protocol: 'native',
2946
+ auth: { token: 'xxx' },
2947
+ });
2948
+ expect(client).toBeDefined();
2949
+ expect(typeof client.connect).toBe('function');
2950
+ expect(typeof client.emit).toBe('function');
2951
+ expect(typeof client.send).toBe('function');
2952
+ expect(typeof client.on).toBe('function');
2953
+ });
2908
2954
  });
2909
2955
 
2910
2956
  describe('Application Configuration', () => {
@@ -2918,7 +2964,7 @@ describe('WebSocket Gateway API Documentation (docs/api/websocket.md)', () => {
2918
2964
  @Module({ controllers: [TestGateway] })
2919
2965
  class AppModule {}
2920
2966
 
2921
- // From docs: Application Options example
2967
+ // From docs: Application Options example (native + optional Socket.IO)
2922
2968
  const app = new OneBunApplication(AppModule, {
2923
2969
  port: 3000,
2924
2970
  websocket: {
@@ -2926,8 +2972,12 @@ describe('WebSocket Gateway API Documentation (docs/api/websocket.md)', () => {
2926
2972
  storage: {
2927
2973
  type: 'memory',
2928
2974
  },
2929
- pingInterval: 25000,
2930
- pingTimeout: 20000,
2975
+ socketio: {
2976
+ enabled: true,
2977
+ path: '/socket.io',
2978
+ pingInterval: 25000,
2979
+ pingTimeout: 20000,
2980
+ },
2931
2981
  maxPayload: 1048576,
2932
2982
  },
2933
2983
  loggerLayer: makeMockLoggerLayer(),
@@ -3204,8 +3254,10 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3204
3254
  const app = new OneBunApplication(ChatModule, {
3205
3255
  port: 3000,
3206
3256
  websocket: {
3207
- pingInterval: 25000,
3208
- pingTimeout: 20000,
3257
+ socketio: {
3258
+ pingInterval: 25000,
3259
+ pingTimeout: 20000,
3260
+ },
3209
3261
  },
3210
3262
  loggerLayer: makeMockLoggerLayer(),
3211
3263
  });
@@ -3240,10 +3292,11 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3240
3292
  })
3241
3293
  class ChatModule {}
3242
3294
 
3243
- // From docs: Typed Client example
3295
+ // From docs: Typed Client (native) example
3244
3296
  const definition = createWsServiceDefinition(ChatModule);
3245
3297
  const client = createWsClient(definition, {
3246
3298
  url: 'ws://localhost:3000/chat',
3299
+ protocol: 'native',
3247
3300
  auth: {
3248
3301
  token: 'user-jwt-token',
3249
3302
  },
@@ -3323,6 +3376,7 @@ import {
3323
3376
  SharedRedisProvider,
3324
3377
  createWsServiceDefinition,
3325
3378
  createWsClient,
3379
+ createNativeWsClient,
3326
3380
  matchPattern,
3327
3381
  makeMockLoggerLayer,
3328
3382
  hasOnModuleInit,
@@ -764,24 +764,35 @@ export class OneBunModule implements ModuleInstance {
764
764
  }
765
765
 
766
766
  /**
767
- * Get controller instance (searches this module then child modules recursively).
767
+ * Find controller instance (searches this module then child modules recursively, no logging).
768
768
  */
769
- getControllerInstance(controllerClass: Function): Controller | undefined {
769
+ private findControllerInstance(controllerClass: Function): Controller | undefined {
770
770
  const instance = this.controllerInstances.get(controllerClass);
771
771
  if (instance) {
772
772
  return instance;
773
773
  }
774
774
  for (const childModule of this.childModules) {
775
- const childInstance = childModule.getControllerInstance(controllerClass);
775
+ const childInstance = childModule.findControllerInstance(controllerClass);
776
776
  if (childInstance) {
777
777
  return childInstance;
778
778
  }
779
779
  }
780
- this.logger.warn(`No instance found for controller ${controllerClass.name}`);
781
780
 
782
781
  return undefined;
783
782
  }
784
783
 
784
+ /**
785
+ * Get controller instance (searches this module then child modules recursively).
786
+ */
787
+ getControllerInstance(controllerClass: Function): Controller | undefined {
788
+ const instance = this.findControllerInstance(controllerClass);
789
+ if (!instance) {
790
+ this.logger.warn(`No instance found for controller ${controllerClass.name}`);
791
+ }
792
+
793
+ return instance;
794
+ }
795
+
785
796
  /**
786
797
  * Get all controller instances from this module and child modules (recursive).
787
798
  */
package/src/types.ts CHANGED
@@ -381,18 +381,30 @@ export interface WsStorageOptions {
381
381
  };
382
382
  }
383
383
 
384
+ /**
385
+ * Socket.IO-specific options (optional; when enabled, Socket.IO runs on its own path)
386
+ */
387
+ export interface WebSocketSocketIOOptions {
388
+ /** Enable Socket.IO protocol (default: false) */
389
+ enabled?: boolean;
390
+ /** Path for Socket.IO connections (default: '/socket.io') */
391
+ path?: string;
392
+ /** Ping interval in milliseconds (default: 25000) */
393
+ pingInterval?: number;
394
+ /** Ping timeout in milliseconds (default: 20000) */
395
+ pingTimeout?: number;
396
+ }
397
+
384
398
  /**
385
399
  * WebSocket configuration for OneBunApplication
386
400
  */
387
401
  export interface WebSocketApplicationOptions {
388
402
  /** Enable/disable WebSocket (default: auto - enabled if gateways exist) */
389
403
  enabled?: boolean;
404
+ /** Socket.IO options; when enabled, Socket.IO is served on socketio.path */
405
+ socketio?: WebSocketSocketIOOptions;
390
406
  /** Storage options */
391
407
  storage?: WsStorageOptions;
392
- /** Ping interval in milliseconds for heartbeat (socket.io) */
393
- pingInterval?: number;
394
- /** Ping timeout in milliseconds (socket.io) */
395
- pingTimeout?: number;
396
408
  /** Maximum payload size in bytes */
397
409
  maxPayload?: number;
398
410
  }
@@ -41,6 +41,7 @@ function createClientData(id: string, rooms: string[] = []): WsClientData {
41
41
  connectedAt: Date.now(),
42
42
  auth: null,
43
43
  metadata: {},
44
+ protocol: 'native',
44
45
  };
45
46
  }
46
47
 
@@ -9,11 +9,11 @@ import type { WsStorageAdapter, WsPubSubStorageAdapter } from './ws-storage';
9
9
  import type {
10
10
  WsClientData,
11
11
  WsRoom,
12
- WsMessage,
13
12
  WsServer,
14
13
  } from './ws.types';
15
14
  import type { Server, ServerWebSocket } from 'bun';
16
15
 
16
+ import { createFullEventMessage, createNativeMessage } from './ws-socketio-protocol';
17
17
  import { WsStorageEvent, isPubSubAdapter } from './ws-storage';
18
18
 
19
19
  /**
@@ -247,6 +247,16 @@ export abstract class BaseWebSocketGateway {
247
247
  }
248
248
  }
249
249
 
250
+ /**
251
+ * Encode message for client's protocol
252
+ * @internal
253
+ */
254
+ private _encodeMessage(protocol: WsClientData['protocol'], event: string, data: unknown): string {
255
+ return protocol === 'socketio'
256
+ ? createFullEventMessage(event, data ?? {})
257
+ : createNativeMessage(event, data);
258
+ }
259
+
250
260
  /**
251
261
  * Send to local client only
252
262
  * @internal
@@ -254,8 +264,7 @@ export abstract class BaseWebSocketGateway {
254
264
  private _localEmit(clientId: string, event: string, data: unknown): void {
255
265
  const socket = clientSockets.get(clientId);
256
266
  if (socket) {
257
- const message: WsMessage = { event, data };
258
- socket.send(JSON.stringify(message));
267
+ socket.send(this._encodeMessage(socket.data.protocol, event, data));
259
268
  }
260
269
  }
261
270
 
@@ -280,12 +289,11 @@ export abstract class BaseWebSocketGateway {
280
289
  * @internal
281
290
  */
282
291
  private _localBroadcast(event: string, data: unknown, excludeClientIds?: string[]): void {
283
- const message = JSON.stringify({ event, data } as WsMessage);
284
292
  const excludeSet = new Set(excludeClientIds || []);
285
293
 
286
294
  for (const [clientId, socket] of clientSockets) {
287
295
  if (!excludeSet.has(clientId)) {
288
- socket.send(message);
296
+ socket.send(this._encodeMessage(socket.data.protocol, event, data));
289
297
  }
290
298
  }
291
299
  }
@@ -323,14 +331,13 @@ export abstract class BaseWebSocketGateway {
323
331
  }
324
332
 
325
333
  const clientIds = await this.storage.getClientsInRoom(roomName);
326
- const message = JSON.stringify({ event, data } as WsMessage);
327
334
  const excludeSet = new Set(excludeClientIds || []);
328
335
 
329
336
  for (const clientId of clientIds) {
330
337
  if (!excludeSet.has(clientId)) {
331
338
  const socket = clientSockets.get(clientId);
332
339
  if (socket) {
333
- socket.send(message);
340
+ socket.send(this._encodeMessage(socket.data.protocol, event, data));
334
341
  }
335
342
  }
336
343
  }
@@ -355,15 +362,13 @@ export abstract class BaseWebSocketGateway {
355
362
  }
356
363
  }
357
364
 
358
- // Send to each unique client
359
- const message = JSON.stringify({ event, data } as WsMessage);
360
365
  const excludeSet = new Set(excludeClientIds || []);
361
366
 
362
367
  for (const clientId of clientIdsSet) {
363
368
  if (!excludeSet.has(clientId)) {
364
369
  const socket = clientSockets.get(clientId);
365
370
  if (socket) {
366
- socket.send(message);
371
+ socket.send(this._encodeMessage(socket.data.protocol, event, data));
367
372
  }
368
373
  }
369
374
  }
@@ -15,7 +15,7 @@ import type { WsServiceDefinition } from './ws-service-definition';
15
15
 
16
16
  import { useFakeTimers } from '../testing/test-utils';
17
17
 
18
- import { createWsClient } from './ws-client';
18
+ import { createWsClient, createNativeWsClient } from './ws-client';
19
19
  import { WsConnectionState } from './ws-client.types';
20
20
  import { WsHandlerType } from './ws.types';
21
21
 
@@ -114,7 +114,7 @@ describe('WsClient', () => {
114
114
  it('should create client with default options', () => {
115
115
  const definition = createMockDefinition();
116
116
  const client = createWsClient(definition, { url: 'ws://localhost:3000' });
117
-
117
+
118
118
  expect(client).toBeDefined();
119
119
  expect(typeof client.connect).toBe('function');
120
120
  expect(typeof client.disconnect).toBe('function');
@@ -123,6 +123,40 @@ describe('WsClient', () => {
123
123
  });
124
124
  });
125
125
 
126
+ describe('createNativeWsClient', () => {
127
+ it('should create standalone client without definition', () => {
128
+ const client = createNativeWsClient({ url: 'ws://localhost:3000/chat' });
129
+
130
+ expect(client).toBeDefined();
131
+ expect(typeof client.connect).toBe('function');
132
+ expect(typeof client.disconnect).toBe('function');
133
+ expect(typeof client.isConnected).toBe('function');
134
+ expect(typeof client.getState).toBe('function');
135
+ expect(typeof client.on).toBe('function');
136
+ expect(typeof client.off).toBe('function');
137
+ expect(typeof client.emit).toBe('function');
138
+ expect(typeof client.send).toBe('function');
139
+ });
140
+
141
+ it('should connect and use emit/send/on like typed client', async () => {
142
+ const client = createNativeWsClient({ url: 'ws://localhost:3000/chat' });
143
+ await client.connect();
144
+
145
+ const ws = MockWebSocket.getLastInstance();
146
+ expect(ws).toBeDefined();
147
+ expect(client.isConnected()).toBe(true);
148
+
149
+ client.on('welcome', (data) => expect(data).toBeDefined());
150
+ ws?.receiveMessage(JSON.stringify({ event: 'welcome', data: { msg: 'hi' } }));
151
+
152
+ client.send('ping', {});
153
+ expect(ws?.sentMessages.some((m) => m.includes('ping'))).toBe(true);
154
+
155
+ client.disconnect();
156
+ expect(client.isConnected()).toBe(false);
157
+ });
158
+ });
159
+
126
160
  describe('connect', () => {
127
161
  it('should connect to WebSocket server', async () => {
128
162
  const definition = createMockDefinition();
@@ -312,20 +346,23 @@ describe('WsClient', () => {
312
346
  expect(handler).toHaveBeenCalled();
313
347
  });
314
348
 
315
- it('should handle Engine.IO PING packet', async () => {
349
+ it('should handle Engine.IO PING packet when using Socket.IO protocol', async () => {
316
350
  const definition = createMockDefinition();
317
- const client = createWsClient(definition, { url: 'ws://localhost:3000' });
318
-
351
+ const client = createWsClient(definition, {
352
+ url: 'ws://localhost:3000/socket.io',
353
+ protocol: 'socketio',
354
+ });
355
+
319
356
  await client.connect();
320
-
357
+
321
358
  const ws = MockWebSocket.getLastInstance();
322
359
  if (ws) {
323
- ws.sentMessages.length = 0; // Clear previous messages
324
-
360
+ ws.sentMessages.length = 0;
361
+
325
362
  // Send PING (Engine.IO packet type 2)
326
363
  ws.receiveMessage('2');
327
364
  }
328
-
365
+
329
366
  // Should respond with PONG (Engine.IO packet type 3)
330
367
  expect(ws?.sentMessages).toContain('3');
331
368
  });
@@ -6,27 +6,49 @@
6
6
  */
7
7
 
8
8
  /* eslint-disable @typescript-eslint/no-magic-numbers */
9
+ /* eslint-disable import/order -- type vs value from same path; keep single mixed import */
9
10
 
10
- import type {
11
- WsClientOptions,
12
- WsClient,
13
- WsGatewayClient,
14
- WsEventListener,
15
- WsClientEvent,
16
- WsClientEventListeners,
17
- PendingRequest,
18
- TypedWsClient,
11
+ import {
12
+ type NativeWsClient,
13
+ type PendingRequest,
14
+ type TypedWsClient,
15
+ type WsClient,
16
+ type WsClientEventListeners,
17
+ type WsClientEvent,
18
+ type WsClientOptions,
19
+ type WsEventListener,
20
+ type WsGatewayClient,
21
+ WsConnectionState,
19
22
  } from './ws-client.types';
20
23
  import type { WsServiceDefinition } from './ws-service-definition';
21
24
 
22
- import { WsConnectionState } from './ws-client.types';
25
+ /** Client lifecycle event names (subscriptions go to client, not server events) */
26
+ const WS_CLIENT_EVENTS: WsClientEvent[] = [
27
+ 'connect',
28
+ 'disconnect',
29
+ 'error',
30
+ 'reconnect',
31
+ 'reconnect_attempt',
32
+ 'reconnect_failed',
33
+ ];
34
+
35
+ function isClientEvent(event: string): event is WsClientEvent {
36
+ return WS_CLIENT_EVENTS.includes(event as WsClientEvent);
37
+ }
38
+
39
+ /** Dummy definition for standalone client (single logical gateway) */
40
+ const NATIVE_WS_DUMMY_DEFINITION: WsServiceDefinition = {
41
+ _module: null,
42
+ _endpoints: [],
43
+ _gateways: new Map([['default', { name: 'default', path: '/', events: new Map() }]]),
44
+ };
23
45
  import { matchPattern, isPattern } from './ws-pattern-matcher';
24
46
  import {
25
47
  parseMessage,
26
48
  createPongPacket,
49
+ createFullEventMessage,
27
50
  EngineIOPacketType,
28
51
  SocketIOPacketType,
29
- isNativeMessage,
30
52
  parseNativeMessage,
31
53
  createNativeMessage,
32
54
  } from './ws-socketio-protocol';
@@ -35,6 +57,7 @@ import {
35
57
  * Default client options
36
58
  */
37
59
  const DEFAULT_OPTIONS: Partial<WsClientOptions> = {
60
+ protocol: 'native',
38
61
  reconnect: true,
39
62
  reconnectInterval: 1000,
40
63
  maxReconnectAttempts: 10,
@@ -85,23 +108,22 @@ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
85
108
  let url = this.options.url;
86
109
  const params = new URLSearchParams();
87
110
 
88
- // Add Socket.IO parameters
89
- params.set('EIO', '4');
90
- params.set('transport', 'websocket');
111
+ const protocol = this.options.protocol ?? 'native';
112
+ if (protocol === 'socketio') {
113
+ params.set('EIO', '4');
114
+ params.set('transport', 'websocket');
115
+ }
91
116
 
92
- // Add auth token
93
117
  if (this.options.auth?.token) {
94
118
  params.set('token', this.options.auth.token);
95
119
  }
96
120
 
97
- // Add namespace
98
121
  if (this.options.namespace) {
99
122
  params.set('namespace', this.options.namespace);
100
123
  }
101
124
 
102
- // Append params to URL
103
125
  const separator = url.includes('?') ? '&' : '?';
104
- url = `${url}${separator}${params.toString()}`;
126
+ url = params.toString() ? `${url}${separator}${params.toString()}` : url;
105
127
 
106
128
  // Create WebSocket connection
107
129
  // Use globalThis.WebSocket for browser compatibility
@@ -204,23 +226,22 @@ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
204
226
  * Handle incoming message
205
227
  */
206
228
  private handleMessage(data: string): void {
207
- // Try native format first
208
- if (isNativeMessage(data)) {
229
+ const protocol = this.options.protocol ?? 'native';
230
+
231
+ if (protocol === 'native') {
209
232
  const native = parseNativeMessage(data);
210
233
  if (native) {
211
234
  this.handleEvent(native.event, native.data, native.ack);
212
-
213
- return;
214
235
  }
236
+
237
+ return;
215
238
  }
216
239
 
217
- // Parse Socket.IO format
240
+ // Socket.IO format
218
241
  const { engineIO, socketIO } = parseMessage(data);
219
242
 
220
- // Handle Engine.IO packets
221
243
  switch (engineIO.type) {
222
244
  case EngineIOPacketType.OPEN:
223
- // Handle handshake
224
245
  if (engineIO.data) {
225
246
  try {
226
247
  const handshake = JSON.parse(engineIO.data as string);
@@ -239,7 +260,6 @@ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
239
260
  return;
240
261
 
241
262
  case EngineIOPacketType.PONG:
242
- // Server responded to our ping
243
263
  return;
244
264
 
245
265
  case EngineIOPacketType.CLOSE:
@@ -248,7 +268,6 @@ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
248
268
  return;
249
269
 
250
270
  case EngineIOPacketType.MESSAGE:
251
- // Socket.IO packet
252
271
  if (socketIO) {
253
272
  this.handleSocketIOPacket(socketIO);
254
273
  }
@@ -468,8 +487,11 @@ class WsClientImpl<TDef extends WsServiceDefinition> implements WsClient<TDef> {
468
487
  throw new Error('Not connected');
469
488
  }
470
489
 
471
- // Use native format (simpler)
472
- const message = createNativeMessage(event, data, ackId);
490
+ const protocol = this.options.protocol ?? 'native';
491
+ const message =
492
+ protocol === 'socketio'
493
+ ? createFullEventMessage(event, data ?? {}, '/', ackId)
494
+ : createNativeMessage(event, data, ackId);
473
495
  this.ws.send(message);
474
496
  }
475
497
 
@@ -626,3 +648,64 @@ export function createWsClient<TDef extends WsServiceDefinition>(
626
648
  },
627
649
  });
628
650
  }
651
+
652
+ /**
653
+ * Create a standalone WebSocket client without a service definition.
654
+ * Uses the same native message format and API (emit, send, on, off) as the typed client.
655
+ * Use in frontend or when you do not want to depend on backend modules.
656
+ *
657
+ * @param options - Client options (url, protocol, auth, reconnect, etc.)
658
+ * @returns Standalone client with connect, disconnect, on, off, emit, send
659
+ *
660
+ * @example
661
+ * ```typescript
662
+ * import { createNativeWsClient } from '@onebun/core';
663
+ *
664
+ * const client = createNativeWsClient({
665
+ * url: 'ws://localhost:3000/chat',
666
+ * protocol: 'native',
667
+ * auth: { token: 'xxx' },
668
+ * });
669
+ *
670
+ * await client.connect();
671
+ * client.on('welcome', (data) => console.log(data));
672
+ * client.on('connect', () => console.log('Connected'));
673
+ *
674
+ * await client.emit('chat:message', { text: 'Hello' });
675
+ * client.send('typing', {});
676
+ * client.disconnect();
677
+ * ```
678
+ */
679
+ export function createNativeWsClient(options: WsClientOptions): NativeWsClient {
680
+ const typed = createWsClient(NATIVE_WS_DUMMY_DEFINITION, options);
681
+ const gateway = (typed as unknown as Record<string, WsGatewayClient>).default;
682
+
683
+ return {
684
+ connect: () => typed.connect(),
685
+ disconnect: () => typed.disconnect(),
686
+ isConnected: () => typed.isConnected(),
687
+ getState: () => typed.getState(),
688
+
689
+ on(event: string, listener: WsEventListener | WsClientEventListeners[WsClientEvent]): void {
690
+ if (isClientEvent(event)) {
691
+ typed.on(event, listener as WsClientEventListeners[typeof event]);
692
+ } else {
693
+ gateway.on(event, listener as WsEventListener);
694
+ }
695
+ },
696
+
697
+ off(
698
+ event: string,
699
+ listener?: WsEventListener | WsClientEventListeners[WsClientEvent],
700
+ ): void {
701
+ if (isClientEvent(event)) {
702
+ typed.off(event, listener as WsClientEventListeners[typeof event]);
703
+ } else {
704
+ gateway.off(event, listener as WsEventListener);
705
+ }
706
+ },
707
+
708
+ emit: <T = unknown>(event: string, data?: unknown) => gateway.emit<T>(event, data),
709
+ send: (event: string, data?: unknown) => gateway.send(event, data),
710
+ };
711
+ }
@@ -6,12 +6,19 @@
6
6
 
7
7
  import type { WsServiceDefinition, WsGatewayDefinition } from './ws-service-definition';
8
8
 
9
+ /**
10
+ * WebSocket protocol to use when connecting
11
+ */
12
+ export type WsClientProtocol = 'native' | 'socketio';
13
+
9
14
  /**
10
15
  * Options for WebSocket client
11
16
  */
12
17
  export interface WsClientOptions {
13
- /** WebSocket server URL */
18
+ /** WebSocket server URL (for Socket.IO use the server root or socketio path, e.g. ws://host/socket.io) */
14
19
  url: string;
20
+ /** Protocol to use (default: 'native') */
21
+ protocol?: WsClientProtocol;
15
22
  /** Authentication options */
16
23
  auth?: {
17
24
  /** Bearer token */
@@ -127,3 +134,25 @@ export interface PendingRequest {
127
134
  reject: (error: Error) => void;
128
135
  timeout: ReturnType<typeof setTimeout>;
129
136
  }
137
+
138
+ /**
139
+ * Standalone WebSocket client (no service definition).
140
+ * Same message format and API as the typed client, but without gateway proxies.
141
+ * Use in frontend or when you do not want to depend on backend module/definitions.
142
+ */
143
+ export interface NativeWsClient {
144
+ connect(): Promise<void>;
145
+ disconnect(): void;
146
+ isConnected(): boolean;
147
+ getState(): WsConnectionState;
148
+ /** Lifecycle events: connect, disconnect, error, reconnect, reconnect_attempt, reconnect_failed */
149
+ on<E extends WsClientEvent>(event: E, listener: WsClientEventListeners[E]): void;
150
+ /** Server events (event names from your gateway) */
151
+ on<T = unknown>(event: string, listener: WsEventListener<T>): void;
152
+ off<E extends WsClientEvent>(event: E, listener?: WsClientEventListeners[E]): void;
153
+ off(event: string, listener?: WsEventListener): void;
154
+ /** Send event and wait for acknowledgement */
155
+ emit<T = unknown>(event: string, data?: unknown): Promise<T>;
156
+ /** Send event without waiting for response */
157
+ send(event: string, data?: unknown): void;
158
+ }
@@ -32,6 +32,7 @@ describe('ws-guards', () => {
32
32
  connectedAt: Date.now(),
33
33
  auth: null,
34
34
  metadata: {},
35
+ protocol: 'native',
35
36
  ...overrides,
36
37
  });
37
38
 
@@ -13,6 +13,7 @@ import type {
13
13
  WsHandlerMetadata,
14
14
  WebSocketApplicationOptions,
15
15
  } from './ws.types';
16
+ import type { WsHandlerResponse } from './ws.types';
16
17
  import type { Server, ServerWebSocket } from 'bun';
17
18
 
18
19
  import type { SyncLogger } from '@onebun/logger';
@@ -30,7 +31,6 @@ import {
30
31
  createFullEventMessage,
31
32
  EngineIOPacketType,
32
33
  SocketIOPacketType,
33
- isNativeMessage,
34
34
  parseNativeMessage,
35
35
  createNativeMessage,
36
36
  DEFAULT_PING_INTERVAL,
@@ -68,14 +68,19 @@ export class WsHandler {
68
68
  private pingTimeoutMs: number;
69
69
  private maxPayload: number;
70
70
  private pingIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
71
+ private socketioEnabled: boolean;
72
+ private socketioPath: string;
71
73
 
72
74
  constructor(
73
75
  private logger: SyncLogger,
74
76
  private options: WebSocketApplicationOptions = {},
75
77
  ) {
76
78
  this.storage = new InMemoryWsStorage();
77
- this.pingIntervalMs = options.pingInterval ?? DEFAULT_PING_INTERVAL;
78
- this.pingTimeoutMs = options.pingTimeout ?? DEFAULT_PING_TIMEOUT;
79
+ const socketio = options.socketio;
80
+ this.socketioEnabled = socketio?.enabled ?? false;
81
+ this.socketioPath = socketio?.path ?? '/socket.io';
82
+ this.pingIntervalMs = socketio?.pingInterval ?? DEFAULT_PING_INTERVAL;
83
+ this.pingTimeoutMs = socketio?.pingTimeout ?? DEFAULT_PING_TIMEOUT;
79
84
  this.maxPayload = options.maxPayload ?? DEFAULT_MAX_PAYLOAD;
80
85
  }
81
86
 
@@ -183,15 +188,20 @@ export class WsHandler {
183
188
  const url = new URL(req.url);
184
189
  const path = url.pathname;
185
190
 
186
- // Find matching gateway
187
- const gateway = this.getGatewayForPath(path);
188
- if (!gateway) {
189
- return new Response('Not Found', { status: 404 });
191
+ let protocol: WsClientData['protocol'] = 'native';
192
+
193
+ if (this.socketioEnabled && path.startsWith(this.socketioPath)) {
194
+ protocol = 'socketio';
195
+ } else {
196
+ const gateway = this.getGatewayForPath(path);
197
+ if (!gateway) {
198
+ return new Response('Not Found', { status: 404 });
199
+ }
190
200
  }
191
201
 
192
202
  // Extract auth from query or headers
193
- const token = url.searchParams.get('token') ||
194
- req.headers.get('Authorization')?.replace('Bearer ', '');
203
+ const token = url.searchParams.get('token') ||
204
+ req.headers.get('Authorization')?.replace('Bearer ', '');
195
205
 
196
206
  // Create client ID
197
207
  const clientId = crypto.randomUUID();
@@ -201,11 +211,14 @@ export class WsHandler {
201
211
  id: clientId,
202
212
  rooms: [],
203
213
  connectedAt: Date.now(),
204
- auth: token ? {
205
- authenticated: false,
206
- token,
207
- } : null,
214
+ auth: token
215
+ ? {
216
+ authenticated: false,
217
+ token,
218
+ }
219
+ : null,
208
220
  metadata: {},
221
+ protocol,
209
222
  };
210
223
 
211
224
  // Try to upgrade
@@ -225,7 +238,7 @@ export class WsHandler {
225
238
  */
226
239
  private async handleOpen(ws: ServerWebSocket<WsClientData>): Promise<void> {
227
240
  const client = ws.data;
228
- this.logger.debug(`WebSocket client connected: ${client.id}`);
241
+ this.logger.debug(`WebSocket client connected: ${client.id} (${client.protocol})`);
229
242
 
230
243
  // Store client
231
244
  await this.storage.addClient(client);
@@ -235,16 +248,16 @@ export class WsHandler {
235
248
  gateway.instance._registerSocket(client.id, ws);
236
249
  }
237
250
 
238
- // Send Socket.IO handshake
239
- const handshake = createHandshake(client.id, {
240
- pingInterval: this.pingIntervalMs,
241
- pingTimeout: this.pingTimeoutMs,
242
- maxPayload: this.maxPayload,
243
- });
244
- ws.send(createOpenPacket(handshake));
245
-
246
- // Start ping interval
247
- this.startPingInterval(client.id, ws);
251
+ if (client.protocol === 'socketio') {
252
+ // Send Socket.IO handshake
253
+ const handshake = createHandshake(client.id, {
254
+ pingInterval: this.pingIntervalMs,
255
+ pingTimeout: this.pingTimeoutMs,
256
+ maxPayload: this.maxPayload,
257
+ });
258
+ ws.send(createOpenPacket(handshake));
259
+ this.startPingInterval(client.id, ws);
260
+ }
248
261
 
249
262
  // Call OnConnect handlers
250
263
  for (const [_, gateway] of this.gateways) {
@@ -253,7 +266,7 @@ export class WsHandler {
253
266
  try {
254
267
  const result = await this.executeHandler(gateway, handler, ws, undefined, {});
255
268
  if (result && isWsHandlerResponse(result)) {
256
- ws.send(createNativeMessage(result.event, result.data));
269
+ ws.send(this.encodeResponse(client.protocol, result));
257
270
  }
258
271
  } catch (error) {
259
272
  this.logger.error(`Error in OnConnect handler: ${error}`);
@@ -262,6 +275,25 @@ export class WsHandler {
262
275
  }
263
276
  }
264
277
 
278
+ /**
279
+ * Encode handler response for the client's protocol
280
+ */
281
+ private encodeResponse(
282
+ protocol: WsClientData['protocol'],
283
+ result: WsHandlerResponse,
284
+ ackId?: number,
285
+ ): string {
286
+ if (protocol === 'socketio') {
287
+ if (ackId !== undefined) {
288
+ return createFullAckMessage(ackId, result);
289
+ }
290
+
291
+ return createFullEventMessage(result.event, result.data);
292
+ }
293
+
294
+ return createNativeMessage(result.event, result.data, ackId);
295
+ }
296
+
265
297
  /**
266
298
  * Handle incoming message
267
299
  */
@@ -270,21 +302,20 @@ export class WsHandler {
270
302
  message: string | Buffer,
271
303
  ): Promise<void> {
272
304
  const messageStr = typeof message === 'string' ? message : message.toString();
305
+ const protocol = ws.data.protocol;
273
306
 
274
- // Try native format first
275
- if (isNativeMessage(messageStr)) {
307
+ if (protocol === 'native') {
276
308
  const native = parseNativeMessage(messageStr);
277
309
  if (native) {
278
310
  await this.routeMessage(ws, native.event, native.data, native.ack);
279
-
280
- return;
281
311
  }
312
+
313
+ return;
282
314
  }
283
315
 
284
- // Parse Socket.IO format
316
+ // Socket.IO format
285
317
  const { engineIO, socketIO } = parseMessage(messageStr);
286
318
 
287
- // Handle Engine.IO packets
288
319
  switch (engineIO.type) {
289
320
  case EngineIOPacketType.PING:
290
321
  ws.send(createPongPacket(engineIO.data as string | undefined));
@@ -292,7 +323,6 @@ export class WsHandler {
292
323
  return;
293
324
 
294
325
  case EngineIOPacketType.PONG:
295
- // Client responded to ping - connection is alive
296
326
  return;
297
327
 
298
328
  case EngineIOPacketType.CLOSE:
@@ -301,7 +331,6 @@ export class WsHandler {
301
331
  return;
302
332
 
303
333
  case EngineIOPacketType.MESSAGE:
304
- // Socket.IO packet
305
334
  if (socketIO) {
306
335
  await this.handleSocketIOPacket(ws, socketIO);
307
336
  }
@@ -378,14 +407,10 @@ export class WsHandler {
378
407
  if (match.matched) {
379
408
  try {
380
409
  const result = await this.executeHandler(gateway, handler, ws, data, match.params);
381
-
410
+
382
411
  // Send response
383
- if (result !== undefined) {
384
- if (ackId !== undefined) {
385
- ws.send(createFullAckMessage(ackId, result));
386
- } else if (isWsHandlerResponse(result)) {
387
- ws.send(createNativeMessage(result.event, result.data));
388
- }
412
+ if (result !== undefined && isWsHandlerResponse(result)) {
413
+ ws.send(this.encodeResponse(ws.data.protocol, result, ackId));
389
414
  }
390
415
  } catch (error) {
391
416
  this.logger.error(`Error in message handler: ${error}`);
@@ -430,12 +455,8 @@ export class WsHandler {
430
455
  if (match.matched) {
431
456
  try {
432
457
  const result = await this.executeHandler(gateway, handler, ws, data, match.params, roomName);
433
- if (result !== undefined) {
434
- if (ackId !== undefined) {
435
- ws.send(createFullAckMessage(ackId, result));
436
- } else if (isWsHandlerResponse(result)) {
437
- ws.send(createNativeMessage(result.event, result.data));
438
- }
458
+ if (result !== undefined && isWsHandlerResponse(result)) {
459
+ ws.send(this.encodeResponse(ws.data.protocol, result, ackId));
439
460
  }
440
461
  } catch (error) {
441
462
  this.logger.error(`Error in OnJoinRoom handler: ${error}`);
@@ -478,12 +499,8 @@ export class WsHandler {
478
499
  if (match.matched) {
479
500
  try {
480
501
  const result = await this.executeHandler(gateway, handler, ws, data, match.params, roomName);
481
- if (result !== undefined) {
482
- if (ackId !== undefined) {
483
- ws.send(createFullAckMessage(ackId, result));
484
- } else if (isWsHandlerResponse(result)) {
485
- ws.send(createNativeMessage(result.event, result.data));
486
- }
502
+ if (result !== undefined && isWsHandlerResponse(result)) {
503
+ ws.send(this.encodeResponse(ws.data.protocol, result, ackId));
487
504
  }
488
505
  } catch (error) {
489
506
  this.logger.error(`Error in OnLeaveRoom handler: ${error}`);
@@ -28,6 +28,7 @@ describe('InMemoryWsStorage', () => {
28
28
  connectedAt: Date.now(),
29
29
  auth: null,
30
30
  metadata: {},
31
+ protocol: 'native',
31
32
  });
32
33
 
33
34
  it('should add and retrieve a client', async () => {
@@ -135,6 +136,7 @@ describe('InMemoryWsStorage', () => {
135
136
  connectedAt: Date.now(),
136
137
  auth: null,
137
138
  metadata: {},
139
+ protocol: 'native',
138
140
  });
139
141
 
140
142
  it('should add client to room', async () => {
@@ -227,6 +229,7 @@ describe('InMemoryWsStorage', () => {
227
229
  connectedAt: Date.now(),
228
230
  auth: null,
229
231
  metadata: {},
232
+ protocol: 'native',
230
233
  };
231
234
  await storage.addClient(client);
232
235
  await storage.createRoom({ name: 'test-room', clientIds: [] });
@@ -78,6 +78,7 @@ describe('RedisWsStorage', () => {
78
78
  connectedAt: Date.now(),
79
79
  auth: null,
80
80
  metadata: {},
81
+ protocol: 'native',
81
82
  ...options,
82
83
  });
83
84
 
@@ -26,6 +26,11 @@ export interface WsAuthData {
26
26
  token?: string;
27
27
  }
28
28
 
29
+ /**
30
+ * WebSocket protocol used by the client
31
+ */
32
+ export type WsProtocol = 'native' | 'socketio';
33
+
29
34
  /**
30
35
  * WebSocket client data (fixed fields)
31
36
  */
@@ -40,6 +45,8 @@ export interface WsClientData {
40
45
  auth: WsAuthData | null;
41
46
  /** Custom metadata */
42
47
  metadata: Record<string, unknown>;
48
+ /** Protocol used by the client */
49
+ protocol: WsProtocol;
43
50
  }
44
51
 
45
52
  /**
@@ -178,18 +185,30 @@ export interface WsStorageOptions {
178
185
  };
179
186
  }
180
187
 
188
+ /**
189
+ * Socket.IO-specific options (optional; when enabled, Socket.IO runs on its own path)
190
+ */
191
+ export interface WebSocketSocketIOOptions {
192
+ /** Enable Socket.IO protocol (default: false) */
193
+ enabled?: boolean;
194
+ /** Path for Socket.IO connections (default: '/socket.io') */
195
+ path?: string;
196
+ /** Ping interval in milliseconds (default: 25000) */
197
+ pingInterval?: number;
198
+ /** Ping timeout in milliseconds (default: 20000) */
199
+ pingTimeout?: number;
200
+ }
201
+
181
202
  /**
182
203
  * WebSocket configuration for OneBunApplication
183
204
  */
184
205
  export interface WebSocketApplicationOptions {
185
206
  /** Enable/disable WebSocket (default: auto - enabled if gateways exist) */
186
207
  enabled?: boolean;
208
+ /** Socket.IO options; when enabled, Socket.IO is served on socketio.path */
209
+ socketio?: WebSocketSocketIOOptions;
187
210
  /** Storage options */
188
211
  storage?: WsStorageOptions;
189
- /** Ping interval in milliseconds for heartbeat (socket.io) */
190
- pingInterval?: number;
191
- /** Ping timeout in milliseconds (socket.io) */
192
- pingTimeout?: number;
193
212
  /** Maximum payload size in bytes */
194
213
  maxPayload?: number;
195
214
  }
@@ -315,15 +334,19 @@ export function isWsHandlerResponse(value: unknown): value is WsHandlerResponse
315
334
  * Check if value is a valid WsClientData
316
335
  */
317
336
  export function isWsClientData(value: unknown): value is WsClientData {
337
+ const v = value as WsClientData;
338
+
318
339
  return (
319
340
  typeof value === 'object' &&
320
341
  value !== null &&
321
342
  'id' in value &&
322
- typeof (value as WsClientData).id === 'string' &&
343
+ typeof v.id === 'string' &&
323
344
  'rooms' in value &&
324
- Array.isArray((value as WsClientData).rooms) &&
345
+ Array.isArray(v.rooms) &&
325
346
  'connectedAt' in value &&
326
- typeof (value as WsClientData).connectedAt === 'number'
347
+ typeof v.connectedAt === 'number' &&
348
+ 'protocol' in value &&
349
+ (v.protocol === 'native' || v.protocol === 'socketio')
327
350
  );
328
351
  }
329
352