@morojs/moro 1.8.9 → 1.8.11

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 (42) hide show
  1. package/README.md +4 -0
  2. package/dist/core/cluster/thread-cluster.d.ts +178 -0
  3. package/dist/core/cluster/thread-cluster.js +389 -0
  4. package/dist/core/cluster/thread-cluster.js.map +1 -0
  5. package/dist/core/framework.js.map +1 -1
  6. package/dist/core/http/moro-engine-server.d.ts +48 -1
  7. package/dist/core/http/moro-engine-server.js +386 -37
  8. package/dist/core/http/moro-engine-server.js.map +1 -1
  9. package/dist/core/http/utils/compression.js +12 -6
  10. package/dist/core/http/utils/compression.js.map +1 -1
  11. package/dist/core/http/utils/uws-worker-clustering.js +10 -6
  12. package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
  13. package/dist/core/http/uws-http-server.js +3 -2
  14. package/dist/core/http/uws-http-server.js.map +1 -1
  15. package/dist/core/jobs/leader-election.js +16 -5
  16. package/dist/core/jobs/leader-election.js.map +1 -1
  17. package/dist/core/networking/websocket-manager.js +4 -2
  18. package/dist/core/networking/websocket-manager.js.map +1 -1
  19. package/dist/core/routing/index.d.ts +9 -2
  20. package/dist/core/routing/index.js +12 -7
  21. package/dist/core/routing/index.js.map +1 -1
  22. package/dist/core/routing/unified-router.d.ts +25 -1
  23. package/dist/core/routing/unified-router.js +69 -6
  24. package/dist/core/routing/unified-router.js.map +1 -1
  25. package/dist/core/runtime/node-adapter.d.ts +1 -1
  26. package/dist/core/runtime/node-adapter.js.map +1 -1
  27. package/dist/core/utilities/builtin.d.ts +1 -0
  28. package/dist/core/utilities/builtin.js +25 -0
  29. package/dist/core/utilities/builtin.js.map +1 -0
  30. package/dist/core/utilities/package-utils.d.ts +29 -3
  31. package/dist/core/utilities/package-utils.js +24 -12
  32. package/dist/core/utilities/package-utils.js.map +1 -1
  33. package/dist/core/workers/worker-manager.js +4 -3
  34. package/dist/core/workers/worker-manager.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/moro.d.ts +5 -0
  38. package/dist/moro.js +401 -211
  39. package/dist/moro.js.map +1 -1
  40. package/dist/types/http.d.ts +1 -1
  41. package/dist/types/http.js.map +1 -1
  42. package/package.json +1 -1
@@ -14,6 +14,7 @@
14
14
  // Date, chunked encoding, HEAD body suppression and native 413/400 are all
15
15
  // engine concerns - this adapter never sees them. No dependency on the uWS
16
16
  // adapter: shared helpers live in ./utils.
17
+ import { setImmediate } from 'node:timers';
17
18
  import { randomUUID } from 'crypto';
18
19
  import { createFrameworkLogger } from '../logger/index.js';
19
20
  import { loadNativeEngine, getNativeEngineLoadErrors, } from '../utilities/package-utils.js';
@@ -80,6 +81,55 @@ const SENDFILE_BUFFER_LIMIT = 1024 * 1024;
80
81
  // the response has no user-set headers, so no header object or flat array is
81
82
  // allocated. The engine only reads it; never mutate.
82
83
  const JSON_HEADER_PAIR = ['content-type', 'application/json'];
