@martel/calyx 1.5.0 → 1.6.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.6.0](https://github.com/bmartel/calyx/compare/v1.5.0...v1.6.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **websockets,microservices:** implement WebSocketGateways, TCP Microservices, and context switching for Guards/Interceptors ([a8cde0d](https://github.com/bmartel/calyx/commit/a8cde0d68411a3ab736fe1df00ae04672588b1af))
7
+
1
8
  # [1.5.0](https://github.com/bmartel/calyx/compare/v1.4.0...v1.5.0) (2026-07-01)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -99,6 +99,9 @@ export class CalyxApplication {
99
99
  private middlewareConfigurations: MiddlewareConfiguration[] = [];
100
100
  private hostPool = new ObjectPool<CalyxArgumentsHost>(() => new CalyxArgumentsHost());
101
101
  private contextPool = new ObjectPool<CalyxExecutionContext>(() => new CalyxExecutionContext());
102
+ private sharedWebSockets: any[] = [];
103
+ private hasWebSockets = false;
104
+ private serverPort = 3000;
102
105
 
103
106
  use(...middlewares: any[]) {
104
107
  this.globalMiddlewares.push(...middlewares);
@@ -149,6 +152,9 @@ export class CalyxApplication {
149
152
  // Register Scheduled tasks
150
153
  this.registerScheduledTasks();
151
154
 
155
+ // Register WebSocket Gateways
156
+ this.registerWebSocketGateways();
157
+
152
158
  // Call OnModuleInit hooks
153
159
  await this.runOnModuleInit();
154
160
 
@@ -928,11 +934,43 @@ export class CalyxApplication {
928
934
  }
929
935
 
930
936
  async listen(port: number): Promise<any> {
937
+ this.serverPort = port;
931
938
  await this.init();
932
- this.server = Bun.serve({
939
+
940
+ const fetchHandler = (req: Request, server: any) => {
941
+ if (this.hasWebSockets && req.headers.get('upgrade') === 'websocket') {
942
+ const success = server.upgrade(req);
943
+ if (success) return undefined;
944
+ }
945
+ return this.handleRequest(req);
946
+ };
947
+
948
+ const serveOptions: any = {
933
949
  port,
934
- fetch: (req) => this.handleRequest(req),
935
- });
950
+ fetch: fetchHandler,
951
+ };
952
+
953
+ if (this.hasWebSockets) {
954
+ serveOptions.websocket = {
955
+ open: (ws: any) => {
956
+ for (const gateway of this.sharedWebSockets) {
957
+ this.dispatchWsConnection(gateway, ws);
958
+ }
959
+ },
960
+ message: (ws: any, message: any) => {
961
+ for (const gateway of this.sharedWebSockets) {
962
+ this.dispatchWsMessage(gateway, ws, message);
963
+ }
964
+ },
965
+ close: (ws: any) => {
966
+ for (const gateway of this.sharedWebSockets) {
967
+ this.dispatchWsDisconnect(gateway, ws);
968
+ }
969
+ }
970
+ };
971
+ }
972
+
973
+ this.server = Bun.serve(serveOptions);
936
974
  return this.server;
937
975
  }
938
976
 
@@ -1103,5 +1141,185 @@ export class CalyxApplication {
1103
1141
  }
1104
1142
  }
1105
1143
  }
1144
+
1145
+ private registerWebSocketGateways() {
1146
+ const instances = this.container.getProviderAndControllerInstances();
1147
+ for (const instance of instances) {
1148
+ if (!instance || !instance.constructor) continue;
1149
+ const metadata = Reflect.getMetadata('calyx:websocket_gateway', instance.constructor);
1150
+ if (!metadata) continue;
1151
+
1152
+ const handlers = new Map<string, { propertyKey: string | symbol; paramMapping: any[] }>();
1153
+
1154
+ const subMessages: { event: string; propertyKey: string | symbol }[] =
1155
+ Reflect.getMetadata('calyx:subscribe_message', instance.constructor) || [];
1156
+
1157
+ const bodyParams: { propertyKey: string | symbol; parameterIndex: number }[] =
1158
+ Reflect.getMetadata('calyx:message_body', instance.constructor) || [];
1159
+
1160
+ const socketParams: { propertyKey: string | symbol; parameterIndex: number }[] =
1161
+ Reflect.getMetadata('calyx:connected_socket', instance.constructor) || [];
1162
+
1163
+ for (const sub of subMessages) {
1164
+ const paramMapping: any[] = [];
1165
+
1166
+ for (const bp of bodyParams) {
1167
+ if (bp.propertyKey === sub.propertyKey) {
1168
+ paramMapping[bp.parameterIndex] = 'body';
1169
+ }
1170
+ }
1171
+ for (const sp of socketParams) {
1172
+ if (sp.propertyKey === sub.propertyKey) {
1173
+ paramMapping[sp.parameterIndex] = 'socket';
1174
+ }
1175
+ }
1176
+
1177
+ const classGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor) || [];
1178
+ const methodGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor.prototype, sub.propertyKey) || [];
1179
+ const guards = this.compileLifecycleItems(this.rootModule, [...this.globalGuards, ...classGuards, ...methodGuards]);
1180
+
1181
+ const classInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor) || [];
1182
+ const methodInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor.prototype, sub.propertyKey) || [];
1183
+ const interceptors = this.compileLifecycleItems(this.rootModule, [...this.globalInterceptors, ...classInterceptors, ...methodInterceptors]);
1184
+
1185
+ handlers.set(sub.event, {
1186
+ propertyKey: sub.propertyKey,
1187
+ paramMapping,
1188
+ guards,
1189
+ interceptors,
1190
+ gatewayClass: instance.constructor,
1191
+ });
1192
+ }
1193
+
1194
+ const gatewayInfo = {
1195
+ instance,
1196
+ metadata,
1197
+ handlers,
1198
+ };
1199
+
1200
+ if (metadata.port && metadata.port !== this.serverPort) {
1201
+ this.startDedicatedWebSocketServer(gatewayInfo);
1202
+ } else {
1203
+ this.sharedWebSockets.push(gatewayInfo);
1204
+ this.hasWebSockets = true;
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ private startDedicatedWebSocketServer(gateway: any) {
1210
+ const port = gateway.metadata.port;
1211
+ const self = this;
1212
+ const wsServer = Bun.serve({
1213
+ port,
1214
+ fetch(req, server) {
1215
+ if (server.upgrade(req)) {
1216
+ return undefined;
1217
+ }
1218
+ return new Response('Expected upgrade to websocket', { status: 400 });
1219
+ },
1220
+ websocket: {
1221
+ open: (ws: any) => {
1222
+ self.dispatchWsConnection(gateway, ws);
1223
+ },
1224
+ message: (ws: any, message: any) => {
1225
+ self.dispatchWsMessage(gateway, ws, message);
1226
+ },
1227
+ close: (ws: any) => {
1228
+ self.dispatchWsDisconnect(gateway, ws);
1229
+ }
1230
+ }
1231
+ });
1232
+
1233
+ this.cleanupListeners.push(() => wsServer.stop());
1234
+
1235
+ if (typeof gateway.instance.afterInit === 'function') {
1236
+ gateway.instance.afterInit(wsServer);
1237
+ }
1238
+ }
1239
+
1240
+ private dispatchWsConnection(gateway: any, ws: any) {
1241
+ if (typeof gateway.instance.handleConnection === 'function') {
1242
+ gateway.instance.handleConnection(ws);
1243
+ }
1244
+ }
1245
+
1246
+ private dispatchWsDisconnect(gateway: any, ws: any) {
1247
+ if (typeof gateway.instance.handleDisconnect === 'function') {
1248
+ gateway.instance.handleDisconnect(ws);
1249
+ }
1250
+ }
1251
+
1252
+ private async dispatchWsMessage(gateway: any, ws: any, message: any) {
1253
+ let messageStr = '';
1254
+ if (typeof message === 'string') {
1255
+ messageStr = message;
1256
+ } else if (message instanceof Uint8Array || message instanceof ArrayBuffer) {
1257
+ messageStr = new TextDecoder().decode(message);
1258
+ } else {
1259
+ return;
1260
+ }
1261
+
1262
+ try {
1263
+ const parsed = JSON.parse(messageStr);
1264
+ const event = parsed.event;
1265
+ const data = parsed.data;
1266
+
1267
+ const handlerInfo = gateway.handlers.get(event);
1268
+ if (handlerInfo) {
1269
+ const args: any[] = [];
1270
+ for (let i = 0; i < handlerInfo.paramMapping.length; i++) {
1271
+ const type = handlerInfo.paramMapping[i];
1272
+ if (type === 'body') {
1273
+ args[i] = data;
1274
+ } else if (type === 'socket') {
1275
+ args[i] = ws;
1276
+ }
1277
+ }
1278
+
1279
+ const context = this.contextPool.acquire();
1280
+ context.resetContextWs(ws, data, handlerInfo.gatewayClass, gateway.instance[handlerInfo.propertyKey]);
1281
+
1282
+ try {
1283
+ for (const guard of handlerInfo.guards) {
1284
+ const canActive = await guard.instance.canActivate(context);
1285
+ if (!canActive) {
1286
+ return; // Block event
1287
+ }
1288
+ }
1289
+
1290
+ const nextCall = {
1291
+ handle: async () => {
1292
+ return gateway.instance[handlerInfo.propertyKey](...args);
1293
+ }
1294
+ };
1295
+
1296
+ let chain = nextCall.handle;
1297
+ for (let i = handlerInfo.interceptors.length - 1; i >= 0; i--) {
1298
+ const interceptor = handlerInfo.interceptors[i];
1299
+ const currentChain = chain;
1300
+ chain = async () => {
1301
+ return interceptor.instance.intercept(context, {
1302
+ handle: () => currentChain()
1303
+ });
1304
+ };
1305
+ }
1306
+
1307
+ const result = await chain();
1308
+
1309
+ if (result !== undefined) {
1310
+ ws.send(JSON.stringify(result));
1311
+ }
1312
+ } catch (err: any) {
1313
+ // ignore or log
1314
+ } finally {
1315
+ context.clearContext();
1316
+ this.contextPool.release(context);
1317
+ }
1318
+ }
1319
+ } catch {
1320
+ // ignore non-json
1321
+ }
1322
+ }
1106
1323
  }
