@cocreate/server 1.6.0 → 1.6.2
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/.github/workflows/automated.yml +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +435 -55
- package/package.json +14 -9
- package/release.config.js +1 -1
- package/src/index.js +245 -393
- package/src/ipc.js +157 -0
- package/src/log.js +207 -0
- package/src/state.js +436 -0
- package/src/storage.js +104 -0
- package/src/getIp.js +0 -113
package/src/state.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (C) 2023 CoCreate and Contributors.
|
|
3
|
+
*
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU Affero General Public License as published
|
|
6
|
+
* by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* This program is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU Affero General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*
|
|
17
|
+
********************************************************************************/
|
|
18
|
+
|
|
19
|
+
import { exec } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Manages process-level state transitions, edge listener lifecycle (cordon/drain/shed),
|
|
23
|
+
* system signal handling, and synchronizing state to the database and mesh network.
|
|
24
|
+
*/
|
|
25
|
+
export class StateManager {
|
|
26
|
+
constructor(Server) {
|
|
27
|
+
this.server = Server;
|
|
28
|
+
|
|
29
|
+
// Top-level status reflects OS/VM status: RUNNING, REBOOTING, STOPPED
|
|
30
|
+
this.server.state = {
|
|
31
|
+
status: 'RUNNING',
|
|
32
|
+
isCordon: false,
|
|
33
|
+
isShedding: false,
|
|
34
|
+
isDraining: false,
|
|
35
|
+
isShuttingDown: false,
|
|
36
|
+
sheddingOrgs: new Set()
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
this.bindMethods();
|
|
40
|
+
this.setupSignalHandlers();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
bindMethods() {
|
|
44
|
+
this.server.getServerObject = this.getServerObject.bind(this);
|
|
45
|
+
this.server.publishState = this.publishState.bind(this);
|
|
46
|
+
this.server.cordon = this.cordon.bind(this);
|
|
47
|
+
this.server.shed = this.shed.bind(this);
|
|
48
|
+
this.server.drain = this.drain.bind(this);
|
|
49
|
+
this.server.restart = this.restart.bind(this);
|
|
50
|
+
this.server.reboot = this.reboot.bind(this);
|
|
51
|
+
this.server.shutdown = this.shutdown.bind(this);
|
|
52
|
+
this.server.triggerGlobalShutdown = this.triggerGlobalShutdown.bind(this);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setupSignalHandlers() {
|
|
56
|
+
if (this.server.isMaster) {
|
|
57
|
+
['SIGINT', 'SIGTERM', 'SIGBREAK'].forEach((signal) => {
|
|
58
|
+
process.on(signal, () => {
|
|
59
|
+
console.log(`\n[@cocreate/server] [MASTER] Received ${signal}. Orchestrating graceful shutdown...`);
|
|
60
|
+
this.triggerGlobalShutdown(signal);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
['SIGINT', 'SIGTERM', 'SIGBREAK'].forEach((signal) => {
|
|
65
|
+
process.on(signal, async () => {
|
|
66
|
+
console.log(`\n[@cocreate/server] [WORKER ${this.server.workerId}] Received ${signal}. Draining local connections...`);
|
|
67
|
+
await this.drain(true, true);
|
|
68
|
+
await this.publishState('stopped');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.server.on('shutdown', async () => {
|
|
74
|
+
console.log(`[@cocreate/server] [WORKER ${this.server.workerId}] Instructed by master to shutdown. Draining...`);
|
|
75
|
+
await this.drain(true, true);
|
|
76
|
+
await this.publishState('stopped');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getServerObject(data = {}) {
|
|
83
|
+
return {
|
|
84
|
+
method: 'object.update',
|
|
85
|
+
array: 'servers',
|
|
86
|
+
object: {
|
|
87
|
+
_id: this.server.id,
|
|
88
|
+
...data
|
|
89
|
+
},
|
|
90
|
+
upsert: true,
|
|
91
|
+
broadcast: true,
|
|
92
|
+
organization_id: this.server.organization_id,
|
|
93
|
+
host: this.server.host
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async publishState(overrideStatus) {
|
|
98
|
+
if (this.server.isMaster) {
|
|
99
|
+
const masterStatus = overrideStatus || (this.server.state.isShuttingDown ? 'stopped' : 'running');
|
|
100
|
+
const isMasterStopped = masterStatus === 'stopped';
|
|
101
|
+
|
|
102
|
+
const payloadData = {
|
|
103
|
+
ip: this.server.ip,
|
|
104
|
+
host: this.server.host,
|
|
105
|
+
status: this.server.state.status.toLowerCase(), // OS level status: 'running', 'rebooting', 'stopped'
|
|
106
|
+
totalWorkers: this.server.totalWorkers,
|
|
107
|
+
infrastructure: this.server.infrastructure,
|
|
108
|
+
location: this.server.location,
|
|
109
|
+
master: isMasterStopped ? {
|
|
110
|
+
pid: null,
|
|
111
|
+
status: 'stopped'
|
|
112
|
+
} : {
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
status: masterStatus
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// If running in single-process mode, also record worker1 state in DB
|
|
119
|
+
if (this.server.totalWorkers <= 1) {
|
|
120
|
+
payloadData.worker1 = isMasterStopped ? {
|
|
121
|
+
pid: null,
|
|
122
|
+
status: 'stopped',
|
|
123
|
+
isCordoned: false,
|
|
124
|
+
isShedding: false,
|
|
125
|
+
isDraining: false
|
|
126
|
+
} : {
|
|
127
|
+
pid: process.pid,
|
|
128
|
+
status: masterStatus,
|
|
129
|
+
isCordoned: this.server.state.isCordon,
|
|
130
|
+
isShedding: this.server.state.isShedding,
|
|
131
|
+
isDraining: this.server.state.isDraining
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const payload = this.getServerObject(payloadData);
|
|
136
|
+
|
|
137
|
+
if (this.server.crud) {
|
|
138
|
+
await this.server.crud.send(payload);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const dbStatus = overrideStatus || (this.server.state.isShuttingDown ? 'stopped' : 'running');
|
|
142
|
+
const isStopped = dbStatus === 'stopped';
|
|
143
|
+
|
|
144
|
+
const payload = this.getServerObject({
|
|
145
|
+
[`worker${this.server.workerId}`]: isStopped ? {
|
|
146
|
+
pid: null,
|
|
147
|
+
status: 'stopped',
|
|
148
|
+
isCordoned: false,
|
|
149
|
+
isShedding: false,
|
|
150
|
+
isDraining: false
|
|
151
|
+
} : {
|
|
152
|
+
pid: process.pid,
|
|
153
|
+
status: dbStatus,
|
|
154
|
+
isCordoned: this.server.state.isCordon,
|
|
155
|
+
isShedding: this.server.state.isShedding,
|
|
156
|
+
isDraining: this.server.state.isDraining
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (this.server.crud) {
|
|
161
|
+
await this.server.crud.send(payload);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async cordon(value = true, internalCall = false) {
|
|
167
|
+
const isCordon = typeof value === 'boolean' ? value : (value && value.value !== undefined ? Boolean(value.value) : true);
|
|
168
|
+
|
|
169
|
+
// Prevent un-cordoning if a higher operational state is active
|
|
170
|
+
if (!isCordon && (this.server.state.isShedding || this.server.state.isDraining || this.server.state.isShuttingDown)) {
|
|
171
|
+
console.warn(`[@cocreate/server] Blocked un-cordon request. Active states: isShedding=${this.server.state.isShedding}, isDraining=${this.server.state.isDraining}, isShuttingDown=${this.server.state.isShuttingDown}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (this.server.state.isCordon === isCordon) return;
|
|
176
|
+
this.server.state.isCordon = isCordon;
|
|
177
|
+
|
|
178
|
+
if (!this.server.isMaster) {
|
|
179
|
+
if (isCordon) {
|
|
180
|
+
this.server.closeEdgeListeners();
|
|
181
|
+
} else {
|
|
182
|
+
this.server.restoreEdgeListeners();
|
|
183
|
+
}
|
|
184
|
+
if (this.server.send) {
|
|
185
|
+
this.server.send({ method: 'worker_state_change', isCordoned: isCordon });
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
this.server.evaluateMasterFallback();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.server.isMaster && !internalCall) {
|
|
192
|
+
await this.publishState();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const processLabel = this.server.isMaster ? `Master (${process.pid})` : `Worker ${process.pid} (ID: ${this.server.workerId})`;
|
|
196
|
+
console.log(`[@cocreate/server] ${processLabel} isCordon updated to: ${this.server.state.isCordon}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async shed(arg, internalCall = false) {
|
|
200
|
+
let organizations = [];
|
|
201
|
+
let isShedding = false;
|
|
202
|
+
|
|
203
|
+
if (arg === false || (arg && arg.organizations === false)) {
|
|
204
|
+
isShedding = false;
|
|
205
|
+
} else if (arg && arg.organizations !== undefined) {
|
|
206
|
+
organizations = Array.isArray(arg.organizations) ? arg.organizations : [arg.organizations];
|
|
207
|
+
isShedding = organizations.length > 0;
|
|
208
|
+
} else if (arg) {
|
|
209
|
+
organizations = Array.isArray(arg) ? arg : (typeof arg === 'string' ? [arg] : []);
|
|
210
|
+
isShedding = organizations.length > 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Prevent un-shedding if drain or shutdown is active
|
|
214
|
+
if (!isShedding && (this.server.state.isDraining || this.server.state.isShuttingDown)) {
|
|
215
|
+
console.warn(`[@cocreate/server] Blocked un-shed request while drain or shutdown is active.`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (this.server.state.isShedding === isShedding && organizations.length === 0) return;
|
|
220
|
+
|
|
221
|
+
this.server.state.isShedding = isShedding;
|
|
222
|
+
this.server.state.sheddingOrgs = new Set(organizations);
|
|
223
|
+
|
|
224
|
+
if (isShedding) {
|
|
225
|
+
await this.cordon(true, true);
|
|
226
|
+
if (!this.server.isMaster && organizations.length > 0 && this.server.wsManager) {
|
|
227
|
+
console.log(`[@cocreate/server] Selective eviction of org IDs:`, organizations);
|
|
228
|
+
this.server.wsManager.destroyOrganizations(organizations);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
await this.cordon(false, true);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!this.server.isMaster && this.server.send) {
|
|
235
|
+
this.server.send({ method: 'worker_state_change', isShedding: this.server.state.isShedding });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (this.server.isMaster && !internalCall) {
|
|
239
|
+
await this.publishState();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const processLabel = this.server.isMaster ? `Master (${process.pid})` : `Worker ${process.pid} (ID: ${this.server.workerId})`;
|
|
243
|
+
console.log(`[@cocreate/server] ${processLabel} isShedding updated to: ${this.server.state.isShedding}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async drain(value = true, internalCall = false) {
|
|
247
|
+
const isDraining = typeof value === 'boolean' ? value : (value && value.value !== undefined ? Boolean(value.value) : true);
|
|
248
|
+
|
|
249
|
+
// Prevent un-draining if shutdown is active
|
|
250
|
+
if (!isDraining && this.server.state.isShuttingDown) {
|
|
251
|
+
console.warn(`[@cocreate/server] Blocked un-drain request while shutdown is active.`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (this.server.state.isDraining === isDraining) return;
|
|
256
|
+
|
|
257
|
+
this.server.state.isDraining = isDraining;
|
|
258
|
+
|
|
259
|
+
if (isDraining) {
|
|
260
|
+
await this.cordon(true, true);
|
|
261
|
+
if (!this.server.isMaster && this.server.wsManager) {
|
|
262
|
+
console.log(`[@cocreate/server] Worker drain triggered. Purging websocket connections.`);
|
|
263
|
+
this.server.wsManager.destroyAll();
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
await this.cordon(false, true);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!this.server.isMaster && this.server.send) {
|
|
270
|
+
this.server.send({ method: 'worker_state_change', isDraining: this.server.state.isDraining });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (this.server.isMaster && !internalCall) {
|
|
274
|
+
await this.publishState();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const processLabel = this.server.isMaster ? `Master (${process.pid})` : `Worker ${process.pid} (ID: ${this.server.workerId})`;
|
|
278
|
+
console.log(`[@cocreate/server] ${processLabel} isDraining updated to: ${this.server.state.isDraining}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async restart(payload = {}, fromEvent = false) {
|
|
282
|
+
if (!fromEvent) {
|
|
283
|
+
const sendPayload = (payload && typeof payload === 'object') ? payload : {};
|
|
284
|
+
sendPayload.method = 'restart';
|
|
285
|
+
if (this.server.send) this.server.send(sendPayload);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (this.server.isMaster) {
|
|
290
|
+
if (payload && (payload.isMaster || payload.master)) {
|
|
291
|
+
console.log(`[@cocreate/server] [MASTER] App restart commanded. Triggering global graceful shutdown...`);
|
|
292
|
+
this.triggerGlobalShutdown('API_RESTART');
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
if (payload && (payload.isMaster || payload.master)) return;
|
|
296
|
+
|
|
297
|
+
console.log(`[@cocreate/server] [WORKER ${this.server.workerId}] Restart triggered. Commencing drain...`);
|
|
298
|
+
await this.publishState('restarting');
|
|
299
|
+
await this.drain(true, true);
|
|
300
|
+
await this.publishState('stopped');
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
reboot(payload = {}, fromEvent = false) {
|
|
306
|
+
if (!fromEvent) {
|
|
307
|
+
const sendPayload = (payload && typeof payload === 'object') ? payload : {};
|
|
308
|
+
sendPayload.method = 'reboot';
|
|
309
|
+
if (!this.server.isMaster) sendPayload.master = true;
|
|
310
|
+
if (this.server.send) this.server.send(sendPayload);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!this.server.isMaster) return;
|
|
315
|
+
|
|
316
|
+
console.log(`[@cocreate/server] [MASTER] OS Reboot requested. Commencing graceful shutdown before kernel reboot...`);
|
|
317
|
+
this.server.state.status = 'REBOOTING';
|
|
318
|
+
|
|
319
|
+
if (this.server.mesh) {
|
|
320
|
+
this.server.mesh.send({ method: 'mesh.node_status_update', server_id: this.server.id, status: 'REBOOTING' });
|
|
321
|
+
}
|
|
322
|
+
this.publishState();
|
|
323
|
+
|
|
324
|
+
this.triggerGlobalShutdown('OS_REBOOT', () => {
|
|
325
|
+
console.log(`[@cocreate/server] [MASTER] Executing OS Reboot: sudo reboot`);
|
|
326
|
+
exec('sudo reboot', (err) => {
|
|
327
|
+
if (err) {
|
|
328
|
+
console.error("[@cocreate/server] Failed to execute sudo reboot:", err.message);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
shutdown(payload = {}, fromEvent = false) {
|
|
336
|
+
if (!fromEvent) {
|
|
337
|
+
const sendPayload = (payload && typeof payload === 'object') ? payload : {};
|
|
338
|
+
sendPayload.method = 'shutdown';
|
|
339
|
+
if (!this.server.isMaster) sendPayload.master = true;
|
|
340
|
+
if (this.server.send) this.server.send(sendPayload);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!this.server.isMaster) return;
|
|
345
|
+
|
|
346
|
+
console.log(`[@cocreate/server] [MASTER] OS Shutdown requested. Commencing graceful shutdown before VM poweroff...`);
|
|
347
|
+
this.server.state.status = 'STOPPED';
|
|
348
|
+
|
|
349
|
+
this.triggerGlobalShutdown('OS_SHUTDOWN', () => {
|
|
350
|
+
console.log(`[@cocreate/server] [MASTER] Executing OS Poweroff: sudo poweroff`);
|
|
351
|
+
exec('sudo poweroff', (err) => {
|
|
352
|
+
if (err) {
|
|
353
|
+
console.error("[@cocreate/server] Failed to execute sudo poweroff:", err.message);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async triggerGlobalShutdown(reason, completionCallback) {
|
|
361
|
+
if (!this.server.isMaster || this.server.state.isShuttingDown) return;
|
|
362
|
+
|
|
363
|
+
console.log(`\n[@cocreate/server] [MASTER] Commencing Global Shutdown Sequence (Reason: ${reason})...`);
|
|
364
|
+
|
|
365
|
+
this.server.state.isShuttingDown = true;
|
|
366
|
+
this.server.state.shutdownCompletionCallback = completionCallback;
|
|
367
|
+
|
|
368
|
+
// Update top-level status only if this is an explicit OS-level reboot or poweroff
|
|
369
|
+
if (reason === 'OS_SHUTDOWN') {
|
|
370
|
+
this.server.state.status = 'STOPPED';
|
|
371
|
+
} else if (reason === 'OS_REBOOT') {
|
|
372
|
+
this.server.state.status = 'REBOOTING';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Immediately publish stopped state for master (clears PID and sets master.status = 'stopped')
|
|
376
|
+
await this.publishState('stopped');
|
|
377
|
+
|
|
378
|
+
// Step 1: Execute primary drain
|
|
379
|
+
await this.drain(true, true);
|
|
380
|
+
|
|
381
|
+
// Step 2: Notify mesh cluster
|
|
382
|
+
if (this.server.mesh) {
|
|
383
|
+
this.server.mesh.send({
|
|
384
|
+
method: 'mesh.node_status_update',
|
|
385
|
+
server_id: this.server.id,
|
|
386
|
+
status: this.server.state.status.toLowerCase(),
|
|
387
|
+
reason: reason
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
if (this.server.mesh && typeof this.server.mesh.close === 'function') {
|
|
392
|
+
this.server.mesh.close();
|
|
393
|
+
}
|
|
394
|
+
this.server.state.isMeshClosed = true;
|
|
395
|
+
console.log("[@cocreate/server] [MASTER] Mesh closed successfully.");
|
|
396
|
+
}, 1500);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const finalizeShutdown = async () => {
|
|
400
|
+
if (this.server.state.primaryShutdownTimeout) {
|
|
401
|
+
clearTimeout(this.server.state.primaryShutdownTimeout);
|
|
402
|
+
this.server.state.primaryShutdownTimeout = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await this.publishState('stopped');
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
this.server.state.finalizeShutdown = finalizeShutdown;
|
|
409
|
+
|
|
410
|
+
// Step 3: Safety timeout
|
|
411
|
+
this.server.state.primaryShutdownTimeout = setTimeout(async () => {
|
|
412
|
+
console.error("[@cocreate/server] [MASTER] Shutdown Timeout exceeded (15s). Forcing process exit.");
|
|
413
|
+
await finalizeShutdown();
|
|
414
|
+
if (typeof completionCallback === 'function') completionCallback();
|
|
415
|
+
else process.exit(1);
|
|
416
|
+
}, 15000);
|
|
417
|
+
|
|
418
|
+
// Step 4: Command worker processes to shut down
|
|
419
|
+
if (this.server.send) {
|
|
420
|
+
this.server.send({ method: 'shutdown', broadcast: true });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.server.workers && this.server.workers.size === 0) {
|
|
424
|
+
console.log("[@cocreate/server] [MASTER] No active workers detected. Concluding shutdown.");
|
|
425
|
+
await finalizeShutdown();
|
|
426
|
+
if (typeof completionCallback === 'function') setTimeout(completionCallback, 1000);
|
|
427
|
+
else setTimeout(() => process.exit(0), 1000);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export default {
|
|
433
|
+
init(Server) {
|
|
434
|
+
return new StateManager(Server);
|
|
435
|
+
}
|
|
436
|
+
};
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { uuid } from '@cocreate/utils';
|
|
3
|
+
|
|
4
|
+
export default class Storage extends EventEmitter {
|
|
5
|
+
constructor(server, pendingPromises) {
|
|
6
|
+
super();
|
|
7
|
+
|
|
8
|
+
this.server = server;
|
|
9
|
+
this.workerId = server.workerId;
|
|
10
|
+
this.pendingPromises = pendingPromises;
|
|
11
|
+
this.store = new Map();
|
|
12
|
+
|
|
13
|
+
// Listen for top-down state broadcasts from Master
|
|
14
|
+
if (!this.server.isMaster) {
|
|
15
|
+
this.server.on('cluster_state_sync', (payload) => {
|
|
16
|
+
if (payload && payload.key !== undefined) {
|
|
17
|
+
if (payload.value === undefined) {
|
|
18
|
+
this.store.delete(payload.key);
|
|
19
|
+
this.emit(`delete:${payload.key}`);
|
|
20
|
+
this.emit('delete', { key: payload.key });
|
|
21
|
+
} else {
|
|
22
|
+
this.store.set(payload.key, payload.value);
|
|
23
|
+
this.emit(`change:${payload.key}`, payload.value);
|
|
24
|
+
this.emit('change', { key: payload.key, value: payload.value });
|
|
25
|
+
}
|
|
26
|
+
} else if (payload && payload.clear) {
|
|
27
|
+
this.store.clear();
|
|
28
|
+
this.emit('clear');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async set(key, value) {
|
|
35
|
+
if (this.server.isMaster) {
|
|
36
|
+
this.store.set(key, value);
|
|
37
|
+
this.emit(`change:${key}`, value);
|
|
38
|
+
this.emit('change', { key, value });
|
|
39
|
+
|
|
40
|
+
this.server.send({
|
|
41
|
+
method: 'cluster_state_sync', key, value, broadcast: true, master: false
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
} else {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const messageId = uuid();
|
|
47
|
+
this.pendingPromises.set(messageId, { resolve, reject });
|
|
48
|
+
this.server.send({ workerId: this.workerId, messageId, method: 'cluster_state_set', key, value });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get(key) {
|
|
54
|
+
if (this.server.isMaster) {
|
|
55
|
+
return this.store.get(key);
|
|
56
|
+
} else {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const messageId = uuid();
|
|
59
|
+
this.pendingPromises.set(messageId, { resolve, reject });
|
|
60
|
+
this.server.send({ workerId: this.workerId, messageId, method: 'cluster_state_get', key });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async delete(key) {
|
|
66
|
+
if (this.server.isMaster) {
|
|
67
|
+
const result = this.store.delete(key);
|
|
68
|
+
this.emit(`delete:${key}`);
|
|
69
|
+
this.emit('delete', { key });
|
|
70
|
+
this.server.send({ method: 'cluster_state_sync', key, value: undefined, broadcast: true, master: false });
|
|
71
|
+
return result;
|
|
72
|
+
} else {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const messageId = uuid();
|
|
75
|
+
this.pendingPromises.set(messageId, { resolve, reject });
|
|
76
|
+
this.server.send({ workerId: this.workerId, messageId, method: 'cluster_state_delete', key });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async size() {
|
|
82
|
+
if (this.server.isMaster) return this.store.size;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const messageId = uuid();
|
|
85
|
+
this.pendingPromises.set(messageId, { resolve, reject });
|
|
86
|
+
this.server.send({ workerId: this.workerId, messageId, method: 'cluster_state_size' });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async clear() {
|
|
91
|
+
if (this.server.isMaster) {
|
|
92
|
+
this.store.clear();
|
|
93
|
+
this.emit('clear');
|
|
94
|
+
this.server.send({ method: 'cluster_state_sync', clear: true, broadcast: true, master: false });
|
|
95
|
+
return true;
|
|
96
|
+
} else {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const messageId = uuid();
|
|
99
|
+
this.pendingPromises.set(messageId, { resolve, reject });
|
|
100
|
+
this.server.send({ workerId: this.workerId, messageId, method: 'cluster_state_clear' });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/getIp.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/********************************************************************************
|
|
2
|
-
* Copyright (C) 2023 CoCreate and Contributors.
|
|
3
|
-
*
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify
|
|
5
|
-
* it under the terms of the GNU Affero General Public License as published
|
|
6
|
-
* by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
-
* (at your option) any later version.
|
|
8
|
-
*
|
|
9
|
-
* This program is distributed in the hope that it will be useful,
|
|
10
|
-
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
-
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
-
* GNU Affero General Public License for more details.
|
|
13
|
-
*
|
|
14
|
-
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
-
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
-
********************************************************************************/
|
|
17
|
-
|
|
18
|
-
import https from 'node:https';
|
|
19
|
-
import os from 'node:os';
|
|
20
|
-
|
|
21
|
-
// Prioritized list of reliable public IP lookup APIs
|
|
22
|
-
const IP_PROVIDERS = [
|
|
23
|
-
'https://api.ipify.org',
|
|
24
|
-
'https://icanhazip.com',
|
|
25
|
-
'https://ifconfig.me/ip',
|
|
26
|
-
'https://ipinfo.io/ip'
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Initiates an HTTPS GET request to a specific IP provider with a hard timeout.
|
|
31
|
-
*
|
|
32
|
-
* @param {string} url - The URL of the IP lookup provider
|
|
33
|
-
* @param {number} timeoutMs - Max execution limit before canceling the promise
|
|
34
|
-
* @returns {Promise<string>} - Resolves with the plain-text IP address
|
|
35
|
-
*/
|
|
36
|
-
function fetchIpFromProvider(url, timeoutMs = 3000) {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
const req = https.get(url, (res) => {
|
|
39
|
-
if (res.statusCode !== 200) {
|
|
40
|
-
reject(new Error(`Non-200 response code: ${res.statusCode}`));
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let data = '';
|
|
45
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
46
|
-
|
|
47
|
-
res.on('end', () => {
|
|
48
|
-
const cleanIp = data.trim();
|
|
49
|
-
const ipv4Regex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
|
50
|
-
if (ipv4Regex.test(cleanIp)) {
|
|
51
|
-
resolve(cleanIp);
|
|
52
|
-
} else {
|
|
53
|
-
reject(new Error(`Returned invalid IP structure: "${cleanIp}"`));
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
req.on('error', (err) => { reject(err); });
|
|
59
|
-
|
|
60
|
-
req.setTimeout(timeoutMs, () => {
|
|
61
|
-
req.destroy();
|
|
62
|
-
reject(new Error(`Request timed out`));
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Scans the local machine's physical network adapters to find the primary local IP.
|
|
69
|
-
* Used as a fallback if the machine has no active internet connection or is fully offline.
|
|
70
|
-
*
|
|
71
|
-
* @returns {string} - Returns the first valid local IPv4 address, or '127.0.0.1'
|
|
72
|
-
*/
|
|
73
|
-
export function getLocalIPFallback() {
|
|
74
|
-
try {
|
|
75
|
-
const interfaces = os.networkInterfaces();
|
|
76
|
-
for (const devName in interfaces) {
|
|
77
|
-
const face = interfaces[devName];
|
|
78
|
-
for (let i = 0; i < face.length; i++) {
|
|
79
|
-
const alias = face[i];
|
|
80
|
-
// Check for standard IPv4 and exclude typical loopbacks
|
|
81
|
-
if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
|
|
82
|
-
return alias.address;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} catch (err) {
|
|
87
|
-
// Safe, non-blocking fallback context
|
|
88
|
-
}
|
|
89
|
-
return '127.0.0.1';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Robust universal public IP resolution function.
|
|
94
|
-
* Iterates through highly redundant public IP lookup services.
|
|
95
|
-
* Guaranteed to never throw errors.
|
|
96
|
-
*
|
|
97
|
-
* @returns {Promise<string>} - The resolved public IP (or local IP if completely offline)
|
|
98
|
-
*/
|
|
99
|
-
export async function getIp() {
|
|
100
|
-
for (const url of IP_PROVIDERS) {
|
|
101
|
-
try {
|
|
102
|
-
const ip = await fetchIpFromProvider(url);
|
|
103
|
-
return ip;
|
|
104
|
-
} catch (error) {
|
|
105
|
-
// Suppress fallback exceptions to attempt the next endpoint seamlessly
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Offline / Air-Gapped Fallback
|
|
110
|
-
return getLocalIPFallback();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export default getIp;
|