@morojs/moro 1.7.29 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +89 -37
  2. package/dist/core/config/config-sources.js +114 -2
  3. package/dist/core/config/config-sources.js.map +1 -1
  4. package/dist/core/config/config-validator.js +187 -5
  5. package/dist/core/config/config-validator.js.map +1 -1
  6. package/dist/core/config/schema.js +31 -3
  7. package/dist/core/config/schema.js.map +1 -1
  8. package/dist/core/framework.d.ts +41 -2
  9. package/dist/core/framework.js +338 -73
  10. package/dist/core/framework.js.map +1 -1
  11. package/dist/core/http/http-server.d.ts +21 -22
  12. package/dist/core/http/http-server.js +268 -226
  13. package/dist/core/http/http-server.js.map +1 -1
  14. package/dist/core/http/http2-server.d.ts +6 -16
  15. package/dist/core/http/http2-server.js +14 -79
  16. package/dist/core/http/http2-server.js.map +1 -1
  17. package/dist/core/http/index.d.ts +1 -0
  18. package/dist/core/http/index.js +3 -0
  19. package/dist/core/http/index.js.map +1 -1
  20. package/dist/core/http/moro-engine-server.d.ts +263 -0
  21. package/dist/core/http/moro-engine-server.js +1939 -0
  22. package/dist/core/http/moro-engine-server.js.map +1 -0
  23. package/dist/core/http/utils/compression.d.ts +47 -0
  24. package/dist/core/http/utils/compression.js +132 -0
  25. package/dist/core/http/utils/compression.js.map +1 -0
  26. package/dist/core/http/utils/lazy-event-emitter.d.ts +11 -0
  27. package/dist/core/http/utils/lazy-event-emitter.js +69 -0
  28. package/dist/core/http/utils/lazy-event-emitter.js.map +1 -0
  29. package/dist/core/http/utils/multipart-parser.d.ts +32 -0
  30. package/dist/core/http/utils/multipart-parser.js +211 -0
  31. package/dist/core/http/utils/multipart-parser.js.map +1 -0
  32. package/dist/core/http/utils/query-parser.d.ts +1 -0
  33. package/dist/core/http/utils/query-parser.js +44 -0
  34. package/dist/core/http/utils/query-parser.js.map +1 -0
  35. package/dist/core/http/utils/size.d.ts +33 -0
  36. package/dist/core/http/utils/size.js +37 -0
  37. package/dist/core/http/utils/size.js.map +1 -0
  38. package/dist/core/http/utils/ssl-config.d.ts +69 -0
  39. package/dist/core/http/utils/ssl-config.js +116 -0
  40. package/dist/core/http/utils/ssl-config.js.map +1 -0
  41. package/dist/core/http/utils/uws-worker-clustering.js +8 -2
  42. package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
  43. package/dist/core/http/uws-http-server.d.ts +24 -3
  44. package/dist/core/http/uws-http-server.js +386 -65
  45. package/dist/core/http/uws-http-server.js.map +1 -1
  46. package/dist/core/middleware/built-in/compression/core.d.ts +3 -0
  47. package/dist/core/middleware/built-in/compression/core.js +28 -34
  48. package/dist/core/middleware/built-in/compression/core.js.map +1 -1
  49. package/dist/core/networking/adapters/engine-adapter.d.ts +23 -0
  50. package/dist/core/networking/adapters/engine-adapter.js +365 -0
  51. package/dist/core/networking/adapters/engine-adapter.js.map +1 -0
  52. package/dist/core/networking/adapters/index.d.ts +1 -0
  53. package/dist/core/networking/adapters/index.js +1 -0
  54. package/dist/core/networking/adapters/index.js.map +1 -1
  55. package/dist/core/networking/adapters/uws-adapter.js +26 -15
  56. package/dist/core/networking/adapters/uws-adapter.js.map +1 -1
  57. package/dist/core/utilities/package-utils.d.ts +68 -0
  58. package/dist/core/utilities/package-utils.js +198 -1
  59. package/dist/core/utilities/package-utils.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/moro.d.ts +2 -0
  64. package/dist/moro.js +63 -21
  65. package/dist/moro.js.map +1 -1
  66. package/dist/types/config.d.ts +90 -5
  67. package/package.json +4 -1
