@cocreate/server 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # [1.6.0](https://github.com/CoCreate-app/CoCreate-server/compare/v1.5.1...v1.6.0) (2026-07-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * add ingress state management with cordon, shedding, and draining events ([b52a198](https://github.com/CoCreate-app/CoCreate-server/commit/b52a1986c566fe9a73c8e694542be69d808ae3eb))
7
+ * implement graceful shutdown process with selective shedding and draining for improved resource management ([5c367e0](https://github.com/CoCreate-app/CoCreate-server/commit/5c367e0284f587965bd5b4248b1f1f97a4b51b2f))
8
+
1
9
  ## [1.5.1](https://github.com/CoCreate-app/CoCreate-server/compare/v1.5.0...v1.5.1) (2026-07-17)
2
10
 
3
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/server",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "A dynamic SSL certificate management and generation tool for proxies like NGINX, with a fallback to Node.js SSL termination. It seamlessly integrates HTTP, HTTPS, and ACME protocols to ensure secure, encrypted connections.",
5
5
  "keywords": [
6
6
  "ssl-certificate-management",
package/src/index.js CHANGED
@@ -50,6 +50,11 @@ const sharedMap = new Map();
50
50
  const pendingPromises = new Map();
51
51
  const activeWorkers = new Map();
52
52
 
53
+ // Track overall system state during shutdowns to prevent concurrent signal re-evaluation
54
+ let isPrimaryShuttingDown = false;
55
+ let isMeshClosed = false; // Dual-lock flag to guarantee DB prunes finish before process exits
56
+ let primaryShutdownTimeout = null;
57
+
53
58
  export class CoCreateServer extends EventEmitter {
54
59
  constructor() {
55
60
  super();
@@ -71,15 +76,76 @@ export class CoCreateServer extends EventEmitter {
71
76
  this.ip = null;
72
77
  this.infrastructure = null;
73
78
 
74
- // Create HTTP server for edge proxy ACME challenges and secure HTTPS redirects
79
+ // Ingress state management flags
80
+ this.isCordon = false;
81
+ this.isShedding = false;
82
+ this.isDraining = false;
83
+
84
+ this.on('cordon', (payload) => {
85
+ const active = payload?.active !== false;
86
+
87
+ // Core Logic: Telemetry or callers cannot uncordon the node if shedding or draining is active
88
+ if (!active && (this.isShedding || this.isDraining)) {
89
+ console.warn(`[@cocreate/server] Blocked un-cordon request. Overriding active states: isShedding=${this.isShedding}, isDraining=${this.isDraining}`);
90
+ return;
91
+ }
92
+
93
+ this.isCordon = active;
94
+ const processLabel = this.isPrimary ? `Primary (${process.pid})` : `Worker ${process.pid} (ID: ${this.workerId})`;
95
+ console.log(`[@cocreate/server] ${processLabel} isCordon updated to: ${this.isCordon}`);
96
+ });
97
+
98
+ this.on('shed', (payload) => {
99
+ this.isShedding = payload?.active !== false;
100
+
101
+ // Shedding state activation automatically triggers a network cordon
102
+ if (this.isShedding) {
103
+ this.isCordon = true;
104
+
105
+ // Selective Shedding Integration: Target specified organizations inside the socket server
106
+ if (payload?.organizations && this.wsManager && typeof this.wsManager.destroyOrganizations === 'function') {
107
+ const orgList = Array.isArray(payload.organizations) ? payload.organizations : [payload.organizations];
108
+ console.log(`[@cocreate/server] Shedding event triggered. Selective eviction of org IDs:`, orgList);
109
+ this.wsManager.destroyOrganizations(orgList);
110
+ }
111
+ }
112
+ const processLabel = this.isPrimary ? `Primary (${process.pid})` : `Worker ${process.pid} (ID: ${this.workerId})`;
113
+ console.log(`[@cocreate/server] ${processLabel} isShedding updated to: ${this.isShedding} (isCordon: ${this.isCordon})`);
114
+ });
115
+
116
+ this.on('drain', (payload) => {
117
+ this.isDraining = payload?.active !== false;
118
+
119
+ // Draining state activation automatically triggers a network cordon
120
+ if (this.isDraining) {
121
+ this.isCordon = true;
122
+
123
+ // Complete Worker Drain Integration: Instantly purge all sockets and loaded maps
124
+ if (this.wsManager && typeof this.wsManager.destroyAll === 'function') {
125
+ console.log(`[@cocreate/server] Worker-level drain event triggered. Purging all tenant configurations.`);
126
+ this.wsManager.destroyAll();
127
+ }
128
+ }
129
+ const processLabel = this.isPrimary ? `Primary (${process.pid})` : `Worker ${process.pid} (ID: ${this.workerId})`;
130
+ console.log(`[@cocreate/server] ${processLabel} isDraining updated to: ${this.isDraining} (isCordon: ${this.isCordon})`);
131
+ });
132
+
75
133
  this.http = Http.createServer((req, res) => {
76
134
  const requestUrl = req.url || '/';
77
135
  // 1. Allow ACME challenges to pass through over unencrypted HTTP
78
136
  if (requestUrl.startsWith('/.well-known/acme-challenge/')) {
79
137
  return this.handleEdgeRequest(req, res);
80
138
  }
81
- // 2. Force a 301 redirect to HTTPS for all other traffic
139
+
82
140
  const host = req.headers.host ? req.headers.host.split(':')[0] : 'localhost';
141
+
142
+ // 2. Intercept with a 307 Temporary Redirect if cordon is active
143
+ if (this.isCordon) {
144
+ res.writeHead(307, { "Location": `https://${host}${requestUrl}` });
145
+ return res.end();
146
+ }
147
+
148
+ // 3. Force a 301 redirect to HTTPS for all other traffic
83
149
  res.writeHead(301, { "Location": `https://${host}${requestUrl}` });
84
150
  res.end();
85
151
  });
@@ -110,7 +176,6 @@ export class CoCreateServer extends EventEmitter {
110
176
  }
111
177
 
112
178
  // 2. Compile target worker list dynamically using a flat ternary condition.
113
- // This eliminates duplicate loops, nested blocks, and repeated try/catch statements.
114
179
  const targetIds = payload.broadcast === true
115
180
  ? Array.from(this.workers.keys())
116
181
  : (Array.isArray(payload.workers) ? payload.workers : []);
@@ -118,7 +183,7 @@ export class CoCreateServer extends EventEmitter {
118
183
  // 3. Dispatch to all compiled destination targets in a single unified loop.
119
184
  for (const wId of targetIds) {
120
185
  const worker = this.workers.get(wId);
121
- if (worker?.process && typeof worker.process.send === 'function') {
186
+ if (worker?.process && typeof worker.process.send === 'function' && worker.process.isConnected()) {
122
187
  try {
123
188
  worker.process.send({ ipcPayload: true, payload });
124
189
  } catch (err) {
@@ -172,22 +237,17 @@ export class CoCreateServer extends EventEmitter {
172
237
  if (configuredWorkers !== false && configuredWorkers !== 'false') {
173
238
  const parsedWorkers = parseInt(configuredWorkers, 10);
174
239
  if (parsedWorkers === -1) {
175
- // Use total CPU cores minus 1 (minimum of 1) to reserve one core for the master process
176
240
  this.totalWorkers = Math.max(1, os.cpus().length - 1);
177
241
  } else {
178
242
  this.totalWorkers = parsedWorkers || os.cpus().length;
179
243
  }
180
244
  }
181
245
 
182
- // Explicitly set workerId = 1 on the single Primary process if workers configuration specifies no clustering (<= 1)
183
246
  if (this.isPrimary && this.totalWorkers <= 1) {
184
247
  this.workerId = 1;
185
248
  }
186
249
 
187
- // DB interface is booted first so that ServerMesh can cleanly read nodes and write startup heartbeats
188
250
  this.crud = await CrudServer.init(this);
189
-
190
- // Assign the ServerMesh globally. This guarantees both Workers and Primary can safely call "this.mesh.send"
191
251
  this.mesh = ServerMesh;
192
252
  this.autoscaler = ServerAutoscaler;
193
253
 
@@ -199,6 +259,9 @@ export class CoCreateServer extends EventEmitter {
199
259
  await this.mesh.init(this);
200
260
  await this.autoscaler.init(this);
201
261
 
262
+ // Setup Primary process Graceful Signal Listeners
263
+ setupPrimaryShutdownHooks(this);
264
+
202
265
  if (this.totalWorkers > 1) {
203
266
  console.log(`[@cocreate/server] [PRIMARY] Orchestrator running with PID: ${process.pid}`);
204
267
  for (let i = 0; i < this.totalWorkers; i++) {
@@ -209,6 +272,12 @@ export class CoCreateServer extends EventEmitter {
209
272
  SERVER_IP: this.ip,
210
273
  SERVER_INFRASTRUCTURE: JSON.stringify(this.infrastructure)
211
274
  });
275
+
276
+ // Protect against unhandled 'error' events on the cluster worker wrapper
277
+ worker.on('error', (err) => {
278
+ console.error(`[@cocreate/server] [PRIMARY] Error on Worker ${currentWorkerId}:`, err.message);
279
+ });
280
+
212
281
  activeWorkers.set(currentWorkerId, {
213
282
  id: currentWorkerId,
214
283
  pid: worker.process.pid,
@@ -219,6 +288,7 @@ export class CoCreateServer extends EventEmitter {
219
288
  // Supervise and automatically reboot dead or crashed worker processes
220
289
  cluster.on('exit', (worker, code, signal) => {
221
290
  console.log(`[@cocreate/server] [PRIMARY] Worker ${worker.process.pid} exited (Code: ${code}, Signal: ${signal})`);
291
+
222
292
  let deadWorkerId = null;
223
293
  for (let [id, w] of activeWorkers.entries()) {
224
294
  if (w.pid === worker.process.pid) {
@@ -227,6 +297,22 @@ export class CoCreateServer extends EventEmitter {
227
297
  break;
228
298
  }
229
299
  }
300
+
301
+ // SAFETY: If Primary is shutting down or draining, DO NOT fork a replacement worker process!
302
+ if (isPrimaryShuttingDown || this.isDraining) {
303
+ console.log(`[@cocreate/server] [PRIMARY] Worker ${worker.process.pid} removed. Draining active workers: ${activeWorkers.size} remaining.`);
304
+
305
+ // Dual-Lock Guard: Only exit Master if workers are dead AND mesh has completed the MongoDB prune
306
+ if (activeWorkers.size === 0 && isMeshClosed) {
307
+ console.log("[@cocreate/server] [PRIMARY] All workers successfully drained and Mesh closed. Terminating master process cleanly.");
308
+ if (primaryShutdownTimeout) {
309
+ clearTimeout(primaryShutdownTimeout);
310
+ }
311
+ process.exit(0);
312
+ }
313
+ return;
314
+ }
315
+
230
316
  const targetId = deadWorkerId || (activeWorkers.size + 1);
231
317
 
232
318
  const newWorker = cluster.fork({
@@ -235,6 +321,11 @@ export class CoCreateServer extends EventEmitter {
235
321
  SERVER_IP: this.ip,
236
322
  SERVER_INFRASTRUCTURE: JSON.stringify(this.infrastructure)
237
323
  });
324
+
325
+ newWorker.on('error', (err) => {
326
+ console.error(`[@cocreate/server] [PRIMARY] Error on replacement Worker ${targetId}:`, err.message);
327
+ });
328
+
238
329
  activeWorkers.set(targetId, {
239
330
  id: targetId,
240
331
  pid: newWorker.process.pid,
@@ -242,7 +333,6 @@ export class CoCreateServer extends EventEmitter {
242
333
  });
243
334
  });
244
335
 
245
- // In clustered mode, only the Primary exits early. The workers handle the core traffic.
246
336
  if (this.totalWorkers > 1) {
247
337
  return this;
248
338
  }
@@ -260,23 +350,19 @@ export class CoCreateServer extends EventEmitter {
260
350
  this.infrastructure = await resolveInfrastructure(this.id);
261
351
  }
262
352
 
263
- // Initialize HTTPS & Ephemeral certificate via ACME
264
353
  this.certificate = Acme;
265
354
  this.https = Https.createServer({
266
355
  SNICallback: (domain, cb) => this.certificate.sniCallback(domain, cb)
267
356
  }, (req, res) => this.handleEdgeRequest(req, res));
268
357
  await this.certificate.init(this);
269
358
 
270
- // Standard core services
271
359
  this.wsManager = new SocketServer(this);
272
360
  this.api = await ApiServer.init(this);
273
361
  this.files = FileServer.init(this);
274
362
  this.loader = await LazyLoader.init(this);
275
363
 
276
- // Initialize performance telemetry
277
364
  this.telemetry = await ServerTelemetry.init(this);
278
365
 
279
- // Bind directly to standard web ports (Requires setcap or root privileges)
280
366
  const HTTP_PORT = process.env.HTTP_PORT || 80;
281
367
  const HTTPS_PORT = process.env.HTTPS_PORT || 443;
282
368
  const BIND_IP = process.env.BIND_IP || '0.0.0.0';
@@ -300,6 +386,14 @@ export class CoCreateServer extends EventEmitter {
300
386
  async handleEdgeRequest(req, res) {
301
387
  try {
302
388
  const requestUrl = req.url || '/';
389
+
390
+ // Check if cordon is active (exclude critical ACME challenges from loop-breaking)
391
+ if (this.isCordon && !requestUrl.startsWith('/.well-known/acme-challenge/')) {
392
+ const host = req.headers.host ? req.headers.host.split(':')[0] : 'localhost';
393
+ res.writeHead(307, { "Location": `https://${host}${requestUrl}` });
394
+ return res.end();
395
+ }
396
+
303
397
  if (requestUrl.startsWith("/webhooks/")) {
304
398
  if (this.loader) return this.loader.request(req, res);
305
399
  }
@@ -341,10 +435,82 @@ function workerStorageManager(serverCtx, method, key, value) {
341
435
  });
342
436
  }
343
437
 
438
+ function setupPrimaryShutdownHooks(serverCtx) {
439
+ const handleSignal = async (signal) => {
440
+ if (isPrimaryShuttingDown) return;
441
+ isPrimaryShuttingDown = true;
442
+ serverCtx.isCordon = true;
443
+ serverCtx.isDraining = true;
444
+
445
+ console.log(`\n[@cocreate/server] [PRIMARY] Intercepted process termination signal: ${signal}. Commencing cluster-wide graceful shutdown...`);
446
+
447
+ // STEP 1: Close Mesh FIRST. Wipes the server record profile from the database while CRUD connection pools are completely active.
448
+ if (serverCtx.mesh && typeof serverCtx.mesh.close === 'function') {
449
+ try {
450
+ console.log("[@cocreate/server] [PRIMARY] Step 1: Initiating Server Mesh close & server record database removal...");
451
+ await serverCtx.mesh.close();
452
+ console.log("[@cocreate/server] [PRIMARY] Success: Server Mesh closed and server record cleanly wiped from database.");
453
+ } catch (err) {
454
+ console.error("[@cocreate/server] [PRIMARY] Failed to close Server Mesh or wipe server profile cleanly:", err);
455
+ }
456
+ }
457
+ isMeshClosed = true;
458
+
459
+ // Dual-Lock Resolution: If all workers finished exiting while the database prune was running,
460
+ // terminate the Master process cleanly right now instead of waiting for the safety timeout.
461
+ if (activeWorkers.size === 0) {
462
+ console.log("[@cocreate/server] [PRIMARY] All workers successfully drained and Mesh closed. Terminating master process cleanly.");
463
+ if (primaryShutdownTimeout) {
464
+ clearTimeout(primaryShutdownTimeout);
465
+ }
466
+ process.exit(0);
467
+ }
468
+
469
+ // STEP 2: Now that the database profile is clean, tell all worker processes over IPC to begin local graceful drains.
470
+ console.log("[@cocreate/server] [PRIMARY] Step 2: Informing worker processes to start local connection drains...");
471
+ for (const [wId, worker] of activeWorkers.entries()) {
472
+ const workerInstance = worker.process;
473
+ if (workerInstance && typeof workerInstance.send === 'function' && workerInstance.isConnected()) {
474
+ console.log(`[@cocreate/server] [PRIMARY] Sending 'shutdown' instruction to Worker ${wId} (PID: ${worker.pid})`);
475
+ try {
476
+ workerInstance.send({ type: 'shutdown' });
477
+ } catch (err) {
478
+ console.error(`[@cocreate/server] Failed sending shutdown IPC instruction to worker ${wId}:`, err);
479
+ }
480
+ } else {
481
+ console.log(`[@cocreate/server] [PRIMARY] Skipping disconnected Worker ${wId} (PID: ${worker.pid})`);
482
+ }
483
+ }
484
+
485
+ // STEP 3: Set Cooperative Grace Fallback Timer (5 Seconds)
486
+ // If workers finish early, the exit event triggers process exit immediately. If they hang, we force-kill.
487
+ primaryShutdownTimeout = setTimeout(() => {
488
+ console.warn("[@cocreate/server] [PRIMARY] Safety grace window expired! Force-terminating remaining processes...");
489
+
490
+ for (const [wId, worker] of activeWorkers.entries()) {
491
+ if (worker?.process) {
492
+ try {
493
+ console.log(`[@cocreate/server] [PRIMARY] Force-killing hanging Worker ${wId} (PID: ${worker.pid}).`);
494
+ worker.process.kill('SIGKILL');
495
+ } catch (err) {
496
+ console.error(`[@cocreate/server] Error force-killing Worker ${wId}:`, err);
497
+ }
498
+ }
499
+ }
500
+
501
+ console.log("[@cocreate/server] [PRIMARY] All worker processes forcefully reaped. Terminating Master process.");
502
+ process.exit(0);
503
+ }, 5000);
504
+ };
505
+
506
+ process.on('SIGTERM', () => handleSignal('SIGTERM'));
507
+ process.on('SIGINT', () => handleSignal('SIGINT'));
508
+ process.on('SIGBREAK', () => handleSignal('SIGBREAK'));
509
+ }
510
+
344
511
  function setupIPC(serverCtx) {
345
512
  if (serverCtx.isPrimary) {
346
513
  cluster.on('message', (worker, message) => {
347
- // 1. Check for standard storage operations
348
514
  if (message && message.id === serverCtx.id) {
349
515
  try {
350
516
  message.value = executeLocalWorkerStorageOperation(message.method, message.key, message.value);
@@ -354,11 +520,9 @@ function setupIPC(serverCtx) {
354
520
  worker.send(message);
355
521
  }
356
522
  }
357
- // 2. Routing logic for CoCreate Unified IPC Payloads
358
523
  else if (message && message.ipcPayload && message.payload) {
359
524
  const { payload } = message;
360
525
 
361
- // Track sending worker PID to worker mapping
362
526
  let senderWorkerId = null;
363
527
  for (const [wId, w] of serverCtx.workers.entries()) {
364
528
  if (w.pid === worker.process.pid) {
@@ -367,17 +531,25 @@ function setupIPC(serverCtx) {
367
531
  }
368
532
  }
369
533
 
370
- // Expose sender metadata to routing logic
371
534
  payload.senderWorkerId = senderWorkerId;
372
-
373
- // Pass it off to the primary orchestrator's central send method
374
535
  serverCtx.send(payload);
375
536
  }
376
537
  });
377
538
  } else {
378
539
  process.on('message', (message) => {
379
- // 1. Check for standard storage operations
380
- if (message && message.id === serverCtx.id) {
540
+ // Intercept Master-directed shutdown instruction over the worker's IPC channel
541
+ if (message && message.type === 'shutdown') {
542
+ console.log(`[@cocreate/server] [Worker ${process.pid}] Received master coordinated shutdown signal. Commencing local drain...`);
543
+
544
+ // Immediately cordon worker traffic and trigger the internal SocketServer destroyAll logic
545
+ serverCtx.isCordon = true;
546
+ serverCtx.isDraining = true;
547
+
548
+ if (serverCtx.wsManager && typeof serverCtx.wsManager.destroyAll === 'function') {
549
+ serverCtx.wsManager.destroyAll();
550
+ }
551
+ }
552
+ else if (message && message.id === serverCtx.id) {
381
553
  const pending = pendingPromises.get(message.messageId);
382
554
  if (pending) {
383
555
  pendingPromises.delete(message.messageId);
@@ -388,7 +560,6 @@ function setupIPC(serverCtx) {
388
560
  }
389
561
  }
390
562
  }
391
- // 2. Incoming Unified IPC payload from Master -> Worker Process
392
563
  else if (message && message.ipcPayload && message.payload) {
393
564
  serverCtx.emit(message.payload.method, message.payload);
394
565
  }