@martel/calyx 1.8.0 → 1.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +71 -27
  3. package/benchmarks/graphql-benchmark.ts +81 -0
  4. package/benchmarks/index.ts +32 -0
  5. package/benchmarks/openapi-benchmark.ts +168 -0
  6. package/benchmarks/serialization-benchmark.ts +52 -0
  7. package/benchmarks/techniques-benchmark.ts +84 -0
  8. package/benchmarks/validation-benchmark.ts +74 -0
  9. package/bun.lock +11 -0
  10. package/package.json +7 -6
  11. package/src/cli/index.ts +19 -3
  12. package/src/compression/compression.middleware.ts +7 -0
  13. package/src/cookies/cookies.ts +69 -0
  14. package/src/database/mongoose.module.ts +250 -0
  15. package/src/database/typeorm.module.ts +276 -0
  16. package/src/file-upload/file-upload.interceptor.ts +93 -0
  17. package/src/file-upload/index.ts +1 -0
  18. package/src/graphql/decorators.ts +70 -0
  19. package/src/graphql/graphql.module.ts +401 -57
  20. package/src/http/application.ts +434 -74
  21. package/src/http-client/http-client.module.ts +124 -0
  22. package/src/http-client/index.ts +1 -0
  23. package/src/index.ts +14 -0
  24. package/src/logger/index.ts +1 -0
  25. package/src/logger/logger.service.ts +118 -0
  26. package/src/mvc/index.ts +1 -0
  27. package/src/mvc/mvc.ts +22 -0
  28. package/src/openapi/decorators.ts +154 -0
  29. package/src/openapi/swagger.module.ts +172 -20
  30. package/src/queue/queue.module.ts +174 -0
  31. package/src/session/index.ts +1 -0
  32. package/src/session/session.middleware.ts +82 -0
  33. package/src/sse/index.ts +1 -0
  34. package/src/sse/sse.ts +18 -0
  35. package/src/streaming/index.ts +1 -0
  36. package/src/streaming/streamable-file.ts +32 -0
  37. package/src/validation/pipe.ts +79 -10
  38. package/src/versioning/versioning.ts +46 -0
  39. package/tests/graphql.test.ts +245 -6
  40. package/tests/openapi.test.ts +78 -11
  41. package/tests/techniques.test.ts +471 -0
@@ -11,6 +11,12 @@ 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
13
  import { SerializationCompiler } from '../validation/compiler.ts';
14
+ import { VersioningOptions, VersioningType, VersionExtractor, VERSION_METADATA_KEY } from '../versioning/versioning.ts';
15
+ import { QueueManager, PROCESSOR_METADATA_KEY } from '../queue/queue.module.ts';
16
+ import { parseCookies, formatCookie } from '../cookies/cookies.ts';
17
+ import { StreamableFile } from '../streaming/streamable-file.ts';
18
+ import { defaultRenderEngine, ViewEngine } from '../mvc/mvc.ts';
19
+ import { join } from 'path';
14
20
 
15
21
  class ObjectPool<T> {
16
22
  private pool: T[] = [];
@@ -53,6 +59,13 @@ export class CalyxResponse {
53
59
  this.headers[name.toLowerCase()] = value;
54
60
  return this;
55
61
  }
62
+
63
+ cookiesList: string[] = [];
64
+
65
+ cookie(name: string, value: string, options?: any) {
66
+ this.cookiesList.push(formatCookie(name, value, options));
67
+ return this;
68
+ }
56
69
  }
57
70
 
