@morojs/moro 1.7.27 → 1.7.29

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 (46) hide show
  1. package/dist/core/config/config-sources.js +62 -4
  2. package/dist/core/config/config-sources.js.map +1 -1
  3. package/dist/core/config/config-validator.js +26 -0
  4. package/dist/core/config/config-validator.js.map +1 -1
  5. package/dist/core/config/schema.js +11 -1
  6. package/dist/core/config/schema.js.map +1 -1
  7. package/dist/core/framework.d.ts +0 -1
  8. package/dist/core/framework.js +14 -25
  9. package/dist/core/framework.js.map +1 -1
  10. package/dist/core/http/http-server.d.ts +124 -12
  11. package/dist/core/http/http-server.js +1067 -833
  12. package/dist/core/http/http-server.js.map +1 -1
  13. package/dist/core/http/uws-http-server.d.ts +72 -5
  14. package/dist/core/http/uws-http-server.js +718 -150
  15. package/dist/core/http/uws-http-server.js.map +1 -1
  16. package/dist/core/logger/logger.d.ts +9 -2
  17. package/dist/core/logger/logger.js +114 -15
  18. package/dist/core/logger/logger.js.map +1 -1
  19. package/dist/core/middleware/built-in/compression/core.js +47 -29
  20. package/dist/core/middleware/built-in/compression/core.js.map +1 -1
  21. package/dist/core/middleware/built-in/error-tracker/middleware.d.ts +5 -2
  22. package/dist/core/middleware/built-in/error-tracker/middleware.js +13 -11
  23. package/dist/core/middleware/built-in/error-tracker/middleware.js.map +1 -1
  24. package/dist/core/middleware/built-in/index.d.ts +3 -3
  25. package/dist/core/middleware/built-in/performance-monitor/middleware.d.ts +2 -1
  26. package/dist/core/middleware/built-in/performance-monitor/middleware.js +7 -6
  27. package/dist/core/middleware/built-in/performance-monitor/middleware.js.map +1 -1
  28. package/dist/core/middleware/built-in/request-logger/middleware.d.ts +2 -1
  29. package/dist/core/middleware/built-in/request-logger/middleware.js +5 -5
  30. package/dist/core/middleware/built-in/request-logger/middleware.js.map +1 -1
  31. package/dist/core/middleware/built-in/validation/core.js +61 -27
  32. package/dist/core/middleware/built-in/validation/core.js.map +1 -1
  33. package/dist/core/routing/radix-tree.d.ts +1 -0
  34. package/dist/core/routing/radix-tree.js +48 -10
  35. package/dist/core/routing/radix-tree.js.map +1 -1
  36. package/dist/core/routing/unified-router.d.ts +16 -0
  37. package/dist/core/routing/unified-router.js +39 -12
  38. package/dist/core/routing/unified-router.js.map +1 -1
  39. package/dist/core/utilities/hooks.d.ts +2 -0
  40. package/dist/core/utilities/hooks.js +12 -0
  41. package/dist/core/utilities/hooks.js.map +1 -1
  42. package/dist/moro.d.ts +4 -0
  43. package/dist/moro.js +94 -70
  44. package/dist/moro.js.map +1 -1
  45. package/dist/types/config.d.ts +7 -0
  46. package/package.json +1 -1
