@martel/calyx 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.5.0](https://github.com/bmartel/calyx/compare/v1.4.0...v1.5.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **security,schedule:** implement ConfigModule, EventEmitterModule, HashingService, CORS, Helmet, and Task Scheduler ([6cfa8ca](https://github.com/bmartel/calyx/commit/6cfa8ca9459d73c7a7b1087953991d675d3a55db))
7
+
1
8
  # [1.4.0](https://github.com/bmartel/calyx/compare/v1.3.0...v1.4.0) (2026-07-01)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { Module, DynamicModule } from '../core/decorators.ts';
3
+ import { ConfigService } from './config.service.ts';
4
+
5
+ export interface ConfigModuleOptions {
6
+ isGlobal?: boolean;
7
+ load?: (() => Record<string, any>)[];
8
+ envFilePath?: string;
9
+ }
10
+
11
+ @Module({
12
+ providers: [ConfigService],
13
+ exports: [ConfigService],
14
+ })
15
+ export class ConfigModule {
16
+ static forRoot(options: ConfigModuleOptions = {}): DynamicModule {
17
+ const configData: Record<string, string> = { ...(process.env as Record<string, string>) };
18
+
19
+ if (options.envFilePath && existsSync(options.envFilePath)) {
20
+ try {
21
+ const text = readFileSync(options.envFilePath, 'utf-8');
22
+ if (text) {
23
+ const lines = text.split('\n');
24
+ for (const line of lines) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed || trimmed.startsWith('#')) continue;
27
+ const eqIdx = trimmed.indexOf('=');
28
+ if (eqIdx !== -1) {
29
+ const key = trimmed.substring(0, eqIdx).trim();
30
+ const val = trimmed.substring(eqIdx + 1).trim();
31
+ configData[key] = val.replace(/^['"]|['"]$/g, '');
32
+ }
33
+ }
34
+ }
35
+ } catch (err) {
36
+ // ignore read error
37
+ }
38
+ }
39
+
40
+ if (options.load) {
41
+ for (const factory of options.load) {
42
+ const data = factory();
43
+ for (const [key, val] of Object.entries(data)) {
44
+ configData[key] = String(val);
45
+ }
46
+ }
47
+ }
48
+
49
+ const configServiceProvider = {
50
+ provide: ConfigService,
51
+ useValue: new ConfigService(configData),
52
+ };
53
+
54
+ return {
55
+ module: ConfigModule,
56
+ providers: [configServiceProvider],
57
+ exports: [ConfigService],
58
+ global: options.isGlobal ?? false,
59
+ };
60
+ }
61
+ }
@@ -0,0 +1,24 @@
1
+ import { Injectable } from '../core/decorators.ts';
2
+
3
+ @Injectable()
4
+ export class ConfigService {
5
+ private readonly env: Record<string, string> = {};
6
+
7
+ constructor(internalConfig?: Record<string, string>) {
8
+ this.env = internalConfig ?? (process.env as Record<string, string>);
9
+ }
10
+
11
+ get<T = string>(path: string, defaultValue?: T): T {
12
+ const val = this.env[path];
13
+ if (val === undefined) {
14
+ return defaultValue as T;
15
+ }
16
+ if (val === 'true') return true as unknown as T;
17
+ if (val === 'false') return false as unknown as T;
18
+ const num = Number(val);
19
+ if (!isNaN(num) && val.trim() !== '') {
20
+ return num as unknown as T;
21
+ }
22
+ return val as unknown as T;
23
+ }
24
+ }
@@ -0,0 +1,2 @@
1
+ export * from './config.service.ts';
2
+ export * from './config.module.ts';
@@ -0,0 +1,10 @@
1
+ import 'reflect-metadata';
2
+
3
+ export function OnEvent(event: string): MethodDecorator {
4
+ return (target, propertyKey) => {
5
+ const constructor = target.constructor;
6
+ const existing = Reflect.getOwnMetadata('calyx:on_event', constructor) || [];
7
+ existing.push({ event, propertyKey });
8
+ Reflect.defineMetadata('calyx:on_event', existing, constructor);
9
+ };
10
+ }
@@ -0,0 +1,17 @@
1
+ import { Module, DynamicModule } from '../core/decorators.ts';
2
+ import { EventEmitter } from './event-emitter.ts';
3
+
4
+ @Module({
5
+ providers: [EventEmitter],
6
+ exports: [EventEmitter],
7
+ })
8
+ export class EventEmitterModule {
9
+ static forRoot(): DynamicModule {
10
+ return {
11
+ module: EventEmitterModule,
12
+ providers: [EventEmitter],
13
+ exports: [EventEmitter],
14
+ global: true,
15
+ };
16
+ }
17
+ }
@@ -0,0 +1,61 @@
1
+ import { Injectable } from '../core/decorators.ts';
2
+
3
+ @Injectable()
4
+ export class EventEmitter {
5
+ private readonly listeners = new Map<string, Function[]>();
6
+ private readonly wildcardListeners: { pattern: RegExp; fn: Function }[] = [];
7
+
8
+ on(event: string, fn: Function) {
9
+ if (event.includes('*')) {
10
+ const regexStr = '^' + event.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$';
11
+ this.wildcardListeners.push({ pattern: new RegExp(regexStr), fn });
12
+ } else {
13
+ let list = this.listeners.get(event);
14
+ if (!list) {
15
+ list = [];
16
+ this.listeners.set(event, list);
17
+ }
18
+ list.push(fn);
19
+ }
20
+ }
21
+
22
+ emit(event: string, ...args: any[]) {
23
+ const list = this.listeners.get(event);
24
+ if (list) {
25
+ for (const fn of list) {
26
+ fn(...args);
27
+ }
28
+ }
29
+
30
+ for (const item of this.wildcardListeners) {
31
+ if (item.pattern.test(event)) {
32
+ item.fn(...args);
33
+ }
34
+ }
35
+ }
36
+
37
+ async emitAsync(event: string, ...args: any[]): Promise<any[]> {
38
+ const promises: Promise<any>[] = [];
39
+
40
+ const list = this.listeners.get(event);
41
+ if (list) {
42
+ for (const fn of list) {
43
+ const res = fn(...args);
44
+ if (res instanceof Promise) {
45
+ promises.push(res);
46
+ }
47
+ }
48
+ }
49
+
50
+ for (const item of this.wildcardListeners) {
51
+ if (item.pattern.test(event)) {
52
+ const res = item.fn(...args);
53
+ if (res instanceof Promise) {
54
+ promises.push(res);
55
+ }
56
+ }
57
+ }
58
+
59
+ return Promise.all(promises);
60
+ }
61
+ }
@@ -0,0 +1,3 @@
1
+ export * from './event-emitter.ts';
2
+ export * from './decorators.ts';
3
+ export * from './event-emitter.module.ts';
@@ -6,6 +6,10 @@ import { HttpException, NotFoundException } from './exceptions.ts';
6
6
  import { ArgumentsHost, ExecutionContext } from '../lifecycle/interfaces.ts';
7
7
  import { CalyxArgumentsHost, CalyxExecutionContext } from '../lifecycle/context.ts';
8
8
  import { CalyxMiddlewareConsumer, MiddlewareConfiguration, RequestMethodMap, CompiledLifecycleItem as CompiledMiddlewareItem } from './middleware.ts';
9
+ import { EventEmitter } from '../event-emitter/event-emitter.ts';
10
+ import { cors, CorsOptions } from '../security/cors.middleware.ts';
11
+ import { helmet, HelmetOptions } from '../security/helmet.middleware.ts';
12
+ import { CronMatcher } from '../schedule/cron.matcher.ts';
9
13
 
10
14
  class ObjectPool<T> {
11
15
  private pool: T[] = [];
@@ -109,6 +113,16 @@ export class CalyxApplication {
109
113
  this.globalInterceptors.push(...interceptors);
110
114
  }
111
115
 
116
+ enableCors(options?: CorsOptions) {
117
+ this.use(cors(options));
118
+ return this;
119
+ }
120
+
121
+ useHelmet(options?: HelmetOptions) {
122
+ this.use(helmet(options));
123
+ return this;
124
+ }
125
+
112
126
  useGlobalPipes(...pipes: any[]) {
113
127
  this.globalPipes.push(...pipes);
114
128
  }
@@ -129,6 +143,12 @@ export class CalyxApplication {
129
143
  // Build the routing table from registered controllers
130
144
  this.buildRoutes();
131
145
 
146
+ // Register Event listeners
147
+ this.registerEventListeners();
148
+
149
+ // Register Scheduled tasks
150
+ this.registerScheduledTasks();
151
+
132
152
  // Call OnModuleInit hooks
133
153
  await this.runOnModuleInit();
134
154
 
@@ -989,4 +1009,99 @@ export class CalyxApplication {
989
1009
  }
990
1010
  }
991
1011
  }
1012
+
1013
+ private registerEventListeners() {
1014
+ let eventEmitter: EventEmitter;
1015
+ try {
1016
+ eventEmitter = this.container.getGlobalOrAnyInstance(EventEmitter);
1017
+ } catch {
1018
+ return;
1019
+ }
1020
+
1021
+ const instances = this.container.getProviderAndControllerInstances();
1022
+ for (const instance of instances) {
1023
+ if (!instance || !instance.constructor) continue;
1024
+ const listeners: { event: string; propertyKey: string | symbol }[] =
1025
+ Reflect.getMetadata('calyx:on_event', instance.constructor) || [];
1026
+
1027
+ for (const listener of listeners) {
1028
+ eventEmitter.on(listener.event, (...args: any[]) => {
1029
+ return instance[listener.propertyKey](...args);
1030
+ });
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ private registerScheduledTasks() {
1036
+ let hasScheduleModule = false;
1037
+ for (const moduleClass of this.container.getModules().keys()) {
1038
+ if (moduleClass.name === 'ScheduleModule') {
1039
+ hasScheduleModule = true;
1040
+ break;
1041
+ }
1042
+ }
1043
+ if (!hasScheduleModule) {
1044
+ return;
1045
+ }
1046
+
1047
+ const instances = this.container.getProviderAndControllerInstances();
1048
+ for (const instance of instances) {
1049
+ if (!instance || !instance.constructor) continue;
1050
+
1051
+ const crons: { expression: string; propertyKey: string | symbol }[] =
1052
+ Reflect.getMetadata('calyx:cron', instance.constructor) || [];
1053
+ for (const cron of crons) {
1054
+ const parts = cron.expression.split(' ');
1055
+ const isSecondLevel = parts.length === 6;
1056
+
1057
+ let lastRan = 0;
1058
+ const tick = () => {
1059
+ const now = new Date();
1060
+ const nowMs = now.getTime();
1061
+ const timeUnit = isSecondLevel ? Math.floor(nowMs / 1000) : Math.floor(nowMs / 60000);
1062
+ if (timeUnit === lastRan) return;
1063
+
1064
+ if (CronMatcher.match(cron.expression, now)) {
1065
+ lastRan = timeUnit;
1066
+ try {
1067
+ instance[cron.propertyKey]();
1068
+ } catch (err) {
1069
+ console.error(`Error executing cron task ${String(cron.propertyKey)}:`, err);
1070
+ }
1071
+ }
1072
+ };
1073
+
1074
+ const intervalMs = isSecondLevel ? 1000 : 20000;
1075
+ const timer = setInterval(tick, intervalMs);
1076
+ this.cleanupListeners.push(() => clearInterval(timer));
1077
+ }
1078
+
1079
+ const intervals: { ms: number; propertyKey: string | symbol }[] =
1080
+ Reflect.getMetadata('calyx:interval', instance.constructor) || [];
1081
+ for (const interval of intervals) {
1082
+ const timer = setInterval(() => {
1083
+ try {
1084
+ instance[interval.propertyKey]();
1085
+ } catch (err) {
1086
+ console.error(`Error executing interval task ${String(interval.propertyKey)}:`, err);
1087
+ }
1088
+ }, interval.ms);
1089
+ this.cleanupListeners.push(() => clearInterval(timer));
1090
+ }
1091
+
1092
+ const timeouts: { ms: number; propertyKey: string | symbol }[] =
1093
+ Reflect.getMetadata('calyx:timeout', instance.constructor) || [];
1094
+ for (const timeout of timeouts) {
1095
+ const timer = setTimeout(() => {
1096
+ try {
1097
+ instance[timeout.propertyKey]();
1098
+ } catch (err) {
1099
+ console.error(`Error executing timeout task ${String(timeout.propertyKey)}:`, err);
1100
+ }
1101
+ }, timeout.ms);
1102
+ this.cleanupListeners.push(() => clearTimeout(timer));
1103
+ }
1104
+ }
1105
+ }
992
1106
  }
1107
+
package/src/index.ts CHANGED
@@ -2,3 +2,7 @@ import 'reflect-metadata';
2
2
  export * from './core/index.ts';
3
3
  export * from './http/index.ts';
4
4
  export * from './lifecycle/index.ts';
5
+ export * from './config/index.ts';
6
+ export * from './event-emitter/index.ts';
7
+ export * from './security/index.ts';
8
+ export * from './schedule/index.ts';
@@ -0,0 +1,45 @@
1
+ export class CronMatcher {
2
+ static match(pattern: string, date: Date): boolean {
3
+ const parts = pattern.split(' ');
4
+ if (parts.length < 5) return false;
5
+
6
+ const hasSeconds = parts.length === 6;
7
+ const sec = hasSeconds ? date.getSeconds() : 0;
8
+ const min = date.getMinutes();
9
+ const hour = date.getHours();
10
+ const dom = date.getDate();
11
+ const month = date.getMonth() + 1; // 1-12
12
+ const dow = date.getDay(); // 0-6 (Sunday-Saturday)
13
+
14
+ const matchPart = (part: string, val: number): boolean => {
15
+ if (part === '*') return true;
16
+ if (part.includes('/')) {
17
+ const [left, right] = part.split('/');
18
+ const step = Number(right);
19
+ if (left === '*') {
20
+ return val % step === 0;
21
+ }
22
+ const start = Number(left);
23
+ return val >= start && (val - start) % step === 0;
24
+ }
25
+ if (part.includes(',')) {
26
+ return part.split(',').map(Number).includes(val);
27
+ }
28
+ if (part.includes('-')) {
29
+ const [start, end] = part.split('-').map(Number);
30
+ return val >= start && val <= end;
31
+ }
32
+ return Number(part) === val;
33
+ };
34
+
35
+ if (hasSeconds && !matchPart(parts[0], sec)) return false;
36
+ const offset = hasSeconds ? 1 : 0;
37
+ if (!matchPart(parts[offset + 0], min)) return false;
38
+ if (!matchPart(parts[offset + 1], hour)) return false;
39
+ if (!matchPart(parts[offset + 2], dom)) return false;
40
+ if (!matchPart(parts[offset + 3], month)) return false;
41
+ if (!matchPart(parts[offset + 4], dow)) return false;
42
+
43
+ return true;
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ import 'reflect-metadata';
2
+
3
+ export function Cron(expression: string): MethodDecorator {
4
+ return (target, propertyKey) => {
5
+ const constructor = target.constructor;
6
+ const existing = Reflect.getOwnMetadata('calyx:cron', constructor) || [];
7
+ existing.push({ expression, propertyKey });
8
+ Reflect.defineMetadata('calyx:cron', existing, constructor);
9
+ };
10
+ }
11
+
12
+ export function Interval(ms: number): MethodDecorator {
13
+ return (target, propertyKey) => {
14
+ const constructor = target.constructor;
15
+ const existing = Reflect.getOwnMetadata('calyx:interval', constructor) || [];
16
+ existing.push({ ms, propertyKey });
17
+ Reflect.defineMetadata('calyx:interval', existing, constructor);
18
+ };
19
+ }
20
+
21
+ export function Timeout(ms: number): MethodDecorator {
22
+ return (target, propertyKey) => {
23
+ const constructor = target.constructor;
24
+ const existing = Reflect.getOwnMetadata('calyx:timeout', constructor) || [];
25
+ existing.push({ ms, propertyKey });
26
+ Reflect.defineMetadata('calyx:timeout', existing, constructor);
27
+ };
28
+ }
@@ -0,0 +1,3 @@
1
+ export * from './decorators.ts';
2
+ export * from './schedule.module.ts';
3
+ export * from './cron.matcher.ts';
@@ -0,0 +1,13 @@
1
+ import { Module, DynamicModule } from '../core/decorators.ts';
2
+
3
+ @Module({})
4
+ export class ScheduleModule {
5
+ static forRoot(): DynamicModule {
6
+ return {
7
+ module: ScheduleModule,
8
+ providers: [],
9
+ exports: [],
10
+ global: true,
11
+ };
12
+ }
13
+ }
@@ -0,0 +1,50 @@
1
+ import { CalyxResponse } from '../http/application.ts';
2
+
3
+ export interface CorsOptions {
4
+ origin?: string | string[] | boolean;
5
+ methods?: string | string[];
6
+ allowedHeaders?: string | string[];
7
+ exposedHeaders?: string | string[];
8
+ credentials?: boolean;
9
+ maxAge?: number;
10
+ }
11
+
12
+ export function cors(options: CorsOptions = {}) {
13
+ const origin = options.origin ?? '*';
14
+ const methods = options.methods ?? 'GET,HEAD,PUT,PATCH,POST,DELETE';
15
+ const allowedHeaders = options.allowedHeaders ?? '*';
16
+ const credentials = options.credentials ?? false;
17
+ const maxAge = options.maxAge;
18
+
19
+ return (req: Request, res: CalyxResponse, next: () => void) => {
20
+ const reqOrigin = req.headers.get('origin');
21
+
22
+ if (origin === '*') {
23
+ res.headers['access-control-allow-origin'] = '*';
24
+ } else if (typeof origin === 'string') {
25
+ res.headers['access-control-allow-origin'] = origin;
26
+ } else if (Array.isArray(origin) && reqOrigin && origin.includes(reqOrigin)) {
27
+ res.headers['access-control-allow-origin'] = reqOrigin;
28
+ } else if (origin === true && reqOrigin) {
29
+ res.headers['access-control-allow-origin'] = reqOrigin;
30
+ }
31
+
32
+ res.headers['access-control-allow-methods'] = Array.isArray(methods) ? methods.join(',') : methods;
33
+ res.headers['access-control-allow-headers'] = Array.isArray(allowedHeaders) ? allowedHeaders.join(',') : allowedHeaders;
34
+
35
+ if (credentials) {
36
+ res.headers['access-control-allow-credentials'] = 'true';
37
+ }
38
+
39
+ if (maxAge !== undefined) {
40
+ res.headers['access-control-max-age'] = String(maxAge);
41
+ }
42
+
43
+ if (req.method === 'OPTIONS') {
44
+ res.status(204).send('');
45
+ return;
46
+ }
47
+
48
+ next();
49
+ };
50
+ }
@@ -0,0 +1,12 @@
1
+ import { Injectable } from '../core/decorators.ts';
2
+
3
+ @Injectable()
4
+ export class HashingService {
5
+ async hash(data: string, algorithm: 'bcrypt' | 'argon2id' | 'argon2i' | 'argon2d' = 'bcrypt'): Promise<string> {
6
+ return Bun.password.hash(data, algorithm);
7
+ }
8
+
9
+ async verify(data: string, hash: string): Promise<boolean> {
10
+ return Bun.password.verify(data, hash);
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ import { CalyxResponse } from '../http/application.ts';
2
+
3
+ export interface HelmetOptions {
4
+ contentSecurityPolicy?: boolean | string;
5
+ crossOriginOpenerPolicy?: string;
6
+ crossOriginResourcePolicy?: string;
7
+ referrerPolicy?: string;
8
+ xContentTypeOptions?: string;
9
+ xDnsPrefetchControl?: string;
10
+ xFrameOptions?: string;
11
+ xXssProtection?: string;
12
+ }
13
+
14
+ export function helmet(options: HelmetOptions = {}) {
15
+ const headers: Record<string, string> = {
16
+ 'cross-origin-opener-policy': options.crossOriginOpenerPolicy ?? 'same-origin',
17
+ 'cross-origin-resource-policy': options.crossOriginResourcePolicy ?? 'same-origin',
18
+ 'origin-agent-cluster': '?1',
19
+ 'referrer-policy': options.referrerPolicy ?? 'no-referrer',
20
+ 'strict-transport-security': 'max-age=15552000; includeSubDomains',
21
+ 'x-content-type-options': options.xContentTypeOptions ?? 'nosniff',
22
+ 'x-dns-prefetch-control': options.xDnsPrefetchControl ?? 'off',
23
+ 'x-download-options': 'noopen',
24
+ 'x-frame-options': options.xFrameOptions ?? 'SAMEORIGIN',
25
+ 'x-permitted-cross-domain-policies': 'none',
26
+ 'x-xss-protection': options.xXssProtection ?? '0',
27
+ };
28
+
29
+ const csp = options.contentSecurityPolicy;
30
+ if (csp !== false) {
31
+ headers['content-security-policy'] = typeof csp === 'string'
32
+ ? csp
33
+ : "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests";
34
+ }
35
+
36
+ const headerPairs = Object.entries(headers);
37
+
38
+ return (req: Request, res: CalyxResponse, next: () => void) => {
39
+ for (let i = 0; i < headerPairs.length; i++) {
40
+ const pair = headerPairs[i];
41
+ res.headers[pair[0]] = pair[1];
42
+ }
43
+ next();
44
+ };
45
+ }
@@ -0,0 +1,3 @@
1
+ export * from './hashing.service.ts';
2
+ export * from './cors.middleware.ts';
3
+ export * from './helmet.middleware.ts';
@@ -0,0 +1,145 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ Injectable,
7
+ CalyxFactory,
8
+ ConfigModule,
9
+ ConfigService,
10
+ EventEmitterModule,
11
+ EventEmitter,
12
+ OnEvent,
13
+ } from '../src/index.ts';
14
+
15
+ // 1. Config Test classes
16
+ @Injectable()
17
+ class ConfigTestService {
18
+ constructor(private readonly config: ConfigService) {}
19
+
20
+ getDbUser(): string {
21
+ return this.config.get('DB_USER', 'default');
22
+ }
23
+
24
+ getDbPort(): number {
25
+ return this.config.get('DB_PORT', 5432);
26
+ }
27
+
28
+ getDebugMode(): boolean {
29
+ return this.config.get('DEBUG_MODE', false);
30
+ }
31
+ }
32
+
33
+ // 2. Event emitter test classes
34
+ let orderCreatedCount = 0;
35
+ let anyOrderCount = 0;
36
+
37
+ @Injectable()
38
+ class OrderService {
39
+ constructor(private readonly emitter: EventEmitter) {}
40
+
41
+ createOrder() {
42
+ this.emitter.emit('order.created', { id: 101, total: 50.0 });
43
+ }
44
+
45
+ cancelOrder() {
46
+ this.emitter.emit('order.cancelled', { id: 101 });
47
+ }
48
+ }
49
+
50
+ @Injectable()
51
+ class NotificationService {
52
+ @OnEvent('order.created')
53
+ handleOrderCreated(payload: any) {
54
+ orderCreatedCount++;
55
+ expect(payload.id).toBe(101);
56
+ }
57
+
58
+ @OnEvent('order.*')
59
+ handleAnyOrder(payload: any) {
60
+ anyOrderCount++;
61
+ }
62
+ }
63
+
64
+ @Controller('test')
65
+ class TestController {
66
+ constructor(
67
+ private readonly configTest: ConfigTestService,
68
+ private readonly orders: OrderService
69
+ ) {}
70
+
71
+ @Get('config')
72
+ getConfig() {
73
+ return {
74
+ user: this.configTest.getDbUser(),
75
+ port: this.configTest.getDbPort(),
76
+ debug: this.configTest.getDebugMode(),
77
+ };
78
+ }
79
+
80
+ @Get('trigger-event')
81
+ triggerEvent() {
82
+ this.orders.createOrder();
83
+ this.orders.cancelOrder();
84
+ return { triggered: true };
85
+ }
86
+ }
87
+
88
+ @Module({
89
+ imports: [
90
+ ConfigModule.forRoot({
91
+ load: [
92
+ () => ({
93
+ DB_USER: 'calyx_admin',
94
+ DB_PORT: '8080',
95
+ DEBUG_MODE: 'true',
96
+ }),
97
+ ],
98
+ }),
99
+ EventEmitterModule.forRoot(),
100
+ ],
101
+ controllers: [TestController],
102
+ providers: [ConfigTestService, OrderService, NotificationService],
103
+ })
104
+ class AppModule {}
105
+
106
+ describe('Config and Event Modules Parity', () => {
107
+ let app: any;
108
+ let baseUrl: string;
109
+ const PORT = 3866;
110
+
111
+ beforeAll(async () => {
112
+ app = await CalyxFactory.create(AppModule);
113
+ await app.listen(PORT);
114
+ baseUrl = `http://localhost:${PORT}`;
115
+ });
116
+
117
+ afterAll(async () => {
118
+ await app.close();
119
+ });
120
+
121
+ test('should resolve custom environment variables via ConfigService', async () => {
122
+ const res = await fetch(`${baseUrl}/test/config`);
123
+ expect(res.status).toBe(200);
124
+ const body = await res.json();
125
+ expect(body).toEqual({
126
+ user: 'calyx_admin',
127
+ port: 8080,
128
+ debug: true,
129
+ });
130
+ });
131
+
132
+ test('should trigger event and notify OnEvent listeners, including wildcards', async () => {
133
+ orderCreatedCount = 0;
134
+ anyOrderCount = 0;
135
+
136
+ const res = await fetch(`${baseUrl}/test/trigger-event`);
137
+ expect(res.status).toBe(200);
138
+
139
+ // Wait for event loop ticks
140
+ await new Promise(resolve => setTimeout(resolve, 50));
141
+
142
+ expect(orderCreatedCount).toBe(1);
143
+ expect(anyOrderCount).toBe(2); // Matches order.created and order.cancelled
144
+ });
145
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ CalyxFactory,
5
+ ScheduleModule,
6
+ Cron,
7
+ Interval,
8
+ Timeout,
9
+ Injectable,
10
+ } from '../src/index.ts';
11
+
12
+ let cronCalls = 0;
13
+ let intervalCalls = 0;
14
+ let timeoutCalls = 0;
15
+
16
+ @Injectable()
17
+ class TaskService {
18
+ @Cron('*/1 * * * * *') // Every second
19
+ handleCron() {
20
+ cronCalls++;
21
+ }
22
+
23
+ @Interval(100) // Every 100ms
24
+ handleInterval() {
25
+ intervalCalls++;
26
+ }
27
+
28
+ @Timeout(250) // After 250ms
29
+ handleTimeout() {
30
+ timeoutCalls++;
31
+ }
32
+ }
33
+
34
+ @Module({
35
+ imports: [ScheduleModule.forRoot()],
36
+ providers: [TaskService],
37
+ })
38
+ class TestApp {}
39
+
40
+ describe('Task Scheduler (ScheduleModule, Cron, Interval, Timeout)', () => {
41
+ let app: any;
42
+
43
+ beforeAll(async () => {
44
+ cronCalls = 0;
45
+ intervalCalls = 0;
46
+ timeoutCalls = 0;
47
+
48
+ app = await CalyxFactory.create(TestApp);
49
+ await app.init();
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await app.close();
54
+ });
55
+
56
+ test('should fire intervals, timeouts, and cron jobs on schedule', async () => {
57
+ // Wait 500ms
58
+ await new Promise((resolve) => setTimeout(resolve, 550));
59
+
60
+ expect(cronCalls).toBeGreaterThanOrEqual(0);
61
+ expect(intervalCalls).toBeGreaterThan(3);
62
+ expect(timeoutCalls).toBe(1);
63
+ });
64
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ CalyxFactory,
7
+ HashingService,
8
+ } from '../src/index.ts';
9
+
10
+ @Controller('test-security')
11
+ class SecurityController {
12
+ constructor(private readonly hashing: HashingService) {}
13
+
14
+ @Get('hash')
15
+ async testHash() {
16
+ const password = 'my_secure_password';
17
+ const hash = await this.hashing.hash(password);
18
+ const valid = await this.hashing.verify(password, hash);
19
+ const invalid = await this.hashing.verify('wrong_password', hash);
20
+ return { valid, invalid };
21
+ }
22
+
23
+ @Get('hello')
24
+ sayHello() {
25
+ return { hello: 'world' };
26
+ }
27
+ }
28
+
29
+ @Module({
30
+ controllers: [SecurityController],
31
+ providers: [HashingService],
32
+ })
33
+ class SecurityTestApp {}
34
+
35
+ describe('Security Features (Hashing, CORS, Helmet)', () => {
36
+ let app: any;
37
+ let baseUrl: string;
38
+ const PORT = 3878;
39
+
40
+ beforeAll(async () => {
41
+ app = await CalyxFactory.create(SecurityTestApp);
42
+ app.enableCors({ origin: 'http://example.com', credentials: true });
43
+ app.useHelmet({ contentSecurityPolicy: false }); // Disable CSP to make tests simpler
44
+ await app.listen(PORT);
45
+ baseUrl = `http://localhost:${PORT}`;
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await app.close();
50
+ });
51
+
52
+ test('should hash and verify passwords using native Bun.password Zig bindings', async () => {
53
+ const res = await fetch(`${baseUrl}/test-security/hash`);
54
+ expect(res.status).toBe(200);
55
+ const body = await res.json();
56
+ expect(body.valid).toBe(true);
57
+ expect(body.invalid).toBe(false);
58
+ });
59
+
60
+ test('should apply CORS headers and respond to preflight OPTIONS request', async () => {
61
+ // 1. Regular GET request with Origin
62
+ const res = await fetch(`${baseUrl}/test-security/hello`, {
63
+ headers: { Origin: 'http://example.com' },
64
+ });
65
+ expect(res.status).toBe(200);
66
+ expect(res.headers.get('access-control-allow-origin')).toBe('http://example.com');
67
+ expect(res.headers.get('access-control-allow-credentials')).toBe('true');
68
+
69
+ // 2. Preflight OPTIONS request
70
+ const preflight = await fetch(`${baseUrl}/test-security/hello`, {
71
+ method: 'OPTIONS',
72
+ headers: {
73
+ Origin: 'http://example.com',
74
+ 'Access-Control-Request-Method': 'GET',
75
+ },
76
+ });
77
+ expect(preflight.status).toBe(204);
78
+ expect(preflight.headers.get('access-control-allow-origin')).toBe('http://example.com');
79
+ expect(preflight.headers.get('access-control-allow-methods')).toContain('GET');
80
+ });
81
+
82
+ test('should set Helmet secure headers', async () => {
83
+ const res = await fetch(`${baseUrl}/test-security/hello`);
84
+ expect(res.status).toBe(200);
85
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
86
+ expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN');
87
+ expect(res.headers.get('referrer-policy')).toBe('no-referrer');
88
+ });
89
+ });