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.
Files changed (197) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/.github/workflows/npm-publish.yml +37 -0
  4. package/.github/workflows/release.yml +21 -0
  5. package/.prettierrc +7 -0
  6. package/CHANGELOG.md +145 -0
  7. package/LICENSE +21 -0
  8. package/README.md +343 -0
  9. package/dist/package.json +61 -0
  10. package/dist/src/benchmarks/compare-frameworks.js +119 -0
  11. package/dist/src/benchmarks/quantam-users.js +56 -0
  12. package/dist/src/benchmarks/simple-json.js +58 -0
  13. package/dist/src/benchmarks/ultra-mode.js +122 -0
  14. package/dist/src/cli/index.js +200 -0
  15. package/dist/src/client/index.js +72 -0
  16. package/dist/src/core/batch.js +97 -0
  17. package/dist/src/core/body-parser.js +121 -0
  18. package/dist/src/core/buffer-pool.js +70 -0
  19. package/dist/src/core/config.js +50 -0
  20. package/dist/src/core/fusion.js +183 -0
  21. package/dist/src/core/logger.js +49 -0
  22. package/dist/src/core/metrics.js +111 -0
  23. package/dist/src/core/resources.js +25 -0
  24. package/dist/src/core/scheduler.js +85 -0
  25. package/dist/src/core/scope.js +68 -0
  26. package/dist/src/core/serializer.js +44 -0
  27. package/dist/src/core/server.js +905 -0
  28. package/dist/src/core/stream.js +71 -0
  29. package/dist/src/core/tasks.js +87 -0
  30. package/dist/src/core/types.js +19 -0
  31. package/dist/src/core/websocket.js +86 -0
  32. package/dist/src/core/worker-queue.js +73 -0
  33. package/dist/src/database/adapters/memory.js +90 -0
  34. package/dist/src/database/adapters/mongo.js +141 -0
  35. package/dist/src/database/adapters/postgres.js +111 -0
  36. package/dist/src/database/adapters/sqlite.js +42 -0
  37. package/dist/src/database/coalescer.js +134 -0
  38. package/dist/src/database/manager.js +87 -0
  39. package/dist/src/database/types.js +2 -0
  40. package/dist/src/index.js +61 -0
  41. package/dist/src/middleware/compression.js +133 -0
  42. package/dist/src/middleware/cors.js +66 -0
  43. package/dist/src/middleware/presets.js +33 -0
  44. package/dist/src/middleware/rate-limit.js +77 -0
  45. package/dist/src/middleware/security.js +69 -0
  46. package/dist/src/middleware/static.js +191 -0
  47. package/dist/src/openapi/generator.js +149 -0
  48. package/dist/src/router/radix-router.js +89 -0
  49. package/dist/src/router/radix-tree.js +81 -0
  50. package/dist/src/router/router.js +146 -0
  51. package/dist/src/testing/index.js +84 -0
  52. package/dist/src/utils/cookies.js +59 -0
  53. package/dist/src/utils/logger.js +45 -0
  54. package/dist/src/utils/signals.js +31 -0
  55. package/dist/src/utils/sse.js +32 -0
  56. package/dist/src/validation/index.js +19 -0
  57. package/dist/src/validation/simple.js +102 -0
  58. package/dist/src/validation/types.js +12 -0
  59. package/dist/src/validation/zod.js +18 -0
  60. package/dist/src/views/index.js +17 -0
  61. package/dist/src/views/types.js +2 -0
  62. package/dist/tests/adapters.test.js +106 -0
  63. package/dist/tests/batch.test.js +117 -0
  64. package/dist/tests/body-parser.test.js +52 -0
  65. package/dist/tests/compression-sse.test.js +87 -0
  66. package/dist/tests/cookies.test.js +63 -0
  67. package/dist/tests/cors.test.js +55 -0
  68. package/dist/tests/database.test.js +80 -0
  69. package/dist/tests/dx.test.js +64 -0
  70. package/dist/tests/ecosystem.test.js +133 -0
  71. package/dist/tests/features.test.js +47 -0
  72. package/dist/tests/fusion.test.js +92 -0
  73. package/dist/tests/http-basic.test.js +124 -0
  74. package/dist/tests/logger.test.js +33 -0
  75. package/dist/tests/middleware.test.js +109 -0
  76. package/dist/tests/observability.test.js +59 -0
  77. package/dist/tests/openapi.test.js +64 -0
  78. package/dist/tests/plugin.test.js +65 -0
  79. package/dist/tests/plugins.test.js +71 -0
  80. package/dist/tests/rate-limit.test.js +77 -0
  81. package/dist/tests/resources.test.js +44 -0
  82. package/dist/tests/scheduler.test.js +46 -0
  83. package/dist/tests/schema-routes.test.js +77 -0
  84. package/dist/tests/security.test.js +83 -0
  85. package/dist/tests/server-db.test.js +72 -0
  86. package/dist/tests/smoke.test.js +10 -0
  87. package/dist/tests/sqlite-fusion.test.js +92 -0
  88. package/dist/tests/static.test.js +102 -0
  89. package/dist/tests/stream.test.js +44 -0
  90. package/dist/tests/task-metrics.test.js +53 -0
  91. package/dist/tests/tasks.test.js +62 -0
  92. package/dist/tests/testing.test.js +47 -0
  93. package/dist/tests/validation.test.js +107 -0
  94. package/dist/tests/websocket.test.js +146 -0
  95. package/dist/vitest.config.js +9 -0
  96. package/docs/AEGIS.md +76 -0
  97. package/docs/BENCHMARKS.md +36 -0
  98. package/docs/CAPABILITIES.md +70 -0
  99. package/docs/CLI.md +43 -0
  100. package/docs/DATABASE.md +142 -0
  101. package/docs/ECOSYSTEM.md +146 -0
  102. package/docs/NEXT_STEPS.md +99 -0
  103. package/docs/OPENAPI.md +99 -0
  104. package/docs/PLUGINS.md +59 -0
  105. package/docs/REAL_WORLD_EXAMPLES.md +109 -0
  106. package/docs/ROADMAP.md +366 -0
  107. package/docs/VALIDATION.md +136 -0
  108. package/eslint.config.cjs +26 -0
  109. package/examples/api-server.ts +254 -0
  110. package/package.json +61 -0
  111. package/src/benchmarks/compare-frameworks.ts +149 -0
  112. package/src/benchmarks/quantam-users.ts +70 -0
  113. package/src/benchmarks/simple-json.ts +71 -0
  114. package/src/benchmarks/ultra-mode.ts +159 -0
  115. package/src/cli/index.ts +214 -0
  116. package/src/client/index.ts +93 -0
  117. package/src/core/batch.ts +110 -0
  118. package/src/core/body-parser.ts +151 -0
  119. package/src/core/buffer-pool.ts +96 -0
  120. package/src/core/config.ts +60 -0
  121. package/src/core/fusion.ts +210 -0
  122. package/src/core/logger.ts +70 -0
  123. package/src/core/metrics.ts +166 -0
  124. package/src/core/resources.ts +38 -0
  125. package/src/core/scheduler.ts +126 -0
  126. package/src/core/scope.ts +87 -0
  127. package/src/core/serializer.ts +41 -0
  128. package/src/core/server.ts +1113 -0
  129. package/src/core/stream.ts +111 -0
  130. package/src/core/tasks.ts +138 -0
  131. package/src/core/types.ts +178 -0
  132. package/src/core/websocket.ts +112 -0
  133. package/src/core/worker-queue.ts +90 -0
  134. package/src/database/adapters/memory.ts +99 -0
  135. package/src/database/adapters/mongo.ts +116 -0
  136. package/src/database/adapters/postgres.ts +86 -0
  137. package/src/database/adapters/sqlite.ts +44 -0
  138. package/src/database/coalescer.ts +153 -0
  139. package/src/database/manager.ts +97 -0
  140. package/src/database/types.ts +24 -0
  141. package/src/index.ts +42 -0
  142. package/src/middleware/compression.ts +147 -0
  143. package/src/middleware/cors.ts +98 -0
  144. package/src/middleware/presets.ts +50 -0
  145. package/src/middleware/rate-limit.ts +106 -0
  146. package/src/middleware/security.ts +109 -0
  147. package/src/middleware/static.ts +216 -0
  148. package/src/openapi/generator.ts +167 -0
  149. package/src/router/radix-router.ts +119 -0
  150. package/src/router/radix-tree.ts +106 -0
  151. package/src/router/router.ts +190 -0
  152. package/src/testing/index.ts +104 -0
  153. package/src/utils/cookies.ts +67 -0
  154. package/src/utils/logger.ts +59 -0
  155. package/src/utils/signals.ts +45 -0
  156. package/src/utils/sse.ts +41 -0
  157. package/src/validation/index.ts +3 -0
  158. package/src/validation/simple.ts +93 -0
  159. package/src/validation/types.ts +38 -0
  160. package/src/validation/zod.ts +14 -0
  161. package/src/views/index.ts +1 -0
  162. package/src/views/types.ts +4 -0
  163. package/tests/adapters.test.ts +120 -0
  164. package/tests/batch.test.ts +139 -0
  165. package/tests/body-parser.test.ts +83 -0
  166. package/tests/compression-sse.test.ts +98 -0
  167. package/tests/cookies.test.ts +74 -0
  168. package/tests/cors.test.ts +79 -0
  169. package/tests/database.test.ts +90 -0
  170. package/tests/dx.test.ts +78 -0
  171. package/tests/ecosystem.test.ts +156 -0
  172. package/tests/features.test.ts +51 -0
  173. package/tests/fusion.test.ts +121 -0
  174. package/tests/http-basic.test.ts +161 -0
  175. package/tests/logger.test.ts +48 -0
  176. package/tests/middleware.test.ts +137 -0
  177. package/tests/observability.test.ts +91 -0
  178. package/tests/openapi.test.ts +74 -0
  179. package/tests/plugin.test.ts +85 -0
  180. package/tests/plugins.test.ts +93 -0
  181. package/tests/rate-limit.test.ts +97 -0
  182. package/tests/resources.test.ts +64 -0
  183. package/tests/scheduler.test.ts +71 -0
  184. package/tests/schema-routes.test.ts +89 -0
  185. package/tests/security.test.ts +128 -0
  186. package/tests/server-db.test.ts +72 -0
  187. package/tests/smoke.test.ts +9 -0
  188. package/tests/sqlite-fusion.test.ts +106 -0
  189. package/tests/static.test.ts +111 -0
  190. package/tests/stream.test.ts +58 -0
  191. package/tests/task-metrics.test.ts +78 -0
  192. package/tests/tasks.test.ts +90 -0
  193. package/tests/testing.test.ts +53 -0
  194. package/tests/validation.test.ts +126 -0
  195. package/tests/websocket.test.ts +132 -0
  196. package/tsconfig.json +16 -0
  197. package/vitest.config.ts +9 -0