84
+ // Prepared response templates (engine >= 1.1.6, capabilities.responseTemplates).
85
+ // The header sets the framework ITSELF emits - the JSON/text/octet-stream
86
+ // defaults and "no headers at all" - are materialised once per (kind, status)
87
+ // inside the engine (prepareResponse) and replayed per response with
88
+ // respondPrepared: no header object, no flat array, no per-response header
89
+ // walk in the engine, and the call is fast-call eligible. Used only when the
90
+ // response carries NO user-set headers (then the wire bytes are identical to
91
+ // respond() with the same pair - the engine's wire-parity suite proves it)
92
+ // and compression is off. TPL_HEADERS doubles as the respond() fallback for
93
+ // engines without templates, so both paths send exactly these headers.
94
+ const TPL_JSON = 0; // json(): 'application/json'
95
+ const TPL_JSON_UTF8 = 1; // send() of a JSON-looking string
96
+ const TPL_TEXT_UTF8 = 2; // send() of text, sendStatus()
97
+ const TPL_OCTET = 3; // send() of a Buffer
98
+ const TPL_NONE = 4; // end() with no headers set
99
+ const TPL_HEADERS = [
100
+ JSON_HEADER_PAIR,
101
+ ['content-type', 'application/json; charset=utf-8'],
102
+ ['content-type', 'text/plain; charset=utf-8'],
103
+ ['content-type', 'application/octet-stream'],
104
+ null,
105
+ ];
106
+ const TPL_CONTENT_TYPE = TPL_HEADERS.map(h => h?.[1]);
107
+ const TPL_KINDS = TPL_HEADERS.length;
108
+ // Engine-answered static routes take the engine's method index; only the
109
+ // named methods (GET..OPTIONS) can short-circuit - 'OTHER' never does.
110
+ const STATIC_ROUTE_METHODS = 7;
111
+ // [k1, v1, k2, v2, ...] for the engine, from any header shape a caller holds.
112
+ // null when there is nothing to send, so the wire bytes equal respond(null).
113
+ function flattenHeaders(headers) {
114
+ if (headers === undefined || headers === null)
115
+ return null;
116
+ if (Array.isArray(headers))
117
+ return headers.length === 0 ? null : headers.map(String);
118
+ let flat = null;
119
+ for (const key in headers) {
120
+ const value = headers[key];
121
+ if (flat === null)
122
+ flat = [];
123
+ if (Array.isArray(value)) {
124
+ for (let i = 0; i < value.length; i++)
125
+ flat.push(key, String(value[i]));
126
+ }
127
+ else {
128
+ flat.push(key, String(value));
129
+ }
130
+ }
131
+ return flat;
132
+ }
83
133
  // Content-Type sniff for send(): does the body start with '{' or '[' after
84
134
  // optional leading whitespace? Regex `\s` matches exactly the set trimStart()
85
135
  // strips, and the match stops at the first non-space character, so this costs
@@ -266,7 +316,7 @@ export class EngineRequest extends LazyEventEmitter {
266
316
  this._ip = value;
267
317
  }
268
318
  // Minimal net.Socket-shaped shim. `encrypted` reflects whether the engine
269
- // is terminating TLS for this server (engine >= 1.2.0); when it isn't and
319
+ // is terminating TLS for this server (engine >= 1.1.6); when it isn't and
270
320
  // TLS is proxy-terminated, middleware should read `x-forwarded-proto`. This
271
321
  // exists so code written against Node's IncomingMessage (req.socket.encrypted,
272
322
  // the CSRF middleware; req.connection.remoteAddress, rate-limiters) doesn't