@@ -0,0 +1,1939 @@
1
+ // Moro Engine HTTP Server Implementation (@morojs/engine)
2
+ //
3
+ // One of the three HTTP backends, kept deliberately separate from the others:
4
+ // - MoroHttpServer (http-server.ts) -> Node's http module
5
+ // - UWebSocketsHttpServer (uws-http-server.ts) -> uWebSockets.js (opt-in)
6
+ // - MoroEngineServer (this file) -> @morojs/engine, Moro's own
7
+ // from-scratch native engine
8
+ //
9
+ // Bridges the Moro-shaped native engine API (one JS crossing per request in,
10
+ // one out - see the engine repo's docs/API.md) with Moro's HTTP abstractions.
11
+ // Unlike the uWS adapter there is no materialize() hazard: every per-request
12
+ // fetcher (getHeaders/getQuery/getBody/...) returns a stable snapshot, and the
13
+ // body is already complete when onRequest fires. Keep-alive, Content-Length,
14
+ // Date, chunked encoding, HEAD body suppression and native 413/400 are all
15
+ // engine concerns - this adapter never sees them. No dependency on the uWS
16
+ // adapter: shared helpers live in ./utils.
17
+ import { randomUUID } from 'crypto';
18
+ import { createFrameworkLogger } from '../logger/index.js';
19
+ import { loadNativeEngine, getNativeEngineLoadErrors, } from '../utilities/package-utils.js';
20
+ import { parseMultipart as parseMultipartBuffer, } from './utils/multipart-parser.js';
21
+ import { resolveCompressionSettings, compressBuffer, isCompressible, negotiateEncoding, } from './utils/compression.js';
22
+ import { LazyEventEmitter } from './utils/lazy-event-emitter.js';
23
+ import { parseRawQueryString as parseUwsQueryString } from './utils/query-parser.js';
24
+ import { PathMatcher } from '../routing/path-matcher.js';
25
+ /** Engine method table - onRequest's methodIdx indexes into this */
26
+ const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'OTHER'];
27
+ // Pre-cached status strings for common codes (sendStatus bodies; the engine
28
+ // derives the wire reason phrase from the numeric status itself)
29
+ const STATUS_STRINGS = new Map([
30
+ [200, '200 OK'],
31
+ [201, '201 Created'],
32
+ [204, '204 No Content'],
33
+ [301, '301 Moved Permanently'],
34
+ [302, '302 Found'],
35
+ [304, '304 Not Modified'],
36
+ [400, '400 Bad Request'],
37
+ [401, '401 Unauthorized'],
38
+ [403, '403 Forbidden'],
39
+ [404, '404 Not Found'],
40
+ [500, '500 Internal Server Error'],
41
+ [502, '502 Bad Gateway'],
42
+ [503, '503 Service Unavailable'],
43
+ ]);
44
+ // Same table as MoroHttpServer.getMimeType - kept sync here so attachment()
45
+ // stays chainable without a fire-and-forget promise
46
+ const MIME_TYPES = {
47
+ '.html': 'text/html',
48
+ '.css': 'text/css',
49
+ '.js': 'application/javascript',
50
+ '.json': 'application/json',
51
+ '.png': 'image/png',
52
+ '.jpg': 'image/jpeg',
53
+ '.jpeg': 'image/jpeg',
54
+ '.gif': 'image/gif',
55
+ '.svg': 'image/svg+xml',
56
+ '.ico': 'image/x-icon',
57
+ '.pdf': 'application/pdf',
58
+ '.txt': 'text/plain',
59
+ '.xml': 'application/xml',
60
+ };
61
+ function mimeTypeFor(ext) {
62
+ return MIME_TYPES[ext.toLowerCase()] || 'application/octet-stream';
63
+ }
64
+ function addCharsetIfNeeded(mimeType) {
65
+ if (mimeType.startsWith('text/') ||
66
+ mimeType.startsWith('application/json') ||
67
+ mimeType.startsWith('application/javascript') ||
68
+ mimeType.startsWith('application/xml') ||
69
+ mimeType.startsWith('image/svg+xml')) {
70
+ if (!mimeType.includes('charset')) {
71
+ return `${mimeType}; charset=utf-8`;
72
+ }
73
+ }
74
+ return mimeType;
75
+ }
76
+ // Files up to this size are sent with a single respond(); larger ones stream
77
+ // through writeHead/write/end with backpressure respected via 'drain'
78
+ const SENDFILE_BUFFER_LIMIT = 1024 * 1024;
79
+ /**
80
+ * Prototype-based request object for the engine adapter.
81
+ *
82
+ * Headers/query/cookies/ip/requestId are lazy: a synchronous handler that
83
+ * never reads them pays nothing (no native crossing, no UUID). Every fetcher
84
+ * returns a stable snapshot and reqIds are safe no-ops after the response
85
+ * terminates, so - unlike the uWS adapter - there is no validity window.
86
+ */
87
+ export class EngineRequest extends LazyEventEmitter {
88
+ method;
89
+ path;
90
+ params = {};
91
+ body = null;
92
+ _server;
93
+ _reqId;
94
+ _url = undefined;
95
+ _queryString = undefined;
96
+ _query = undefined;
97
+ _headers = undefined;
98
+ _cookies = undefined;
99
+ _context = undefined;
100
+ _requestId = undefined;
101
+ _ip = undefined;
102
+ _socket = undefined;
103
+ constructor(server, reqId, method, path) {
104
+ super();
105
+ this._server = server;
106
+ this._reqId = reqId;
107
+ this.method = method;
108
+ this.path = path;
109
+ }
110
+ /** Engine snapshots are stable - nothing to detach. No-op kept for
111
+ * interface parity with UwsRequest so shared dispatch code can call it. */
112
+ materialize() { }
113
+ _rawQueryString() {
114
+ let qs = this._queryString;
115
+ if (qs === undefined) {
116
+ // Safe no-op (undefined) after the response terminates - coerce to ''
117
+ qs = this._server._engine.getQuery(this._reqId) ?? '';
118
+ this._queryString = qs;
119
+ }
120
+ return qs;
121
+ }
122
+ get url() {
123
+ let u = this._url;
124
+ if (u === undefined) {
125
+ const qs = this._rawQueryString();
126
+ u = qs ? `${this.path}?${qs}` : this.path;
127
+ this._url = u;
128
+ }
129
+ return u;
130
+ }
131
+ set url(value) {
132
+ this._url = value;
133
+ }
134
+ get query() {
135
+ let q = this._query;
136
+ if (q === undefined) {
137
+ const qs = this._rawQueryString();
138
+ q = qs ? parseUwsQueryString(qs) : {};
139
+ this._query = q;
140
+ }
141
+ return q;
142
+ }
143
+ set query(value) {
144
+ this._query = value;
145
+ }
146
+ get headers() {
147
+ let h = this._headers;
148
+ if (h === undefined) {
149
+ h = {};
150
+ // One crossing: flat [k1,v1,k2,v2,...] with keys already lowercased.
151
+ // Duplicate header lines are joined like Node's IncomingMessage ('; '
152
+ // for cookie, ', ' otherwise) - last-wins would let a client hide
153
+ // earlier X-Forwarded-For entries from IP-based middleware.
154
+ const flat = this._server._engine.getHeaders(this._reqId);
155
+ if (flat) {
156
+ for (let i = 0; i + 1 < flat.length; i += 2) {
157
+ const key = flat[i];
158
+ const existing = h[key];
159
+ if (existing === undefined) {
160
+ h[key] = flat[i + 1];
161
+ }
162
+ else {
163
+ h[key] = existing + (key === 'cookie' ? '; ' : ', ') + flat[i + 1];
164
+ }
165
+ }
166
+ }
167
+ this._headers = h;
168
+ }
169
+ return h;
170
+ }
171
+ set headers(value) {
172
+ this._headers = value;
173
+ }
174
+ get cookies() {
175
+ let c = this._cookies;
176
+ if (c === undefined) {
177
+ c = {};
178
+ const header = this.headers.cookie;
179
+ if (header) {
180
+ const parts = header.split(';');
181
+ for (let i = 0; i < parts.length; i++) {
182
+ const part = parts[i];
183
+ const eq = part.indexOf('=');
184
+ if (eq > 0) {
185
+ const name = part.substring(0, eq).trim();
186
+ const value = part.substring(eq + 1);
187
+ if (name && value) {
188
+ try {
189
+ c[name] = decodeURIComponent(value);
190
+ }
191
+ catch {
192
+ c[name] = value;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ this._cookies = c;
199
+ }
200
+ return c;
201
+ }
202
+ set cookies(value) {
203
+ this._cookies = value;
204
+ }
205
+ get context() {
206
+ let ctx = this._context;
207
+ if (ctx === undefined) {
208
+ ctx = {};
209
+ this._context = ctx;
210
+ }
211
+ return ctx;
212
+ }
213
+ set context(value) {
214
+ this._context = value;
215
+ }
216
+ get requestId() {
217
+ let id = this._requestId;
218
+ if (id === undefined) {
219
+ const server = this._server;
220
+ id = server && server.requestTrackingEnabled ? randomUUID() : '';
221
+ this._requestId = id;
222
+ }
223
+ return id;
224
+ }
225
+ set requestId(value) {
226
+ this._requestId = value;
227
+ }
228
+ get ip() {
229
+ let ip = this._ip;
230
+ if (ip === undefined) {
231
+ ip = this._server._engine.getRemoteAddress(this._reqId) ?? '';
232
+ this._ip = ip;
233
+ }
234
+ return ip;
235
+ }
236
+ set ip(value) {
237
+ this._ip = value;
238
+ }
239
+ // Minimal net.Socket-shaped shim. `encrypted` reflects whether the engine
240
+ // is terminating TLS for this server (engine >= 1.2.0); when it isn't and
241
+ // TLS is proxy-terminated, middleware should read `x-forwarded-proto`. This
242
+ // exists so code written against Node's IncomingMessage (req.socket.encrypted,
243
+ // the CSRF middleware; req.connection.remoteAddress, rate-limiters) doesn't
244
+ // throw on the engine transport. `connection` is the legacy alias for `socket`.
245
+ get socket() {
246
+ let s = this._socket;
247
+ if (s === undefined) {
248
+ const getIp = () => this.ip; // arrow captures this lexically (no this-alias)
249
+ const encrypted = Boolean(this._server?.isSsl);
250
+ s = {
251
+ get remoteAddress() {
252
+ return getIp() || undefined;
253
+ },
254
+ remotePort: undefined,
255
+ encrypted,
256
+ destroyed: false,
257
+ };
258
+ this._socket = s;
259
+ }
260
+ return s;
261
+ }
262
+ set socket(value) {
263
+ this._socket = value;
264
+ }
265
+ get connection() {
266
+ return this.socket;
267
+ }
268
+ set connection(value) {
269
+ this._socket = value;
270
+ }
271
+ // ==== Express-compatible request helpers (parity with the uWS adapter) ====
272
+ get hostname() {
273
+ const host = this.headers.host || '';
274
+ return host ? host.split(':')[0] : '';
275
+ }
276
+ get protocol() {
277
+ const forwardedProto = this.headers['x-forwarded-proto'];
278
+ if (forwardedProto)
279
+ return forwardedProto.split(',')[0].trim();
280
+ return this._server && this._server.isSsl ? 'https' : 'http';
281
+ }
282
+ get secure() {
283
+ return this.protocol === 'https';
284
+ }
285
+ get xhr() {
286
+ const xrw = this.headers['x-requested-with'];
287
+ return !!xrw && xrw.toLowerCase() === 'xmlhttprequest';
288
+ }
289
+ get originalUrl() {
290
+ return this.url;
291
+ }
292
+ get ips() {
293
+ const forwardedFor = this.headers['x-forwarded-for'];
294
+ return forwardedFor
295
+ ? forwardedFor
296
+ .split(',')
297
+ .map(s => s.trim())
298
+ .filter(Boolean)
299
+ : [];
300
+ }
301
+ get subdomains() {
302
+ const hostnameParts = this.hostname.split('.');
303
+ return hostnameParts.length > 2 ? hostnameParts.slice(0, -2).reverse() : [];
304
+ }
305
+ get(name) {
306
+ const lower = name.toLowerCase();
307
+ if (lower === 'referer' || lower === 'referrer') {
308
+ return this.headers.referer || this.headers.referrer;
309
+ }
310
+ return this.headers[lower];
311
+ }
312
+ header(name) {
313
+ return this.get(name);
314
+ }
315
+ is(type) {
316
+ const ct = this.headers['content-type'] || '';
317
+ if (!ct)
318
+ return false;
319
+ const mime = ct.split(';')[0].trim().toLowerCase();
320
+ const t = type.toLowerCase();
321
+ if (t.indexOf('/') === -1) {
322
+ return mime.endsWith(`/${t}`) || mime.endsWith(`+${t}`);
323
+ }
324
+ if (t.endsWith('/*')) {
325
+ return mime.startsWith(t.slice(0, -1));
326
+ }
327
+ return mime === t;
328
+ }
329
+ accepts(types) {
330
+ const accept = this.headers.accept || '*/*';
331
+ if (!types)
332
+ return accept;
333
+ const wanted = Array.isArray(types) ? types : [types];
334
+ if (accept === '*/*' || accept === '')
335
+ return wanted[0] || false;
336
+ const acceptTypes = accept.split(',').map(s => s.split(';')[0].trim().toLowerCase());
337
+ for (const w of wanted) {
338
+ const wl = w.toLowerCase();
339
+ const wMime = wl.indexOf('/') === -1 ? `application/${wl}` : wl;
340
+ for (const at of acceptTypes) {
341
+ if (at === '*/*')
342
+ return w;
343
+ if (at === wMime)
344
+ return w;
345
+ if (at.endsWith('/*') && wMime.startsWith(at.slice(0, -1)))
346
+ return w;
347
+ }
348
+ }
349
+ return false;
350
+ }
351
+ acceptsLanguages(langs) {
352
+ const acceptLang = this.headers['accept-language'] || '';
353
+ if (!langs)
354
+ return acceptLang || false;
355
+ const wanted = Array.isArray(langs) ? langs : [langs];
356
+ if (!acceptLang)
357
+ return wanted[0] || false;
358
+ const accepted = acceptLang
359
+ .split(',')
360
+ .map(s => {
361
+ const parts = s.trim().split(';');
362
+ const tag = parts[0].toLowerCase();
363
+ const qPart = parts.find(p => p.trim().startsWith('q='));
364
+ const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1;
365
+ return { tag, q: isNaN(q) ? 0 : q };
366
+ })
367
+ .filter(a => a.q > 0)
368
+ .sort((a, b) => b.q - a.q);
369
+ for (const { tag } of accepted) {
370
+ for (const w of wanted) {
371
+ const wl = w.toLowerCase();
372
+ if (tag === '*' || tag === wl || tag.split('-')[0] === wl.split('-')[0])
373
+ return w;
374
+ }
375
+ }
376
+ return false;
377
+ }
378
+ }
379
+ /**
380
+ * Prototype-based response object for the engine adapter. Same
381
+ * MoroResponseMethods surface as the uWS adapter's ResponsePrototype.
382
+ *
383
+ * Terminal writes (json/send/end/redirect) go out as ONE respond() call -
384
+ * status + headers + body in a single native crossing. Streaming responses
385
+ * (writeHead/write/end) map to the engine's streaming API; the engine's
386
+ * onWritable backpressure signal surfaces as the 'drain' event.
387
+ */
388
+ export class EngineResponse extends LazyEventEmitter {
389
+ headersSent = false;
390
+ statusCode = 200;
391
+ responseHeaders = {};
392
+ locals = {};
393
+ // The EngineRequest wrapper, linked by the server so lifecycle events reach
394
+ // req.on('close') listeners (SSE cleanup, monitors)
395
+ _moroReq = null;
396
+ _server;
397
+ _engine;
398
+ _reqId;
399
+ _logger;
400
+ // Terminal-write latch: headersSent means "head flushed" (true mid-stream
401
+ // after writeHead/write), _ended means the body is complete
402
+ _ended = false;
403
+ _aborted = false;
404
+ constructor(server, reqId, logger) {
405
+ super();
406
+ this._server = server;
407
+ this._engine = server._engine;
408
+ this._reqId = reqId;
409
+ this._logger = logger;
410
+ }
411
+ get writableEnded() {
412
+ return this._ended;
413
+ }
414
+ get writableFinished() {
415
+ return this._ended;
416
+ }
417
+ get finished() {
418
+ return this._ended;
419
+ }
420
+ get aborted() {
421
+ return this._aborted;
422
+ }
423
+ // Node ServerResponse lifecycle: 'finish' then 'close' after the terminal
424
+ // write; the request wrapper's 'close' fires too (IncomingMessage parity).
425
+ // Also releases the reqId from the server's in-flight map.
426
+ _emitDone() {
427
+ this._ended = true;
428
+ this._server._complete(this._reqId);
429
+ if (this._events) {
430
+ this.emit('finish');
431
+ this.emit('close');
432
+ }
433
+ const req = this._moroReq;
434
+ if (req && req._events)
435
+ req.emit('close');
436
+ }
437
+ // Client aborted: 'close' without 'finish' (Node semantics), plus
438
+ // 'aborted'/'close' on the request wrapper. All later engine calls with
439
+ // this reqId are safe no-ops, but the guards below skip them anyway.
440
+ _handleAbort() {
441
+ if (this._ended)
442
+ return;
443
+ this._ended = true;
444
+ this._aborted = true;
445
+ if (this._events)
446
+ this.emit('close');
447
+ const req = this._moroReq;
448
+ if (req && req._events) {
449
+ req.emit('aborted');
450
+ req.emit('close');
451
+ }
452
+ }
453
+ // Backpressure: the engine's onWritable relayed as the 'drain' event that
454
+ // pipe() and manual streaming loops wait on
455
+ _handleWritable() {
456
+ this.emit('drain');
457
+ }
458
+ // Build the engine's flat [k1,v1,k2,v2,...] header array from headers
459
+ // actually set. Multi-value headers (set-cookie) become separate pairs so
460
+ // each goes out as its own header line.
461
+ _headersFlat() {
462
+ const headers = this.responseHeaders;
463
+ let flat = null;
464
+ // Performance: for...in is the fastest way to iterate response headers
465
+ for (const key in headers) {
466
+ const value = headers[key];
467
+ if (flat === null)
468
+ flat = [];
469
+ if (Array.isArray(value)) {
470
+ for (let i = 0; i < value.length; i++)
471
+ flat.push(key, String(value[i]));
472
+ }
473
+ else {
474
+ flat.push(key, String(value));
475
+ }
476
+ }
477
+ return flat;
478
+ }
479
+ status(code) {
480
+ this.statusCode = code;
481
+ return this;
482
+ }
483
+ setHeader(name, value) {
484
+ // Cache toLowerCase result to avoid repeated calls
485
+ const lowerName = name.toLowerCase();
486
+ this.responseHeaders[lowerName] = value;
487
+ return this;
488
+ }
489
+ getHeader(name) {
490
+ const lowerName = name.toLowerCase();
491
+ return this.responseHeaders[lowerName];
492
+ }
493
+ removeHeader(name) {
494
+ const lowerName = name.toLowerCase();
495
+ if (lowerName in this.responseHeaders) {
496
+ delete this.responseHeaders[lowerName];
497
+ }
498
+ return this;
499
+ }
500
+ json(data) {
501
+ if (this.headersSent || this._ended)
502
+ return;
503
+ // Fast-path JSON serialization for common API patterns
504
+ let body;
505
+ // The interpolated fast path is only valid when `success` is a real boolean
506
+ // (a string/number would produce invalid JSON like {"success":yes,...});
507
+ // otherwise fall through to JSON.stringify.
508
+ if (data && typeof data === 'object' && typeof data.success === 'boolean') {
509
+ let keyCount = 0;
510
+ let hasData = false;
511
+ let hasError = false;
512
+ let hasTotal = false;
513
+ for (const key in data) {
514
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
515
+ keyCount++;
516
+ if (key === 'data')
517
+ hasData = true;
518
+ else if (key === 'error')
519
+ hasError = true;
520
+ else if (key === 'total')
521
+ hasTotal = true;
522
+ }
523
+ }
524
+ // JSON.stringify(undefined) yields the literal token "undefined"; guard the
525
+ // interpolated branches so an explicit `data: undefined` doesn't corrupt
526
+ // the body (fall through to JSON.stringify, which omits the key instead).
527
+ if (keyCount === 3 &&
528
+ hasData &&
529
+ hasError &&
530
+ data.data !== undefined &&
531
+ data.error !== undefined) {
532
+ body = `{"success":${data.success},"data":${JSON.stringify(data.data)},"error":${JSON.stringify(data.error)}}`;
533
+ }
534
+ else if (keyCount === 3 &&
535
+ hasData &&
536
+ hasTotal &&
537
+ data.data !== undefined &&
538
+ typeof data.total === 'number') {
539
+ body = `{"success":${data.success},"data":${JSON.stringify(data.data)},"total":${data.total}}`;
540
+ }
541
+ else if (keyCount === 2 && hasData && data.data !== undefined) {
542
+ body = `{"success":${data.success},"data":${JSON.stringify(data.data)}}`;
543
+ }
544
+ else if (keyCount === 2 && hasError && data.error !== undefined) {
545
+ body = `{"success":${data.success},"error":${JSON.stringify(data.error)}}`;
546
+ }
547
+ else {
548
+ body = JSON.stringify(data);
549
+ }
550
+ }
551
+ else {
552
+ body = JSON.stringify(data);
553
+ }
554
+ if (!('content-type' in this.responseHeaders)) {
555
+ this.responseHeaders['content-type'] = 'application/json';
556
+ }
557
+ try {
558
+ this._respondMaybeCompressed(body, 'application/json');
559
+ }
560
+ catch (err) {
561
+ this._failSafe('Failed to send JSON response', err);
562
+ }
563
+ }
564
+ // Buffered-response compression parity with the Node path. Terminal helper:
565
+ // respond() with `body`, compressing first when enabled + compressible +
566
+ // over threshold + the client accepts an encoding. Keeps the synchronous
567
+ // fast path (zero extra work) whenever compression can't apply, so the
568
+ // default (compression off) pays nothing.
569
+ _respondMaybeCompressed(body, contentType) {
570
+ const s = this._server._compression;
571
+ // HOT PATH: compression is off by default - bail before ANY per-response
572
+ // work (byteLength scans the string; header reads allocate nothing but
573
+ // still cost). The default config must pay zero for this feature.
574
+ if (!s.enabled) {
575
+ this._engine.respond(this._reqId, this.statusCode, this._headersFlat(), body);
576
+ this.headersSent = true;
577
+ this._emitDone();
578
+ return;
579
+ }
580
+ const bytes = typeof body === 'string' ? Buffer.byteLength(body) : body.length;
581
+ const alreadyEncoded = 'content-encoding' in this.responseHeaders;
582
+ const ctHeader = this.responseHeaders['content-type'];
583
+ const ct = contentType ?? (Array.isArray(ctHeader) ? ctHeader[0] : ctHeader);
584
+ if (alreadyEncoded ||
585
+ bytes < s.threshold ||
586
+ !isCompressible(ct) ||
587
+ this.statusCode === 204 ||
588
+ this.statusCode === 304) {
589
+ this._engine.respond(this._reqId, this.statusCode, this._headersFlat(), body);
590
+ this.headersSent = true;
591
+ this._emitDone();
592
+ return;
593
+ }
594
+ const accept = this._engine.getHeader(this._reqId, 'accept-encoding');
595
+ const encoding = negotiateEncoding(accept, s.encodings);
596
+ if (!encoding) {
597
+ this._engine.respond(this._reqId, this.statusCode, this._headersFlat(), body);
598
+ this.headersSent = true;
599
+ this._emitDone();
600
+ return;
601
+ }
602
+ // Async path: compress, then respond (guarding against an abort that
603
+ // raced the await - the reqId would be invalid).
604
+ void compressBuffer(body, encoding, s.level)
605
+ .then(compressed => {
606
+ if (this._ended || this._engine.isAborted(this._reqId))
607
+ return;
608
+ this.responseHeaders['content-encoding'] = encoding;
609
+ const vary = this.responseHeaders['vary'];
610
+ this.responseHeaders['vary'] = vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding';
611
+ this._engine.respond(this._reqId, this.statusCode, this._headersFlat(), compressed);
612
+ this.headersSent = true;
613
+ this._emitDone();
614
+ })
615
+ .catch(err => this._failSafe('Failed to compress response', err));
616
+ }
617
+ // A response-send failure must never leave the request hanging open (the
618
+ // sweep exempts active requests, so nothing else would ever close it):
619
+ // attempt a plain 500, then release the wrapper either way.
620
+ _failSafe(context, err) {
621
+ this._logger.error(`${context}: ${err instanceof Error ? err.message : String(err)}`, 'ResponseError');
622
+ if (!this._ended) {
623
+ try {
624
+ this._engine.respond(this._reqId, 500, null, '{"success":false,"error":"Internal server error"}');
625
+ this.headersSent = true;
626
+ }
627
+ catch {
628
+ // reqId already gone (aborted/finished) - nothing to send
629
+ }
630
+ this._emitDone();
631
+ }
632
+ }
633
+ send(data) {
634
+ if (this.headersSent || this._ended)
635
+ return;
636
+ // Default a Content-Type to match the Node server (parity for the now-
637
+ // default engine path): JSON for JSON-looking strings, octet-stream for
638
+ // Buffers, text/plain otherwise.
639
+ if (!('content-type' in this.responseHeaders)) {
640
+ if (typeof data === 'string') {
641
+ const t = data.trimStart();
642
+ this.responseHeaders['content-type'] =
643
+ t.startsWith('{') || t.startsWith('[')
644
+ ? 'application/json; charset=utf-8'
645
+ : 'text/plain; charset=utf-8';
646
+ }
647
+ else {
648
+ this.responseHeaders['content-type'] = 'application/octet-stream';
649
+ }
650
+ }
651
+ try {
652
+ // Buffers pass through binary-safe (the engine accepts Uint8Array).
653
+ // Compression applies only to compressible content types (isCompressible
654
+ // excludes octet-stream), so binary sends stay on the sync fast path.
655
+ this._respondMaybeCompressed(data);
656
+ // headersSent/_emitDone handled inside the helper.
657
+ }
658
+ catch (err) {
659
+ this._failSafe('Failed to send response', err);
660
+ }
661
+ }
662
+ // Flush status + headers without ending the response (Node streaming
663
+ // entry point - SSE and manual chunked responses start here). Supports
664
+ // both (code, headers) and (code, statusMessage, headers) signatures;
665
+ // the engine derives the reason phrase from the status code, so a custom
666
+ // message cannot be sent on the wire and is ignored.
667
+ writeHead(statusCode, reasonOrHeaders, maybeHeaders) {
668
+ if (this.headersSent || this._ended)
669
+ return this;
670
+ this.statusCode = statusCode;
671
+ const headers = reasonOrHeaders && typeof reasonOrHeaders === 'object' ? reasonOrHeaders : maybeHeaders;
672
+ if (headers) {
673
+ for (const key of Object.keys(headers)) {
674
+ this.setHeader(key, headers[key]);
675
+ }
676
+ }
677
+ try {
678
+ this._engine.writeHead(this._reqId, statusCode, this._headersFlat());
679
+ this.headersSent = true;
680
+ }
681
+ catch {
682
+ this._logger.error('Failed to write response head', 'ResponseError');
683
+ }
684
+ return this;
685
+ }
686
+ // Stream a body chunk, flushing the head first if needed. Returns the
687
+ // engine's backpressure signal like Node's Writable.write; 'drain' fires
688
+ // (via onWritable) when the socket can accept more.
689
+ write(chunk, encoding, callback) {
690
+ if (typeof encoding === 'function') {
691
+ callback = encoding;
692
+ encoding = undefined;
693
+ }
694
+ if (this._ended)
695
+ return false;
696
+ let ok = true;
697
+ try {
698
+ if (!this.headersSent) {
699
+ this._engine.writeHead(this._reqId, this.statusCode, this._headersFlat());
700
+ this.headersSent = true;
701
+ }
702
+ ok = this._engine.write(this._reqId, chunk) !== false;
703
+ }
704
+ catch {
705
+ this._logger.error('Failed to write response chunk', 'ResponseError');
706
+ return false;
707
+ }
708
+ if (typeof callback === 'function')
709
+ callback();
710
+ return ok;
711
+ }
712
+ end(data, encoding, callback) {
713
+ if (typeof encoding === 'function') {
714
+ callback = encoding;
715
+ encoding = undefined;
716
+ }
717
+ // Guard on _ended, not headersSent: in streaming mode (writeHead/write)
718
+ // the head is already flushed and end() must still complete the body
719
+ if (this._ended) {
720
+ if (typeof callback === 'function')
721
+ callback();
722
+ return this;
723
+ }
724
+ try {
725
+ if (!this.headersSent) {
726
+ // Terminal single-shot: status + headers + body in one native call
727
+ this._engine.respond(this._reqId, this.statusCode, this._headersFlat(), data !== undefined && data !== null ? data : null);
728
+ this.headersSent = true;
729
+ }
730
+ else {
731
+ this._engine.end(this._reqId, data !== undefined && data !== null ? data : undefined);
732
+ }
733
+ this._emitDone();
734
+ if (typeof callback === 'function')
735
+ callback();
736
+ }
737
+ catch (err) {
738
+ this._failSafe('Failed to end response', err);
739
+ if (typeof callback === 'function')
740
+ callback();
741
+ }
742
+ return this;
743
+ }
744
+ redirect(url, code) {
745
+ if (this.headersSent || this._ended)
746
+ return;
747
+ const redirectCode = code || 302;
748
+ this.statusCode = redirectCode;
749
+ this.setHeader('location', url.replace(/[\r\n]/g, ''));
750
+ try {
751
+ this._engine.respond(this._reqId, redirectCode, this._headersFlat(), null);
752
+ this.headersSent = true;
753
+ this._emitDone();
754
+ }
755
+ catch (err) {
756
+ this._failSafe('Failed to send redirect', err);
757
+ }
758
+ }
759
+ // Standardized response helpers
760
+ success(data, message) {
761
+ const response = {
762
+ success: true,
763
+ data,
764
+ };
765
+ if (message !== undefined) {
766
+ response.message = message;
767
+ }
768
+ this.json(response);
769
+ }
770
+ error(error, code, message) {
771
+ const response = {
772
+ success: false,
773
+ error,
774
+ };
775
+ if (code !== undefined) {
776
+ response.code = code;
777
+ }
778
+ if (message !== undefined) {
779
+ response.message = message;
780
+ }
781
+ this.json(response);
782
+ }
783
+ // Common HTTP error helpers (automatically set status code)
784
+ unauthorized(message = 'Authentication required') {
785
+ this.statusCode = 401;
786
+ this.json({
787
+ success: false,
788
+ error: 'Unauthorized',
789
+ code: 'UNAUTHORIZED',
790
+ message,
791
+ });
792
+ }
793
+ forbidden(message = 'Insufficient permissions') {
794
+ this.statusCode = 403;
795
+ this.json({
796
+ success: false,
797
+ error: 'Forbidden',
798
+ code: 'FORBIDDEN',
799
+ message,
800
+ });
801
+ }
802
+ notFound(resource = 'Resource') {
803
+ this.statusCode = 404;
804
+ this.json({
805
+ success: false,
806
+ error: 'Not Found',
807
+ code: 'NOT_FOUND',
808
+ message: `${resource} not found`,
809
+ });
810
+ }
811
+ badRequest(message = 'Invalid request') {
812
+ this.statusCode = 400;
813
+ this.json({
814
+ success: false,
815
+ error: 'Bad Request',
816
+ code: 'BAD_REQUEST',
817
+ message,
818
+ });
819
+ }
820
+ conflict(message) {
821
+ this.statusCode = 409;
822
+ this.json({
823
+ success: false,
824
+ error: 'Conflict',
825
+ code: 'CONFLICT',
826
+ message,
827
+ });
828
+ }
829
+ internalError(message = 'Internal server error') {
830
+ this.statusCode = 500;
831
+ this.json({
832
+ success: false,
833
+ error: 'Internal Server Error',
834
+ code: 'INTERNAL_ERROR',
835
+ message,
836
+ });
837
+ }
838
+ validationError(errors) {
839
+ this.statusCode = 422;
840
+ this.json({
841
+ success: false,
842
+ error: 'Validation Failed',
843
+ code: 'VALIDATION_ERROR',
844
+ errors,
845
+ });
846
+ }
847
+ rateLimited(retryAfter) {
848
+ this.statusCode = 429;
849
+ if (retryAfter) {
850
+ this.setHeader('Retry-After', retryAfter.toString());
851
+ }
852
+ this.json({
853
+ success: false,
854
+ error: 'Rate Limit Exceeded',
855
+ code: 'RATE_LIMITED',
856
+ message: retryAfter
857
+ ? `Too many requests. Retry after ${retryAfter} seconds.`
858
+ : 'Too many requests',
859
+ retryAfter,
860
+ });
861
+ }
862
+ // Common success patterns
863
+ created(data, location) {
864
+ this.statusCode = 201;
865
+ if (location) {
866
+ this.setHeader('Location', location);
867
+ }
868
+ this.json({
869
+ success: true,
870
+ data,
871
+ });
872
+ }
873
+ noContent() {
874
+ this.statusCode = 204;
875
+ this.end();
876
+ }
877
+ paginated(data, pagination) {
878
+ const totalPages = Math.ceil(pagination.total / pagination.limit);
879
+ this.json({
880
+ success: true,
881
+ data,
882
+ pagination: {
883
+ page: pagination.page,
884
+ limit: pagination.limit,
885
+ total: pagination.total,
886
+ totalPages,
887
+ hasNext: pagination.page < totalPages,
888
+ hasPrev: pagination.page > 1,
889
+ },
890
+ });
891
+ }
892
+ cookie(name, value, options) {
893
+ // Percent-encode the value (matches the Node path and Express): an
894
+ // unencoded value containing ';' or ',' would inject cookie attributes, and
895
+ // control chars would make the native header validator silently drop the
896
+ // whole Set-Cookie header.
897
+ const parts = [name, '=', encodeURIComponent(value)];
898
+ if (options) {
899
+ // maxAge: 0 is meaningful (immediate expiry - clearCookie relies on it)
900
+ if (options.maxAge !== undefined && options.maxAge !== null) {
901
+ parts.push('; Max-Age=', String(options.maxAge));
902
+ }
903
+ if (options.expires) {
904
+ const expires = options.expires instanceof Date ? options.expires.toUTCString() : String(options.expires);
905
+ parts.push('; Expires=', expires);
906
+ }
907
+ if (options.domain) {
908
+ parts.push('; Domain=', options.domain);
909
+ }
910
+ if (options.secure) {
911
+ parts.push('; Secure');
912
+ }
913
+ if (options.httpOnly) {
914
+ parts.push('; HttpOnly');
915
+ }
916
+ if (options.sameSite) {
917
+ parts.push('; SameSite=', options.sameSite);
918
+ }
919
+ }
920
+ // Path defaults to '/' (Express behavior): without it the browser scopes
921
+ // the cookie to the current URL's directory, so a clearCookie() from
922
+ // /auth/logout would miss a cookie originally set at Path=/.
923
+ parts.push('; Path=', options?.path ?? '/');
924
+ const cookie = parts.join('');
925
+ const lowerKey = 'set-cookie';
926
+ const existing = this.responseHeaders[lowerKey];
927
+ if (existing) {
928
+ if (Array.isArray(existing)) {
929
+ existing.push(cookie);
930
+ }
931
+ else {
932
+ this.responseHeaders[lowerKey] = [existing, cookie];
933
+ }
934
+ }
935
+ else {
936
+ this.responseHeaders[lowerKey] = cookie;
937
+ }
938
+ return this;
939
+ }
940
+ clearCookie(name, options = {}) {
941
+ const clearOptions = { expires: new Date(0), maxAge: 0 };
942
+ if (options.path !== undefined)
943
+ clearOptions.path = options.path;
944
+ if (options.domain !== undefined)
945
+ clearOptions.domain = options.domain;
946
+ if (options.httpOnly !== undefined)
947
+ clearOptions.httpOnly = options.httpOnly;
948
+ if (options.secure !== undefined)
949
+ clearOptions.secure = options.secure;
950
+ if (options.sameSite !== undefined)
951
+ clearOptions.sameSite = options.sameSite;
952
+ return this.cookie(name, '', clearOptions);
953
+ }
954
+ // ==== File responses ====
955
+ // Small files go out in one respond(); larger files stream with
956
+ // backpressure ('drain' via the engine's onWritable)
957
+ async sendFile(filePath) {
958
+ if (this.headersSent || this._ended)
959
+ return;
960
+ let streamRef = null; // read stream, so the catch can tear it down
961
+ try {
962
+ const fs = await import('fs');
963
+ const path = await import('path');
964
+ const stats = await fs.promises.stat(filePath);
965
+ const contentType = addCharsetIfNeeded(mimeTypeFor(path.extname(filePath)));
966
+ if (!('content-type' in this.responseHeaders)) {
967
+ this.setHeader('Content-Type', contentType);
968
+ }
969
+ this.setHeader('X-Content-Type-Options', 'nosniff');
970
+ this.setHeader('Last-Modified', stats.mtime.toUTCString());
971
+ this.setHeader('Cache-Control', 'public, max-age=31536000');
972
+ if (stats.size <= SENDFILE_BUFFER_LIMIT) {
973
+ const data = await fs.promises.readFile(filePath);
974
+ this.end(data);
975
+ return;
976
+ }
977
+ // Explicit length so the engine skips chunked encoding for the stream.
978
+ // (If the file shrinks mid-stream, the engine detects the short write at
979
+ // end() and forces Connection: close - the client sees truncation, never
980
+ // the next response's bytes as this body.)
981
+ this.setHeader('Content-Length', String(stats.size));
982
+ await new Promise((resolve, reject) => {
983
+ const stream = fs.createReadStream(filePath);
984
+ streamRef = stream;
985
+ let pendingResume = null;
986
+ // ONE close listener for the stream's whole lifetime (not one per
987
+ // backpressure pause), and it SETTLES the promise: a client abort
988
+ // while the stream is paused must not leave `await sendFile()`
989
+ // hanging forever.
990
+ const onClose = () => {
991
+ if (pendingResume)
992
+ this.removeListener('drain', pendingResume);
993
+ stream.destroy();
994
+ resolve();
995
+ };
996
+ this.once('close', onClose);
997
+ const cleanup = () => {
998
+ this.removeListener('close', onClose);
999
+ if (pendingResume)
1000
+ this.removeListener('drain', pendingResume);
1001
+ };
1002
+ stream.on('data', chunk => {
1003
+ if (this._ended) {
1004
+ cleanup();
1005
+ stream.destroy();
1006
+ resolve();
1007
+ return;
1008
+ }
1009
+ if (!this.write(chunk)) {
1010
+ stream.pause();
1011
+ pendingResume = () => {
1012
+ pendingResume = null;
1013
+ stream.resume();
1014
+ };
1015
+ this.once('drain', pendingResume);
1016
+ }
1017
+ });
1018
+ stream.on('end', () => {
1019
+ cleanup();
1020
+ this.end();
1021
+ resolve();
1022
+ });
1023
+ stream.on('error', (err) => {
1024
+ cleanup();
1025
+ reject(err);
1026
+ });
1027
+ });
1028
+ }
1029
+ catch {
1030
+ // The head may already be on the wire (streaming branch). Terminate the
1031
+ // response so the client isn't left waiting on the declared
1032
+ // Content-Length, and tear down the read stream; only 404 if nothing was
1033
+ // sent yet.
1034
+ if (streamRef)
1035
+ streamRef.destroy();
1036
+ if (this.headersSent && !this._ended) {
1037
+ this.end();
1038
+ }
1039
+ else if (!this.headersSent && !this._ended) {
1040
+ this.status(404).json({ success: false, error: 'File not found' });
1041
+ }
1042
+ }
1043
+ }
1044
+ attachment(filename) {
1045
+ if (this.headersSent)
1046
+ return this;
1047
+ if (filename) {
1048
+ const safeName = filename.replace(/[\r\n"]/g, '');
1049
+ this.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
1050
+ if (!this.getHeader('Content-Type')) {
1051
+ const dot = filename.lastIndexOf('.');
1052
+ if (dot !== -1) {
1053
+ this.setHeader('Content-Type', mimeTypeFor(filename.substring(dot)));
1054
+ }
1055
+ }
1056
+ }
1057
+ else {
1058
+ this.setHeader('Content-Disposition', 'attachment');
1059
+ }
1060
+ return this;
1061
+ }
1062
+ async download(filePath, filename) {
1063
+ this.attachment(filename || filePath.split('/').pop());
1064
+ return this.sendFile(filePath);
1065
+ }
1066
+ format(handlers) {
1067
+ const types = Object.keys(handlers).filter(k => k !== 'default');
1068
+ const matched = this._moroReq ? this._moroReq.accepts(types) : false;
1069
+ const chosen = matched ? handlers[matched] : handlers.default;
1070
+ if (!chosen) {
1071
+ this.statusCode = 406;
1072
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
1073
+ this.end('Not Acceptable');
1074
+ return;
1075
+ }
1076
+ this.vary('Accept');
1077
+ if (matched && typeof matched === 'string') {
1078
+ this.type(matched);
1079
+ }
1080
+ chosen();
1081
+ }
1082
+ // ==== Express-compatible response helpers (parity with the uWS adapter) ====
1083
+ set(field, value) {
1084
+ if (this.headersSent)
1085
+ return this;
1086
+ if (typeof field === 'string') {
1087
+ if (value !== undefined)
1088
+ this.setHeader(field, value);
1089
+ }
1090
+ else {
1091
+ for (const key in field) {
1092
+ this.setHeader(key, field[key]);
1093
+ }
1094
+ }
1095
+ return this;
1096
+ }
1097
+ get(field) {
1098
+ return this.getHeader(field);
1099
+ }
1100
+ hasHeader(name) {
1101
+ return this.getHeader(name) !== undefined;
1102
+ }
1103
+ setBulkHeaders(headers) {
1104
+ if (this.headersSent)
1105
+ return this;
1106
+ for (const key in headers) {
1107
+ this.setHeader(key, String(headers[key]));
1108
+ }
1109
+ return this;
1110
+ }
1111
+ appendHeader(name, value) {
1112
+ if (this.headersSent)
1113
+ return this;
1114
+ const lower = name.toLowerCase();
1115
+ const existing = this.responseHeaders[lower];
1116
+ if (existing) {
1117
+ const values = Array.isArray(existing) ? existing : [existing];
1118
+ const incoming = Array.isArray(value) ? value : [value];
1119
+ this.responseHeaders[lower] = values.concat(incoming);
1120
+ }
1121
+ else {
1122
+ this.responseHeaders[lower] = value;
1123
+ }
1124
+ return this;
1125
+ }
1126
+ append(field, value) {
1127
+ return this.appendHeader(field, value);
1128
+ }
1129
+ type(contentType) {
1130
+ if (this.headersSent)
1131
+ return this;
1132
+ let ct = contentType;
1133
+ if (ct.indexOf('/') === -1) {
1134
+ const shorthands = {
1135
+ json: 'application/json; charset=utf-8',
1136
+ html: 'text/html; charset=utf-8',
1137
+ text: 'text/plain; charset=utf-8',
1138
+ txt: 'text/plain; charset=utf-8',
1139
+ xml: 'application/xml',
1140
+ js: 'application/javascript',
1141
+ css: 'text/css; charset=utf-8',
1142
+ form: 'application/x-www-form-urlencoded',
1143
+ };
1144
+ ct = shorthands[ct.toLowerCase()] || `application/${ct}`;
1145
+ }
1146
+ this.setHeader('Content-Type', ct);
1147
+ return this;
1148
+ }
1149
+ sendStatus(code) {
1150
+ if (this.headersSent)
1151
+ return;
1152
+ this.statusCode = code;
1153
+ // Reason phrase when the code is known, otherwise the code itself
1154
+ // (Node behavior) - never a fabricated "OK" for a non-2xx status.
1155
+ const statusString = STATUS_STRINGS.get(code);
1156
+ const body = statusString ? statusString.slice(String(code).length + 1) : String(code);
1157
+ this.setHeader('Content-Type', 'text/plain; charset=utf-8');
1158
+ this.end(body);
1159
+ }
1160
+ location(url) {
1161
+ if (this.headersSent)
1162
+ return this;
1163
+ this.setHeader('Location', url.replace(/[\r\n]/g, ''));
1164
+ return this;
1165
+ }
1166
+ vary(field) {
1167
+ if (this.headersSent)
1168
+ return this;
1169
+ const existing = this.responseHeaders['vary'];
1170
+ const existingList = existing
1171
+ ? String(existing)
1172
+ .split(',')
1173
+ .map(s => s.trim())
1174
+ .filter(Boolean)
1175
+ : [];
1176
+ const incoming = Array.isArray(field) ? field : [field];
1177
+ for (const f of incoming) {
1178
+ if (!existingList.some(e => e.toLowerCase() === f.toLowerCase())) {
1179
+ existingList.push(f);
1180
+ }
1181
+ }
1182
+ this.responseHeaders['vary'] = existingList.join(', ');
1183
+ return this;
1184
+ }
1185
+ links(links) {
1186
+ if (this.headersSent)
1187
+ return this;
1188
+ const parts = [];
1189
+ for (const rel in links) {
1190
+ parts.push(`<${links[rel]}>; rel="${rel}"`);
1191
+ }
1192
+ if (parts.length > 0) {
1193
+ const existing = this.responseHeaders['link'];
1194
+ this.responseHeaders['link'] = existing
1195
+ ? `${existing}, ${parts.join(', ')}`
1196
+ : parts.join(', ');
1197
+ }
1198
+ return this;
1199
+ }
1200
+ canSetHeaders() {
1201
+ return !this.headersSent;
1202
+ }
1203
+ getResponseState() {
1204
+ return {
1205
+ headersSent: this.headersSent,
1206
+ statusCode: this.statusCode,
1207
+ headers: this.responseHeaders,
1208
+ finished: this._ended,
1209
+ writable: !this._ended,
1210
+ };
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Moro Engine HTTP Server Adapter
1215
+ * Bridges @morojs/engine with Moro's HTTP abstractions. Duck-typed to the
1216
+ * same surface framework.ts/moro.ts use on UWebSocketsHttpServer.
1217
+ */
1218
+ export class MoroEngineServer {
1219
+ /** @internal read by EngineRequest/EngineResponse for lazy fetchers */
1220
+ _engine;
1221
+ /** @internal read by EngineRequest#protocol/socket.encrypted - true when the
1222
+ * engine is terminating TLS for this server (engine >= 1.2.0 + ssl passed) */
1223
+ isSsl = false;
1224
+ serverId;
1225
+ globalMiddleware = [];
1226
+ logger = createFrameworkLogger('EngineHttpServer');
1227
+ hookManager;
1228
+ requestCounter = 0;
1229
+ requestTrackingEnabled = true; // Generate request IDs (read lazily by EngineRequest)
1230
+ isListening = false;
1231
+ port;
1232
+ host;
1233
+ // In-flight responses by reqId so onAborted/onWritable reach the wrappers;
1234
+ // entries are dropped on terminal writes (_complete) and aborts
1235
+ inflight = new Map();
1236
+ // Small stable handle exposed via getServer()/getApp() (observability only)
1237
+ handle;
1238
+ // Set by the engine WebSocket adapter when WS support is enabled; when
1239
+ // present, upgrade requests are routed to the engine's native WS path.
1240
+ // onOpen receives the handshake request's remote address and headers -
1241
+ // captured before the upgrade invalidates the reqId - so IP-based WS
1242
+ // rate limiting and header/token connection auth see real values.
1243
+ _wsBridge;
1244
+ // Handshake info for the upgrade currently in flight: set just before
1245
+ // upgradeToWebSocket() (which fires onWsOpen synchronously), consumed there.
1246
+ _pendingWsInfo;
1247
+ // Body size limits (bytes) - configured from server.bodySizeLimit/maxUploadSize
1248
+ maxBodySize = 10 * 1024 * 1024;
1249
+ maxUploadSize = 100 * 1024 * 1024;
1250
+ reusePort = false;
1251
+ _capabilities;
1252
+ _limits;
1253
+ _ssl;
1254
+ _http2Settings;
1255
+ multipartLimits;
1256
+ /** @internal read by EngineResponse for buffered-response compression */
1257
+ _compression = {
1258
+ enabled: false,
1259
+ threshold: 1024,
1260
+ level: 6,
1261
+ encodings: ['br', 'gzip', 'deflate'],
1262
+ };
1263
+ // Set by close(); listen() then registers a fresh native server, because the
1264
+ // old serverId's uv handles are torn down and must never be reused.
1265
+ closed = false;
1266
+ // Direct router dispatch (set from Moro.listen via setRouterHandler)
1267
+ routerHandler;
1268
+ // Global error handler (Moro.setErrorHandler) - shapes errors thrown from
1269
+ // global middleware, hooks and body parsing (parity with MoroHttpServer)
1270
+ errorHandler;
1271
+ // Direct route table (parity with MoroHttpServer): routes registered via
1272
+ // get/post/... - the framework's registerDirectRoutes() calls these. They
1273
+ // live here, independent of the UnifiedRouter singleton, so app.post()-style
1274
+ // routes keep working even if the unified router is reset (as the test
1275
+ // harness does between cases). Checked before the unified router in dispatch.
1276
+ directRoutes = [];
1277
+ constructor(options = {}) {
1278
+ if (options.maxBodySize)
1279
+ this.maxBodySize = options.maxBodySize;
1280
+ if (options.maxUploadSize)
1281
+ this.maxUploadSize = options.maxUploadSize;
1282
+ this.reusePort = options.reusePort === true;
1283
+ this._capabilities = options.capabilities;
1284
+ this._limits = options.limits;
1285
+ this._ssl = options.ssl ?? undefined;
1286
+ this._http2Settings = options.http2Settings;
1287
+ if (options.limits?.multipart)
1288
+ this.multipartLimits = options.limits.multipart;
1289
+ // The framework preloads the engine synchronously (loadNativeEngine) and
1290
+ // injects it; loading here directly covers standalone construction.
1291
+ const engineModule = options.engineModule ?? loadNativeEngine()?.module;
1292
+ if (!engineModule) {
1293
+ throw new Error('No native HTTP engine available: ' +
1294
+ (getNativeEngineLoadErrors().join('; ') || 'not installed'));
1295
+ }
1296
+ const surface = engineModule.default || engineModule;
1297
+ if (typeof surface.serve !== 'function' || typeof surface.respond !== 'function') {
1298
+ throw new Error('Engine module does not provide the @morojs/engine API (serve/respond) - ' +
1299
+ 'use UWebSocketsHttpServer for uWS-style modules');
1300
+ }
1301
+ this._engine = surface;
1302
+ // TLS: pass it through when the engine supports it; otherwise keep the
1303
+ // (now capability-gated) warning so a proxy/node fallback is clear.
1304
+ if (this._ssl && this._capabilities?.tls) {
1305
+ this.isSsl = true; // req.secure/protocol now report https
1306
+ }
1307
+ else if (this._ssl && !this._capabilities?.tls) {
1308
+ this.logger.warn('This @morojs/engine build does not terminate TLS - terminate at a ' +
1309
+ "proxy, upgrade the engine, or set engine: 'node' for in-process HTTPS", 'Init');
1310
+ }
1311
+ // Register callbacks now - the engine opens sockets only at listen().
1312
+ this.serverId = this.registerWithEngine();
1313
+ // Framework observability handle exposed via getServer()/getApp(). It
1314
+ // mimics enough of a net.Server that code reaching for the raw server
1315
+ // (e.g. `app.core.httpServer.getServer().close(cb)`) works on the engine
1316
+ // path too - address() is null until listening, and close(cb) forwards to
1317
+ // the adapter's shutdown so callers never hang waiting on a missing method.
1318
+ this.handle = Object.defineProperties({
1319
+ engine: '@morojs/engine',
1320
+ address: () => this.isListening
1321
+ ? { address: this.host || '0.0.0.0', family: 'IPv4', port: this.port || 0 }
1322
+ : null,
1323
+ close: (cb) => {
1324
+ this.close(cb);
1325
+ return this.handle;
1326
+ },
1327
+ }, {
1328
+ serverId: { get: () => this.serverId, enumerable: true },
1329
+ listening: { get: () => this.isListening, enumerable: true },
1330
+ });
1331
+ this.logger.info('Moro engine HTTP server created', 'Init');
1332
+ }
1333
+ /** @internal terminal write/abort cleanup - drops the reqId mapping */
1334
+ _complete(reqId) {
1335
+ this.inflight.delete(reqId);
1336
+ }
1337
+ // Register callbacks + limits with the native engine, returning the new
1338
+ // serverId. Called from the constructor and again by listen() after a
1339
+ // close() (the old native server is gone; a restart needs a fresh one).
1340
+ // The engine buffers bodies natively and 413s over its limit; it cannot
1341
+ // see content types, so it gets the larger upload limit and parseBody
1342
+ // enforces the tighter non-multipart limit (mirrors the other servers).
1343
+ //
1344
+ // Every option beyond maxBodySize/reusePort is gated on engine capabilities
1345
+ // so this never hands a key an older engine build cannot parse (an unknown
1346
+ // option would just be ignored, but gating keeps the intent explicit).
1347
+ registerWithEngine() {
1348
+ const caps = this._capabilities;
1349
+ const limits = this._limits;
1350
+ const t = limits?.timeouts ?? {};
1351
+ const serveOptions = {
1352
+ maxBodySize: Math.max(this.maxBodySize, this.maxUploadSize),
1353
+ reusePort: this.reusePort,
1354
+ };
1355
+ // Timeouts + connection cap: understood since the engine's first release.
1356
+ if (t.idle !== undefined)
1357
+ serveOptions.idleTimeoutMs = t.idle;
1358
+ if (t.request !== undefined)
1359
+ serveOptions.requestTimeoutMs = t.request;
1360
+ if (limits?.maxConnections && limits.maxConnections > 0) {
1361
+ serveOptions.maxConnections = limits.maxConnections;
1362
+ }
1363
+ if (limits?.maxPendingBytes !== undefined) {
1364
+ serveOptions.maxPendingBytes = limits.maxPendingBytes;
1365
+ }
1366
+ // Full limit surface (engine >= 1.1.0).
1367
+ if (caps?.limits) {
1368
+ if (limits?.maxHeaderSize !== undefined)
1369
+ serveOptions.maxHeadSize = limits.maxHeaderSize;
1370
+ if (limits?.maxHeaders !== undefined)
1371
+ serveOptions.maxHeaders = limits.maxHeaders;
1372
+ if (limits?.wsMaxMessageSize !== undefined)
1373
+ serveOptions.wsMaxMessageSize = limits.wsMaxMessageSize;
1374
+ if (limits?.wsBackpressureLimit !== undefined)
1375
+ serveOptions.wsBackpressureLimit = limits.wsBackpressureLimit;
1376
+ if (limits?.writeHighWaterMark !== undefined)
1377
+ serveOptions.writeHighWaterMark = limits.writeHighWaterMark;
1378
+ if (limits?.backlog !== undefined)
1379
+ serveOptions.backlog = limits.backlog;
1380
+ }
1381
+ // TLS (engine >= 1.2.0).
1382
+ if (caps?.tls && this._ssl) {
1383
+ serveOptions.ssl = this._ssl;
1384
+ }
1385
+ // HTTP/2 (engine >= 1.4.0).
1386
+ if (caps?.http2 && this._http2Settings) {
1387
+ serveOptions.http2 = {
1388
+ ...(this._http2Settings.settings ?? {}),
1389
+ };
1390
+ }
1391
+ return this._engine.serve({
1392
+ onRequest: (reqId, methodIdx, path) => this.onRequest(reqId, methodIdx, path),
1393
+ onAborted: (reqId) => this.onAborted(reqId),
1394
+ onWritable: (reqId) => this.onWritable(reqId),
1395
+ onWsOpen: (wsId, path) => this._wsBridge?.onOpen(wsId, path, this._pendingWsInfo),
1396
+ onWsMessage: (wsId, data, isBinary) => this._wsBridge?.onMessage(wsId, data, isBinary),
1397
+ onWsClose: (wsId, code) => this._wsBridge?.onClose(wsId, code),
1398
+ }, serveOptions);
1399
+ }
1400
+ // ---- WebSocket bridge (used by EngineWebSocketAdapter) ----
1401
+ /** Register the engine WebSocket bridge; upgrade requests then route natively. */
1402
+ enableWebSocket(bridge) {
1403
+ this._wsBridge = bridge;
1404
+ }
1405
+ /** Send a frame on a WebSocket. Returns false on backpressure. */
1406
+ wsSend(wsId, data, isBinary) {
1407
+ return this._engine.wsSend(wsId, data, isBinary) !== false;
1408
+ }
1409
+ /** Close a WebSocket (RFC 6455 close frame). */
1410
+ wsClose(wsId, code, reason) {
1411
+ this._engine.wsClose(wsId, code, reason);
1412
+ }
1413
+ // Direct router dispatch - called after global middleware without the
1414
+ // per-middleware promise machinery (mirrors MoroHttpServer.setRouterHandler)
1415
+ setRouterHandler(fn) {
1416
+ this.routerHandler = fn;
1417
+ }
1418
+ // Register a global error handler (called from Moro.setErrorHandler)
1419
+ setErrorHandler(fn) {
1420
+ this.errorHandler = fn;
1421
+ }
1422
+ // Request/response decorations (app.decorateRequest/decorateReply). Applied
1423
+ // per request/response since EngineRequest/EngineResponse are shared classes
1424
+ // (decorating their prototype would leak across apps in one process). Empty
1425
+ // by default, so the common path pays only a `> 0` length check.
1426
+ requestDecorationKeys = [];
1427
+ requestDecorations = {};
1428
+ responseDecorationKeys = [];
1429
+ responseDecorations = {};
1430
+ setRequestDecorations(decorations) {
1431
+ this.requestDecorations = decorations;
1432
+ this.requestDecorationKeys = Object.keys(decorations);
1433
+ }
1434
+ setResponseDecorations(decorations) {
1435
+ this.responseDecorations = decorations;
1436
+ this.responseDecorationKeys = Object.keys(decorations);
1437
+ }
1438
+ // Direct route registration (mirrors MoroHttpServer). The framework's
1439
+ // registerDirectRoutes() invokes these as httpServer[method](path, handler).
1440
+ addDirectRoute(method, path, handlers) {
1441
+ const compiled = PathMatcher.compile(path);
1442
+ const pattern = compiled.pattern || new RegExp(`^${path.split('?')[0].replace(/\//g, '\\/')}$`);
1443
+ this.directRoutes.push({ method, pattern, paramNames: compiled.paramNames, handlers });
1444
+ }
1445
+ get(path, ...handlers) {
1446
+ this.addDirectRoute('GET', path, handlers);
1447
+ }
1448
+ post(path, ...handlers) {
1449
+ this.addDirectRoute('POST', path, handlers);
1450
+ }
1451
+ put(path, ...handlers) {
1452
+ this.addDirectRoute('PUT', path, handlers);
1453
+ }
1454
+ delete(path, ...handlers) {
1455
+ this.addDirectRoute('DELETE', path, handlers);
1456
+ }
1457
+ patch(path, ...handlers) {
1458
+ this.addDirectRoute('PATCH', path, handlers);
1459
+ }
1460
+ options(path, ...handlers) {
1461
+ this.addDirectRoute('OPTIONS', path, handlers);
1462
+ }
1463
+ head(path, ...handlers) {
1464
+ this.addDirectRoute('HEAD', path, handlers);
1465
+ }
1466
+ // Match + run a direct route. Returns true if one handled the request.
1467
+ async dispatchDirectRoute(httpReq, httpRes) {
1468
+ if (this.directRoutes.length === 0)
1469
+ return false;
1470
+ const method = httpReq.method;
1471
+ const path = httpReq.path;
1472
+ for (const route of this.directRoutes) {
1473
+ if (route.method !== method)
1474
+ continue;
1475
+ const matches = path.match(route.pattern);
1476
+ if (!matches)
1477
+ continue;
1478
+ if (route.paramNames.length > 0) {
1479
+ const params = {};
1480
+ for (let i = 0; i < route.paramNames.length; i++) {
1481
+ const raw = matches[i + 1];
1482
+ if (raw === undefined) {
1483
+ params[route.paramNames[i]] = '';
1484
+ }
1485
+ else {
1486
+ // Malformed escapes (e.g. /users/%zz) keep the raw text - a
1487
+ // URIError here would turn an odd URL into a 500
1488
+ try {
1489
+ params[route.paramNames[i]] = decodeURIComponent(raw);
1490
+ }
1491
+ catch {
1492
+ params[route.paramNames[i]] = raw;
1493
+ }
1494
+ }
1495
+ }
1496
+ httpReq.params = { ...httpReq.params, ...params };
1497
+ }
1498
+ // Run the handler chain: middleware-style (req,res,next) links run in
1499
+ // order; the final handler's return value (if any) is sent as JSON.
1500
+ for (let i = 0; i < route.handlers.length; i++) {
1501
+ if (httpRes.headersSent)
1502
+ break;
1503
+ const handler = route.handlers[i];
1504
+ const isLast = i === route.handlers.length - 1;
1505
+ if (isLast) {
1506
+ const result = handler(httpReq, httpRes);
1507
+ const value = result && typeof result.then === 'function' ? await result : result;
1508
+ if (value !== undefined && !httpRes.headersSent) {
1509
+ httpRes.json(value);
1510
+ }
1511
+ }
1512
+ else {
1513
+ await new Promise((resolve, reject) => {
1514
+ let settled = false;
1515
+ const done = (err) => {
1516
+ if (settled)
1517
+ return;
1518
+ settled = true;
1519
+ if (err) {
1520
+ reject(err);
1521
+ }
1522
+ else {
1523
+ resolve();
1524
+ }
1525
+ };
1526
+ try {
1527
+ const r = handler(httpReq, httpRes, done);
1528
+ if (r && typeof r.then === 'function') {
1529
+ r.then(() => done(), done);
1530
+ }
1531
+ else if (httpRes.headersSent) {
1532
+ // A middleware that ended the response without calling next()
1533
+ // would otherwise leave this await unresolved - unblock the chain.
1534
+ done();
1535
+ }
1536
+ }
1537
+ catch (e) {
1538
+ done(e);
1539
+ }
1540
+ });
1541
+ }
1542
+ }
1543
+ return true;
1544
+ }
1545
+ return false;
1546
+ }
1547
+ // The engine has no native route table - every request already reaches the
1548
+ // unified router through a single onRequest callback with zero middleware
1549
+ // machinery, so there is no fast path to register. Accepted for interface
1550
+ // parity with the uWS adapter.
1551
+ setNativeRouteProvider(_provider, _onError) {
1552
+ this.logger.debug('Native route provider noted (engine dispatch is already direct)', 'NativeRoutes');
1553
+ }
1554
+ onRequest(reqId, methodIdx, path) {
1555
+ // WebSocket upgrade: when a WS bridge is registered and this is an upgrade
1556
+ // request, hand the connection to the engine's native WS path. The engine
1557
+ // sends the 101 handshake and drives onWsOpen/Message/Close.
1558
+ if (this._wsBridge) {
1559
+ const upgrade = this._engine.getHeader(reqId, 'upgrade');
1560
+ if (upgrade && upgrade.toLowerCase() === 'websocket') {
1561
+ // Snapshot handshake data now - the upgrade invalidates the reqId,
1562
+ // after which these fetchers return empty values.
1563
+ const ip = this._engine.getRemoteAddress(reqId) || '';
1564
+ const headers = {};
1565
+ const flat = this._engine.getHeaders(reqId);
1566
+ if (flat) {
1567
+ // Join duplicates exactly like the EngineRequest.headers getter -
1568
+ // last-wins would let a client send two X-Forwarded-For lines and
1569
+ // hide the earlier one from IP-based WS auth / rate-limiting.
1570
+ for (let i = 0; i + 1 < flat.length; i += 2) {
1571
+ const key = flat[i];
1572
+ const existing = headers[key];
1573
+ headers[key] =
1574
+ existing === undefined
1575
+ ? flat[i + 1]
1576
+ : existing + (key === 'cookie' ? '; ' : ', ') + flat[i + 1];
1577
+ }
1578
+ }
1579
+ this._pendingWsInfo = { ip, headers };
1580
+ const wsId = this._engine.upgradeToWebSocket(reqId);
1581
+ this._pendingWsInfo = undefined;
1582
+ if (wsId !== -1)
1583
+ return; // WS took over this connection
1584
+ // else: not a valid upgrade - fall through to normal HTTP handling
1585
+ }
1586
+ }
1587
+ // methodIdx 7 (OTHER) needs the extra crossing; the seven common methods
1588
+ // resolve from the static table
1589
+ const method = methodIdx === 7 ? this._engine.getMethod(reqId) || 'OTHER' : METHODS[methodIdx];
1590
+ const httpReq = new EngineRequest(this, reqId, method, path);
1591
+ const httpRes = new EngineResponse(this, reqId, this.logger);
1592
+ httpRes._moroReq = httpReq;
1593
+ this.inflight.set(reqId, httpRes);
1594
+ // Apply app.decorateRequest/decorateReply values (rare; opt-in)
1595
+ if (this.requestDecorationKeys.length > 0) {
1596
+ for (const key of this.requestDecorationKeys) {
1597
+ httpReq[key] = this.requestDecorations[key];
1598
+ }
1599
+ }
1600
+ if (this.responseDecorationKeys.length > 0) {
1601
+ for (const key of this.responseDecorationKeys) {
1602
+ httpRes[key] = this.responseDecorations[key];
1603
+ }
1604
+ }
1605
+ void this.handleRequest(httpReq, httpRes);
1606
+ }
1607
+ onAborted(reqId) {
1608
+ const httpRes = this.inflight.get(reqId);
1609
+ if (httpRes) {
1610
+ this.inflight.delete(reqId);
1611
+ httpRes._handleAbort();
1612
+ }
1613
+ }
1614
+ onWritable(reqId) {
1615
+ const httpRes = this.inflight.get(reqId);
1616
+ if (httpRes)
1617
+ httpRes._handleWritable();
1618
+ }
1619
+ async handleRequest(httpReq, httpRes) {
1620
+ this.requestCounter++;
1621
+ try {
1622
+ const method = httpReq.method;
1623
+ // Parse body only for body methods (all start with 'P'). The engine
1624
+ // buffered the body before onRequest fired, so this is synchronous.
1625
+ if (method.charCodeAt(0) === 80 && // 'P' char code
1626
+ (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
1627
+ this.parseBody(httpReq);
1628
+ }
1629
+ // Execute hooks before request processing - skipped entirely when none registered
1630
+ const hookManager = this.hookManager;
1631
+ if (hookManager && (hookManager.hasHooks === undefined || hookManager.hasHooks('request'))) {
1632
+ await hookManager.execute('request', {
1633
+ request: httpReq,
1634
+ response: httpRes,
1635
+ });
1636
+ }
1637
+ // Execute global middleware chain
1638
+ if (this.globalMiddleware.length > 0) {
1639
+ await this.executeMiddleware(this.globalMiddleware, httpReq, httpRes);
1640
+ if (httpRes.headersSent)
1641
+ return;
1642
+ }
1643
+ // Unified router first - it carries the full route feature set
1644
+ // (per-route middleware, validation, auth, ...). The direct route table
1645
+ // below is only a fallback for when the unified router singleton was
1646
+ // reset out from under a listening app (as the test harness does), so it
1647
+ // must never shadow the richer unified-router path.
1648
+ const routerHandler = this.routerHandler;
1649
+ if (routerHandler) {
1650
+ const handled = routerHandler(httpReq, httpRes);
1651
+ if (handled) {
1652
+ if (typeof handled.then === 'function') {
1653
+ if (await handled)
1654
+ return;
1655
+ }
1656
+ else {
1657
+ return;
1658
+ }
1659
+ }
1660
+ }
1661
+ // Direct route table fallback (parity with node's dual registration -
1662
+ // survives a UnifiedRouter reset)
1663
+ if (await this.dispatchDirectRoute(httpReq, httpRes))
1664
+ return;
1665
+ // No route matched
1666
+ if (!httpRes.headersSent && !httpRes.writableEnded) {
1667
+ httpRes.statusCode = 404;
1668
+ httpRes.setHeader('Content-Type', 'application/json');
1669
+ httpRes.end('{"success":false,"error":"Not found"}');
1670
+ }
1671
+ }
1672
+ catch (error) {
1673
+ // Payload-too-large: respond 413 rather than a generic 500
1674
+ if (error?.statusCode === 413 && !httpRes.writableEnded && !httpRes.headersSent) {
1675
+ httpRes.statusCode = 413;
1676
+ httpRes.setHeader('Content-Type', 'application/json');
1677
+ httpRes.end('{"success":false,"error":"Request entity too large"}');
1678
+ return;
1679
+ }
1680
+ // Malformed body (parseBody): a client error, not a server error
1681
+ if (error?.statusCode === 400 && !httpRes.writableEnded && !httpRes.headersSent) {
1682
+ httpRes.statusCode = 400;
1683
+ httpRes.setHeader('Content-Type', 'application/json');
1684
+ httpRes.end('{"success":false,"error":"Invalid request body"}');
1685
+ return;
1686
+ }
1687
+ this.logger.error(`Request handling error: ${error instanceof Error ? error.message : String(error)}`, 'RequestError');
1688
+ // The app's global error handler (Moro.setErrorHandler) gets first shot
1689
+ // at shaping the response - same contract as the Node server.
1690
+ if (this.errorHandler && !httpRes.writableEnded && !httpRes.headersSent) {
1691
+ try {
1692
+ await this.errorHandler(error, httpReq, httpRes);
1693
+ if (httpRes.headersSent || httpRes.writableEnded)
1694
+ return;
1695
+ }
1696
+ catch (handlerError) {
1697
+ this.logger.error(`Error handler threw: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`, 'ErrorHandler');
1698
+ }
1699
+ }
1700
+ // Send error response if not already sent
1701
+ if (!httpRes.writableEnded && !httpRes.headersSent) {
1702
+ try {
1703
+ httpRes.statusCode = 500;
1704
+ httpRes.setHeader('Content-Type', 'application/json');
1705
+ httpRes.end('{"success":false,"error":"Internal server error"}');
1706
+ }
1707
+ catch {
1708
+ this.logger.error('Failed to send error response', 'ResponseError');
1709
+ }
1710
+ }
1711
+ }
1712
+ }
1713
+ // Parse the engine-buffered body (JSON/urlencoded/multipart/text - identical
1714
+ // semantics to the Node and uWS servers' readBody). getBody returns a STABLE
1715
+ // ArrayBuffer copy, so Buffer.from() views it with no aliasing hazard.
1716
+ parseBody(httpReq) {
1717
+ const raw = this._engine.getBody(httpReq._reqId);
1718
+ if (!raw || raw.byteLength === 0)
1719
+ return;
1720
+ const contentType = httpReq.headers['content-type'] || '';
1721
+ const isMultipart = contentType.includes('multipart/form-data');
1722
+ const maxSize = isMultipart ? this.maxUploadSize : this.maxBodySize;
1723
+ // The engine natively enforces the larger of the two limits; the tighter
1724
+ // non-multipart limit is enforced here (responds 413 via handleRequest)
1725
+ if (raw.byteLength > maxSize) {
1726
+ const error = new Error(isMultipart ? 'File upload too large' : 'Request body too large');
1727
+ error.statusCode = 413;
1728
+ throw error;
1729
+ }
1730
+ const buffer = Buffer.from(raw);
1731
+ try {
1732
+ if (contentType.includes('application/json')) {
1733
+ httpReq.body = JSON.parse(buffer.toString('utf-8'));
1734
+ }
1735
+ else if (contentType.includes('application/x-www-form-urlencoded')) {
1736
+ const params = new URLSearchParams(buffer.toString('utf-8'));
1737
+ const body = {};
1738
+ params.forEach((value, key) => {
1739
+ body[key] = value;
1740
+ });
1741
+ httpReq.body = body;
1742
+ }
1743
+ else if (isMultipart) {
1744
+ // Shared parser (same as the Node/uWS servers) - stringifying
1745
+ // multipart would corrupt binary uploads and lose fields/files
1746
+ httpReq.body = parseMultipartBuffer(buffer, contentType, this.multipartLimits);
1747
+ }
1748
+ else {
1749
+ httpReq.body = buffer.toString('utf-8');
1750
+ }
1751
+ }
1752
+ catch (parseError) {
1753
+ // A limit error (multipart maxParts/maxFiles/maxFileSize) carries its own
1754
+ // 413 - preserve it rather than masking it as a generic 400 malformed.
1755
+ if (parseError && typeof parseError.statusCode === 'number') {
1756
+ throw parseError;
1757
+ }
1758
+ // A malformed body must fail the request (Node-path parity), not slip a
1759
+ // silent `body = null` past handlers that then treat it as "no input".
1760
+ const error = new Error(`Invalid request body: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
1761
+ error.statusCode = 400;
1762
+ throw error;
1763
+ }
1764
+ }
1765
+ async executeMiddleware(middleware, req, res) {
1766
+ // Express error semantics: a middleware that calls next(err) (or throws)
1767
+ // routes to the NEXT 4-arg error handler (err, req, res, next), skipping
1768
+ // the normal 3-arg middleware in between; normal middleware is skipped
1769
+ // while an error is pending, and error handlers are skipped when there is
1770
+ // no error. An uncaught error propagates to handleRequest's 500 path.
1771
+ let error = null;
1772
+ for (const mw of middleware) {
1773
+ if (res.headersSent)
1774
+ break;
1775
+ const isErrorHandler = mw.length >= 4;
1776
+ if (error && !isErrorHandler)
1777
+ continue;
1778
+ if (!error && isErrorHandler)
1779
+ continue;
1780
+ try {
1781
+ await new Promise((resolve, reject) => {
1782
+ const next = (err) => (err ? reject(err) : resolve());
1783
+ try {
1784
+ const result = error ? mw(error, req, res, next) : mw(req, res, next);
1785
+ if (result && typeof result.then === 'function') {
1786
+ result.then(() => resolve(), reject);
1787
+ }
1788
+ }
1789
+ catch (e) {
1790
+ reject(e);
1791
+ }
1792
+ });
1793
+ error = null; // handler (error or normal) completed cleanly
1794
+ }
1795
+ catch (e) {
1796
+ error = e; // route to the next error handler
1797
+ }
1798
+ }
1799
+ if (error)
1800
+ throw error;
1801
+ }
1802
+ // Public API - matches MoroHttpServer/UWebSocketsHttpServer interface
1803
+ use(middleware) {
1804
+ // Defensive check: Don't add MiddlewareInterface objects to globalMiddleware
1805
+ // MiddlewareInterface objects should only be handled by MiddlewareManager
1806
+ if (middleware &&
1807
+ typeof middleware === 'object' &&
1808
+ middleware.install &&
1809
+ middleware.metadata) {
1810
+ this.logger?.warn?.(`Attempted to add MiddlewareInterface "${middleware.metadata?.name}" to HTTP server globalMiddleware. ` +
1811
+ `This should be handled by MiddlewareManager instead. Skipping.`, 'Middleware');
1812
+ return;
1813
+ }
1814
+ this.globalMiddleware.push(middleware);
1815
+ }
1816
+ // Configure request tracking (ID generation)
1817
+ setRequestTracking(enabled) {
1818
+ this.requestTrackingEnabled = enabled;
1819
+ }
1820
+ // Note: Route registration methods (get, post, etc.) are not used by the
1821
+ // engine adapter - all routing is handled by UnifiedRouter via setRouterHandler
1822
+ // (same as the uWS adapter)
1823
+ setHookManager(hookManager) {
1824
+ this.hookManager = hookManager;
1825
+ }
1826
+ configurePerformance(config) {
1827
+ // The engine's request hot path is batched natively; the one thing it
1828
+ // does NOT do itself is response compression, so wire that here (parity
1829
+ // with the Node path). Buffered responses only - streaming stays raw.
1830
+ this._compression = resolveCompressionSettings(config);
1831
+ this.logger.debug(`Performance configured (compression ${this._compression.enabled ? 'on' : 'off'})`, 'Config');
1832
+ }
1833
+ listen(port, hostOrCallback, callback) {
1834
+ if (this.isListening) {
1835
+ this.logger.warn('Server is already listening', 'Listen');
1836
+ return;
1837
+ }
1838
+ const host = typeof hostOrCallback === 'string' ? hostOrCallback : '0.0.0.0';
1839
+ const cb = typeof hostOrCallback === 'function' ? hostOrCallback : callback;
1840
+ try {
1841
+ // listen() after close(): the old native server's handles are torn
1842
+ // down (its serverId is dead) - register a fresh one, like a Node
1843
+ // http.Server supports close() -> listen() cycles.
1844
+ if (this.closed) {
1845
+ this.serverId = this.registerWithEngine();
1846
+ this.closed = false;
1847
+ }
1848
+ // Synchronous bind; throws on bind errors, returns the actual port
1849
+ // (meaningful when port 0 asks for an ephemeral one)
1850
+ const actualPort = this._engine.listen(this.serverId, host, port);
1851
+ this.port = typeof actualPort === 'number' ? actualPort : port;
1852
+ this.host = host;
1853
+ this.isListening = true;
1854
+ this.logger.info(`Moro engine HTTP server listening on ${host}:${this.port}`, 'Listen');
1855
+ if (cb)
1856
+ cb();
1857
+ }
1858
+ catch (error) {
1859
+ this.logger.error(`Failed to listen on port ${port}: ${error instanceof Error ? error.message : String(error)}`, 'Listen');
1860
+ throw error;
1861
+ }
1862
+ }
1863
+ /** Bounded drain window for in-flight responses during close() */
1864
+ static CLOSE_DRAIN_MS = 2000;
1865
+ close(callback) {
1866
+ if (!this.isListening) {
1867
+ if (callback)
1868
+ callback();
1869
+ return;
1870
+ }
1871
+ try {
1872
+ this.isListening = false;
1873
+ this.closed = true;
1874
+ // Graceful shutdown: stop accepting first, give in-flight responses a
1875
+ // bounded window to finish, then tear down. Requests still open at
1876
+ // teardown get onAborted -> 'close' events, so SSE intervals/monitors
1877
+ // registered on req.on('close') are released, not leaked.
1878
+ // Middleware is intentionally kept: close() -> listen() must serve with
1879
+ // the same cors/auth/compression stack (Node http.Server parity).
1880
+ if (typeof this._engine.stopListening === 'function') {
1881
+ this._engine.stopListening(this.serverId);
1882
+ }
1883
+ const finish = () => {
1884
+ try {
1885
+ this._engine.close(this.serverId);
1886
+ }
1887
+ catch (error) {
1888
+ this.logger.error('Error closing server', 'Close', {
1889
+ error: error instanceof Error ? error.message : String(error),
1890
+ });
1891
+ if (callback)
1892
+ callback(error instanceof Error ? error : new Error(String(error)));
1893
+ return;
1894
+ }
1895
+ this.inflight.clear();
1896
+ this.logger.info('Moro engine HTTP server closed', 'Close');
1897
+ if (callback) {
1898
+ // Give the event loop time to process the engine's socket teardown
1899
+ // before invoking the callback (matches the uWS adapter)
1900
+ setTimeout(() => {
1901
+ callback();
1902
+ }, 50);
1903
+ }
1904
+ };
1905
+ if (this.inflight.size === 0) {
1906
+ finish();
1907
+ return;
1908
+ }
1909
+ const deadline = Date.now() + MoroEngineServer.CLOSE_DRAIN_MS;
1910
+ const poll = setInterval(() => {
1911
+ if (this.inflight.size === 0 || Date.now() >= deadline) {
1912
+ clearInterval(poll);
1913
+ finish();
1914
+ }
1915
+ }, 25);
1916
+ // The drain poll must never keep the process alive by itself
1917
+ poll.unref?.();
1918
+ }
1919
+ catch (error) {
1920
+ this.logger.error('Error closing server', 'Close', {
1921
+ error: error instanceof Error ? error.message : String(error),
1922
+ });
1923
+ if (callback)
1924
+ callback(error instanceof Error ? error : new Error(String(error)));
1925
+ }
1926
+ }
1927
+ getServer() {
1928
+ // Stable observability handle (listening/address) - there is no app object
1929
+ return this.handle;
1930
+ }
1931
+ getApp() {
1932
+ return this.handle;
1933
+ }
1934
+ forceCleanup() {
1935
+ // Cleanup method for compatibility
1936
+ this.logger.debug('Force cleanup called', 'Cleanup');
1937
+ }
1938
+ }
1939
+ //# sourceMappingURL=moro-engine-server.js.map