@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 +7 -0
- package/package.json +1 -1
- package/src/http/application.ts +221 -3
- package/src/http/factory.ts +7 -0
- package/src/index.ts +2 -0
- package/src/lifecycle/context.ts +75 -0
- package/src/lifecycle/interfaces.ts +3 -0
- package/src/microservices/client-proxy.ts +7 -0
- package/src/microservices/client-tcp.ts +127 -0
- package/src/microservices/decorators.ts +19 -0
- package/src/microservices/index.ts +6 -0
- package/src/microservices/interfaces.ts +11 -0
- package/src/microservices/microservice.ts +114 -0
- package/src/microservices/server-tcp.ts +210 -0
- package/src/websockets/decorators.ts +49 -0
- package/src/websockets/gateway.ts +11 -0
- package/src/websockets/index.ts +2 -0
- package/tests/microservices.test.ts +105 -0
- package/tests/rpc-ws-context.test.ts +135 -0
- package/tests/websockets.test.ts +125 -0
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
package/src/http/application.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
+
|
package/src/http/factory.ts
CHANGED
|
@@ -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
package/src/lifecycle/context.ts
CHANGED
|
@@ -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,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,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
|
+
});
|