@morojs/moro 1.8.8 → 1.8.10

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 (36) hide show
  1. package/dist/core/cluster/thread-cluster.d.ts +178 -0
  2. package/dist/core/cluster/thread-cluster.js +389 -0
  3. package/dist/core/cluster/thread-cluster.js.map +1 -0
  4. package/dist/core/framework.js.map +1 -1
  5. package/dist/core/http/moro-engine-server.d.ts +29 -1
  6. package/dist/core/http/moro-engine-server.js +375 -68
  7. package/dist/core/http/moro-engine-server.js.map +1 -1
  8. package/dist/core/http/utils/compression.js +12 -6
  9. package/dist/core/http/utils/compression.js.map +1 -1
  10. package/dist/core/http/utils/uws-worker-clustering.js +10 -6
  11. package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
  12. package/dist/core/http/uws-http-server.d.ts +1 -0
  13. package/dist/core/http/uws-http-server.js +105 -22
  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/unified-router.js +60 -29
  20. package/dist/core/routing/unified-router.js.map +1 -1
  21. package/dist/core/runtime/node-adapter.d.ts +1 -1
  22. package/dist/core/runtime/node-adapter.js.map +1 -1
  23. package/dist/core/utilities/builtin.d.ts +1 -0
  24. package/dist/core/utilities/builtin.js +25 -0
  25. package/dist/core/utilities/builtin.js.map +1 -0
  26. package/dist/core/utilities/package-utils.d.ts +28 -3
  27. package/dist/core/utilities/package-utils.js +24 -12
  28. package/dist/core/utilities/package-utils.js.map +1 -1
  29. package/dist/core/workers/worker-manager.js +4 -3
  30. package/dist/core/workers/worker-manager.js.map +1 -1
  31. package/dist/moro.d.ts +5 -0
  32. package/dist/moro.js +364 -204
  33. package/dist/moro.js.map +1 -1
  34. package/dist/types/http.d.ts +1 -1
  35. package/dist/types/http.js.map +1 -1
  36. package/package.json +1 -1
package/dist/moro.js CHANGED
@@ -9,11 +9,16 @@ import { UnifiedRouter, } from './core/routing/unified-router.js';
9
9
  import { PathMatcher } from './core/routing/path-matcher.js';
10
10
  import { AppDocumentationManager } from './core/docs/index.js';
11
11
  import { EventEmitter } from 'events';
12
- import cluster from 'cluster';
13
- import os from 'os';
12
+ import { requireBuiltin } from './core/utilities/builtin.js';
13
+ // node:cluster (it drags in child_process, dgram and net) is loaded only when
14
+ // process clustering actually runs; worker_threads only on the clustering
15
+ // path. See core/utilities/builtin.ts.
16
+ const nodeCluster = () => requireBuiltin('cluster');
17
+ const wt = () => requireBuiltin('worker_threads');
18
+ import { ThreadClusterPrimary, ThreadClusterWorker, computeWorkerCount, isUserTunedYoungGen, resolveClusterTransport, threadWorkerInfo, YOUNG_GEN_MB_FOR_SEMI_SPACE_4, } from './core/cluster/thread-cluster.js';
14
19
  import { normalizeValidationError } from './core/validation/schema-interface.js';
15
20
  import { buildModuleBasePath } from './core/utilities/module-path.js';
16
- import { filePathToImportURL } from './core/utilities/package-utils.js';
21
+ import { filePathToImportURL, loadNativeEngine } from './core/utilities/package-utils.js';
17
22
  // Configuration System Integration
18
23
  import { initializeConfig, initializeConfigAsync } from './core/config/index.js';
19
24
  // Runtime System Integration
@@ -778,7 +783,8 @@ export class Moro extends EventEmitter {
778
783
  }
779
784
  // Check if clustering is enabled for massive performance gains
780
785
  if (this.config.performance?.clustering?.enabled) {
781
- this.logger.info('Clustering enabled - using Node.js cluster', 'Cluster');
786
+ // Worker threads (engine backend, POSIX, engine >= 1.1.6) or node:cluster
787
+ // processes - startWithClustering picks and logs the transport.
782
788
  this.startWithClustering(port, host, callback);
783
789
  return;
784
790
  }
