@cocreate/server 1.2.0 → 1.4.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/.github/workflows/automated.yml +25 -13
- package/.github/workflows/manual.yml +1 -2
- package/CHANGELOG.md +37 -0
- package/demo/index.html +19 -19
- package/docs/index.html +336 -331
- package/package.json +4 -4
- package/release.config.js +12 -4
- package/src/getInfrastructure.js +362 -0
- package/src/getIp.js +113 -0
- package/src/index.js +381 -31
package/src/index.js
CHANGED
|
@@ -1,38 +1,388 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 { EventEmitter } from 'node:events';
|
|
20
|
+
import Http from 'node:http';
|
|
21
|
+
import Https from 'node:https';
|
|
22
|
+
import Acme from '@cocreate/acme';
|
|
23
|
+
import SocketMesh from '@cocreate/socket-mesh';
|
|
24
|
+
import Metrics from '@cocreate/metrics';
|
|
25
|
+
|
|
26
|
+
import cluster from 'node:cluster';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import uid from '@cocreate/uuid';
|
|
29
|
+
import Config from '@cocreate/config';
|
|
30
|
+
import authenticate from '@cocreate/authenticate';
|
|
31
|
+
import authorize from '@cocreate/authorize';
|
|
32
|
+
|
|
33
|
+
import SocketServer from '@cocreate/socket-server';
|
|
34
|
+
import CrudServer from '@cocreate/crud-server';
|
|
35
|
+
import LazyLoader from '@cocreate/lazy-loader';
|
|
36
|
+
import FileServer from '@cocreate/file-server';
|
|
37
|
+
import ApiServer from '@cocreate/api';
|
|
38
|
+
|
|
39
|
+
import { ObjectId } from '@cocreate/utils';
|
|
40
|
+
|
|
41
|
+
// Dynamic environmental resolvers
|
|
42
|
+
import resolveInfrastructure from './getInfrastructure.js';
|
|
43
|
+
import getIP from './getIp.js';
|
|
44
|
+
|
|
45
|
+
const isPrimary = cluster.isPrimary !== undefined ? cluster.isPrimary : cluster.isMaster;
|
|
46
|
+
const id = process.env.SERVER_ID || ObjectId().toString();
|
|
47
|
+
|
|
48
|
+
const sharedMap = new Map();
|
|
49
|
+
const pendingPromises = new Map();
|
|
50
|
+
const activeWorkers = new Map();
|
|
51
|
+
|
|
52
|
+
export class CoCreateServer extends EventEmitter {
|
|
53
|
+
constructor() {
|
|
54
|
+
super();
|
|
55
|
+
this.id = id;
|
|
56
|
+
this.isPrimary = isPrimary;
|
|
57
|
+
|
|
58
|
+
if (isPrimary) {
|
|
59
|
+
this.workers = activeWorkers;
|
|
60
|
+
} else {
|
|
61
|
+
this.workerId = parseInt(process.env.WORKER_ID, 10) || 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.totalWorkers = 0;
|
|
65
|
+
this.workerStorage = null;
|
|
66
|
+
this.isInitialized = false;
|
|
67
|
+
|
|
68
|
+
this.organization_id = null;
|
|
69
|
+
this.host = null;
|
|
70
|
+
this.ip = null;
|
|
71
|
+
this.infrastructure = null;
|
|
72
|
+
|
|
73
|
+
// Create HTTP server for edge proxy ACME challenges and secure HTTPS redirects
|
|
74
|
+
this.http = Http.createServer((req, res) => {
|
|
75
|
+
const requestUrl = req.url || '/';
|
|
76
|
+
// 1. Allow ACME challenges to pass through over unencrypted HTTP
|
|
77
|
+
if (requestUrl.startsWith('/.well-known/acme-challenge/')) {
|
|
78
|
+
return this.handleEdgeRequest(req, res);
|
|
79
|
+
}
|
|
80
|
+
// 2. Force a 301 redirect to HTTPS for all other traffic
|
|
81
|
+
const host = req.headers.host ? req.headers.host.split(':')[0] : 'localhost';
|
|
82
|
+
res.writeHead(301, { "Location": `https://${host}${requestUrl}` });
|
|
83
|
+
res.end();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.https = null;
|
|
87
|
+
this.authenticate = authenticate;
|
|
88
|
+
this.authorize = authorize;
|
|
89
|
+
this.wsManager = null;
|
|
90
|
+
this.mesh = null;
|
|
91
|
+
this.crud = null;
|
|
92
|
+
this.metrics = null;
|
|
93
|
+
this.files = null;
|
|
94
|
+
this.loader = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
send(payload) {
|
|
98
|
+
if (!payload || !payload.method) {
|
|
99
|
+
console.error("[@cocreate/server] Cannot route IPC send without a 'method' definition.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.isPrimary) {
|
|
104
|
+
// --- MASTER SEND ROUTING PATHWAY ---
|
|
105
|
+
// 1. Dispatch locally on Master process unless explicitly bypassed
|
|
106
|
+
if (payload.master !== false) {
|
|
107
|
+
this.emit(payload.method, payload);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Compile target worker list dynamically using a flat ternary condition.
|
|
111
|
+
// This eliminates duplicate loops, nested blocks, and repeated try/catch statements.
|
|
112
|
+
const targetIds = payload.broadcast === true
|
|
113
|
+
? Array.from(this.workers.keys())
|
|
114
|
+
: (Array.isArray(payload.workers) ? payload.workers : []);
|
|
115
|
+
|
|
116
|
+
// 3. Dispatch to all compiled destination targets in a single unified loop.
|
|
117
|
+
for (const wId of targetIds) {
|
|
118
|
+
const worker = this.workers.get(wId);
|
|
119
|
+
if (worker?.process && typeof worker.process.send === 'function') {
|
|
120
|
+
try {
|
|
121
|
+
worker.process.send({ ipcPayload: true, payload });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[@cocreate/server] Failed sending to worker ${wId}:`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// --- WORKER SEND ROUTING PATHWAY ---
|
|
129
|
+
// 1. Emit locally on this worker so native listeners trigger immediately
|
|
130
|
+
this.emit(payload.method, payload);
|
|
131
|
+
|
|
132
|
+
// 2. Relay the message upstream to the Primary Orchestrator Process
|
|
133
|
+
if (typeof process.send === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
process.send({ ipcPayload: true, payload });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error("[@cocreate/server] Failed sending upstream IPC payload to Master:", err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async init() {
|
|
144
|
+
if (this.isInitialized) {
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.isInitialized = true;
|
|
149
|
+
setupIPC(this);
|
|
150
|
+
|
|
151
|
+
this.workerStorage = {
|
|
152
|
+
set: (key, value) => workerStorageManager(this, 'set', key, value),
|
|
153
|
+
get: (key) => workerStorageManager(this, 'get', key),
|
|
154
|
+
delete: (key) => workerStorageManager(this, 'delete', key),
|
|
155
|
+
size: () => workerStorageManager(this, 'size'),
|
|
156
|
+
clear: () => workerStorageManager(this, 'clear')
|
|
18
157
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
158
|
+
|
|
159
|
+
const config = await Config({
|
|
160
|
+
'organization_id': '',
|
|
161
|
+
'host': '',
|
|
162
|
+
'workers': ''
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.organization_id = config.organization_id || null;
|
|
166
|
+
this.host = config.host || null;
|
|
167
|
+
|
|
168
|
+
let configuredWorkers = config.workers;
|
|
169
|
+
|
|
170
|
+
if (configuredWorkers !== false && configuredWorkers !== 'false') {
|
|
171
|
+
this.totalWorkers = parseInt(configuredWorkers, 10) || os.cpus().length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// DB interface is booted first so that SocketMesh can cleanly read nodes and write startup heartbeats
|
|
175
|
+
this.crud = await CrudServer.init(this);
|
|
176
|
+
|
|
177
|
+
// Assign the SocketMesh globally. This guarantees both Workers and Primary can safely call "this.mesh.broadcastToPeers"
|
|
178
|
+
this.mesh = SocketMesh;
|
|
179
|
+
|
|
180
|
+
if (this.isPrimary) {
|
|
181
|
+
console.log(`[@cocreate/server] [PRIMARY] Orchestrator resolving environmental context...`);
|
|
182
|
+
|
|
183
|
+
this.ip = await getIP();
|
|
184
|
+
this.infrastructure = await resolveInfrastructure(this.id);
|
|
185
|
+
|
|
186
|
+
if (this.totalWorkers > 1) {
|
|
187
|
+
console.log(`[@cocreate/server] [PRIMARY] Orchestrator running with PID: ${process.pid}`);
|
|
188
|
+
for (let i = 0; i < this.totalWorkers; i++) {
|
|
189
|
+
const currentWorkerId = i + 1;
|
|
190
|
+
const worker = cluster.fork({
|
|
191
|
+
WORKER_ID: currentWorkerId,
|
|
192
|
+
SERVER_ID: this.id,
|
|
193
|
+
SERVER_IP: this.ip,
|
|
194
|
+
SERVER_INFRASTRUCTURE: JSON.stringify(this.infrastructure)
|
|
195
|
+
});
|
|
196
|
+
activeWorkers.set(currentWorkerId, {
|
|
197
|
+
id: currentWorkerId,
|
|
198
|
+
pid: worker.process.pid,
|
|
199
|
+
process: worker
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Supervise and automatically reboot dead or crashed worker processes
|
|
204
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
205
|
+
console.log(`[@cocreate/server] [PRIMARY] Worker ${worker.process.pid} exited (Code: ${code}, Signal: ${signal})`);
|
|
206
|
+
let deadWorkerId = null;
|
|
207
|
+
for (let [id, w] of activeWorkers.entries()) {
|
|
208
|
+
if (w.pid === worker.process.pid) {
|
|
209
|
+
deadWorkerId = id;
|
|
210
|
+
activeWorkers.delete(id);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const targetId = deadWorkerId || (activeWorkers.size + 1);
|
|
215
|
+
|
|
216
|
+
const newWorker = cluster.fork({
|
|
217
|
+
WORKER_ID: targetId,
|
|
218
|
+
SERVER_ID: this.id,
|
|
219
|
+
SERVER_IP: this.ip,
|
|
220
|
+
SERVER_INFRASTRUCTURE: JSON.stringify(this.infrastructure)
|
|
221
|
+
});
|
|
222
|
+
activeWorkers.set(targetId, {
|
|
223
|
+
id: targetId,
|
|
224
|
+
pid: newWorker.process.pid,
|
|
225
|
+
process: newWorker
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// In clustered mode, only the Primary boots the network tunnels
|
|
230
|
+
await this.mesh.init(this);
|
|
231
|
+
|
|
232
|
+
// In clustered mode, only the Primary exits early. The workers handle the core traffic.
|
|
233
|
+
if (this.totalWorkers > 1) {
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const processLabel = this.isPrimary ? `Primary (${process.pid})` : `Worker ${process.pid} (ID: ${this.workerId})`;
|
|
240
|
+
console.log(`[@cocreate/server] ${processLabel} adopting primary environmental context...`);
|
|
241
|
+
|
|
242
|
+
this.ip = process.env.SERVER_IP || await getIP();
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
this.infrastructure = process.env.SERVER_INFRASTRUCTURE
|
|
246
|
+
? JSON.parse(process.env.SERVER_INFRASTRUCTURE)
|
|
247
|
+
: await resolveInfrastructure(this.id);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error(`[@cocreate/server] Worker ${process.pid} failed to parse pre-resolved infrastructure. Falling back...`, error);
|
|
250
|
+
this.infrastructure = await resolveInfrastructure(this.id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Initialize HTTPS & Ephemeral certificate via ACME
|
|
254
|
+
this.certificate = Acme;
|
|
255
|
+
this.https = Https.createServer({
|
|
256
|
+
SNICallback: (domain, cb) => this.certificate.sniCallback(domain, cb)
|
|
257
|
+
}, (req, res) => this.handleEdgeRequest(req, res));
|
|
258
|
+
await this.certificate.init(this);
|
|
259
|
+
|
|
260
|
+
// Standard core services
|
|
261
|
+
this.wsManager = new SocketServer(this);
|
|
262
|
+
this.api = await ApiServer.init(this);
|
|
263
|
+
this.files = FileServer.init(this);
|
|
264
|
+
this.loader = await LazyLoader.init(this);
|
|
265
|
+
|
|
266
|
+
// Initialize performance metrics
|
|
267
|
+
this.metrics = await Metrics.init(this);
|
|
268
|
+
|
|
269
|
+
// Bind directly to standard web ports (Requires setcap or root privileges)
|
|
270
|
+
const HTTP_PORT = process.env.HTTP_PORT || 80;
|
|
271
|
+
const HTTPS_PORT = process.env.HTTPS_PORT || 443;
|
|
272
|
+
const BIND_IP = process.env.BIND_IP || '0.0.0.0';
|
|
273
|
+
|
|
274
|
+
if (this.http) {
|
|
275
|
+
this.http.listen(HTTP_PORT, BIND_IP, () => {
|
|
276
|
+
console.log(`[@cocreate/server] ${processLabel} HTTP listening on ${BIND_IP}:${HTTP_PORT} (Redirects & ACME)`);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (this.https) {
|
|
281
|
+
this.https.listen(HTTPS_PORT, BIND_IP, () => {
|
|
282
|
+
console.log(`[@cocreate/server] ${processLabel} HTTPS listening on ${BIND_IP}:${HTTPS_PORT} (Secure Edge & WSS)`);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async handleEdgeRequest(req, res) {
|
|
290
|
+
try {
|
|
291
|
+
const requestUrl = req.url || '/';
|
|
292
|
+
if (requestUrl.startsWith("/webhooks/")) {
|
|
293
|
+
if (this.loader) return this.loader.request(req, res);
|
|
294
|
+
}
|
|
295
|
+
if (this.files) return this.files.send(req, res);
|
|
296
|
+
|
|
297
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
298
|
+
res.end("Internal Error: Files component missing");
|
|
299
|
+
} catch (error) {
|
|
300
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
301
|
+
res.end("Invalid request routing context");
|
|
302
|
+
}
|
|
22
303
|
}
|
|
23
304
|
}
|
|
24
305
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
306
|
+
function executeLocalWorkerStorageOperation(method, key, value) {
|
|
307
|
+
switch (method) {
|
|
308
|
+
case 'set': sharedMap.set(key, value); return true;
|
|
309
|
+
case 'get': return sharedMap.get(key);
|
|
310
|
+
case 'delete': return sharedMap.delete(key);
|
|
311
|
+
case 'size': return sharedMap.size;
|
|
312
|
+
case 'clear': sharedMap.clear(); return true;
|
|
313
|
+
default: throw new Error(`Unsupported storage key operation: "${method}"`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function workerStorageManager(serverCtx, method, key, value) {
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
try {
|
|
320
|
+
if (serverCtx.isPrimary) {
|
|
321
|
+
resolve(executeLocalWorkerStorageOperation(method, key, value));
|
|
322
|
+
} else {
|
|
323
|
+
const messageId = uid.generate();
|
|
324
|
+
pendingPromises.set(messageId, { resolve, reject });
|
|
325
|
+
process.send({ id: serverCtx.id, messageId, method, key, value });
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
reject(error);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setupIPC(serverCtx) {
|
|
334
|
+
if (serverCtx.isPrimary) {
|
|
335
|
+
cluster.on('message', (worker, message) => {
|
|
336
|
+
// 1. Check for standard storage operations
|
|
337
|
+
if (message && message.id === serverCtx.id) {
|
|
338
|
+
try {
|
|
339
|
+
message.value = executeLocalWorkerStorageOperation(message.method, message.key, message.value);
|
|
340
|
+
worker.send(message);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
message.error = error.error;
|
|
343
|
+
worker.send(message);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// 2. Routing logic for CoCreate Unified IPC Payloads
|
|
347
|
+
else if (message && message.ipcPayload && message.payload) {
|
|
348
|
+
const { payload } = message;
|
|
349
|
+
|
|
350
|
+
// Track sending worker PID to worker mapping
|
|
351
|
+
let senderWorkerId = null;
|
|
352
|
+
for (const [wId, w] of serverCtx.workers.entries()) {
|
|
353
|
+
if (w.pid === worker.process.pid) {
|
|
354
|
+
senderWorkerId = wId;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Expose sender metadata to routing logic
|
|
360
|
+
payload.senderWorkerId = senderWorkerId;
|
|
361
|
+
|
|
362
|
+
// Pass it off to the primary orchestrator's central send method
|
|
363
|
+
serverCtx.send(payload);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
} else {
|
|
367
|
+
process.on('message', (message) => {
|
|
368
|
+
// 1. Check for standard storage operations
|
|
369
|
+
if (message && message.id === serverCtx.id) {
|
|
370
|
+
const pending = pendingPromises.get(message.messageId);
|
|
371
|
+
if (pending) {
|
|
372
|
+
pendingPromises.delete(message.messageId);
|
|
373
|
+
if (message.error) {
|
|
374
|
+
pending.reject(new Error(message.error));
|
|
375
|
+
} else {
|
|
376
|
+
pending.resolve(message.value);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// 2. Incoming Unified IPC payload from Master -> Worker Process
|
|
381
|
+
else if (message && message.ipcPayload && message.payload) {
|
|
382
|
+
serverCtx.emit(message.payload.method, message.payload);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
35
385
|
}
|
|
36
386
|
}
|
|
37
387
|
|
|
38
|
-
|
|
388
|
+
export default new CoCreateServer();
|