@morojs/moro 1.8.10 → 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.
package/README.md CHANGED
@@ -68,6 +68,10 @@ import { createApp, z } from '@morojs/moro';
68
68
  return { success: true, data: req.body };
69
69
  });
70
70
 
71
+ // A literal body is the whole handler: on Moro's native engine it is
72
+ // answered without entering JS at all
73
+ app.get('/health').handler('ok');
74
+
71
75
  app.listen(3000);
72
76
  })();
73
77
  ```
@@ -191,6 +191,9 @@ export declare class MoroEngineServer {
191
191
  /** @internal */ _respondPreparedEmpty?: (reqId: number, tplId: number) => void;
192
192
  /** @internal */ _endWith?: (reqId: number, chunk: any) => void;
193
193
  private _templatesOn;
194
+ /** @internal */ _setStaticRoute?: (serverId: number, method: number, path: string, status: number, headersFlat: string[] | null, body: any) => void;
195
+ private _staticOn;
196
+ private staticRoutes;
194
197
  private _batchDesc;
195
198
  private _batchCtl;
196
199
  private _batchPaths;
@@ -225,6 +228,7 @@ export declare class MoroEngineServer {
225
228
  /** @internal read by EngineResponse for buffered-response compression */
226
229
  _compression: CompressionSettings;
227
230
  private closed;
231
+ private _drainArmed;
228
232
  private routerHandler?;
229
233
  private errorHandler?;
230
234
  private directRoutes;
@@ -256,6 +260,21 @@ export declare class MoroEngineServer {
256
260
  * headers) or the engine's per-server store is full. */
257
261
  _template(kind: number, status: number): number;
258
262
  private _resetTemplates;
263
+ /** Whether this engine build answers static routes itself. */
264
+ get staticRoutesEnabled(): boolean;
265
+ /**
266
+ * Register a fixed reply for one (method, path) that the engine serves
267
+ * without calling into JS: no routing, no middleware, no hooks and no
268
+ * header building per request. The bytes on the wire are those of
269
+ * respond(status, headers, body). Returns false when this engine cannot
270
+ * take it (no capability, or a method the engine does not index), so the
271
+ * caller keeps its JS handler as the answer. Registering the same
272
+ * (method, path) again replaces the earlier reply.
273
+ */
274
+ setStaticRoute(method: string, path: string, status?: number, headers?: Record<string, string | number | string[]> | string[] | null, body?: string | Buffer | Uint8Array | ArrayBuffer | null): boolean;
275
+ /** Drop every engine-answered static route; the JS routes are untouched. */
276
+ clearStaticRoutes(): void;
277
+ private _reapplyStaticRoutes;
259
278
  private registerWithEngine;
260
279
  private onRequestBatch;
261
280
  private dispatchOne;
@@ -288,6 +307,7 @@ export declare class MoroEngineServer {
288
307
  private dispatchDirectRoute;
289
308
  setNativeRouteProvider(_provider: () => Array<any>, _onError?: (...args: any[]) => any): void;
290
309
  private onRequest;
310
+ private _armMicrotaskDrain;
291
311
  private onAborted;
292
312
  private onWritable;
293
313
  private finishUnrouted;
@@ -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';
@@ -104,6 +105,31 @@ const TPL_HEADERS = [
104
105
  ];
105
106
  const TPL_CONTENT_TYPE = TPL_HEADERS.map(h => h?.[1]);
106
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
+ }
107
133
  // Content-Type sniff for send(): does the body start with '{' or '[' after
108
134
  // optional leading whitespace? Regex `\s` matches exactly the set trimStart()
109
135
  // strips, and the match stops at the first non-space character, so this costs
@@ -1434,6 +1460,14 @@ export class MoroEngineServer {
1434
1460
  /** @internal */ _respondPreparedEmpty;
1435
1461
  /** @internal */ _endWith;
1436
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 = [];
1437
1471
  // Batched pipelined dispatch (engine >= 1.1.6, capabilities.batchDispatch):
1438
1472
  // the engine parses complete pipelined requests ahead and delivers them in
1439
1473
  // one onRequestBatch(count) call; the descriptors/control/paths buffers are
@@ -1491,6 +1525,8 @@ export class MoroEngineServer {
1491
1525
  // Set by close(); listen() then registers a fresh native server, because the
1492
1526
  // old serverId's uv handles are torn down and must never be reused.
1493
1527
  closed = false;
1528
+ // See _armMicrotaskDrain.
1529
+ _drainArmed = false;
1494
1530
  // Direct router dispatch (set from Moro.listen via setRouterHandler)
1495
1531
  routerHandler;
1496
1532
  // Global error handler (Moro.setErrorHandler) - shapes errors thrown from
@@ -1553,6 +1589,10 @@ export class MoroEngineServer {
1553
1589
  this._endWith = surface.endWith;
1554
1590
  this._templatesOn = true;
1555
1591
  }
1592
+ if (this._capabilities?.staticRoutes === true && typeof surface.setStaticRoute === 'function') {
1593
+ this._setStaticRoute = surface.setStaticRoute;
1594
+ this._staticOn = true;
1595
+ }
1556
1596
  if (this._capabilities?.batchDispatch === true &&
1557
1597
  typeof surface.getBatchBuffers === 'function' &&
1558
1598
  typeof surface.getPath === 'function') {
@@ -1626,6 +1666,58 @@ export class MoroEngineServer {
1626
1666
  for (const m of this._tpl)
1627
1667
  m.clear();
1628
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
+ }
1629
1721
  // Register callbacks + limits with the native engine, returning the new
1630
1722
  // serverId. Called from the constructor and again by listen() after a
1631
1723
  // close() (the old native server is gone; a restart needs a fresh one).
@@ -2008,7 +2100,25 @@ export class MoroEngineServer {
2008
2100
  if (!httpRes.writableEnded) {
2009
2101
  httpRes._registered = true;
2010
2102
  this.inflight.set(reqId, httpRes);
2011
- }
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
+ });
2012
2122
  }
2013
2123
  onAborted(reqId) {
2014
2124
  const httpRes = this.inflight.get(reqId);
@@ -2344,6 +2454,7 @@ export class MoroEngineServer {
2344
2454
  if (this.closed) {
2345
2455
  this.serverId = this.registerWithEngine();
2346
2456
  this._resetTemplates();
2457
+ this._reapplyStaticRoutes();
2347
2458
  this.closed = false;
2348
2459
  }
2349
2460
  // Synchronous bind; throws on bind errors, returns the actual port