@@ -1652,150 +1658,275 @@ export class Moro extends EventEmitter {
1652
1658
  * - BetterStack Node.js Clustering: https://betterstack.com/community/guides/scaling-nodejs/node-clustering/
1653
1659
  */
1654
1660
  clusterWorkers = new Map();
1661
+ threadCluster = undefined;
1662
+ // Entry point for performance.clustering: decides whether THIS instance is
1663
+ // a worker (thread or process) or the primary, and which transport the
1664
+ // primary uses. Config keys are unchanged; the transport is automatic.
1655
1665
  startWithClustering(port, host, callback) {
1656
- // Worker count calculation - respect user choice
1657
- let workerCount = this.config.performance?.clustering?.workers || os.cpus().length;
1658
- // Only auto-optimize if user hasn't specified a number or set it to 'auto'
1659
- if (workerCount === 'auto') {
1660
- const cpuCount = os.cpus().length;
1661
- const totalMemoryGB = os.totalmem() / (1024 * 1024 * 1024);
1662
- // Get memory per worker from config - if not set by user, calculate dynamically
1663
- let memoryPerWorkerGB = this.config.performance?.clustering?.memoryPerWorkerGB;
1664
- if (!memoryPerWorkerGB) {
1665
- // Dynamic calculation: (Total RAM - 4GB headroom) / CPU cores
1666
- const headroomGB = 4;
1667
- memoryPerWorkerGB = Math.max(0.5, Math.floor((totalMemoryGB - headroomGB) / cpuCount));
1668
- }
1669
- // Conservative formula based on general guidelines:
1670
- // - Don't exceed CPU cores
1671
- // - Respect user's memory allocation preference
1672
- // - Let the system resources determine the limit
1673
- workerCount = Math.min(cpuCount, // Don't exceed CPU cores
1674
- Math.floor(totalMemoryGB / memoryPerWorkerGB) // User-configurable memory per worker
1675
- );
1676
- this.logger.info(`Auto-calculated worker count: ${workerCount} (CPU: ${cpuCount}, RAM: ${totalMemoryGB.toFixed(1)}GB, ${memoryPerWorkerGB}GB per worker)`, 'Cluster');
1677
- }
1678
- else if (typeof workerCount === 'number') {
1679
- // User specified a number - respect their choice
1680
- this.logger.info(`Using user-specified worker count: ${workerCount}`, 'Cluster');
1681
- }
1682
- if (cluster.isPrimary) {
1683
- this.logger.info(`Starting ${workerCount} workers`, 'Cluster');
1684
- // Optimize cluster scheduling for high concurrency
1685
- // Round-robin is the default on all platforms except Windows (Node.js docs)
1686
- // Provides better load distribution than shared socket approach
1687
- cluster.schedulingPolicy = cluster.SCHED_RR;
1688
- // Workers inherit the primary's execArgv; additionally bound each
1689
- // worker's V8 young generation unless the user tuned it themselves.
1690
- // On big machines V8 sizes the nursery from total RAM and grows it
1691
- // under sustained load (~10-34 MB extra RSS per process, measured on a
1692
- // 64 GB box) - and in cluster mode that cost multiplies by the worker
1693
- // count. A/B-benched 2026-08-01: identical throughput at 4 MB semi-space
1694
- // in both the realistic profile (~92k vs ~90k req/s) and the pipelined
1695
- // x10 microbenchmark (~560k both ways), while sustained-load RSS drops
1696
- // ~30 MB per worker (115 -> 81 MB on the 25-route POST soak).
1697
- const userTunedGC = process.execArgv.some(a => a.includes('--max-semi-space-size')) ||
1698
- (process.env.NODE_OPTIONS || '').includes('--max-semi-space-size');
1699
- // Set cluster settings for better performance
1700
- cluster.setupMaster({
1701
- exec: process.argv[1] || process.execPath,
1702
- args: process.argv.slice(2),
1703
- silent: false,
1704
- ...(userTunedGC ? {} : { execArgv: [...process.execArgv, '--max-semi-space-size=4'] }),
1666
+ const { count: workerCount, detail } = computeWorkerCount(this.config.performance?.clustering);
1667
+ const tinfo = threadWorkerInfo();
1668
+ if (tinfo) {
1669
+ this.startClusterWorker({ kind: 'thread', info: tinfo }, port, host);
1670
+ return;
1671
+ }
1672
+ if (nodeCluster().isWorker) {
1673
+ this.startClusterWorker({ kind: 'process' }, port, host);
1674
+ return;
1675
+ }
1676
+ this.logger.info(detail, 'Cluster');
1677
+ const engineKind = this.engine;
1678
+ const transport = resolveClusterTransport({
1679
+ platform: process.platform,
1680
+ isMainThread: wt().isMainThread,
1681
+ argv1: process.argv[1],
1682
+ engineServer: engineKind.server,
1683
+ enginePackage: engineKind.enginePackage,
1684
+ workerThreadsCapable: loadNativeEngine()?.capabilities?.workerThreads === true,
1685
+ });
1686
+ if (transport.kind === 'threads') {
1687
+ this.startThreadPrimary(transport.entry, workerCount, port, host, callback);
1688
+ return;
1689
+ }
1690
+ this.logger.info(`Clustering: ${workerCount} worker processes (node:cluster; ${transport.reason})`, 'Cluster');
1691
+ this.startProcessPrimary(workerCount, port, host, callback);
1692
+ }
1693
+ // Services that must run exactly ONCE per cluster, in the primary: the job
1694
+ // scheduler (leader election already treats the main thread of a
1695
+ // non-cluster-worker process as a participant) and the gRPC server (its own
1696
+ // port). GraphQL registers HTTP/WS routes, so it initialises in every
1697
+ // worker instead. Before 1.9.0 none of these started under clustering.
1698
+ startPrimaryServices() {
1699
+ this.startJobScheduler().catch(err => {
1700
+ this.logger.error(`Failed to start job scheduler: ${String(err)}`);
1701
+ });
1702
+ if (this.grpcManager && !this.grpcStarted) {
1703
+ this.startGrpc().catch(error => {
1704
+ this.logger.error(`Failed to start gRPC server: ${error}`, 'GRPC');
1705
+ });
1706
+ }
1707
+ }
1708
+ startThreadPrimary(entry, workerCount, port, host, callback) {
1709
+ // The libuv threadpool is process-wide and sized once at first use;
1710
+ // the process path set this per worker process. Only if the operator
1711
+ // did not choose a size.
1712
+ if (!process.env.UV_THREADPOOL_SIZE)
1713
+ process.env.UV_THREADPOOL_SIZE = '64';
1714
+ // Same young-generation bound the process path applies through execArgv
1715
+ // (see YOUNG_GEN_MB_FOR_SEMI_SPACE_4), unless the operator tuned GC.
1716
+ const resourceLimits = isUserTunedYoungGen()
1717
+ ? undefined
1718
+ : { maxYoungGenerationSizeMb: YOUNG_GEN_MB_FOR_SEMI_SPACE_4 };
1719
+ const engineKind = this.engine;
1720
+ this.logger.info(`Clustering: ${workerCount} worker threads (worker_threads + SO_REUSEPORT, ` +
1721
+ `${engineKind.enginePackage}${engineKind.engineVersion ? ` v${engineKind.engineVersion}` : ''})`, 'Cluster');
1722
+ let fellBack = false;
1723
+ const fallBackToProcesses = (why) => {
1724
+ if (fellBack)
1725
+ return;
1726
+ fellBack = true;
1727
+ this.logger.warn(`Worker threads unavailable (${why}); falling back to worker processes`, 'Cluster');
1728
+ const primary = this.threadCluster;
1729
+ this.threadCluster = undefined;
1730
+ void primary?.shutdown(1000).finally(() => {
1731
+ this.startProcessPrimary(workerCount, port, host, callback);
1705
1732
  });
1706
- // IPC Optimization: Reduce communication overhead between master and workers
1707
- // Research shows excessive IPC can create bottlenecks in clustered applications
1708
- // (Source: BetterStack - Node.js Clustering Guide)
1709
- process.env.NODE_CLUSTER_SCHED_POLICY = 'rr'; // Ensure round-robin
1710
- process.env.NODE_DISABLE_COLORS = '1'; // Reduce IPC message size by disabling color codes
1711
- // Graceful shutdown handler
1712
- const gracefulShutdown = () => {
1713
- this.logger.info('Gracefully shutting down cluster...', 'Cluster');
1714
- // Clean up all workers
1715
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1716
- for (const [pid, worker] of this.clusterWorkers) {
1717
- worker.removeAllListeners();
1718
- worker.kill('SIGTERM');
1733
+ };
1734
+ const primary = new ThreadClusterPrimary({
1735
+ entry,
1736
+ argv: process.argv.slice(2),
1737
+ workerCount,
1738
+ logger: this.logger,
1739
+ ...(resourceLimits ? { resourceLimits } : {}),
1740
+ onReady: () => {
1741
+ this.startPrimaryServices();
1742
+ if (callback)
1743
+ callback();
1744
+ },
1745
+ onFatal: (error, kind) => {
1746
+ if (kind === 'bind') {
1747
+ // Processes would fail identically: surface it like the single-process
1748
+ // path does (Node's server convention; rethrows without a listener).
1749
+ this.emit('error', error);
1750
+ return;
1719
1751
  }
1720
- // Clean up cluster listeners
1721
- cluster.removeAllListeners();
1752
+ fallBackToProcesses(error.message);
1753
+ },
1754
+ });
1755
+ this.threadCluster = primary;
1756
+ try {
1757
+ primary.start();
1758
+ }
1759
+ catch (error) {
1760
+ fallBackToProcesses(error instanceof Error ? error.message : String(error));
1761
+ return;
1762
+ }
1763
+ // Graceful shutdown: drain every worker thread (each closes its server),
1764
+ // then the primary's own services; a second signal exits at once.
1765
+ let shuttingDown = false;
1766
+ const gracefulShutdown = (code) => {
1767
+ if (shuttingDown) {
1768
+ process.exit(code);
1769
+ return;
1770
+ }
1771
+ shuttingDown = true;
1772
+ this.logger.info('Gracefully shutting down cluster...', 'Cluster');
1773
+ this.close()
1774
+ .then(() => process.exit(code))
1775
+ .catch(() => process.exit(1));
1776
+ };
1777
+ process.on('SIGINT', () => gracefulShutdown(0));
1778
+ process.on('SIGTERM', () => gracefulShutdown(0));
1779
+ }
1780
+ startProcessPrimary(workerCount, port, host, callback) {
1781
+ void port;
1782
+ void host;
1783
+ this.logger.info(`Starting ${workerCount} workers`, 'Cluster');
1784
+ // Optimize cluster scheduling for high concurrency
1785
+ // Round-robin is the default on all platforms except Windows (Node.js docs)
1786
+ // Provides better load distribution than shared socket approach
1787
+ nodeCluster().schedulingPolicy = nodeCluster().SCHED_RR;
1788
+ // Workers inherit the primary's execArgv; additionally bound each
1789
+ // worker's V8 young generation unless the user tuned it themselves.
1790
+ // On big machines V8 sizes the nursery from total RAM and grows it
1791
+ // under sustained load (~10-34 MB extra RSS per process, measured on a
1792
+ // 64 GB box) - and in cluster mode that cost multiplies by the worker
1793
+ // count. A/B-benched 2026-08-01: identical throughput at 4 MB semi-space
1794
+ // in both the realistic profile (~92k vs ~90k req/s) and the pipelined
1795
+ // x10 microbenchmark (~560k both ways), while sustained-load RSS drops
1796
+ // ~30 MB per worker (115 -> 81 MB on the 25-route POST soak).
1797
+ const userTunedGC = isUserTunedYoungGen();
1798
+ // Set cluster settings for better performance
1799
+ nodeCluster().setupMaster({
1800
+ exec: process.argv[1] || process.execPath,
1801
+ args: process.argv.slice(2),
1802
+ silent: false,
1803
+ ...(userTunedGC ? {} : { execArgv: [...process.execArgv, '--max-semi-space-size=4'] }),
1804
+ });
1805
+ // IPC Optimization: Reduce communication overhead between master and workers
1806
+ // Research shows excessive IPC can create bottlenecks in clustered applications
1807
+ // (Source: BetterStack - Node.js Clustering Guide)
1808
+ process.env.NODE_CLUSTER_SCHED_POLICY = 'rr'; // Ensure round-robin
1809
+ process.env.NODE_DISABLE_COLORS = '1'; // Reduce IPC message size by disabling color codes
1810
+ // Graceful shutdown: SIGTERM every worker (each stops accepting, drains
1811
+ // its in-flight responses, then exits), wait for their exits up to a
1812
+ // bounded deadline, and only then exit. Exiting right after the kill
1813
+ // used to close the cluster IPC channel, and a node:cluster worker whose
1814
+ // primary disappears exits immediately - cutting the very responses the
1815
+ // worker was draining. A second signal while draining exits at once.
1816
+ let shuttingDown = false;
1817
+ const gracefulShutdown = () => {
1818
+ if (shuttingDown)
1819
+ process.exit(0);
1820
+ shuttingDown = true;
1821
+ this.logger.info('Gracefully shutting down cluster...', 'Cluster');
1822
+ if (this.clusterWorkers.size === 0) {
1823
+ nodeCluster().removeAllListeners();
1824
+ process.exit(0);
1825
+ }
1826
+ const pending = new Set(this.clusterWorkers.keys());
1827
+ const timer = setTimeout(() => {
1828
+ this.logger.warn(`Shutdown deadline reached; terminating ${pending.size} worker process(es)`, 'Cluster');
1829
+ for (const pid of pending)
1830
+ this.clusterWorkers.get(pid)?.kill('SIGKILL');
1831
+ finish();
1832
+ }, 3000);
1833
+ const finish = () => {
1834
+ clearTimeout(timer);
1835
+ nodeCluster().removeAllListeners();
1722
1836
  process.exit(0);
1723
1837
  };
1724
- // Handle process signals for graceful shutdown
1725
- process.on('SIGINT', gracefulShutdown);
1726
- process.on('SIGTERM', gracefulShutdown);
1727
- // Fork workers with basic tracking
1728
- for (let i = 0; i < workerCount; i++) {
1729
- const worker = cluster.fork();
1838
+ for (const [pid, worker] of this.clusterWorkers) {
1839
+ worker.removeAllListeners(); // no restart on these exits
1840
+ worker.once('exit', () => {
1841
+ pending.delete(pid);
1842
+ if (pending.size === 0)
1843
+ finish();
1844
+ });
1845
+ worker.kill('SIGTERM');
1846
+ }
1847
+ };
1848
+ // Handle process signals for graceful shutdown
1849
+ process.on('SIGINT', gracefulShutdown);
1850
+ process.on('SIGTERM', gracefulShutdown);
1851
+ // Fork workers with basic tracking
1852
+ for (let i = 0; i < workerCount; i++) {
1853
+ const worker = nodeCluster().fork();
1854
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1855
+ this.clusterWorkers.set(worker.process.pid, worker);
1856
+ this.logger.info(`Worker ${worker.process.pid} started`, 'Cluster');
1857
+ // Handle individual worker messages
1858
+ worker.on('message', this.handleWorkerMessage.bind(this));
1859
+ }
1860
+ // Primary-only services start once the first worker is serving.
1861
+ let servicesStarted = false;
1862
+ nodeCluster().on('listening', () => {
1863
+ if (servicesStarted)
1864
+ return;
1865
+ servicesStarted = true;
1866
+ this.startPrimaryServices();
1867
+ });
1868
+ // Simple worker exit handling
1869
+ nodeCluster().on('exit', (worker, code, signal) => {
1870
+ const pid = worker.process.pid;
1871
+ this.clusterWorkers.delete(pid);
1872
+ if (code !== 0 && !worker.exitedAfterDisconnect) {
1873
+ this.logger.warn(`Worker ${pid} died unexpectedly (${signal || code}). Restarting...`, 'Cluster');
1874
+ // Simple restart
1875
+ const newWorker = nodeCluster().fork();
1730
1876
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1731
- this.clusterWorkers.set(worker.process.pid, worker);
1732
- this.logger.info(`Worker ${worker.process.pid} started`, 'Cluster');
1733
- // Handle individual worker messages
1734
- worker.on('message', this.handleWorkerMessage.bind(this));
1735
- }
1736
- // Simple worker exit handling
1737
- cluster.on('exit', (worker, code, signal) => {
1738
- const pid = worker.process.pid;
1739
- this.clusterWorkers.delete(pid);
1740
- if (code !== 0 && !worker.exitedAfterDisconnect) {
1741
- this.logger.warn(`Worker ${pid} died unexpectedly (${signal || code}). Restarting...`, 'Cluster');
1742
- // Simple restart
1743
- const newWorker = cluster.fork();
1744
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1745
- this.clusterWorkers.set(newWorker.process.pid, newWorker);
1746
- this.logger.info(`Worker ${newWorker.process.pid} restarted`, 'Cluster');
1747
- }
1877
+ this.clusterWorkers.set(newWorker.process.pid, newWorker);
1878
+ this.logger.info(`Worker ${newWorker.process.pid} restarted`, 'Cluster');
1879
+ }
1880
+ });
1881
+ // Master process callback
1882
+ if (callback)
1883
+ callback();
1884
+ }
1885
+ // One worker, either a worker_threads thread or a node:cluster process:
1886
+ // boot the app exactly like the single-process path (auto-discovery,
1887
+ // file-based routes, WebSocket registrations, GraphQL) and listen with
1888
+ // SO_REUSEPORT. Signals/exit are the differences: a thread gets 'shutdown'
1889
+ // from the primary over parentPort and never calls process.exit().
1890
+ startClusterWorker(mode, port, host) {
1891
+ const label = mode.kind === 'thread' ? `Worker thread ${wt().threadId}` : `Worker ${process.pid}`;
1892
+ const workerRef = mode.kind === 'thread' ? wt().threadId : process.pid;
1893
+ this.logger.info(`${label} initializing`, 'Worker');
1894
+ if (mode.kind === 'process') {
1895
+ // Worker-specific optimizations for high concurrency (a thread shares
1896
+ // the primary's pool, sized before it was spawned)
1897
+ process.env.UV_THREADPOOL_SIZE = '64';
1898
+ }
1899
+ // Reduce logging contention in workers (major bottleneck)
1900
+ // Multiple workers writing to same log files creates I/O contention
1901
+ // ONLY reduce log level if user didn't explicitly set one
1902
+ if (!this.userSetLogger) {
1903
+ // Workers log less frequently to reduce I/O contention (only if not explicitly configured)
1904
+ applyLoggingConfiguration(undefined, { level: 'warn' }); // Only warnings and errors
1905
+ }
1906
+ // Optimize garbage collection for workers
1907
+ // eslint-disable-next-line no-undef
1908
+ if (global.gc) {
1909
+ setInterval(() => {
1910
+ // eslint-disable-next-line no-undef
1911
+ if (global.gc)
1912
+ global.gc();
1913
+ }, 60000); // GC every 60 seconds (less frequent)
1914
+ }
1915
+ let tcw = null;
1916
+ if (mode.kind === 'thread') {
1917
+ tcw = new ThreadClusterWorker(this.logger);
1918
+ // 'shutdown' from the primary: drain and close; the thread then exits
1919
+ // on its own once the engine's handles are gone.
1920
+ tcw.onShutdown(async () => {
1921
+ this.eventBus.removeAllListeners();
1922
+ this.removeAllListeners();
1923
+ await this.close();
1748
1924
  });
1749
- // Master process callback
1750
- if (callback)
1751
- callback();
1752
1925
  }
1753
1926
  else {
1754
- // Worker process - start the actual server with proper cleanup
1755
- this.logger.info(`Worker ${process.pid} initializing`, 'Worker');
1756
- // Worker-specific optimizations for high concurrency
1757
- process.env.UV_THREADPOOL_SIZE = '64';
1758
- // Reduce logging contention in workers (major bottleneck)
1759
- // Multiple workers writing to same log files creates I/O contention
1760
- // ONLY reduce log level if user didn't explicitly set one
1761
- if (!this.userSetLogger) {
1762
- // Workers log less frequently to reduce I/O contention (only if not explicitly configured)
1763
- applyLoggingConfiguration(undefined, { level: 'warn' }); // Only warnings and errors
1764
- }
1765
- // Research-based memory optimization for workers
1766
- const totalMemoryGB = os.totalmem() / (1024 * 1024 * 1024);
1767
- const workerCount = Object.keys(cluster.workers || {}).length || 1;
1768
- // Conservative memory allocation
1769
- const heapSizePerWorkerMB = Math.min(Math.floor(((totalMemoryGB * 1024) / workerCount) * 0.8), // 80% of available memory
1770
- 1536 // Cap at 1.5GB (GC efficiency threshold from research)
1771
- );
1772
- process.env.NODE_OPTIONS = `--max-old-space-size=${heapSizePerWorkerMB}`;
1773
- this.logger.debug(`Worker memory allocated: ${heapSizePerWorkerMB}MB heap (${workerCount} workers, ${totalMemoryGB.toFixed(1)}GB total)`, 'Worker');
1774
- // Optimize V8 flags for better performance
1775
- if (process.env.NODE_ENV === 'production') {
1776
- // Aggressive V8 optimizations for maximum performance
1777
- const v8Flags = [
1778
- '--optimize-for-size', // Trade memory for speed
1779
- '--always-opt', // Always optimize functions
1780
- '--turbo-fast-api-calls', // Optimize API calls
1781
- '--turbo-escape-analysis', // Escape analysis optimization
1782
- '--turbo-inline-api-calls', // Inline API calls
1783
- `--max-old-space-size=${heapSizePerWorkerMB}`, // Limit memory to prevent GC pressure
1784
- ];
1785
- process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ' ' + v8Flags.join(' ');
1786
- }
1787
- // Optimize garbage collection for workers
1788
- // eslint-disable-next-line no-undef
1789
- if (global.gc) {
1790
- setInterval(() => {
1791
- // eslint-disable-next-line no-undef
1792
- if (global.gc)
1793
- global.gc();
1794
- }, 60000); // GC every 60 seconds (less frequent)
1795
- }
1796
1927
  // Graceful shutdown for worker
1797
1928
  const workerShutdown = () => {
1798
- this.logger.info(`Worker ${process.pid} shutting down gracefully...`, 'Worker');
1929
+ this.logger.info(`${label} shutting down gracefully...`, 'Worker');
1799
1930
  // Clean up event listeners
1800
1931
  this.eventBus.removeAllListeners();
1801
1932
  this.removeAllListeners();
@@ -1812,79 +1943,96 @@ export class Moro extends EventEmitter {
1812
1943
  // Handle worker shutdown signals
1813
1944
  process.on('SIGTERM', workerShutdown);
1814
1945
  process.on('SIGINT', workerShutdown);
1815
- // Continue with normal server startup for this worker
1816
- void this.eventBus.emit('server:starting', {
1946
+ }
1947
+ const fatal = (code, message) => {
1948
+ this.logger.error(`${label} ${message}`, 'Worker');
1949
+ if (tcw) {
1950
+ tcw.fatal(code, message);
1951
+ void this.close().finally(() => tcw?.close());
1952
+ }
1953
+ else {
1954
+ process.exit(1);
1955
+ }
1956
+ };
1957
+ // Continue with normal server startup for this worker
1958
+ void this.eventBus.emit('server:starting', {
1959
+ port,
1960
+ runtime: this.runtimeType,
1961
+ worker: workerRef,
1962
+ });
1963
+ // Add documentation middleware first (if enabled)
1964
+ try {
1965
+ const docsMiddleware = this.documentation.getDocsMiddleware();
1966
+ this.coreFramework.addMiddleware(docsMiddleware);
1967
+ }
1968
+ catch {
1969
+ // Documentation not enabled, that's fine
1970
+ }
1971
+ // Attach the unified router (handles both chainable and direct routes)
1972
+ this.attachUnifiedRouter();
1973
+ // Register legacy direct routes with the HTTP server (for backward compatibility)
1974
+ if (this.routes.length > 0) {
1975
+ this.registerDirectRoutes();
1976
+ }
1977
+ const workerCallback = () => {
1978
+ const displayHost = host || 'localhost';
1979
+ this.logger.info(`${label} ready on ${displayHost}:${port}`, 'Worker');
1980
+ void this.eventBus.emit('server:started', {
1817
1981
  port,
1818
1982
  runtime: this.runtimeType,
1819
- worker: process.pid,
1983
+ worker: workerRef,
1984
+ engine: this.engine,
1820
1985
  });
1821
- // Add documentation middleware first (if enabled)
1822
- try {
1823
- const docsMiddleware = this.documentation.getDocsMiddleware();
1824
- this.coreFramework.addMiddleware(docsMiddleware);
1986
+ // GraphQL registers HTTP/WS routes on this app: every worker.
1987
+ this.ensureGraphQLInitialized().catch(error => {
1988
+ this.logger.error(`Failed to initialize GraphQL: ${error}`, 'GraphQL');
1989
+ });
1990
+ tcw?.ready(port);
1991
+ };
1992
+ const startWorkerListening = () => {
1993
+ if (host) {
1994
+ this.coreFramework.listen(port, host, workerCallback);
1825
1995
  }
1826
- catch {
1827
- // Documentation not enabled, that's fine
1996
+ else {
1997
+ this.coreFramework.listen(port, workerCallback);
1828
1998
  }
1829
- // Attach the unified router (handles both chainable and direct routes)
1830
- this.attachUnifiedRouter();
1831
- // Register legacy direct routes with the HTTP server (for backward compatibility)
1832
- if (this.routes.length > 0) {
1833
- this.registerDirectRoutes();
1999
+ };
2000
+ // Same init as the single-process path: auto-discovery, file-based
2001
+ // routes, queued WebSocket registrations - before 1.9.0 workers skipped
2002
+ // discovery/route loading entirely.
2003
+ Promise.all([
2004
+ this.ensureAutoDiscoveryComplete().then(() => this.ensureRoutesLoaded()),
2005
+ this.processQueuedWebSocketRegistrations(),
2006
+ ])
2007
+ .then(startWorkerListening)
2008
+ .catch(error => {
2009
+ const message = error instanceof Error ? error.message : String(error);
2010
+ const code = error?.code;
2011
+ // listen() itself failed (EADDRINUSE etc.): retrying is guaranteed to
2012
+ // fail again and would surface as an unhandled rejection crash-loop.
2013
+ // Report fatally so the primary sees a clean death.
2014
+ if (code === 'EADDRINUSE' ||
2015
+ code === 'EACCES' ||
2016
+ /EADDRINUSE|failed to bind/i.test(message)) {
2017
+ fatal(code === 'EACCES' ? 'EACCES' : 'EADDRINUSE', `could not bind port ${port}: ${message}`);
2018
+ return;
1834
2019
  }
1835
- const workerCallback = () => {
1836
- const displayHost = host || 'localhost';
1837
- this.logger.info(`Worker ${process.pid} ready on ${displayHost}:${port}`, 'Worker');
1838
- void this.eventBus.emit('server:started', {
1839
- port,
1840
- runtime: this.runtimeType,
1841
- worker: process.pid,
1842
- engine: this.engine,
1843
- });
1844
- };
1845
- // Ensure WebSocket setup is complete before starting worker
1846
- const startWorkerListening = () => {
1847
- if (host) {
1848
- this.coreFramework.listen(port, host, workerCallback);
1849
- }
1850
- else {
1851
- this.coreFramework.listen(port, workerCallback);
1852
- }
1853
- };
1854
- this.processQueuedWebSocketRegistrations()
1855
- .then(startWorkerListening)
1856
- .catch(error => {
1857
- const message = error instanceof Error ? error.message : String(error);
1858
- const code = error?.code;
1859
- // listen() itself failed (EADDRINUSE etc.): retrying is guaranteed to
1860
- // fail again and would surface as an unhandled rejection crash-loop.
1861
- // Log fatally and exit so the cluster primary sees a clean death.
1862
- // Prefer the code the native/Node servers set; fall back to message.
1863
- if (code === 'EADDRINUSE' ||
1864
- code === 'EACCES' ||
1865
- /EADDRINUSE|failed to bind/i.test(message)) {
1866
- this.logger.error(`Worker ${process.pid} could not bind port ${port}: ${message}`, 'Worker');
1867
- process.exit(1);
1868
- }
1869
- this.logger.error('WebSocket initialization failed in worker', 'Worker', {
1870
- error: message,
1871
- });
1872
- // For WebSocket failures with queued registrations, error will propagate
1873
- if (error instanceof Error &&
1874
- error.message.includes('WebSocket features require a WebSocket adapter')) {
1875
- throw error;
1876
- }
1877
- // WebSocket setup failed but the HTTP server can still serve: start
1878
- // anyway, and surface (not swallow) a listen failure from this path.
1879
- try {
1880
- startWorkerListening();
1881
- }
1882
- catch (listenError) {
1883
- this.logger.error(`Worker ${process.pid} failed to start: ${listenError instanceof Error ? listenError.message : String(listenError)}`, 'Worker');
1884
- process.exit(1);
1885
- }
1886
- });
1887
- }
2020
+ this.logger.error('Initialization failed in worker', 'Worker', { error: message });
2021
+ // For WebSocket failures with queued registrations, error will propagate
2022
+ if (error instanceof Error &&
2023
+ error.message.includes('WebSocket features require a WebSocket adapter')) {
2024
+ throw error;
2025
+ }
2026
+ // Init failed but the HTTP server can still serve: start anyway, and
2027
+ // surface (not swallow) a listen failure from this path.
2028
+ try {
2029
+ startWorkerListening();
2030
+ }
2031
+ catch (listenError) {
2032
+ const lm = listenError instanceof Error ? listenError.message : String(listenError);
2033
+ fatal(listenError?.code === 'EACCES' ? 'EACCES' : 'EADDRINUSE', `failed to start: ${lm}`);
2034
+ }
2035
+ });
1888
2036
  }
1889
2037
  // Simple worker message handler
1890
2038
  handleWorkerMessage(message) {
@@ -1999,6 +2147,18 @@ export class Moro extends EventEmitter {
1999
2147
  }
2000
2148
  }
2001
2149
  }
2150
+ // Cluster worker threads first: each drains its server via its own
2151
+ // close(); the primary's services below stop after the traffic did.
2152
+ if (this.threadCluster) {
2153
+ const primary = this.threadCluster;
2154
+ this.threadCluster = undefined;
2155
+ try {
2156
+ await primary.shutdown();
2157
+ }
2158
+ catch (err) {
2159
+ this.logger.error(`Error shutting down cluster worker threads: ${String(err)}`);
2160
+ }
2161
+ }
2002
2162
  // Shutdown worker threads first (fastest)
2003
2163
  try {
2004
2164
  this.logger.info('Shutting down worker threads...');