@orkify/cli 1.0.0-beta.5

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 (203) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +1701 -0
  3. package/bin/orkify +3 -0
  4. package/boot/systemd/orkify@.service +30 -0
  5. package/dist/agent-name.d.ts +4 -0
  6. package/dist/agent-name.js +42 -0
  7. package/dist/alerts/AlertEvaluator.d.ts +14 -0
  8. package/dist/alerts/AlertEvaluator.js +135 -0
  9. package/dist/cli/commands/autostart.d.ts +3 -0
  10. package/dist/cli/commands/autostart.js +11 -0
  11. package/dist/cli/commands/crash-test.d.ts +3 -0
  12. package/dist/cli/commands/crash-test.js +17 -0
  13. package/dist/cli/commands/daemon-reload.d.ts +3 -0
  14. package/dist/cli/commands/daemon-reload.js +72 -0
  15. package/dist/cli/commands/delete.d.ts +3 -0
  16. package/dist/cli/commands/delete.js +37 -0
  17. package/dist/cli/commands/deploy.d.ts +6 -0
  18. package/dist/cli/commands/deploy.js +266 -0
  19. package/dist/cli/commands/down.d.ts +3 -0
  20. package/dist/cli/commands/down.js +36 -0
  21. package/dist/cli/commands/flush.d.ts +3 -0
  22. package/dist/cli/commands/flush.js +28 -0
  23. package/dist/cli/commands/kill.d.ts +3 -0
  24. package/dist/cli/commands/kill.js +35 -0
  25. package/dist/cli/commands/list.d.ts +14 -0
  26. package/dist/cli/commands/list.js +361 -0
  27. package/dist/cli/commands/logs.d.ts +3 -0
  28. package/dist/cli/commands/logs.js +107 -0
  29. package/dist/cli/commands/mcp.d.ts +3 -0
  30. package/dist/cli/commands/mcp.js +151 -0
  31. package/dist/cli/commands/reload.d.ts +3 -0
  32. package/dist/cli/commands/reload.js +54 -0
  33. package/dist/cli/commands/restart.d.ts +3 -0
  34. package/dist/cli/commands/restart.js +43 -0
  35. package/dist/cli/commands/restore.d.ts +3 -0
  36. package/dist/cli/commands/restore.js +88 -0
  37. package/dist/cli/commands/run.d.ts +8 -0
  38. package/dist/cli/commands/run.js +212 -0
  39. package/dist/cli/commands/snap.d.ts +3 -0
  40. package/dist/cli/commands/snap.js +30 -0
  41. package/dist/cli/commands/up.d.ts +3 -0
  42. package/dist/cli/commands/up.js +125 -0
  43. package/dist/cli/crash-recovery.d.ts +2 -0
  44. package/dist/cli/crash-recovery.js +67 -0
  45. package/dist/cli/index.d.ts +3 -0
  46. package/dist/cli/index.js +46 -0
  47. package/dist/cli/parse.d.ts +28 -0
  48. package/dist/cli/parse.js +97 -0
  49. package/dist/cluster/ClusterWrapper.d.ts +18 -0
  50. package/dist/cluster/ClusterWrapper.js +602 -0
  51. package/dist/config/ConfigStore.d.ts +11 -0
  52. package/dist/config/ConfigStore.js +21 -0
  53. package/dist/config/schema.d.ts +103 -0
  54. package/dist/config/schema.js +49 -0
  55. package/dist/constants.d.ts +83 -0
  56. package/dist/constants.js +289 -0
  57. package/dist/cron/CronScheduler.d.ts +25 -0
  58. package/dist/cron/CronScheduler.js +149 -0
  59. package/dist/daemon/GracefulManager.d.ts +8 -0
  60. package/dist/daemon/GracefulManager.js +29 -0
  61. package/dist/daemon/ManagedProcess.d.ts +71 -0
  62. package/dist/daemon/ManagedProcess.js +1020 -0
  63. package/dist/daemon/Orchestrator.d.ts +51 -0
  64. package/dist/daemon/Orchestrator.js +416 -0
  65. package/dist/daemon/RotatingWriter.d.ts +27 -0
  66. package/dist/daemon/RotatingWriter.js +264 -0
  67. package/dist/daemon/index.d.ts +2 -0
  68. package/dist/daemon/index.js +106 -0
  69. package/dist/daemon/startDaemon.d.ts +30 -0
  70. package/dist/daemon/startDaemon.js +693 -0
  71. package/dist/deploy/CommandPoller.d.ts +13 -0
  72. package/dist/deploy/CommandPoller.js +53 -0
  73. package/dist/deploy/DeployExecutor.d.ts +33 -0
  74. package/dist/deploy/DeployExecutor.js +340 -0
  75. package/dist/deploy/config.d.ts +20 -0
  76. package/dist/deploy/config.js +161 -0
  77. package/dist/deploy/env.d.ts +2 -0
  78. package/dist/deploy/env.js +17 -0
  79. package/dist/deploy/tarball.d.ts +32 -0
  80. package/dist/deploy/tarball.js +243 -0
  81. package/dist/detect/framework.d.ts +2 -0
  82. package/dist/detect/framework.js +24 -0
  83. package/dist/ipc/DaemonClient.d.ts +31 -0
  84. package/dist/ipc/DaemonClient.js +248 -0
  85. package/dist/ipc/DaemonServer.d.ts +28 -0
  86. package/dist/ipc/DaemonServer.js +166 -0
  87. package/dist/ipc/MultiUserClient.d.ts +27 -0
  88. package/dist/ipc/MultiUserClient.js +203 -0
  89. package/dist/ipc/protocol.d.ts +7 -0
  90. package/dist/ipc/protocol.js +53 -0
  91. package/dist/ipc/restoreDaemon.d.ts +8 -0
  92. package/dist/ipc/restoreDaemon.js +19 -0
  93. package/dist/machine-id.d.ts +11 -0
  94. package/dist/machine-id.js +51 -0
  95. package/dist/mcp/auth.d.ts +118 -0
  96. package/dist/mcp/auth.js +245 -0
  97. package/dist/mcp/http.d.ts +20 -0
  98. package/dist/mcp/http.js +229 -0
  99. package/dist/mcp/index.d.ts +3 -0
  100. package/dist/mcp/index.js +8 -0
  101. package/dist/mcp/server.d.ts +37 -0
  102. package/dist/mcp/server.js +413 -0
  103. package/dist/probe/compute-fingerprint.d.ts +27 -0
  104. package/dist/probe/compute-fingerprint.js +65 -0
  105. package/dist/probe/parse-frames.d.ts +21 -0
  106. package/dist/probe/parse-frames.js +57 -0
  107. package/dist/probe/resolve-sourcemaps.d.ts +25 -0
  108. package/dist/probe/resolve-sourcemaps.js +281 -0
  109. package/dist/state/StateStore.d.ts +11 -0
  110. package/dist/state/StateStore.js +78 -0
  111. package/dist/telemetry/TelemetryReporter.d.ts +49 -0
  112. package/dist/telemetry/TelemetryReporter.js +451 -0
  113. package/dist/types/index.d.ts +373 -0
  114. package/dist/types/index.js +2 -0
  115. package/package.json +148 -0
  116. package/packages/cache/README.md +114 -0
  117. package/packages/cache/dist/CacheClient.d.ts +26 -0
  118. package/packages/cache/dist/CacheClient.d.ts.map +1 -0
  119. package/packages/cache/dist/CacheClient.js +174 -0
  120. package/packages/cache/dist/CacheClient.js.map +1 -0
  121. package/packages/cache/dist/CacheFileStore.d.ts +45 -0
  122. package/packages/cache/dist/CacheFileStore.d.ts.map +1 -0
  123. package/packages/cache/dist/CacheFileStore.js +446 -0
  124. package/packages/cache/dist/CacheFileStore.js.map +1 -0
  125. package/packages/cache/dist/CachePersistence.d.ts +9 -0
  126. package/packages/cache/dist/CachePersistence.d.ts.map +1 -0
  127. package/packages/cache/dist/CachePersistence.js +67 -0
  128. package/packages/cache/dist/CachePersistence.js.map +1 -0
  129. package/packages/cache/dist/CachePrimary.d.ts +25 -0
  130. package/packages/cache/dist/CachePrimary.d.ts.map +1 -0
  131. package/packages/cache/dist/CachePrimary.js +155 -0
  132. package/packages/cache/dist/CachePrimary.js.map +1 -0
  133. package/packages/cache/dist/CacheStore.d.ts +50 -0
  134. package/packages/cache/dist/CacheStore.d.ts.map +1 -0
  135. package/packages/cache/dist/CacheStore.js +271 -0
  136. package/packages/cache/dist/CacheStore.js.map +1 -0
  137. package/packages/cache/dist/constants.d.ts +6 -0
  138. package/packages/cache/dist/constants.d.ts.map +1 -0
  139. package/packages/cache/dist/constants.js +9 -0
  140. package/packages/cache/dist/constants.js.map +1 -0
  141. package/packages/cache/dist/index.d.ts +16 -0
  142. package/packages/cache/dist/index.d.ts.map +1 -0
  143. package/packages/cache/dist/index.js +86 -0
  144. package/packages/cache/dist/index.js.map +1 -0
  145. package/packages/cache/dist/serialize.d.ts +9 -0
  146. package/packages/cache/dist/serialize.d.ts.map +1 -0
  147. package/packages/cache/dist/serialize.js +40 -0
  148. package/packages/cache/dist/serialize.js.map +1 -0
  149. package/packages/cache/dist/types.d.ts +123 -0
  150. package/packages/cache/dist/types.d.ts.map +1 -0
  151. package/packages/cache/dist/types.js +2 -0
  152. package/packages/cache/dist/types.js.map +1 -0
  153. package/packages/cache/package.json +27 -0
  154. package/packages/cache/src/CacheClient.ts +227 -0
  155. package/packages/cache/src/CacheFileStore.ts +528 -0
  156. package/packages/cache/src/CachePersistence.ts +89 -0
  157. package/packages/cache/src/CachePrimary.ts +172 -0
  158. package/packages/cache/src/CacheStore.ts +308 -0
  159. package/packages/cache/src/constants.ts +10 -0
  160. package/packages/cache/src/index.ts +100 -0
  161. package/packages/cache/src/serialize.ts +49 -0
  162. package/packages/cache/src/types.ts +156 -0
  163. package/packages/cache/tsconfig.json +18 -0
  164. package/packages/cache/tsconfig.tsbuildinfo +1 -0
  165. package/packages/next/README.md +166 -0
  166. package/packages/next/dist/error-capture.d.ts +34 -0
  167. package/packages/next/dist/error-capture.d.ts.map +1 -0
  168. package/packages/next/dist/error-capture.js +130 -0
  169. package/packages/next/dist/error-capture.js.map +1 -0
  170. package/packages/next/dist/error-handler.d.ts +10 -0
  171. package/packages/next/dist/error-handler.d.ts.map +1 -0
  172. package/packages/next/dist/error-handler.js +186 -0
  173. package/packages/next/dist/error-handler.js.map +1 -0
  174. package/packages/next/dist/isr-cache.d.ts +9 -0
  175. package/packages/next/dist/isr-cache.d.ts.map +1 -0
  176. package/packages/next/dist/isr-cache.js +86 -0
  177. package/packages/next/dist/isr-cache.js.map +1 -0
  178. package/packages/next/dist/stream.d.ts +5 -0
  179. package/packages/next/dist/stream.d.ts.map +1 -0
  180. package/packages/next/dist/stream.js +22 -0
  181. package/packages/next/dist/stream.js.map +1 -0
  182. package/packages/next/dist/types.d.ts +33 -0
  183. package/packages/next/dist/types.d.ts.map +1 -0
  184. package/packages/next/dist/types.js +6 -0
  185. package/packages/next/dist/types.js.map +1 -0
  186. package/packages/next/dist/use-cache.d.ts +4 -0
  187. package/packages/next/dist/use-cache.d.ts.map +1 -0
  188. package/packages/next/dist/use-cache.js +86 -0
  189. package/packages/next/dist/use-cache.js.map +1 -0
  190. package/packages/next/dist/utils.d.ts +32 -0
  191. package/packages/next/dist/utils.d.ts.map +1 -0
  192. package/packages/next/dist/utils.js +88 -0
  193. package/packages/next/dist/utils.js.map +1 -0
  194. package/packages/next/package.json +52 -0
  195. package/packages/next/src/error-capture.ts +177 -0
  196. package/packages/next/src/error-handler.ts +221 -0
  197. package/packages/next/src/isr-cache.ts +100 -0
  198. package/packages/next/src/stream.ts +23 -0
  199. package/packages/next/src/types.ts +33 -0
  200. package/packages/next/src/use-cache.ts +99 -0
  201. package/packages/next/src/utils.ts +102 -0
  202. package/packages/next/tsconfig.json +19 -0
  203. package/packages/next/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ClusterWrapper - Primary process that manages worker lifecycle