@@ -0,0 +1,1113 @@
1
+ import http, { IncomingMessage, ServerResponse } from 'http';
2
+ import path from 'path';
3
+ import type { Duplex } from 'stream';
4
+ import { URL } from 'url';
5
+ import pkgJson from '../../package.json';
6
+ import { Router } from '../router/router';
7
+ import { Scheduler } from './scheduler';
8
+ import { TaskEngine } from './tasks';
9
+ import { calculateWorkerCount, isResourceOverloaded } from './resources';
10
+ import { Metrics } from './metrics';
11
+ import { BodyParser } from './body-parser';
12
+ import { BufferPool } from './buffer-pool';
13
+ import { WebSocketManager } from './websocket';
14
+ import { fastJsonStringify, getStringifier } from './serializer';
15
+ import { BatchExecutor } from './batch';
16
+ import { RequestFusion } from './fusion';
17
+ import { SimpleValidator } from '../validation/simple';
18
+ import { RouteSchema, Validator } from '../validation/types';
19
+ import { OpenAPIGenerator, OpenAPIOptions } from '../openapi/generator';
20
+ import {
21
+ HTTPMethod,
22
+ HttpError,
23
+ QHTTPXContext,
24
+ QHTTPXErrorHandler,
25
+ QHTTPXHandler,
26
+ QHTTPXMethodNotAllowedHandler,
27
+ QHTTPXMiddleware,
28
+ QHTTPXNotFoundHandler,
29
+ QHTTPXOptions,
30
+ QHTTPXRouteOptions,
31
+ QHTTPXTracer,
32
+ RoutePriority,
33
+ QHTTPXPlugin,
34
+ QHTTPXPluginOptions,
35
+ CookieOptions,
36
+ } from './types';
37
+
38
+ import { parseCookies, serializeCookie } from '../utils/cookies';
39
+ import { QHTTPXScope } from './scope';
40
+ import { Logger } from './logger';
41
+
42
+ export class QHTTPX {
43
+ private readonly options: QHTTPXOptions;
44
+
45
+ private readonly server: http.Server;
46
+
47
+ public readonly logger: Logger;
48
+
49
+ private readonly router: Router;
50
+
51
+ private readonly scheduler: Scheduler;
52
+
53
+ private readonly middlewares: QHTTPXMiddleware[] = [];
54
+
55
+ private readonly workerCount: number;
56
+
57
+ private readonly tasks: TaskEngine;
58
+
59
+ private readonly metrics: Metrics;
60
+
61
+ private readonly contextPool: QHTTPXContext[] = [];
62
+
63
+ private readonly bufferPool: BufferPool;
64
+
65
+ private pipelineRunner: ((ctx: QHTTPXContext, handler: QHTTPXHandler) => Promise<void>) | null = null;
66
+
67
+ private errorHandler?: QHTTPXErrorHandler;
68
+
69
+ private notFoundHandler?: QHTTPXNotFoundHandler;
70
+
71
+ private methodNotAllowedHandler?: QHTTPXMethodNotAllowedHandler;
72
+
73
+ private readonly tracer?: QHTTPXTracer;
74
+
75
+ private readonly onStartHooks: Array<() => void | Promise<void>> = [];
76
+
77
+ private readonly onBeforeShutdownHooks: Array<() => void | Promise<void>> = [];
78
+
79
+ private readonly onShutdownHooks: Array<() => void | Promise<void>> = [];
80
+
81
+ private nextRequestId = 1;
82
+
83
+ private readonly wsManager: WebSocketManager;
84
+
85
+ private readonly ultraMode: boolean;
86
+
87
+ private readonly batchExecutor?: BatchExecutor;
88
+
89
+ private readonly fusion?: RequestFusion;
90
+
91
+ private readonly validator: Validator;
92
+
93
+ constructor(options: QHTTPXOptions = {}) {
94
+ this.options = options;
95
+ this.logger = new Logger({
96
+ name: options.name,
97
+ level: options.performanceMode === 'ultra' ? 'error' : 'info'
98
+ });
99
+ this.ultraMode = options.performanceMode === 'ultra';
100
+ this.errorHandler = this.ultraMode ? undefined : options.errorHandler;
101
+ this.notFoundHandler = this.ultraMode ? undefined : options.notFoundHandler;
102
+ this.methodNotAllowedHandler = this.ultraMode ? undefined : options.methodNotAllowedHandler;
103
+ this.tracer = this.ultraMode ? undefined : options.tracer;
104
+ this.workerCount = calculateWorkerCount(options.workers ?? 'auto');
105
+ this.router = new Router();
106
+ this.wsManager = new WebSocketManager(this.generateRequestId.bind(this));
107
+ this.bufferPool = new BufferPool(options.bufferPoolConfig);
108
+ const maxConcurrency = options.maxConcurrency ?? 1024;
109
+ this.scheduler = new Scheduler({
110
+ maxConcurrency,
111
+ });
112
+ this.tasks = new TaskEngine(this.scheduler);
113
+
114
+ if (options.enableBatching) {
115
+ this.batchExecutor = new BatchExecutor(options.database);
116
+ }
117
+
118
+ if (options.enableRequestFusion) {
119
+ this.fusion = new RequestFusion(options.enableRequestFusion);
120
+ }
121
+
122
+ this.validator = options.validator ?? new SimpleValidator();
123
+
124
+ this.metrics = new Metrics(
125
+ this.scheduler,
126
+ {
127
+ enabled: !this.ultraMode && (this.options.metricsEnabled ?? true),
128
+ },
129
+ this.tasks,
130
+ );
131
+ for (let i = 0; i < maxConcurrency; i += 1) {
132
+ this.contextPool.push(this.createContext());
133
+ }
134
+ this.registerInternalRoutes();
135
+ this.compileMiddlewarePipeline();
136
+ this.server = http.createServer(this.handleRequest.bind(this));
137
+ if (this.options.keepAliveTimeoutMs !== undefined) {
138
+ this.server.keepAliveTimeout = this.options.keepAliveTimeoutMs;
139
+ } else if (this.ultraMode) {
140
+ this.server.keepAliveTimeout = 0;
141
+ }
142
+ if (this.options.headersTimeoutMs !== undefined) {
143
+ this.server.headersTimeout = this.options.headersTimeoutMs;
144
+ } else if (this.ultraMode) {
145
+ this.server.headersTimeout = 0;
146
+ }
147
+ if (this.ultraMode) {
148
+ this.server.requestTimeout = 0;
149
+ this.server.maxHeadersCount = 0;
150
+ this.server.timeout = 0;
151
+ }
152
+ this.server.on('upgrade', (req, socket, head) => {
153
+ void this.handleUpgrade(req, socket, head);
154
+ });
155
+ }
156
+
157
+ get serverInstance(): http.Server {
158
+ return this.server;
159
+ }
160
+
161
+ get websocket(): WebSocketManager {
162
+ return this.wsManager;
163
+ }
164
+
165
+ setErrorHandler(handler: QHTTPXErrorHandler): void {
166
+ this.errorHandler = handler;
167
+ }
168
+
169
+ setNotFoundHandler(handler: QHTTPXNotFoundHandler): void {
170
+ this.notFoundHandler = handler;
171
+ }
172
+
173
+ setMethodNotAllowedHandler(handler: QHTTPXMethodNotAllowedHandler): void {
174
+ this.methodNotAllowedHandler = handler;
175
+ }
176
+
177
+ set404Handler(handler: QHTTPXNotFoundHandler): void {
178
+ this.setNotFoundHandler(handler);
179
+ }
180
+
181
+ set405Handler(handler: QHTTPXMethodNotAllowedHandler): void {
182
+ this.setMethodNotAllowedHandler(handler);
183
+ }
184
+
185
+ onStart(hook: () => void | Promise<void>): void {
186
+ this.onStartHooks.push(hook);
187
+ }
188
+
189
+ onBeforeShutdown(hook: () => void | Promise<void>): void {
190
+ this.onBeforeShutdownHooks.push(hook);
191
+ }
192
+
193
+ onShutdown(hook: () => void | Promise<void>): void {
194
+ this.onShutdownHooks.push(hook);
195
+ }
196
+
197
+ upgrade(
198
+ path: string,
199
+ handler: import('./websocket').WSHandler,
200
+ ): void {
201
+ this.wsManager.register(path, handler);
202
+ }
203
+
204
+ use(middleware: QHTTPXMiddleware): void {
205
+ this.middlewares.push(middleware);
206
+ // Recompile pipeline after middleware is added
207
+ this.compileMiddlewarePipeline();
208
+ }
209
+
210
+ private compileMiddlewarePipeline(): void {
211
+ const middlewares = this.middlewares;
212
+
213
+ if (middlewares.length === 0) {
214
+ this.pipelineRunner = async (ctx, handler) => {
215
+ const result = handler(ctx);
216
+ if (result && typeof (result as Promise<void>).then === 'function') {
217
+ await result;
218
+ }
219
+ };
220
+ return;
221
+ }
222
+
223
+ // Flatten middleware pipeline into a single function chain
224
+ // This eliminates Promise nesting, recursive dispatch overhead, and microtask backlogs
225
+ // Each middleware executes directly without closure allocation overhead
226
+ this.pipelineRunner = async (ctx, handler) => {
227
+ let index = 0;
228
+
229
+ const executeNext = async (): Promise<void> => {
230
+ if (index >= middlewares.length) {
231
+ // All middlewares done, execute handler
232
+ const result = handler(ctx);
233
+ if (result && typeof (result as unknown as Record<string, unknown>).then === 'function') {
234
+ await (result as Promise<void>);
235
+ }
236
+ return;
237
+ }
238
+
239
+ const currentIndex = index;
240
+ index += 1;
241
+
242
+ const result = middlewares[currentIndex](ctx, executeNext);
243
+ if (result && typeof (result as unknown as Record<string, unknown>).then === 'function') {
244
+ await (result as Promise<void>);
245
+ }
246
+ };
247
+
248
+ await executeNext();
249
+ };
250
+ }
251
+
252
+ private compileRoutePipeline(
253
+ handler: QHTTPXHandler,
254
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
255
+ schema?: RouteSchema | Record<string, any>,
256
+ ): QHTTPXHandler {
257
+ const middlewares = this.middlewares;
258
+
259
+ // Heuristic: Is it a structured RouteSchema or a legacy ResponseSchema?
260
+ let responseSchema: unknown | undefined;
261
+ let requestSchema: RouteSchema | undefined;
262
+
263
+ if (schema) {
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
+ const s = schema as any;
266
+ if (s.body || s.query || s.params || s.headers || s.response) {
267
+ // It is a RouteSchema
268
+ requestSchema = s;
269
+ responseSchema = s.response;
270
+ } else {
271
+ // It is a legacy ResponseSchema
272
+ responseSchema = s;
273
+ }
274
+ }
275
+
276
+ const stringifier = responseSchema ? getStringifier(responseSchema) : undefined;
277
+
278
+ // Build middleware chain
279
+ let pipeline = handler;
280
+
281
+ // Wrap with validation if needed (before handler, after global middleware)
282
+ if (requestSchema) {
283
+ const inner = pipeline;
284
+ pipeline = async (ctx) => {
285
+ // Validate Body
286
+ if (requestSchema!.body) {
287
+ const result = await this.validator.validate(requestSchema!.body, ctx.body);
288
+ if (!result.success) {
289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
290
+ const err = result.error as any;
291
+ ctx.json({ error: 'Validation Error', details: err.details || err.message || err }, 400);
292
+ return;
293
+ }
294
+ ctx.body = result.data;
295
+ }
296
+
297
+ // Validate Query
298
+ if (requestSchema!.query) {
299
+ const result = await this.validator.validate(requestSchema!.query, ctx.query);
300
+ if (!result.success) {
301
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
+ const err = result.error as any;
303
+ ctx.json({ error: 'Validation Error', details: err.details || err.message || err }, 400);
304
+ return;
305
+ }
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
+ (ctx as any).query = result.data;
308
+ }
309
+
310
+ // Validate Params
311
+ if (requestSchema!.params) {
312
+ const result = await this.validator.validate(requestSchema!.params, ctx.params);
313
+ if (!result.success) {
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ const err = result.error as any;
316
+ ctx.json({ error: 'Validation Error', details: err.details || err.message || err }, 400);
317
+ return;
318
+ }
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
+ (ctx as any).params = result.data;
321
+ }
322
+
323
+ return inner(ctx);
324
+ };
325
+ }
326
+
327
+ // Wrap with Request Fusion if enabled
328
+ if (this.fusion) {
329
+ const inner = pipeline;
330
+ pipeline = (ctx) => this.fusion!.coalesce(ctx, inner);
331
+ }
332
+
333
+ // We iterate backwards to wrap the handler
334
+ // pipeline = m[last](ctx, () => pipeline(ctx))
335
+ for (let i = middlewares.length - 1; i >= 0; i -= 1) {
336
+ const middleware = middlewares[i];
337
+ const next = pipeline;
338
+ pipeline = (ctx) => {
339
+ return middleware(ctx, async () => {
340
+ const result = next(ctx);
341
+ if (result && typeof (result as Promise<void>).then === 'function') {
342
+ await result;
343
+ }
344
+ });
345
+ };
346
+ }
347
+
348
+ if (stringifier) {
349
+ const inner = pipeline;
350
+ return (ctx) => {
351
+ ctx.serializer = stringifier;
352
+ return inner(ctx);
353
+ };
354
+ }
355
+
356
+ return pipeline;
357
+ }
358
+
359
+ // Internal registration to be accessed by Scopes
360
+ public _registerRoute(
361
+ method: HTTPMethod,
362
+ path: string,
363
+ handlerOrOptions: QHTTPXHandler | QHTTPXRouteOptions,
364
+ ): void {
365
+ this.registerRoute(method, path, handlerOrOptions);
366
+ }
367
+
368
+ public async register<Options extends QHTTPXPluginOptions>(
369
+ plugin: QHTTPXPlugin<Options>,
370
+ options?: Options
371
+ ): Promise<void> {
372
+ const scope = new QHTTPXScope(this, options?.prefix);
373
+ await plugin(scope, options as Options);
374
+ }
375
+
376
+ private registerRoute(
377
+ method: HTTPMethod,
378
+ path: string,
379
+ handlerOrOptions: QHTTPXHandler | QHTTPXRouteOptions,
380
+ ): void {
381
+ let handler: QHTTPXHandler;
382
+ let schema: RouteSchema | Record<string, unknown> | undefined;
383
+ const options: { priority?: RoutePriority } = {};
384
+
385
+ if (typeof handlerOrOptions === 'function') {
386
+ handler = handlerOrOptions;
387
+ } else {
388
+ handler = handlerOrOptions.handler;
389
+ schema = handlerOrOptions.schema;
390
+ options.priority = handlerOrOptions.priority;
391
+ }
392
+
393
+ const compiled = this.compileRoutePipeline(handler, schema);
394
+ this.router.register(method, path, compiled, { ...options, schema });
395
+ }
396
+
397
+ get(path: string, handler: QHTTPXHandler | QHTTPXRouteOptions): void {
398
+ this.registerRoute('GET', path, handler);
399
+ }
400
+
401
+ post(path: string, handler: QHTTPXHandler | QHTTPXRouteOptions): void {
402
+ this.registerRoute('POST', path, handler);
403
+ }
404
+
405
+ put(path: string, handler: QHTTPXHandler | QHTTPXRouteOptions): void {
406
+ this.registerRoute('PUT', path, handler);
407
+ }
408
+
409
+ delete(path: string, handler: QHTTPXHandler | QHTTPXRouteOptions): void {
410
+ this.registerRoute('DELETE', path, handler);
411
+ }
412
+
413
+ route(path: string) {
414
+ const register = (method: HTTPMethod, handler: QHTTPXHandler | QHTTPXRouteOptions) => {
415
+ this.registerRoute(method, path, handler);
416
+ };
417
+
418
+ const builder = {
419
+ get(handler: QHTTPXHandler | QHTTPXRouteOptions) {
420
+ register('GET', handler);
421
+ return this;
422
+ },
423
+ post(handler: QHTTPXHandler | QHTTPXRouteOptions) {
424
+ register('POST', handler);
425
+ return this;
426
+ },
427
+ put(handler: QHTTPXHandler | QHTTPXRouteOptions) {
428
+ register('PUT', handler);
429
+ return this;
430
+ },
431
+ delete(handler: QHTTPXHandler | QHTTPXRouteOptions) {
432
+ register('DELETE', handler);
433
+ return this;
434
+ },
435
+ };
436
+
437
+ return builder;
438
+ }
439
+
440
+ task(
441
+ name: string,
442
+ handler: import('./types').QHTTPXTaskHandler,
443
+ options?: import('./types').QHTTPXTaskOptions,
444
+ ): void {
445
+ this.tasks.register(name, handler, options);
446
+ }
447
+
448
+ enqueue(name: string, payload: unknown): Promise<void> {
449
+ return this.tasks.enqueue(name, payload);
450
+ }
451
+
452
+ op(name: string, handler: import('./types').QHTTPXOpHandler): void {
453
+ if (!this.batchExecutor) {
454
+ // Auto-enable batch executor if op is called?
455
+ // Or throw?
456
+ // Better to throw or warn if not enabled.
457
+ // But for ease of use, let's create it if missing (but we miss DB context if done this way without options)
458
+ // The constructor handles options. If enableBatching is false, we shouldn't be here.
459
+ throw new Error('Batching is not enabled. Pass enableBatching: true to QHTTPX options.');
460
+ }
461
+ this.batchExecutor.register(name, handler);
462
+ }
463
+
464
+ private registerInternalRoutes(): void {
465
+ this.router.register('GET', '/__qhttpx/health', (ctx) => {
466
+ const version = typeof pkgJson.version === 'string' ? pkgJson.version : '';
467
+ const name = typeof pkgJson.name === 'string' ? pkgJson.name : '';
468
+
469
+ ctx.json({
470
+ status: 'ok',
471
+ name,
472
+ version,
473
+ workers: this.workerCount,
474
+ });
475
+ });
476
+
477
+ this.router.register('GET', '/__qhttpx/metrics', (ctx) => {
478
+ const snapshot = this.metrics.snapshot();
479
+ ctx.json({
480
+ ...snapshot,
481
+ workers: this.workerCount,
482
+ });
483
+ });
484
+
485
+ this.router.register('GET', '/__qhttpx/runtime', (ctx) => {
486
+ const schedulerStats = this.scheduler.getStats();
487
+ ctx.json({
488
+ workers: this.workerCount,
489
+ router: {
490
+ frozen: this.router.isFrozenRouter(),
491
+ },
492
+ scheduler: schedulerStats,
493
+ });
494
+ });
495
+
496
+ if (this.options.enableBatching && this.batchExecutor) {
497
+ const endpoint = typeof this.options.enableBatching === 'object'
498
+ ? this.options.enableBatching.endpoint
499
+ : '/qhttpx';
500
+
501
+ this.router.register('POST', endpoint, async (ctx) => {
502
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
503
+ const body = ctx.body as any;
504
+ if (!body || !Array.isArray(body.batch)) {
505
+ ctx.json({ error: 'Invalid batch format' }, 400);
506
+ return;
507
+ }
508
+
509
+ try {
510
+ const result = await this.batchExecutor!.handleBatch(ctx, body.batch);
511
+ ctx.json(result);
512
+ } catch (err) {
513
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
514
+ ctx.json({ error: (err as any).message }, 500);
515
+ }
516
+ });
517
+ }
518
+ }
519
+
520
+ getOpenAPI(options: OpenAPIOptions): object {
521
+ const generator = new OpenAPIGenerator(this.router, options);
522
+ return generator.generate();
523
+ }
524
+
525
+ public async listen(port: number, hostname?: string): Promise<{ port: number }> {
526
+ if (this.options.database) {
527
+ await this.options.database.connect();
528
+ }
529
+
530
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
531
+ if ((this.options as any).plugins) {
532
+ // Plugins are registered synchronously in constructor, but their async init happens now?
533
+ // Actually, plugins are registered via app.register() which is async.
534
+ // So they should be ready.
535
+ }
536
+
537
+ return new Promise((resolve, reject) => {
538
+ const onError = (error: unknown) => {
539
+ this.server.off('error', onError);
540
+ reject(error);
541
+ };
542
+
543
+ this.server.once('error', onError);
544
+
545
+ this.server.listen(port, hostname, () => {
546
+ this.server.off('error', onError);
547
+ // Freeze router after server starts to prevent further registrations
548
+ this.router.freeze();
549
+ void this.runLifecycleHooks(this.onStartHooks);
550
+ const address = this.server.address();
551
+ if (address && typeof address === 'object') {
552
+ resolve({ port: address.port });
553
+ } else {
554
+ resolve({ port });
555
+ }
556
+ });
557
+ });
558
+ }
559
+
560
+ close(): Promise<void> {
561
+ return new Promise((resolve, reject) => {
562
+ this.server.close((err) => {
563
+ if (err) {
564
+ reject(err);
565
+ return;
566
+ }
567
+ resolve();
568
+ });
569
+ });
570
+ }
571
+
572
+ async shutdown(): Promise<void> {
573
+ await this.runLifecycleHooks(this.onBeforeShutdownHooks);
574
+ await this.close();
575
+ await this.runLifecycleHooks(this.onShutdownHooks);
576
+ }
577
+
578
+ private createContext(): QHTTPXContext {
579
+ const jsonSerializer = this.options.jsonSerializer;
580
+ const useFastStringify = jsonSerializer === undefined;
581
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
582
+ const ctx: any = {
583
+ req: null,
584
+ res: null,
585
+ url: null,
586
+ params: null,
587
+ query: null,
588
+ body: undefined,
589
+ cookies: null,
590
+ state: null,
591
+ bufferPool: this.bufferPool,
592
+ requestId: '',
593
+ requestStart: 0,
594
+ serializer: null,
595
+ json(payload: unknown, status = 200) {
596
+ const res = this.res;
597
+ if (!res.headersSent) {
598
+ res.statusCode = status;
599
+ res.setHeader('content-type', 'application/json; charset=utf-8');
600
+ }
601
+ let body: string | Buffer;
602
+ if (this.serializer) {
603
+ body = this.serializer(payload);
604
+ } else if (useFastStringify) {
605
+ body = fastJsonStringify(payload);
606
+ } else if (jsonSerializer) {
607
+ body = jsonSerializer(payload);
608
+ } else {
609
+ body = JSON.stringify(payload);
610
+ }
611
+ res.end(body);
612
+ },
613
+ send(payload: string | Buffer, status = 200) {
614
+ const res = this.res;
615
+ if (!res.headersSent) {
616
+ res.statusCode = status;
617
+ }
618
+ res.end(payload);
619
+ },
620
+ html(payload: string, status = 200) {
621
+ const res = this.res;
622
+ if (!res.headersSent) {
623
+ res.statusCode = status;
624
+ res.setHeader('content-type', 'text/html; charset=utf-8');
625
+ }
626
+ res.end(payload);
627
+ },
628
+ redirect(url: string, status = 302) {
629
+ const res = this.res;
630
+ if (!res.headersSent) {
631
+ res.statusCode = status;
632
+ res.setHeader('Location', url);
633
+ }
634
+ res.end();
635
+ },
636
+ setCookie(name: string, value: string, options: CookieOptions | undefined) {
637
+ const res = this.res;
638
+ const serialized = serializeCookie(name, value, options);
639
+ let existing = res.getHeader('Set-Cookie');
640
+ if (Array.isArray(existing)) {
641
+ existing.push(serialized);
642
+ res.setHeader('Set-Cookie', existing);
643
+ } else if (existing) {
644
+ res.setHeader('Set-Cookie', [existing as string, serialized]);
645
+ } else {
646
+ res.setHeader('Set-Cookie', serialized);
647
+ }
648
+ },
649
+ db: this.options.database,
650
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
651
+ render: async (view: string, locals?: Record<string, any>) => {
652
+ const engine = this.options.viewEngine;
653
+ if (!engine) {
654
+ throw new Error('No view engine registered');
655
+ }
656
+ const viewsPath = this.options.viewsPath || process.cwd();
657
+ const fullPath = path.resolve(viewsPath, view);
658
+
659
+ const html = await engine.render(fullPath, locals || {});
660
+
661
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
662
+ const res = (ctx as any).res;
663
+ if (!res.headersSent) {
664
+ res.statusCode = 200;
665
+ res.setHeader('content-type', 'text/html; charset=utf-8');
666
+ }
667
+ res.end(html);
668
+ },
669
+ validate: async <T>(schema: unknown, data?: unknown): Promise<T> => {
670
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
671
+ const target = data ?? (ctx as any).body;
672
+ const result = await this.validator.validate(schema, target);
673
+ if (result.success) {
674
+ return result.data as T;
675
+ }
676
+ throw new HttpError(400, 'Validation Error', {
677
+ code: 'VALIDATION_ERROR',
678
+ details: result.error,
679
+ });
680
+ },
681
+ };
682
+ return ctx;
683
+ }
684
+
685
+ private acquireContext(
686
+ req: IncomingMessage,
687
+ res: ServerResponse,
688
+ url: URL,
689
+ params: Record<string, string>,
690
+ query: Record<string, string | string[]>,
691
+ requestId: string | undefined,
692
+ body: unknown,
693
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
694
+ files?: Record<string, any>,
695
+ ): QHTTPXContext {
696
+ const ctx = this.contextPool.pop() ?? this.createContext();
697
+ // Reset and populate properties
698
+ // We use type assertions to write to readonly/managed properties for performance
699
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
700
+ const mutableCtx = ctx as any;
701
+ mutableCtx.req = req;
702
+ mutableCtx.res = res;
703
+ mutableCtx.url = url;
704
+ mutableCtx.params = params;
705
+ mutableCtx.query = query;
706
+ mutableCtx.body = body;
707
+ mutableCtx.files = files;
708
+ mutableCtx.requestId = requestId;
709
+ mutableCtx.serializer = undefined;
710
+ // In ultra mode, skip cookie parsing to save overhead
711
+ if (!this.ultraMode) {
712
+ mutableCtx.cookies = parseCookies(req.headers.cookie);
713
+ }
714
+ mutableCtx.state = {};
715
+ mutableCtx.disableAutoEnd = false;
716
+
717
+ return ctx;
718
+ }
719
+
720
+ private releaseContext(ctx: QHTTPXContext) {
721
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
722
+ const mutableCtx = ctx as any;
723
+ mutableCtx.req = null;
724
+ mutableCtx.res = null;
725
+ mutableCtx.url = null;
726
+ mutableCtx.params = null;
727
+ mutableCtx.query = null;
728
+ mutableCtx.body = undefined;
729
+ mutableCtx.files = undefined;
730
+ mutableCtx.requestId = '';
731
+ mutableCtx.requestStart = 0;
732
+ mutableCtx.serializer = null;
733
+ mutableCtx.cookies = null;
734
+ mutableCtx.state = null;
735
+ // render method is static per context instance creation (closure over options),
736
+ // but good to keep it consistent.
737
+ // Wait, 'render' is defined in 'createContext' and depends on 'this.options'.
738
+ // It doesn't hold request-specific state other than 'res' which is updated in 'acquireContext'.
739
+ // So we don't need to null it out.
740
+
741
+ if (this.contextPool.length < (this.options.maxConcurrency ?? 1024)) {
742
+ this.contextPool.push(ctx);
743
+ }
744
+ }
745
+
746
+ private async handleRequest(req: IncomingMessage, res: ServerResponse) {
747
+ const rawMethod = (req.method || 'GET').toUpperCase();
748
+ const method = rawMethod as HTTPMethod;
749
+ const rawUrl = req.url || '/';
750
+ const host = req.headers.host || 'localhost';
751
+ const url = new URL(rawUrl, `http://${host}`);
752
+
753
+ const incomingRequestIdHeader = req.headers['x-request-id'];
754
+ let requestId: string | undefined;
755
+ if (typeof incomingRequestIdHeader === 'string') {
756
+ requestId = incomingRequestIdHeader;
757
+ } else if (Array.isArray(incomingRequestIdHeader)) {
758
+ requestId = incomingRequestIdHeader[0];
759
+ }
760
+ if (!requestId) {
761
+ requestId = this.generateRequestId();
762
+ }
763
+ if (!res.headersSent && requestId) {
764
+ res.setHeader('x-request-id', requestId);
765
+ }
766
+
767
+ let match = this.router.match(method, url.pathname);
768
+ const hasRoute = !!match;
769
+
770
+ if (!match) {
771
+ match = {
772
+ handler: () => {},
773
+ params: {},
774
+ priority: RoutePriority.STANDARD,
775
+ };
776
+ }
777
+
778
+ const allowedMethods = this.router.getAllowedMethods(url.pathname);
779
+
780
+ if (!hasRoute && !res.headersSent) {
781
+ const hasAnyMethod = allowedMethods.length > 0;
782
+ res.statusCode = hasAnyMethod ? 405 : 404;
783
+ }
784
+
785
+ const query: Record<string, string | string[]> = {};
786
+ url.searchParams.forEach((value, key) => {
787
+ if (Object.prototype.hasOwnProperty.call(query, key)) {
788
+ const existing = query[key];
789
+ if (Array.isArray(existing)) {
790
+ existing.push(value);
791
+ } else {
792
+ query[key] = [existing, value];
793
+ }
794
+ } else {
795
+ query[key] = value;
796
+ }
797
+ });
798
+
799
+ if (this.options.maxMemoryBytes !== undefined) {
800
+ const sampleRss = process.memoryUsage().rss;
801
+ const overloadedByMemory = isResourceOverloaded(
802
+ { rssBytes: sampleRss },
803
+ { maxRssBytes: this.options.maxMemoryBytes },
804
+ );
805
+ if (overloadedByMemory) {
806
+ const overloaded = () => {
807
+ if (res.writableEnded) {
808
+ return;
809
+ }
810
+ if (!res.headersSent) {
811
+ res.statusCode = 503;
812
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
813
+ }
814
+ res.end('Server overloaded');
815
+ };
816
+ overloaded();
817
+ return;
818
+ }
819
+ }
820
+
821
+ let body: unknown;
822
+ let files: Record<string, unknown> | undefined;
823
+
824
+ if (method !== 'GET' && method !== 'HEAD') {
825
+ try {
826
+ const parsed = await BodyParser.parse(req, {
827
+ maxBodyBytes: this.options.maxBodyBytes,
828
+ });
829
+ body = parsed.body;
830
+ files = parsed.files;
831
+ } catch (err) {
832
+ if (err instanceof Error && err.message === 'QHTTPX_INVALID_JSON') {
833
+ if (!res.headersSent) {
834
+ res.statusCode = 400;
835
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
836
+ }
837
+ res.end('Invalid JSON');
838
+ return;
839
+ }
840
+ if (err instanceof Error && err.message === 'QHTTPX_BODY_TOO_LARGE') {
841
+ if (!res.headersSent) {
842
+ res.statusCode = 413;
843
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
844
+ }
845
+ res.end('Payload Too Large');
846
+ return;
847
+ }
848
+
849
+ if (!res.headersSent) {
850
+ res.statusCode = 500;
851
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
852
+ }
853
+ res.end('Internal Server Error');
854
+ return;
855
+ }
856
+ }
857
+
858
+ const ctx = this.acquireContext(
859
+ req,
860
+ res,
861
+ url,
862
+ match.params,
863
+ query,
864
+ requestId,
865
+ body,
866
+ files,
867
+ );
868
+
869
+ const overloaded = () => {
870
+ if (res.writableEnded) {
871
+ return;
872
+ }
873
+ if (!res.headersSent) {
874
+ res.statusCode = 503;
875
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
876
+ }
877
+ res.end('Server overloaded');
878
+ };
879
+
880
+ let timedOut = false;
881
+
882
+ const onTimeout = () => {
883
+ timedOut = true;
884
+ if (res.writableEnded) {
885
+ return;
886
+ }
887
+ if (!res.headersSent) {
888
+ res.statusCode = 504;
889
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
890
+ }
891
+ res.end('Request timed out');
892
+ };
893
+
894
+ const start = Date.now();
895
+ ctx.requestStart = start;
896
+ if (!this.ultraMode) {
897
+ this.metrics.onRequestStart();
898
+ }
899
+
900
+ if (this.tracer) {
901
+ const event = {
902
+ type: 'request_start' as const,
903
+ method,
904
+ path: url.pathname,
905
+ requestId,
906
+ };
907
+ const result = this.tracer(event);
908
+ if (result && typeof (result as Promise<void>).then === 'function') {
909
+ void result;
910
+ }
911
+ }
912
+
913
+ const handle = async () => {
914
+ const handler = match.handler;
915
+
916
+ try {
917
+ if (hasRoute) {
918
+ // Optimized path: Route handler is already compiled with middlewares
919
+ const result = handler(ctx);
920
+ if (result && typeof (result as Promise<void>).then === 'function') {
921
+ await result;
922
+ }
923
+ } else if (this.pipelineRunner) {
924
+ // Slow path: Run global middleware pipeline for 404/405
925
+ await this.pipelineRunner(ctx, handler);
926
+ }
927
+
928
+ if (!res.writableEnded) {
929
+ if (!hasRoute) {
930
+ const hasAnyMethod = allowedMethods.length > 0;
931
+ if (hasAnyMethod && this.methodNotAllowedHandler) {
932
+ const result = this.methodNotAllowedHandler(
933
+ ctx,
934
+ allowedMethods,
935
+ );
936
+ if (
937
+ result &&
938
+ typeof (result as Promise<void>).then === 'function'
939
+ ) {
940
+ await result;
941
+ }
942
+ if (!res.writableEnded && !res.headersSent) {
943
+ res.statusCode = 405;
944
+ res.setHeader(
945
+ 'content-type',
946
+ 'text/plain; charset=utf-8',
947
+ );
948
+ res.end('Method Not Allowed');
949
+ }
950
+ } else if (!hasAnyMethod && this.notFoundHandler) {
951
+ const result = this.notFoundHandler(ctx);
952
+ if (
953
+ result &&
954
+ typeof (result as Promise<void>).then === 'function'
955
+ ) {
956
+ await result;
957
+ }
958
+ if (!res.writableEnded && !res.headersSent) {
959
+ res.statusCode = 404;
960
+ res.setHeader(
961
+ 'content-type',
962
+ 'text/plain; charset=utf-8',
963
+ );
964
+ res.end('Not Found');
965
+ }
966
+ } else if (hasAnyMethod) {
967
+ if (!res.headersSent) {
968
+ res.statusCode = 405;
969
+ res.setHeader(
970
+ 'content-type',
971
+ 'text/plain; charset=utf-8',
972
+ );
973
+ }
974
+ res.end('Method Not Allowed');
975
+ } else {
976
+ if (!res.headersSent) {
977
+ res.statusCode = 404;
978
+ res.setHeader(
979
+ 'content-type',
980
+ 'text/plain; charset=utf-8',
981
+ );
982
+ }
983
+ res.end('Not Found');
984
+ }
985
+ } else if (!ctx.disableAutoEnd) {
986
+ res.end();
987
+ }
988
+ }
989
+ } catch (err) {
990
+ await this.handleError(err, ctx);
991
+ }
992
+ };
993
+
994
+ if (this.ultraMode) {
995
+ await handle();
996
+ } else {
997
+ await this.scheduler.run(handle, {
998
+ priority: match.priority,
999
+ onOverloaded: overloaded,
1000
+ timeoutMs: this.options.requestTimeoutMs,
1001
+ onTimeout,
1002
+ });
1003
+ }
1004
+
1005
+ const duration = Date.now() - start;
1006
+ if (!this.ultraMode) {
1007
+ this.metrics.onRequestEnd(duration, res.statusCode);
1008
+ if (timedOut) {
1009
+ this.metrics.onTimeout();
1010
+ }
1011
+ }
1012
+
1013
+ if (this.tracer) {
1014
+ const event = {
1015
+ type: 'request_end' as const,
1016
+ method,
1017
+ path: url.pathname,
1018
+ statusCode: res.statusCode,
1019
+ durationMs: duration,
1020
+ requestId,
1021
+ };
1022
+ const result = this.tracer(event);
1023
+ if (result && typeof (result as Promise<void>).then === 'function') {
1024
+ void result;
1025
+ }
1026
+ }
1027
+
1028
+ this.releaseContext(ctx);
1029
+ }
1030
+
1031
+ private async handleUpgrade(
1032
+ req: IncomingMessage,
1033
+ socket: Duplex,
1034
+ head: Buffer,
1035
+ ): Promise<void> {
1036
+ await this.wsManager.handleUpgrade(req, socket, head);
1037
+ }
1038
+
1039
+ private async runLifecycleHooks(
1040
+ hooks: Array<() => void | Promise<void>>,
1041
+ ): Promise<void> {
1042
+ if (hooks.length === 0) {
1043
+ return;
1044
+ }
1045
+ for (const hook of hooks) {
1046
+ try {
1047
+ const result = hook();
1048
+ if (result && typeof (result as Promise<void>).then === 'function') {
1049
+ await result;
1050
+ }
1051
+ } catch {
1052
+ // Ignore hook errors to avoid impacting core server flow
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ private async handleError(err: unknown, ctx: QHTTPXContext): Promise<void> {
1058
+ const res = ctx.res;
1059
+
1060
+ if (res.writableEnded) {
1061
+ return;
1062
+ }
1063
+
1064
+ if (this.errorHandler) {
1065
+ try {
1066
+ const result = this.errorHandler(err, ctx);
1067
+ if (result && typeof (result as Promise<void>).then === 'function') {
1068
+ await result;
1069
+ }
1070
+ if (res.writableEnded) {
1071
+ return;
1072
+ }
1073
+ } catch {
1074
+ // Fall through to default error handling below
1075
+ }
1076
+ }
1077
+
1078
+ if (res.writableEnded) {
1079
+ return;
1080
+ }
1081
+
1082
+ if (err instanceof HttpError) {
1083
+ if (!res.headersSent) {
1084
+ res.statusCode = err.status;
1085
+ res.setHeader('content-type', 'application/json; charset=utf-8');
1086
+ }
1087
+ const payload = {
1088
+ error: {
1089
+ message: err.message,
1090
+ code: err.code ?? 'HTTP_ERROR',
1091
+ details: err.details,
1092
+ },
1093
+ };
1094
+ const serializer = this.options.jsonSerializer;
1095
+ const body =
1096
+ serializer !== undefined ? serializer(payload) : JSON.stringify(payload);
1097
+ res.end(body);
1098
+ return;
1099
+ }
1100
+
1101
+ if (!res.headersSent) {
1102
+ res.statusCode = 500;
1103
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
1104
+ }
1105
+ res.end('Internal Server Error');
1106
+ }
1107
+
1108
+ private generateRequestId(): string {
1109
+ const id = this.nextRequestId;
1110
+ this.nextRequestId += 1;
1111
+ return `${Date.now().toString(36)}-${id.toString(36)}`;
1112
+ }
1113
+ }