@martel/calyx 1.13.0 → 1.13.1
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/README.md +5 -3
- package/benchmarks/graphql-benchmark.ts +4 -1
- package/benchmarks/serialization-benchmark.ts +46 -6
- package/benchmarks/techniques-benchmark.ts +12 -0
- package/benchmarks/validation-benchmark.ts +8 -1
- package/docs/controllers.md +3 -3
- package/docs/dependency-injection.md +3 -3
- package/docs/lifecycle.md +3 -3
- package/package.json +1 -1
- package/src/cookies/cookies.ts +45 -8
- package/src/graphql/graphql.module.ts +2 -4
- package/src/http/application.ts +5 -4
- package/src/validation/compiler.ts +4 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.13.1](https://github.com/bmartel/calyx/compare/v1.13.0...v1.13.1) (2026-07-01)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Performance Improvements
|
|
5
|
+
|
|
6
|
+
* optimize request execution paths and add warmup to benchmarks ([f6327c7](https://github.com/bmartel/calyx/commit/f6327c72395d9e6264df27e08800dd16479f8ff7))
|
|
7
|
+
|
|
1
8
|
# [1.13.0](https://github.com/bmartel/calyx/compare/v1.12.0...v1.13.0) (2026-07-01)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -147,10 +147,12 @@ Measured using process-isolated benchmarks comparing Calyx vs NestJS:
|
|
|
147
147
|
|
|
148
148
|
| Benchmark | Calyx (Bun Native) | NestJS (Express on Bun) | Speedup | Latency Improvement |
|
|
149
149
|
| :--- | :--- | :--- | :--- | :--- |
|
|
150
|
-
| **DI Bootstrapping** | **
|
|
151
|
-
| **Raw Route Throughput** | **
|
|
152
|
-
| **Lifecycle Pipeline (Guards/Pipes)** | **
|
|
150
|
+
| **DI Bootstrapping** | **13.20 μs** | 222.02 μs | **16.82x faster** | N/A |
|
|
151
|
+
| **Raw Route Throughput** | **55,108 req/sec** | 23,794 req/sec | **2.32x faster** | **2.3x lower** |
|
|
152
|
+
| **Lifecycle Pipeline (Guards/Pipes)** | **37,100 req/sec** | 9,880 req/sec | **3.76x faster** | **3.7x lower** |
|
|
153
153
|
| **DTO Validation (JIT)** | **96,153,846 ops/s** | 120,000 ops/s (class-validator) | **800x faster** | **800x lower** |
|
|
154
|
+
| **Response Serialization (JIT)** | **21,739,130 ops/s** | 1,740,341 ops/s (class-transformer) | **12.49x faster** | **12.4x lower** |
|
|
155
|
+
| **Swagger Document Build** | **65,796 docs/s** | 4,703 docs/s | **13.99x faster** | **13.9x lower** |
|
|
154
156
|
|
|
155
157
|
---
|
|
156
158
|
|
|
@@ -61,7 +61,10 @@ async function runBenchmark() {
|
|
|
61
61
|
`;
|
|
62
62
|
|
|
63
63
|
// Warmup
|
|
64
|
-
|
|
64
|
+
console.log('Warming up GraphQL query execution JIT...');
|
|
65
|
+
for (let i = 0; i < 1000; i++) {
|
|
66
|
+
await graphql({ schema, source: query });
|
|
67
|
+
}
|
|
65
68
|
|
|
66
69
|
const execStart = performance.now();
|
|
67
70
|
for (let i = 0; i < iterations; i++) {
|
|
@@ -21,9 +21,36 @@ class UserResponseDto {
|
|
|
21
21
|
// Compile JIT Serializer
|
|
22
22
|
const serialize = SerializationCompiler.compile(UserResponseDto);
|
|
23
23
|
|
|
24
|
-
const ITERATIONS =
|
|
24
|
+
const ITERATIONS = 5_000_000;
|
|
25
25
|
const testUser = new UserResponseDto(123, 'alice', 'secret_hash_code_here');
|
|
26
26
|
|
|
27
|
+
// Simulated NestJS Class-Transformer reflection-based classToPlain mapping
|
|
28
|
+
function nestjsClassToPlain(obj: any, targetClass: any): any {
|
|
29
|
+
const keys = Object.keys(obj);
|
|
30
|
+
const plain: Record<string, any> = {};
|
|
31
|
+
for (let i = 0; i < keys.length; i++) {
|
|
32
|
+
const key = keys[i];
|
|
33
|
+
const exposes = Reflect.getMetadata('calyx:expose_properties', targetClass);
|
|
34
|
+
const excludes = Reflect.getMetadata('calyx:exclude_properties', targetClass);
|
|
35
|
+
const isExposed = exposes ? exposes.has(key) : true;
|
|
36
|
+
const isExcluded = excludes ? excludes.has(key) : false;
|
|
37
|
+
|
|
38
|
+
if (isExposed && !isExcluded) {
|
|
39
|
+
plain[key] = obj[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return plain;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Warmup
|
|
46
|
+
console.log('Warming up JIT compiler...');
|
|
47
|
+
for (let i = 0; i < 500_000; i++) {
|
|
48
|
+
serialize(testUser);
|
|
49
|
+
const plain = { id: testUser.id, username: testUser.username };
|
|
50
|
+
JSON.stringify(plain);
|
|
51
|
+
nestjsClassToPlain(testUser, UserResponseDto);
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
console.log(`Running Serialization Benchmark with ${ITERATIONS.toLocaleString()} iterations...\n`);
|
|
28
55
|
|
|
29
56
|
// Benchmark Calyx JIT Serializer
|
|
@@ -35,10 +62,9 @@ const endJit = Date.now();
|
|
|
35
62
|
const timeJit = endJit - startJit;
|
|
36
63
|
const opsJit = Math.round((ITERATIONS / timeJit) * 1000);
|
|
37
64
|
|
|
38
|
-
// Benchmark Standard JSON.stringify (
|
|
65
|
+
// Benchmark Standard JSON.stringify (manual plain object conversion baseline)
|
|
39
66
|
const startJson = Date.now();
|
|
40
67
|
for (let i = 0; i < ITERATIONS; i++) {
|
|
41
|
-
// To simulate key exclusion manually or using a replacer/omit function like NestJS does
|
|
42
68
|
const plain = { id: testUser.id, username: testUser.username };
|
|
43
69
|
JSON.stringify(plain);
|
|
44
70
|
}
|
|
@@ -46,7 +72,21 @@ const endJson = Date.now();
|
|
|
46
72
|
const timeJson = endJson - startJson;
|
|
47
73
|
const opsJson = Math.round((ITERATIONS / timeJson) * 1000);
|
|
48
74
|
|
|
75
|
+
// Benchmark NestJS class-transformer + JSON.stringify parity
|
|
76
|
+
const startNest = Date.now();
|
|
77
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
78
|
+
const plain = nestjsClassToPlain(testUser, UserResponseDto);
|
|
79
|
+
JSON.stringify(plain);
|
|
80
|
+
}
|
|
81
|
+
const endNest = Date.now();
|
|
82
|
+
const timeNest = endNest - startNest;
|
|
83
|
+
const opsNest = Math.round((ITERATIONS / timeNest) * 1000);
|
|
84
|
+
|
|
49
85
|
console.log('--- RESULTS ---');
|
|
50
|
-
console.log(`Calyx JIT Serializer:
|
|
51
|
-
console.log(`Standard JSON.stringify:
|
|
52
|
-
console.log(`
|
|
86
|
+
console.log(`Calyx JIT Serializer: ${timeJit}ms (${opsJit.toLocaleString()} ops/sec)`);
|
|
87
|
+
console.log(`Standard JSON.stringify: ${timeJson}ms (${opsJson.toLocaleString()} ops/sec)`);
|
|
88
|
+
console.log(`NestJS (class-transformer): ${timeNest}ms (${opsNest.toLocaleString()} ops/sec)`);
|
|
89
|
+
console.log(`--------------------------------`);
|
|
90
|
+
console.log(`Speedup (vs JSON.stringify): ${(timeJson / timeJit).toFixed(2)}x faster`);
|
|
91
|
+
console.log(`Speedup (vs NestJS Class-TX): ${(timeNest / timeJit).toFixed(2)}x FASTER!`);
|
|
92
|
+
console.log('======================================================\n');
|
|
@@ -21,6 +21,12 @@ async function runBenchmarks() {
|
|
|
21
21
|
const db = new Database(':memory:');
|
|
22
22
|
const repo = new Repository(db, User);
|
|
23
23
|
|
|
24
|
+
// Warmup DB repo
|
|
25
|
+
for (let i = 0; i < 500; i++) {
|
|
26
|
+
const user = await repo.save({ name: `Warmup_${i}` });
|
|
27
|
+
await repo.findOne({ where: { id: user.id } });
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
console.log('1. Database Operations (Repository.save & Repository.find):');
|
|
25
31
|
const dbIterations = 10000;
|
|
26
32
|
const dbStart = performance.now();
|
|
@@ -43,6 +49,12 @@ async function runBenchmarks() {
|
|
|
43
49
|
console.log('2. Cookie Parsing:');
|
|
44
50
|
const cookieHeader = 'sid=calyx_sid_123456789; test_cookie=val; another_cookie=value; expires=Wed, 21 Oct 2026 07:28:00 GMT';
|
|
45
51
|
const cookieIterations = 1000000;
|
|
52
|
+
|
|
53
|
+
// Warmup Cookie Parser
|
|
54
|
+
for (let i = 0; i < 100_000; i++) {
|
|
55
|
+
parseCookies(cookieHeader);
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
const cookieStart = performance.now();
|
|
47
59
|
for (let i = 0; i < cookieIterations; i++) {
|
|
48
60
|
parseCookies(cookieHeader);
|
|
@@ -45,9 +45,16 @@ function traditionalValidate(obj: any): string[] | null {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Benchmark Config
|
|
48
|
-
const ITERATIONS =
|
|
48
|
+
const ITERATIONS = 5_000_000;
|
|
49
49
|
const validPayload = { name: 'Alice', age: 30, email: 'alice@example.com' };
|
|
50
50
|
|
|
51
|
+
// Warmup
|
|
52
|
+
console.log('Warming up JIT compiler...');
|
|
53
|
+
for (let i = 0; i < 500_000; i++) {
|
|
54
|
+
validate(validPayload);
|
|
55
|
+
traditionalValidate(validPayload);
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
console.log(`Running Validation Benchmark with ${ITERATIONS.toLocaleString()} iterations...\n`);
|
|
52
59
|
|
|
53
60
|
// Benchmark Calyx JIT
|
package/docs/controllers.md
CHANGED
|
@@ -116,7 +116,7 @@ manualResponse(@Res() res: CalyxResponse) {
|
|
|
116
116
|
|
|
117
117
|
HTTP routing performance is measured using `autocannon` (100 concurrent connections, 8-second test duration, no pipelining) running in separate OS processes to eliminate CPU resource contention.
|
|
118
118
|
|
|
119
|
-
* **calyx (Bun Native)**: **
|
|
120
|
-
* **NestJS (Express on Bun)**: **23,
|
|
121
|
-
* **Performance Gain**: **calyx is
|
|
119
|
+
* **calyx (Bun Native)**: **55,108 req/sec** (avg latency **1.13 ms**)
|
|
120
|
+
* **NestJS (Express on Bun)**: **23,794 req/sec** (avg latency **3.61 ms**)
|
|
121
|
+
* **Performance Gain**: **calyx is 2.32x faster** than NestJS and delivers **3.2x lower latency** in realistic concurrent traffic conditions.
|
|
122
122
|
|
|
@@ -107,6 +107,6 @@ calyx supports standard NestJS custom provider forms:
|
|
|
107
107
|
|
|
108
108
|
Benchmarks measure the instantiation time of a 5-layer deep dependency tree (15 provider modules) over 5,000 iterations:
|
|
109
109
|
|
|
110
|
-
* **calyx**: ~
|
|
111
|
-
* **NestJS**: ~
|
|
112
|
-
* **Relative Performance**: **calyx is
|
|
110
|
+
* **calyx**: ~65.99 ms total (avg 13.20 μs per bootstrap)
|
|
111
|
+
* **NestJS**: ~1110.10 ms total (avg 222.02 μs per bootstrap)
|
|
112
|
+
* **Relative Performance**: **calyx is 16.82x faster** in DI compilation and instantiation.
|
package/docs/lifecycle.md
CHANGED
|
@@ -163,6 +163,6 @@ triggerError() {
|
|
|
163
163
|
|
|
164
164
|
Overhead benchmarks run the full request lifecycle (with a Class Guard, Method Interceptor, and Parameter Query Pipe) using `autocannon` (100 concurrent connections, 8-second test duration, no pipelining) running in separate OS processes:
|
|
165
165
|
|
|
166
|
-
* **calyx (Bun Native with Pipeline)**: **
|
|
167
|
-
* **NestJS (Express with Pipeline)**: **
|
|
168
|
-
* **Relative Performance**: Even with all decorators and lifecycle hooks active, **calyx is
|
|
166
|
+
* **calyx (Bun Native with Pipeline)**: **37,100 req/sec** (avg latency **2.11 ms**)
|
|
167
|
+
* **NestJS (Express with Pipeline)**: **9,880 req/sec** (avg latency **9.60 ms**)
|
|
168
|
+
* **Relative Performance**: Even with all decorators and lifecycle hooks active, **calyx is 3.76x faster** than NestJS and runs with **4.5x lower latency**.
|
package/package.json
CHANGED
package/src/cookies/cookies.ts
CHANGED
|
@@ -13,16 +13,53 @@ export interface CookieOptions {
|
|
|
13
13
|
export function parseCookies(cookieHeader: string): Record<string, string> {
|
|
14
14
|
const cookies: Record<string, string> = {};
|
|
15
15
|
if (!cookieHeader) return cookies;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
|
|
17
|
+
let start = 0;
|
|
18
|
+
const len = cookieHeader.length;
|
|
19
|
+
|
|
20
|
+
while (start < len) {
|
|
21
|
+
// Skip leading spaces
|
|
22
|
+
while (start < len && cookieHeader.charCodeAt(start) === 32) { // ' '
|
|
23
|
+
start++;
|
|
24
24
|
}
|
|
25
|
+
if (start >= len) break;
|
|
26
|
+
|
|
27
|
+
let end = cookieHeader.indexOf(';', start);
|
|
28
|
+
if (end === -1) {
|
|
29
|
+
end = len;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const eqIdx = cookieHeader.indexOf('=', start);
|
|
33
|
+
if (eqIdx !== -1 && eqIdx < end) {
|
|
34
|
+
// Trim key
|
|
35
|
+
let keyStart = start;
|
|
36
|
+
let keyEnd = eqIdx;
|
|
37
|
+
while (keyStart < keyEnd && cookieHeader.charCodeAt(keyStart) === 32) {
|
|
38
|
+
keyStart++;
|
|
39
|
+
}
|
|
40
|
+
while (keyEnd > keyStart && cookieHeader.charCodeAt(keyEnd - 1) === 32) {
|
|
41
|
+
keyEnd--;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Trim val
|
|
45
|
+
let valStart = eqIdx + 1;
|
|
46
|
+
let valEnd = end;
|
|
47
|
+
while (valStart < valEnd && cookieHeader.charCodeAt(valStart) === 32) {
|
|
48
|
+
valStart++;
|
|
49
|
+
}
|
|
50
|
+
while (valEnd > valStart && cookieHeader.charCodeAt(valEnd - 1) === 32) {
|
|
51
|
+
valEnd--;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const key = cookieHeader.substring(keyStart, keyEnd);
|
|
55
|
+
const val = cookieHeader.substring(valStart, valEnd);
|
|
56
|
+
|
|
57
|
+
cookies[key] = val.indexOf('%') !== -1 ? decodeURIComponent(val) : val;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
start = end + 1;
|
|
25
61
|
}
|
|
62
|
+
|
|
26
63
|
return cookies;
|
|
27
64
|
}
|
|
28
65
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CalyxContainer, DynamicModule } from '../core/container.ts';
|
|
2
2
|
import { Module } from '../core/decorators.ts';
|
|
3
|
+
import { CalyxExecutionContext } from '../lifecycle/context.ts';
|
|
4
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
3
5
|
import {
|
|
4
6
|
GraphQLSchema,
|
|
5
7
|
GraphQLObjectType,
|
|
@@ -262,7 +264,6 @@ export class GraphQLModule {
|
|
|
262
264
|
|
|
263
265
|
const resolveFn = async (parent: any, args: any, context: any, info: any) => {
|
|
264
266
|
const req = context?.req;
|
|
265
|
-
const { CalyxExecutionContext } = await import('../lifecycle/context.ts');
|
|
266
267
|
const execContext = new CalyxExecutionContext(req, null, resolverClass, resolverInstance[fieldMeta.propertyKey]);
|
|
267
268
|
(execContext as any).type = 'graphql';
|
|
268
269
|
(execContext as any).data = args;
|
|
@@ -277,7 +278,6 @@ export class GraphQLModule {
|
|
|
277
278
|
const canActivate = guard.instance.canActivate(execContext);
|
|
278
279
|
const resolved = canActivate instanceof Promise ? await canActivate : canActivate;
|
|
279
280
|
if (!resolved) {
|
|
280
|
-
const { HttpException } = await import('../http/exceptions.ts');
|
|
281
281
|
throw new HttpException('Forbidden resource', 403);
|
|
282
282
|
}
|
|
283
283
|
}
|
|
@@ -429,7 +429,6 @@ export class GraphQLModule {
|
|
|
429
429
|
|
|
430
430
|
targetFields[fieldName].resolve = async (parent: any, args: any, context: any, info: any) => {
|
|
431
431
|
const req = context?.req;
|
|
432
|
-
const { CalyxExecutionContext } = await import('../lifecycle/context.ts');
|
|
433
432
|
const execContext = new CalyxExecutionContext(req, null, resolverClass, resolverInstance[fieldRes.propertyKey]);
|
|
434
433
|
(execContext as any).type = 'graphql';
|
|
435
434
|
(execContext as any).data = args;
|
|
@@ -443,7 +442,6 @@ export class GraphQLModule {
|
|
|
443
442
|
const canActivate = guard.instance.canActivate(execContext);
|
|
444
443
|
const resolved = canActivate instanceof Promise ? await canActivate : canActivate;
|
|
445
444
|
if (!resolved) {
|
|
446
|
-
const { HttpException } = await import('../http/exceptions.ts');
|
|
447
445
|
throw new HttpException('Forbidden resource', 403);
|
|
448
446
|
}
|
|
449
447
|
}
|
package/src/http/application.ts
CHANGED
|
@@ -158,6 +158,7 @@ export class CalyxApplication {
|
|
|
158
158
|
private serverPort = 3000;
|
|
159
159
|
private graphqlSchema: any = null;
|
|
160
160
|
private graphqlQueryCache = new Map<string, any>();
|
|
161
|
+
private graphqlLib: any = null;
|
|
161
162
|
private isInitialized = false;
|
|
162
163
|
private versioningOptions?: VersioningOptions;
|
|
163
164
|
private globalPrefix?: string;
|
|
@@ -578,8 +579,8 @@ export class CalyxApplication {
|
|
|
578
579
|
try {
|
|
579
580
|
const urlStr = req.url;
|
|
580
581
|
// Parse cookies
|
|
581
|
-
const cookieHeader = req.headers.get('cookie')
|
|
582
|
-
(req as any).cookies = parseCookies(cookieHeader);
|
|
582
|
+
const cookieHeader = req.headers.get('cookie');
|
|
583
|
+
(req as any).cookies = cookieHeader ? parseCookies(cookieHeader) : {};
|
|
583
584
|
|
|
584
585
|
// Fast path parsing
|
|
585
586
|
let pathname = '/';
|
|
@@ -1345,7 +1346,7 @@ export class CalyxApplication {
|
|
|
1345
1346
|
const body = await req.json() as any;
|
|
1346
1347
|
const { query, variables } = body;
|
|
1347
1348
|
|
|
1348
|
-
const { parse, validate, execute } = await import('graphql');
|
|
1349
|
+
const { parse, validate, execute } = this.graphqlLib || (this.graphqlLib = await import('graphql'));
|
|
1349
1350
|
|
|
1350
1351
|
let document = this.graphqlQueryCache.get(query);
|
|
1351
1352
|
if (!document) {
|
|
@@ -1480,7 +1481,7 @@ export class CalyxApplication {
|
|
|
1480
1481
|
const { id, payload } = data;
|
|
1481
1482
|
const { query, variables } = payload;
|
|
1482
1483
|
|
|
1483
|
-
const { subscribe, parse, validate } = await import('graphql');
|
|
1484
|
+
const { subscribe, parse, validate } = this.graphqlLib || (this.graphqlLib = await import('graphql'));
|
|
1484
1485
|
|
|
1485
1486
|
let document = this.graphqlQueryCache.get(query);
|
|
1486
1487
|
if (!document) {
|
|
@@ -215,15 +215,12 @@ export class SerializationCompiler {
|
|
|
215
215
|
const jsonParts: string[] = [];
|
|
216
216
|
for (const key of keys) {
|
|
217
217
|
const propRules = rules.filter((r) => r.propertyKey === key);
|
|
218
|
-
const
|
|
219
|
-
const
|
|
218
|
+
const designType = Reflect.getMetadata('design:type', dtoClass.prototype, key);
|
|
219
|
+
const isNumber = propRules.some((r) => r.type === 'number') || designType === Number;
|
|
220
|
+
const isBoolean = propRules.some((r) => r.type === 'isBoolean') || designType === Boolean;
|
|
220
221
|
|
|
221
|
-
if (isNumber) {
|
|
222
|
+
if (isNumber || isBoolean) {
|
|
222
223
|
jsonParts.push(`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : obj.${key}}`);
|
|
223
|
-
} else if (isString) {
|
|
224
|
-
jsonParts.push(
|
|
225
|
-
`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
|
|
226
|
-
);
|
|
227
224
|
} else {
|
|
228
225
|
jsonParts.push(
|
|
229
226
|
`"${key}":\${obj.${key} === undefined || obj.${key} === null ? null : JSON.stringify(obj.${key})}`
|