@@ -429,6 +479,11 @@ export class EngineResponse extends LazyEventEmitter {
429
479
  // materialize a header object (see _headersFlat / the json fast path)
430
480
  _responseHeaders = undefined;
431
481
  _locals = undefined;
482
+ // The default content-type a template reply carried when the app set no
483
+ // headers: send()/sendStatus()/the framework's own error replies no longer
484
+ // materialise a header object for it, so getHeader('content-type') and a
485
+ // late responseHeaders read answer from here (parity with the old path).
486
+ _impliedContentType = undefined;
432
487
  // Terminal-write latch: headersSent means "head flushed" (true mid-stream
433
488
  // after writeHead/write), _ended means the body is complete
434
489
  _ended = false;
@@ -443,6 +498,11 @@ export class EngineResponse extends LazyEventEmitter {
443
498
  let h = this._responseHeaders;
444
499
  if (h === undefined) {
445
500
  h = {};
501
+ // A reply already sent through a template carried this content-type;
502
+ // a reader materialising the object afterwards (a 'finish' listener,
503
+ // a logger) must still see it.
504
+ if (this._impliedContentType !== undefined)
505
+ h['content-type'] = this._impliedContentType;
446
506
  this._responseHeaders = h;
447
507
  }
448
508
  return h;
@@ -549,7 +609,11 @@ export class EngineResponse extends LazyEventEmitter {
549
609
  }
550
610
  getHeader(name) {
551
611
  const headers = this._responseHeaders;
552
- return headers === undefined ? undefined : headers[name.toLowerCase()];
612
+ const lower = name.toLowerCase();
613
+ if (headers === undefined) {
614
+ return lower === 'content-type' ? this._impliedContentType : undefined;
615
+ }
616
+ return headers[lower];
553
617
  }
554
618
  removeHeader(name) {
555
619
  const headers = this._responseHeaders;
@@ -617,9 +681,16 @@ export class EngineResponse extends LazyEventEmitter {
617
681
  try {
618
682
  const rh = this._responseHeaders;
619
683
  if (rh === undefined && !this._server._compression.enabled) {
620
- // Hot path: no user-set headers, no compression - one native call
621
- // with the shared content-type pair; zero per-response header work
622
- this._server._respond(this._reqId, this.statusCode, JSON_HEADER_PAIR, body);
684
+ // Hot path: no user-set headers, no compression - one native call.
685
+ // With templates (engine >= 1.1.6) the content-type pair was
686
+ // materialised once in the engine; otherwise the shared pair goes
687
+ // with the call. Zero per-response header work either way.
688
+ const tpl = this._server._template(TPL_JSON, this.statusCode);
689
+ const rp = this._server._respondPrepared;
690
+ if (tpl > 0 && rp !== undefined)
691
+ rp(this._reqId, tpl, body);
692
+ else
693
+ this._server._respond(this._reqId, this.statusCode, JSON_HEADER_PAIR, body);
623
694
  this.headersSent = true;
624
695
  this._emitDone();
625
696
  return;
@@ -703,24 +774,72 @@ export class EngineResponse extends LazyEventEmitter {
703
774
  this._emitDone();
704
775
  }
705
776
  }
777
+ // Terminal reply carrying one of the framework's fixed header sets (see
778
+ // TPL_*): through a prepared template when the engine has them, else
779
+ // respond() with the same pair - identical bytes, no header object either
780
+ // way. Callers guarantee the app set no headers of its own.
781
+ _sendTemplated(kind, body) {
782
+ try {
783
+ const tpl = this._server._template(kind, this.statusCode);
784
+ const hasBody = body !== undefined && body !== null && body !== '';
785
+ const rp = this._server._respondPrepared;
786
+ const rpe = this._server._respondPreparedEmpty;
787
+ if (tpl > 0 && rp !== undefined && rpe !== undefined) {
788
+ if (hasBody)
789
+ rp(this._reqId, tpl, body);
790
+ else
791
+ rpe(this._reqId, tpl);
792
+ }
793
+ else {
794
+ this._server._respond(this._reqId, this.statusCode, TPL_HEADERS[kind] ?? null, hasBody ? body : null);
795
+ }
796
+ this.headersSent = true;
797
+ this._emitDone();
798
+ }
799
+ catch (err) {
800
+ this._failSafe('Failed to send response', err);
801
+ }
802
+ }
803
+ /** @internal The framework's own constant JSON replies (404/413/400/500):
804
+ * the same bytes as setHeader('Content-Type','application/json') + end(body)
805
+ * (setHeader lowercases the name), without allocating a header object when
806
+ * the app set none. */
807
+ _sendConstantJson(status, body) {
808
+ if (this.headersSent || this._ended)
809
+ return;
810
+ this.statusCode = status;
811
+ if (this._responseHeaders === undefined) {
812
+ this._impliedContentType = TPL_CONTENT_TYPE[TPL_JSON];
813
+ this._sendTemplated(TPL_JSON, body);
814
+ return;
815
+ }
816
+ this._responseHeaders['content-type'] = 'application/json';
817
+ this.end(body);
818
+ }
706
819
  send(data) {
707
820
  if (this.headersSent || this._ended)
708
821
  return;
709
822
  // Default a Content-Type to match the Node server (parity for the now-
710
823
  // default engine path): JSON for JSON-looking strings, octet-stream for
711
824
  // Buffers, text/plain otherwise.
712
- if (!('content-type' in this.responseHeaders)) {
713
- if (typeof data === 'string') {
714
- this.responseHeaders['content-type'] = JSON_BODY_START.test(data)
715
- ? 'application/json; charset=utf-8'
716
- : 'text/plain; charset=utf-8';
717
- }
718
- else {
719
- this.responseHeaders['content-type'] = 'application/octet-stream';
825
+ const rh = this._responseHeaders;
826
+ if (rh === undefined || !('content-type' in rh)) {
827
+ const kind = typeof data === 'string'
828
+ ? JSON_BODY_START.test(data)
829
+ ? TPL_JSON_UTF8
830
+ : TPL_TEXT_UTF8
831
+ : TPL_OCTET;
832
+ if (rh === undefined && !this._server._compression.enabled) {
833
+ // No app headers, no compression: the implied content-type rides a
834
+ // prepared template (or the shared pair) - no header object at all.
835
+ // Buffers pass through binary-safe (the engine accepts Uint8Array).
836
+ this._impliedContentType = TPL_CONTENT_TYPE[kind];
837
+ this._sendTemplated(kind, data);
838
+ return;
720
839
  }
840
+ this.responseHeaders['content-type'] = TPL_CONTENT_TYPE[kind];
721
841
  }
722
842
  try {
723
- // Buffers pass through binary-safe (the engine accepts Uint8Array).
724
843
  // Compression applies only to compressible content types (isCompressible
725
844
  // excludes octet-stream), so binary sends stay on the sync fast path.
726
845
  this._respondMaybeCompressed(data);
@@ -793,13 +912,36 @@ export class EngineResponse extends LazyEventEmitter {
793
912
  return this;
794
913
  }
795
914
  try {
915
+ const hasBody = data !== undefined && data !== null;
796
916
  if (!this.headersSent) {
797
- // Terminal single-shot: status + headers + body in one native call
798
- this._server._respond(this._reqId, this.statusCode, this._headersFlat(), data !== undefined && data !== null ? data : null);
917
+ // Terminal single-shot: status + headers + body in one native call.
918
+ // No app headers: the "no headers" template (engine >= 1.1.6) or a
919
+ // null header list - identical bytes.
920
+ if (this._responseHeaders === undefined) {
921
+ const tpl = this._server._template(TPL_NONE, this.statusCode);
922
+ const rp = this._server._respondPrepared;
923
+ const rpe = this._server._respondPreparedEmpty;
924
+ if (tpl > 0 && rp !== undefined && rpe !== undefined) {
925
+ if (hasBody)
926
+ rp(this._reqId, tpl, data);
927
+ else
928
+ rpe(this._reqId, tpl);
929
+ }
930
+ else {
931
+ this._server._respond(this._reqId, this.statusCode, null, hasBody ? data : null);
932
+ }
933
+ }
934
+ else {
935
+ this._server._respond(this._reqId, this.statusCode, this._headersFlat(), hasBody ? data : null);
936
+ }
799
937
  this.headersSent = true;
800
938
  }
939
+ else if (hasBody && this._server._endWith !== undefined) {
940
+ // Streaming terminal with a chunk: the fixed-arity twin of end()
941
+ this._server._endWith(this._reqId, data);
942
+ }
801
943
  else {
802
- this._server._end(this._reqId, data !== undefined && data !== null ? data : undefined);
944
+ this._server._end(this._reqId, hasBody ? data : undefined);
803
945
  }
804
946
  this._emitDone();
805
947
  if (typeof callback === 'function')
@@ -1225,6 +1367,11 @@ export class EngineResponse extends LazyEventEmitter {
1225
1367
  // (Node behavior) - never a fabricated "OK" for a non-2xx status.
1226
1368
  const statusString = STATUS_STRINGS.get(code);
1227
1369
  const body = statusString ? statusString.slice(String(code).length + 1) : String(code);
1370
+ if (this._responseHeaders === undefined && !this._ended) {
1371
+ this._impliedContentType = TPL_CONTENT_TYPE[TPL_TEXT_UTF8];
1372
+ this._sendTemplated(TPL_TEXT_UTF8, body);
1373
+ return;
1374
+ }
1228
1375
  this.setHeader('Content-Type', 'text/plain; charset=utf-8');
1229
1376
  this.end(body);
1230
1377
  }
@@ -1305,8 +1452,36 @@ export class MoroEngineServer {
1305
1452
  /** @internal */ _getBody;
1306
1453
  /** @internal */ _getRemoteAddress;
1307
1454
  /** @internal */ _getMethod;
1455
+ // Prepared response templates (engine >= 1.1.6): assigned only when the
1456
+ // engine advertises capabilities.responseTemplates AND the functions exist,
1457
+ // so a flag/binary mismatch can never put `undefined` into a hot call site.
1458
+ /** @internal */ _prepareResponse;
1459
+ /** @internal */ _respondPrepared;
1460
+ /** @internal */ _respondPreparedEmpty;
1461
+ /** @internal */ _endWith;
1462
+ _templatesOn = false;
1463
+ // Engine-answered static routes (engine >= 1.1.6, capabilities.staticRoutes):
1464
+ // a fixed reply for one (method, path) that the engine sends without calling
1465
+ // into JS. Assigned only when the flag AND the function are present (the
1466
+ // same guard as templates). The list is kept because listen() after close()
1467
+ // registers a fresh native server that knows nothing of the old one's routes.
1468
+ /** @internal */ _setStaticRoute;
1469
+ _staticOn = false;
1470
+ staticRoutes = [];
1471
+ // Batched pipelined dispatch (engine >= 1.1.6, capabilities.batchDispatch):
1472
+ // the engine parses complete pipelined requests ahead and delivers them in
1473
+ // one onRequestBatch(count) call; the descriptors/control/paths buffers are
1474
+ // the engine's (getBatchBuffers), fetched once per native server.
1475
+ _batchDesc = undefined;
1476
+ _batchCtl = undefined;
1477
+ _batchPaths = undefined;
1478
+ _batchOn = false;
1479
+ _getBatchBuffers;
1480
+ _getPath;
1481
+ // (kind -> status -> template id), prepared on first use, per engine server
1482
+ _tpl = Array.from({ length: TPL_KINDS }, () => new Map());
1308
1483
  /** @internal read by EngineRequest#protocol/socket.encrypted - true when the
1309
- * engine is terminating TLS for this server (engine >= 1.2.0 + ssl passed) */
1484
+ * engine is terminating TLS for this server (engine >= 1.1.6 + ssl passed) */
1310
1485
  isSsl = false;
1311
1486
  serverId;
1312
1487
  globalMiddleware = [];
@@ -1350,6 +1525,8 @@ export class MoroEngineServer {
1350
1525
  // Set by close(); listen() then registers a fresh native server, because the
1351
1526
  // old serverId's uv handles are torn down and must never be reused.
1352
1527
  closed = false;
1528
+ // See _armMicrotaskDrain.
1529
+ _drainArmed = false;
1353
1530
  // Direct router dispatch (set from Moro.listen via setRouterHandler)
1354
1531
  routerHandler;
1355
1532
  // Global error handler (Moro.setErrorHandler) - shapes errors thrown from
@@ -1401,6 +1578,28 @@ export class MoroEngineServer {
1401
1578
  this._getBody = surface.getBody;
1402
1579
  this._getRemoteAddress = surface.getRemoteAddress;
1403
1580
  this._getMethod = surface.getMethod;
1581
+ if (this._capabilities?.responseTemplates === true &&
1582
+ typeof surface.prepareResponse === 'function' &&
1583
+ typeof surface.respondPrepared === 'function' &&
1584
+ typeof surface.respondPreparedEmpty === 'function' &&
1585
+ typeof surface.endWith === 'function') {
1586
+ this._prepareResponse = surface.prepareResponse;
1587
+ this._respondPrepared = surface.respondPrepared;
1588
+ this._respondPreparedEmpty = surface.respondPreparedEmpty;
1589
+ this._endWith = surface.endWith;
1590
+ this._templatesOn = true;
1591
+ }
1592
+ if (this._capabilities?.staticRoutes === true && typeof surface.setStaticRoute === 'function') {
1593
+ this._setStaticRoute = surface.setStaticRoute;
1594
+ this._staticOn = true;
1595
+ }
1596
+ if (this._capabilities?.batchDispatch === true &&
1597
+ typeof surface.getBatchBuffers === 'function' &&
1598
+ typeof surface.getPath === 'function') {
1599
+ this._getBatchBuffers = surface.getBatchBuffers;
1600
+ this._getPath = surface.getPath;
1601
+ this._batchOn = true;
1602
+ }
1404
1603
  // TLS: pass it through when the engine supports it; otherwise keep the
1405
1604
  // (now capability-gated) warning so a proxy/node fallback is clear.
1406
1605
  if (this._ssl && this._capabilities?.tls) {
@@ -1436,6 +1635,89 @@ export class MoroEngineServer {
1436
1635
  _complete(reqId) {
1437
1636
  this.inflight.delete(reqId);
1438
1637
  }
1638
+ /** @internal Template id for one of the framework's fixed header sets
1639
+ * (TPL_*) at a status, materialised in the engine on first use; -1 when
1640
+ * the engine has no templates (callers then respond() with the same
1641
+ * headers) or the engine's per-server store is full. */
1642
+ _template(kind, status) {
1643
+ const prep = this._prepareResponse;
1644
+ if (!this._templatesOn || prep === undefined)
1645
+ return -1;
1646
+ let m = this._tpl[kind];
1647
+ if (m === undefined) {
1648
+ m = new Map();
1649
+ this._tpl[kind] = m;
1650
+ }
1651
+ let id = m.get(status);
1652
+ if (id === undefined) {
1653
+ try {
1654
+ id = prep(this.serverId, status, TPL_HEADERS[kind]);
1655
+ }
1656
+ catch {
1657
+ id = -1; // store full (4096/server) - a caller bug elsewhere; respond() still works
1658
+ }
1659
+ m.set(status, id);
1660
+ }
1661
+ return id;
1662
+ }
1663
+ // Template ids belong to one native server: forget them whenever that
1664
+ // server is replaced (close() then listen() registers a fresh one).
1665
+ _resetTemplates() {
1666
+ for (const m of this._tpl)
1667
+ m.clear();
1668
+ }
1669
+ /** Whether this engine build answers static routes itself. */
1670
+ get staticRoutesEnabled() {
1671
+ return this._staticOn;
1672
+ }
1673
+ /**
1674
+ * Register a fixed reply for one (method, path) that the engine serves
1675
+ * without calling into JS: no routing, no middleware, no hooks and no
1676
+ * header building per request. The bytes on the wire are those of
1677
+ * respond(status, headers, body). Returns false when this engine cannot
1678
+ * take it (no capability, or a method the engine does not index), so the
1679
+ * caller keeps its JS handler as the answer. Registering the same
1680
+ * (method, path) again replaces the earlier reply.
1681
+ */
1682
+ setStaticRoute(method, path, status = 200, headers, body) {
1683
+ const fn = this._setStaticRoute;
1684
+ if (!this._staticOn || fn === undefined)
1685
+ return false;
1686
+ // The engine sends the stored bytes as they are; with compression on, the
1687
+ // JS handler would negotiate an encoding, so the two replies would differ.
1688
+ if (this._compression.enabled)
1689
+ return false;
1690
+ const methodIdx = METHODS.indexOf(method.toUpperCase());
1691
+ if (methodIdx < 0 || methodIdx >= STATIC_ROUTE_METHODS)
1692
+ return false;
1693
+ const headersFlat = flattenHeaders(headers);
1694
+ const fixedBody = body === undefined ? null : body;
1695
+ const entry = { methodIdx, path, status, headersFlat, body: fixedBody };
1696
+ const at = this.staticRoutes.findIndex(r => r.methodIdx === methodIdx && r.path === path);
1697
+ if (at === -1)
1698
+ this.staticRoutes.push(entry);
1699
+ else
1700
+ this.staticRoutes[at] = entry;
1701
+ fn(this.serverId, methodIdx, path, status, headersFlat, fixedBody);
1702
+ return true;
1703
+ }
1704
+ /** Drop every engine-answered static route; the JS routes are untouched. */
1705
+ clearStaticRoutes() {
1706
+ this.staticRoutes.length = 0;
1707
+ if (this._staticOn && typeof this._engine.clearStaticRoutes === 'function') {
1708
+ this._engine.clearStaticRoutes(this.serverId);
1709
+ }
1710
+ }
1711
+ // listen() after close() registers a fresh native server; give it every
1712
+ // static route the old one had.
1713
+ _reapplyStaticRoutes() {
1714
+ const fn = this._setStaticRoute;
1715
+ if (fn === undefined)
1716
+ return;
1717
+ for (const r of this.staticRoutes) {
1718
+ fn(this.serverId, r.methodIdx, r.path, r.status, r.headersFlat, r.body);
1719
+ }
1720
+ }
1439
1721
  // Register callbacks + limits with the native engine, returning the new
1440
1722
  // serverId. Called from the constructor and again by listen() after a
1441
1723
  // close() (the old native server is gone; a restart needs a fresh one).
@@ -1492,14 +1774,70 @@ export class MoroEngineServer {
1492
1774
  ...(this._http2Settings.settings ?? {}),
1493
1775
  };
1494
1776
  }
1495
- return this._engine.serve({
1496
- onRequest: (reqId, methodIdx, path) => this.onRequest(reqId, methodIdx, path),
1777
+ const callbacks = {
1778
+ onRequest: (reqId, methodIdx, path) => this.dispatchOne(reqId, methodIdx, path),
1497
1779
  onAborted: (reqId) => this.onAborted(reqId),
1498
1780
  onWritable: (reqId) => this.onWritable(reqId),
1499
1781
  onWsOpen: (wsId, path) => this._wsBridge?.onOpen(wsId, path, this._pendingWsInfo),
1500
1782
  onWsMessage: (wsId, data, isBinary) => this._wsBridge?.onMessage(wsId, data, isBinary),
1501
1783
  onWsClose: (wsId, code) => this._wsBridge?.onClose(wsId, code),
1502
- }, serveOptions);
1784
+ };
1785
+ if (this._batchOn)
1786
+ callbacks.onRequestBatch = (count) => this.onRequestBatch(count);
1787
+ const serverId = this._engine.serve(callbacks, serveOptions);
1788
+ if (this._batchOn && this._getBatchBuffers) {
1789
+ const b = this._getBatchBuffers(serverId);
1790
+ this._batchDesc = b.descriptors;
1791
+ this._batchCtl = b.control;
1792
+ this._batchPaths = b.paths;
1793
+ }
1794
+ return serverId;
1795
+ }
1796
+ // Batched pipelined dispatch: `count` requests are ready, described three
1797
+ // Uint32 per slot (reqId, methodIdx, pathIdx). Dispatch in order; after
1798
+ // each, the control cell says which slot the engine has activated (the next
1799
+ // one only if this handler completed synchronously) - continue there, or
1800
+ // stop when it still points at the slot just dispatched (async handler;
1801
+ // the engine re-delivers the rest once that response completes).
1802
+ onRequestBatch(count) {
1803
+ const d = this._batchDesc;
1804
+ const ctl = this._batchCtl;
1805
+ const paths = this._batchPaths;
1806
+ const getPath = this._getPath;
1807
+ if (d === undefined || ctl === undefined || paths === undefined || getPath === undefined)
1808
+ return 0;
1809
+ let i = 0;
1810
+ for (;;) {
1811
+ const reqId = d[3 * i];
1812
+ const methodIdx = d[3 * i + 1];
1813
+ const pi = d[3 * i + 2];
1814
+ const path = pi === 0xffffffff ? getPath(reqId) : paths[pi];
1815
+ this.dispatchOne(reqId, methodIdx, path);
1816
+ const next = ctl[0];
1817
+ if (next === i)
1818
+ return i + 1;
1819
+ if (next >= count)
1820
+ return count;
1821
+ i = next;
1822
+ }
1823
+ }
1824
+ // One request from the engine (single dispatch or a batch slot).
1825
+ dispatchOne(reqId, methodIdx, path) {
1826
+ try {
1827
+ this.onRequest(reqId, methodIdx, path);
1828
+ }
1829
+ catch (err) {
1830
+ // A synchronous throw out of dispatch must not stall the pipelined
1831
+ // requests behind this one: answer 500 here (the async paths have
1832
+ // their own error boundary inside handleRequest).
1833
+ this.logger.error(`Unhandled error dispatching ${path}: ${err?.message ?? err}`, 'Request');
1834
+ try {
1835
+ this._respond(reqId, 500, ['content-type', 'application/json'], '{"error":"Internal Server Error"}');
1836
+ }
1837
+ catch {
1838
+ // already answered or gone
1839
+ }
1840
+ }
1503
1841
  }
1504
1842
  // ---- WebSocket bridge (used by EngineWebSocketAdapter) ----
1505
1843
  /** Register the engine WebSocket bridge; upgrade requests then route natively. */
@@ -1762,7 +2100,25 @@ export class MoroEngineServer {
1762
2100
  if (!httpRes.writableEnded) {
1763
2101
  httpRes._registered = true;
1764
2102
  this.inflight.set(reqId, httpRes);
1765
- }
2103
+ this._armMicrotaskDrain();
2104
+ }
2105
+ }
2106
+ // The engine invokes onRequest with a plain V8 call from its own uv
2107
+ // callback, outside a Node callback scope, so microtasks queued during
2108
+ // dispatch - an await on an already-settled promise, a .then chain, the
2109
+ // router's own async 404 continuation - do not run when the callback
2110
+ // returns. They run at the next Node-managed callback, which on a quiet
2111
+ // server is whatever timer fires next: seconds away. A no-op immediate is
2112
+ // that callback: Node drains nextTicks and microtasks when its scope
2113
+ // closes, in this loop iteration's check phase. One per burst is enough,
2114
+ // so it is armed once and re-armed only after it has fired.
2115
+ _armMicrotaskDrain() {
2116
+ if (this._drainArmed)
2117
+ return;
2118
+ this._drainArmed = true;
2119
+ setImmediate(() => {
2120
+ this._drainArmed = false;
2121
+ });
1766
2122
  }
1767
2123
  onAborted(reqId) {
1768
2124
  const httpRes = this.inflight.get(reqId);
@@ -1783,9 +2139,7 @@ export class MoroEngineServer {
1783
2139
  if (await this.dispatchDirectRoute(httpReq, httpRes))
1784
2140
  return;
1785
2141
  if (!httpRes.headersSent && !httpRes.writableEnded) {
1786
- httpRes.statusCode = 404;
1787
- httpRes.setHeader('Content-Type', 'application/json');
1788
- httpRes.end('{"success":false,"error":"Not found"}');
2142
+ httpRes._sendConstantJson(404, '{"success":false,"error":"Not found"}');
1789
2143
  }
1790
2144
  }
1791
2145
  catch (error) {
@@ -1854,9 +2208,7 @@ export class MoroEngineServer {
1854
2208
  return;
1855
2209
  // No route matched
1856
2210
  if (!httpRes.headersSent && !httpRes.writableEnded) {
1857
- httpRes.statusCode = 404;
1858
- httpRes.setHeader('Content-Type', 'application/json');
1859
- httpRes.end('{"success":false,"error":"Not found"}');
2211
+ httpRes._sendConstantJson(404, '{"success":false,"error":"Not found"}');
1860
2212
  }
1861
2213
  }
1862
2214
  catch (error) {
@@ -1869,16 +2221,12 @@ export class MoroEngineServer {
1869
2221
  {
1870
2222
  // Payload-too-large: respond 413 rather than a generic 500
1871
2223
  if (error?.statusCode === 413 && !httpRes.writableEnded && !httpRes.headersSent) {
1872
- httpRes.statusCode = 413;
1873
- httpRes.setHeader('Content-Type', 'application/json');
1874
- httpRes.end('{"success":false,"error":"Request entity too large"}');
2224
+ httpRes._sendConstantJson(413, '{"success":false,"error":"Request entity too large"}');
1875
2225
  return;
1876
2226
  }
1877
2227
  // Malformed body (parseBody): a client error, not a server error
1878
2228
  if (error?.statusCode === 400 && !httpRes.writableEnded && !httpRes.headersSent) {
1879
- httpRes.statusCode = 400;
1880
- httpRes.setHeader('Content-Type', 'application/json');
1881
- httpRes.end('{"success":false,"error":"Invalid request body"}');
2229
+ httpRes._sendConstantJson(400, '{"success":false,"error":"Invalid request body"}');
1882
2230
  return;
1883
2231
  }
1884
2232
  this.logger.error(`Request handling error: ${error instanceof Error ? error.message : String(error)}`, 'RequestError');
@@ -1903,9 +2251,7 @@ export class MoroEngineServer {
1903
2251
  // Send error response if not already sent
1904
2252
  if (!httpRes.writableEnded && !httpRes.headersSent) {
1905
2253
  try {
1906
- httpRes.statusCode = 500;
1907
- httpRes.setHeader('Content-Type', 'application/json');
1908
- httpRes.end('{"success":false,"error":"Internal server error"}');
2254
+ httpRes._sendConstantJson(500, '{"success":false,"error":"Internal server error"}');
1909
2255
  }
1910
2256
  catch {
1911
2257
  this.logger.error('Failed to send error response', 'ResponseError');
@@ -2107,6 +2453,8 @@ export class MoroEngineServer {
2107
2453
  // http.Server supports close() -> listen() cycles.
2108
2454
  if (this.closed) {
2109
2455
  this.serverId = this.registerWithEngine();
2456
+ this._resetTemplates();
2457
+ this._reapplyStaticRoutes();
2110
2458
  this.closed = false;
2111
2459
  }
2112
2460
  // Synchronous bind; throws on bind errors, returns the actual port
@@ -2157,6 +2505,7 @@ export class MoroEngineServer {
2157
2505
  return;
2158
2506
  }
2159
2507
  this.inflight.clear();
2508
+ this._resetTemplates();
2160
2509
  this.logger.info('Moro engine HTTP server closed', 'Close');
2161
2510
  if (callback) {
2162
2511
  // Give the event loop time to process the engine's socket teardown