4
+ *
5
+ * This script is spawned by ManagedProcess when running in cluster mode.
6
+ * It uses Node's cluster module to fork workers that share the same port.
7
+ *
8
+ * Environment variables expected:
9
+ * - ORKIFY_SCRIPT: Path to the user's script
10
+ * - ORKIFY_WORKERS: Number of workers to spawn
11
+ * - ORKIFY_PROCESS_NAME: Process name for logging
12
+ * - ORKIFY_PROCESS_ID: Process ID in ORKIFY
13
+ * - ORKIFY_KILL_TIMEOUT: Timeout for graceful shutdown
14
+ * - ORKIFY_STICKY: Whether to use sticky sessions
15
+ * - ORKIFY_ARGS: JSON-encoded array of arguments to pass to the user script
16
+ */
17
+ import { CachePrimary } from '@orkify/cache/primary';
18
+ import cluster from 'node:cluster';
19
+ import { createHash } from 'node:crypto';
20
+ import { createServer } from 'node:net';
21
+ import { METRICS_PROBE_IMPORT } from '../constants.js';
22
+ // Force round-robin scheduling on all platforms
23
+ // By default, Windows uses "shared handle" where workers compete for connections,
24
+ // leading to very unbalanced load distribution (one worker may handle 70%+ of requests)
25
+ // Setting SCHED_RR ensures even distribution across all workers
26
+ cluster.schedulingPolicy = cluster.SCHED_RR;
27
+ const SCRIPT = process.env.ORKIFY_SCRIPT;
28
+ if (!SCRIPT) {
29
+ console.error('ORKIFY_SCRIPT environment variable is required');
30
+ process.exit(1);
31
+ }
32
+ const WORKER_COUNT = parseInt(process.env.ORKIFY_WORKERS || '1', 10);
33
+ const PROCESS_NAME = process.env.ORKIFY_PROCESS_NAME || 'app';
34
+ const PROCESS_ID = process.env.ORKIFY_PROCESS_ID || '0';
35
+ const KILL_TIMEOUT = parseInt(process.env.ORKIFY_KILL_TIMEOUT || '5000', 10);
36
+ const RELOAD_RETRIES = parseInt(process.env.ORKIFY_RELOAD_RETRIES || '3', 10);
37
+ const STICKY = process.env.ORKIFY_STICKY === 'true';
38
+ // Use ORKIFY_STICKY_PORT if set, otherwise fall back to PORT env
39
+ const STICKY_PORT = process.env.ORKIFY_STICKY_PORT
40
+ ? parseInt(process.env.ORKIFY_STICKY_PORT, 10)
41
+ : process.env.PORT
42
+ ? parseInt(process.env.PORT, 10)
43
+ : null;
44
+ const SCRIPT_ARGS = process.env.ORKIFY_ARGS ? JSON.parse(process.env.ORKIFY_ARGS) : [];
45
+ const HEALTH_CHECK = process.env.ORKIFY_HEALTH_CHECK || null;
46
+ const HEALTH_PORT = process.env.ORKIFY_PORT ? parseInt(process.env.ORKIFY_PORT, 10) : null;
47
+ const workers = new Map();
48
+ const stickySessionMap = new Map(); // Session ID -> Worker mapping
49
+ const freeSlots = new Set();
50
+ let isShuttingDown = false;
51
+ let isReloading = false;
52
+ const reloadCandidateWorkerIds = new Set(); // cluster worker.id values of temp replacement workers
53
+ let stickyServer = null;
54
+ const cachePrimary = new CachePrimary(PROCESS_NAME);
55
+ function log(message) {
56
+ console.log(message);
57
+ }
58
+ function setupCluster() {
59
+ // Configure cluster to use the user's script
60
+ // Inject the metrics probe into workers (not the primary) via --import
61
+ const execArgv = [METRICS_PROBE_IMPORT];
62
+ cluster.setupPrimary({
63
+ exec: SCRIPT,
64
+ args: SCRIPT_ARGS,
65
+ silent: true, // Capture per-worker stdout/stderr for log attribution
66
+ windowsHide: true, // Hide worker console windows on Windows
67
+ serialization: 'advanced', // V8 structured clone — preserves Map, Set, Date, etc.
68
+ execArgv,
69
+ });
70
+ cluster.on('online', (worker) => {
71
+ const state = findWorkerState(worker);
72
+ if (state) {
73
+ state.pid = worker.process.pid ?? state.pid;
74
+ log(`Worker ${state.id} online (PID: ${state.pid})`);
75
+ // Suppress IPC for temporary reload candidates — ManagedProcess
76
+ // will be notified via reload:complete once the outcome is determined.
77
+ if (!reloadCandidateWorkerIds.has(worker.id) && process.send) {
78
+ process.send({ type: 'worker:online', workerId: state.id, pid: state.pid });
79
+ }
80
+ // Send cache snapshot to new worker so it starts with a warm cache.
81
+ //
82
+ // Race condition safety: this is safe without explicit broadcast buffering
83
+ // because (1) sendSnapshot() is synchronous — serialize + send happen in the
84
+ // same event-loop tick, so no broadcast can interleave, (2) IPC is FIFO per
85
+ // connection, so any broadcasts sent BEFORE this point arrive at the worker
86
+ // first but are harmlessly overwritten by applySnapshot()'s full replace,
87
+ // and any broadcasts sent AFTER arrive in order on top of the snapshot.
88
+ // The early-listener buffer in cache/index.ts handles messages that arrive
89
+ // before the worker's CacheClient is created.
90
+ if (state.worker.isConnected()) {
91
+ cachePrimary.sendSnapshot(state.worker);
92
+ }
93
+ }
94
+ });
95
+ cluster.on('listening', (worker, address) => {
96
+ const state = findWorkerState(worker);
97
+ if (state && !state.ready) {
98
+ state.ready = true;
99
+ log(`Worker ${state.id} listening on ${address.address || '*'}:${address.port}`);
100
+ if (!reloadCandidateWorkerIds.has(worker.id) && process.send) {
101
+ process.send({
102
+ type: 'worker:listening',
103
+ workerId: state.id,
104
+ address: { address: address.address, port: address.port },
105
+ });
106
+ }
107
+ }
108
+ });
109
+ cluster.on('exit', (worker, code, signal) => {
110
+ const state = findWorkerState(worker);
111
+ if (state) {
112
+ const slotId = state.id;
113
+ workers.delete(worker.id);
114
+ // Only mark slot as free if no other worker is using it
115
+ // (during reload, the replacement worker already holds this slot)
116
+ const slotStillInUse = Array.from(workers.values()).some((w) => w.id === slotId);
117
+ if (!slotStillInUse) {
118
+ freeSlots.add(slotId);
119
+ }
120
+ log(`Worker ${slotId} exited (code: ${code}, signal: ${signal})`);
121
+ // Suppress IPC for temporary reload candidate workers exiting — these
122
+ // are intermediate attempts that should not affect ManagedProcess state.
123
+ // The old worker being stopped is NOT in this set, so its exit propagates normally.
124
+ if (!reloadCandidateWorkerIds.has(worker.id) && process.send) {
125
+ process.send({ type: 'worker:exit', workerId: slotId, pid: state.pid, code, signal });
126
+ }
127
+ reloadCandidateWorkerIds.delete(worker.id);
128
+ // Auto-restart if not shutting down or reloading
129
+ if (!isShuttingDown && !isReloading) {
130
+ log(`Restarting worker ${slotId}...`);
131
+ spawnWorker(slotId);
132
+ }
133
+ }
134
+ });
135
+ cluster.on('message', (worker, message) => {
136
+ const state = findWorkerState(worker);
137
+ if (!state)
138
+ return;
139
+ // Relay metrics probe messages from workers to the daemon
140
+ const probeMsg = message;
141
+ if (probeMsg?.__orkify && probeMsg.type === 'metrics') {
142
+ if (process.send) {
143
+ process.send({ type: 'worker:metrics', workerId: state.id, data: probeMsg.data });
144
+ }
145
+ return;
146
+ }
147
+ if (probeMsg?.__orkify && probeMsg.type === 'error') {
148
+ if (process.send) {
149
+ process.send({
150
+ type: 'worker:error:captured',
151
+ workerId: state.id,
152
+ data: probeMsg.data,
153
+ });
154
+ }
155
+ return;
156
+ }
157
+ if (probeMsg?.__orkify && probeMsg.type === 'broadcast') {
158
+ for (const [, s] of workers) {
159
+ if (s.worker !== worker && s.worker.isConnected()) {
160
+ s.worker.send(message);
161
+ }
162
+ }
163
+ return;
164
+ }
165
+ if (probeMsg?.__orkify && probeMsg.type?.startsWith('cache:')) {
166
+ cachePrimary.handleMessage(worker, probeMsg, workers);
167
+ return;
168
+ }
169
+ if (message === 'ready' && !state.ready) {
170
+ state.ready = true;
171
+ log(`Worker ${state.id} ready`);
172
+ // Notify parent (daemon) that a worker is ready
173
+ if (process.send) {
174
+ process.send({ type: 'worker:ready', workerId: state.id });
175
+ }
176
+ }
177
+ // Handle sticky session registration from @socket.io/sticky
178
+ // Workers send these messages when clients connect/disconnect
179
+ const msg = message;
180
+ if (msg.type === 'sticky:connection' && msg.data) {
181
+ stickySessionMap.set(msg.data, state);
182
+ }
183
+ else if (msg.type === 'sticky:disconnection' && msg.data) {
184
+ stickySessionMap.delete(msg.data);
185
+ }
186
+ });
187
+ }
188
+ function findWorkerState(worker) {
189
+ for (const state of workers.values()) {
190
+ if (state.worker === worker) {
191
+ return state;
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+ function allocateSlot() {
197
+ if (freeSlots.size > 0) {
198
+ return Math.min(...freeSlots);
199
+ }
200
+ return workers.size;
201
+ }
202
+ function spawnWorker(slotId) {
203
+ const workerId = slotId ?? allocateSlot();
204
+ freeSlots.delete(workerId);
205
+ const env = {
206
+ ...process.env,
207
+ ORKIFY_WORKER_ID: String(workerId),
208
+ ORKIFY_PROCESS_NAME: PROCESS_NAME,
209
+ ORKIFY_PROCESS_ID: PROCESS_ID,
210
+ ORKIFY_CLUSTER_MODE: 'true',
211
+ ORKIFY_WORKERS: String(WORKER_COUNT),
212
+ };
213
+ // When sticky mode is enabled, workers receive connections via IPC from the
214
+ // primary's sticky balancer. They should NOT bind to the sticky port.
215
+ if (STICKY && STICKY_PORT) {
216
+ env.ORKIFY_STICKY = 'true';
217
+ env.ORKIFY_WORKER_PORT = '0'; // Bind to ephemeral port, connections come via IPC
218
+ env.PORT = String(STICKY_PORT); // Keep for reference (e.g., socket.io client URLs)
219
+ }
220
+ const worker = cluster.fork(env);
221
+ const state = {
222
+ worker,
223
+ id: workerId,
224
+ pid: worker.process.pid ?? 0,
225
+ ready: false,
226
+ stale: false,
227
+ startedAt: Date.now(),
228
+ };
229
+ workers.set(worker.id, state);
230
+ // With silent: true each worker gets its own stdout/stderr streams.
231
+ // Relay to the daemon via IPC with the real workerId for per-worker
232
+ // log attribution. ManagedProcess.handleLog writes to log files.
233
+ if (process.send) {
234
+ const send = process.send.bind(process);
235
+ worker.process.stdout?.on('data', (chunk) => {
236
+ send({ type: 'worker:output', workerId, stream: 'out', data: chunk.toString() });
237
+ });
238
+ worker.process.stderr?.on('data', (chunk) => {
239
+ send({ type: 'worker:output', workerId, stream: 'err', data: chunk.toString() });
240
+ });
241
+ }
242
+ return state;
243
+ }
244
+ async function stopWorker(state) {
245
+ return new Promise((resolve) => {
246
+ const timeout = setTimeout(() => {
247
+ log(`Worker ${state.id} kill timeout, forcing...`);
248
+ state.worker.kill('SIGKILL');
249
+ resolve();
250
+ }, KILL_TIMEOUT);
251
+ state.worker.once('exit', () => {
252
+ clearTimeout(timeout);
253
+ resolve();
254
+ });
255
+ // First disconnect from cluster (stops receiving new connections)
256
+ // This ensures the load balancer stops routing to this worker
257
+ try {
258
+ state.worker.disconnect();
259
+ }
260
+ catch {
261
+ // Worker might already be disconnected
262
+ }
263
+ // Give brief time for in-flight requests to complete, then send SIGTERM
264
+ setTimeout(() => {
265
+ state.worker.kill('SIGTERM');
266
+ }, 100);
267
+ });
268
+ }
269
+ async function reload() {
270
+ if (isReloading) {
271
+ log('Reload already in progress');
272
+ return;
273
+ }
274
+ isReloading = true;
275
+ const slotResults = [];
276
+ try {
277
+ log(`Starting rolling reload of ${workers.size} workers...`);
278
+ // Get current worker states
279
+ const oldWorkers = Array.from(workers.values());
280
+ for (const oldState of oldWorkers) {
281
+ if (isShuttingDown)
282
+ break;
283
+ const slotId = oldState.id;
284
+ let replaced = false;
285
+ for (let attempt = 0; attempt <= RELOAD_RETRIES; attempt++) {
286
+ if (isShuttingDown)
287
+ break;
288
+ if (attempt > 0) {
289
+ // Brief backoff before retrying to let transient issues resolve
290
+ await new Promise((r) => setTimeout(r, 1000));
291
+ log(`Retrying worker ${slotId} (attempt ${attempt + 1}/${RELOAD_RETRIES + 1})...`);
292
+ }
293
+ else {
294
+ log(`Replacing worker ${slotId}...`);
295
+ }
296
+ // Spawn new worker with the same slot ID.
297
+ // Track its cluster worker.id so we can suppress IPC events for it
298
+ // while it's a temporary reload candidate.
299
+ const newState = spawnWorker(slotId);
300
+ reloadCandidateWorkerIds.add(newState.worker.id);
301
+ try {
302
+ // Wait for new worker to be ready (with timeout)
303
+ await waitForReady(newState, 30000);
304
+ // Small delay to ensure cluster has registered new worker for load balancing
305
+ await new Promise((r) => setTimeout(r, 50));
306
+ // New worker is ready — promote it. Remove from candidates so
307
+ // subsequent events (if any) propagate normally.
308
+ reloadCandidateWorkerIds.delete(newState.worker.id);
309
+ // Stop old worker (disconnect first, then graceful shutdown).
310
+ // Its exit event will propagate to ManagedProcess normally.
311
+ await stopWorker(oldState);
312
+ log(`Worker ${slotId} replaced`);
313
+ replaced = true;
314
+ // Notify ManagedProcess of the new worker
315
+ if (process.send) {
316
+ process.send({ type: 'worker:online', workerId: slotId, pid: newState.pid });
317
+ if (newState.ready) {
318
+ process.send({ type: 'worker:listening', workerId: slotId });
319
+ }
320
+ }
321
+ slotResults.push({ slotId, status: 'success', retries: attempt });
322
+ break;
323
+ }
324
+ catch {
325
+ // New worker failed to become ready — kill it
326
+ log(`Worker ${slotId} failed to start on attempt ${attempt + 1}`);
327
+ try {
328
+ newState.worker.kill('SIGKILL');
329
+ }
330
+ catch {
331
+ // Worker might already be dead
332
+ }
333
+ // Note: reloadCandidateWorkerIds entry is cleaned up by exit handler
334
+ }
335
+ }
336
+ if (!replaced && !isShuttingDown) {
337
+ // All retries exhausted — keep old worker alive but mark it stale
338
+ log(`Worker ${slotId} reload failed after ${RELOAD_RETRIES + 1} attempts, keeping old worker (stale)`);
339
+ oldState.stale = true;
340
+ slotResults.push({
341
+ slotId,
342
+ status: 'stale',
343
+ retries: RELOAD_RETRIES,
344
+ error: 'All reload retries exhausted',
345
+ });
346
+ // Abort remaining slots
347
+ log('Aborting remaining reload slots due to failure');
348
+ break;
349
+ }
350
+ }
351
+ // If all slots succeeded, clear any prior stale flags
352
+ const allSuccess = slotResults.every((r) => r.status === 'success');
353
+ if (allSuccess) {
354
+ for (const state of workers.values()) {
355
+ state.stale = false;
356
+ }
357
+ }
358
+ const failed = slotResults.filter((r) => r.status !== 'success');
359
+ if (failed.length > 0) {
360
+ log(`Rolling reload completed with failures: ${failed.map((f) => `slot ${f.slotId} (${f.status})`).join(', ')}`);
361
+ }
362
+ else {
363
+ log('Rolling reload complete');
364
+ }
365
+ }
366
+ finally {
367
+ isReloading = false;
368
+ reloadCandidateWorkerIds.clear();
369
+ if (process.send) {
370
+ process.send({ type: 'reload:complete', results: slotResults });
371
+ }
372
+ }
373
+ }
374
+ async function restartWorker(slotId) {
375
+ if (isShuttingDown || isReloading)
376
+ return;
377
+ const oldState = Array.from(workers.values()).find((w) => w.id === slotId);
378
+ if (!oldState)
379
+ return;
380
+ log(`Memory restart: replacing worker ${slotId}...`);
381
+ const newState = spawnWorker(slotId);
382
+ reloadCandidateWorkerIds.add(newState.worker.id);
383
+ try {
384
+ await waitForReady(newState, 30000);
385
+ await new Promise((r) => setTimeout(r, 50));
386
+ // Promote new worker
387
+ reloadCandidateWorkerIds.delete(newState.worker.id);
388
+ // Stop old worker gracefully (disconnect → SIGTERM)
389
+ await stopWorker(oldState);
390
+ log(`Worker ${slotId} replaced (memory restart)`);
391
+ if (process.send) {
392
+ process.send({ type: 'worker:online', workerId: slotId, pid: newState.pid });
393
+ if (newState.ready) {
394
+ process.send({ type: 'worker:listening', workerId: slotId });
395
+ }
396
+ }
397
+ }
398
+ catch {
399
+ // Replacement failed — kill it, keep old worker
400
+ log(`Memory restart of worker ${slotId} failed, keeping old worker`);
401
+ try {
402
+ newState.worker.kill('SIGKILL');
403
+ }
404
+ catch {
405
+ // Worker might already be dead
406
+ }
407
+ reloadCandidateWorkerIds.delete(newState.worker.id);
408
+ if (process.send) {
409
+ process.send({ type: 'restart-worker-failed', workerId: slotId });
410
+ }
411
+ }
412
+ }
413
+ async function checkHealth(port, path) {
414
+ const url = `http://localhost:${port}${path}`;
415
+ for (let attempt = 0; attempt < 3; attempt++) {
416
+ try {
417
+ const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
418
+ if (resp.status >= 200 && resp.status < 300)
419
+ return;
420
+ }
421
+ catch {
422
+ // Retry
423
+ }
424
+ if (attempt < 2)
425
+ await new Promise((r) => setTimeout(r, 1000));
426
+ }
427
+ throw new Error(`Health check failed: ${url}`);
428
+ }
429
+ function waitForReady(state, timeout) {
430
+ return new Promise((resolve, reject) => {
431
+ const doResolve = async () => {
432
+ try {
433
+ if (HEALTH_CHECK && HEALTH_PORT) {
434
+ await checkHealth(HEALTH_PORT, HEALTH_CHECK);
435
+ }
436
+ resolve();
437
+ }
438
+ catch (err) {
439
+ reject(err);
440
+ }
441
+ };
442
+ if (state.ready) {
443
+ doResolve();
444
+ return;
445
+ }
446
+ const checkReadyInterval = setInterval(() => {
447
+ if (state.ready) {
448
+ clearTimeout(timer);
449
+ clearInterval(checkReadyInterval);
450
+ doResolve();
451
+ }
452
+ }, 100);
453
+ const timer = setTimeout(() => {
454
+ clearInterval(checkReadyInterval);
455
+ reject(new Error(`Worker ${state.id} ready timeout after ${timeout}ms`));
456
+ }, timeout);
457
+ });
458
+ }
459
+ async function shutdown() {
460
+ if (isShuttingDown)
461
+ return;
462
+ isShuttingDown = true;
463
+ log('Shutting down all workers...');
464
+ // Stop sticky server if running
465
+ if (stickyServer) {
466
+ stickyServer.close();
467
+ }
468
+ // Stop all workers in parallel
469
+ const promises = Array.from(workers.values()).map(stopWorker);
470
+ await Promise.all(promises);
471
+ log('All workers stopped');
472
+ process.exit(0);
473
+ }
474
+ // Sticky session support - intercept connections and route by session
475
+ // Uses the same message format as @socket.io/sticky for compatibility
476
+ function setupStickyServer(port) {
477
+ const workerArray = () => Array.from(workers.values()).filter((w) => w.ready);
478
+ let currentIndex = 0;
479
+ stickyServer = createServer((socket) => {
480
+ socket.on('data', (buffer) => {
481
+ const activeWorkers = workerArray();
482
+ if (activeWorkers.length === 0) {
483
+ socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
484
+ return;
485
+ }
486
+ const data = buffer.toString();
487
+ // Extract session ID for sticky routing
488
+ const sessionId = extractSessionId(buffer);
489
+ let targetWorker;
490
+ if (sessionId) {
491
+ // Check if we have a cached worker for this session
492
+ // stickySessionMap is populated by workers via IPC when they create sessions
493
+ const cachedWorker = stickySessionMap.get(sessionId);
494
+ if (cachedWorker && activeWorkers.includes(cachedWorker)) {
495
+ targetWorker = cachedWorker;
496
+ }
497
+ else {
498
+ // Hash session to worker for consistent routing
499
+ const hash = createHash('md5').update(sessionId).digest('hex');
500
+ const num = parseInt(hash.substring(0, 8), 16);
501
+ const workerIndex = num % activeWorkers.length;
502
+ targetWorker = activeWorkers[workerIndex];
503
+ stickySessionMap.set(sessionId, targetWorker);
504
+ }
505
+ }
506
+ else {
507
+ // Round-robin for new connections without session ID
508
+ currentIndex = (currentIndex + 1) % activeWorkers.length;
509
+ targetWorker = activeWorkers[currentIndex];
510
+ }
511
+ // Send in the format that @socket.io/sticky's setupWorker expects
512
+ targetWorker.worker.send({ type: 'sticky:connection', data }, socket, { keepOpen: false }, (err) => {
513
+ if (err) {
514
+ socket.destroy();
515
+ }
516
+ });
517
+ });
518
+ });
519
+ stickyServer.listen(port, () => {
520
+ log(`Sticky balancer listening on port ${port}`);
521
+ });
522
+ // Clean up session mappings when workers exit
523
+ cluster.on('exit', (worker) => {
524
+ stickySessionMap.forEach((value, key) => {
525
+ if (value.worker === worker) {
526
+ stickySessionMap.delete(key);
527
+ }
528
+ });
529
+ });
530
+ }
531
+ function extractSessionId(buffer) {
532
+ const data = buffer.toString('utf8', 0, Math.min(buffer.length, 2048));
533
+ // Custom sticky_id parameter (recommended for explicit sticky routing)
534
+ const stickyIdMatch = data.match(/sticky_id=([^&\s\r\n]+)/);
535
+ if (stickyIdMatch)
536
+ return stickyIdMatch[1];
537
+ // Socket.IO sid parameter (session already established)
538
+ const sidMatch = data.match(/sid=([^&\s\r\n]+)/);
539
+ if (sidMatch)
540
+ return sidMatch[1];
541
+ // io cookie
542
+ const cookieMatch = data.match(/Cookie:[^\r\n]*io=([^;\s\r\n]+)/i);
543
+ if (cookieMatch)
544
+ return cookieMatch[1];
545
+ // X-Forwarded-For for IP-based stickiness
546
+ const forwardedMatch = data.match(/X-Forwarded-For:\s*([^,\r\n]+)/i);
547
+ if (forwardedMatch)
548
+ return forwardedMatch[1].trim();
549
+ return null;
550
+ }
551
+ // Handle IPC messages from daemon
552
+ process.on('message', async (message) => {
553
+ const msg = message;
554
+ switch (msg.type) {
555
+ case 'reload':
556
+ await reload();
557
+ break;
558
+ case 'restart-worker':
559
+ await restartWorker(msg.workerId);
560
+ break;
561
+ case 'shutdown':
562
+ if (msg.persistCache) {
563
+ await cachePrimary.shutdown().catch((err) => {
564
+ log(`Cache persist failed: ${err instanceof Error ? err.message : String(err)}`);
565
+ });
566
+ }
567
+ else {
568
+ cachePrimary.destroy();
569
+ }
570
+ await shutdown();
571
+ break;
572
+ }
573
+ });
574
+ // Signal handlers
575
+ process.on('SIGTERM', shutdown);
576
+ process.on('SIGINT', shutdown);
577
+ // Start the cluster
578
+ (async () => {
579
+ setupCluster();
580
+ // Restore cache from disk before spawning workers
581
+ await cachePrimary.restore().catch((err) => {
582
+ log(`Cache restore failed: ${err instanceof Error ? err.message : String(err)}`);
583
+ });
584
+ log(`Starting ${WORKER_COUNT} workers for ${SCRIPT}...`);
585
+ // Initialize free slots and spawn initial workers
586
+ for (let i = 0; i < WORKER_COUNT; i++) {
587
+ freeSlots.add(i);
588
+ }
589
+ for (let i = 0; i < WORKER_COUNT; i++) {
590
+ spawnWorker();
591
+ }
592
+ // Setup sticky server if enabled
593
+ if (STICKY && STICKY_PORT) {
594
+ setupStickyServer(STICKY_PORT);
595
+ }
596
+ // Signal to daemon that primary is ready
597
+ if (process.send) {
598
+ process.send({ type: 'primary:ready', pid: process.pid });
599
+ }
600
+ log('Cluster primary started');
601
+ })();
602
+ //# sourceMappingURL=ClusterWrapper.js.map
@@ -0,0 +1,11 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { AlertRuleConfig, McpRemoteConfig, ProjectConfig } from '../types/index.js';
3
+ export declare class ConfigStore extends EventEmitter {
4
+ private hash;
5
+ private config;
6
+ getHash(): null | string;
7
+ getAlertRules(): AlertRuleConfig[];
8
+ getMcpConfig(): McpRemoteConfig;
9
+ update(config: ProjectConfig, hash: string): void;
10
+ }
11
+ //# sourceMappingURL=ConfigStore.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export class ConfigStore extends EventEmitter {
3
+ hash = null;
4
+ config = null;
5
+ getHash() {
6
+ return this.hash;
7
+ }
8
+ getAlertRules() {
9
+ return this.config?.alert_rules ?? [];
10
+ }
11
+ getMcpConfig() {
12
+ return this.config?.mcp ?? { enabled: false, keys: [] };
13
+ }
14
+ update(config, hash) {
15
+ const prevMcp = this.config?.mcp;
16
+ this.config = config;
17
+ this.hash = hash;
18
+ this.emit('config:updated', config, prevMcp);
19
+ }
20
+ }
21
+ //# sourceMappingURL=ConfigStore.js.map