qhttpx 1.8.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/.eslintrc.json +22 -0
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/npm-publish.yml +37 -0
- package/.github/workflows/release.yml +21 -0
- package/.prettierrc +7 -0
- package/CHANGELOG.md +145 -0
- package/LICENSE +21 -0
- package/README.md +343 -0
- package/dist/package.json +61 -0
- package/dist/src/benchmarks/compare-frameworks.js +119 -0
- package/dist/src/benchmarks/quantam-users.js +56 -0
- package/dist/src/benchmarks/simple-json.js +58 -0
- package/dist/src/benchmarks/ultra-mode.js +122 -0
- package/dist/src/cli/index.js +200 -0
- package/dist/src/client/index.js +72 -0
- package/dist/src/core/batch.js +97 -0
- package/dist/src/core/body-parser.js +121 -0
- package/dist/src/core/buffer-pool.js +70 -0
- package/dist/src/core/config.js +50 -0
- package/dist/src/core/fusion.js +183 -0
- package/dist/src/core/logger.js +49 -0
- package/dist/src/core/metrics.js +111 -0
- package/dist/src/core/resources.js +25 -0
- package/dist/src/core/scheduler.js +85 -0
- package/dist/src/core/scope.js +68 -0
- package/dist/src/core/serializer.js +44 -0
- package/dist/src/core/server.js +905 -0
- package/dist/src/core/stream.js +71 -0
- package/dist/src/core/tasks.js +87 -0
- package/dist/src/core/types.js +19 -0
- package/dist/src/core/websocket.js +86 -0
- package/dist/src/core/worker-queue.js +73 -0
- package/dist/src/database/adapters/memory.js +90 -0
- package/dist/src/database/adapters/mongo.js +141 -0
- package/dist/src/database/adapters/postgres.js +111 -0
- package/dist/src/database/adapters/sqlite.js +42 -0
- package/dist/src/database/coalescer.js +134 -0
- package/dist/src/database/manager.js +87 -0
- package/dist/src/database/types.js +2 -0
- package/dist/src/index.js +61 -0
- package/dist/src/middleware/compression.js +133 -0
- package/dist/src/middleware/cors.js +66 -0
- package/dist/src/middleware/presets.js +33 -0
- package/dist/src/middleware/rate-limit.js +77 -0
- package/dist/src/middleware/security.js +69 -0
- package/dist/src/middleware/static.js +191 -0
- package/dist/src/openapi/generator.js +149 -0
- package/dist/src/router/radix-router.js +89 -0
- package/dist/src/router/radix-tree.js +81 -0
- package/dist/src/router/router.js +146 -0
- package/dist/src/testing/index.js +84 -0
- package/dist/src/utils/cookies.js +59 -0
- package/dist/src/utils/logger.js +45 -0
- package/dist/src/utils/signals.js +31 -0
- package/dist/src/utils/sse.js +32 -0
- package/dist/src/validation/index.js +19 -0
- package/dist/src/validation/simple.js +102 -0
- package/dist/src/validation/types.js +12 -0
- package/dist/src/validation/zod.js +18 -0
- package/dist/src/views/index.js +17 -0
- package/dist/src/views/types.js +2 -0
- package/dist/tests/adapters.test.js +106 -0
- package/dist/tests/batch.test.js +117 -0
- package/dist/tests/body-parser.test.js +52 -0
- package/dist/tests/compression-sse.test.js +87 -0
- package/dist/tests/cookies.test.js +63 -0
- package/dist/tests/cors.test.js +55 -0
- package/dist/tests/database.test.js +80 -0
- package/dist/tests/dx.test.js +64 -0
- package/dist/tests/ecosystem.test.js +133 -0
- package/dist/tests/features.test.js +47 -0
- package/dist/tests/fusion.test.js +92 -0
- package/dist/tests/http-basic.test.js +124 -0
- package/dist/tests/logger.test.js +33 -0
- package/dist/tests/middleware.test.js +109 -0
- package/dist/tests/observability.test.js +59 -0
- package/dist/tests/openapi.test.js +64 -0
- package/dist/tests/plugin.test.js +65 -0
- package/dist/tests/plugins.test.js +71 -0
- package/dist/tests/rate-limit.test.js +77 -0
- package/dist/tests/resources.test.js +44 -0
- package/dist/tests/scheduler.test.js +46 -0
- package/dist/tests/schema-routes.test.js +77 -0
- package/dist/tests/security.test.js +83 -0
- package/dist/tests/server-db.test.js +72 -0
- package/dist/tests/smoke.test.js +10 -0
- package/dist/tests/sqlite-fusion.test.js +92 -0
- package/dist/tests/static.test.js +102 -0
- package/dist/tests/stream.test.js +44 -0
- package/dist/tests/task-metrics.test.js +53 -0
- package/dist/tests/tasks.test.js +62 -0
- package/dist/tests/testing.test.js +47 -0
- package/dist/tests/validation.test.js +107 -0
- package/dist/tests/websocket.test.js +146 -0
- package/dist/vitest.config.js +9 -0
- package/docs/AEGIS.md +76 -0
- package/docs/BENCHMARKS.md +36 -0
- package/docs/CAPABILITIES.md +70 -0
- package/docs/CLI.md +43 -0
- package/docs/DATABASE.md +142 -0
- package/docs/ECOSYSTEM.md +146 -0
- package/docs/NEXT_STEPS.md +99 -0
- package/docs/OPENAPI.md +99 -0
- package/docs/PLUGINS.md +59 -0
- package/docs/REAL_WORLD_EXAMPLES.md +109 -0
- package/docs/ROADMAP.md +366 -0
- package/docs/VALIDATION.md +136 -0
- package/eslint.config.cjs +26 -0
- package/examples/api-server.ts +254 -0
- package/package.json +61 -0
- package/src/benchmarks/compare-frameworks.ts +149 -0
- package/src/benchmarks/quantam-users.ts +70 -0
- package/src/benchmarks/simple-json.ts +71 -0
- package/src/benchmarks/ultra-mode.ts +159 -0
- package/src/cli/index.ts +214 -0
- package/src/client/index.ts +93 -0
- package/src/core/batch.ts +110 -0
- package/src/core/body-parser.ts +151 -0
- package/src/core/buffer-pool.ts +96 -0
- package/src/core/config.ts +60 -0
- package/src/core/fusion.ts +210 -0
- package/src/core/logger.ts +70 -0
- package/src/core/metrics.ts +166 -0
- package/src/core/resources.ts +38 -0
- package/src/core/scheduler.ts +126 -0
- package/src/core/scope.ts +87 -0
- package/src/core/serializer.ts +41 -0
- package/src/core/server.ts +1113 -0
- package/src/core/stream.ts +111 -0
- package/src/core/tasks.ts +138 -0
- package/src/core/types.ts +178 -0
- package/src/core/websocket.ts +112 -0
- package/src/core/worker-queue.ts +90 -0
- package/src/database/adapters/memory.ts +99 -0
- package/src/database/adapters/mongo.ts +116 -0
- package/src/database/adapters/postgres.ts +86 -0
- package/src/database/adapters/sqlite.ts +44 -0
- package/src/database/coalescer.ts +153 -0
- package/src/database/manager.ts +97 -0
- package/src/database/types.ts +24 -0
- package/src/index.ts +42 -0
- package/src/middleware/compression.ts +147 -0
- package/src/middleware/cors.ts +98 -0
- package/src/middleware/presets.ts +50 -0
- package/src/middleware/rate-limit.ts +106 -0
- package/src/middleware/security.ts +109 -0
- package/src/middleware/static.ts +216 -0
- package/src/openapi/generator.ts +167 -0
- package/src/router/radix-router.ts +119 -0
- package/src/router/radix-tree.ts +106 -0
- package/src/router/router.ts +190 -0
- package/src/testing/index.ts +104 -0
- package/src/utils/cookies.ts +67 -0
- package/src/utils/logger.ts +59 -0
- package/src/utils/signals.ts +45 -0
- package/src/utils/sse.ts +41 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/simple.ts +93 -0
- package/src/validation/types.ts +38 -0
- package/src/validation/zod.ts +14 -0
- package/src/views/index.ts +1 -0
- package/src/views/types.ts +4 -0
- package/tests/adapters.test.ts +120 -0
- package/tests/batch.test.ts +139 -0
- package/tests/body-parser.test.ts +83 -0
- package/tests/compression-sse.test.ts +98 -0
- package/tests/cookies.test.ts +74 -0
- package/tests/cors.test.ts +79 -0
- package/tests/database.test.ts +90 -0
- package/tests/dx.test.ts +78 -0
- package/tests/ecosystem.test.ts +156 -0
- package/tests/features.test.ts +51 -0
- package/tests/fusion.test.ts +121 -0
- package/tests/http-basic.test.ts +161 -0
- package/tests/logger.test.ts +48 -0
- package/tests/middleware.test.ts +137 -0
- package/tests/observability.test.ts +91 -0
- package/tests/openapi.test.ts +74 -0
- package/tests/plugin.test.ts +85 -0
- package/tests/plugins.test.ts +93 -0
- package/tests/rate-limit.test.ts +97 -0
- package/tests/resources.test.ts +64 -0
- package/tests/scheduler.test.ts +71 -0
- package/tests/schema-routes.test.ts +89 -0
- package/tests/security.test.ts +128 -0
- package/tests/server-db.test.ts +72 -0
- package/tests/smoke.test.ts +9 -0
- package/tests/sqlite-fusion.test.ts +106 -0
- package/tests/static.test.ts +111 -0
- package/tests/stream.test.ts +58 -0
- package/tests/task-metrics.test.ts +78 -0
- package/tests/tasks.test.ts +90 -0
- package/tests/testing.test.ts +53 -0
- package/tests/validation.test.ts +126 -0
- package/tests/websocket.test.ts +132 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { IncomingMessage } from 'http';
|
|
2
|
+
import { parse as parseQueryString } from 'querystring';
|
|
3
|
+
import busboy from 'busboy';
|
|
4
|
+
|
|
5
|
+
export type BodyParserOptions = {
|
|
6
|
+
maxBodyBytes?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ParsedBody = {
|
|
10
|
+
body: unknown;
|
|
11
|
+
files?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface IBodyParser {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
parse(req: IncomingMessage, options?: BodyParserOptions): Promise<any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class BodyParser {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
static async parse(req: IncomingMessage, options: BodyParserOptions = {}): Promise<any> {
|
|
22
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
23
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
24
|
+
return { body: undefined };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const contentType = req.headers['content-type'] || '';
|
|
28
|
+
|
|
29
|
+
if (contentType.includes('multipart/form-data')) {
|
|
30
|
+
return this.parseMultipart(req, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
let totalBytes = 0;
|
|
35
|
+
const maxBodyBytes = options.maxBodyBytes;
|
|
36
|
+
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const bufferChunk =
|
|
39
|
+
typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
40
|
+
chunks.push(bufferChunk);
|
|
41
|
+
totalBytes += bufferChunk.length;
|
|
42
|
+
if (maxBodyBytes !== undefined && totalBytes > maxBodyBytes) {
|
|
43
|
+
throw new Error('QHTTPX_BODY_TOO_LARGE');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (chunks.length === 0) {
|
|
48
|
+
return { body: undefined };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const buffer = Buffer.concat(chunks);
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
typeof contentType === 'string' &&
|
|
55
|
+
contentType.includes('application/json')
|
|
56
|
+
) {
|
|
57
|
+
const text = buffer.toString('utf8');
|
|
58
|
+
try {
|
|
59
|
+
return { body: JSON.parse(text) };
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error('QHTTPX_INVALID_JSON');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
typeof contentType === 'string' &&
|
|
67
|
+
contentType.includes('application/x-www-form-urlencoded')
|
|
68
|
+
) {
|
|
69
|
+
const text = buffer.toString('utf8');
|
|
70
|
+
return { body: parseQueryString(text) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { body: buffer };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static parseMultipart(
|
|
77
|
+
req: IncomingMessage,
|
|
78
|
+
options: BodyParserOptions,
|
|
79
|
+
): Promise<ParsedBody> {
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
return new Promise<any>((resolve, reject) => {
|
|
82
|
+
let bb;
|
|
83
|
+
try {
|
|
84
|
+
bb = busboy({ headers: req.headers, limits: { fileSize: options.maxBodyBytes } });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return reject(err);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const body: Record<string, unknown> = {};
|
|
90
|
+
const files: Record<string, unknown> = {};
|
|
91
|
+
|
|
92
|
+
bb.on('field', (name, val) => {
|
|
93
|
+
if (Object.prototype.hasOwnProperty.call(body, name)) {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
if (Array.isArray((body as any)[name])) {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
(body as any)[name].push(val);
|
|
98
|
+
} else {
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
100
|
+
(body as any)[name] = [(body as any)[name], val];
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
body[name] = val;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
bb.on('file', (name, file, info) => {
|
|
108
|
+
const { filename, encoding, mimeType } = info;
|
|
109
|
+
const chunks: Buffer[] = [];
|
|
110
|
+
|
|
111
|
+
file.on('data', (chunk) => {
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
file.on('end', () => {
|
|
116
|
+
const buffer = Buffer.concat(chunks);
|
|
117
|
+
const fileData = {
|
|
118
|
+
filename,
|
|
119
|
+
encoding,
|
|
120
|
+
mimeType,
|
|
121
|
+
data: buffer,
|
|
122
|
+
size: buffer.length,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (Object.prototype.hasOwnProperty.call(files, name)) {
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
if (Array.isArray((files as any)[name])) {
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
(files as any)[name].push(fileData);
|
|
130
|
+
} else {
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
(files as any)[name] = [(files as any)[name], fileData];
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
files[name] = fileData;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
bb.on('close', () => {
|
|
141
|
+
resolve({ body, files });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
bb.on('error', (err) => {
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
req.pipe(bb);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffer pool for response bodies (small, medium, large).
|
|
3
|
+
* Reuses allocated buffers to reduce GC pressure.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type BufferPoolConfig = {
|
|
7
|
+
smallSize?: number; // Default: 4KB
|
|
8
|
+
mediumSize?: number; // Default: 64KB
|
|
9
|
+
largeSize?: number; // Default: 256KB
|
|
10
|
+
smallPoolSize?: number; // Number of small buffers to maintain
|
|
11
|
+
mediumPoolSize?: number; // Number of medium buffers to maintain
|
|
12
|
+
largePoolSize?: number; // Number of large buffers to maintain
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class BufferPool {
|
|
16
|
+
private readonly smallSize: number;
|
|
17
|
+
private readonly mediumSize: number;
|
|
18
|
+
private readonly largeSize: number;
|
|
19
|
+
|
|
20
|
+
private readonly smallBuffers: Buffer[] = [];
|
|
21
|
+
private readonly mediumBuffers: Buffer[] = [];
|
|
22
|
+
private readonly largeBuffers: Buffer[] = [];
|
|
23
|
+
|
|
24
|
+
private readonly smallPoolSize: number;
|
|
25
|
+
private readonly mediumPoolSize: number;
|
|
26
|
+
private readonly largePoolSize: number;
|
|
27
|
+
|
|
28
|
+
constructor(config: BufferPoolConfig = {}) {
|
|
29
|
+
this.smallSize = config.smallSize ?? 4096; // 4KB
|
|
30
|
+
this.mediumSize = config.mediumSize ?? 65536; // 64KB
|
|
31
|
+
this.largeSize = config.largeSize ?? 262144; // 256KB
|
|
32
|
+
|
|
33
|
+
this.smallPoolSize = config.smallPoolSize ?? 32;
|
|
34
|
+
this.mediumPoolSize = config.mediumPoolSize ?? 8;
|
|
35
|
+
this.largePoolSize = config.largePoolSize ?? 2;
|
|
36
|
+
|
|
37
|
+
// Preallocate buffers
|
|
38
|
+
for (let i = 0; i < this.smallPoolSize; i += 1) {
|
|
39
|
+
this.smallBuffers.push(Buffer.allocUnsafe(this.smallSize));
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < this.mediumPoolSize; i += 1) {
|
|
42
|
+
this.mediumBuffers.push(Buffer.allocUnsafe(this.mediumSize));
|
|
43
|
+
}
|
|
44
|
+
for (let i = 0; i < this.largePoolSize; i += 1) {
|
|
45
|
+
this.largeBuffers.push(Buffer.allocUnsafe(this.largeSize));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Acquire a buffer suitable for the given size.
|
|
51
|
+
* Returns a buffer from the appropriate pool.
|
|
52
|
+
*/
|
|
53
|
+
acquire(size: number): Buffer {
|
|
54
|
+
if (size <= this.smallSize) {
|
|
55
|
+
return this.smallBuffers.pop() || Buffer.allocUnsafe(this.smallSize);
|
|
56
|
+
}
|
|
57
|
+
if (size <= this.mediumSize) {
|
|
58
|
+
return this.mediumBuffers.pop() || Buffer.allocUnsafe(this.mediumSize);
|
|
59
|
+
}
|
|
60
|
+
return this.largeBuffers.pop() || Buffer.allocUnsafe(this.largeSize);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Release a buffer back to the appropriate pool.
|
|
65
|
+
*/
|
|
66
|
+
release(buffer: Buffer): void {
|
|
67
|
+
if (buffer.length === this.smallSize && this.smallBuffers.length < this.smallPoolSize) {
|
|
68
|
+
this.smallBuffers.push(buffer);
|
|
69
|
+
} else if (
|
|
70
|
+
buffer.length === this.mediumSize &&
|
|
71
|
+
this.mediumBuffers.length < this.mediumPoolSize
|
|
72
|
+
) {
|
|
73
|
+
this.mediumBuffers.push(buffer);
|
|
74
|
+
} else if (
|
|
75
|
+
buffer.length === this.largeSize &&
|
|
76
|
+
this.largeBuffers.length < this.largePoolSize
|
|
77
|
+
) {
|
|
78
|
+
this.largeBuffers.push(buffer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the number of available buffers in each pool.
|
|
84
|
+
*/
|
|
85
|
+
getPoolStatus(): {
|
|
86
|
+
small: number;
|
|
87
|
+
medium: number;
|
|
88
|
+
large: number;
|
|
89
|
+
} {
|
|
90
|
+
return {
|
|
91
|
+
small: this.smallBuffers.length,
|
|
92
|
+
medium: this.mediumBuffers.length,
|
|
93
|
+
large: this.largeBuffers.length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { QHTTPXOptions } from './types';
|
|
2
|
+
|
|
3
|
+
export type LoadConfigOptions = {
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
defaults?: QHTTPXOptions;
|
|
6
|
+
prefix?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function loadConfig(options: LoadConfigOptions = {}): QHTTPXOptions {
|
|
10
|
+
const env = options.env ?? process.env;
|
|
11
|
+
const defaults = options.defaults ?? {};
|
|
12
|
+
const prefix = options.prefix ?? 'QHTTPX_';
|
|
13
|
+
|
|
14
|
+
const getNumber = (key: string): number | undefined => {
|
|
15
|
+
const value = env[prefix + key];
|
|
16
|
+
if (!value) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const getBoolean = (key: string): boolean | undefined => {
|
|
27
|
+
const value = env[prefix + key];
|
|
28
|
+
if (!value) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const lower = value.toLowerCase();
|
|
32
|
+
if (lower === 'true' || lower === '1') {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (lower === 'false' || lower === '0') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const maxConcurrency = getNumber('MAX_CONCURRENCY');
|
|
42
|
+
const requestTimeoutMs = getNumber('REQUEST_TIMEOUT_MS');
|
|
43
|
+
const maxMemoryBytes = getNumber('MAX_MEMORY_BYTES');
|
|
44
|
+
const maxBodyBytes = getNumber('MAX_BODY_BYTES');
|
|
45
|
+
const keepAliveTimeoutMs = getNumber('KEEP_ALIVE_TIMEOUT_MS');
|
|
46
|
+
const headersTimeoutMs = getNumber('HEADERS_TIMEOUT_MS');
|
|
47
|
+
const metricsEnabled = getBoolean('METRICS_ENABLED');
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...defaults,
|
|
51
|
+
maxConcurrency: maxConcurrency ?? defaults.maxConcurrency,
|
|
52
|
+
requestTimeoutMs: requestTimeoutMs ?? defaults.requestTimeoutMs,
|
|
53
|
+
maxMemoryBytes: maxMemoryBytes ?? defaults.maxMemoryBytes,
|
|
54
|
+
maxBodyBytes: maxBodyBytes ?? defaults.maxBodyBytes,
|
|
55
|
+
keepAliveTimeoutMs: keepAliveTimeoutMs ?? defaults.keepAliveTimeoutMs,
|
|
56
|
+
headersTimeoutMs: headersTimeoutMs ?? defaults.headersTimeoutMs,
|
|
57
|
+
metricsEnabled: metricsEnabled ?? defaults.metricsEnabled,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { QHTTPXContext } from './types';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
|
|
4
|
+
export interface RequestFusionOptions {
|
|
5
|
+
windowMs?: number;
|
|
6
|
+
vary?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface FusionResult {
|
|
10
|
+
type: 'json' | 'send' | 'html' | 'redirect';
|
|
11
|
+
payload: unknown;
|
|
12
|
+
status: number;
|
|
13
|
+
contentType?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RequestFusion {
|
|
17
|
+
private inflight: Map<string, Promise<FusionResult>> = new Map();
|
|
18
|
+
private cache: Map<string, { result: FusionResult; expires: number }> = new Map();
|
|
19
|
+
private options: RequestFusionOptions;
|
|
20
|
+
|
|
21
|
+
constructor(options: boolean | RequestFusionOptions = {}) {
|
|
22
|
+
this.options = typeof options === 'boolean' ? {} : options;
|
|
23
|
+
if (!this.options.vary) {
|
|
24
|
+
this.options.vary = ['authorization', 'cookie'];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async coalesce(ctx: QHTTPXContext, next: (ctx: QHTTPXContext) => Promise<void> | void): Promise<void> {
|
|
29
|
+
const key = this.getKey(ctx);
|
|
30
|
+
|
|
31
|
+
// 1. Check Cache (Short-lived "Micro-TTL")
|
|
32
|
+
if (this.options.windowMs && this.options.windowMs > 0) {
|
|
33
|
+
const cached = this.cache.get(key);
|
|
34
|
+
if (cached) {
|
|
35
|
+
if (Date.now() < cached.expires) {
|
|
36
|
+
this.applyResult(ctx, cached.result);
|
|
37
|
+
return;
|
|
38
|
+
} else {
|
|
39
|
+
this.cache.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Check In-Flight
|
|
45
|
+
if (this.inflight.has(key)) {
|
|
46
|
+
try {
|
|
47
|
+
const result = await this.inflight.get(key)!;
|
|
48
|
+
this.applyResult(ctx, result);
|
|
49
|
+
return;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// If leader failed, we should probably fail too or retry?
|
|
52
|
+
// For now, let's propagate the error.
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. Be the Leader
|
|
58
|
+
let resolve: (value: FusionResult) => void;
|
|
59
|
+
let reject: (reason?: unknown) => void;
|
|
60
|
+
const promise = new Promise<FusionResult>((res, rej) => {
|
|
61
|
+
resolve = res;
|
|
62
|
+
reject = rej;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.inflight.set(key, promise);
|
|
66
|
+
|
|
67
|
+
// Hijack context methods to capture result
|
|
68
|
+
// Cast to any to allow overwriting readonly methods
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
const mutableCtx = ctx as any;
|
|
71
|
+
|
|
72
|
+
const originalJson = mutableCtx.json;
|
|
73
|
+
const originalSend = mutableCtx.send;
|
|
74
|
+
const originalHtml = mutableCtx.html;
|
|
75
|
+
const originalRedirect = mutableCtx.redirect;
|
|
76
|
+
|
|
77
|
+
let captured = false;
|
|
78
|
+
|
|
79
|
+
// Helper to cleanup and resolve
|
|
80
|
+
const finish = (result: FusionResult) => {
|
|
81
|
+
if (captured) return;
|
|
82
|
+
captured = true;
|
|
83
|
+
resolve(result);
|
|
84
|
+
|
|
85
|
+
// Cache if needed
|
|
86
|
+
if (this.options.windowMs && this.options.windowMs > 0) {
|
|
87
|
+
this.cache.set(key, {
|
|
88
|
+
result,
|
|
89
|
+
expires: Date.now() + this.options.windowMs
|
|
90
|
+
});
|
|
91
|
+
// Cleanup cache later
|
|
92
|
+
setTimeout(() => this.cache.delete(key), this.options.windowMs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Remove from inflight immediately (unless we rely on cache for window)
|
|
96
|
+
// Actually, if we have windowMs, we rely on cache.
|
|
97
|
+
// If windowMs=0, we remove immediately.
|
|
98
|
+
this.inflight.delete(key);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
mutableCtx.json = (payload: unknown, status = 200) => {
|
|
102
|
+
finish({ type: 'json', payload, status });
|
|
103
|
+
return originalJson.call(ctx, payload, status);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
mutableCtx.send = (payload: string | Buffer, status = 200) => {
|
|
107
|
+
finish({ type: 'send', payload, status });
|
|
108
|
+
return originalSend.call(ctx, payload, status);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
mutableCtx.html = (payload: string, status = 200) => {
|
|
112
|
+
finish({ type: 'html', payload, status });
|
|
113
|
+
return originalHtml.call(ctx, payload, status);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
mutableCtx.redirect = (url: string, status = 302) => {
|
|
117
|
+
finish({ type: 'redirect', payload: url, status });
|
|
118
|
+
return originalRedirect.call(ctx, url, status);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await next(ctx);
|
|
123
|
+
// If next() completes but no response method was called,
|
|
124
|
+
// it implies the handler might be async and forgot to await,
|
|
125
|
+
// or it's a 404/middleware issue.
|
|
126
|
+
// If not captured yet, we can't resolve the followers properly.
|
|
127
|
+
// They will hang unless we reject or resolve with something.
|
|
128
|
+
// However, typical QHTTPX usage implies ctx.json/send is called.
|
|
129
|
+
} catch (err) {
|
|
130
|
+
reject!(err);
|
|
131
|
+
this.inflight.delete(key);
|
|
132
|
+
// Restore methods
|
|
133
|
+
mutableCtx.json = originalJson;
|
|
134
|
+
mutableCtx.send = originalSend;
|
|
135
|
+
mutableCtx.html = originalHtml;
|
|
136
|
+
mutableCtx.redirect = originalRedirect;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private applyResult(ctx: QHTTPXContext, result: FusionResult) {
|
|
142
|
+
switch (result.type) {
|
|
143
|
+
case 'json':
|
|
144
|
+
ctx.json(result.payload, result.status);
|
|
145
|
+
break;
|
|
146
|
+
case 'send': {
|
|
147
|
+
if (typeof result.payload === 'string' || Buffer.isBuffer(result.payload)) {
|
|
148
|
+
ctx.send(result.payload as string | Buffer, result.status);
|
|
149
|
+
} else {
|
|
150
|
+
ctx.send(String(result.payload), result.status);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'html': {
|
|
155
|
+
if (typeof result.payload === 'string') {
|
|
156
|
+
ctx.html(result.payload as string, result.status);
|
|
157
|
+
} else {
|
|
158
|
+
ctx.html(String(result.payload), result.status);
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'redirect': {
|
|
163
|
+
if (typeof result.payload === 'string') {
|
|
164
|
+
ctx.redirect(result.payload as string, result.status);
|
|
165
|
+
} else {
|
|
166
|
+
ctx.redirect(String(result.payload), result.status);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getKey(ctx: QHTTPXContext): string {
|
|
174
|
+
// Base: Method + Path
|
|
175
|
+
let data = `${ctx.req.method}|${ctx.url?.pathname}|`;
|
|
176
|
+
|
|
177
|
+
// Query
|
|
178
|
+
if (ctx.query) {
|
|
179
|
+
// Deterministic sort
|
|
180
|
+
const keys = Object.keys(ctx.query).sort();
|
|
181
|
+
for (const k of keys) {
|
|
182
|
+
data += `${k}=${ctx.query[k]}|`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Body (if parsed)
|
|
187
|
+
if (ctx.body) {
|
|
188
|
+
// We assume body is simple JSON.
|
|
189
|
+
// For objects, order matters for hashing.
|
|
190
|
+
// fast-json-stringify or JSON.stringify isn't always deterministic key order.
|
|
191
|
+
// But for performance, JSON.stringify is often "good enough" if keys aren't shuffled.
|
|
192
|
+
// For strict correctness, we should use a canonical stringify, but that's slow.
|
|
193
|
+
// Let's use JSON.stringify for now.
|
|
194
|
+
data += JSON.stringify(ctx.body);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Vary Headers
|
|
198
|
+
if (this.options.vary) {
|
|
199
|
+
for (const header of this.options.vary) {
|
|
200
|
+
const val = ctx.req.headers[header.toLowerCase()];
|
|
201
|
+
if (val) {
|
|
202
|
+
data += `|${header}:${val}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// We hash it to keep the key size manageable
|
|
208
|
+
return createHash('sha1').update(data).digest('hex');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import pino from 'pino';
|
|
3
|
+
|
|
4
|
+
export type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
|
5
|
+
|
|
6
|
+
export interface LoggerOptions {
|
|
7
|
+
level?: LogLevel;
|
|
8
|
+
pretty?: boolean;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Logger {
|
|
13
|
+
private pino: pino.Logger;
|
|
14
|
+
|
|
15
|
+
constructor(options: LoggerOptions = {}) {
|
|
16
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
17
|
+
const pretty = options.pretty ?? isDev;
|
|
18
|
+
|
|
19
|
+
this.pino = pino({
|
|
20
|
+
name: options.name || 'qhttpx',
|
|
21
|
+
level: options.level || 'info',
|
|
22
|
+
transport: pretty
|
|
23
|
+
? {
|
|
24
|
+
target: 'pino-pretty',
|
|
25
|
+
options: {
|
|
26
|
+
colorize: true,
|
|
27
|
+
translateTime: 'HH:MM:ss Z',
|
|
28
|
+
ignore: 'pid,hostname',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: undefined,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
info(msg: string, ...args: any[]): void;
|
|
36
|
+
info(obj: object, msg?: string, ...args: any[]): void;
|
|
37
|
+
info(arg1: string | object, ...args: any[]): void {
|
|
38
|
+
this.pino.info(arg1 as any, ...args);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
error(msg: string, ...args: any[]): void;
|
|
42
|
+
error(obj: object, msg?: string, ...args: any[]): void;
|
|
43
|
+
error(arg1: string | object, ...args: any[]): void {
|
|
44
|
+
this.pino.error(arg1 as any, ...args);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
warn(msg: string, ...args: any[]): void;
|
|
48
|
+
warn(obj: object, msg?: string, ...args: any[]): void;
|
|
49
|
+
warn(arg1: string | object, ...args: any[]): void {
|
|
50
|
+
this.pino.warn(arg1 as any, ...args);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
debug(msg: string, ...args: any[]): void;
|
|
54
|
+
debug(obj: object, msg?: string, ...args: any[]): void;
|
|
55
|
+
debug(arg1: string | object, ...args: any[]): void {
|
|
56
|
+
this.pino.debug(arg1 as any, ...args);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fatal(msg: string, ...args: any[]): void;
|
|
60
|
+
fatal(obj: object, msg?: string, ...args: any[]): void;
|
|
61
|
+
fatal(arg1: string | object, ...args: any[]): void {
|
|
62
|
+
this.pino.fatal(arg1 as any, ...args);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
child(bindings: pino.Bindings): Logger {
|
|
66
|
+
const child = new Logger();
|
|
67
|
+
child.pino = this.pino.child(bindings);
|
|
68
|
+
return child;
|
|
69
|
+
}
|
|
70
|
+
}
|