@morojs/moro 1.7.28 → 1.7.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/core/config/schema.js +3 -1
  2. package/dist/core/config/schema.js.map +1 -1
  3. package/dist/core/framework.d.ts +1 -1
  4. package/dist/core/framework.js +42 -28
  5. package/dist/core/framework.js.map +1 -1
  6. package/dist/core/http/http-server.d.ts +116 -12
  7. package/dist/core/http/http-server.js +1171 -862
  8. package/dist/core/http/http-server.js.map +1 -1
  9. package/dist/core/http/uws-http-server.d.ts +72 -5
  10. package/dist/core/http/uws-http-server.js +718 -150
  11. package/dist/core/http/uws-http-server.js.map +1 -1
  12. package/dist/core/logger/logger.d.ts +2 -0
  13. package/dist/core/logger/logger.js +32 -6
  14. package/dist/core/logger/logger.js.map +1 -1
  15. package/dist/core/middleware/built-in/compression/core.js +47 -29
  16. package/dist/core/middleware/built-in/compression/core.js.map +1 -1
  17. package/dist/core/middleware/built-in/error-tracker/middleware.d.ts +5 -2
  18. package/dist/core/middleware/built-in/error-tracker/middleware.js +13 -11
  19. package/dist/core/middleware/built-in/error-tracker/middleware.js.map +1 -1
  20. package/dist/core/middleware/built-in/index.d.ts +3 -3
  21. package/dist/core/middleware/built-in/performance-monitor/middleware.d.ts +2 -1
  22. package/dist/core/middleware/built-in/performance-monitor/middleware.js +7 -6
  23. package/dist/core/middleware/built-in/performance-monitor/middleware.js.map +1 -1
  24. package/dist/core/middleware/built-in/request-logger/middleware.d.ts +2 -1
  25. package/dist/core/middleware/built-in/request-logger/middleware.js +5 -5
  26. package/dist/core/middleware/built-in/request-logger/middleware.js.map +1 -1
  27. package/dist/core/middleware/built-in/validation/core.js +61 -27
  28. package/dist/core/middleware/built-in/validation/core.js.map +1 -1
  29. package/dist/core/routing/radix-tree.d.ts +1 -0
  30. package/dist/core/routing/radix-tree.js +48 -10
  31. package/dist/core/routing/radix-tree.js.map +1 -1
  32. package/dist/core/routing/unified-router.d.ts +16 -0
  33. package/dist/core/routing/unified-router.js +39 -12
  34. package/dist/core/routing/unified-router.js.map +1 -1
  35. package/dist/core/utilities/hooks.d.ts +2 -0
  36. package/dist/core/utilities/hooks.js +12 -0
  37. package/dist/core/utilities/hooks.js.map +1 -1
  38. package/dist/moro.d.ts +1 -0
  39. package/dist/moro.js +44 -68
  40. package/dist/moro.js.map +1 -1
  41. package/package.json +1 -1
@@ -1,25 +1,893 @@
1
1
  // src/core/http-server.ts
2
- import { createServer } from 'http';
2
+ import { IncomingMessage, ServerResponse, createServer } from 'http';
3
3
  import * as zlib from 'zlib';
4
4
  import { promisify } from 'util';
5
+ import { randomUUID } from 'crypto';
5
6
  import { createFrameworkLogger } from '../logger/index.js';
6
7
  import { PathMatcher } from '../routing/path-matcher.js';
7
8
  import { ObjectPoolManager } from '../pooling/object-pool-manager.js';
8
9
  const gzip = promisify(zlib.gzip);
9
10
  const deflate = promisify(zlib.deflate);