@@ -1,25 +1,889 @@
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 stable shape)
100
+ params = {};
101
+ body = null;
102
+ path = '';
103
+ // Lazy backing fields - single hidden-class shape, created at construction
104
+ _queryString = null;
105
+ _query = undefined;
106
+ _cookies = undefined;
107
+ _context = undefined;
108
+ _requestId = undefined;
109
+ _ip = undefined;
110
+ _originalUrl = undefined;
111
+ _hostname = undefined;
112
+ _protocol = undefined;
113
+ _secure = undefined;
114
+ _xhr = undefined;
115
+ _ips = undefined;
116
+ _subdomains = undefined;
117
+ get query() {
118
+ let q = this._query;
119
+ if (q === undefined) {
120
+ const qs = this._queryString;
121
+ q = qs ? parseQueryString(qs) : {};
122
+ this._query = q;
123
+ }
124
+ return q;
125
+ }
126
+ set query(value) {
127
+ this._query = value;
128
+ }
129
+ get cookies() {
130
+ let c = this._cookies;
131
+ if (c === undefined) {
132
+ const header = this.headers.cookie;
133
+ c = header ? parseCookieHeader(header) : {};
134
+ this._cookies = c;
135
+ }
136
+ return c;
137
+ }
138
+ set cookies(value) {
139
+ this._cookies = value;
140
+ }
141
+ get context() {
142
+ let ctx = this._context;
143
+ if (ctx === undefined) {
144
+ ctx = {};
145
+ this._context = ctx;
146
+ }
147
+ return ctx;
148
+ }
149
+ set context(value) {
150
+ this._context = value;
151
+ }
152
+ get requestId() {
153
+ let id = this._requestId;
154
+ if (id === undefined) {
155
+ const server = this._server;
156
+ id = server && server.requestTrackingEnabled ? randomUUID() : '';
157
+ this._requestId = id;
158
+ }
159
+ return id;
160
+ }
161
+ set requestId(value) {
162
+ this._requestId = value;
163
+ }
164
+ get ip() {
165
+ let v = this._ip;
166
+ if (v === undefined) {
167
+ v = (this.socket && this.socket.remoteAddress) || '';
168
+ this._ip = v;
169
+ }
170
+ return v;
171
+ }
172
+ set ip(value) {
173
+ this._ip = value;
174
+ }
175
+ get originalUrl() {
176
+ const v = this._originalUrl;
177
+ return v !== undefined ? v : this.url || '';
178
+ }
179
+ set originalUrl(value) {
180
+ this._originalUrl = value;
181
+ }
182
+ get hostname() {
183
+ let v = this._hostname;
184
+ if (v === undefined) {
185
+ const host = (this.headers.host || '');
186
+ v = host ? host.split(':')[0] : '';
187
+ this._hostname = v;
188
+ }
189
+ return v;
190
+ }
191
+ set hostname(value) {
192
+ this._hostname = value;
193
+ }
194
+ get protocol() {
195
+ let v = this._protocol;
196
+ if (v === undefined) {
197
+ const forwardedProto = this.headers['x-forwarded-proto'];
198
+ v = forwardedProto
199
+ ? forwardedProto.split(',')[0].trim()
200
+ : this.socket?.encrypted
201
+ ? 'https'
202
+ : 'http';
203
+ this._protocol = v;
204
+ }
205
+ return v;
206
+ }
207
+ set protocol(value) {
208
+ this._protocol = value;
209
+ }
210
+ get secure() {
211
+ const v = this._secure;
212
+ return v !== undefined ? v : this.protocol === 'https';
213
+ }
214
+ set secure(value) {
215
+ this._secure = value;
216
+ }
217
+ get xhr() {
218
+ let v = this._xhr;
219
+ if (v === undefined) {
220
+ const xrw = this.headers['x-requested-with'];
221
+ v = !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
222
+ this._xhr = v;
223
+ }
224
+ return v;
225
+ }
226
+ set xhr(value) {
227
+ this._xhr = value;
228
+ }
229
+ get ips() {
230
+ let v = this._ips;
231
+ if (v === undefined) {
232
+ const forwardedFor = this.headers['x-forwarded-for'];
233
+ v = forwardedFor
234
+ ? forwardedFor
235
+ .split(',')
236
+ .map(s => s.trim())
237
+ .filter(Boolean)
238
+ : [];
239
+ this._ips = v;
240
+ }
241
+ return v;
242
+ }
243
+ set ips(value) {
244
+ this._ips = value;
245
+ }
246
+ get subdomains() {
247
+ let v = this._subdomains;
248
+ if (v === undefined) {
249
+ const hostnameParts = this.hostname.split('.');
250
+ // Matches Express: subdomains are parts before the last two (domain + tld),
251
+ // reversed (closest subdomain first).
252
+ v = hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
253
+ this._subdomains = v;
254
+ }
255
+ return v;
256
+ }
257
+ set subdomains(value) {
258
+ this._subdomains = value;
259
+ }
260
+ // Express-compatible request helpers (shared prototype methods, not closures)
261
+ get(name) {
262
+ const lower = name.toLowerCase();
263
+ if (lower === 'referer' || lower === 'referrer') {
264
+ return (this.headers.referer || this.headers.referrer);
265
+ }
266
+ const v = this.headers[lower];
267
+ return Array.isArray(v) ? v[0] : v;
268
+ }
269
+ header(name) {
270
+ return this.get(name);
271
+ }
272
+ is(type) {
273
+ const ct = (this.headers['content-type'] || '');
274
+ if (!ct)
275
+ return false;
276
+ const mime = ct.split(';')[0].trim().toLowerCase();
277
+ const t = type.toLowerCase();
278
+ if (t.indexOf('/') === -1) {
279
+ // e.g. 'json' → match */json or +json suffix
280
+ return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
281
+ }
282
+ if (t.endsWith('/*')) {
283
+ return mime.startsWith(t.slice(0, -1));
284
+ }
285
+ return mime === t;
286
+ }
287
+ accepts(types) {
288
+ const accept = (this.headers.accept || '*/*');
289
+ if (!types)
290
+ return accept;
291
+ const wanted = Array.isArray(types) ? types : [types];
292
+ if (accept === '*/*' || accept === '')
293
+ return wanted[0] || false;
294
+ const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
295
+ for (const w of wanted) {
296
+ const wl = w.toLowerCase();
297
+ const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
298
+ for (const at of acceptTypes) {
299
+ if (at === '*/*')
300
+ return w;
301
+ if (at === wMime)
302
+ return w;
303
+ if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
304
+ return w;
305
+ }
306
+ }
307
+ return false;
308
+ }
309
+ acceptsLanguages(langs) {
310
+ const acceptLang = (this.headers['accept-language'] || '');
311
+ if (!langs)
312
+ return acceptLang || false;
313
+ const wanted = Array.isArray(langs) ? langs : [langs];
314
+ if (!acceptLang)
315
+ return wanted[0] || false;
316
+ // Parse with q values; sort by q desc to match Express's "best match wins"
317
+ const accepted = acceptLang
318
+ .split(',')
319
+ .map(s => {
320
+ const parts = s.trim().split(';');
321
+ const tag = parts[0].toLowerCase();
322
+ const qPart = parts.find(p => p.trim().startsWith('q='));
323
+ const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
324
+ return { tag, q: isNaN(q) ? 0 : q };
325
+ })
326
+ .filter(a => a.q > 0)
327
+ .sort((a, b) => b.q - a.q);
328
+ for (const { tag } of accepted) {
329
+ for (const w of wanted) {
330
+ const wl = w.toLowerCase();
331
+ if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
332
+ return w;
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+ }
338
+ export class MoroServerResponse extends ServerResponse {
339
+ _locals = undefined;
340
+ get locals() {
341
+ let l = this._locals;
342
+ if (l === undefined) {
343
+ l = {};
344
+ this._locals = l;
345
+ }
346
+ return l;
347
+ }
348
+ set locals(value) {
349
+ this._locals = value;
350
+ }
351
+ status(code) {
352
+ this.statusCode = code;
353
+ return this;
354
+ }
355
+ json(data) {
356
+ if (this.headersSent)
357
+ return;
358
+ const jsonString = JSON.stringify(data);
359
+ const server = this._server;
360
+ // Compression - EARLY EXIT if disabled or below threshold
361
+ if (server && server.compressionEnabled && jsonString.length > server.compressionThreshold) {
362
+ const acceptEncoding = this.req?.headers['accept-encoding'];
363
+ if (acceptEncoding && acceptEncoding.includes('gzip')) {
364
+ const buffer = Buffer.from(jsonString, 'utf8');
365
+ gzip(buffer)
366
+ .then(compressed => {
367
+ if (this.headersSent)
368
+ return;
369
+ this.writeHead(this.statusCode || 200, {
370
+ 'Content-Type': JSON_CONTENT_TYPE,
371
+ 'Content-Encoding': 'gzip',
372
+ Vary: 'Accept-Encoding',
373
+ 'Content-Length': compressed.length,
374
+ });
375
+ this.end(compressed);
376
+ })
377
+ .catch(err => {
378
+ server.logger.error('Response compression failed', 'HttpServer', {
379
+ error: err instanceof Error ? err.message : String(err),
380
+ });
381
+ if (!this.headersSent) {
382
+ this.writeHead(this.statusCode || 200, {
383
+ 'Content-Type': JSON_CONTENT_TYPE,
384
+ 'Content-Length': Buffer.byteLength(jsonString),
385
+ });
386
+ this.end(jsonString);
387
+ }
388
+ else {
389
+ this.end();
390
+ }
391
+ });
392
+ return;
393
+ }
394
+ else if (acceptEncoding && acceptEncoding.includes('deflate')) {
395
+ const buffer = Buffer.from(jsonString, 'utf8');
396
+ deflate(buffer)
397
+ .then(compressed => {
398
+ if (this.headersSent)
399
+ return;
400
+ this.writeHead(this.statusCode || 200, {
401
+ 'Content-Type': JSON_CONTENT_TYPE,
402
+ 'Content-Encoding': 'deflate',
403
+ Vary: 'Accept-Encoding',
404
+ 'Content-Length': compressed.length,
405
+ });
406
+ this.end(compressed);
407
+ })
408
+ .catch(err => {
409
+ server.logger.error('Response compression failed', 'HttpServer', {
410
+ error: err instanceof Error ? err.message : String(err),
411
+ });
412
+ if (!this.headersSent) {
413
+ this.writeHead(this.statusCode || 200, {
414
+ 'Content-Type': JSON_CONTENT_TYPE,
415
+ 'Content-Length': Buffer.byteLength(jsonString),
416
+ });
417
+ this.end(jsonString);
418
+ }
419
+ else {
420
+ this.end();
421
+ }
422
+ });
423
+ return;
424
+ }
425
+ }
426
+ // SYNC PATH - no compression: single serialize, single write, no Buffer copy
427
+ this.writeHead(this.statusCode || 200, {
428
+ 'Content-Type': JSON_CONTENT_TYPE,
429
+ 'Content-Length': Buffer.byteLength(jsonString),
430
+ });
431
+ this.end(jsonString);
432
+ }
433
+ send(data) {
434
+ if (this.headersSent)
435
+ return;
436
+ // Auto-detect content type if not already set
437
+ if (!this.getHeader('Content-Type')) {
438
+ if (typeof data === 'string') {
439
+ // Cheap JSON sniff: first non-whitespace char is '{', '[' or '"'
440
+ // (replaces a full JSON.parse of the body just to pick a content type)
441
+ let i = 0;
442
+ const len = data.length;
443
+ while (i < len) {
444
+ const c = data.charCodeAt(i);
445
+ if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
446
+ break;
447
+ i++;
448
+ }
449
+ const first = i < len ? data.charCodeAt(i) : 0;
450
+ if (first === 123 /* { */ || first === 91 /* [ */ || first === 34 /* " */) {
451
+ this.setHeader('Content-Type', JSON_CONTENT_TYPE);
452
+ }
453
+ else {
454
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
455
+ }
456
+ }
457
+ else {
458
+ this.setHeader('Content-Type', 'application/octet-stream');
459
+ }
460
+ }
461
+ this.end(data);
462
+ }
463
+ cookie(name, value, options = {}) {
464
+ if (this.headersSent) {
465
+ const isCritical = options.critical ||
466
+ name.includes('session') ||
467
+ name.includes('auth') ||
468
+ name.includes('csrf');
469
+ const message = `Cookie '${name}' could not be set - headers already sent`;
470
+ if (isCritical || options.throwOnLateSet) {
471
+ throw new Error(`${message}. This may cause authentication or security issues.`);
472
+ }
473
+ else {
474
+ this._server?.logger.warn(message, 'CookieWarning', {
475
+ cookieName: name,
476
+ critical: isCritical,
477
+ stackTrace: new Error().stack,
478
+ });
479
+ }
480
+ return this;
481
+ }
482
+ const cookieValue = encodeURIComponent(value);
483
+ let cookieString = `${name}=${cookieValue}`;
484
+ if (options.maxAge)
485
+ cookieString += `; Max-Age=${options.maxAge}`;
486
+ if (options.expires)
487
+ cookieString += `; Expires=${options.expires.toUTCString()}`;
488
+ if (options.httpOnly)
489
+ cookieString += '; HttpOnly';
490
+ if (options.secure)
491
+ cookieString += '; Secure';
492
+ if (options.sameSite)
493
+ cookieString += `; SameSite=${options.sameSite}`;
494
+ if (options.domain)
495
+ cookieString += `; Domain=${options.domain}`;
496
+ if (options.path)
497
+ cookieString += `; Path=${options.path}`;
498
+ const existingCookies = this.getHeader('Set-Cookie') || [];
499
+ // Avoid spread operator - direct array manipulation
500
+ const cookies = Array.isArray(existingCookies) ? existingCookies : [existingCookies];
501
+ cookies.push(cookieString);
502
+ this.setHeader('Set-Cookie', cookies);
503
+ return this;
504
+ }
505
+ clearCookie(name, options = {}) {
506
+ // Avoid spread operator - manually set properties
507
+ const clearOptions = {
508
+ expires: new Date(0),
509
+ maxAge: 0,
510
+ };
511
+ // Copy other options manually
512
+ if (options.path !== undefined)
513
+ clearOptions.path = options.path;
514
+ if (options.domain !== undefined)
515
+ clearOptions.domain = options.domain;
516
+ if (options.httpOnly !== undefined)
517
+ clearOptions.httpOnly = options.httpOnly;
518
+ if (options.secure !== undefined)
519
+ clearOptions.secure = options.secure;
520
+ if (options.sameSite !== undefined)
521
+ clearOptions.sameSite = options.sameSite;
522
+ return this.cookie(name, '', clearOptions);
523
+ }
524
+ redirect(url, status = 302) {
525
+ if (this.headersSent)
526
+ return;
527
+ this.statusCode = status;
528
+ const safeUrl = url.replace(/[\r\n]/g, '');
529
+ this.setHeader('Location', safeUrl);
530
+ this.end();
531
+ }
532
+ async sendFile(filePath) {
533
+ if (this.headersSent)
534
+ return;
535
+ try {
536
+ const fs = await import('fs/promises');
537
+ const path = await import('path');
538
+ const extension = path.extname(filePath);
539
+ const mime = await this._server.getMimeType(extension);
540
+ const stats = await fs.stat(filePath);
541
+ const data = await fs.readFile(filePath);
542
+ // Add charset for text-based files
543
+ const contentType = this._server.addCharsetIfNeeded(mime);
544
+ this.setHeader('Content-Type', contentType);
545
+ this.setHeader('Content-Length', stats.size);
546
+ // Add security headers for file downloads
547
+ this.setHeader('X-Content-Type-Options', 'nosniff');
548
+ // Add caching headers
549
+ this.setHeader('Last-Modified', stats.mtime.toUTCString());
550
+ this.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year for static files
551
+ this.end(data);
552
+ }
553
+ catch {
554
+ this.status(404).json({ success: false, error: 'File not found' });
555
+ }
556
+ }
557
+ // Standardized response helpers
558
+ success(data, message) {
559
+ const response = {
560
+ success: true,
561
+ data,
562
+ };
563
+ if (message !== undefined) {
564
+ response.message = message;
565
+ }
566
+ this.json(response);
567
+ }
568
+ error(error, code, message) {
569
+ const response = {
570
+ success: false,
571
+ error,
572
+ };
573
+ if (code !== undefined) {
574
+ response.code = code;
575
+ }
576
+ if (message !== undefined) {
577
+ response.message = message;
578
+ }
579
+ this.json(response);
580
+ }
581
+ // Common HTTP error helpers (automatically set status code)
582
+ unauthorized(message = 'Authentication required') {
583
+ this.statusCode = 401;
584
+ this.json({
585
+ success: false,
586
+ error: 'Unauthorized',
587
+ code: 'UNAUTHORIZED',
588
+ message,
589
+ });
590
+ }
591
+ forbidden(message = 'Insufficient permissions') {
592
+ this.statusCode = 403;
593
+ this.json({
594
+ success: false,
595
+ error: 'Forbidden',
596
+ code: 'FORBIDDEN',
597
+ message,
598
+ });
599
+ }
600
+ notFound(resource = 'Resource') {
601
+ this.statusCode = 404;
602
+ this.json({
603
+ success: false,
604
+ error: 'Not Found',
605
+ code: 'NOT_FOUND',
606
+ message: `${resource} not found`,
607
+ });
608
+ }
609
+ badRequest(message = 'Invalid request') {
610
+ this.statusCode = 400;
611
+ this.json({
612
+ success: false,
613
+ error: 'Bad Request',
614
+ code: 'BAD_REQUEST',
615
+ message,
616
+ });
617
+ }
618
+ conflict(message) {
619
+ this.statusCode = 409;
620
+ this.json({
621
+ success: false,
622
+ error: 'Conflict',
623
+ code: 'CONFLICT',
624
+ message,
625
+ });
626
+ }
627
+ internalError(message = 'Internal server error') {
628
+ this.statusCode = 500;
629
+ this.json({
630
+ success: false,
631
+ error: 'Internal Server Error',
632
+ code: 'INTERNAL_ERROR',
633
+ message,
634
+ });
635
+ }
636
+ validationError(errors) {
637
+ this.statusCode = 422;
638
+ this.json({
639
+ success: false,
640
+ error: 'Validation Failed',
641
+ code: 'VALIDATION_ERROR',
642
+ errors,
643
+ });
644
+ }
645
+ rateLimited(retryAfter) {
646
+ this.statusCode = 429;
647
+ if (retryAfter) {
648
+ this.setHeader('Retry-After', retryAfter.toString());
649
+ }
650
+ this.json({
651
+ success: false,
652
+ error: 'Rate Limit Exceeded',
653
+ code: 'RATE_LIMITED',
654
+ message: retryAfter
655
+ ? `Too many requests. Retry after ${retryAfter} seconds.`
656
+ : 'Too many requests',
657
+ retryAfter,
658
+ });
659
+ }
660
+ // Common success patterns
661
+ created(data, location) {
662
+ this.statusCode = 201;
663
+ if (location) {
664
+ this.setHeader('Location', location);
665
+ }
666
+ this.json({
667
+ success: true,
668
+ data,
669
+ });
670
+ }
671
+ noContent() {
672
+ this.statusCode = 204;
673
+ this.end();
674
+ }
675
+ paginated(data, pagination) {
676
+ const totalPages = Math.ceil(pagination.total / pagination.limit);
677
+ this.json({
678
+ success: true,
679
+ data,
680
+ pagination: {
681
+ page: pagination.page,
682
+ limit: pagination.limit,
683
+ total: pagination.total,
684
+ totalPages,
685
+ hasNext: pagination.page < totalPages,
686
+ hasPrev: pagination.page > 1,
687
+ },
688
+ });
689
+ }
690
+ // Header management utilities
691
+ hasHeader(name) {
692
+ return this.getHeader(name) !== undefined;
693
+ }
694
+ setBulkHeaders(headers) {
695
+ if (this.headersSent) {
696
+ // Only enumerate keys for warning if headers were already sent
697
+ const attemptedHeaderKeys = [];
698
+ for (const key in headers) {
699
+ attemptedHeaderKeys.push(key);
700
+ }
701
+ this._server?.logger.warn('Cannot set headers - headers already sent', 'HeaderWarning', {
702
+ attemptedHeaders: attemptedHeaderKeys,
703
+ });
704
+ return this;
705
+ }
706
+ for (const key in headers) {
707
+ this.setHeader(key, headers[key]);
708
+ }
709
+ return this;
710
+ }
711
+ appendHeader(name, value) {
712
+ if (this.headersSent) {
713
+ this._server?.logger.warn(`Cannot append to header '${name}' - headers already sent`, 'HeaderWarning');
714
+ return this;
715
+ }
716
+ const existing = this.getHeader(name);
717
+ if (existing) {
718
+ const values = Array.isArray(existing) ? existing : [existing.toString()];
719
+ const newValues = Array.isArray(value) ? value : [value];
720
+ this.setHeader(name, [...values, ...newValues]);
721
+ }
722
+ else {
723
+ this.setHeader(name, value);
724
+ }
725
+ return this;
726
+ }
727
+ // Express-compatible response helpers
728
+ set(field, value) {
729
+ if (this.headersSent)
730
+ return this;
731
+ if (typeof field === 'string') {
732
+ if (value !== undefined) {
733
+ this.setHeader(field, value);
734
+ }
735
+ }
736
+ else {
737
+ for (const key in field) {
738
+ this.setHeader(key, field[key]);
739
+ }
740
+ }
741
+ return this;
742
+ }
743
+ get(field) {
744
+ return this.getHeader(field);
745
+ }
746
+ append(field, value) {
747
+ return this.appendHeader(field, value);
748
+ }
749
+ type(contentType) {
750
+ if (this.headersSent)
751
+ return this;
752
+ // If no "/" treat as shorthand (e.g. "json" → "application/json")
753
+ let ct = contentType;
754
+ if (ct.indexOf('/') === -1) {
755
+ ct = TYPE_SHORTHANDS[ct.toLowerCase()] || `application/${ct}`;
756
+ }
757
+ this.setHeader('Content-Type', ct);
758
+ return this;
759
+ }
760
+ sendStatus(code) {
761
+ if (this.headersSent)
762
+ return;
763
+ this.statusCode = code;
764
+ const body = STATUS_MESSAGES[code] || String(code);
765
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
766
+ this.end(body);
767
+ }
768
+ location(url) {
769
+ if (this.headersSent)
770
+ return this;
771
+ const safe = url.replace(/[\r\n]/g, '');
772
+ this.setHeader('Location', safe);
773
+ return this;
774
+ }
775
+ vary(field) {
776
+ if (this.headersSent)
777
+ return this;
778
+ const existing = this.getHeader('Vary');
779
+ const existingList = existing
780
+ ? String(existing)
781
+ .split(',')
782
+ .map(s => s.trim())
783
+ .filter(Boolean)
784
+ : [];
785
+ const incoming = Array.isArray(field) ? field : [field];
786
+ for (const f of incoming) {
787
+ if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
788
+ existingList.push(f);
789
+ }
790
+ }
791
+ this.setHeader('Vary', existingList.join(', '));
792
+ return this;
793
+ }
794
+ attachment(filename) {
795
+ if (this.headersSent)
796
+ return this;
797
+ if (filename) {
798
+ const safeName = filename.replace(/[\r\n"]/g, '');
799
+ this.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
800
+ // Set Content-Type from extension if not already set
801
+ if (!this.getHeader('Content-Type')) {
802
+ // fire-and-forget mime lookup; not awaited to keep signature chainable
803
+ this._server.getMimeType(filename.substring(filename.lastIndexOf('.'))).then(mime => {
804
+ if (!this.getHeader('Content-Type') && !this.headersSent) {
805
+ this.setHeader('Content-Type', mime);
806
+ }
807
+ });
808
+ }
809
+ }
810
+ else {
811
+ this.setHeader('Content-Disposition', 'attachment');
812
+ }
813
+ return this;
814
+ }
815
+ async download(filePath, filename) {
816
+ this.attachment(filename || filePath.split('/').pop());
817
+ return this.sendFile(filePath);
818
+ }
819
+ format(handlers) {
820
+ const types = Object.keys(handlers).filter(k => k !== 'default');
821
+ const matched = this.req.accepts(types);
822
+ const chosen = matched ? handlers[matched] : handlers.default;
823
+ if (!chosen) {
824
+ this.statusCode = 406;
825
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
826
+ this.end('Not Acceptable');
827
+ return;
828
+ }
829
+ this.vary('Accept');
830
+ if (matched && typeof matched === 'string') {
831
+ this.type(matched);
832
+ }
833
+ chosen();
834
+ }
835
+ links(links) {
836
+ if (this.headersSent)
837
+ return this;
838
+ const parts = [];
839
+ for (const rel in links) {
840
+ parts.push(`<${links[rel]}>; rel="${rel}"`);
841
+ }
842
+ if (parts.length > 0) {
843
+ const existing = this.getHeader('Link');
844
+ const combined = existing ? `${existing}, ${parts.join(', ')}` : parts.join(', ');
845
+ this.setHeader('Link', combined);
846
+ }
847
+ return this;
848
+ }
849
+ // Response state utilities
850
+ canSetHeaders() {
851
+ return !this.headersSent;
852
+ }
853
+ getResponseState() {
854
+ return {
855
+ headersSent: this.headersSent,
856
+ statusCode: this.statusCode,
857
+ headers: this.getHeaders ? this.getHeaders() : {},
858
+ finished: this.finished || false,
859
+ writable: this.writable,
860
+ };
861
+ }
862
+ }
10
863
  export class MoroHttpServer {
11
864
  server;
12
865
  routes = [];
13
866
  globalMiddleware = [];
867
+ /** @internal read by MoroServerResponse prototype methods */
14
868
  compressionEnabled = true;
869
+ /** @internal read by MoroServerResponse prototype methods */
15
870
  compressionThreshold = 1024;
871
+ /** @internal read by MoroIncomingMessage prototype methods */
16
872
  requestTrackingEnabled = true; // Generate request IDs
873
+ /** @internal read by MoroServerResponse prototype methods */
17
874
  logger = createFrameworkLogger('HttpServer');
18
875
  hookManager;
19
876
  requestCounter = 0;
20
877
  errorHandler;
21
878
  requestDecorations = {};
22
879
  responseDecorations = {};
880
+ // Per-server request/response subclasses; their prototype carries the
881
+ // `_server` back-reference plus user decorations (applied once, not per request)
882
+ RequestClass;
883
+ ResponseClass;
884
+ // Direct router dispatch slot - runs after global middleware without the
885
+ // per-middleware promise machinery (set from Moro.listen via setRouterHandler)
886
+ routerHandler;
23
887
  // Body size limits - defaults overridden by config in constructor
24
888
  maxBodySize = 10 * 1024 * 1024; // Default 10MB (overridden by server.bodySizeLimit config)
25
889
  maxUploadSize = 100 * 1024 * 1024; // Default 100MB for file uploads (future: configurable)
@@ -43,7 +907,19 @@ export class MoroHttpServer {
43
907
  rateLimited: Buffer.from('{"success":false,"error":"Rate limit exceeded"}'),
44
908
  };
45
909
  constructor(options) {
46
- this.server = createServer(this.handleRequest.bind(this));
910
+ // Per-instance subclasses so prototype methods can reach this server's
911
+ // config (logger, compression) with zero per-request allocation.
912
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
913
+ const boundServer = this;
914
+ class BoundRequest extends MoroIncomingMessage {
915
+ }
916
+ class BoundResponse extends MoroServerResponse {
917
+ }
918
+ BoundRequest.prototype._server = boundServer;
919
+ BoundResponse.prototype._server = boundServer;
920
+ this.RequestClass = BoundRequest;
921
+ this.ResponseClass = BoundResponse;
922
+ this.server = createServer({ IncomingMessage: BoundRequest, ServerResponse: BoundResponse }, this.handleRequest.bind(this));
47
923
  // Configure body size limits from options
48
924
  if (options?.maxBodySize) {
49
925
  this.maxBodySize = options.maxBodySize;
@@ -56,6 +932,11 @@ export class MoroHttpServer {
56
932
  this.server.headersTimeout = 6000; // 6 seconds
57
933
  this.server.timeout = 30000; // 30 seconds request timeout
58
934
  }
935
+ // Direct router dispatch - called after global middleware, before the legacy
936
+ // route table. Returning true (or a promise of true) means the request was handled.
937
+ setRouterHandler(fn) {
938
+ this.routerHandler = fn;
939
+ }
59
940
  // Configure server for maximum performance (can disable all overhead)
60
941
  configurePerformance(config = {}) {
61
942
  if (config.compression !== undefined) {
@@ -78,12 +959,30 @@ export class MoroHttpServer {
78
959
  setErrorHandler(fn) {
79
960
  this.errorHandler = fn;
80
961
  }
81
- // Apply per-request/response decoration maps (called from Moro.decorateRequest/Reply)
962
+ // Apply request/response decoration maps (called from Moro.decorateRequest/Reply).
963
+ // Decorations are installed on the per-server prototypes once, so requests pay
964
+ // nothing for them; per-request writes to the same key simply shadow the prototype.
82
965
  setRequestDecorations(decorations) {
83
966
  this.requestDecorations = decorations;
967
+ for (const key in decorations) {
968
+ Object.defineProperty(this.RequestClass.prototype, key, {
969
+ value: decorations[key],
970
+ writable: true,
971
+ enumerable: true,
972
+ configurable: true,
973
+ });
974
+ }
84
975
  }
85
976
  setResponseDecorations(decorations) {
86
977
  this.responseDecorations = decorations;
978
+ for (const key in decorations) {
979
+ Object.defineProperty(this.ResponseClass.prototype, key, {
980
+ value: decorations[key],
981
+ writable: true,
982
+ enumerable: true,
983
+ configurable: true,
984
+ });
985
+ }
87
986
  }
88
987
  // Middleware management
89
988
  use(middleware) {
@@ -158,48 +1057,90 @@ export class MoroHttpServer {
158
1057
  };
159
1058
  }
160
1059
  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;
1060
+ // req/res arrive as the per-server subclasses (MoroIncomingMessage /
1061
+ // MoroServerResponse) - all helpers are already on their prototypes.
1062
+ const httpReq = req;
1063
+ const httpRes = res;
165
1064
  try {
166
- // Optimized URL and query parsing with object pooling
1065
+ // URL split - query string parsing is lazy (MoroIncomingMessage#query getter)
167
1066
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
168
1067
  const urlString = req.url;
169
1068
  const queryIndex = urlString.indexOf('?');
170
1069
  if (queryIndex === -1) {
171
- // No query string
172
1070
  httpReq.path = urlString;
173
- httpReq.query = {};
174
1071
  }
175
1072
  else {
176
- // Has query string - parse efficiently with pooled object
177
1073
  httpReq.path = urlString.substring(0, queryIndex);
178
- httpReq.query = this.parseQueryStringPooled(urlString.substring(queryIndex + 1));
1074
+ httpReq._queryString = urlString.substring(queryIndex + 1);
179
1075
  }
180
- // Method checking - use reference equality for interned strings (50-100% faster)
1076
+ // Intern method string for fast reference equality comparison (50-100% faster)
1077
+ switch (req.method) {
1078
+ case 'POST':
1079
+ httpReq.method = MoroHttpServer.METHOD_POST;
1080
+ break;
1081
+ case 'PUT':
1082
+ httpReq.method = MoroHttpServer.METHOD_PUT;
1083
+ break;
1084
+ case 'PATCH':
1085
+ httpReq.method = MoroHttpServer.METHOD_PATCH;
1086
+ break;
1087
+ case 'GET':
1088
+ httpReq.method = MoroHttpServer.METHOD_GET;
1089
+ break;
1090
+ case 'DELETE':
1091
+ httpReq.method = MoroHttpServer.METHOD_DELETE;
1092
+ break;
1093
+ case 'HEAD':
1094
+ httpReq.method = MoroHttpServer.METHOD_HEAD;
1095
+ break;
1096
+ case 'OPTIONS':
1097
+ httpReq.method = MoroHttpServer.METHOD_OPTIONS;
1098
+ break;
1099
+ }
1100
+ // Method checking - use reference equality for interned strings
181
1101
  if (httpReq.method === MoroHttpServer.METHOD_POST ||
182
1102
  httpReq.method === MoroHttpServer.METHOD_PUT ||
183
1103
  httpReq.method === MoroHttpServer.METHOD_PATCH) {
184
1104
  httpReq.body = await this.parseBody(req);
185
1105
  }
186
- // Execute hooks before request processing - NOOP if no hookManager
187
- if (this.hookManager) {
188
- await this.hookManager.execute('request', {
1106
+ // Execute hooks before request processing - skipped entirely (no context
1107
+ // object, no promise) when no 'request' hooks are registered
1108
+ const hookManager = this.hookManager;
1109
+ if (hookManager && (hookManager.hasHooks === undefined || hookManager.hasHooks('request'))) {
1110
+ await hookManager.execute('request', {
189
1111
  request: httpReq,
190
1112
  response: httpRes,
191
1113
  });
192
1114
  }
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);
1115
+ // Execute global middleware first - EARLY EXIT if none registered.
1116
+ // executeMiddleware returns undefined when the whole chain completed
1117
+ // synchronously, so a sync chain costs zero promise allocations.
1118
+ if (this.globalMiddleware.length > 0) {
1119
+ const mwResult = this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
1120
+ if (mwResult)
1121
+ await mwResult;
197
1122
  }
198
1123
  // If middleware handled the request, don't continue
199
1124
  if (httpRes.headersSent) {
200
1125
  return;
201
1126
  }
202
- // Find matching route
1127
+ // Unified router direct dispatch - no middleware-chain promise wrapper
1128
+ const routerHandler = this.routerHandler;
1129
+ if (routerHandler) {
1130
+ const handled = routerHandler(httpReq, httpRes);
1131
+ if (handled) {
1132
+ if (typeof handled.then === 'function') {
1133
+ if (await handled)
1134
+ return;
1135
+ }
1136
+ else {
1137
+ return;
1138
+ }
1139
+ }
1140
+ if (httpRes.headersSent)
1141
+ return;
1142
+ }
1143
+ // Find matching route (legacy direct-route table)
203
1144
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
204
1145
  const route = this.findRoute(req.method, httpReq.path);
205
1146
  if (!route) {
@@ -210,21 +1151,22 @@ export class MoroHttpServer {
210
1151
  httpRes.end(MoroHttpServer.RESPONSE_TEMPLATES.notFound);
211
1152
  return;
212
1153
  }
213
- // Extract path parameters - optimized with object pooling
1154
+ // Extract path parameters
214
1155
  const matches = httpReq.path.match(route.pattern);
215
1156
  if (matches) {
216
- // Use pooled object for parameters
217
- httpReq.params = this.acquireParamObject();
1157
+ const params = {};
218
1158
  const paramNames = route.paramNames;
219
1159
  const paramNamesLen = paramNames.length;
220
1160
  for (let i = 0; i < paramNamesLen; i++) {
221
- httpReq.params[paramNames[i]] = matches[i + 1];
1161
+ params[paramNames[i]] = matches[i + 1];
222
1162
  }
1163
+ httpReq.params = params;
223
1164
  }
224
1165
  // 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);
1166
+ if (route.middleware.length > 0) {
1167
+ const mwResult = this.executeMiddleware(route.middleware, httpReq, httpRes);
1168
+ if (mwResult)
1169
+ await mwResult;
228
1170
  }
229
1171
  // Execute handler - Don't await sync handlers
230
1172
  const handlerResult = route.handler(httpReq, httpRes);
@@ -241,6 +1183,16 @@ export class MoroHttpServer {
241
1183
  requestPath: req.url,
242
1184
  requestMethod: req.method,
243
1185
  });
1186
+ // Payload-too-large from parseBody: respond 413 (matches the old bodySize
1187
+ // middleware semantics, now enforced in-server)
1188
+ if (error?.statusCode === 413 && !httpRes.headersSent) {
1189
+ httpRes.statusCode = 413;
1190
+ httpRes.json({
1191
+ success: false,
1192
+ error: 'Request entity too large',
1193
+ });
1194
+ return;
1195
+ }
244
1196
  this.logger.error('Request error', 'RequestHandler', {
245
1197
  error: error instanceof Error ? error.message : String(error),
246
1198
  requestId: httpReq.requestId,
@@ -298,66 +1250,6 @@ export class MoroHttpServer {
298
1250
  }
299
1251
  }
300
1252
  }
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
- }
327
- }
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
1253
  }
362
1254
  // Force cleanup of all pooled objects
363
1255
  forceCleanupPools() {
@@ -429,697 +1321,7 @@ export class MoroHttpServer {
429
1321
  }
430
1322
  return normalized;
431
1323
  }
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
- }
1324
+ /** @internal called by MoroServerResponse prototype methods */
1123
1325
  async getMimeType(ext) {
1124
1326
  const mimeTypes = {
1125
1327
  '.html': 'text/html',
@@ -1138,6 +1340,7 @@ export class MoroHttpServer {
1138
1340
  };
1139
1341
  return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
1140
1342
  }
1343
+ /** @internal called by MoroServerResponse prototype methods */
1141
1344
  addCharsetIfNeeded(mimeType) {
1142
1345
  // Add charset for text-based content types - optimized with early exit
1143
1346
  // Check most common cases first
@@ -1161,6 +1364,14 @@ export class MoroHttpServer {
1161
1364
  const maxSize = isMultipart
1162
1365
  ? this.maxUploadSize // Configurable file upload limit (default 100MB)
1163
1366
  : this.maxBodySize; // Configurable body size limit (default 10MB, from server.bodySizeLimit config)
1367
+ // Early rejection from the declared Content-Length - nothing is read off the wire
1368
+ if (contentLength > maxSize) {
1369
+ const error = new Error('Request entity too large');
1370
+ error.statusCode = 413;
1371
+ error.limit = maxSize;
1372
+ error.received = contentLength;
1373
+ return Promise.reject(error);
1374
+ }
1164
1375
  // For very large payloads, return a streaming interface instead of buffering
1165
1376
  if (contentLength > maxSize / 2) {
1166
1377
  // Stream for payloads > 50MB (uploads) or > 5MB (JSON/form)
@@ -1176,14 +1387,19 @@ export class MoroHttpServer {
1176
1387
  const errorMessage = isMultipart
1177
1388
  ? `File upload too large. Maximum ${Math.floor(maxSize / (1024 * 1024))}MB allowed.`
1178
1389
  : 'Request body too large';
1179
- reject(new Error(errorMessage));
1390
+ const error = new Error(errorMessage);
1391
+ error.statusCode = 413;
1392
+ error.limit = maxSize;
1393
+ reject(error);
1180
1394
  return;
1181
1395
  }
1182
1396
  chunks.push(chunk);
1183
1397
  });
1184
1398
  req.on('end', () => {
1185
1399
  try {
1186
- const body = Buffer.concat(chunks);
1400
+ // Single-chunk fast path (most JSON bodies arrive in one chunk);
1401
+ // otherwise concat with the known total length to avoid a re-scan
1402
+ const body = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, totalLength);
1187
1403
  if (contentType.includes('application/json')) {
1188
1404
  resolve(JSON.parse(body.toString()));
1189
1405
  }
@@ -1410,30 +1626,14 @@ export class MoroHttpServer {
1410
1626
  }
1411
1627
  return result;
1412
1628
  }
1413
- // Legacy method for backward compatibility
1629
+ // Legacy methods for backward compatibility - the hot path now parses query
1630
+ // strings lazily via the MoroIncomingMessage#query getter (no pooling: a fresh
1631
+ // object literal is cheaper than pool bookkeeping)
1414
1632
  parseQueryString(queryString) {
1415
- return this.parseQueryStringPooled(queryString);
1633
+ return parseQueryString(queryString);
1416
1634
  }
1417
- // Optimized query string parser with object pooling
1418
1635
  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;
1636
+ return parseQueryString(queryString);
1437
1637
  }
1438
1638
  // Advanced route optimization: cache + static routes + segment grouping
1439
1639
  routeCache = new Map();
@@ -1498,13 +1698,23 @@ export class MoroHttpServer {
1498
1698
  // Middleware execution with Express-compatible error propagation.
1499
1699
  // Supports: 3-arg (req, res, next), 4-arg (err, req, res, next) error middlewares,
1500
1700
  // and next(err) to skip forward to the next error middleware.
1501
- async executeMiddleware(middleware, req, res) {
1701
+ //
1702
+ // Sync-aware dispatch: middleware that calls next() synchronously (or throws
1703
+ // synchronously) advances the chain in a plain loop with ZERO promise
1704
+ // allocations. A promise is only created when a middleware actually completes
1705
+ // asynchronously. Returns undefined when the whole chain ran synchronously.
1706
+ executeMiddleware(middleware, req, res) {
1707
+ return this.dispatchMiddleware(middleware, req, res, 0, undefined);
1708
+ }
1709
+ dispatchMiddleware(middleware, req, res, startIndex, initialError) {
1502
1710
  const len = middleware.length;
1503
- let activeError = undefined;
1504
- for (let i = 0; i < len; i++) {
1711
+ let activeError = initialError;
1712
+ let i = startIndex;
1713
+ while (i < len) {
1505
1714
  if (res.headersSent)
1506
1715
  return;
1507
1716
  const mw = middleware[i];
1717
+ i++;
1508
1718
  const isErrorHandler = mw.length >= 4;
1509
1719
  // Non-error middleware is skipped while an error is active; error middleware
1510
1720
  // is skipped while no error is active. Matches Express semantics.
@@ -1512,36 +1722,60 @@ export class MoroHttpServer {
1512
1722
  continue;
1513
1723
  if (activeError === undefined && isErrorHandler)
1514
1724
  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
- }
1725
+ let settled = false;
1726
+ let settledError = undefined;
1727
+ let asyncResolve;
1728
+ const next = (err) => {
1729
+ if (settled)
1730
+ return;
1731
+ settled = true;
1732
+ settledError = err;
1733
+ if (asyncResolve)
1734
+ asyncResolve(err);
1735
+ };
1736
+ let result;
1737
+ try {
1738
+ result = isErrorHandler ? mw(activeError, req, res, next) : mw(req, res, next);
1739
+ }
1740
+ catch (err) {
1741
+ if (!settled) {
1742
+ settled = true;
1743
+ settledError = err;
1539
1744
  }
1540
- catch (err) {
1541
- if (!resolved)
1542
- next(err);
1745
+ }
1746
+ const isThenable = result && typeof result.then === 'function';
1747
+ if (settled) {
1748
+ // Completed synchronously (next() called sync, or threw sync).
1749
+ // Matches the old behavior: a still-pending returned promise no longer
1750
+ // gates the chain once next() has been called; swallow late rejections.
1751
+ if (isThenable) {
1752
+ result.then(undefined, () => { });
1543
1753
  }
1544
- });
1754
+ activeError = settledError;
1755
+ continue;
1756
+ }
1757
+ if (!isThenable) {
1758
+ // Sync-looking middleware that neither called next() nor returned a
1759
+ // promise: it will call next() later (e.g. from an event callback).
1760
+ // Fall back to a promise for the remainder of the chain.
1761
+ const resumeIndex = i;
1762
+ return new Promise(resolve => {
1763
+ asyncResolve = resolve;
1764
+ }).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
1765
+ }
1766
+ // Async middleware: completion is next() OR promise settle, whichever
1767
+ // happens first (same semantics as the previous implementation).
1768
+ const resumeIndex = i;
1769
+ return new Promise(resolve => {
1770
+ asyncResolve = resolve;
1771
+ result.then(() => {
1772
+ if (!settled)
1773
+ next();
1774
+ }, (err) => {
1775
+ if (!settled)
1776
+ next(err);
1777
+ });
1778
+ }).then(err => this.dispatchMiddleware(middleware, req, res, resumeIndex, err));
1545
1779
  }
1546
1780
  // If an error is still unhandled after the chain, re-throw so the top-level
1547
1781
  // catch in handleRequest can invoke the registered errorHandler / default 500.