@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
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
  ```
@@ -0,0 +1,178 @@
1
+ import type { WorkerOptions } from 'node:worker_threads';
2
+ /** Key under workerData that marks a Moro cluster worker thread. */
3
+ export declare const WORKER_DATA_KEY = "moroCluster";
4
+ /**
5
+ * The process path pins each worker's V8 young generation with
6
+ * `--max-semi-space-size=4` (A/B-benched 2026-08-01: throughput parity,
7
+ * ~30 MB RSS per worker saved). Worker threads reject V8 flags in execArgv;
8
+ * the equivalent knob is resourceLimits.maxYoungGenerationSizeMb, and V8
9
+ * sizes the young generation at 3x the semi-space: 4 MB semi-space == 12 MB.
10
+ */
11
+ export declare const YOUNG_GEN_MB_FOR_SEMI_SPACE_4 = 12;
12
+ export interface ThreadWorkerInfo {
13
+ index: number;
14
+ workers: number;
15
+ }
16
+ /** Is this thread a Moro cluster worker (the thread analogue of cluster.isWorker)? */
17
+ export declare function threadWorkerInfo(data?: unknown): ThreadWorkerInfo | null;
18
+ export interface ClusteringConfig {
19
+ enabled?: boolean;
20
+ workers?: number | 'auto';
21
+ memoryPerWorkerGB?: number | undefined;
22
+ }
23
+ export interface SystemInfo {
24
+ cpus: number;
25
+ totalMemGB: number;
26
+ }
27
+ /**
28
+ * Worker count: a user number is respected; 'auto' (or unset) is
29
+ * min(cpus, floor(totalRAM / memoryPerWorkerGB)) where memoryPerWorkerGB
30
+ * defaults to max(0.5, floor((totalRAM - 4 GB headroom) / cpus)). Never < 1.
31
+ * (Lifted unchanged from the process path so both transports agree.)
32
+ */
33
+ export declare function computeWorkerCount(cfg: ClusteringConfig | undefined, sys?: SystemInfo): {
34
+ count: number;
35
+ detail: string;
36
+ };
37
+ /** Did the operator tune the young generation themselves? Then leave it alone. */
38
+ export declare function isUserTunedYoungGen(execArgv?: readonly string[], nodeOptions?: string | undefined): boolean;
39
+ export type ClusterTransport = {
40
+ kind: 'threads';
41
+ entry: string;
42
+ } | {
43
+ kind: 'processes';
44
+ reason: string;
45
+ };
46
+ export interface TransportInput {
47
+ platform: NodeJS.Platform;
48
+ isMainThread: boolean;
49
+ argv1: string | undefined;
50
+ /** app.engine.server: 'engine' | 'node' | 'uws' */
51
+ engineServer: string;
52
+ /** app.engine.enginePackage, e.g. '@morojs/engine' */
53
+ enginePackage?: string | undefined;
54
+ /** engine capabilities.workerThreads (teardown-safe inside a thread) */
55
+ workerThreadsCapable: boolean;
56
+ fileExists?: (p: string) => boolean;
57
+ }
58
+ /**
59
+ * Threads iff every precondition holds; otherwise processes, with the reason
60
+ * (logged once). The reasons are the exact conditions the process path still
61
+ * covers, so nothing that worked before loses clustering.
62
+ */
63
+ export declare function resolveClusterTransport(input: TransportInput): ClusterTransport;
64
+ export type WorkerToPrimary = {
65
+ type: 'ready';
66
+ threadId: number;
67
+ port: number;
68
+ } | {
69
+ type: 'closed';
70
+ threadId: number;
71
+ } | {
72
+ type: 'pong';
73
+ threadId: number;
74
+ inflight: number;
75
+ } | {
76
+ type: 'fatal';
77
+ threadId: number;
78
+ code: string;
79
+ message: string;
80
+ };
81
+ export type PrimaryToWorker = {
82
+ type: 'shutdown';
83
+ } | {
84
+ type: 'ping';
85
+ };
86
+ /** The subset of worker_threads.Worker the primary uses (fakeable). */
87
+ export interface WorkerLike {
88
+ threadId: number;
89
+ on(event: string, listener: (...args: any[]) => void): this;
90
+ once(event: string, listener: (...args: any[]) => void): this;
91
+ off(event: string, listener: (...args: any[]) => void): this;
92
+ postMessage(message: PrimaryToWorker): void;
93
+ terminate(): Promise<number>;
94
+ unref(): void;
95
+ ref(): void;
96
+ }
97
+ export type SpawnWorker = (entry: string, options: WorkerOptions) => WorkerLike;
98
+ export interface ClusterLogger {
99
+ info(message: string, context?: string, ...rest: any[]): void;
100
+ warn(message: string, context?: string, ...rest: any[]): void;
101
+ error(message: string, context?: string, ...rest: any[]): void;
102
+ debug?(message: string, context?: string, ...rest: any[]): void;
103
+ }
104
+ export interface WorkerRecord {
105
+ worker: WorkerLike;
106
+ index: number;
107
+ startedAt: number;
108
+ ready: boolean;
109
+ port: number | null;
110
+ restarts: number;
111
+ missedPongs: number;
112
+ }
113
+ export type FatalKind = 'boot' | 'bind';
114
+ export interface ThreadClusterPrimaryOptions {
115
+ entry: string;
116
+ argv: string[];
117
+ workerCount: number;
118
+ logger: ClusterLogger;
119
+ resourceLimits?: WorkerOptions['resourceLimits'];
120
+ /** First 'ready' from any worker (fires once). */
121
+ onReady: () => void;
122
+ /** A worker failed before the cluster ever became ready ('boot': spawn or
123
+ * module error - the caller may fall back to processes; 'bind': the port
124
+ * cannot be bound - processes would fail identically, surface it). */
125
+ onFatal: (error: Error, kind: FatalKind) => void;
126
+ spawn?: SpawnWorker;
127
+ now?: () => number;
128
+ setTimer?: (fn: () => void, ms: number) => any;
129
+ clearTimer?: (t: any) => void;
130
+ healthIntervalMs?: number;
131
+ }
132
+ export declare class ThreadClusterPrimary {
133
+ private readonly opts;
134
+ private readonly spawnFn;
135
+ private readonly now;
136
+ private readonly setTimer;
137
+ private readonly clearTimer;
138
+ private readonly records;
139
+ private readonly backoff;
140
+ private readyFired;
141
+ private shuttingDown;
142
+ private healthTimer;
143
+ private pendingTimers;
144
+ constructor(opts: ThreadClusterPrimaryOptions);
145
+ get workers(): ReadonlyMap<number, WorkerRecord>;
146
+ /** True until every initial worker has reported 'ready'. */
147
+ get bootPhase(): boolean;
148
+ /** Spawn every worker. Throws synchronously if the FIRST spawn throws (the
149
+ * entry cannot be started as a thread at all). */
150
+ start(): void;
151
+ private spawnAt;
152
+ private scheduleRestart;
153
+ private healthTick;
154
+ /**
155
+ * Post 'shutdown' to every worker (each drains its server via app.close()),
156
+ * wait for their 'exit's up to the deadline, then terminate() stragglers.
157
+ * Resolves once every thread is gone.
158
+ */
159
+ shutdown(deadlineMs?: number): Promise<void>;
160
+ }
161
+ /** The worker-thread side: a thin wrapper over parentPort. */
162
+ export declare class ThreadClusterWorker {
163
+ private readonly port;
164
+ private readonly tid;
165
+ private inflight;
166
+ private readonly logger;
167
+ private shutdownHandler;
168
+ constructor(logger: ClusterLogger);
169
+ private post;
170
+ private runShutdown;
171
+ onShutdown(fn: () => Promise<void> | void): void;
172
+ inflightProvider(fn: () => number): void;
173
+ ready(port: number): void;
174
+ fatal(code: string, message: string): void;
175
+ /** Signal completion and release the port so the thread can exit naturally
176
+ * once its handles are closed (never process.exit() inside a thread). */
177
+ close(): void;
178
+ }
@@ -0,0 +1,389 @@
1
+ // Worker-thread clustering for the native engine backend.
2
+ //
3
+ // `performance.clustering` keeps its keys; what changes is the transport
4
+ // underneath: on POSIX, with @morojs/engine >= 1.1.6 (capabilities.workerThreads),
5
+ // the workers are worker_threads inside ONE process, each binding the port
6
+ // with SO_REUSEPORT exactly as the process workers did. Everything that
7
+ // cannot be a thread stays on node:cluster automatically - Windows (no
8
+ // SO_REUSEPORT), the node/uWS engines, an older engine, a REPL/eval entry,
9
+ // or a worker thread that fails to boot (see resolveClusterTransport and the
10
+ // boot-phase fallback in Moro.startThreadPrimary).
11
+ //
12
+ // Why threads: one process shares the node binary, the addon and the code
13
+ // pages, so per-worker RSS drops; there is one pid to supervise; and a
14
+ // worker crash is an 'error'/'exit' event on the primary instead of a
15
+ // process death. The trade-off (stated in the release notes): a NATIVE crash
16
+ // in one thread takes the whole process down, where processes isolate.
17
+ //
18
+ // This module is internal (not exported from src/index.ts). Everything here
19
+ // is dependency-injectable so the sequencing (spawn, restart backoff, health
20
+ // pings, bounded shutdown) is unit-tested with a fake Worker and fake timers.
21
+ import { requireBuiltin } from '../utilities/builtin.js';
22
+ import { existsSync } from 'fs';
23
+ import { resolve as resolvePath } from 'path';
24
+ // Loaded on first use (core/utilities/builtin.ts): an app that never clusters
25
+ // never pays for worker_threads or os.
26
+ const wt = () => requireBuiltin('worker_threads');
27
+ const os = () => requireBuiltin('os');
28
+ /** Key under workerData that marks a Moro cluster worker thread. */
29
+ export const WORKER_DATA_KEY = 'moroCluster';
30
+ /**
31
+ * The process path pins each worker's V8 young generation with
32
+ * `--max-semi-space-size=4` (A/B-benched 2026-08-01: throughput parity,
33
+ * ~30 MB RSS per worker saved). Worker threads reject V8 flags in execArgv;
34
+ * the equivalent knob is resourceLimits.maxYoungGenerationSizeMb, and V8
35
+ * sizes the young generation at 3x the semi-space: 4 MB semi-space == 12 MB.
36
+ */
37
+ export const YOUNG_GEN_MB_FOR_SEMI_SPACE_4 = 12;
38
+ /** Is this thread a Moro cluster worker (the thread analogue of cluster.isWorker)? */
39
+ export function threadWorkerInfo(data = wt().workerData) {
40
+ if (wt().isMainThread)
41
+ return null;
42
+ const d = data;
43
+ const info = d && typeof d === 'object' ? d[WORKER_DATA_KEY] : undefined;
44
+ if (!info || typeof info !== 'object' || typeof info.index !== 'number')
45
+ return null;
46
+ return { index: info.index, workers: typeof info.workers === 'number' ? info.workers : 1 };
47
+ }
48
+ function systemInfo() {
49
+ return { cpus: os().cpus().length, totalMemGB: os().totalmem() / (1024 * 1024 * 1024) };
50
+ }
51
+ /**
52
+ * Worker count: a user number is respected; 'auto' (or unset) is
53
+ * min(cpus, floor(totalRAM / memoryPerWorkerGB)) where memoryPerWorkerGB
54
+ * defaults to max(0.5, floor((totalRAM - 4 GB headroom) / cpus)). Never < 1.
55
+ * (Lifted unchanged from the process path so both transports agree.)
56
+ */
57
+ export function computeWorkerCount(cfg, sys = systemInfo()) {
58
+ const requested = cfg?.workers ?? 'auto';
59
+ if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {
60
+ return {
61
+ count: Math.floor(requested),
62
+ detail: `Using user-specified worker count: ${Math.floor(requested)}`,
63
+ };
64
+ }
65
+ const cpuCount = Math.max(1, sys.cpus);
66
+ const totalMemoryGB = sys.totalMemGB;
67
+ let memoryPerWorkerGB = cfg?.memoryPerWorkerGB;
68
+ if (!memoryPerWorkerGB) {
69
+ const headroomGB = 4;
70
+ memoryPerWorkerGB = Math.max(0.5, Math.floor((totalMemoryGB - headroomGB) / cpuCount));
71
+ }
72
+ const count = Math.max(1, Math.min(cpuCount, Math.floor(totalMemoryGB / memoryPerWorkerGB)));
73
+ return {
74
+ count,
75
+ detail: `Auto-calculated worker count: ${count} (CPU: ${cpuCount}, RAM: ${totalMemoryGB.toFixed(1)}GB, ${memoryPerWorkerGB}GB per worker)`,
76
+ };
77
+ }
78
+ /** Did the operator tune the young generation themselves? Then leave it alone. */
79
+ export function isUserTunedYoungGen(execArgv = process.execArgv, nodeOptions = process.env.NODE_OPTIONS) {
80
+ return (execArgv.some(a => a.includes('--max-semi-space-size')) ||
81
+ (nodeOptions || '').includes('--max-semi-space-size'));
82
+ }
83
+ /**
84
+ * Threads iff every precondition holds; otherwise processes, with the reason
85
+ * (logged once). The reasons are the exact conditions the process path still
86
+ * covers, so nothing that worked before loses clustering.
87
+ */
88
+ export function resolveClusterTransport(input) {
89
+ const exists = input.fileExists ?? existsSync;
90
+ if (input.platform === 'win32')
91
+ return { kind: 'processes', reason: 'Windows has no SO_REUSEPORT' };
92
+ if (input.engineServer !== 'engine' || input.enginePackage !== '@morojs/engine') {
93
+ return { kind: 'processes', reason: `engine '${input.engineServer}' clusters as processes` };
94
+ }
95
+ if (!input.workerThreadsCapable) {
96
+ return {
97
+ kind: 'processes',
98
+ reason: 'this @morojs/engine build predates worker-thread support (< 1.1.6)',
99
+ };
100
+ }
101
+ if (!input.isMainThread)
102
+ return { kind: 'processes', reason: 'not on the main thread' };
103
+ if (typeof input.argv1 !== 'string' || input.argv1.length === 0) {
104
+ return { kind: 'processes', reason: 'no entry script (REPL/eval)' };
105
+ }
106
+ const entry = resolvePath(input.argv1);
107
+ if (!exists(entry))
108
+ return { kind: 'processes', reason: `entry script not found: ${entry}` };
109
+ return { kind: 'threads', entry };
110
+ }
111
+ const RESTART_BASE_MS = 200;
112
+ const RESTART_MAX_MS = 5000;
113
+ const RESTART_WINDOW_MS = 60_000; // a worker alive this long resets its backoff
114
+ const HEALTH_INTERVAL_MS = 30_000;
115
+ const HEALTH_MISSES_BEFORE_WARN = 3;
116
+ const defaultSpawn = (entry, options) => new (wt().Worker)(entry, options);
117
+ export class ThreadClusterPrimary {
118
+ opts;
119
+ spawnFn;
120
+ now;
121
+ setTimer;
122
+ clearTimer;
123
+ records = new Map();
124
+ backoff = new Map();
125
+ readyFired = false;
126
+ shuttingDown = false;
127
+ healthTimer = null;
128
+ pendingTimers = new Set();
129
+ constructor(opts) {
130
+ this.opts = opts;
131
+ this.spawnFn = opts.spawn ?? defaultSpawn;
132
+ this.now = opts.now ?? Date.now;
133
+ this.setTimer =
134
+ opts.setTimer ??
135
+ ((fn, ms) => {
136
+ const t = setTimeout(fn, ms);
137
+ t.unref?.();
138
+ return t;
139
+ });
140
+ this.clearTimer = opts.clearTimer ?? (t => clearTimeout(t));
141
+ }
142
+ get workers() {
143
+ return this.records;
144
+ }
145
+ /** True until every initial worker has reported 'ready'. */
146
+ get bootPhase() {
147
+ if (this.readyFired) {
148
+ for (const r of this.records.values())
149
+ if (!r.ready)
150
+ return true;
151
+ return false;
152
+ }
153
+ return true;
154
+ }
155
+ /** Spawn every worker. Throws synchronously if the FIRST spawn throws (the
156
+ * entry cannot be started as a thread at all). */
157
+ start() {
158
+ for (let i = 0; i < this.opts.workerCount; i++)
159
+ this.spawnAt(i);
160
+ this.healthTimer = this.setTimer(() => this.healthTick(), this.opts.healthIntervalMs ?? HEALTH_INTERVAL_MS);
161
+ }
162
+ spawnAt(index) {
163
+ const options = {
164
+ argv: this.opts.argv,
165
+ workerData: { [WORKER_DATA_KEY]: { index, workers: this.opts.workerCount } },
166
+ // No execArgv (inherited: V8 flags passed explicitly are rejected by
167
+ // Worker, inherited ones are tolerated) and no env (a copy of
168
+ // process.env at spawn time - UV_THREADPOOL_SIZE was set before).
169
+ ...(this.opts.resourceLimits ? { resourceLimits: this.opts.resourceLimits } : {}),
170
+ name: `moro-worker-${index}`,
171
+ };
172
+ const worker = this.spawnFn(this.opts.entry, options);
173
+ const record = {
174
+ worker,
175
+ index,
176
+ startedAt: this.now(),
177
+ ready: false,
178
+ port: null,
179
+ restarts: this.backoff.get(index)?.restarts ?? 0,
180
+ missedPongs: 0,
181
+ };
182
+ this.records.set(worker.threadId, record);
183
+ let bootFailed = false;
184
+ worker.on('message', (m) => {
185
+ if (!m || typeof m !== 'object')
186
+ return;
187
+ if (m.type === 'ready') {
188
+ record.ready = true;
189
+ record.port = m.port;
190
+ this.opts.logger.info(`Worker thread ${worker.threadId} ready on port ${m.port}`, 'Cluster');
191
+ if (!this.readyFired) {
192
+ this.readyFired = true;
193
+ this.opts.onReady();
194
+ }
195
+ }
196
+ else if (m.type === 'pong') {
197
+ record.missedPongs = 0;
198
+ }
199
+ else if (m.type === 'fatal') {
200
+ bootFailed = true;
201
+ const err = Object.assign(new Error(m.message), {
202
+ code: m.code,
203
+ threadId: worker.threadId,
204
+ });
205
+ const isBind = m.code === 'EADDRINUSE' || m.code === 'EACCES';
206
+ this.opts.logger.error(`Worker thread ${worker.threadId} fatal (${m.code}): ${m.message}`, 'Cluster');
207
+ if (!this.readyFired || isBind)
208
+ this.opts.onFatal(err, isBind ? 'bind' : 'boot');
209
+ }
210
+ });
211
+ worker.on('error', (err) => {
212
+ this.opts.logger.error(`Worker thread ${worker.threadId} error: ${err.message}`, 'Cluster');
213
+ if (!record.ready && !this.readyFired) {
214
+ bootFailed = true;
215
+ this.opts.onFatal(err, 'boot');
216
+ }
217
+ });
218
+ worker.on('exit', (code) => {
219
+ this.records.delete(worker.threadId);
220
+ if (this.shuttingDown)
221
+ return;
222
+ if (bootFailed)
223
+ return; // onFatal already decided what happens
224
+ if (code === 0 && record.ready) {
225
+ // A worker that finished on its own after serving: nothing to
226
+ // supervise (mirrors cluster's exitedAfterDisconnect).
227
+ this.opts.logger.warn(`Worker thread ${worker.threadId} exited cleanly`, 'Cluster');
228
+ return;
229
+ }
230
+ this.scheduleRestart(index, worker.threadId, code);
231
+ });
232
+ }
233
+ scheduleRestart(index, oldThreadId, code) {
234
+ const state = this.backoff.get(index) ?? { restarts: 0, lastStart: 0 };
235
+ const alive = this.now() - (this.records.get(oldThreadId)?.startedAt ?? this.now());
236
+ if (this.now() - state.lastStart > RESTART_WINDOW_MS && alive > RESTART_WINDOW_MS)
237
+ state.restarts = 0;
238
+ const delay = Math.min(RESTART_MAX_MS, RESTART_BASE_MS * 2 ** state.restarts);
239
+ state.restarts += 1;
240
+ state.lastStart = this.now();
241
+ this.backoff.set(index, state);
242
+ this.opts.logger.warn(`Worker thread ${oldThreadId} exited (code ${code}). Restarting in ${delay} ms...`, 'Cluster');
243
+ const t = this.setTimer(() => {
244
+ this.pendingTimers.delete(t);
245
+ if (this.shuttingDown)
246
+ return;
247
+ try {
248
+ this.spawnAt(index);
249
+ }
250
+ catch (err) {
251
+ this.opts.logger.error(`Worker thread restart failed: ${err instanceof Error ? err.message : String(err)}`, 'Cluster');
252
+ this.scheduleRestart(index, oldThreadId, -1);
253
+ }
254
+ }, delay);
255
+ this.pendingTimers.add(t);
256
+ }
257
+ healthTick() {
258
+ if (this.shuttingDown)
259
+ return;
260
+ for (const r of this.records.values()) {
261
+ if (!r.ready)
262
+ continue;
263
+ r.missedPongs += 1;
264
+ if (r.missedPongs > HEALTH_MISSES_BEFORE_WARN) {
265
+ this.opts.logger.warn(`Worker thread ${r.worker.threadId} missed ${r.missedPongs} health pings (event loop blocked?)`, 'Cluster');
266
+ }
267
+ try {
268
+ r.worker.postMessage({ type: 'ping' });
269
+ }
270
+ catch {
271
+ // exiting
272
+ }
273
+ }
274
+ this.healthTimer = this.setTimer(() => this.healthTick(), this.opts.healthIntervalMs ?? HEALTH_INTERVAL_MS);
275
+ }
276
+ /**
277
+ * Post 'shutdown' to every worker (each drains its server via app.close()),
278
+ * wait for their 'exit's up to the deadline, then terminate() stragglers.
279
+ * Resolves once every thread is gone.
280
+ */
281
+ async shutdown(deadlineMs = 3000) {
282
+ if (this.shuttingDown)
283
+ return;
284
+ this.shuttingDown = true;
285
+ if (this.healthTimer)
286
+ this.clearTimer(this.healthTimer);
287
+ for (const t of this.pendingTimers)
288
+ this.clearTimer(t);
289
+ this.pendingTimers.clear();
290
+ const live = [...this.records.values()];
291
+ if (live.length === 0)
292
+ return;
293
+ const exits = live.map(r => new Promise(resolve => {
294
+ r.worker.once('exit', () => resolve());
295
+ try {
296
+ r.worker.postMessage({ type: 'shutdown' });
297
+ }
298
+ catch {
299
+ resolve();
300
+ }
301
+ }));
302
+ let timer = null;
303
+ const deadline = new Promise(resolve => {
304
+ timer = this.setTimer(() => resolve('timeout'), deadlineMs);
305
+ });
306
+ const outcome = await Promise.race([
307
+ Promise.all(exits).then(() => 'drained'),
308
+ deadline,
309
+ ]);
310
+ if (timer)
311
+ this.clearTimer(timer);
312
+ if (outcome === 'timeout') {
313
+ const stragglers = [...this.records.values()];
314
+ this.opts.logger.warn(`Shutdown deadline (${deadlineMs} ms) reached; terminating ${stragglers.length} worker thread(s)`, 'Cluster');
315
+ await Promise.all(stragglers.map(async (r) => {
316
+ try {
317
+ await r.worker.terminate();
318
+ }
319
+ catch {
320
+ // already gone
321
+ }
322
+ }));
323
+ }
324
+ this.records.clear();
325
+ }
326
+ }
327
+ /** The worker-thread side: a thin wrapper over parentPort. */
328
+ export class ThreadClusterWorker {
329
+ port = wt().parentPort;
330
+ tid = wt().threadId;
331
+ inflight = () => 0;
332
+ logger;
333
+ shutdownHandler = null;
334
+ constructor(logger) {
335
+ this.logger = logger;
336
+ this.port?.on('message', (m) => {
337
+ if (!m || typeof m !== 'object')
338
+ return;
339
+ if (m.type === 'ping') {
340
+ this.post({ type: 'pong', threadId: this.tid, inflight: this.inflight() });
341
+ }
342
+ else if (m.type === 'shutdown') {
343
+ void this.runShutdown();
344
+ }
345
+ });
346
+ }
347
+ post(m) {
348
+ try {
349
+ this.port?.postMessage(m);
350
+ }
351
+ catch {
352
+ // primary gone
353
+ }
354
+ }
355
+ async runShutdown() {
356
+ this.logger.info(`Worker thread ${this.tid} shutting down gracefully...`, 'Worker');
357
+ try {
358
+ await this.shutdownHandler?.();
359
+ }
360
+ catch (err) {
361
+ this.logger.error(`Worker thread ${this.tid} shutdown error: ${err instanceof Error ? err.message : String(err)}`, 'Worker');
362
+ }
363
+ this.close();
364
+ }
365
+ onShutdown(fn) {
366
+ this.shutdownHandler = fn;
367
+ }
368
+ inflightProvider(fn) {
369
+ this.inflight = fn;
370
+ }
371
+ ready(port) {
372
+ this.post({ type: 'ready', threadId: this.tid, port });
373
+ }
374
+ fatal(code, message) {
375
+ this.post({ type: 'fatal', threadId: this.tid, code, message });
376
+ }
377
+ /** Signal completion and release the port so the thread can exit naturally
378
+ * once its handles are closed (never process.exit() inside a thread). */
379
+ close() {
380
+ this.post({ type: 'closed', threadId: this.tid });
381
+ try {
382
+ this.port?.close();
383
+ }
384
+ catch {
385
+ // already closed
386
+ }
387
+ }
388
+ }
389
+ //# sourceMappingURL=thread-cluster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread-cluster.js","sourceRoot":"","sources":["../../../src/core/cluster/thread-cluster.ts"],"names":[],"mappings":"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","sourcesContent":["// Worker-thread clustering for the native engine backend.\n//\n// `performance.clustering` keeps its keys; what changes is the transport\n// underneath: on POSIX, with @morojs/engine >= 1.1.6 (capabilities.workerThreads),\n// the workers are worker_threads inside ONE process, each binding the port\n// with SO_REUSEPORT exactly as the process workers did. Everything that\n// cannot be a thread stays on node:cluster automatically - Windows (no\n// SO_REUSEPORT), the node/uWS engines, an older engine, a REPL/eval entry,\n// or a worker thread that fails to boot (see resolveClusterTransport and the\n// boot-phase fallback in Moro.startThreadPrimary).\n//\n// Why threads: one process shares the node binary, the addon and the code\n// pages, so per-worker RSS drops; there is one pid to supervise; and a\n// worker crash is an 'error'/'exit' event on the primary instead of a\n// process death. The trade-off (stated in the release notes): a NATIVE crash\n// in one thread takes the whole process down, where processes isolate.\n//\n// This module is internal (not exported from src/index.ts). Everything here\n// is dependency-injectable so the sequencing (spawn, restart backoff, health\n// pings, bounded shutdown) is unit-tested with a fake Worker and fake timers.\nimport { requireBuiltin } from '../utilities/builtin.js';\nimport { existsSync } from 'fs';\nimport { resolve as resolvePath } from 'path';\nimport type { WorkerOptions } from 'node:worker_threads';\n\n// Loaded on first use (core/utilities/builtin.ts): an app that never clusters\n// never pays for worker_threads or os.\nconst wt = () => requireBuiltin<typeof import('worker_threads')>('worker_threads');\nconst os = () => requireBuiltin<typeof import('os')>('os');\n\n/** Key under workerData that marks a Moro cluster worker thread. */\nexport const WORKER_DATA_KEY = 'moroCluster';\n\n/**\n * The process path pins each worker's V8 young generation with\n * `--max-semi-space-size=4` (A/B-benched 2026-08-01: throughput parity,\n * ~30 MB RSS per worker saved). Worker threads reject V8 flags in execArgv;\n * the equivalent knob is resourceLimits.maxYoungGenerationSizeMb, and V8\n * sizes the young generation at 3x the semi-space: 4 MB semi-space == 12 MB.\n */\nexport const YOUNG_GEN_MB_FOR_SEMI_SPACE_4 = 12;\n\nexport interface ThreadWorkerInfo {\n index: number;\n workers: number;\n}\n\n/** Is this thread a Moro cluster worker (the thread analogue of cluster.isWorker)? */\nexport function threadWorkerInfo(data: unknown = wt().workerData): ThreadWorkerInfo | null {\n if (wt().isMainThread) return null;\n const d = data as Record<string, any> | null | undefined;\n const info = d && typeof d === 'object' ? d[WORKER_DATA_KEY] : undefined;\n if (!info || typeof info !== 'object' || typeof info.index !== 'number') return null;\n return { index: info.index, workers: typeof info.workers === 'number' ? info.workers : 1 };\n}\n\nexport interface ClusteringConfig {\n enabled?: boolean;\n workers?: number | 'auto';\n memoryPerWorkerGB?: number | undefined;\n}\n\nexport interface SystemInfo {\n cpus: number;\n totalMemGB: number;\n}\n\nfunction systemInfo(): SystemInfo {\n return { cpus: os().cpus().length, totalMemGB: os().totalmem() / (1024 * 1024 * 1024) };\n}\n\n/**\n * Worker count: a user number is respected; 'auto' (or unset) is\n * min(cpus, floor(totalRAM / memoryPerWorkerGB)) where memoryPerWorkerGB\n * defaults to max(0.5, floor((totalRAM - 4 GB headroom) / cpus)). Never < 1.\n * (Lifted unchanged from the process path so both transports agree.)\n */\nexport function computeWorkerCount(\n cfg: ClusteringConfig | undefined,\n sys: SystemInfo = systemInfo()\n): { count: number; detail: string } {\n const requested = cfg?.workers ?? 'auto';\n if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {\n return {\n count: Math.floor(requested),\n detail: `Using user-specified worker count: ${Math.floor(requested)}`,\n };\n }\n const cpuCount = Math.max(1, sys.cpus);\n const totalMemoryGB = sys.totalMemGB;\n let memoryPerWorkerGB = cfg?.memoryPerWorkerGB;\n if (!memoryPerWorkerGB) {\n const headroomGB = 4;\n memoryPerWorkerGB = Math.max(0.5, Math.floor((totalMemoryGB - headroomGB) / cpuCount));\n }\n const count = Math.max(1, Math.min(cpuCount, Math.floor(totalMemoryGB / memoryPerWorkerGB)));\n return {\n count,\n detail: `Auto-calculated worker count: ${count} (CPU: ${cpuCount}, RAM: ${totalMemoryGB.toFixed(1)}GB, ${memoryPerWorkerGB}GB per worker)`,\n };\n}\n\n/** Did the operator tune the young generation themselves? Then leave it alone. */\nexport function isUserTunedYoungGen(\n execArgv: readonly string[] = process.execArgv,\n nodeOptions: string | undefined = process.env.NODE_OPTIONS\n): boolean {\n return (\n execArgv.some(a => a.includes('--max-semi-space-size')) ||\n (nodeOptions || '').includes('--max-semi-space-size')\n );\n}\n\nexport type ClusterTransport =\n { kind: 'threads'; entry: string } | { kind: 'processes'; reason: string };\n\nexport interface TransportInput {\n platform: NodeJS.Platform;\n isMainThread: boolean;\n argv1: string | undefined;\n /** app.engine.server: 'engine' | 'node' | 'uws' */\n engineServer: string;\n /** app.engine.enginePackage, e.g. '@morojs/engine' */\n enginePackage?: string | undefined;\n /** engine capabilities.workerThreads (teardown-safe inside a thread) */\n workerThreadsCapable: boolean;\n fileExists?: (p: string) => boolean;\n}\n\n/**\n * Threads iff every precondition holds; otherwise processes, with the reason\n * (logged once). The reasons are the exact conditions the process path still\n * covers, so nothing that worked before loses clustering.\n */\nexport function resolveClusterTransport(input: TransportInput): ClusterTransport {\n const exists = input.fileExists ?? existsSync;\n if (input.platform === 'win32')\n return { kind: 'processes', reason: 'Windows has no SO_REUSEPORT' };\n if (input.engineServer !== 'engine' || input.enginePackage !== '@morojs/engine') {\n return { kind: 'processes', reason: `engine '${input.engineServer}' clusters as processes` };\n }\n if (!input.workerThreadsCapable) {\n return {\n kind: 'processes',\n reason: 'this @morojs/engine build predates worker-thread support (< 1.1.6)',\n };\n }\n if (!input.isMainThread) return { kind: 'processes', reason: 'not on the main thread' };\n if (typeof input.argv1 !== 'string' || input.argv1.length === 0) {\n return { kind: 'processes', reason: 'no entry script (REPL/eval)' };\n }\n const entry = resolvePath(input.argv1);\n if (!exists(entry)) return { kind: 'processes', reason: `entry script not found: ${entry}` };\n return { kind: 'threads', entry };\n}\n\n// ---- primary <-> worker protocol ----------------------------------------------\n\nexport type WorkerToPrimary =\n | { type: 'ready'; threadId: number; port: number }\n | { type: 'closed'; threadId: number }\n | { type: 'pong'; threadId: number; inflight: number }\n | { type: 'fatal'; threadId: number; code: string; message: string };\n\nexport type PrimaryToWorker = { type: 'shutdown' } | { type: 'ping' };\n\n/** The subset of worker_threads.Worker the primary uses (fakeable). */\nexport interface WorkerLike {\n threadId: number;\n on(event: string, listener: (...args: any[]) => void): this;\n once(event: string, listener: (...args: any[]) => void): this;\n off(event: string, listener: (...args: any[]) => void): this;\n postMessage(message: PrimaryToWorker): void;\n terminate(): Promise<number>;\n unref(): void;\n ref(): void;\n}\n\nexport type SpawnWorker = (entry: string, options: WorkerOptions) => WorkerLike;\n\nexport interface ClusterLogger {\n info(message: string, context?: string, ...rest: any[]): void;\n warn(message: string, context?: string, ...rest: any[]): void;\n error(message: string, context?: string, ...rest: any[]): void;\n debug?(message: string, context?: string, ...rest: any[]): void;\n}\n\nexport interface WorkerRecord {\n worker: WorkerLike;\n index: number;\n startedAt: number;\n ready: boolean;\n port: number | null;\n restarts: number;\n missedPongs: number;\n}\n\nexport type FatalKind = 'boot' | 'bind';\n\nexport interface ThreadClusterPrimaryOptions {\n entry: string;\n argv: string[];\n workerCount: number;\n logger: ClusterLogger;\n resourceLimits?: WorkerOptions['resourceLimits'];\n /** First 'ready' from any worker (fires once). */\n onReady: () => void;\n /** A worker failed before the cluster ever became ready ('boot': spawn or\n * module error - the caller may fall back to processes; 'bind': the port\n * cannot be bound - processes would fail identically, surface it). */\n onFatal: (error: Error, kind: FatalKind) => void;\n spawn?: SpawnWorker;\n now?: () => number;\n setTimer?: (fn: () => void, ms: number) => any;\n clearTimer?: (t: any) => void;\n healthIntervalMs?: number;\n}\n\nconst RESTART_BASE_MS = 200;\nconst RESTART_MAX_MS = 5000;\nconst RESTART_WINDOW_MS = 60_000; // a worker alive this long resets its backoff\nconst HEALTH_INTERVAL_MS = 30_000;\nconst HEALTH_MISSES_BEFORE_WARN = 3;\n\nconst defaultSpawn: SpawnWorker = (entry, options) =>\n new (wt().Worker)(entry, options) as unknown as WorkerLike;\n\nexport class ThreadClusterPrimary {\n private readonly opts: ThreadClusterPrimaryOptions;\n private readonly spawnFn: SpawnWorker;\n private readonly now: () => number;\n private readonly setTimer: (fn: () => void, ms: number) => any;\n private readonly clearTimer: (t: any) => void;\n private readonly records = new Map<number, WorkerRecord>();\n private readonly backoff = new Map<number, { restarts: number; lastStart: number }>();\n private readyFired = false;\n private shuttingDown = false;\n private healthTimer: any = null;\n private pendingTimers = new Set<any>();\n\n constructor(opts: ThreadClusterPrimaryOptions) {\n this.opts = opts;\n this.spawnFn = opts.spawn ?? defaultSpawn;\n this.now = opts.now ?? Date.now;\n this.setTimer =\n opts.setTimer ??\n ((fn, ms) => {\n const t = setTimeout(fn, ms);\n (t as any).unref?.();\n return t;\n });\n this.clearTimer = opts.clearTimer ?? (t => clearTimeout(t));\n }\n\n get workers(): ReadonlyMap<number, WorkerRecord> {\n return this.records;\n }\n\n /** True until every initial worker has reported 'ready'. */\n get bootPhase(): boolean {\n if (this.readyFired) {\n for (const r of this.records.values()) if (!r.ready) return true;\n return false;\n }\n return true;\n }\n\n /** Spawn every worker. Throws synchronously if the FIRST spawn throws (the\n * entry cannot be started as a thread at all). */\n start(): void {\n for (let i = 0; i < this.opts.workerCount; i++) this.spawnAt(i);\n this.healthTimer = this.setTimer(\n () => this.healthTick(),\n this.opts.healthIntervalMs ?? HEALTH_INTERVAL_MS\n );\n }\n\n private spawnAt(index: number): void {\n const options: WorkerOptions = {\n argv: this.opts.argv,\n workerData: { [WORKER_DATA_KEY]: { index, workers: this.opts.workerCount } },\n // No execArgv (inherited: V8 flags passed explicitly are rejected by\n // Worker, inherited ones are tolerated) and no env (a copy of\n // process.env at spawn time - UV_THREADPOOL_SIZE was set before).\n ...(this.opts.resourceLimits ? { resourceLimits: this.opts.resourceLimits } : {}),\n name: `moro-worker-${index}`,\n };\n const worker = this.spawnFn(this.opts.entry, options);\n const record: WorkerRecord = {\n worker,\n index,\n startedAt: this.now(),\n ready: false,\n port: null,\n restarts: this.backoff.get(index)?.restarts ?? 0,\n missedPongs: 0,\n };\n this.records.set(worker.threadId, record);\n let bootFailed = false;\n\n worker.on('message', (m: WorkerToPrimary) => {\n if (!m || typeof m !== 'object') return;\n if (m.type === 'ready') {\n record.ready = true;\n record.port = m.port;\n this.opts.logger.info(\n `Worker thread ${worker.threadId} ready on port ${m.port}`,\n 'Cluster'\n );\n if (!this.readyFired) {\n this.readyFired = true;\n this.opts.onReady();\n }\n } else if (m.type === 'pong') {\n record.missedPongs = 0;\n } else if (m.type === 'fatal') {\n bootFailed = true;\n const err = Object.assign(new Error(m.message), {\n code: m.code,\n threadId: worker.threadId,\n });\n const isBind = m.code === 'EADDRINUSE' || m.code === 'EACCES';\n this.opts.logger.error(\n `Worker thread ${worker.threadId} fatal (${m.code}): ${m.message}`,\n 'Cluster'\n );\n if (!this.readyFired || isBind) this.opts.onFatal(err, isBind ? 'bind' : 'boot');\n }\n });\n worker.on('error', (err: Error) => {\n this.opts.logger.error(`Worker thread ${worker.threadId} error: ${err.message}`, 'Cluster');\n if (!record.ready && !this.readyFired) {\n bootFailed = true;\n this.opts.onFatal(err, 'boot');\n }\n });\n worker.on('exit', (code: number) => {\n this.records.delete(worker.threadId);\n if (this.shuttingDown) return;\n if (bootFailed) return; // onFatal already decided what happens\n if (code === 0 && record.ready) {\n // A worker that finished on its own after serving: nothing to\n // supervise (mirrors cluster's exitedAfterDisconnect).\n this.opts.logger.warn(`Worker thread ${worker.threadId} exited cleanly`, 'Cluster');\n return;\n }\n this.scheduleRestart(index, worker.threadId, code);\n });\n }\n\n private scheduleRestart(index: number, oldThreadId: number, code: number): void {\n const state = this.backoff.get(index) ?? { restarts: 0, lastStart: 0 };\n const alive = this.now() - (this.records.get(oldThreadId)?.startedAt ?? this.now());\n if (this.now() - state.lastStart > RESTART_WINDOW_MS && alive > RESTART_WINDOW_MS)\n state.restarts = 0;\n const delay = Math.min(RESTART_MAX_MS, RESTART_BASE_MS * 2 ** state.restarts);\n state.restarts += 1;\n state.lastStart = this.now();\n this.backoff.set(index, state);\n this.opts.logger.warn(\n `Worker thread ${oldThreadId} exited (code ${code}). Restarting in ${delay} ms...`,\n 'Cluster'\n );\n const t = this.setTimer(() => {\n this.pendingTimers.delete(t);\n if (this.shuttingDown) return;\n try {\n this.spawnAt(index);\n } catch (err) {\n this.opts.logger.error(\n `Worker thread restart failed: ${err instanceof Error ? err.message : String(err)}`,\n 'Cluster'\n );\n this.scheduleRestart(index, oldThreadId, -1);\n }\n }, delay);\n this.pendingTimers.add(t);\n }\n\n private healthTick(): void {\n if (this.shuttingDown) return;\n for (const r of this.records.values()) {\n if (!r.ready) continue;\n r.missedPongs += 1;\n if (r.missedPongs > HEALTH_MISSES_BEFORE_WARN) {\n this.opts.logger.warn(\n `Worker thread ${r.worker.threadId} missed ${r.missedPongs} health pings (event loop blocked?)`,\n 'Cluster'\n );\n }\n try {\n r.worker.postMessage({ type: 'ping' });\n } catch {\n // exiting\n }\n }\n this.healthTimer = this.setTimer(\n () => this.healthTick(),\n this.opts.healthIntervalMs ?? HEALTH_INTERVAL_MS\n );\n }\n\n /**\n * Post 'shutdown' to every worker (each drains its server via app.close()),\n * wait for their 'exit's up to the deadline, then terminate() stragglers.\n * Resolves once every thread is gone.\n */\n async shutdown(deadlineMs = 3000): Promise<void> {\n if (this.shuttingDown) return;\n this.shuttingDown = true;\n if (this.healthTimer) this.clearTimer(this.healthTimer);\n for (const t of this.pendingTimers) this.clearTimer(t);\n this.pendingTimers.clear();\n const live = [...this.records.values()];\n if (live.length === 0) return;\n const exits = live.map(\n r =>\n new Promise<void>(resolve => {\n r.worker.once('exit', () => resolve());\n try {\n r.worker.postMessage({ type: 'shutdown' });\n } catch {\n resolve();\n }\n })\n );\n let timer: any = null;\n const deadline = new Promise<'timeout'>(resolve => {\n timer = this.setTimer(() => resolve('timeout'), deadlineMs);\n });\n const outcome = await Promise.race([\n Promise.all(exits).then(() => 'drained' as const),\n deadline,\n ]);\n if (timer) this.clearTimer(timer);\n if (outcome === 'timeout') {\n const stragglers = [...this.records.values()];\n this.opts.logger.warn(\n `Shutdown deadline (${deadlineMs} ms) reached; terminating ${stragglers.length} worker thread(s)`,\n 'Cluster'\n );\n await Promise.all(\n stragglers.map(async r => {\n try {\n await r.worker.terminate();\n } catch {\n // already gone\n }\n })\n );\n }\n this.records.clear();\n }\n}\n\n/** The worker-thread side: a thin wrapper over parentPort. */\nexport class ThreadClusterWorker {\n private readonly port = wt().parentPort;\n private readonly tid = wt().threadId;\n private inflight: () => number = () => 0;\n private readonly logger: ClusterLogger;\n private shutdownHandler: (() => Promise<void> | void) | null = null;\n\n constructor(logger: ClusterLogger) {\n this.logger = logger;\n this.port?.on('message', (m: PrimaryToWorker) => {\n if (!m || typeof m !== 'object') return;\n if (m.type === 'ping') {\n this.post({ type: 'pong', threadId: this.tid, inflight: this.inflight() });\n } else if (m.type === 'shutdown') {\n void this.runShutdown();\n }\n });\n }\n\n private post(m: WorkerToPrimary): void {\n try {\n this.port?.postMessage(m);\n } catch {\n // primary gone\n }\n }\n\n private async runShutdown(): Promise<void> {\n this.logger.info(`Worker thread ${this.tid} shutting down gracefully...`, 'Worker');\n try {\n await this.shutdownHandler?.();\n } catch (err) {\n this.logger.error(\n `Worker thread ${this.tid} shutdown error: ${err instanceof Error ? err.message : String(err)}`,\n 'Worker'\n );\n }\n this.close();\n }\n\n onShutdown(fn: () => Promise<void> | void): void {\n this.shutdownHandler = fn;\n }\n\n inflightProvider(fn: () => number): void {\n this.inflight = fn;\n }\n\n ready(port: number): void {\n this.post({ type: 'ready', threadId: this.tid, port });\n }\n\n fatal(code: string, message: string): void {\n this.post({ type: 'fatal', threadId: this.tid, code, message });\n }\n\n /** Signal completion and release the port so the thread can exit naturally\n * once its handles are closed (never process.exit() inside a thread). */\n close(): void {\n this.post({ type: 'closed', threadId: this.tid });\n try {\n this.port?.close();\n } catch {\n // already closed\n }\n }\n}\n"]}