@martel/calyx 1.5.0 → 1.7.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,17 @@
1
+ # [1.7.0](https://github.com/bmartel/calyx/compare/v1.6.0...v1.7.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **cache,validation:** implement SQLite CacheModule, JIT ValidationPipe, and JIT Response Serializer ([1d37b68](https://github.com/bmartel/calyx/commit/1d37b68c2a7ba52e7878810b63f75ee31becc433))
7
+
8
+ # [1.6.0](https://github.com/bmartel/calyx/compare/v1.5.0...v1.6.0) (2026-07-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * **websockets,microservices:** implement WebSocketGateways, TCP Microservices, and context switching for Guards/Interceptors ([a8cde0d](https://github.com/bmartel/calyx/commit/a8cde0d68411a3ab736fe1df00ae04672588b1af))
14
+
1
15
  # [1.5.0](https://github.com/bmartel/calyx/compare/v1.4.0...v1.5.0) (2026-07-01)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -0,0 +1,32 @@
1
+ import { NestInterceptor, ExecutionContext, CallHandler } from '../lifecycle/interfaces.ts';
2
+ import { Injectable } from '../core/decorators.ts';
3
+ import { CacheService } from './cache.service.ts';
4
+
5
+ @Injectable()
6
+ export class CacheInterceptor implements NestInterceptor {
7
+ constructor(private readonly cacheService: CacheService) {}
8
+
9
+ async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
10
+ const type = context.getType();
11
+ if (type !== 'http') {
12
+ return next.handle();
13
+ }
14
+
15
+ const req = context.switchToHttp().getRequest<Request>();
16
+ if (req.method !== 'GET') {
17
+ return next.handle();
18
+ }
19
+
20
+ const url = new URL(req.url);
21
+ const key = `http_cache::${url.pathname}${url.search}`;
22
+
23
+ const cached = await this.cacheService.get(key);
24
+ if (cached !== undefined) {
25
+ return cached;
26
+ }
27
+
28
+ const result = await next.handle();
29
+ await this.cacheService.set(key, result);
30
+ return result;
31
+ }
32
+ }
@@ -0,0 +1,31 @@
1
+ import { Module, DynamicModule } from '../core/decorators.ts';
2
+ import { CacheService } from './cache.service.ts';
3
+ import { CacheInterceptor } from './cache.interceptor.ts';
4
+
5
+ export interface CacheModuleOptions {
6
+ dbPath?: string;
7
+ defaultTtl?: number;
8
+ isGlobal?: boolean;
9
+ }
10
+
11
+ @Module({
12
+ providers: [CacheService, CacheInterceptor],
13
+ exports: [CacheService, CacheInterceptor],
14
+ })
15
+ export class CacheModule {
16
+ static register(options: CacheModuleOptions = {}): DynamicModule {
17
+ const cacheServiceInstance = new CacheService(options);
18
+
19
+ const cacheServiceProvider = {
20
+ provide: CacheService,
21
+ useValue: cacheServiceInstance,
22
+ };
23
+
24
+ return {
25
+ module: CacheModule,
26
+ providers: [cacheServiceProvider, CacheInterceptor],
27
+ exports: [CacheService, CacheInterceptor],
28
+ global: options.isGlobal ?? false,
29
+ };
30
+ }
31
+ }
@@ -0,0 +1,86 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { Injectable } from '../core/decorators.ts';
3
+
4
+ @Injectable()
5
+ export class CacheService {
6
+ private db!: Database;
7
+ private pruneTimer: any;
8
+ private readonly dbPath: string;
9
+ private readonly defaultTtl: number;
10
+
11
+ constructor(options: { dbPath?: string; defaultTtl?: number } = {}) {
12
+ this.dbPath = options.dbPath ?? ':memory:';
13
+ this.defaultTtl = options.defaultTtl ?? 5;
14
+ }
15
+
16
+ onModuleInit() {
17
+ this.db = new Database(this.dbPath);
18
+ this.db.run(`
19
+ CREATE TABLE IF NOT EXISTS cache (
20
+ key TEXT PRIMARY KEY,
21
+ value TEXT,
22
+ expires_at INTEGER
23
+ )
24
+ `);
25
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_expires_at ON cache (expires_at)`);
26
+
27
+ this.pruneTimer = setInterval(() => this.prune(), 30000);
28
+ }
29
+
30
+ onModuleDestroy() {
31
+ if (this.pruneTimer) {
32
+ clearInterval(this.pruneTimer);
33
+ }
34
+ if (this.db) {
35
+ this.db.close();
36
+ }
37
+ }
38
+
39
+ async get<T = any>(key: string): Promise<T | undefined> {
40
+ const row = this.db.query('SELECT value, expires_at FROM cache WHERE key = $key').get({ $key: key }) as any;
41
+ if (!row) return undefined;
42
+
43
+ if (row.expires_at !== null && row.expires_at < Date.now()) {
44
+ this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
45
+ return undefined;
46
+ }
47
+
48
+ try {
49
+ return JSON.parse(row.value) as T;
50
+ } catch {
51
+ return row.value as unknown as T;
52
+ }
53
+ }
54
+
55
+ async set<T = any>(key: string, value: T, ttl?: number): Promise<void> {
56
+ const ttlSeconds = ttl !== undefined ? ttl : this.defaultTtl;
57
+ const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
58
+ const valStr = JSON.stringify(value);
59
+
60
+ this.db.query(`
61
+ INSERT INTO cache (key, value, expires_at)
62
+ VALUES ($key, $val, $expiresAt)
63
+ ON CONFLICT(key) DO UPDATE SET value = $val, expires_at = $expiresAt
64
+ `).run({
65
+ $key: key,
66
+ $val: valStr,
67
+ $expiresAt: expiresAt,
68
+ });
69
+ }
70
+
71
+ async del(key: string): Promise<void> {
72
+ this.db.query('DELETE FROM cache WHERE key = $key').run({ $key: key });
73
+ }
74
+
75
+ async reset(): Promise<void> {
76
+ this.db.run('DELETE FROM cache');
77
+ }
78
+
79
+ private prune() {
80
+ try {
81
+ this.db.query('DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < $now').run({ $now: Date.now() });
82
+ } catch {
83
+ // ignore
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,3 @@
1
+ export * from './cache.service.ts';
2
+ export * from './cache.interceptor.ts';
3
+ export * from './cache.module.ts';
@@ -10,6 +10,7 @@ import { EventEmitter } from '../event-emitter/event-emitter.ts';
10
10
  import { cors, CorsOptions } from '../security/cors.middleware.ts';
11
11
  import { helmet, HelmetOptions } from '../security/helmet.middleware.ts';
12
12
  import { CronMatcher } from '../schedule/cron.matcher.ts';
13
+ import { SerializationCompiler } from '../validation/compiler.ts';
13
14
 
14
15
  class ObjectPool<T> {
15
16
  private pool: T[] = [];
@@ -99,6 +100,9 @@ export class CalyxApplication {
99
100
  private middlewareConfigurations: MiddlewareConfiguration[] = [];
100
101
  private hostPool = new ObjectPool<CalyxArgumentsHost>(() => new CalyxArgumentsHost());
101
102
  private contextPool = new ObjectPool<CalyxExecutionContext>(() => new CalyxExecutionContext());
103
+ private sharedWebSockets: any[] = [];
104
+ private hasWebSockets = false;
105
+ private serverPort = 3000;
102
106
 
103
107
  use(...middlewares: any[]) {
104
108
  this.globalMiddlewares.push(...middlewares);
@@ -149,6 +153,9 @@ export class CalyxApplication {
149
153
  // Register Scheduled tasks
150
154
  this.registerScheduledTasks();
151
155
 
156
+ // Register WebSocket Gateways
157
+ this.registerWebSocketGateways();
158
+
152
159
  // Call OnModuleInit hooks
153
160
  await this.runOnModuleInit();
154
161
 
@@ -882,6 +889,15 @@ export class CalyxApplication {
882
889
 
883
890
  if (typeof result === 'object') {
884
891
  responseHeaders['content-type'] = 'application/json';
892
+ const constructor = result.constructor;
893
+ if (constructor) {
894
+ const hasRules = Reflect.hasMetadata('calyx:validation_rules', constructor);
895
+ const hasExpose = Reflect.hasMetadata('calyx:expose_properties', constructor);
896
+ if (hasRules || hasExpose) {
897
+ const serialize = SerializationCompiler.compile(constructor);
898
+ return new Response(serialize(result), { status, headers: responseHeaders });
899
+ }
900
+ }
885
901
  return new Response(JSON.stringify(result), { status, headers: responseHeaders });
886
902
  }
887
903
 
@@ -928,11 +944,43 @@ export class CalyxApplication {
928
944
  }
929
945
 
930
946
  async listen(port: number): Promise<any> {
947
+ this.serverPort = port;
931
948
  await this.init();
932
- this.server = Bun.serve({
949
+
950
+ const fetchHandler = (req: Request, server: any) => {
951
+ if (this.hasWebSockets && req.headers.get('upgrade') === 'websocket') {
952
+ const success = server.upgrade(req);
953
+ if (success) return undefined;
954
+ }
955
+ return this.handleRequest(req);
956
+ };
957
+
958
+ const serveOptions: any = {
933
959
  port,
934
- fetch: (req) => this.handleRequest(req),
935
- });
960
+ fetch: fetchHandler,
961
+ };
962
+
963
+ if (this.hasWebSockets) {
964
+ serveOptions.websocket = {
965
+ open: (ws: any) => {
966
+ for (const gateway of this.sharedWebSockets) {
967
+ this.dispatchWsConnection(gateway, ws);
968
+ }
969
+ },
970
+ message: (ws: any, message: any) => {
971
+ for (const gateway of this.sharedWebSockets) {
972
+ this.dispatchWsMessage(gateway, ws, message);
973
+ }
974
+ },
975
+ close: (ws: any) => {
976
+ for (const gateway of this.sharedWebSockets) {
977
+ this.dispatchWsDisconnect(gateway, ws);
978
+ }
979
+ }
980
+ };
981
+ }
982
+
983
+ this.server = Bun.serve(serveOptions);
936
984
  return this.server;
937
985
  }
938
986
 
@@ -1103,5 +1151,185 @@ export class CalyxApplication {
1103
1151
  }
1104
1152
  }
1105
1153
  }
1154
+
1155
+ private registerWebSocketGateways() {
1156
+ const instances = this.container.getProviderAndControllerInstances();
1157
+ for (const instance of instances) {
1158
+ if (!instance || !instance.constructor) continue;
1159
+ const metadata = Reflect.getMetadata('calyx:websocket_gateway', instance.constructor);
1160
+ if (!metadata) continue;
1161
+
1162
+ const handlers = new Map<string, { propertyKey: string | symbol; paramMapping: any[] }>();
1163
+
1164
+ const subMessages: { event: string; propertyKey: string | symbol }[] =
1165
+ Reflect.getMetadata('calyx:subscribe_message', instance.constructor) || [];
1166
+
1167
+ const bodyParams: { propertyKey: string | symbol; parameterIndex: number }[] =
1168
+ Reflect.getMetadata('calyx:message_body', instance.constructor) || [];
1169
+
1170
+ const socketParams: { propertyKey: string | symbol; parameterIndex: number }[] =
1171
+ Reflect.getMetadata('calyx:connected_socket', instance.constructor) || [];
1172
+
1173
+ for (const sub of subMessages) {
1174
+ const paramMapping: any[] = [];
1175
+
1176
+ for (const bp of bodyParams) {
1177
+ if (bp.propertyKey === sub.propertyKey) {
1178
+ paramMapping[bp.parameterIndex] = 'body';
1179
+ }
1180
+ }
1181
+ for (const sp of socketParams) {
1182
+ if (sp.propertyKey === sub.propertyKey) {
1183
+ paramMapping[sp.parameterIndex] = 'socket';
1184
+ }
1185
+ }
1186
+
1187
+ const classGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor) || [];
1188
+ const methodGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, instance.constructor.prototype, sub.propertyKey) || [];
1189
+ const guards = this.compileLifecycleItems(this.rootModule, [...this.globalGuards, ...classGuards, ...methodGuards]);
1190
+
1191
+ const classInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor) || [];
1192
+ const methodInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, instance.constructor.prototype, sub.propertyKey) || [];
1193
+ const interceptors = this.compileLifecycleItems(this.rootModule, [...this.globalInterceptors, ...classInterceptors, ...methodInterceptors]);
1194
+
1195
+ handlers.set(sub.event, {
1196
+ propertyKey: sub.propertyKey,
1197
+ paramMapping,
1198
+ guards,
1199
+ interceptors,
1200
+ gatewayClass: instance.constructor,
1201
+ });
1202
+ }
1203
+
1204
+ const gatewayInfo = {
1205
+ instance,
1206
+ metadata,
1207
+ handlers,
1208
+ };
1209
+
1210
+ if (metadata.port && metadata.port !== this.serverPort) {
1211
+ this.startDedicatedWebSocketServer(gatewayInfo);
1212
+ } else {
1213
+ this.sharedWebSockets.push(gatewayInfo);
1214
+ this.hasWebSockets = true;
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ private startDedicatedWebSocketServer(gateway: any) {
1220
+ const port = gateway.metadata.port;
1221
+ const self = this;
1222
+ const wsServer = Bun.serve({
1223
+ port,
1224
+ fetch(req, server) {
1225
+ if (server.upgrade(req)) {
1226
+ return undefined;
1227
+ }
1228
+ return new Response('Expected upgrade to websocket', { status: 400 });
1229
+ },
1230
+ websocket: {
1231
+ open: (ws: any) => {
1232
+ self.dispatchWsConnection(gateway, ws);
1233
+ },
1234
+ message: (ws: any, message: any) => {
1235
+ self.dispatchWsMessage(gateway, ws, message);
1236
+ },
1237
+ close: (ws: any) => {
1238
+ self.dispatchWsDisconnect(gateway, ws);
1239
+ }
1240
+ }
1241
+ });
1242
+
1243
+ this.cleanupListeners.push(() => wsServer.stop());
1244
+
1245
+ if (typeof gateway.instance.afterInit === 'function') {
1246
+ gateway.instance.afterInit(wsServer);
1247
+ }
1248
+ }
1249
+
1250
+ private dispatchWsConnection(gateway: any, ws: any) {
1251
+ if (typeof gateway.instance.handleConnection === 'function') {
1252
+ gateway.instance.handleConnection(ws);
1253
+ }
1254
+ }
1255
+
1256
+ private dispatchWsDisconnect(gateway: any, ws: any) {
1257
+ if (typeof gateway.instance.handleDisconnect === 'function') {
1258
+ gateway.instance.handleDisconnect(ws);
1259
+ }
1260
+ }
1261
+
1262
+ private async dispatchWsMessage(gateway: any, ws: any, message: any) {
1263
+ let messageStr = '';
1264
+ if (typeof message === 'string') {
1265
+ messageStr = message;
1266
+ } else if (message instanceof Uint8Array || message instanceof ArrayBuffer) {
1267
+ messageStr = new TextDecoder().decode(message);
1268
+ } else {
1269
+ return;
1270
+ }
1271
+
1272
+ try {
1273
+ const parsed = JSON.parse(messageStr);
1274
+ const event = parsed.event;
1275
+ const data = parsed.data;
1276
+
1277
+ const handlerInfo = gateway.handlers.get(event);
1278
+ if (handlerInfo) {
1279
+ const args: any[] = [];
1280
+ for (let i = 0; i < handlerInfo.paramMapping.length; i++) {
1281
+ const type = handlerInfo.paramMapping[i];
1282
+ if (type === 'body') {
1283
+ args[i] = data;
1284
+ } else if (type === 'socket') {
1285
+ args[i] = ws;
1286
+ }
1287
+ }
1288
+
1289
+ const context = this.contextPool.acquire();
1290
+ context.resetContextWs(ws, data, handlerInfo.gatewayClass, gateway.instance[handlerInfo.propertyKey]);
1291
+
1292
+ try {
1293
+ for (const guard of handlerInfo.guards) {
1294
+ const canActive = await guard.instance.canActivate(context);
1295
+ if (!canActive) {
1296
+ return; // Block event
1297
+ }
1298
+ }
1299
+
1300
+ const nextCall = {
1301
+ handle: async () => {
1302
+ return gateway.instance[handlerInfo.propertyKey](...args);
1303
+ }
1304
+ };
1305
+
1306
+ let chain = nextCall.handle;
1307
+ for (let i = handlerInfo.interceptors.length - 1; i >= 0; i--) {
1308
+ const interceptor = handlerInfo.interceptors[i];
1309
+ const currentChain = chain;
1310
+ chain = async () => {
1311
+ return interceptor.instance.intercept(context, {
1312
+ handle: () => currentChain()
1313
+ });
1314
+ };
1315
+ }
1316
+
1317
+ const result = await chain();
1318
+
1319
+ if (result !== undefined) {
1320
+ ws.send(JSON.stringify(result));
1321
+ }
1322
+ } catch (err: any) {
1323
+ // ignore or log
1324
+ } finally {
1325
+ context.clearContext();
1326
+ this.contextPool.release(context);
1327
+ }
1328
+ }
1329
+ } catch {
1330
+ // ignore non-json
1331
+ }
1332
+ }
1106
1333
  }
1107
1334
 
1335
+
@@ -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,7 @@ 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';
11
+ export * from './cache/index.ts';
12
+ export * from './validation/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
+ }