58
71
  interface CompiledLifecycleItem {
@@ -85,6 +98,8 @@ interface HandlerConfig {
85
98
  interceptorsList: CompiledLifecycleItem[];
86
99
  filtersList: CompiledLifecycleItem[];
87
100
  middlewaresList: CompiledLifecycleItem[];
101
+ renderTemplate?: string;
102
+ isSse?: boolean;
88
103
  }
89
104
 
90
105
  export class CalyxApplication {
@@ -105,6 +120,32 @@ export class CalyxApplication {
105
120
  private serverPort = 3000;
106
121
  private graphqlSchema: any = null;
107
122
  private isInitialized = false;
123
+ private versioningOptions?: VersioningOptions;
124
+
125
+ enableVersioning(options: VersioningOptions) {
126
+ this.versioningOptions = options;
127
+ return this;
128
+ }
129
+
130
+ private compressionEnabled = false;
131
+
132
+ enableCompression() {
133
+ this.compressionEnabled = true;
134
+ return this;
135
+ }
136
+
137
+ private viewsDir = 'views';
138
+ private viewEngine: ViewEngine = defaultRenderEngine;
139
+
140
+ setViewsDir(dir: string) {
141
+ this.viewsDir = dir;
142
+ return this;
143
+ }
144
+
145
+ setViewEngine(engine: ViewEngine) {
146
+ this.viewEngine = engine;
147
+ return this;
148
+ }
108
149
 
109
150
  use(...middlewares: any[]) {
110
151
  this.globalMiddlewares.push(...middlewares);
@@ -155,6 +196,9 @@ export class CalyxApplication {
155
196
  // Register Event listeners
156
197
  this.registerEventListeners();
157
198
 
199
+ // Register Queue Processors
200
+ this.registerQueueProcessors();
201
+
158
202
  // Register Scheduled tasks
159
203
  this.registerScheduledTasks();
160
204
 
@@ -259,26 +303,64 @@ export class CalyxApplication {
259
303
  hasReqScopedPipe ||
260
304
  hasReqScopedMiddleware;
261
305
 
262
- this.router.insert(routeMeta.method, fullPath, {
263
- controllerClass,
264
- moduleClass,
265
- instance: singletonInstance,
266
- methodName: method,
267
- paramsConfig: compiledParamsConfig,
268
- httpCode,
269
- headers,
270
- redirect,
271
- hasBodyParam,
272
- hasQueryParam,
273
- hasResParam,
274
- isPassthrough,
275
- isRequestScoped: isRouteRequestScoped,
276
- hasLifecycleMetadata,
277
- guardsList,
278
- interceptorsList,
279
- filtersList,
280
- middlewaresList,
281
- });
306
+ const methodFn = controllerClass.prototype[method];
307
+ // Compile versions if versioning is enabled
308
+ let versions: string[] = [];
309
+ if (this.versioningOptions) {
310
+ const rawVersion = Reflect.getMetadata(VERSION_METADATA_KEY, controllerClass.prototype, method) ??
311
+ Reflect.getMetadata(VERSION_METADATA_KEY, methodFn) ??
312
+ Reflect.getMetadata(VERSION_METADATA_KEY, controllerClass);
313
+ if (rawVersion !== undefined && rawVersion !== null) {
314
+ versions = Array.isArray(rawVersion) ? rawVersion : [rawVersion];
315
+ } else if (this.versioningOptions.defaultVersion !== undefined && this.versioningOptions.defaultVersion !== null) {
316
+ const defaultV = this.versioningOptions.defaultVersion;
317
+ versions = Array.isArray(defaultV) ? defaultV : [defaultV];
318
+ }
319
+ }
320
+
321
+ const renderTemplate = Reflect.getMetadata('calyx:render_template', controllerClass.prototype, method) ??
322
+ Reflect.getMetadata('calyx:render_template', methodFn);
323
+ const isSse = Reflect.hasMetadata('calyx:sse', controllerClass.prototype, method) ||
324
+ Reflect.hasMetadata('calyx:sse', methodFn);
325
+
326
+ const insertRoute = (methodStr: string, pathStr: string) => {
327
+ this.router.insert(methodStr, pathStr, {
328
+ controllerClass,
329
+ moduleClass,
330
+ instance: singletonInstance,
331
+ methodName: method,
332
+ paramsConfig: compiledParamsConfig,
333
+ httpCode,
334
+ headers,
335
+ redirect,
336
+ hasBodyParam,
337
+ hasQueryParam,
338
+ hasResParam,
339
+ isPassthrough,
340
+ isRequestScoped: isRouteRequestScoped,
341
+ hasLifecycleMetadata,
342
+ guardsList,
343
+ interceptorsList,
344
+ filtersList,
345
+ middlewaresList,
346
+ renderTemplate,
347
+ isSse,
348
+ });
349
+ };
350
+
351
+ if (this.versioningOptions && this.versioningOptions.type === VersioningType.URI && versions.length > 0) {
352
+ for (const version of versions) {
353
+ const versionPrefix = `/v${version}`;
354
+ const versionedPath = '/' + [versionPrefix.replace(/^\/|\/$/g, ''), normalizedPrefix, normalizedPath].filter(Boolean).join('/');
355
+ insertRoute(routeMeta.method, versionedPath);
356
+ }
357
+ } else if (this.versioningOptions && (this.versioningOptions.type === VersioningType.HEADER || this.versioningOptions.type === VersioningType.MEDIA_TYPE) && versions.length > 0) {
358
+ for (const version of versions) {
359
+ insertRoute(`${routeMeta.method}:v${version}`, fullPath);
360
+ }
361
+ } else {
362
+ insertRoute(routeMeta.method, fullPath);
363
+ }
282
364
  }
283
365
  }
284
366
  }
@@ -396,6 +478,10 @@ export class CalyxApplication {
396
478
  private handleRequest(req: Request): Response | Promise<Response> {
397
479
  try {
398
480
  const urlStr = req.url;
481
+ // Parse cookies
482
+ const cookieHeader = req.headers.get('cookie') || '';
483
+ (req as any).cookies = parseCookies(cookieHeader);
484
+
399
485
  // Fast path parsing
400
486
  let pathname = '/';
401
487
  let search = '';
@@ -416,7 +502,16 @@ export class CalyxApplication {
416
502
  return this.handleGraphQLRequest(req);
417
503
  }
418
504
 
419
- const matched = this.router.match(req.method, pathname);
505
+ let matched = null;
506
+ if (this.versioningOptions && this.versioningOptions.type !== VersioningType.URI) {
507
+ const version = VersionExtractor.extract(req, this.versioningOptions.type, this.versioningOptions);
508
+ if (version) {
509
+ matched = this.router.match(`${req.method}:v${version}`.toUpperCase(), pathname);
510
+ }
511
+ }
512
+ if (!matched) {
513
+ matched = this.router.match(req.method, pathname);
514
+ }
420
515
  if (!matched) {
421
516
  if (this.globalMiddlewares.length > 0) {
422
517
  return this.handleGlobalMiddlewaresAnd404(req, pathname);
@@ -484,6 +579,8 @@ export class CalyxApplication {
484
579
  } catch {
485
580
  body = {};
486
581
  }
582
+ } else if (contentType.includes('multipart/form-data')) {
583
+ body = null;
487
584
  } else {
488
585
  try {
489
586
  body = await req.text();
@@ -576,39 +673,41 @@ export class CalyxApplication {
576
673
  query = {};
577
674
  }
578
675
 
579
- // Execute pipes for each argument
580
- const args: any[] = [];
581
- for (const config of paramsConfig) {
582
- let val: any;
583
- switch (config.type) {
584
- case 'req': val = req; break;
585
- case 'res': val = resWrapper; break;
586
- case 'param': val = config.name ? params[config.name] : params; break;
587
- case 'query': val = config.name ? query?.[config.name] : query; break;
588
- case 'body': val = config.name ? body?.[config.name] : body; break;
589
- case 'headers': val = config.name ? req.headers.get(config.name) : Object.fromEntries(req.headers.entries()); break;
590
- case 'custom': val = config.factory ? config.factory(config.name, context) : undefined; break;
591
- }
676
+ const resolveArgs = async (): Promise<any[]> => {
677
+ const args: any[] = [];
678
+ for (const config of paramsConfig) {
679
+ let val: any;
680
+ switch (config.type) {
681
+ case 'req': val = req; break;
682
+ case 'res': val = resWrapper; break;
683
+ case 'param': val = config.name ? params[config.name] : params; break;
684
+ case 'query': val = config.name ? query?.[config.name] : query; break;
685
+ case 'body': val = config.name ? body?.[config.name] : body; break;
686
+ case 'headers': val = config.name ? req.headers.get(config.name) : Object.fromEntries(req.headers.entries()); break;
687
+ case 'custom': val = config.factory ? config.factory(config.name, context) : undefined; break;
688
+ }
592
689
 
593
- // Apply pipes
594
- for (const pipe of config.pipesList) {
595
- const pipeInstance = pipe.isReqScoped
596
- ? this.container.resolveTokenInModuleContext(moduleClass, pipe.token, requestContext)
597
- : pipe.instance;
598
- const transformed = pipeInstance.transform(val, {
599
- type: config.type,
600
- metatype: config.paramType,
601
- data: config.name,
602
- });
603
- if (transformed instanceof Promise) {
604
- val = await transformed;
605
- } else {
606
- val = transformed;
690
+ // Apply pipes
691
+ for (const pipe of config.pipesList) {
692
+ const pipeInstance = pipe.isReqScoped
693
+ ? this.container.resolveTokenInModuleContext(moduleClass, pipe.token, requestContext)
694
+ : pipe.instance;
695
+ const transformed = pipeInstance.transform(val, {
696
+ type: config.type,
697
+ metatype: config.paramType,
698
+ data: config.name,
699
+ });
700
+ if (transformed instanceof Promise) {
701
+ val = await transformed;
702
+ } else {
703
+ val = transformed;
704
+ }
607
705
  }
608
- }
609
706
 
610
- args[config.index] = val;
611
- }
707
+ args[config.index] = val;
708
+ }
709
+ return args;
710
+ };
612
711
 
613
712
  let result: any;
614
713
  if (handler.interceptorsList.length > 0) {
@@ -621,21 +720,23 @@ export class CalyxApplication {
621
720
  : interceptor.instance;
622
721
  return interceptorInstance.intercept(context, { handle: () => executeHandler() });
623
722
  }
723
+ const args = await resolveArgs();
624
724
  return instance[methodName](...args);
625
725
  };
626
726
  result = await executeHandler();
627
727
  } else {
728
+ const args = await resolveArgs();
628
729
  result = instance[methodName](...args);
629
730
  if (result instanceof Promise) {
630
731
  result = await result;
631
732
  }
632
733
  }
633
734
 
634
- if (this.isObservable(result)) {
735
+ if (this.isObservable(result) && !handler.isSse) {
635
736
  result = await this.resolveObservable(result);
636
737
  }
637
738
 
638
- return this.processResult(req, result, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam);
739
+ return this.processResult(req, result, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam, handler.renderTemplate, handler.isSse);
639
740
  } catch (err: any) {
640
741
  return await this.handleLifecycleError(err, host, handler, requestContext);
641
742
  } finally {
@@ -775,6 +876,8 @@ export class CalyxApplication {
775
876
  } catch {
776
877
  body = {};
777
878
  }
879
+ } else if (contentType.includes('multipart/form-data')) {
880
+ body = null;
778
881
  } else {
779
882
  try {
780
883
  body = await req.text();
@@ -844,15 +947,54 @@ export class CalyxApplication {
844
947
 
845
948
  let result = instance[methodName](...args);
846
949
 
847
- if (this.isObservable(result)) {
950
+ if (this.isObservable(result) && !handler.isSse) {
848
951
  result = this.resolveObservable(result);
849
952
  }
850
953
 
851
954
  if (result instanceof Promise) {
852
- return result.then((resolvedResult) => this.processResult(req, resolvedResult, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam));
955
+ return result.then((resolvedResult) => this.processResult(req, resolvedResult, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam, handler.renderTemplate, handler.isSse));
853
956
  }
854
957
 
855
- return this.processResult(req, result, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam);
958
+ return this.processResult(req, result, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam, handler.renderTemplate, handler.isSse);
959
+ }
960
+
961
+ private compressResponseIfNeeded(req: Request, body: any, headers: Headers, resWrapper: CalyxResponse | null): any {
962
+ const isEnabled = this.compressionEnabled || (resWrapper && (resWrapper as any).compressionEnabled);
963
+ if (!isEnabled) return body;
964
+
965
+ const acceptEncoding = req.headers.get('accept-encoding') || '';
966
+ if (!acceptEncoding.includes('gzip')) return body;
967
+
968
+ if (!body) return body;
969
+
970
+ const contentType = headers.get('content-type') || '';
971
+ const isCompressible = contentType.includes('text/') ||
972
+ contentType.includes('json') ||
973
+ contentType.includes('javascript') ||
974
+ contentType.includes('xml') ||
975
+ contentType === '';
976
+
977
+ if (!isCompressible) return body;
978
+
979
+ try {
980
+ let dataToCompress: Uint8Array;
981
+ if (typeof body === 'string') {
982
+ dataToCompress = new TextEncoder().encode(body);
983
+ } else if (body instanceof Uint8Array) {
984
+ dataToCompress = body;
985
+ } else if (body instanceof ArrayBuffer) {
986
+ dataToCompress = new Uint8Array(body);
987
+ } else {
988
+ dataToCompress = new TextEncoder().encode(JSON.stringify(body));
989
+ }
990
+
991
+ const compressed = Bun.gzipSync(dataToCompress);
992
+ headers.set('content-encoding', 'gzip');
993
+ headers.set('content-length', String(compressed.length));
994
+ return compressed;
995
+ } catch {
996
+ return body;
997
+ }
856
998
  }
857
999
 
858
1000
  private processResult(
@@ -863,23 +1005,47 @@ export class CalyxApplication {
863
1005
  headers: { name: string; value: string }[],
864
1006
  redirect: { url: string; statusCode?: number } | undefined,
865
1007
  isPassthrough = false,
866
- hasResParam = false
867
- ): Response {
1008
+ hasResParam = false,
1009
+ renderTemplate?: string,
1010
+ isSse = false
1011
+ ): Response | Promise<Response> {
868
1012
  if (redirect) {
869
1013
  return Response.redirect(redirect.url, redirect.statusCode || 302);
870
1014
  }
871
1015
 
872
1016
  if (resWrapper) {
873
1017
  if (resWrapper.sent && !isPassthrough) {
874
- return new Response(resWrapper.body, {
1018
+ const responseHeaders = new Headers();
1019
+ for (const [k, v] of Object.entries(resWrapper.headers)) {
1020
+ responseHeaders.set(k, v);
1021
+ }
1022
+ for (const c of resWrapper.cookiesList) {
1023
+ responseHeaders.append('Set-Cookie', c);
1024
+ }
1025
+ if ((req as any).finalizeSession) {
1026
+ (req as any).finalizeSession(responseHeaders);
1027
+ }
1028
+ const finalBody = this.compressResponseIfNeeded(req, resWrapper.body, responseHeaders, resWrapper);
1029
+ return new Response(finalBody, {
875
1030
  status: resWrapper.statusCode,
876
- headers: resWrapper.headers,
1031
+ headers: responseHeaders,
877
1032
  });
878
1033
  }
879
1034
  if (hasResParam && !isPassthrough) {
880
- return new Response(resWrapper.body, {
1035
+ const responseHeaders = new Headers();
1036
+ for (const [k, v] of Object.entries(resWrapper.headers)) {
1037
+ responseHeaders.set(k, v);
1038
+ }
1039
+ for (const c of resWrapper.cookiesList) {
1040
+ responseHeaders.append('Set-Cookie', c);
1041
+ }
1042
+ if ((req as any).finalizeSession) {
1043
+ (req as any).finalizeSession(responseHeaders);
1044
+ }
1045
+ const finalBody = this.compressResponseIfNeeded(req, resWrapper.body, responseHeaders, resWrapper);
1046
+ return new Response(finalBody, {
881
1047
  status: resWrapper.statusCode,
882
- headers: resWrapper.headers,
1048
+ headers: responseHeaders,
883
1049
  });
884
1050
  }
885
1051
  }
@@ -889,12 +1055,73 @@ export class CalyxApplication {
889
1055
  : (httpCode ?? (req.method === 'POST' ? 201 : 200));
890
1056
 
891
1057
  // Build headers as a plain object Record<string, string>
892
- const responseHeaders: Record<string, string> = resWrapper
893
- ? { ...resWrapper.headers }
894
- : {};
1058
+ const responseHeaders = new Headers();
1059
+ if (resWrapper) {
1060
+ for (const [k, v] of Object.entries(resWrapper.headers)) {
1061
+ responseHeaders.set(k, v);
1062
+ }
1063
+ for (const c of resWrapper.cookiesList) {
1064
+ responseHeaders.append('Set-Cookie', c);
1065
+ }
1066
+ }
1067
+
1068
+ if ((req as any).finalizeSession) {
1069
+ (req as any).finalizeSession(responseHeaders);
1070
+ }
895
1071
 
896
1072
  for (const header of headers) {
897
- responseHeaders[header.name.toLowerCase()] = header.value;
1073
+ responseHeaders.set(header.name.toLowerCase(), header.value);
1074
+ }
1075
+
1076
+ if (isSse) {
1077
+ responseHeaders.set('content-type', 'text/event-stream');
1078
+ responseHeaders.set('cache-control', 'no-cache');
1079
+ responseHeaders.set('connection', 'keep-alive');
1080
+
1081
+ let subscription: any;
1082
+ const stream = new ReadableStream({
1083
+ start(controller) {
1084
+ subscription = result.subscribe({
1085
+ next(event: any) {
1086
+ let msg = '';
1087
+ if (event.id) msg += `id: ${event.id}\n`;
1088
+ if (event.type) msg += `event: ${event.type}\n`;
1089
+ if (event.retry) msg += `retry: ${event.retry}\n`;
1090
+ const dataStr = typeof event.data === 'object' ? JSON.stringify(event.data) : String(event.data);
1091
+ msg += `data: ${dataStr}\n\n`;
1092
+ controller.enqueue(new TextEncoder().encode(msg));
1093
+ },
1094
+ error(err: any) {
1095
+ controller.error(err);
1096
+ },
1097
+ complete() {
1098
+ controller.close();
1099
+ }
1100
+ });
1101
+ },
1102
+ cancel() {
1103
+ if (subscription) {
1104
+ subscription.unsubscribe();
1105
+ }
1106
+ }
1107
+ });
1108
+
1109
+ return new Response(stream, { status, headers: responseHeaders });
1110
+ }
1111
+
1112
+ if (renderTemplate) {
1113
+ const templatePath = join(this.viewsDir, renderTemplate + (renderTemplate.includes('.') ? '' : '.html'));
1114
+ const renderResult = this.viewEngine(templatePath, result);
1115
+ if (renderResult instanceof Promise) {
1116
+ return renderResult.then((html) => {
1117
+ responseHeaders.set('content-type', 'text/html');
1118
+ const finalBody = this.compressResponseIfNeeded(req, html, responseHeaders, resWrapper);
1119
+ return new Response(finalBody, { status, headers: responseHeaders });
1120
+ });
1121
+ }
1122
+ responseHeaders.set('content-type', 'text/html');
1123
+ const finalBody = this.compressResponseIfNeeded(req, renderResult, responseHeaders, resWrapper);
1124
+ return new Response(finalBody, { status, headers: responseHeaders });
898
1125
  }
899
1126
 
900
1127
  if (result === undefined || result === null) {
@@ -902,24 +1129,46 @@ export class CalyxApplication {
902
1129
  }
903
1130
 
904
1131
  if (result instanceof Response) {
1132
+ if (resWrapper && resWrapper.cookiesList.length > 0) {
1133
+ for (const c of resWrapper.cookiesList) {
1134
+ result.headers.append('Set-Cookie', c);
1135
+ }
1136
+ }
1137
+ if ((req as any).finalizeSession) {
1138
+ (req as any).finalizeSession(result.headers);
1139
+ }
905
1140
  return result;
906
1141
  }
907
1142
 
1143
+ if (result instanceof StreamableFile) {
1144
+ const fileHeaders = result.getHeaders();
1145
+ for (const [k, v] of Object.entries(fileHeaders)) {
1146
+ responseHeaders.set(k, v);
1147
+ }
1148
+ const finalBody = this.compressResponseIfNeeded(req, result.getStream(), responseHeaders, resWrapper);
1149
+ return new Response(finalBody, { status, headers: responseHeaders });
1150
+ }
1151
+
908
1152
  if (typeof result === 'object') {
909
- responseHeaders['content-type'] = 'application/json';
1153
+ responseHeaders.set('content-type', 'application/json');
910
1154
  const constructor = result.constructor;
911
1155
  if (constructor) {
912
1156
  const hasRules = Reflect.hasMetadata('calyx:validation_rules', constructor);
913
1157
  const hasExpose = Reflect.hasMetadata('calyx:expose_properties', constructor);
914
1158
  if (hasRules || hasExpose) {
915
1159
  const serialize = SerializationCompiler.compile(constructor);
916
- return new Response(serialize(result), { status, headers: responseHeaders });
1160
+ const serialized = serialize(result);
1161
+ const finalBody = this.compressResponseIfNeeded(req, serialized, responseHeaders, resWrapper);
1162
+ return new Response(finalBody, { status, headers: responseHeaders });
917
1163
  }
918
1164
  }
919
- return new Response(JSON.stringify(result), { status, headers: responseHeaders });
1165
+ const serialized = JSON.stringify(result);
1166
+ const finalBody = this.compressResponseIfNeeded(req, serialized, responseHeaders, resWrapper);
1167
+ return new Response(finalBody, { status, headers: responseHeaders });
920
1168
  }
921
1169
 
922
- return new Response(String(result), { status, headers: responseHeaders });
1170
+ const finalBody = this.compressResponseIfNeeded(req, String(result), responseHeaders, resWrapper);
1171
+ return new Response(finalBody, { status, headers: responseHeaders });
923
1172
  }
924
1173
 
925
1174
  private isObservable(obj: any): boolean {
@@ -970,6 +1219,7 @@ export class CalyxApplication {
970
1219
  const result = await graphql({
971
1220
  schema: this.graphqlSchema,
972
1221
  source: query,
1222
+ contextValue: { req },
973
1223
  variableValues: variables,
974
1224
  });
975
1225
 
@@ -993,10 +1243,20 @@ export class CalyxApplication {
993
1243
  this.buildRoutes();
994
1244
  await this.init();
995
1245
 
1246
+ if (this.graphqlSchema) {
1247
+ this.hasWebSockets = true;
1248
+ }
1249
+
996
1250
  const fetchHandler = (req: Request, server: any) => {
997
- if (this.hasWebSockets && req.headers.get('upgrade') === 'websocket') {
998
- const success = server.upgrade(req);
999
- if (success) return undefined;
1251
+ const url = new URL(req.url);
1252
+ if (req.headers.get('upgrade') === 'websocket') {
1253
+ if (url.pathname === '/graphql' && this.graphqlSchema) {
1254
+ const success = server.upgrade(req, { data: { isGraphQL: true, req } });
1255
+ if (success) return undefined;
1256
+ } else if (this.hasWebSockets) {
1257
+ const success = server.upgrade(req);
1258
+ if (success) return undefined;
1259
+ }
1000
1260
  }
1001
1261
  return this.handleRequest(req);
1002
1262
  };
@@ -1009,16 +1269,34 @@ export class CalyxApplication {
1009
1269
  if (this.hasWebSockets) {
1010
1270
  serveOptions.websocket = {
1011
1271
  open: (ws: any) => {
1272
+ if (ws.data?.isGraphQL) {
1273
+ ws.data.subscriptions = new Map();
1274
+ return;
1275
+ }
1012
1276
  for (const gateway of this.sharedWebSockets) {
1013
1277
  this.dispatchWsConnection(gateway, ws);
1014
1278
  }
1015
1279
  },
1016
- message: (ws: any, message: any) => {
1280
+ message: async (ws: any, message: any) => {
1281
+ if (ws.data?.isGraphQL) {
1282
+ await this.handleGraphQLWsMessage(ws, message);
1283
+ return;
1284
+ }
1017
1285
  for (const gateway of this.sharedWebSockets) {
1018
1286
  this.dispatchWsMessage(gateway, ws, message);
1019
1287
  }
1020
1288
  },
1021
1289
  close: (ws: any) => {
1290
+ if (ws.data?.isGraphQL) {
1291
+ if (ws.data.subscriptions) {
1292
+ for (const sub of ws.data.subscriptions.values()) {
1293
+ if (typeof sub.return === 'function') {
1294
+ sub.return();
1295
+ }
1296
+ }
1297
+ }
1298
+ return;
1299
+ }
1022
1300
  for (const gateway of this.sharedWebSockets) {
1023
1301
  this.dispatchWsDisconnect(gateway, ws);
1024
1302
  }
@@ -1030,6 +1308,77 @@ export class CalyxApplication {
1030
1308
  return this.server;
1031
1309
  }
1032
1310
 
1311
+ private async handleGraphQLWsMessage(ws: any, message: any) {
1312
+ try {
1313
+ const data = JSON.parse(typeof message === 'string' ? message : message.toString());
1314
+
1315
+ switch (data.type) {
1316
+ case 'connection_init': {
1317
+ ws.send(JSON.stringify({ type: 'connection_ack' }));
1318
+ break;
1319
+ }
1320
+ case 'subscribe': {
1321
+ const { id, payload } = data;
1322
+ const { query, variables } = payload;
1323
+
1324
+ const { subscribe, parse } = await import('graphql');
1325
+
1326
+ const subResult = await subscribe({
1327
+ schema: this.graphqlSchema,
1328
+ document: parse(query),
1329
+ variableValues: variables,
1330
+ contextValue: { req: ws.data?.req },
1331
+ });
1332
+
1333
+ if (subResult && Symbol.asyncIterator in subResult) {
1334
+ ws.data.subscriptions.set(id, subResult);
1335
+ (async () => {
1336
+ try {
1337
+ for await (const val of subResult) {
1338
+ ws.send(JSON.stringify({
1339
+ type: 'next',
1340
+ id,
1341
+ payload: val,
1342
+ }));
1343
+ }
1344
+ ws.send(JSON.stringify({
1345
+ type: 'complete',
1346
+ id,
1347
+ }));
1348
+ } catch (err: any) {
1349
+ ws.send(JSON.stringify({
1350
+ type: 'error',
1351
+ id,
1352
+ payload: [{ message: err.message }],
1353
+ }));
1354
+ }
1355
+ })();
1356
+ } else {
1357
+ ws.send(JSON.stringify({
1358
+ type: 'error',
1359
+ id,
1360
+ payload: subResult.errors || [{ message: 'GraphQL validation failed' }],
1361
+ }));
1362
+ }
1363
+ break;
1364
+ }
1365
+ case 'complete': {
1366
+ const { id } = data;
1367
+ const sub = ws.data.subscriptions.get(id);
1368
+ if (sub) {
1369
+ if (typeof sub.return === 'function') {
1370
+ sub.return();
1371
+ }
1372
+ ws.data.subscriptions.delete(id);
1373
+ }
1374
+ break;
1375
+ }
1376
+ }
1377
+ } catch (err: any) {
1378
+ console.error('GraphQL WS error:', err);
1379
+ }
1380
+ }
1381
+
1033
1382
  private cleanupListeners: (() => void)[] = [];
1034
1383
 
1035
1384
  enableShutdownHooks(signals: string[] = ['SIGTERM', 'SIGINT']) {
@@ -1104,6 +1453,17 @@ export class CalyxApplication {
1104
1453
  }
1105
1454
  }
1106
1455
 
1456
+ private registerQueueProcessors() {
1457
+ const instances = this.container.getProviderAndControllerInstances();
1458
+ for (const instance of instances) {
1459
+ if (!instance || !instance.constructor) continue;
1460
+ const queueName = Reflect.getMetadata(PROCESSOR_METADATA_KEY, instance.constructor);
1461
+ if (queueName) {
1462
+ QueueManager.registerProcessor(queueName, instance);
1463
+ }
1464
+ }
1465
+ }
1466
+
1107
1467
  private registerEventListeners() {
1108
1468
  let eventEmitter: EventEmitter;
1109
1469
  try {