@cocreate/server-mesh 1.2.0 → 1.2.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.
@@ -0,0 +1,222 @@
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 commercials or open source under GNU AGPLv3.
10
+ *
11
+ * You should have received a copy of the GNU Affero General Public License
12
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
13
+ *
14
+ ********************************************************************************/
15
+
16
+ import { WebSocket } from 'ws';
17
+
18
+ /**
19
+ * ServerMeshOutbound manages outbound peer discovery, connection establishment,
20
+ * reconnection retries, and reacting to server events requiring outbound dispatch.
21
+ */
22
+ export class ServerMeshOutbound {
23
+ constructor(mesh) {
24
+ this.mesh = mesh;
25
+ this.server = null;
26
+ this.meshPort = process.env.MESH_PORT || 8090;
27
+ this.retryTimers = new Map();
28
+ this.pruneTimers = new Map();
29
+ }
30
+
31
+ /**
32
+ * Initializes the outbound transport component with host server context and registers IPC handlers.
33
+ *
34
+ * @param {Object} server - CoCreateServer instance context
35
+ * @returns {Promise<void>}
36
+ */
37
+ async init(server) {
38
+ this.server = server;
39
+
40
+ if (this.server && typeof this.server.on === 'function') {
41
+ this.server.on('mesh.outbound.reconnect', (payload) => {
42
+ if (payload && payload.peerIp) {
43
+ this.connectPeer({ ip: payload.peerIp });
44
+ }
45
+ });
46
+
47
+ // Fallback listener for legacy event name
48
+ this.server.on('mesh.client.reconnect', (payload) => {
49
+ if (payload && payload.peerIp) {
50
+ this.connectPeer({ ip: payload.peerIp });
51
+ }
52
+ });
53
+
54
+ this.server.on('mesh.outbound_mesh_relay', (payload) => {
55
+ if (payload) {
56
+ this.mesh.send(payload.payload ? payload.payload : payload);
57
+ }
58
+ });
59
+ }
60
+
61
+ await this.getServers();
62
+ }
63
+
64
+ /**
65
+ * Queries database for existing peer server records (READ ONLY) and triggers outbound connection setup.
66
+ */
67
+ async getServers() {
68
+ if (!this.server?.crud) return;
69
+
70
+ const readPayload = {
71
+ method: 'object.read',
72
+ array: 'servers',
73
+ $filter: {
74
+ query: { _id: { $lt: this.server.id } },
75
+ sort: { _id: 1 },
76
+ limit: 0
77
+ },
78
+ organization_id: this.server.organization_id,
79
+ host: this.server.host
80
+ };
81
+
82
+ let readResult;
83
+ try {
84
+ readResult = await this.server.crud.send(readPayload);
85
+ } catch (err) {
86
+ console.error("[@cocreate/server-mesh/outbound] Failed to query peer server records:", err.message);
87
+ return;
88
+ }
89
+
90
+ const peers = this.normalizePeersList(readResult);
91
+
92
+ for (const targetPeer of peers) {
93
+ this.connectPeer(targetPeer, peers.length);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Initiates secure outbound WebSocket transport link to a remote peer.
99
+ *
100
+ * @param {Object|string} targetPeer - Peer metadata object or IP string
101
+ * @param {number} totalPeers - Count of peers for calculating eviction delay
102
+ */
103
+ connectPeer(targetPeer, totalPeers = 1) {
104
+ const targetIp = typeof targetPeer === 'string' ? targetPeer : targetPeer?.ip;
105
+ const targetId = typeof targetPeer === 'object' ? targetPeer._id : targetIp;
106
+
107
+ if (!targetIp || targetIp === this.server.ip || this.mesh.activeConnections.has(targetIp)) return;
108
+
109
+ console.log(`[@cocreate/server-mesh/outbound] Initiating outbound link to peer: ${targetIp}`);
110
+
111
+ let retryCount = 0;
112
+ const maxRetries = 10;
113
+
114
+ const connectToPeer = () => {
115
+ if (this.mesh.isClosed) return;
116
+
117
+ const targetUrl = `wss://${targetIp}:${this.meshPort}/${this.server.organization_id}`;
118
+ const clientSocket = new WebSocket(targetUrl, [
119
+ encodeURIComponent(JSON.stringify({
120
+ type: 'peer',
121
+ ip: this.server.ip,
122
+ id: this.server.id,
123
+ token: process.env.CLUSTER_SECRET,
124
+ sniHost: this.server.host
125
+ }))
126
+ ], {
127
+ servername: this.server.host,
128
+ rejectUnauthorized: false
129
+ });
130
+
131
+ clientSocket.isAlive = true;
132
+ clientSocket.remoteAddress = targetIp;
133
+ clientSocket.on('pong', () => { clientSocket.isAlive = true; });
134
+ clientSocket.on('ping', () => { clientSocket.pong(); });
135
+
136
+ clientSocket.on('open', () => {
137
+ console.log(`[@cocreate/server-mesh/outbound] Outbound connection complete to peer: ${targetIp}`);
138
+ retryCount = 0;
139
+
140
+ if (this.retryTimers.has(targetIp)) clearTimeout(this.retryTimers.get(targetIp));
141
+ if (this.pruneTimers.has(targetIp)) clearTimeout(this.pruneTimers.get(targetIp));
142
+
143
+ this.retryTimers.delete(targetIp);
144
+ this.pruneTimers.delete(targetIp);
145
+
146
+ clientSocket.isPeer = true;
147
+ clientSocket.isPlatformTunnel = true;
148
+
149
+ if (targetId) this.mesh.activeConnections.set(targetId, clientSocket);
150
+ this.mesh.activeConnections.set(targetIp, clientSocket);
151
+ });
152
+
153
+ clientSocket.on('close', () => {
154
+ if (targetId) this.mesh.activeConnections.delete(targetId);
155
+ this.mesh.activeConnections.delete(targetIp);
156
+ this.retryTimers.delete(targetIp);
157
+ this.mesh.removeSocketFromAllOrgServers(clientSocket);
158
+
159
+ if (this.mesh.isClosed) return;
160
+
161
+ if (retryCount < maxRetries) {
162
+ retryCount++;
163
+ const delay = 5000 + Math.floor(Math.random() * 2000);
164
+ console.warn(`[@cocreate/server-mesh/outbound] Peer dropped (${targetIp}). Retrying in ${delay}ms...`);
165
+ const timerId = setTimeout(connectToPeer, delay);
166
+ this.retryTimers.set(targetIp, timerId);
167
+ } else {
168
+ const staggerDelay = totalPeers * 1000;
169
+ console.warn(`[@cocreate/server-mesh/outbound] Peer dead (${targetIp}). Scheduling consensus notification in ${staggerDelay}ms...`);
170
+
171
+ const pruneTimerId = setTimeout(() => {
172
+ console.error(`[@cocreate/server-mesh/outbound] Peer node dead: ${targetIp}`);
173
+
174
+ this.mesh.send({
175
+ method: 'mesh.peer_pruned',
176
+ deadIp: targetIp,
177
+ isForwarded: true,
178
+ senderId: this.server.id
179
+ });
180
+
181
+ this.pruneTimers.delete(targetIp);
182
+ }, staggerDelay);
183
+
184
+ this.pruneTimers.set(targetIp, pruneTimerId);
185
+ }
186
+ });
187
+
188
+ clientSocket.on('message', (rawData) => {
189
+ if (this.mesh && typeof this.mesh.handleIncomingMessage === 'function') {
190
+ this.mesh.handleIncomingMessage(rawData, clientSocket);
191
+ }
192
+ });
193
+
194
+ clientSocket.on('error', (err) => {
195
+ console.log(`[@cocreate/server-mesh/outbound] Socket transport failure (${targetIp}): ${err.message}`);
196
+ });
197
+ };
198
+
199
+ connectToPeer();
200
+ }
201
+
202
+ /**
203
+ * Extracts raw dataset arrays from standard database responses.
204
+ */
205
+ normalizePeersList(result) {
206
+ if (!result) return [];
207
+ if (Array.isArray(result)) return result;
208
+ if (Array.isArray(result.object)) return result.object;
209
+ if (Array.isArray(result.data)) return result.data;
210
+ return [];
211
+ }
212
+
213
+ /**
214
+ * Clears all outbound retry and prune timers on teardown.
215
+ */
216
+ close() {
217
+ for (const timerId of this.retryTimers.values()) clearTimeout(timerId);
218
+ for (const timerId of this.pruneTimers.values()) clearTimeout(timerId);
219
+ this.retryTimers.clear();
220
+ this.pruneTimers.clear();
221
+ }
222
+ }