11
+ const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
12
+ const STATUS_MESSAGES = {
13
+ 200: 'OK',
14
+ 201: 'Created',
15
+ 202: 'Accepted',
16
+ 204: 'No Content',
17
+ 301: 'Moved Permanently',
18
+ 302: 'Found',
19
+ 304: 'Not Modified',
20
+ 400: 'Bad Request',
21
+ 401: 'Unauthorized',
22
+ 403: 'Forbidden',
23
+ 404: 'Not Found',
24
+ 409: 'Conflict',
25
+ 410: 'Gone',
26
+ 422: 'Unprocessable Entity',
27
+ 429: 'Too Many Requests',
28
+ 500: 'Internal Server Error',
29
+ 502: 'Bad Gateway',
30
+ 503: 'Service Unavailable',
31
+ };
32
+ const TYPE_SHORTHANDS = {
33
+ json: 'application/json; charset=utf-8',
34
+ html: 'text/html; charset=utf-8',
35
+ text: 'text/plain; charset=utf-8',
36
+ txt: 'text/plain; charset=utf-8',
37
+ xml: 'application/xml',
38
+ js: 'application/javascript',
39
+ css: 'text/css; charset=utf-8',
40
+ form: 'application/x-www-form-urlencoded',
41
+ };
42
+ function parseQueryString(queryString) {
43
+ const result = {};
44
+ if (!queryString)
45
+ return result;
46
+ const pairs = queryString.split('&');
47
+ const pairsLen = pairs.length;
48
+ for (let i = 0; i < pairsLen; i++) {
49
+ const pair = pairs[i];
50
+ const equalIndex = pair.indexOf('=');
51
+ if (equalIndex === -1) {
52
+ result[decodeURIComponent(pair)] = '';
53
+ }
54
+ else {
55
+ const key = decodeURIComponent(pair.substring(0, equalIndex));
56
+ const value = decodeURIComponent(pair.substring(equalIndex + 1));
57
+ result[key] = value;
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+ function parseCookieHeader(cookieHeader) {
63
+ const cookies = {};
64
+ if (!cookieHeader)
65
+ return cookies;
66
+ const cookieParts = cookieHeader.split(';');
67
+ const cookiePartsLen = cookieParts.length;
68
+ for (let i = 0; i < cookiePartsLen; i++) {
69
+ const cookie = cookieParts[i];
70
+ const equalIndex = cookie.indexOf('=');
71
+ if (equalIndex > 0) {
72
+ const name = cookie.substring(0, equalIndex).trim();
73
+ const value = cookie.substring(equalIndex + 1);
74
+ if (name && value) {
75
+ try {
76
+ cookies[name] = decodeURIComponent(value);
77
+ }
78
+ catch {
79
+ // Malformed percent-encoding: keep the raw value rather than throwing a 500
80
+ cookies[name] = value;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return cookies;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Prototype-based request/response enhancement.
89
+ //
90
+ // All helper methods live on shared prototypes (defined once per process)
91
+ // instead of being re-created as ~40 closures on every request, and derived
92
+ // request fields (query, cookies, hostname, ips, ...) are lazy memoized
93
+ // getters so a request only pays for what its handler actually reads.
94
+ // Each MoroHttpServer binds per-instance subclasses whose prototype carries
95
+ // the `_server` back-reference, so methods can reach server config (logger,
96
+ // compression settings) without any per-request allocation.
97
+ // ---------------------------------------------------------------------------
98
+ export class MoroIncomingMessage extends IncomingMessage {
99
+ // Hot per-request fields (assigned in handleRequest; declared here for
100
+ // stable shape). Everything else lives behind the single `_lazy` slot -
101
+ // per-request construction pays 5 field initializations instead of 16.
102
+ params = {};
103
+ body = null;
104
+ path = '';
105
+ _queryString = null;
106
+ // Single slot for every lazily-computed/assigned helper value (query,
107
+ // cookies, requestId, hostname, ...). Allocated on first access - a plain
108
+ // JSON GET that never touches the helpers never allocates it, and the
109
+ // request object keeps one stable hidden-class shape either way.
110
+ _lazy = undefined;
111
+ get query() {
112
+ const L = this._lazy ?? (this._lazy = {});
113
+ let q = L.query;
114
+ if (q === undefined) {
115
+ const qs = this._queryString;
116
+ q = qs ? parseQueryString(qs) : {};
117
+ L.query = q;
118
+ }
119
+ return q;
120
+ }
121
+ set query(value) {
122
+ (this._lazy ?? (this._lazy = {})).query = value;
123
+ }
124
+ get cookies() {
125
+ const L = this._lazy ?? (this._lazy = {});
126
+ let c = L.cookies;
127
+ if (c === undefined) {
128
+ const header = this.headers.cookie;
129
+ c = header ? parseCookieHeader(header) : {};
130
+ L.cookies = c;
131
+ }
132
+ return c;
133
+ }
134
+ set cookies(value) {
135
+ (this._lazy ?? (this._lazy = {})).cookies = value;
136
+ }
137
+ get context() {
138
+ const L = this._lazy ?? (this._lazy = {});
139
+ let ctx = L.context;
140
+ if (ctx === undefined) {
141
+ ctx = {};
142
+ L.context = ctx;
143
+ }
144
+ return ctx;
145
+ }
146
+ set context(value) {
147
+ (this._lazy ?? (this._lazy = {})).context = value;
148
+ }
149
+ get requestId() {
150
+ const L = this._lazy ?? (this._lazy = {});
151
+ let id = L.requestId;
152
+ if (id === undefined) {
153
+ const server = this._server;
154
+ id = server && server.requestTrackingEnabled ? randomUUID() : '';
155
+ L.requestId = id;
156
+ }
157
+ return id;
158
+ }
159
+ set requestId(value) {
160
+ (this._lazy ?? (this._lazy = {})).requestId = value;
161
+ }
162
+ get ip() {
163
+ const L = this._lazy ?? (this._lazy = {});
164
+ let v = L.ip;
165
+ if (v === undefined) {
166
+ v = (this.socket && this.socket.remoteAddress) || '';
167
+ L.ip = v;
168
+ }
169
+ return v;
170
+ }
171
+ set ip(value) {
172
+ (this._lazy ?? (this._lazy = {})).ip = value;
173
+ }
174
+ get originalUrl() {
175
+ const v = this._lazy?.originalUrl;
176
+ return v !== undefined ? v : this.url || '';
177
+ }
178
+ set originalUrl(value) {
179
+ (this._lazy ?? (this._lazy = {})).originalUrl = value;
180
+ }
181
+ get hostname() {
182
+ const L = this._lazy ?? (this._lazy = {});
183
+ let v = L.hostname;
184
+ if (v === undefined) {
185
+ const host = (this.headers.host || '');
186
+ v = host ? host.split(':')[0] : '';
187
+ L.hostname = v;
188
+ }
189
+ return v;
190
+ }
191
+ set hostname(value) {
192
+ (this._lazy ?? (this._lazy = {})).hostname = value;
193
+ }
194
+ get protocol() {
195
+ const L = this._lazy ?? (this._lazy = {});
196
+ let v = L.protocol;
197
+ if (v === undefined) {
198
+ const forwardedProto = this.headers['x-forwarded-proto'];
199
+ v = forwardedProto
200
+ ? forwardedProto.split(',')[0].trim()
201
+ : this.socket?.encrypted
202
+ ? 'https'
203
+ : 'http';
204
+ L.protocol = v;
205
+ }
206
+ return v;
207
+ }
208
+ set protocol(value) {
209
+ (this._lazy ?? (this._lazy = {})).protocol = value;
210
+ }
211
+ get secure() {
212
+ const v = this._lazy?.secure;
213
+ return v !== undefined ? v : this.protocol === 'https';
214
+ }
215
+ set secure(value) {
216
+ (this._lazy ?? (this._lazy = {})).secure = value;
217
+ }
218
+ get xhr() {
219
+ const L = this._lazy ?? (this._lazy = {});
220
+ let v = L.xhr;
221
+ if (v === undefined) {
222
+ const xrw = this.headers['x-requested-with'];
223
+ v = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
224
+ L.xhr = v;
225
+ }
226
+ return v;
227
+ }
228
+ set xhr(value) {
229
+ (this._lazy ?? (this._lazy = {})).xhr = value;
230
+ }
231
+ get ips() {
232
+ const L = this._lazy ?? (this._lazy = {});
233
+ let v = L.ips;
234
+ if (v === undefined) {
235
+ const forwardedFor = this.headers['x-forwarded-for'];
236
+ v = forwardedFor
237
+ ? forwardedFor
238
+ .split(',')
239
+ .map(s => s.trim())
240
+ .filter(Boolean)
241
+ : [];
242
+ L.ips = v;
243
+ }
244
+ return v;
245
+ }
246
+ set ips(value) {
247
+ (this._lazy ?? (this._lazy = {})).ips = value;
248
+ }
249
+ get subdomains() {
250
+ const L = this._lazy ?? (this._lazy = {});
251
+ let v = L.subdomains;
252
+ if (v === undefined) {
253
+ const hostnameParts = this.hostname.split('.');
254
+ // Matches Express: subdomains are parts before the last two (domain + tld),
255
+ // reversed (closest subdomain first).
256
+ v = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
257
+ L.subdomains = v;
258
+ }
259
+ return v;
260
+ }
261
+ set subdomains(value) {
262
+ (this._lazy ?? (this._lazy = {})).subdomains = value;
263
+ }
264
+ // Express-compatible request helpers (shared prototype methods, not closures)
265
+ get(name) {
266
+ const lower = name.toLowerCase();
267
+ if (lower === 'referer' || lower === 'referrer') {
268
+ return (this.headers.referer || this.headers.referrer);
269
+ }
270
+ const v = this.headers[lower];
271
+ return Array.isArray(v) ? v[0] : v;
272
+ }
273
+ header(name) {
274
+ return this.get(name);
275
+ }
276
+ is(type) {
277
+ const ct = (this.headers['content-type'] || '');
278
+ if (!ct)
279
+ return false;
280
+ const mime = ct.split(';')[0].trim().toLowerCase();
281
+ const t = type.toLowerCase();
282
+ if (t.indexOf('/') === -1) {
283
+ // e.g. 'json' → match */json or +json suffix
284
+ return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
285
+ }
286
+ if (t.endsWith('/*')) {
287
+ return mime.startsWith(t.slice(0, -1));
288
+ }
289
+ return mime === t;
290
+ }
291
+ accepts(types) {
292
+ const accept = (this.headers.accept || '*/*');
293
+ if (!types)
294
+ return accept;
295
+ const wanted = Array.isArray(types) ? types : [types];
296
+ if (accept === '*/*' || accept === '')
297
+ return wanted[0] || false;
298
+ const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
299
+ for (const w of wanted) {
300
+ const wl = w.toLowerCase();
301
+ const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
302
+ for (const at of acceptTypes) {
303
+ if (at === '*/*')
304
+ return w;
305
+ if (at === wMime)
306
+ return w;
307
+ if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
308
+ return w;
309
+ }
310
+ }
311
+ return false;
312
+ }
313
+ acceptsLanguages(langs) {
314
+ const acceptLang = (this.headers['accept-language'] || '');
315
+ if (!langs)
316
+ return acceptLang || false;
317
+ const wanted = Array.isArray(langs) ? langs : [langs];
318
+ if (!acceptLang)
319
+ return wanted[0] || false;
320
+ // Parse with q values; sort by q desc to match Express's "best match wins"
321
+ const accepted = acceptLang
322
+ .split(',')
323
+ .map(s => {
324
+ const parts = s.trim().split(';');
325
+ const tag = parts[0].toLowerCase();
326
+ const qPart = parts.find(p => p.trim().startsWith('q='));
327
+ const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
328
+ return { tag, q: isNaN(q) ? 0 : q };
329
+ })
330
+ .filter(a => a.q > 0)
331
+ .sort((a, b) => b.q - a.q);
332
+ for (const { tag } of accepted) {
333
+ for (const w of wanted) {
334
+ const wl = w.toLowerCase();
335
+ if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
336
+ return w;
337
+ }
338
+ }
339
+ return false;
340
+ }
341
+ }
342
+ export class MoroServerResponse extends ServerResponse {
343
+ _locals = undefined;
344
+ get locals() {
345
+ let l = this._locals;
346
+ if (l === undefined) {
347
+ l = {};
348
+ this._locals = l;
349
+ }
350
+ return l;
351
+ }
352
+ set locals(value) {
353
+ this._locals = value;
354
+ }
355
+ status(code) {
356
+ this.statusCode = code;
357
+ return this;
358
+ }
359
+ json(data) {
360
+ if (this.headersSent)
361
+ return;
362
+ const jsonString = JSON.stringify(data);
363
+ const server = this._server;
364
+ // Compression - EARLY EXIT if disabled or below threshold
365
+ if (server && server.compressionEnabled && jsonString.length > server.compressionThreshold) {
366
+ const acceptEncoding = this.req?.headers['accept-encoding'];
367
+ if (acceptEncoding && acceptEncoding.includes('gzip')) {
368
+ const buffer = Buffer.from(jsonString, 'utf8');
369
+ gzip(buffer)
370
+ .then(compressed => {
371
+ if (this.headersSent)
372
+ return;
373
+ this.writeHead(this.statusCode || 200, {
374
+ 'Content-Type': JSON_CONTENT_TYPE,
375
+ 'Content-Encoding': 'gzip',
376
+ Vary: 'Accept-Encoding',
377
+ 'Content-Length': compressed.length,
378
+ });
379
+ this.end(compressed);
380
+ })
381
+ .catch(err => {
382
+ server.logger.error('Response compression failed', 'HttpServer', {
383
+ error: err instanceof Error ? err.message : String(err),
384
+ });
385
+ if (!this.headersSent) {
386
+ this.writeHead(this.statusCode || 200, {
387
+ 'Content-Type': JSON_CONTENT_TYPE,
388
+ 'Content-Length': Buffer.byteLength(jsonString),
389
+ });
390
+ this.end(jsonString);
391
+ }
392
+ else {
393
+ this.end();
394
+ }
395
+ });
396
+ return;
397
+ }
398
+ else if (acceptEncoding && acceptEncoding.includes('deflate')) {
399
+ const buffer = Buffer.from(jsonString, 'utf8');
400
+ deflate(buffer)
401
+ .then(compressed => {
402
+ if (this.headersSent)
403
+ return;
404
+ this.writeHead(this.statusCode || 200, {
405
+ 'Content-Type': JSON_CONTENT_TYPE,
406
+ 'Content-Encoding': 'deflate',
407
+ Vary: 'Accept-Encoding',
408
+ 'Content-Length': compressed.length,
409
+ });
410
+ this.end(compressed);
411
+ })
412
+ .catch(err => {
413
+ server.logger.error('Response compression failed', 'HttpServer', {
414
+ error: err instanceof Error ? err.message : String(err),
415
+ });
416
+ if (!this.headersSent) {
417
+ this.writeHead(this.statusCode || 200, {
418
+ 'Content-Type': JSON_CONTENT_TYPE,
419
+ 'Content-Length': Buffer.byteLength(jsonString),
420
+ });
421
+ this.end(jsonString);
422
+ }
423
+ else {
424
+ this.end();
425
+ }
426
+ });
427
+ return;
428
+ }
429
+ }
430
+ // SYNC PATH - no compression: single serialize, single write, no Buffer copy
431
+ this.writeHead(this.statusCode || 200, {
432
+ 'Content-Type': JSON_CONTENT_TYPE,
433
+ 'Content-Length': Buffer.byteLength(jsonString),
434
+ });
435
+ this.end(jsonString);
436
+ }
437
+ send(data) {
438
+ if (this.headersSent)
439
+ return;
440
+ // Auto-detect content type if not already set
441
+ if (!this.getHeader('Content-Type')) {
442
+ if (typeof data === 'string') {
443
+ // Cheap JSON sniff: first non-whitespace char is '{', '[' or '"'
444
+ // (replaces a full JSON.parse of the body just to pick a content type)
445
+ let i = 0;
446
+ const len = data.length;
447
+ while (i < len) {
448
+ const c = data.charCodeAt(i);
449
+ if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
450
+ break;
451
+ i++;
452
+ }
453
+ const first = i < len ? data.charCodeAt(i) : 0;
454
+ if (first === 123 /* { */ || first === 91 /* [ */ || first === 34 /* " */) {
455
+ this.setHeader('Content-Type', JSON_CONTENT_TYPE);
456
+ }
457
+ else {
458
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
459
+ }
460
+ }
461
+ else {
462
+ this.setHeader('Content-Type', 'application/octet-stream');
463
+ }
464
+ }
465
+ this.end(data);
466
+ }
467
+ cookie(name, value, options = {}) {
468
+ if (this.headersSent) {
469
+ const isCritical = options.critical ||
470
+ name.includes('session') ||
471
+ name.includes('auth') ||
472
+ name.includes('csrf');
473
+ const message = `Cookie '${name}' could not be set - headers already sent`;
474
+ if (isCritical || options.throwOnLateSet) {
475
+ throw new Error(`${message}. This may cause authentication or security issues.`);
476
+ }
477
+ else {
478
+ this._server?.logger.warn(message, 'CookieWarning', {
479
+ cookieName: name,
480
+ critical: isCritical,
481
+ stackTrace: new Error().stack,
482
+ });
483
+ }
484
+ return this;
485
+ }
486
+ const cookieValue = encodeURIComponent(value);
487
+ let cookieString = `${name}=${cookieValue}`;
488
+ if (options.maxAge)
489
+ cookieString += `; Max-Age=${options.maxAge}`;
490
+ if (options.expires)
491
+ cookieString += `; Expires=${options.expires.toUTCString()}`;
492
+ if (options.httpOnly)
493
+ cookieString += '; HttpOnly';
494
+ if (options.secure)
495
+ cookieString += '; Secure';
496
+ if (options.sameSite)
497
+ cookieString += `; SameSite=${options.sameSite}`;
498
+ if (options.domain)
499
+ cookieString += `; Domain=${options.domain}`;
500
+ if (options.path)
501
+ cookieString += `; Path=${options.path}`;
502
+ const existingCookies = this.getHeader('Set-Cookie') || [];
503
+ // Avoid spread operator - direct array manipulation
504
+ const cookies = Array.isArray(existingCookies) ? existingCookies : [existingCookies];
505
+ cookies.push(cookieString);
506
+ this.setHeader('Set-Cookie', cookies);
507
+ return this;
508
+ }
509
+ clearCookie(name, options = {}) {
510
+ // Avoid spread operator - manually set properties
511
+ const clearOptions = {
512
+ expires: new Date(0),
513
+ maxAge: 0,
514
+ };
515
+ // Copy other options manually
516
+ if (options.path !== undefined)
517
+ clearOptions.path = options.path;
518
+ if (options.domain !== undefined)
519
+ clearOptions.domain = options.domain;
520
+ if (options.httpOnly !== undefined)
521
+ clearOptions.httpOnly = options.httpOnly;
522
+ if (options.secure !== undefined)
523
+ clearOptions.secure = options.secure;
524
+ if (options.sameSite !== undefined)
525
+ clearOptions.sameSite = options.sameSite;
526
+ return this.cookie(name, '', clearOptions);
527
+ }
528
+ redirect(url, status = 302) {
529
+ if (this.headersSent)
530
+ return;
531
+ this.statusCode = status;
532
+ const safeUrl = url.replace(/[\r\n]/g, '');
533
+ this.setHeader('Location', safeUrl);
534
+ this.end();
535
+ }
536
+ async sendFile(filePath) {
537
+ if (this.headersSent)
538
+ return;
539
+ try {
540
+ const fs = await import('fs/promises');
541
+ const path = await import('path');
542
+ const extension = path.extname(filePath);
543
+ const mime = await this._server.getMimeType(extension);
544
+ const stats = await fs.stat(filePath);
545
+ const data = await fs.readFile(filePath);
546
+ // Add charset for text-based files
547
+ const contentType = this._server.addCharsetIfNeeded(mime);
548
+ this.setHeader('Content-Type', contentType);
549
+ this.setHeader('Content-Length', stats.size);
550
+ // Add security headers for file downloads
551
+ this.setHeader('X-Content-Type-Options', 'nosniff');
552
+ // Add caching headers
553
+ this.setHeader('Last-Modified', stats.mtime.toUTCString());
554
+ this.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
555
+ this.end(data);
556
+ }
557
+ catch {
558
+ this.status(404).json({ success: false, error: 'File not found' });
559
+ }
560
+ }
561
+ // Standardized response helpers
562
+ success(data, message) {
563
+ const response = {
564
+ success: true,
565
+ data,
566
+ };
567
+ if (message !== undefined) {
568
+ response.message = message;
569
+ }
570
+ this.json(response);
571
+ }
572
+ error(error, code, message) {
573
+ const response = {
574
+ success: false,
575
+ error,
576
+ };
577
+ if (code !== undefined) {
578
+ response.code = code;
579
+ }
580
+ if (message !== undefined) {
581
+ response.message = message;
582
+ }
583
+ this.json(response);
584
+ }
585
+ // Common HTTP error helpers (automatically set status code)
586
+ unauthorized(message = 'Authentication required') {
587
+ this.statusCode = 401;
588
+ this.json({
589
+ success: false,
590
+ error: 'Unauthorized',
591
+ code: 'UNAUTHORIZED',
592
+ message,
593
+ });
594
+ }
595
+ forbidden(message = 'Insufficient permissions') {
596
+ this.statusCode = 403;
597
+ this.json({
598
+ success: false,
599
+ error: 'Forbidden',
600
+ code: 'FORBIDDEN',
601
+ message,
602
+ });
603
+ }
604
+ notFound(resource = 'Resource') {
605
+ this.statusCode = 404;
606
+ this.json({
607
+ success: false,
608
+ error: 'Not Found',
609
+ code: 'NOT_FOUND',
610
+ message: `${resource} not found`,
611
+ });
612
+ }
613
+ badRequest(message = 'Invalid request') {
614
+ this.statusCode = 400;
615
+ this.json({
616
+ success: false,
617
+ error: 'Bad Request',
618
+ code: 'BAD_REQUEST',
619
+ message,
620
+ });
621
+ }
622
+ conflict(message) {
623
+ this.statusCode = 409;
624
+ this.json({
625
+ success: false,
626
+ error: 'Conflict',
627
+ code: 'CONFLICT',
628
+ message,
629
+ });
630
+ }
631
+ internalError(message = 'Internal server error') {
632
+ this.statusCode = 500;
633
+ this.json({
634
+ success: false,
635
+ error: 'Internal Server Error',
636
+ code: 'INTERNAL_ERROR',
637
+ message,
638
+ });
639
+ }
640
+ validationError(errors) {
641
+ this.statusCode = 422;
642
+ this.json({
643
+ success: false,
644
+ error: 'Validation Failed',
645
+ code: 'VALIDATION_ERROR',
646
+ errors,
647
+ });
648
+ }
649
+ rateLimited(retryAfter) {
650
+ this.statusCode = 429;
651
+ if (retryAfter) {
652
+ this.setHeader('Retry-After', retryAfter.toString());
653
+ }
654
+ this.json({
655
+ success: false,
656
+ error: 'Rate Limit Exceeded',
657
+ code: 'RATE_LIMITED',
658
+ message: retryAfter
659
+ ? `Too many requests. Retry after ${retryAfter} seconds.`
660
+ : 'Too many requests',
661
+ retryAfter,
662
+ });
663
+ }
664
+ // Common success patterns
665
+ created(data, location) {
666
+ this.statusCode = 201;
667
+ if (location) {
668
+ this.setHeader('Location', location);
669
+ }
670
+ this.json({
671
+ success: true,
672
+ data,
673
+ });
674
+ }
675
+ noContent() {
676
+ this.statusCode = 204;
677
+ this.end();
678
+ }
679
+ paginated(data, pagination) {
680
+ const totalPages = Math.ceil(pagination.total / pagination.limit);
681
+ this.json({
682
+ success: true,
683
+ data,
684
+ pagination: {
685
+ page: pagination.page,
686
+ limit: pagination.limit,
687
+ total: pagination.total,
688
+ totalPages,
689
+ hasNext: pagination.page < totalPages,
690
+ hasPrev: pagination.page > 1,
691
+ },
692
+ });
693
+ }
694
+ // Header management utilities
695
+ hasHeader(name) {
696
+ return this.getHeader(name) !== undefined;
697
+ }
698
+ setBulkHeaders(headers) {
699
+ if (this.headersSent) {
700
+ // Only enumerate keys for warning if headers were already sent
701
+ const attemptedHeaderKeys = [];
702
+ for (const key in headers) {
703
+ attemptedHeaderKeys.push(key);
704
+ }
705
+ this._server?.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
706
+ attemptedHeaders: attemptedHeaderKeys,
707
+ });
708
+ return this;
709
+ }
710
+ for (const key in headers) {
711
+ this.setHeader(key, headers[key]);
712
+ }
713
+ return this;
714
+ }
715
+ appendHeader(name, value) {
716
+ if (this.headersSent) {
717
+ this._server?.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
718
+ return this;
719
+ }
720
+ const existing = this.getHeader(name);
721
+ if (existing) {
722
+ const values = Array.isArray(existing) ? existing : [existing.toString()];
723
+ const newValues = Array.isArray(value) ? value : [value];
724
+ this.setHeader(name, [...values, ...newValues]);
725
+ }
726
+ else {
727
+ this.setHeader(name, value);
728
+ }
729
+ return this;
730
+ }
731
+ // Express-compatible response helpers
732
+ set(field, value) {
733
+ if (this.headersSent)
734
+ return this;
735
+ if (typeof field === 'string') {
736
+ if (value !== undefined) {
737
+ this.setHeader(field, value);
738
+ }
739
+ }
740
+ else {
741
+ for (const key in field) {
742
+ this.setHeader(key, field[key]);
743
+ }
744
+ }
745
+ return this;
746
+ }
747
+ get(field) {
748
+ return this.getHeader(field);
749
+ }
750
+ append(field, value) {
751
+ return this.appendHeader(field, value);
752
+ }
753
+ type(contentType) {
754
+ if (this.headersSent)
755
+ return this;
756
+ // If no "/" treat as shorthand (e.g. "json" → "application/json")
757
+ let ct = contentType;
758
+ if (ct.indexOf('/') === -1) {
759
+ ct = TYPE_SHORTHANDS[ct.toLowerCase()] || `application/${ct}`;
760
+ }
761
+ this.setHeader('Content-Type', ct);
762
+ return this;
763
+ }
764
+ sendStatus(code) {
765
+ if (this.headersSent)
766
+ return;
767
+ this.statusCode = code;
768
+ const body = STATUS_MESSAGES[code] || String(code);
769
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
770
+ this.end(body);
771
+ }
772
+ location(url) {
773
+ if (this.headersSent)
774
+ return this;
775
+ const safe = url.replace(/[\r\n]/g, '');
776
+ this.setHeader('Location', safe);
777
+ return this;
778
+ }
779
+ vary(field) {
780
+ if (this.headersSent)
781
+ return this;
782
+ const existing = this.getHeader('Vary');
783
+ const existingList = existing
784
+ ? String(existing)
785
+ .split(',')
786
+ .map(s => s.trim())
787
+ .filter(Boolean)
788
+ : [];
789
+ const incoming = Array.isArray(field) ? field : [field];
790
+ for (const f of incoming) {
791
+ if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
792
+ existingList.push(f);
793
+ }
794
+ }
795
+ this.setHeader('Vary', existingList.join(', '));
796
+ return this;
797
+ }
798
+ attachment(filename) {
799
+ if (this.headersSent)
800
+ return this;
801
+ if (filename) {
802
+ const safeName = filename.replace(/[\r\n"]/g, '');
803
+ this.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
804
+ // Set Content-Type from extension if not already set
805
+ if (!this.getHeader('Content-Type')) {
806
+ // fire-and-forget mime lookup; not awaited to keep signature chainable
807
+ this._server.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
808
+ if (!this.getHeader('Content-Type') && !this.headersSent) {
809
+ this.setHeader('Content-Type', mime);
810
+ }
811
+ });
812
+ }
813
+ }
814
+ else {
815
+ this.setHeader('Content-Disposition', 'attachment');
816
+ }
817
+ return this;
818
+ }
819
+ async download(filePath, filename) {
820
+ this.attachment(filename || filePath.split('/').pop());
821
+ return this.sendFile(filePath);
822
+ }
823
+ format(handlers) {
824
+ const types = Object.keys(handlers).filter(k => k !== 'default');
825
+ const matched = this.req.accepts(types);
826
+ const chosen = matched ? handlers[matched] : handlers.default;
827
+ if (!chosen) {
828
+ this.statusCode = 406;
829
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
830
+ this.end('Not Acceptable');
831
+ return;
832
+ }
833
+ this.vary('Accept');
834
+ if (matched && typeof matched === 'string') {
835
+ this.type(matched);
836
+ }
837
+ chosen();
838
+ }
839
+ links(links) {
840
+ if (this.headersSent)
841
+ return this;
842
+ const parts = [];
843
+ for (const rel in links) {
844
+ parts.push(`<${links[rel]}>; rel="${rel}"`);
845
+ }
846
+ if (parts.length > 0) {
847
+ const existing = this.getHeader('Link');
848
+ const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
849
+ this.setHeader('Link', combined);
850
+ }
851
+ return this;
852
+ }
853
+ // Response state utilities
854
+ canSetHeaders() {
855
+ return !this.headersSent;
856
+ }
857
+ getResponseState() {
858
+ return {
859
+ headersSent: this.headersSent,
860
+ statusCode: this.statusCode,
861
+ headers: this.getHeaders ? this.getHeaders() : {},
862
+ finished: this.finished || false,
863
+ writable: this.writable,
864
+ };
865
+ }
866
+ }
10
867
  export class MoroHttpServer {
11
868
  server;
12
869
  routes = [];
13
870
  globalMiddleware = [];
871
+ /** @internal read by MoroServerResponse prototype methods */
14
872
  compressionEnabled = true;
873
+ /** @internal read by MoroServerResponse prototype methods */
15
874
  compressionThreshold = 1024;
875
+ /** @internal read by MoroIncomingMessage prototype methods */
16
876
  requestTrackingEnabled = true; // Generate request IDs
877
+ /** @internal read by MoroServerResponse prototype methods */
17
878
  logger = createFrameworkLogger('HttpServer');
18
879
  hookManager;
19
880
  requestCounter = 0;
20
881
  errorHandler;
21
882
  requestDecorations = {};
22
883
  responseDecorations = {};
884
+ // Per-server request/response subclasses; their prototype carries the
885
+ // `_server` back-reference plus user decorations (applied once, not per request)
886
+ RequestClass;
887
+ ResponseClass;
888
+ // Direct router dispatch slot - runs after global middleware without the
889
+ // per-middleware promise machinery (set from Moro.listen via setRouterHandler)
890
+ routerHandler;
23
891
  // Body size limits - defaults overridden by config in constructor
24
892
  maxBodySize = 10 * 1024 * 1024; // Default 10MB (overridden by server.bodySizeLimit config)
25
893
  maxUploadSize = 100 * 1024 * 1024; // Default 100MB for file uploads (future: configurable)
@@ -43,7 +911,19 @@ export class MoroHttpServer {
43
911
  rateLimited: Buffer.from('{"success":false,"error":"Rate limit exceeded"}'),
44
912
  };
45
913
  constructor(options) {
46
- this.server = createServer(this.handleRequest.bind(this));
914
+ // Per-instance subclasses so prototype methods can reach this server's
915
+ // config (logger, compression) with zero per-request allocation.
916
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
917
+ const boundServer = this;
918
+ class BoundRequest extends MoroIncomingMessage {
919
+ }
920
+ class BoundResponse extends MoroServerResponse {
921
+ }
922
+ BoundRequest.prototype._server = boundServer;
923
+ BoundResponse.prototype._server = boundServer;
924
+ this.RequestClass = BoundRequest;
925
+ this.ResponseClass = BoundResponse;
926
+ this.server = createServer({ IncomingMessage: BoundRequest, ServerResponse: BoundResponse }, this.handleRequest.bind(this));
47
927
  // Configure body size limits from options
48
928
  if (options?.maxBodySize) {
49
929
  this.maxBodySize = options.maxBodySize;
@@ -54,7 +934,17 @@ export class MoroHttpServer {
54
934
  // Optimize server for high performance (conservative settings for compatibility)
55
935
  this.server.keepAliveTimeout = 5000; // 5 seconds
56
936
  this.server.headersTimeout = 6000; // 6 seconds
57
- this.server.timeout = 30000; // 30 seconds request timeout
937
+ // Socket inactivity timeout stays at Node's default (0 = disabled). A
938
+ // non-zero value arms/refreshes a timer on every request, which shows up
939
+ // in CPU profiles (setStreamTimeout + timer churn) under load; slowloris
940
+ // protection is already covered by headersTimeout + keepAliveTimeout, and
941
+ // this matches Node's own default connectionTimeout: 0.
942
+ this.server.timeout = 0;
943
+ }
944
+ // Direct router dispatch - called after global middleware, before the legacy
945
+ // route table. Returning true (or a promise of true) means the request was handled.
946
+ setRouterHandler(fn) {
947
+ this.routerHandler = fn;
58
948
  }
59
949
  // Configure server for maximum performance (can disable all overhead)
60
950
  configurePerformance(config = {}) {
@@ -78,12 +968,30 @@ export class MoroHttpServer {
78
968
  setErrorHandler(fn) {
79
969
  this.errorHandler = fn;
80
970
  }
81
- // Apply per-request/response decoration maps (called from Moro.decorateRequest/Reply)
971
+ // Apply request/response decoration maps (called from Moro.decorateRequest/Reply).
972
+ // Decorations are installed on the per-server prototypes once, so requests pay
973
+ // nothing for them; per-request writes to the same key simply shadow the prototype.
82
974
  setRequestDecorations(decorations) {
83
975
  this.requestDecorations = decorations;
976
+ for (const key in decorations) {
977
+ Object.defineProperty(this.RequestClass.prototype, key, {
978
+ value: decorations[key],
979
+ writable: true,
980
+ enumerable: true,
981
+ configurable: true,
982
+ });
983
+ }
84
984
  }
85
985
  setResponseDecorations(decorations) {
86
986
  this.responseDecorations = decorations;
987
+ for (const key in decorations) {
988
+ Object.defineProperty(this.ResponseClass.prototype, key, {
989
+ value: decorations[key],
990
+ writable: true,
991
+ enumerable: true,
992
+ configurable: true,
993
+ });
994
+ }
87
995
  }
88
996
  // Middleware management
89
997
  use(middleware) {
@@ -157,82 +1065,184 @@ export class MoroHttpServer {
157
1065
  paramNames: compiled.paramNames,
158
1066
  };
159
1067
  }
160
- async handleRequest(req, res) {
161
- const httpReq = this.enhanceRequest(req);
162
- const httpRes = this.enhanceResponse(res, httpReq);
163
- // Store original params for efficient cleanup
164
- const originalParams = httpReq.params;
1068
+ // Request entry point. Deliberately NOT an async function: the common case
1069
+ // (no body, no hooks, no global middleware, sync route handler) completes
1070
+ // fully synchronously with zero promise allocations and zero microtask
1071
+ // hops. Anything that genuinely needs to await routes through
1072
+ // handleRequestSlow.
1073
+ handleRequest(req, res) {
1074
+ // req/res arrive as the per-server subclasses (MoroIncomingMessage /
1075
+ // MoroServerResponse) - all helpers are already on their prototypes.
1076
+ const httpReq = req;
1077
+ const httpRes = res;
1078
+ // URL split - query string parsing is lazy (MoroIncomingMessage#query getter)
1079
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1080
+ const urlString = req.url;
1081
+ const queryIndex = urlString.indexOf('?');
1082
+ if (queryIndex === -1) {
1083
+ httpReq.path = urlString;
1084
+ }
1085
+ else {
1086
+ httpReq.path = urlString.substring(0, queryIndex);
1087
+ httpReq._queryString = urlString.substring(queryIndex + 1);
1088
+ }
1089
+ // Intern method string for fast reference equality comparison (50-100% faster)
1090
+ switch (req.method) {
1091
+ case 'POST':
1092
+ httpReq.method = MoroHttpServer.METHOD_POST;
1093
+ break;
1094
+ case 'PUT':
1095
+ httpReq.method = MoroHttpServer.METHOD_PUT;
1096
+ break;
1097
+ case 'PATCH':
1098
+ httpReq.method = MoroHttpServer.METHOD_PATCH;
1099
+ break;
1100
+ case 'GET':
1101
+ httpReq.method = MoroHttpServer.METHOD_GET;
1102
+ break;
1103
+ case 'DELETE':
1104
+ httpReq.method = MoroHttpServer.METHOD_DELETE;
1105
+ break;
1106
+ case 'HEAD':
1107
+ httpReq.method = MoroHttpServer.METHOD_HEAD;
1108
+ break;
1109
+ case 'OPTIONS':
1110
+ httpReq.method = MoroHttpServer.METHOD_OPTIONS;
1111
+ break;
1112
+ }
1113
+ const needsBody = httpReq.method === MoroHttpServer.METHOD_POST ||
1114
+ httpReq.method === MoroHttpServer.METHOD_PUT ||
1115
+ httpReq.method === MoroHttpServer.METHOD_PATCH;
1116
+ const hookManager = this.hookManager;
1117
+ const hasHooks = !!(hookManager &&
1118
+ (hookManager.hasHooks === undefined || hookManager.hasHooks('request')));
1119
+ if (needsBody || hasHooks || this.globalMiddleware.length > 0) {
1120
+ // Slow path: real awaits ahead (body parse / hooks / middleware).
1121
+ // Errors are fully handled inside - the returned promise never rejects.
1122
+ void this.handleRequestSlow(httpReq, httpRes, req, needsBody, hasHooks);
1123
+ return;
1124
+ }
1125
+ // FAST PATH - no promises unless the route handler itself is async
165
1126
  try {
166
- // Optimized URL and query parsing with object pooling
167
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
168
- const urlString = req.url;
169
- const queryIndex = urlString.indexOf('?');
170
- if (queryIndex === -1) {
171
- // No query string
172
- httpReq.path = urlString;
173
- httpReq.query = {};
174
- }
175
- else {
176
- // Has query string - parse efficiently with pooled object
177
- httpReq.path = urlString.substring(0, queryIndex);
178
- httpReq.query = this.parseQueryStringPooled(urlString.substring(queryIndex + 1));
1127
+ const routerHandler = this.routerHandler;
1128
+ if (routerHandler) {
1129
+ const handled = routerHandler(httpReq, httpRes);
1130
+ if (handled) {
1131
+ if (typeof handled.then === 'function') {
1132
+ handled.then(wasHandled => {
1133
+ if (!wasHandled && !httpRes.headersSent) {
1134
+ try {
1135
+ this.dispatchLegacyRoute(httpReq, httpRes, req);
1136
+ }
1137
+ catch (error) {
1138
+ void this.onRequestError(error, httpReq, httpRes, req);
1139
+ }
1140
+ }
1141
+ }, error => void this.onRequestError(error, httpReq, httpRes, req));
1142
+ }
1143
+ return;
1144
+ }
1145
+ if (httpRes.headersSent)
1146
+ return;
179
1147
  }
180
- // Method checking - use reference equality for interned strings (50-100% faster)
181
- if (httpReq.method === MoroHttpServer.METHOD_POST ||
182
- httpReq.method === MoroHttpServer.METHOD_PUT ||
183
- httpReq.method === MoroHttpServer.METHOD_PATCH) {
1148
+ this.dispatchLegacyRoute(httpReq, httpRes, req);
1149
+ }
1150
+ catch (error) {
1151
+ void this.onRequestError(error, httpReq, httpRes, req);
1152
+ }
1153
+ }
1154
+ // Full async pipeline for requests that need it: bodied methods, hook
1155
+ // consumers, and apps with global middleware.
1156
+ async handleRequestSlow(httpReq, httpRes, req, needsBody, hasHooks) {
1157
+ try {
1158
+ if (needsBody) {
184
1159
  httpReq.body = await this.parseBody(req);
185
1160
  }
186
- // Execute hooks before request processing - NOOP if no hookManager
187
- if (this.hookManager) {
1161
+ if (hasHooks) {
188
1162
  await this.hookManager.execute('request', {
189
1163
  request: httpReq,
190
1164
  response: httpRes,
191
1165
  });
192
1166
  }
193
- // Execute global middleware first - EARLY EXIT if none registered
194
- const middlewareLen = this.globalMiddleware.length;
195
- if (middlewareLen > 0) {
196
- await this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
1167
+ // executeMiddleware returns undefined when the whole chain completed
1168
+ // synchronously, so a sync chain costs zero promise allocations.
1169
+ if (this.globalMiddleware.length > 0) {
1170
+ const mwResult = this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
1171
+ if (mwResult)
1172
+ await mwResult;
197
1173
  }
198
1174
  // If middleware handled the request, don't continue
199
1175
  if (httpRes.headersSent) {
200
1176
  return;
201
1177
  }
202
- // Find matching route
203
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
204
- const route = this.findRoute(req.method, httpReq.path);
205
- if (!route) {
206
- // 404 response with pre-compiled buffer
207
- httpRes.statusCode = 404;
208
- httpRes.setHeader('Content-Type', 'application/json; charset=utf-8');
209
- httpRes.setHeader('Content-Length', MoroHttpServer.RESPONSE_TEMPLATES.notFound.length);
210
- httpRes.end(MoroHttpServer.RESPONSE_TEMPLATES.notFound);
211
- return;
212
- }
213
- // Extract path parameters - optimized with object pooling
214
- const matches = httpReq.path.match(route.pattern);
215
- if (matches) {
216
- // Use pooled object for parameters
217
- httpReq.params = this.acquireParamObject();
218
- const paramNames = route.paramNames;
219
- const paramNamesLen = paramNames.length;
220
- for (let i = 0; i < paramNamesLen; i++) {
221
- httpReq.params[paramNames[i]] = matches[i + 1];
1178
+ // Unified router direct dispatch - no middleware-chain promise wrapper
1179
+ const routerHandler = this.routerHandler;
1180
+ if (routerHandler) {
1181
+ const handled = routerHandler(httpReq, httpRes);
1182
+ if (handled) {
1183
+ if (typeof handled.then === 'function') {
1184
+ if (await handled)
1185
+ return;
1186
+ }
1187
+ else {
1188
+ return;
1189
+ }
222
1190
  }
1191
+ if (httpRes.headersSent)
1192
+ return;
223
1193
  }
224
- // Execute middleware chain - EARLY EXIT if no route middleware
225
- const routeMiddlewareLen = route.middleware.length;
226
- if (routeMiddlewareLen > 0) {
227
- await this.executeMiddleware(route.middleware, httpReq, httpRes);
1194
+ this.dispatchLegacyRoute(httpReq, httpRes, req);
1195
+ }
1196
+ catch (error) {
1197
+ await this.onRequestError(error, httpReq, httpRes, req);
1198
+ }
1199
+ }
1200
+ // Legacy direct-route table dispatch + 404 fallback. Synchronous; async
1201
+ // route middleware/handlers are continued via promise callbacks wired to
1202
+ // onRequestError so the sync fast path stays promise-free.
1203
+ dispatchLegacyRoute(httpReq, httpRes, req) {
1204
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1205
+ const route = this.findRoute(req.method, httpReq.path);
1206
+ if (!route) {
1207
+ // 404 response with pre-compiled buffer
1208
+ httpRes.statusCode = 404;
1209
+ httpRes.setHeader('Content-Type', 'application/json; charset=utf-8');
1210
+ httpRes.setHeader('Content-Length', MoroHttpServer.RESPONSE_TEMPLATES.notFound.length);
1211
+ httpRes.end(MoroHttpServer.RESPONSE_TEMPLATES.notFound);
1212
+ return;
1213
+ }
1214
+ // Extract path parameters
1215
+ const matches = httpReq.path.match(route.pattern);
1216
+ if (matches) {
1217
+ const params = {};
1218
+ const paramNames = route.paramNames;
1219
+ const paramNamesLen = paramNames.length;
1220
+ for (let i = 0; i < paramNamesLen; i++) {
1221
+ params[paramNames[i]] = matches[i + 1];
228
1222
  }
1223
+ httpReq.params = params;
1224
+ }
1225
+ const runHandler = () => {
229
1226
  // Execute handler - Don't await sync handlers
230
1227
  const handlerResult = route.handler(httpReq, httpRes);
231
1228
  if (handlerResult && typeof handlerResult.then === 'function') {
232
- await handlerResult;
1229
+ handlerResult.catch(error => void this.onRequestError(error, httpReq, httpRes, req));
1230
+ }
1231
+ };
1232
+ // Execute middleware chain - EARLY EXIT if no route middleware
1233
+ if (route.middleware.length > 0) {
1234
+ const mwResult = this.executeMiddleware(route.middleware, httpReq, httpRes);
1235
+ if (mwResult) {
1236
+ mwResult.then(runHandler, error => void this.onRequestError(error, httpReq, httpRes, req));
1237
+ return;
233
1238
  }
234
1239
  }
235
- catch (error) {
1240
+ runHandler();
1241
+ }
1242
+ // Shared error handling for all request paths (sync throw, slow-path catch,
1243
+ // and promise rejections from async handlers/middleware). Never throws.
1244
+ async onRequestError(error, httpReq, httpRes, req) {
1245
+ try {
236
1246
  // Debug: Log the actual error and where it came from
237
1247
  this.logger.debug('Request error details', 'RequestHandler', {
238
1248
  errorType: typeof error,
@@ -241,6 +1251,16 @@ export class MoroHttpServer {
241
1251
  requestPath: req.url,
242
1252
  requestMethod: req.method,
243
1253
  });
1254
+ // Payload-too-large from parseBody: respond 413 (matches the old bodySize
1255
+ // middleware semantics, now enforced in-server)
1256
+ if (error?.statusCode === 413 && !httpRes.headersSent) {
1257
+ httpRes.statusCode = 413;
1258
+ httpRes.json({
1259
+ success: false,
1260
+ error: 'Request entity too large',
1261
+ });
1262
+ return;
1263
+ }
244
1264
  this.logger.error('Request error', 'RequestHandler', {
245
1265
  error: error instanceof Error ? error.message : String(error),
246
1266
  requestId: httpReq.requestId,
@@ -298,66 +1318,13 @@ export class MoroHttpServer {
298
1318
  }
299
1319
  }
300
1320
  }
301
- finally {
302
- // Always release pooled objects back to the pool
303
- // This prevents memory leaks and ensures consistent performance
304
- // Check if object is empty without Object.keys()
305
- if (originalParams) {
306
- let isEmpty = true;
307
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
308
- for (const _key in originalParams) {
309
- isEmpty = false;
310
- break;
311
- }
312
- if (isEmpty) {
313
- this.releaseParamObject(originalParams);
314
- }
315
- }
316
- if (httpReq.params && httpReq.params !== originalParams) {
317
- let isEmpty = true;
318
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
319
- for (const _key in httpReq.params) {
320
- isEmpty = false;
321
- break;
322
- }
323
- if (isEmpty) {
324
- this.releaseParamObject(httpReq.params);
325
- }
326
- }
1321
+ catch (fatalError) {
1322
+ // Error handling itself failed (e.g. the connection died mid-response) -
1323
+ // never let this escape as an unhandled rejection
1324
+ this.logger.error('Failed while handling request error', 'RequestHandler', {
1325
+ error: fatalError instanceof Error ? fatalError.message : String(fatalError),
1326
+ });
327
1327
  }
328
- // Additional cleanup on response completion to ensure objects are returned to pool
329
- res.once('finish', () => {
330
- // Check if object is empty without Object.keys()
331
- if (originalParams) {
332
- let isEmpty = true;
333
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
334
- for (const _key in originalParams) {
335
- isEmpty = false;
336
- break;
337
- }
338
- if (isEmpty) {
339
- this.releaseParamObject(originalParams);
340
- }
341
- }
342
- if (httpReq.params && httpReq.params !== originalParams) {
343
- let isEmpty = true;
344
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
345
- for (const _key in httpReq.params) {
346
- isEmpty = false;
347
- break;
348
- }
349
- if (isEmpty) {
350
- this.releaseParamObject(httpReq.params);
351
- }
352
- }
353
- });
354
- }
355
- // Use shared object pool for parameter objects
356
- acquireParamObject() {
357
- return this.poolManager.acquireParams();
358
- }
359
- releaseParamObject(params) {
360
- this.poolManager.releaseParams(params);
361
1328
  }
362
1329
  // Force cleanup of all pooled objects
363
1330
  forceCleanupPools() {
@@ -429,697 +1396,7 @@ export class MoroHttpServer {
429
1396
  }
430
1397
  return normalized;
431
1398
  }
432
- enhanceRequest(req) {
433
- const httpReq = req;
434
- httpReq.params = this.acquireParamObject();
435
- httpReq.query = {};
436
- httpReq.body = null;
437
- httpReq.path = '';
438
- httpReq.ip = req.socket.remoteAddress || '';
439
- // Request ID generation using pool manager (if enabled)
440
- httpReq.requestId = this.requestTrackingEnabled ? this.poolManager.generateRequestId() : '';
441
- httpReq.headers = req.headers;
442
- // Intern method string for fast reference equality comparison (50-100% faster)
443
- const method = req.method;
444
- switch (method) {
445
- case 'POST':
446
- httpReq.method = MoroHttpServer.METHOD_POST;
447
- break;
448
- case 'PUT':
449
- httpReq.method = MoroHttpServer.METHOD_PUT;
450
- break;
451
- case 'PATCH':
452
- httpReq.method = MoroHttpServer.METHOD_PATCH;
453
- break;
454
- case 'GET':
455
- httpReq.method = MoroHttpServer.METHOD_GET;
456
- break;
457
- case 'DELETE':
458
- httpReq.method = MoroHttpServer.METHOD_DELETE;
459
- break;
460
- case 'HEAD':
461
- httpReq.method = MoroHttpServer.METHOD_HEAD;
462
- break;
463
- case 'OPTIONS':
464
- httpReq.method = MoroHttpServer.METHOD_OPTIONS;
465
- break;
466
- default:
467
- httpReq.method = method;
468
- }
469
- // Parse cookies - EARLY EXIT if no cookie header
470
- const cookieHeader = req.headers.cookie;
471
- if (cookieHeader) {
472
- httpReq.cookies = this.parseCookies(cookieHeader);
473
- }
474
- else {
475
- httpReq.cookies = {};
476
- }
477
- // Per-request typed context bag
478
- httpReq.context = {};
479
- // Apply user-registered request decorations
480
- for (const key in this.requestDecorations) {
481
- httpReq[key] = this.requestDecorations[key];
482
- }
483
- // Express-compatible request helpers
484
- httpReq.get = httpReq.header = (name) => {
485
- const lower = name.toLowerCase();
486
- if (lower === 'referer' || lower === 'referrer') {
487
- return (httpReq.headers.referer || httpReq.headers.referrer);
488
- }
489
- const v = httpReq.headers[lower];
490
- return Array.isArray(v) ? v[0] : v;
491
- };
492
- httpReq.is = (type) => {
493
- const ct = (httpReq.headers['content-type'] || '');
494
- if (!ct)
495
- return false;
496
- const mime = ct.split(';')[0].trim().toLowerCase();
497
- const t = type.toLowerCase();
498
- if (t.indexOf('/') === -1) {
499
- // e.g. 'json' → match */json or +json suffix
500
- return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
501
- }
502
- if (t.endsWith('/*')) {
503
- return mime.startsWith(t.slice(0, -1));
504
- }
505
- return mime === t;
506
- };
507
- httpReq.accepts = (types) => {
508
- const accept = (httpReq.headers.accept || '*/*');
509
- if (!types)
510
- return accept;
511
- const wanted = Array.isArray(types) ? types : [types];
512
- if (accept === '*/*' || accept === '')
513
- return wanted[0] || false;
514
- const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
515
- for (const w of wanted) {
516
- const wl = w.toLowerCase();
517
- const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
518
- for (const at of acceptTypes) {
519
- if (at === '*/*')
520
- return w;
521
- if (at === wMime)
522
- return w;
523
- if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
524
- return w;
525
- }
526
- }
527
- return false;
528
- };
529
- httpReq.acceptsLanguages = (langs) => {
530
- const acceptLang = (httpReq.headers['accept-language'] || '');
531
- if (!langs)
532
- return acceptLang || false;
533
- const wanted = Array.isArray(langs) ? langs : [langs];
534
- if (!acceptLang)
535
- return wanted[0] || false;
536
- // Parse with q values; sort by q desc to match Express's "best match wins"
537
- const accepted = acceptLang
538
- .split(',')
539
- .map(s => {
540
- const parts = s.trim().split(';');
541
- const tag = parts[0].toLowerCase();
542
- const qPart = parts.find(p => p.trim().startsWith('q='));
543
- const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
544
- return { tag, q: isNaN(q) ? 0 : q };
545
- })
546
- .filter(a => a.q > 0)
547
- .sort((a, b) => b.q - a.q);
548
- for (const { tag } of accepted) {
549
- for (const w of wanted) {
550
- const wl = w.toLowerCase();
551
- if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
552
- return w;
553
- }
554
- }
555
- return false;
556
- };
557
- const host = (httpReq.headers.host || '');
558
- httpReq.hostname = host ? host.split(':')[0] : '';
559
- const forwardedProto = httpReq.headers['x-forwarded-proto'];
560
- httpReq.protocol = forwardedProto
561
- ? forwardedProto.split(',')[0].trim()
562
- : req.socket.encrypted
563
- ? 'https'
564
- : 'http';
565
- httpReq.secure = httpReq.protocol === 'https';
566
- const xrw = httpReq.headers['x-requested-with'];
567
- httpReq.xhr = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
568
- httpReq.originalUrl = req.url || '';
569
- const forwardedFor = httpReq.headers['x-forwarded-for'];
570
- httpReq.ips = forwardedFor
571
- ? forwardedFor
572
- .split(',')
573
- .map(s => s.trim())
574
- .filter(Boolean)
575
- : [];
576
- const hostnameParts = httpReq.hostname.split('.');
577
- // Matches Express: subdomains are parts before the last two (domain + tld),
578
- // reversed (closest subdomain first).
579
- httpReq.subdomains = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
580
- return httpReq;
581
- }
582
- parseCookies(cookieHeader) {
583
- const cookies = {};
584
- // EARLY EXIT if no cookie header
585
- if (!cookieHeader)
586
- return cookies;
587
- const cookieParts = cookieHeader.split(';');
588
- const cookiePartsLen = cookieParts.length;
589
- for (let i = 0; i < cookiePartsLen; i++) {
590
- const cookie = cookieParts[i];
591
- const equalIndex = cookie.indexOf('=');
592
- if (equalIndex > 0) {
593
- const name = cookie.substring(0, equalIndex).trim();
594
- const value = cookie.substring(equalIndex + 1);
595
- if (name && value) {
596
- cookies[name] = decodeURIComponent(value);
597
- }
598
- }
599
- }
600
- return cookies;
601
- }
602
- enhanceResponse(res, req) {
603
- const httpRes = res;
604
- // Store request reference for access to headers (needed for compression, logging, etc.)
605
- httpRes.req = req;
606
- // BULLETPROOF status method - always works
607
- httpRes.status = (code) => {
608
- httpRes.statusCode = code;
609
- return httpRes;
610
- };
611
- httpRes.json = async (data) => {
612
- if (httpRes.headersSent)
613
- return;
614
- // Simple, optimized JSON serialization - let V8 handle the optimization
615
- const jsonString = JSON.stringify(data);
616
- // Large response check - stream if needed
617
- if (jsonString.length > 32768) {
618
- // Large response - stream it
619
- return this.streamLargeResponse(httpRes, data);
620
- }
621
- // Use efficient buffer allocation - let Node.js handle optimization
622
- const finalBuffer = Buffer.from(jsonString, 'utf8');
623
- // Optimized header setting - set multiple headers at once when possible
624
- const headers = {
625
- 'Content-Type': 'application/json; charset=utf-8',
626
- };
627
- // Compression with buffer pool - EARLY EXIT if disabled or below threshold
628
- // Only make this async if compression is actually happening
629
- if (this.compressionEnabled && finalBuffer.length > this.compressionThreshold) {
630
- const acceptEncoding = httpRes.req.headers['accept-encoding'];
631
- if (acceptEncoding && acceptEncoding.includes('gzip')) {
632
- // ASYNC PATH - compression needed
633
- gzip(finalBuffer).then(compressed => {
634
- headers['Content-Encoding'] = 'gzip';
635
- headers['Content-Length'] = compressed.length;
636
- // Batch write all headers at once (50-100% faster)
637
- httpRes.writeHead(httpRes.statusCode || 200, headers);
638
- httpRes.end(compressed);
639
- });
640
- return;
641
- }
642
- else if (acceptEncoding && acceptEncoding.includes('deflate')) {
643
- // ASYNC PATH - compression needed
644
- deflate(finalBuffer).then(compressed => {
645
- headers['Content-Encoding'] = 'deflate';
646
- headers['Content-Length'] = compressed.length;
647
- // Batch write all headers at once
648
- httpRes.writeHead(httpRes.statusCode || 200, headers);
649
- httpRes.end(compressed);
650
- });
651
- return;
652
- }
653
- }
654
- // SYNC PATH - no compression, fast path
655
- headers['Content-Length'] = finalBuffer.length;
656
- // Batch write all headers at once
657
- httpRes.writeHead(httpRes.statusCode || 200, headers);
658
- httpRes.end(finalBuffer);
659
- };
660
- httpRes.send = (data) => {
661
- if (httpRes.headersSent)
662
- return;
663
- // Auto-detect content type if not already set
664
- if (!httpRes.getHeader('Content-Type')) {
665
- if (typeof data === 'string') {
666
- // Check if it's JSON
667
- try {
668
- JSON.parse(data);
669
- httpRes.setHeader('Content-Type', 'application/json; charset=utf-8');
670
- }
671
- catch {
672
- // Default to plain text
673
- httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
674
- }
675
- }
676
- else {
677
- // Buffer data - default to octet-stream
678
- httpRes.setHeader('Content-Type', 'application/octet-stream');
679
- }
680
- }
681
- httpRes.end(data);
682
- };
683
- httpRes.cookie = (name, value, options = {}) => {
684
- if (httpRes.headersSent) {
685
- const isCritical = options.critical ||
686
- name.includes('session') ||
687
- name.includes('auth') ||
688
- name.includes('csrf');
689
- const message = `Cookie '${name}' could not be set - headers already sent`;
690
- if (isCritical || options.throwOnLateSet) {
691
- throw new Error(`${message}. This may cause authentication or security issues.`);
692
- }
693
- else {
694
- this.logger.warn(message, 'CookieWarning', {
695
- cookieName: name,
696
- critical: isCritical,
697
- stackTrace: new Error().stack,
698
- });
699
- }
700
- return httpRes;
701
- }
702
- const cookieValue = encodeURIComponent(value);
703
- let cookieString = `${name}=${cookieValue}`;
704
- if (options.maxAge)
705
- cookieString += `; Max-Age=${options.maxAge}`;
706
- if (options.expires)
707
- cookieString += `; Expires=${options.expires.toUTCString()}`;
708
- if (options.httpOnly)
709
- cookieString += '; HttpOnly';
710
- if (options.secure)
711
- cookieString += '; Secure';
712
- if (options.sameSite)
713
- cookieString += `; SameSite=${options.sameSite}`;
714
- if (options.domain)
715
- cookieString += `; Domain=${options.domain}`;
716
- if (options.path)
717
- cookieString += `; Path=${options.path}`;
718
- const existingCookies = httpRes.getHeader('Set-Cookie') || [];
719
- // Avoid spread operator - direct array manipulation
720
- const cookies = Array.isArray(existingCookies)
721
- ? existingCookies
722
- : [existingCookies];
723
- cookies.push(cookieString);
724
- httpRes.setHeader('Set-Cookie', cookies);
725
- return httpRes;
726
- };
727
- httpRes.clearCookie = (name, options = {}) => {
728
- // Avoid spread operator - manually set properties
729
- const clearOptions = {
730
- expires: new Date(0),
731
- maxAge: 0,
732
- };
733
- // Copy other options manually
734
- if (options.path !== undefined)
735
- clearOptions.path = options.path;
736
- if (options.domain !== undefined)
737
- clearOptions.domain = options.domain;
738
- if (options.httpOnly !== undefined)
739
- clearOptions.httpOnly = options.httpOnly;
740
- if (options.secure !== undefined)
741
- clearOptions.secure = options.secure;
742
- if (options.sameSite !== undefined)
743
- clearOptions.sameSite = options.sameSite;
744
- return httpRes.cookie(name, '', clearOptions);
745
- };
746
- httpRes.redirect = (url, status = 302) => {
747
- if (httpRes.headersSent)
748
- return;
749
- httpRes.statusCode = status;
750
- const safeUrl = url.replace(/[\r\n]/g, '');
751
- httpRes.setHeader('Location', safeUrl);
752
- httpRes.end();
753
- };
754
- httpRes.sendFile = async (filePath) => {
755
- if (httpRes.headersSent)
756
- return;
757
- try {
758
- const fs = await import('fs/promises');
759
- const path = await import('path');
760
- const extension = path.extname(filePath);
761
- const mime = await this.getMimeType(extension);
762
- const stats = await fs.stat(filePath);
763
- const data = await fs.readFile(filePath);
764
- // Add charset for text-based files
765
- const contentType = this.addCharsetIfNeeded(mime);
766
- httpRes.setHeader('Content-Type', contentType);
767
- httpRes.setHeader('Content-Length', stats.size);
768
- // Add security headers for file downloads
769
- httpRes.setHeader('X-Content-Type-Options', 'nosniff');
770
- // Add caching headers
771
- httpRes.setHeader('Last-Modified', stats.mtime.toUTCString());
772
- httpRes.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
773
- httpRes.end(data);
774
- }
775
- catch {
776
- httpRes.status(404).json({ success: false, error: 'File not found' });
777
- }
778
- };
779
- // Standardized response helpers
780
- httpRes.success = (data, message) => {
781
- const response = {
782
- success: true,
783
- data,
784
- };
785
- if (message !== undefined) {
786
- response.message = message;
787
- }
788
- httpRes.json(response);
789
- };
790
- httpRes.error = (error, code, message) => {
791
- const response = {
792
- success: false,
793
- error,
794
- };
795
- if (code !== undefined) {
796
- response.code = code;
797
- }
798
- if (message !== undefined) {
799
- response.message = message;
800
- }
801
- httpRes.json(response);
802
- };
803
- // Common HTTP error helpers (automatically set status code)
804
- httpRes.unauthorized = (message = 'Authentication required') => {
805
- httpRes.statusCode = 401;
806
- httpRes.json({
807
- success: false,
808
- error: 'Unauthorized',
809
- code: 'UNAUTHORIZED',
810
- message,
811
- });
812
- };
813
- httpRes.forbidden = (message = 'Insufficient permissions') => {
814
- httpRes.statusCode = 403;
815
- httpRes.json({
816
- success: false,
817
- error: 'Forbidden',
818
- code: 'FORBIDDEN',
819
- message,
820
- });
821
- };
822
- httpRes.notFound = (resource = 'Resource') => {
823
- httpRes.statusCode = 404;
824
- httpRes.json({
825
- success: false,
826
- error: 'Not Found',
827
- code: 'NOT_FOUND',
828
- message: `${resource} not found`,
829
- });
830
- };
831
- httpRes.badRequest = (message = 'Invalid request') => {
832
- httpRes.statusCode = 400;
833
- httpRes.json({
834
- success: false,
835
- error: 'Bad Request',
836
- code: 'BAD_REQUEST',
837
- message,
838
- });
839
- };
840
- httpRes.conflict = (message) => {
841
- httpRes.statusCode = 409;
842
- httpRes.json({
843
- success: false,
844
- error: 'Conflict',
845
- code: 'CONFLICT',
846
- message,
847
- });
848
- };
849
- httpRes.internalError = (message = 'Internal server error') => {
850
- httpRes.statusCode = 500;
851
- httpRes.json({
852
- success: false,
853
- error: 'Internal Server Error',
854
- code: 'INTERNAL_ERROR',
855
- message,
856
- });
857
- };
858
- httpRes.validationError = (errors) => {
859
- httpRes.statusCode = 422;
860
- httpRes.json({
861
- success: false,
862
- error: 'Validation Failed',
863
- code: 'VALIDATION_ERROR',
864
- errors,
865
- });
866
- };
867
- httpRes.rateLimited = (retryAfter) => {
868
- httpRes.statusCode = 429;
869
- if (retryAfter) {
870
- httpRes.setHeader('Retry-After', retryAfter.toString());
871
- }
872
- httpRes.json({
873
- success: false,
874
- error: 'Rate Limit Exceeded',
875
- code: 'RATE_LIMITED',
876
- message: retryAfter
877
- ? `Too many requests. Retry after ${retryAfter} seconds.`
878
- : 'Too many requests',
879
- retryAfter,
880
- });
881
- };
882
- // Common success patterns
883
- httpRes.created = (data, location) => {
884
- httpRes.statusCode = 201;
885
- if (location) {
886
- httpRes.setHeader('Location', location);
887
- }
888
- httpRes.json({
889
- success: true,
890
- data,
891
- });
892
- };
893
- httpRes.noContent = () => {
894
- httpRes.statusCode = 204;
895
- httpRes.end();
896
- };
897
- httpRes.paginated = (data, pagination) => {
898
- const totalPages = Math.ceil(pagination.total / pagination.limit);
899
- httpRes.json({
900
- success: true,
901
- data,
902
- pagination: {
903
- page: pagination.page,
904
- limit: pagination.limit,
905
- total: pagination.total,
906
- totalPages,
907
- hasNext: pagination.page < totalPages,
908
- hasPrev: pagination.page > 1,
909
- },
910
- });
911
- };
912
- // Header management utilities
913
- httpRes.hasHeader = (name) => {
914
- return httpRes.getHeader(name) !== undefined;
915
- };
916
- // Note: removeHeader is inherited from ServerResponse, we don't override it
917
- httpRes.setBulkHeaders = (headers) => {
918
- if (httpRes.headersSent) {
919
- // Only enumerate keys for warning if headers were already sent
920
- const attemptedHeaderKeys = [];
921
- for (const key in headers) {
922
- attemptedHeaderKeys.push(key);
923
- }
924
- this.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
925
- attemptedHeaders: attemptedHeaderKeys,
926
- });
927
- return httpRes;
928
- }
929
- for (const key in headers) {
930
- httpRes.setHeader(key, headers[key]);
931
- }
932
- return httpRes;
933
- };
934
- httpRes.appendHeader = (name, value) => {
935
- if (httpRes.headersSent) {
936
- this.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
937
- return httpRes;
938
- }
939
- const existing = httpRes.getHeader(name);
940
- if (existing) {
941
- const values = Array.isArray(existing) ? existing : [existing.toString()];
942
- const newValues = Array.isArray(value) ? value : [value];
943
- httpRes.setHeader(name, [...values, ...newValues]);
944
- }
945
- else {
946
- httpRes.setHeader(name, value);
947
- }
948
- return httpRes;
949
- };
950
- // Per-response typed state bag (Express-compatible)
951
- httpRes.locals = {};
952
- // Apply user-registered response decorations
953
- for (const key in this.responseDecorations) {
954
- httpRes[key] = this.responseDecorations[key];
955
- }
956
- // Express-compatible response helpers
957
- httpRes.set = (field, value) => {
958
- if (httpRes.headersSent)
959
- return httpRes;
960
- if (typeof field === 'string') {
961
- if (value !== undefined) {
962
- httpRes.setHeader(field, value);
963
- }
964
- }
965
- else {
966
- for (const key in field) {
967
- httpRes.setHeader(key, field[key]);
968
- }
969
- }
970
- return httpRes;
971
- };
972
- httpRes.get = (field) => {
973
- return httpRes.getHeader(field);
974
- };
975
- httpRes.append = (field, value) => {
976
- return httpRes.appendHeader(field, value);
977
- };
978
- httpRes.type = (contentType) => {
979
- if (httpRes.headersSent)
980
- return httpRes;
981
- // If no "/" treat as shorthand (e.g. "json" → "application/json")
982
- let ct = contentType;
983
- if (ct.indexOf('/') === -1) {
984
- const shorthands = {
985
- json: 'application/json; charset=utf-8',
986
- html: 'text/html; charset=utf-8',
987
- text: 'text/plain; charset=utf-8',
988
- txt: 'text/plain; charset=utf-8',
989
- xml: 'application/xml',
990
- js: 'application/javascript',
991
- css: 'text/css; charset=utf-8',
992
- form: 'application/x-www-form-urlencoded',
993
- };
994
- ct = shorthands[ct.toLowerCase()] || `application/${ct}`;
995
- }
996
- httpRes.setHeader('Content-Type', ct);
997
- return httpRes;
998
- };
999
- httpRes.sendStatus = (code) => {
1000
- if (httpRes.headersSent)
1001
- return;
1002
- httpRes.statusCode = code;
1003
- const messages = {
1004
- 200: 'OK',
1005
- 201: 'Created',
1006
- 202: 'Accepted',
1007
- 204: 'No Content',
1008
- 301: 'Moved Permanently',
1009
- 302: 'Found',
1010
- 304: 'Not Modified',
1011
- 400: 'Bad Request',
1012
- 401: 'Unauthorized',
1013
- 403: 'Forbidden',
1014
- 404: 'Not Found',
1015
- 409: 'Conflict',
1016
- 410: 'Gone',
1017
- 422: 'Unprocessable Entity',
1018
- 429: 'Too Many Requests',
1019
- 500: 'Internal Server Error',
1020
- 502: 'Bad Gateway',
1021
- 503: 'Service Unavailable',
1022
- };
1023
- const body = messages[code] || String(code);
1024
- httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
1025
- httpRes.end(body);
1026
- };
1027
- httpRes.location = (url) => {
1028
- if (httpRes.headersSent)
1029
- return httpRes;
1030
- const safe = url.replace(/[\r\n]/g, '');
1031
- httpRes.setHeader('Location', safe);
1032
- return httpRes;
1033
- };
1034
- httpRes.vary = (field) => {
1035
- if (httpRes.headersSent)
1036
- return httpRes;
1037
- const existing = httpRes.getHeader('Vary');
1038
- const existingList = existing
1039
- ? String(existing)
1040
- .split(',')
1041
- .map(s => s.trim())
1042
- .filter(Boolean)
1043
- : [];
1044
- const incoming = Array.isArray(field) ? field : [field];
1045
- for (const f of incoming) {
1046
- if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
1047
- existingList.push(f);
1048
- }
1049
- }
1050
- httpRes.setHeader('Vary', existingList.join(', '));
1051
- return httpRes;
1052
- };
1053
- httpRes.attachment = (filename) => {
1054
- if (httpRes.headersSent)
1055
- return httpRes;
1056
- if (filename) {
1057
- const safeName = filename.replace(/[\r\n"]/g, '');
1058
- httpRes.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
1059
- // Set Content-Type from extension if not already set
1060
- if (!httpRes.getHeader('Content-Type')) {
1061
- // fire-and-forget mime lookup; not awaited to keep signature chainable
1062
- this.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
1063
- if (!httpRes.getHeader('Content-Type') && !httpRes.headersSent) {
1064
- httpRes.setHeader('Content-Type', mime);
1065
- }
1066
- });
1067
- }
1068
- }
1069
- else {
1070
- httpRes.setHeader('Content-Disposition', 'attachment');
1071
- }
1072
- return httpRes;
1073
- };
1074
- httpRes.download = async (filePath, filename) => {
1075
- httpRes.attachment(filename || filePath.split('/').pop());
1076
- return httpRes.sendFile(filePath);
1077
- };
1078
- httpRes.format = (handlers) => {
1079
- const types = Object.keys(handlers).filter(k => k !== 'default');
1080
- const matched = req.accepts(types);
1081
- const chosen = matched ? handlers[matched] : handlers.default;
1082
- if (!chosen) {
1083
- httpRes.statusCode = 406;
1084
- httpRes.setHeader('Content-Type', 'text/plain; charset=utf-8');
1085
- httpRes.end('Not Acceptable');
1086
- return;
1087
- }
1088
- httpRes.vary('Accept');
1089
- if (matched && typeof matched === 'string') {
1090
- httpRes.type(matched);
1091
- }
1092
- chosen();
1093
- };
1094
- httpRes.links = (links) => {
1095
- if (httpRes.headersSent)
1096
- return httpRes;
1097
- const parts = [];
1098
- for (const rel in links) {
1099
- parts.push(`<${links[rel]}>; rel="${rel}"`);
1100
- }
1101
- if (parts.length > 0) {
1102
- const existing = httpRes.getHeader('Link');
1103
- const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
1104
- httpRes.setHeader('Link', combined);
1105
- }
1106
- return httpRes;
1107
- };
1108
- // Response state utilities
1109
- httpRes.canSetHeaders = () => {
1110
- return !httpRes.headersSent;
1111
- };
1112
- httpRes.getResponseState = () => {
1113
- return {
1114
- headersSent: httpRes.headersSent,
1115
- statusCode: httpRes.statusCode,
1116
- headers: httpRes.getHeaders ? httpRes.getHeaders() : {},
1117
- finished: httpRes.finished || false,
1118
- writable: httpRes.writable,
1119
- };
1120
- };
1121
- return httpRes;
1122
- }
1399
+ /** @internal called by MoroServerResponse prototype methods */
1123
1400
  async getMimeType(ext) {
1124
1401
  const mimeTypes = {
1125
1402
  '.html': 'text/html',
@@ -1138,6 +1415,7 @@ export class MoroHttpServer {
1138
1415
  };
1139
1416
  return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
1140
1417
  }
1418
+ /** @internal called by MoroServerResponse prototype methods */
1141
1419
  addCharsetIfNeeded(mimeType) {
1142
1420
  // Add charset for text-based content types - optimized with early exit
1143
1421
  // Check most common cases first
@@ -1161,6 +1439,14 @@ export class MoroHttpServer {
1161
1439
  const maxSize = isMultipart
1162
1440
  ? this.maxUploadSize // Configurable file upload limit (default 100MB)
1163
1441
  : this.maxBodySize; // Configurable body size limit (default 10MB, from server.bodySizeLimit config)
1442
+ // Early rejection from the declared Content-Length - nothing is read off the wire
1443
+ if (contentLength > maxSize) {
1444
+ const error = new Error('Request entity too large');
1445
+ error.statusCode = 413;
1446
+ error.limit = maxSize;
1447
+ error.received = contentLength;
1448
+ return Promise.reject(error);
1449
+ }
1164
1450
  // For very large payloads, return a streaming interface instead of buffering
1165
1451
  if (contentLength > maxSize / 2) {
1166
1452
  // Stream for payloads > 50MB (uploads) or > 5MB (JSON/form)
@@ -1176,14 +1462,19 @@ export class MoroHttpServer {
1176
1462
  const errorMessage = isMultipart
1177
1463
  ? `File upload too large. Maximum ${Math.floor(maxSize / (1024 * 1024))}MB allowed.`
1178
1464
  : 'Request body too large';
1179
- reject(new Error(errorMessage));
1465
+ const error = new Error(errorMessage);
1466
+ error.statusCode = 413;
1467
+ error.limit = maxSize;
1468
+ reject(error);
1180
1469
  return;
1181
1470
  }
1182
1471
  chunks.push(chunk);
1183
1472
  });
1184
1473
  req.on('end', () => {
1185
1474
  try {
1186
- const body = Buffer.concat(chunks);
1475
+ // Single-chunk fast path (most JSON bodies arrive in one chunk);
1476
+ // otherwise concat with the known total length to avoid a re-scan
1477
+ const body = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, totalLength);
1187
1478
  if (contentType.includes('application/json')) {
1188
1479
  resolve(JSON.parse(body.toString()));
1189
1480
  }
@@ -1410,30 +1701,14 @@ export class MoroHttpServer {
1410
1701
  }
1411
1702
  return result;
1412
1703
  }
1413
- // Legacy method for backward compatibility
1704
+ // Legacy methods for backward compatibility - the hot path now parses query
1705
+ // strings lazily via the MoroIncomingMessage#query getter (no pooling: a fresh
1706
+ // object literal is cheaper than pool bookkeeping)
1414
1707
  parseQueryString(queryString) {
1415
- return this.parseQueryStringPooled(queryString);
1708
+ return parseQueryString(queryString);
1416
1709
  }
1417
- // Optimized query string parser with object pooling
1418
1710
  parseQueryStringPooled(queryString) {
1419
- if (!queryString)
1420
- return {};
1421
- const result = this.poolManager.acquireQuery();
1422
- const pairs = queryString.split('&');
1423
- const pairsLen = pairs.length;
1424
- for (let i = 0; i < pairsLen; i++) {
1425
- const pair = pairs[i];
1426
- const equalIndex = pair.indexOf('=');
1427
- if (equalIndex === -1) {
1428
- result[decodeURIComponent(pair)] = '';
1429
- }
1430
- else {
1431
- const key = decodeURIComponent(pair.substring(0, equalIndex));
1432
- const value = decodeURIComponent(pair.substring(equalIndex + 1));
1433
- result[key] = value;
1434
- }
1435
- }
1436
- return result;
1711
+ return parseQueryString(queryString);
1437
1712
  }
1438
1713
  // Advanced route optimization: cache + static routes + segment grouping
1439
1714
  routeCache = new Map();
@@ -1498,13 +1773,23 @@ export class MoroHttpServer {
1498
1773
  // Middleware execution with Express-compatible error propagation.
1499
1774
  // Supports: 3-arg (req, res, next), 4-arg (err, req, res, next) error middlewares,
1500
1775
  // and next(err) to skip forward to the next error middleware.
1501
- async executeMiddleware(middleware, req, res) {
1776
+ //
1777
+ // Sync-aware dispatch: middleware that calls next() synchronously (or throws
1778
+ // synchronously) advances the chain in a plain loop with ZERO promise
1779
+ // allocations. A promise is only created when a middleware actually completes
1780
+ // asynchronously. Returns undefined when the whole chain ran synchronously.
1781
+ executeMiddleware(middleware, req, res) {
1782
+ return this.dispatchMiddleware(middleware, req, res, 0, undefined);
1783
+ }
1784
+ dispatchMiddleware(middleware, req, res, startIndex, initialError) {
1502
1785
  const len = middleware.length;
1503
- let activeError = undefined;
1504
- for (let i = 0; i < len; i++) {
1786
+ let activeError = initialError;
1787
+ let i = startIndex;
1788
+ while (i < len) {
1505
1789
  if (res.headersSent)
1506
1790
  return;
1507
1791
  const mw = middleware[i];
1792
+ i++;
1508
1793
  const isErrorHandler = mw.length >= 4;
1509
1794
  // Non-error middleware is skipped while an error is active; error middleware
1510
1795
  // is skipped while no error is active. Matches Express semantics.
@@ -1512,36 +1797,60 @@ export class MoroHttpServer {
1512
1797
  continue;
1513
1798
  if (activeError === undefined && isErrorHandler)
1514
1799
  continue;
1515
- activeError = await new Promise(resolve => {
1516
- let resolved = false;
1517
- const next = (err) => {
1518
- if (resolved)
1519
- return;
1520
- resolved = true;
1521
- resolve(err);
1522
- };
1523
- try {
1524
- const result = isErrorHandler ? mw(activeError, req, res, next) : mw(req, res, next);
1525
- if (result && typeof result.then === 'function') {
1526
- result
1527
- .then(() => {
1528
- if (!resolved)
1529
- next();
1530
- })
1531
- .catch((err) => {
1532
- if (!resolved)
1533
- next(err);
1534
- });
1535
- }
1536
- else if (!resolved) {
1537
- next();
1538
- }
1800
+ let settled = false;
1801
+ let settledError = undefined;
1802
+ let asyncResolve;
1803
+ const next = (err) => {
1804
+ if (settled)
1805
+ return;
1806
+ settled = true;
1807
+ settledError = err;
1808
+ if (asyncResolve)
1809
+ asyncResolve(err);
1810
+ };
1811
+ let result;
1812
+ try {
1813
+ result = isErrorHandler ? mw(activeError, req, res, next) : mw(req, res, next);
1814
+ }
1815
+ catch (err) {
1816
+ if (!settled) {
1817
+ settled = true;
1818
+ settledError = err;
1539
1819
  }
1540
- catch (err) {
1541
- if (!resolved)
1542
- next(err);
1820
+ }
1821
+ const isThenable = result && typeof result.then === 'function';
1822
+ if (settled) {
1823
+ // Completed synchronously (next() called sync, or threw sync).
1824
+ // Matches the old behavior: a still-pending returned promise no longer
1825
+ // gates the chain once next() has been called; swallow late rejections.
1826
+ if (isThenable) {
1827
+ result.then(undefined, () => { });
1543
1828
  }
1544
- });
1829
+ activeError = settledError;
1830
+ continue;
1831
+ }
1832
+ if (!isThenable) {
1833
+ // Sync-looking middleware that neither called next() nor returned a
1834
+ // promise: it will call next() later (e.g. from an event callback).
1835
+ // Fall back to a promise for the remainder of the chain.
1836
+ const resumeIndex = i;
1837
+ return new Promise(resolve => {
1838
+ asyncResolve = resolve;
1839
+ }).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
1840
+ }
1841
+ // Async middleware: completion is next() OR promise settle, whichever
1842
+ // happens first (same semantics as the previous implementation).
1843
+ const resumeIndex = i;
1844
+ return new Promise(resolve => {
1845
+ asyncResolve = resolve;
1846
+ result.then(() => {
1847
+ if (!settled)
1848
+ next();
1849
+ }, (err) => {
1850
+ if (!settled)
1851
+ next(err);
1852
+ });
1853
+ }).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
1545
1854
  }
1546
1855
  // If an error is still unhandled after the chain, re-throw so the top-level
1547
1856
  // catch in handleRequest can invoke the registered errorHandler / default 500.