1107
1324
 
1325
+
@@ -1,8 +1,15 @@
1
1
  import { CalyxApplication } from './application.ts';
2
+ import { CalyxMicroservice } from '../microservices/microservice.ts';
3
+ import { MicroserviceOptions } from '../microservices/interfaces.ts';
2
4
 
3
5
  export class CalyxFactory {
4
6
  static async create(rootModule: any): Promise<CalyxApplication> {
5
7
  const app = new CalyxApplication(rootModule);
6
8
  return app;
7
9
  }
10
+
11
+ static async createMicroservice(rootModule: any, options?: MicroserviceOptions): Promise<CalyxMicroservice> {
12
+ const app = new CalyxMicroservice(rootModule, options);
13
+ return app;
14
+ }
8
15
  }
package/src/index.ts CHANGED
@@ -6,3 +6,5 @@ export * from './config/index.ts';
6
6
  export * from './event-emitter/index.ts';
7
7
  export * from './security/index.ts';
8
8
  export * from './schedule/index.ts';
9
+ export * from './websockets/index.ts';
10
+ export * from './microservices/index.ts';
@@ -4,6 +4,10 @@ import { Type } from '../core/metadata.ts';
4
4
  export class CalyxArgumentsHost implements ArgumentsHost {
5
5
  protected req!: Request;
6
6
  protected res!: any;
7
+ protected type: 'http' | 'ws' | 'rpc' = 'http';
8
+ protected wsClient: any = null;
9
+ protected rpcContext: any = null;
10
+ protected data: any = null;
7
11
 
8
12
  constructor(req?: Request, res?: any) {
9
13
  if (req && res) {
@@ -12,16 +16,47 @@ export class CalyxArgumentsHost implements ArgumentsHost {
12
16
  }
13
17
 
14
18
  reset(req: Request, res: any) {
19
+ this.type = 'http';
15
20
  this.req = req;
16
21
  this.res = res;
22
+ this.wsClient = null;
23
+ this.rpcContext = null;
24
+ this.data = null;
25
+ }
26
+
27
+ resetWs(client: any, data: any) {
28
+ this.type = 'ws';
29
+ this.wsClient = client;
30
+ this.data = data;
31
+ this.req = null as any;
32
+ this.res = null;
33
+ this.rpcContext = null;
34
+ }
35
+
36
+ resetRpc(ctx: any, data: any) {
37
+ this.type = 'rpc';
38
+ this.rpcContext = ctx;
39
+ this.data = data;
40
+ this.req = null as any;
41
+ this.res = null;
42
+ this.wsClient = null;
17
43
  }
18
44
 
19
45
  clear() {
20
46
  this.req = null as any;
21
47
  this.res = null;
48
+ this.wsClient = null;
49
+ this.rpcContext = null;
50
+ this.data = null;
22
51
  }
23
52
 
24
53
  getArgs<T extends any[] = any[]>(): T {
54
+ if (this.type === 'ws') {
55
+ return [this.wsClient, this.data] as unknown as T;
56
+ }
57
+ if (this.type === 'rpc') {
58
+ return [this.data, this.rpcContext] as unknown as T;
59
+ }
25
60
  return [this.req, this.res] as unknown as T;
26
61
  }
27
62
 
@@ -36,6 +71,24 @@ export class CalyxArgumentsHost implements ArgumentsHost {
36
71
  getNext: <T = any>() => (() => {}) as unknown as T,
37
72
  };
38
73
  }
74
+
75
+ switchToWs(): any {
76
+ return {
77
+ getClient: <T = any>() => this.wsClient as T,
78
+ getData: <T = any>() => this.data as T,
79
+ };
80
+ }
81
+
82
+ switchToRpc(): any {
83
+ return {
84
+ getContext: <T = any>() => this.rpcContext as T,
85
+ getData: <T = any>() => this.data as T,
86
+ };
87
+ }
88
+
89
+ getType<TContextType extends string = string>(): TContextType {
90
+ return this.type as TContextType;
91
+ }
39
92
  }
40
93
 
41
94
  export class CalyxExecutionContext extends CalyxArgumentsHost implements ExecutionContext {
@@ -65,6 +118,28 @@ export class CalyxExecutionContext extends CalyxArgumentsHost implements Executi
65
118
  this.handlerMethod = handlerMethod;
66
119
  }
67
120
 
121
+ resetContextWs(
122
+ client: any,
123
+ data: any,
124
+ targetClass: Type<any>,
125
+ handlerMethod: Function
126
+ ) {
127
+ this.resetWs(client, data);
128
+ this.targetClass = targetClass;
129
+ this.handlerMethod = handlerMethod;
130
+ }
131
+
132
+ resetContextRpc(
133
+ ctx: any,
134
+ data: any,
135
+ targetClass: Type<any>,
136
+ handlerMethod: Function
137
+ ) {
138
+ this.resetRpc(ctx, data);
139
+ this.targetClass = targetClass;
140
+ this.handlerMethod = handlerMethod;
141
+ }
142
+
68
143
  clearContext() {
69
144
  this.clear();
70
145
  this.targetClass = null as any;
@@ -4,6 +4,9 @@ export interface ArgumentsHost {
4
4
  getArgs<T extends any[] = any[]>(): T;
5
5
  getArgByIndex<T = any>(index: number): T;
6
6
  switchToHttp(): HttpArgumentsHost;
7
+ switchToWs(): any;
8
+ switchToRpc(): any;
9
+ getType<TContextType extends string = string>(): TContextType;
7
10
  }
8
11
 
9
12
  export interface HttpArgumentsHost {
@@ -0,0 +1,7 @@
1
+ import { Observable } from 'rxjs';
2
+
3
+ export abstract class ClientProxy {
4
+ abstract send<TResult = any, TInput = any>(pattern: any, data: TInput): Observable<TResult>;
5
+ abstract emit<TResult = any, TInput = any>(pattern: any, data: TInput): Observable<TResult>;
6
+ abstract close(): void;
7
+ }
@@ -0,0 +1,127 @@
1
+ import { ClientProxy } from './client-proxy.ts';
2
+ import { Observable } from 'rxjs';
3
+
4
+ export class ClientTcp extends ClientProxy {
5
+ private socket: any;
6
+ private readonly host: string;
7
+ private readonly port: number;
8
+ private messageId = 0;
9
+ private pendingRequests = new Map<string, { next: (val: any) => void; error: (err: any) => void; complete: () => void }>();
10
+ private socketPromise: Promise<any> | null = null;
11
+ private buffer = '';
12
+
13
+ constructor(options: { host?: string; port?: number }) {
14
+ super();
15
+ this.host = options.host ?? '127.0.0.1';
16
+ this.port = options.port ?? 3000;
17
+ }
18
+
19
+ private async getSocket(): Promise<any> {
20
+ if (this.socket) return this.socket;
21
+ if (this.socketPromise) return this.socketPromise;
22
+
23
+ this.socketPromise = (async () => {
24
+ const self = this;
25
+ const socket = await Bun.connect({
26
+ hostname: this.host,
27
+ port: this.port,
28
+ socket: {
29
+ data(socket, data) {
30
+ self.handleData(data);
31
+ },
32
+ error(socket, error) {
33
+ self.handleError(error);
34
+ },
35
+ close(socket) {
36
+ self.socket = null;
37
+ self.socketPromise = null;
38
+ }
39
+ }
40
+ });
41
+ this.socket = socket;
42
+ return socket;
43
+ })();
44
+
45
+ return this.socketPromise;
46
+ }
47
+
48
+ private handleData(data: Uint8Array) {
49
+ this.buffer += new TextDecoder().decode(data);
50
+ let boundary = this.buffer.indexOf('\n');
51
+ while (boundary !== -1) {
52
+ const messageStr = this.buffer.substring(0, boundary).trim();
53
+ this.buffer = this.buffer.substring(boundary + 1);
54
+
55
+ if (messageStr) {
56
+ try {
57
+ const parsed = JSON.parse(messageStr);
58
+ const { id, response, error, isDisposed } = parsed;
59
+
60
+ const pending = this.pendingRequests.get(id);
61
+ if (pending) {
62
+ if (error) {
63
+ pending.error(new Error(error));
64
+ this.pendingRequests.delete(id);
65
+ } else if (isDisposed) {
66
+ pending.complete();
67
+ this.pendingRequests.delete(id);
68
+ } else {
69
+ pending.next(response);
70
+ }
71
+ }
72
+ } catch (err) {
73
+ // ignore parsing errors for partial or malformed frames
74
+ }
75
+ }
76
+ boundary = this.buffer.indexOf('\n');
77
+ }
78
+ }
79
+
80
+ private handleError(error: any) {
81
+ for (const pending of this.pendingRequests.values()) {
82
+ pending.error(error);
83
+ }
84
+ this.pendingRequests.clear();
85
+ }
86
+
87
+ send<TResult = any, TInput = any>(pattern: any, data: TInput): Observable<TResult> {
88
+ return new Observable<TResult>((observer) => {
89
+ this.messageId++;
90
+ const id = String(this.messageId);
91
+ this.pendingRequests.set(id, observer);
92
+
93
+ this.getSocket().then((socket) => {
94
+ const packet = JSON.stringify({ pattern, data, id }) + '\n';
95
+ socket.write(packet);
96
+ }).catch((err) => {
97
+ observer.error(err);
98
+ });
99
+
100
+ return () => {
101
+ this.pendingRequests.delete(id);
102
+ };
103
+ });
104
+ }
105
+
106
+ emit<TResult = any, TInput = any>(pattern: any, data: TInput): Observable<TResult> {
107
+ return new Observable<TResult>((observer) => {
108
+ this.getSocket().then((socket) => {
109
+ const packet = JSON.stringify({ pattern, data }) + '\n';
110
+ socket.write(packet);
111
+ observer.next(undefined as any);
112
+ observer.complete();
113
+ }).catch((err) => {
114
+ observer.error(err);
115
+ });
116
+ });
117
+ }
118
+
119
+ close() {
120
+ if (this.socket) {
121
+ this.socket.end();
122
+ this.socket = null;
123
+ }
124
+ this.socketPromise = null;
125
+ this.pendingRequests.clear();
126
+ }
127
+ }
@@ -0,0 +1,19 @@
1
+ import 'reflect-metadata';
2
+
3
+ export function MessagePattern(pattern: any): MethodDecorator {
4
+ return (target, propertyKey) => {
5
+ const constructor = target.constructor;
6
+ const existing = Reflect.getOwnMetadata('calyx:message_pattern', constructor) || [];
7
+ existing.push({ pattern, propertyKey });
8
+ Reflect.defineMetadata('calyx:message_pattern', existing, constructor);
9
+ };
10
+ }
11
+
12
+ export function EventPattern(pattern: any): MethodDecorator {
13
+ return (target, propertyKey) => {
14
+ const constructor = target.constructor;
15
+ const existing = Reflect.getOwnMetadata('calyx:event_pattern', constructor) || [];
16
+ existing.push({ pattern, propertyKey });
17
+ Reflect.defineMetadata('calyx:event_pattern', existing, constructor);
18
+ };
19
+ }
@@ -0,0 +1,6 @@
1
+ export * from './interfaces.ts';
2
+ export * from './client-proxy.ts';
3
+ export * from './client-tcp.ts';
4
+ export * from './decorators.ts';
5
+ export * from './server-tcp.ts';
6
+ export * from './microservice.ts';
@@ -0,0 +1,11 @@
1
+ export enum Transport {
2
+ TCP = 0,
3
+ }
4
+
5
+ export interface MicroserviceOptions {
6
+ transport?: Transport;
7
+ options?: {
8
+ host?: string;
9
+ port?: number;
10
+ };
11
+ }
@@ -0,0 +1,114 @@
1
+ import { CalyxContainer } from '../core/container.ts';
2
+ import { ServerTcp } from './server-tcp.ts';
3
+ import { MicroserviceOptions, Transport } from './interfaces.ts';
4
+
5
+ export class CalyxMicroservice {
6
+ private readonly container = new CalyxContainer();
7
+ private readonly server: ServerTcp;
8
+ private isListening = false;
9
+ private cleanupListeners: (() => void)[] = [];
10
+ private readonly globalGuards: any[] = [];
11
+ private readonly globalInterceptors: any[] = [];
12
+
13
+ constructor(private readonly rootModule: any, options: MicroserviceOptions = {}) {
14
+ const transport = options.transport ?? Transport.TCP;
15
+ if (transport !== Transport.TCP) {
16
+ throw new Error(`Calyx microservice: Transport algorithm "${transport}" not supported`);
17
+ }
18
+ this.server = new ServerTcp(options.options);
19
+ }
20
+
21
+ useGlobalGuards(...guards: any[]) {
22
+ this.globalGuards.push(...guards);
23
+ return this;
24
+ }
25
+
26
+ useGlobalInterceptors(...interceptors: any[]) {
27
+ this.globalInterceptors.push(...interceptors);
28
+ return this;
29
+ }
30
+
31
+ async listen(): Promise<any> {
32
+ if (this.isListening) return;
33
+
34
+ this.container.bootstrap(this.rootModule);
35
+ this.server.registerHandlers(this.container, this.globalGuards, this.globalInterceptors);
36
+
37
+ const hostInfo = await this.server.listen();
38
+ this.isListening = true;
39
+
40
+ await this.runOnModuleInit();
41
+ await this.runOnApplicationBootstrap();
42
+
43
+ return hostInfo;
44
+ }
45
+
46
+ async close() {
47
+ if (!this.isListening) return;
48
+
49
+ await this.runShutdownHooks();
50
+
51
+ for (const cleanup of this.cleanupListeners) {
52
+ cleanup();
53
+ }
54
+ this.cleanupListeners = [];
55
+
56
+ this.server.close();
57
+ this.isListening = false;
58
+ }
59
+
60
+ enableShutdownHooks(signals: string[] = ['SIGTERM', 'SIGINT']) {
61
+ const handler = async (signal: string) => {
62
+ await this.close();
63
+ process.exit(0);
64
+ };
65
+
66
+ for (const signal of signals) {
67
+ const listener = () => handler(signal);
68
+ process.on(signal as any, listener);
69
+ this.cleanupListeners.push(() => {
70
+ process.off(signal as any, listener);
71
+ });
72
+ }
73
+ }
74
+
75
+ private async runOnModuleInit() {
76
+ const instances = this.container.getProviderAndControllerInstances();
77
+ for (const instance of instances) {
78
+ if (instance && typeof instance.onModuleInit === 'function') {
79
+ await instance.onModuleInit();
80
+ }
81
+ }
82
+ }
83
+
84
+ private async runOnApplicationBootstrap() {
85
+ const instances = this.container.getProviderAndControllerInstances();
86
+ for (const instance of instances) {
87
+ if (instance && typeof instance.onApplicationBootstrap === 'function') {
88
+ await instance.onApplicationBootstrap();
89
+ }
90
+ }
91
+ }
92
+
93
+ private async runShutdownHooks() {
94
+ const instances = this.container.getProviderAndControllerInstances();
95
+
96
+ for (const instance of instances) {
97
+ if (instance && typeof instance.onModuleDestroy === 'function') {
98
+ await instance.onModuleDestroy();
99
+ }
100
+ }
101
+
102
+ for (const instance of instances) {
103
+ if (instance && typeof instance.beforeApplicationShutdown === 'function') {
104
+ await instance.beforeApplicationShutdown();
105
+ }
106
+ }
107
+
108
+ for (const instance of instances) {
109
+ if (instance && typeof instance.onApplicationShutdown === 'function') {
110
+ await instance.onApplicationShutdown();
111
+ }
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,210 @@
1
+ import { CalyxContainer } from '../core/container.ts';
2
+ import { METADATA_KEYS } from '../core/metadata.ts';
3
+ import { CalyxExecutionContext } from '../lifecycle/context.ts';
4
+
5
+ export class ServerTcp {
6
+ private server: any;
7
+ private readonly host: string;
8
+ private readonly port: number;
9
+ private readonly messageHandlers = new Map<string, {
10
+ instance: any;
11
+ propertyKey: string | symbol;
12
+ guards: any[];
13
+ interceptors: any[];
14
+ controllerClass: any;
15
+ }>();
16
+ private readonly eventHandlers = new Map<string, { instance: any; propertyKey: string | symbol }>();
17
+
18
+ constructor(options: { host?: string; port?: number } = {}) {
19
+ this.host = options.host ?? '127.0.0.1';
20
+ this.port = options.port ?? 3000;
21
+ }
22
+
23
+ registerHandlers(
24
+ container: CalyxContainer,
25
+ globalGuards: any[] = [],
26
+ globalInterceptors: any[] = []
27
+ ) {
28
+ const instances = container.getProviderAndControllerInstances();
29
+ for (const instance of instances) {
30
+ if (!instance || !instance.constructor) continue;
31
+
32
+ const msgPatterns: { pattern: any; propertyKey: string | symbol }[] =
33
+ Reflect.getMetadata('calyx:message_pattern', instance.constructor) || [];
34
+ for (const handler of msgPatterns) {
35
+ const patternStr = typeof handler.pattern === 'string' ? handler.pattern : JSON.stringify(handler.pattern);
36
+
37
+ const classGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor) || [];
38
+ const methodGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor.prototype, handler.propertyKey) || [];
39
+ const guards = this.compileLifecycleItems(container, [...globalGuards, ...classGuards, ...methodGuards]);
40
+
41
+ const classInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor) || [];
42
+ const methodInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor.prototype, handler.propertyKey) || [];
43
+ const interceptors = this.compileLifecycleItems(container, [...globalInterceptors, ...classInterceptors, ...methodInterceptors]);
44
+
45
+ this.messageHandlers.set(patternStr, {
46
+ instance,
47
+ propertyKey: handler.propertyKey,
48
+ guards,
49
+ interceptors,
50
+ controllerClass: instance.constructor,
51
+ });
52
+ }
53
+
54
+ const eventPatterns: { pattern: any; propertyKey: string | symbol }[] =
55
+ Reflect.getMetadata('calyx:event_pattern', instance.constructor) || [];
56
+ for (const handler of eventPatterns) {
57
+ const patternStr = typeof handler.pattern === 'string' ? handler.pattern : JSON.stringify(handler.pattern);
58
+ this.eventHandlers.set(patternStr, { instance, propertyKey: handler.propertyKey });
59
+ }
60
+ }
61
+ }
62
+
63
+ private compileLifecycleItems(
64
+ container: CalyxContainer,
65
+ items: any[]
66
+ ): any[] {
67
+ return items.map((item) => {
68
+ if (typeof item === 'function') {
69
+ const proto = item.prototype;
70
+ const hasLifecycle = proto && (proto.canActivate || proto.intercept);
71
+ if (hasLifecycle) {
72
+ try {
73
+ return { instance: container.resolveTokenGlobally(item) };
74
+ } catch {
75
+ return { instance: new item() };
76
+ }
77
+ }
78
+ return { instance: { canActivate: item, intercept: item } };
79
+ }
80
+ return { instance: item };
81
+ });
82
+ }
83
+
84
+ listen(): Promise<any> {
85
+ const self = this;
86
+ return new Promise((resolve, reject) => {
87
+ try {
88
+ this.server = Bun.listen({
89
+ hostname: this.host,
90
+ port: this.port,
91
+ socket: {
92
+ data(socket, data) {
93
+ self.handleSocketData(socket, data);
94
+ },
95
+ error(socket, err) {
96
+ console.error(`Microservice socket error:`, err);
97
+ }
98
+ }
99
+ });
100
+ resolve(this.server);
101
+ } catch (err) {
102
+ reject(err);
103
+ }
104
+ });
105
+ }
106
+
107
+ private handleSocketData(socket: any, data: Uint8Array) {
108
+ if (!socket.data) {
109
+ socket.data = { buffer: '' };
110
+ }
111
+
112
+ socket.data.buffer += new TextDecoder().decode(data);
113
+ let boundary = socket.data.buffer.indexOf('\n');
114
+ while (boundary !== -1) {
115
+ const messageStr = socket.data.buffer.substring(0, boundary).trim();
116
+ socket.data.buffer = socket.data.buffer.substring(boundary + 1);
117
+
118
+ if (messageStr) {
119
+ this.handleMessage(socket, messageStr);
120
+ }
121
+ boundary = socket.data.buffer.indexOf('\n');
122
+ }
123
+ }
124
+
125
+ private async handleMessage(socket: any, messageStr: string) {
126
+ try {
127
+ const parsed = JSON.parse(messageStr);
128
+ const { pattern, data, id } = parsed;
129
+ const patternStr = typeof pattern === 'string' ? pattern : JSON.stringify(pattern);
130
+
131
+ if (id !== undefined) {
132
+ const handler = this.messageHandlers.get(patternStr);
133
+ if (!handler) {
134
+ socket.write(JSON.stringify({ id, error: `Handler for pattern "${patternStr}" not found` }) + '\n');
135
+ return;
136
+ }
137
+
138
+ const context = new CalyxExecutionContext();
139
+ context.resetContextRpc(socket, data, handler.controllerClass, handler.instance[handler.propertyKey]);
140
+
141
+ try {
142
+ for (const guard of handler.guards) {
143
+ const canActive = await guard.instance.canActivate(context);
144
+ if (!canActive) {
145
+ throw new Error('Forbidden resource');
146
+ }
147
+ }
148
+
149
+ const nextCall = {
150
+ handle: async () => {
151
+ return handler.instance[handler.propertyKey](data);
152
+ }
153
+ };
154
+
155
+ let chain = nextCall.handle;
156
+ for (let i = handler.interceptors.length - 1; i >= 0; i--) {
157
+ const interceptor = handler.interceptors[i];
158
+ const currentChain = chain;
159
+ chain = async () => {
160
+ return interceptor.instance.intercept(context, {
161
+ handle: () => currentChain()
162
+ });
163
+ };
164
+ }
165
+
166
+ const result = await chain();
167
+
168
+ if (result && typeof result.subscribe === 'function') {
169
+ result.subscribe({
170
+ next: (val: any) => {
171
+ socket.write(JSON.stringify({ id, response: val }) + '\n');
172
+ },
173
+ error: (err: any) => {
174
+ socket.write(JSON.stringify({ id, error: err.message }) + '\n');
175
+ },
176
+ complete: () => {
177
+ socket.write(JSON.stringify({ id, isDisposed: true }) + '\n');
178
+ }
179
+ });
180
+ } else {
181
+ socket.write(JSON.stringify({ id, response: result }) + '\n');
182
+ socket.write(JSON.stringify({ id, isDisposed: true }) + '\n');
183
+ }
184
+ } catch (err: any) {
185
+ socket.write(JSON.stringify({ id, error: err.message }) + '\n');
186
+ } finally {
187
+ context.clearContext();
188
+ }
189
+ } else {
190
+ const handler = this.eventHandlers.get(patternStr);
191
+ if (handler) {
192
+ try {
193
+ handler.instance[handler.propertyKey](data);
194
+ } catch (err) {
195
+ console.error(`Error executing EventPattern handler for pattern "${patternStr}":`, err);
196
+ }
197
+ }
198
+ }
199
+ } catch {
200
+ // ignore
201
+ }
202
+ }
203
+
204
+ close() {
205
+ if (this.server) {
206
+ this.server.stop();
207
+ this.server = null;
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,49 @@
1
+ import 'reflect-metadata';
2
+
3
+ export interface GatewayMetadata {
4
+ port?: number;
5
+ namespace?: string;
6
+ cors?: any;
7
+ }
8
+
9
+ export function WebSocketGateway(portOrOptions?: number | GatewayMetadata): ClassDecorator {
10
+ return (target) => {
11
+ let metadata: GatewayMetadata = {};
12
+ if (typeof portOrOptions === 'number') {
13
+ metadata.port = portOrOptions;
14
+ } else if (portOrOptions) {
15
+ metadata = portOrOptions;
16
+ }
17
+ Reflect.defineMetadata('calyx:websocket_gateway', metadata, target);
18
+ Reflect.defineMetadata('calyx:injectable', true, target);
19
+ };
20
+ }
21
+
22
+ export function SubscribeMessage(event: string): MethodDecorator {
23
+ return (target, propertyKey) => {
24
+ const constructor = target.constructor;
25
+ const existing = Reflect.getOwnMetadata('calyx:subscribe_message', constructor) || [];
26
+ existing.push({ event, propertyKey });
27
+ Reflect.defineMetadata('calyx:subscribe_message', existing, constructor);
28
+ };
29
+ }
30
+
31
+ export function MessageBody(): ParameterDecorator {
32
+ return (target, propertyKey, parameterIndex) => {
33
+ if (!propertyKey) return;
34
+ const constructor = target.constructor;
35
+ const existing = Reflect.getOwnMetadata('calyx:message_body', constructor) || [];
36
+ existing.push({ propertyKey, parameterIndex });
37
+ Reflect.defineMetadata('calyx:message_body', existing, constructor);
38
+ };
39
+ }
40
+
41
+ export function ConnectedSocket(): ParameterDecorator {
42
+ return (target, propertyKey, parameterIndex) => {
43
+ if (!propertyKey) return;
44
+ const constructor = target.constructor;
45
+ const existing = Reflect.getOwnMetadata('calyx:connected_socket', constructor) || [];
46
+ existing.push({ propertyKey, parameterIndex });
47
+ Reflect.defineMetadata('calyx:connected_socket', existing, constructor);
48
+ };
49
+ }
@@ -0,0 +1,11 @@
1
+ export interface OnGatewayInit {
2
+ afterInit(server: any): void;
3
+ }
4
+
5
+ export interface OnGatewayConnection {
6
+ handleConnection(client: any, ...args: any[]): void;
7
+ }
8
+
9
+ export interface OnGatewayDisconnect {
10
+ handleDisconnect(client: any): void;
11
+ }
@@ -0,0 +1,2 @@
1
+ export * from './decorators.ts';
2
+ export * from './gateway.ts';
@@ -0,0 +1,105 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { of, Observable } from 'rxjs';
3
+ import {
4
+ Module,
5
+ Controller,
6
+ CalyxFactory,
7
+ MessagePattern,
8
+ EventPattern,
9
+ ClientTcp,
10
+ Transport,
11
+ } from '../src/index.ts';
12
+
13
+ let eventReceived = false;
14
+ let eventPayload: any = null;
15
+
16
+ @Controller()
17
+ class MathController {
18
+ @MessagePattern('sum')
19
+ accumulate(data: number[]): number {
20
+ return (data || []).reduce((a, b) => a + b, 0);
21
+ }
22
+
23
+ @MessagePattern('stream')
24
+ streamData(data: string): Observable<string> {
25
+ return of(`echo1: ${data}`, `echo2: ${data}`);
26
+ }
27
+
28
+ @MessagePattern('error_test')
29
+ throwError(data: any) {
30
+ throw new Error('This is a simulated RPC error');
31
+ }
32
+
33
+ @EventPattern('log_event')
34
+ handleLogEvent(data: any) {
35
+ eventReceived = true;
36
+ eventPayload = data;
37
+ }
38
+ }
39
+
40
+ @Module({
41
+ controllers: [MathController],
42
+ })
43
+ class MicroserviceModule {}
44
+
45
+ describe('TCP Microservices (ClientTcp, ServerTcp, MessagePattern, EventPattern)', () => {
46
+ let server: any;
47
+ let client: ClientTcp;
48
+ const PORT = 3899;
49
+
50
+ beforeAll(async () => {
51
+ // 1. Create and start microservice server
52
+ server = await CalyxFactory.createMicroservice(MicroserviceModule, {
53
+ transport: Transport.TCP,
54
+ options: { port: PORT },
55
+ });
56
+ await server.listen();
57
+
58
+ // 2. Initialize client
59
+ client = new ClientTcp({ port: PORT });
60
+ });
61
+
62
+ afterAll(async () => {
63
+ client.close();
64
+ await server.close();
65
+ });
66
+
67
+ test('should exchange request-response message and calculate sum', async () => {
68
+ const res = await client.send<number, number[]>('sum', [1, 2, 3, 4]).toPromise();
69
+ expect(res).toBe(10);
70
+ });
71
+
72
+ test('should handle streaming response from RxJS Observable', async () => {
73
+ const results: string[] = [];
74
+ await new Promise<void>((resolve, reject) => {
75
+ client.send<string, string>('stream', 'hello').subscribe({
76
+ next: (val) => results.push(val),
77
+ error: (err) => reject(err),
78
+ complete: () => resolve(),
79
+ });
80
+ });
81
+ expect(results).toEqual(['echo1: hello', 'echo2: hello']);
82
+ });
83
+
84
+ test('should propagate error message from RPC back to client', async () => {
85
+ try {
86
+ await client.send('error_test', {}).toPromise();
87
+ expect().fail('Expected send to throw error');
88
+ } catch (err: any) {
89
+ expect(err.message).toBe('This is a simulated RPC error');
90
+ }
91
+ });
92
+
93
+ test('should dispatch event pattern messages asynchronously', async () => {
94
+ eventReceived = false;
95
+ eventPayload = null;
96
+
97
+ await client.emit('log_event', { status: 'ok', code: 200 }).toPromise();
98
+
99
+ // Wait a tick for TCP event handling
100
+ await new Promise((resolve) => setTimeout(resolve, 50));
101
+
102
+ expect(eventReceived).toBe(true);
103
+ expect(eventPayload).toEqual({ status: 'ok', code: 200 });
104
+ });
105
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { of, Observable } from 'rxjs';
3
+ import {
4
+ Module,
5
+ CalyxFactory,
6
+ WebSocketGateway,
7
+ SubscribeMessage,
8
+ MessageBody,
9
+ UseGuards,
10
+ UseInterceptors,
11
+ CanActivate,
12
+ ExecutionContext,
13
+ NestInterceptor,
14
+ CallHandler,
15
+ ClientTcp,
16
+ Transport,
17
+ MessagePattern,
18
+ Controller,
19
+ } from '../src/index.ts';
20
+
21
+ class SecurityGuard implements CanActivate {
22
+ canActivate(context: ExecutionContext): boolean {
23
+ const type = context.getType();
24
+ if (type === 'ws') {
25
+ const data = context.switchToWs().getData();
26
+ return data.user === 'admin';
27
+ }
28
+ if (type === 'rpc') {
29
+ const data = context.switchToRpc().getData();
30
+ return data.role === 'admin';
31
+ }
32
+ return false;
33
+ }
34
+ }
35
+
36
+ class LoggingInterceptor implements NestInterceptor {
37
+ async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
38
+ const type = context.getType();
39
+ const result = await next.handle();
40
+ return { type, original: result };
41
+ }
42
+ }
43
+
44
+ @WebSocketGateway(3915)
45
+ class SecureGateway {
46
+ @SubscribeMessage('ping')
47
+ @UseGuards(SecurityGuard)
48
+ @UseInterceptors(LoggingInterceptor)
49
+ handlePing(@MessageBody() data: any) {
50
+ return 'pong';
51
+ }
52
+ }
53
+
54
+ @Controller()
55
+ class SecureController {
56
+ @MessagePattern('ping_rpc')
57
+ @UseGuards(SecurityGuard)
58
+ @UseInterceptors(LoggingInterceptor)
59
+ handlePingRpc(data: any) {
60
+ return 'pong_rpc';
61
+ }
62
+ }
63
+
64
+ @Module({
65
+ controllers: [SecureController],
66
+ providers: [SecureGateway],
67
+ })
68
+ class TestApp {}
69
+
70
+ describe('Guards and Interceptors for WS and RPC contexts', () => {
71
+ let wsApp: any;
72
+ let rpcApp: any;
73
+ let client: ClientTcp;
74
+ const PORT = 3889;
75
+ const RPC_PORT = 3916;
76
+
77
+ beforeAll(async () => {
78
+ // 1. Start standard app for HTTP/WS
79
+ wsApp = await CalyxFactory.create(TestApp);
80
+ await wsApp.listen(PORT);
81
+
82
+ // 2. Start dedicated microservice for RPC
83
+ rpcApp = await CalyxFactory.createMicroservice(TestApp, {
84
+ transport: Transport.TCP,
85
+ options: { port: RPC_PORT },
86
+ });
87
+ await rpcApp.listen();
88
+
89
+ client = new ClientTcp({ port: RPC_PORT });
90
+ });
91
+
92
+ afterAll(async () => {
93
+ client.close();
94
+ await rpcApp.close();
95
+ await wsApp.close();
96
+ });
97
+
98
+ test('should pass or deny WS events using context switching in guards/interceptors', async () => {
99
+ const ws = new WebSocket('ws://localhost:3915');
100
+
101
+ await new Promise<void>((resolve, reject) => {
102
+ ws.onopen = () => resolve();
103
+ ws.onerror = (err) => reject(err);
104
+ });
105
+
106
+ ws.send(JSON.stringify({ event: 'ping', data: { user: 'admin' } }));
107
+ const successRes = await new Promise<any>((resolve) => {
108
+ ws.onmessage = (event) => resolve(JSON.parse(event.data));
109
+ });
110
+ expect(successRes).toEqual({ type: 'ws', original: 'pong' });
111
+
112
+ let receivedMessage = false;
113
+ ws.onmessage = () => {
114
+ receivedMessage = true;
115
+ };
116
+ ws.send(JSON.stringify({ event: 'ping', data: { user: 'guest' } }));
117
+
118
+ await new Promise((resolve) => setTimeout(resolve, 100));
119
+ expect(receivedMessage).toBe(false);
120
+
121
+ ws.close();
122
+ });
123
+
124
+ test('should pass or deny RPC messages using context switching in guards/interceptors', async () => {
125
+ const successRes = await client.send('ping_rpc', { role: 'admin' }).toPromise();
126
+ expect(successRes).toEqual({ type: 'rpc', original: 'pong_rpc' });
127
+
128
+ try {
129
+ await client.send('ping_rpc', { role: 'guest' }).toPromise();
130
+ expect().fail('Expected send to throw error');
131
+ } catch (err: any) {
132
+ expect(err.message).toBe('Forbidden resource');
133
+ }
134
+ });
135
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ CalyxFactory,
5
+ WebSocketGateway,
6
+ SubscribeMessage,
7
+ MessageBody,
8
+ ConnectedSocket,
9
+ } from '../src/index.ts';
10
+
11
+ let clientConnected = false;
12
+ let clientDisconnected = false;
13
+ let messageReceived: any = null;
14
+
15
+ // 1. Shared Port Gateway
16
+ @WebSocketGateway()
17
+ class SharedEventsGateway {
18
+ handleConnection(client: any) {
19
+ clientConnected = true;
20
+ }
21
+
22
+ handleDisconnect(client: any) {
23
+ clientDisconnected = true;
24
+ }
25
+
26
+ @SubscribeMessage('events')
27
+ onEvents(@MessageBody() data: any, @ConnectedSocket() client: any) {
28
+ messageReceived = data;
29
+ return { event: 'events', data: `echo: ${data}` };
30
+ }
31
+ }
32
+
33
+ // 2. Dedicated Port Gateway
34
+ let dedicatedConnected = false;
35
+ @WebSocketGateway(3912)
36
+ class DedicatedEventsGateway {
37
+ handleConnection(client: any) {
38
+ dedicatedConnected = true;
39
+ }
40
+
41
+ @SubscribeMessage('dedicated_event')
42
+ onDedicatedEvent(@MessageBody() data: any) {
43
+ return { event: 'dedicated_response', data: `dedicated echo: ${data}` };
44
+ }
45
+ }
46
+
47
+ @Module({
48
+ providers: [SharedEventsGateway, DedicatedEventsGateway],
49
+ })
50
+ class TestApp {}
51
+
52
+ describe('WebSocket Gateways (Bun.serve, SubscribeMessage, MessageBody, ConnectedSocket)', () => {
53
+ let app: any;
54
+ const PORT = 3889;
55
+
56
+ beforeAll(async () => {
57
+ app = await CalyxFactory.create(TestApp);
58
+ await app.listen(PORT);
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await app.close();
63
+ });
64
+
65
+ test('should connect and handle message exchange on shared port', async () => {
66
+ clientConnected = false;
67
+ clientDisconnected = false;
68
+ messageReceived = null;
69
+
70
+ const ws = new WebSocket(`ws://localhost:${PORT}`);
71
+
72
+ // Wait for connection to open
73
+ await new Promise<void>((resolve, reject) => {
74
+ ws.onopen = () => resolve();
75
+ ws.onerror = (err) => reject(err);
76
+ });
77
+
78
+ expect(clientConnected).toBe(true);
79
+
80
+ // Send a message
81
+ ws.send(JSON.stringify({ event: 'events', data: 'hello calyx' }));
82
+
83
+ // Wait for response message
84
+ const response = await new Promise<any>((resolve) => {
85
+ ws.onmessage = (event) => {
86
+ resolve(JSON.parse(event.data));
87
+ };
88
+ });
89
+
90
+ expect(messageReceived).toBe('hello calyx');
91
+ expect(response).toEqual({ event: 'events', data: 'echo: hello calyx' });
92
+
93
+ // Close connection
94
+ ws.close();
95
+
96
+ // Wait a tick for disconnect event
97
+ await new Promise((resolve) => setTimeout(resolve, 50));
98
+ expect(clientDisconnected).toBe(true);
99
+ });
100
+
101
+ test('should connect and handle message exchange on dedicated port', async () => {
102
+ dedicatedConnected = false;
103
+
104
+ const ws = new WebSocket('ws://localhost:3912');
105
+
106
+ await new Promise<void>((resolve, reject) => {
107
+ ws.onopen = () => resolve();
108
+ ws.onerror = (err) => reject(err);
109
+ });
110
+
111
+ expect(dedicatedConnected).toBe(true);
112
+
113
+ ws.send(JSON.stringify({ event: 'dedicated_event', data: 'secure channel' }));
114
+
115
+ const response = await new Promise<any>((resolve) => {
116
+ ws.onmessage = (event) => {
117
+ resolve(JSON.parse(event.data));
118
+ };
119
+ });
120
+
121
+ expect(response).toEqual({ event: 'dedicated_response', data: 'dedicated echo: secure channel' });
122
+
123
+ ws.close();
124
+ });
125
+ });