@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 +1 -1
- package/src/application/application.ts +15 -5
- package/src/docs-examples.test.ts +60 -6
- package/src/module/module.ts +15 -4
- package/src/types.ts +16 -4
- package/src/websocket/ws-base-gateway.test.ts +1 -0
- package/src/websocket/ws-base-gateway.ts +15 -10
- package/src/websocket/ws-client.test.ts +46 -9
- package/src/websocket/ws-client.ts +112 -29
- package/src/websocket/ws-client.types.ts +30 -1
- package/src/websocket/ws-guards.test.ts +1 -0
- package/src/websocket/ws-handler.ts +69 -52
- package/src/websocket/ws-storage-memory.test.ts +3 -0
- package/src/websocket/ws-storage-redis.test.ts +1 -0
- package/src/websocket/ws.types.ts +30 -7
package/package.json
CHANGED
|
@@ -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
|
-
|
|
559
|
-
|
|
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(
|
|
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
|
-
|
|
2930
|
-
|
|
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
|
-
|
|
3208
|
-
|
|
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,
|
package/src/module/module.ts
CHANGED
|
@@ -764,24 +764,35 @@ export class OneBunModule implements ModuleInstance {
|
|
|
764
764
|
}
|
|
765
765
|
|
|
766
766
|
/**
|
|
767
|
-
*
|
|
767
|
+
* Find controller instance (searches this module then child modules recursively, no logging).
|
|
768
768
|
*/
|
|
769
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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, {
|
|
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;
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
472
|
-
const message =
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
78
|
-
this.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [] });
|
|
@@ -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
|
|
343
|
+
typeof v.id === 'string' &&
|
|
323
344
|
'rooms' in value &&
|
|
324
|
-
Array.isArray(
|
|
345
|
+
Array.isArray(v.rooms) &&
|
|
325
346
|
'connectedAt' in value &&
|
|
326
|
-
typeof
|
|
347
|
+
typeof v.connectedAt === 'number' &&
|
|
348
|
+
'protocol' in value &&
|
|
349
|
+
(v.protocol === 'native' || v.protocol === 'socketio')
|
|
327
350
|
);
|
|
328
351
|
}
|
|
329
352
|
|