@cocreate/socket 0.0.0-stage → 3.0.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/FUNDING.yml +3 -0
- package/.github/workflows/automated.yml +69 -0
- package/CHANGELOG.md +1412 -0
- package/CONTRIBUTING.md +97 -0
- package/CoCreate.config.js +23 -0
- package/LICENSE +683 -0
- package/README.md +245 -3
- package/demo/server.js +24 -0
- package/docs/index.html +217 -0
- package/package.json +50 -4
- package/prettier.config.js +16 -0
- package/release.config.js +30 -0
- package/src/client/index.js +785 -0
- package/src/index.js +2 -0
- package/src/server/index.js +1115 -0
- package/src/server/sockets.js +149 -0
- package/test/client/transport.test.js +84 -0
- package/test/server/collections.test.js +111 -0
- package/test/server/send.test.js +257 -0
|
@@ -0,0 +1,1115 @@
|
|
|
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
|
+
// Commercial Licensing Information:
|
|
19
|
+
// For commercial use of this software without the copyleft provisions of the AGPLv3,
|
|
20
|
+
// you must obtain a commercial license from CoCreate LLC.
|
|
21
|
+
// For details, visit <https://CoCreateJS.com/licenses/> or contact us at sales@CoCreateJS.com.
|
|
22
|
+
|
|
23
|
+
import Sockets from "./sockets.js";
|
|
24
|
+
import { EventEmitter } from 'node:events';
|
|
25
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
26
|
+
import { URL } from 'node:url';
|
|
27
|
+
import Config from '@cocreate/config';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
* @environment server
|
|
33
|
+
*/
|
|
34
|
+
export class SocketServer extends EventEmitter {
|
|
35
|
+
constructor(server) {
|
|
36
|
+
super();
|
|
37
|
+
|
|
38
|
+
// Cache reference to primary server process manager - Must be defined
|
|
39
|
+
this.server = server;
|
|
40
|
+
this.serverId = server.id;
|
|
41
|
+
|
|
42
|
+
// Dynamic reference tracking context bound to our cluster process topology
|
|
43
|
+
this.workerId = server.workerId;
|
|
44
|
+
|
|
45
|
+
// Use ES6 Maps for O(1) lookups and zero-allocation updates
|
|
46
|
+
this.organizations = new Map();
|
|
47
|
+
this.organizationBalances = new Map();
|
|
48
|
+
this.clients = new Map();
|
|
49
|
+
this.sockets = new Map();
|
|
50
|
+
this.collection = new Sockets(this);
|
|
51
|
+
this.users = new Map();
|
|
52
|
+
|
|
53
|
+
// Extract isolated helper properties directly from the server instance
|
|
54
|
+
this.authenticate = server.authenticate;
|
|
55
|
+
this.authorize = server.authorize;
|
|
56
|
+
|
|
57
|
+
Config.prompt({ organization_id: { prompt: "Enter your organization_id: " } });
|
|
58
|
+
|
|
59
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
60
|
+
|
|
61
|
+
// We use bound prototype methods instead of inline closures to prevent scope leakage
|
|
62
|
+
this.wss.on("headers", this._handleHeaders.bind(this));
|
|
63
|
+
this.wss.on("error", this._handleWssError.bind(this));
|
|
64
|
+
|
|
65
|
+
if (server.https) {
|
|
66
|
+
server.https.on("upgrade", this._upgradeWss.bind(this));
|
|
67
|
+
}
|
|
68
|
+
if (server.http) {
|
|
69
|
+
server.http.on("upgrade", this._upgradeWs.bind(this));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Hook up process-level events to allow other modules (Telemetry, CoCreateServer) to invoke evictions
|
|
73
|
+
process.on('socket.destroyAll', () => {
|
|
74
|
+
this.destroyAll();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
process.on('socket.destroyOrgs', (orgIds) => {
|
|
78
|
+
this.destroyOrganizations(orgIds);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
process.on('organization.balance', (data) => {
|
|
82
|
+
this.setOrganizationBalance(data, { propagate: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.heartbeatInterval = setInterval(() => {
|
|
86
|
+
this.sockets.forEach((socket) => {
|
|
87
|
+
if (socket.isAlive === false) {
|
|
88
|
+
socket.terminate();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
socket.isAlive = false;
|
|
92
|
+
try {
|
|
93
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
94
|
+
socket.ping();
|
|
95
|
+
} else {
|
|
96
|
+
socket.terminate();
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error("[@cocreate/socket] Error pinging socket:", err);
|
|
100
|
+
socket.terminate();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}, 30000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
close() {
|
|
107
|
+
clearInterval(this.heartbeatInterval);
|
|
108
|
+
this.wss.close();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_handleHeaders(headers, request) {
|
|
112
|
+
headers.push("Access-Control-Allow-Origin: *");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_handleWssError(error) {
|
|
116
|
+
console.error("[@cocreate/socket] WebSocketServer error:", error);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_upgradeWss(request, socket, head) {
|
|
120
|
+
this.upgrade(request, socket, head, "wss");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_upgradeWs(request, socket, head) {
|
|
124
|
+
this.upgrade(request, socket, head, "ws");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static async init(server) {
|
|
128
|
+
return new SocketServer(server);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
upgrade(request, socket, head, protocol) {
|
|
132
|
+
const self = this;
|
|
133
|
+
|
|
134
|
+
const host = request.headers.host || "localhost";
|
|
135
|
+
const parsedUrl = new URL(request.url, `http://${host}`);
|
|
136
|
+
|
|
137
|
+
// .filter(Boolean) removes empty strings, protecting against trailing slashes!
|
|
138
|
+
const paths = parsedUrl.pathname.split("/").filter(Boolean);
|
|
139
|
+
// Grab the FIRST segment as the organization_id (e.g., /org_123/namespace/room -> org_123)
|
|
140
|
+
const organization_id = paths[0];
|
|
141
|
+
|
|
142
|
+
this.wss.handleUpgrade(request, socket, head, async function (socket) {
|
|
143
|
+
if (organization_id) {
|
|
144
|
+
let organization = self.organizations.get(organization_id);
|
|
145
|
+
let organizationBalance = self.organizationBalances.get(organization_id);
|
|
146
|
+
if (self.server?.tls?.checkOrganizationAccess) {
|
|
147
|
+
const access = await self.server.tls.checkOrganizationAccess(host);
|
|
148
|
+
if (access.organization_id === organization_id) {
|
|
149
|
+
if (access.allowed === false) {
|
|
150
|
+
organizationBalance = false;
|
|
151
|
+
self.organizationBalances.set(organization_id, false);
|
|
152
|
+
} else {
|
|
153
|
+
organizationBalance = true;
|
|
154
|
+
self.organizationBalances.delete(organization_id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
organizationBalance === false ||
|
|
160
|
+
(organization && organization.status === false)
|
|
161
|
+
) {
|
|
162
|
+
let errors = {};
|
|
163
|
+
errors.serverOrganization = organization?.serverOrganization;
|
|
164
|
+
errors.serverStorage = organization?.serverStorage;
|
|
165
|
+
errors.organizationBalance = organizationBalance ?? organization?.organizationBalance;
|
|
166
|
+
errors.error = organization?.error;
|
|
167
|
+
self._safeSend(socket, {
|
|
168
|
+
method: "Access Denied",
|
|
169
|
+
error: errors
|
|
170
|
+
});
|
|
171
|
+
return socket.close(1008, "Organization balance depleted");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 1. Extract Query Parameters as the baseline options
|
|
175
|
+
let options = Object.fromEntries(parsedUrl.searchParams.entries());
|
|
176
|
+
|
|
177
|
+
// 2. Safely merge and override with secure protocol headers if they exist
|
|
178
|
+
try {
|
|
179
|
+
const protocolHeader = request.headers["sec-websocket-protocol"];
|
|
180
|
+
if (protocolHeader) {
|
|
181
|
+
const headerOptions = JSON.parse(decodeURIComponent(protocolHeader));
|
|
182
|
+
options = { ...options, ...headerOptions };
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error("[@cocreate/socket] Error parsing socket options:", err);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
socket.organization_id = organization_id;
|
|
189
|
+
socket.id = options.socketId;
|
|
190
|
+
socket.clientId = options.clientId;
|
|
191
|
+
socket.clientInfo = options.clientInfo;
|
|
192
|
+
socket.pathname = request.url;
|
|
193
|
+
socket.origin = request.headers.origin;
|
|
194
|
+
socket.host = request.headers.host;
|
|
195
|
+
|
|
196
|
+
if (!socket.host && socket.origin && socket.origin !== "null") {
|
|
197
|
+
if (socket.origin.includes("://"))
|
|
198
|
+
socket.host = new URL(socket.origin).host;
|
|
199
|
+
else socket.host = socket.origin;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
socket.socketUrl =
|
|
203
|
+
protocol + "://" + socket.host + socket.pathname;
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
!organization ||
|
|
207
|
+
(organization && organization.status !== false)
|
|
208
|
+
) {
|
|
209
|
+
const authService = self.authenticate;
|
|
210
|
+
if (authService) {
|
|
211
|
+
const { user_id, expires } =
|
|
212
|
+
await authService.decodeToken(
|
|
213
|
+
options.token,
|
|
214
|
+
organization_id,
|
|
215
|
+
options.clientId,
|
|
216
|
+
socket.host
|
|
217
|
+
);
|
|
218
|
+
const userStatus = {
|
|
219
|
+
socket,
|
|
220
|
+
method: "userStatus",
|
|
221
|
+
host: socket.host,
|
|
222
|
+
user_id: options.user_id,
|
|
223
|
+
clientId: options.clientId,
|
|
224
|
+
userStatus: "off",
|
|
225
|
+
organization_id
|
|
226
|
+
};
|
|
227
|
+
if (user_id) {
|
|
228
|
+
options.user_id = user_id;
|
|
229
|
+
socket.user_id = user_id;
|
|
230
|
+
socket.expires = expires;
|
|
231
|
+
userStatus.userStatus = "on";
|
|
232
|
+
self.emit("notification.user", socket);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
self.emit("userStatus", userStatus);
|
|
236
|
+
|
|
237
|
+
self.onWebSocket(socket);
|
|
238
|
+
} else self.onWebSocket(socket);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
self._safeSend(socket, {
|
|
242
|
+
method: "Access Denied",
|
|
243
|
+
error: "An organization_id is required"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
onWebSocket(socket) {
|
|
250
|
+
const self = this;
|
|
251
|
+
|
|
252
|
+
socket.isAlive = true;
|
|
253
|
+
socket.on("pong", () => {
|
|
254
|
+
socket.isAlive = true;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.add(socket);
|
|
258
|
+
|
|
259
|
+
socket.on("message", async (message) => {
|
|
260
|
+
self.onMessage(socket, message);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
socket.on("close", () => {
|
|
264
|
+
// Guard: If we are intentionally tearing down, bypass redundant delete cycles
|
|
265
|
+
if (socket.bypassDeleteCascade) {
|
|
266
|
+
socket.removeAllListeners();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
self.delete(socket);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
socket.on("error", () => {
|
|
273
|
+
if (socket.bypassDeleteCascade) {
|
|
274
|
+
socket.removeAllListeners();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
self.delete(socket);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
this._safeSend(socket, { method: "connect", connectedKey: socket.pathname });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
add(socket) {
|
|
284
|
+
let organization_id = socket.organization_id;
|
|
285
|
+
|
|
286
|
+
let organization = this.organizations.get(organization_id);
|
|
287
|
+
const firstConnection = !organization || !organization.isRegistered;
|
|
288
|
+
if (!organization) {
|
|
289
|
+
organization = {
|
|
290
|
+
status: true,
|
|
291
|
+
isRegistered: true,
|
|
292
|
+
organizationBalance: this.organizationBalances.get(organization_id),
|
|
293
|
+
clients: new Map(),
|
|
294
|
+
subscriptions: new Map(),
|
|
295
|
+
debounce: null
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
this.organizations.set(organization_id, organization);
|
|
299
|
+
|
|
300
|
+
this.emit("object.update", {
|
|
301
|
+
method: "object.update",
|
|
302
|
+
host: socket.host,
|
|
303
|
+
array: "organizations",
|
|
304
|
+
object: {
|
|
305
|
+
_id: organization_id,
|
|
306
|
+
["$addToSet.activeHost"]: socket.socketUrl
|
|
307
|
+
},
|
|
308
|
+
organization_id
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
} else {
|
|
312
|
+
clearTimeout(organization.debounce);
|
|
313
|
+
if (!organization.isRegistered) {
|
|
314
|
+
organization.isRegistered = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!this.clients.has(socket.clientId)) {
|
|
319
|
+
this.clients.set(socket.clientId, new Map());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!organization.clients.has(socket.clientId)) {
|
|
323
|
+
organization.clients.set(socket.clientId, new Map());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.sockets.set(socket.id, socket);
|
|
327
|
+
this.clients.get(socket.clientId).set(socket.id, socket);
|
|
328
|
+
organization.clients.get(socket.clientId).set(socket.id, socket);
|
|
329
|
+
|
|
330
|
+
if (firstConnection) {
|
|
331
|
+
process.emit('orgConnected', organization_id, { organization_id, workerId: this.workerId });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (socket.user_id) {
|
|
335
|
+
this.emit("userStatus", {
|
|
336
|
+
socket,
|
|
337
|
+
host: socket.host,
|
|
338
|
+
user_id: socket.user_id,
|
|
339
|
+
clientId: socket.clientId,
|
|
340
|
+
userStatus: "on",
|
|
341
|
+
organization_id
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
let userSession = this.users.get(socket.user_id);
|
|
345
|
+
|
|
346
|
+
if (!userSession) {
|
|
347
|
+
userSession = { sockets: new Map(), debounce: null };
|
|
348
|
+
this.users.set(socket.user_id, userSession);
|
|
349
|
+
} else {
|
|
350
|
+
clearTimeout(userSession.debounce);
|
|
351
|
+
}
|
|
352
|
+
userSession.sockets.set(socket.id, socket);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
subscribe(socket, subscriptionKeys) {
|
|
357
|
+
const organization = this.organizations.get(socket.organization_id);
|
|
358
|
+
if (!organization) return;
|
|
359
|
+
if (!organization.subscriptions)
|
|
360
|
+
organization.subscriptions = new Map();
|
|
361
|
+
if (!socket.subscriptionKeys) socket.subscriptionKeys = new Set();
|
|
362
|
+
|
|
363
|
+
const keys = Array.isArray(subscriptionKeys)
|
|
364
|
+
? subscriptionKeys
|
|
365
|
+
: subscriptionKeys
|
|
366
|
+
? [subscriptionKeys]
|
|
367
|
+
: [];
|
|
368
|
+
|
|
369
|
+
for (const key of keys) {
|
|
370
|
+
if (typeof key !== "string" || !key) continue;
|
|
371
|
+
|
|
372
|
+
let socketIds = organization.subscriptions.get(key);
|
|
373
|
+
if (!socketIds) {
|
|
374
|
+
socketIds = new Set();
|
|
375
|
+
organization.subscriptions.set(key, socketIds);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
socketIds.add(socket.id);
|
|
379
|
+
socket.subscriptionKeys.add(key);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
unsubscribe(socket, subscriptionKeys) {
|
|
384
|
+
const organization = this.organizations.get(socket.organization_id);
|
|
385
|
+
if (!organization?.subscriptions) return;
|
|
386
|
+
|
|
387
|
+
const keys = Array.isArray(subscriptionKeys)
|
|
388
|
+
? subscriptionKeys
|
|
389
|
+
: subscriptionKeys
|
|
390
|
+
? [subscriptionKeys]
|
|
391
|
+
: [];
|
|
392
|
+
|
|
393
|
+
for (const key of keys) {
|
|
394
|
+
const socketIds = organization.subscriptions.get(key);
|
|
395
|
+
if (!socketIds) continue;
|
|
396
|
+
|
|
397
|
+
socketIds.delete(socket.id);
|
|
398
|
+
if (!socketIds.size) organization.subscriptions.delete(key);
|
|
399
|
+
socket.subscriptionKeys?.delete(key);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
removeSubscriptions(socket) {
|
|
404
|
+
if (!socket.subscriptionKeys?.size) return;
|
|
405
|
+
this.unsubscribe(socket, [...socket.subscriptionKeys]);
|
|
406
|
+
socket.subscriptionKeys.clear();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
getSubscribers(data) {
|
|
410
|
+
const clients = new Map();
|
|
411
|
+
const organization = this.organizations.get(data.organization_id);
|
|
412
|
+
|
|
413
|
+
const socketIds = new Set();
|
|
414
|
+
for (const key of data.subscriptionKeys) {
|
|
415
|
+
const subscribedIds = organization?.subscriptions?.get(key);
|
|
416
|
+
if (!subscribedIds) continue;
|
|
417
|
+
for (const socketId of subscribedIds) socketIds.add(socketId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const socketId of socketIds) {
|
|
421
|
+
const socket = this.sockets.get(socketId);
|
|
422
|
+
if (!socket || socket.organization_id !== data.organization_id)
|
|
423
|
+
continue;
|
|
424
|
+
|
|
425
|
+
let sockets = clients.get(socket.clientId);
|
|
426
|
+
if (!sockets) {
|
|
427
|
+
sockets = [];
|
|
428
|
+
clients.set(socket.clientId, sockets);
|
|
429
|
+
}
|
|
430
|
+
sockets.push(socket);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const socketById = this.sockets.get(data.socketId);
|
|
434
|
+
const origin = socketById?.organization_id === data.organization_id
|
|
435
|
+
? socketById
|
|
436
|
+
: undefined;
|
|
437
|
+
if (origin) {
|
|
438
|
+
const sockets = clients.get(origin.clientId) || [];
|
|
439
|
+
clients.set(origin.clientId, [origin, ...sockets]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Subscriptions select clients; any tab of that client can provide failover.
|
|
443
|
+
for (const [clientId, sockets] of clients) {
|
|
444
|
+
const fallbackSockets = organization?.clients?.get(clientId);
|
|
445
|
+
clients.set(clientId, [...new Set([
|
|
446
|
+
...sockets,
|
|
447
|
+
...(fallbackSockets ? fallbackSockets.values() : [])
|
|
448
|
+
])]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return clients;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
get(data) {
|
|
455
|
+
try {
|
|
456
|
+
if (data.broadcast === "false") data.broadcast = false;
|
|
457
|
+
|
|
458
|
+
if (
|
|
459
|
+
Array.isArray(data.subscriptionKeys) &&
|
|
460
|
+
data.subscriptionKeys.length
|
|
461
|
+
) {
|
|
462
|
+
return this.getSubscribers(data);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const clients = new Map();
|
|
466
|
+
const organization = this.organizations.get(data.organization_id);
|
|
467
|
+
const clientsMap = organization?.clients || new Map();
|
|
468
|
+
const clientIds = Array.isArray(data.clientId)
|
|
469
|
+
? data.clientId
|
|
470
|
+
: data.clientId
|
|
471
|
+
? [data.clientId]
|
|
472
|
+
: [];
|
|
473
|
+
const socketById = this.sockets.get(data.socketId);
|
|
474
|
+
const origin = socketById?.organization_id === data.organization_id
|
|
475
|
+
? socketById
|
|
476
|
+
: undefined;
|
|
477
|
+
const addClient = (clientId, clientSocketsMap, originatingSocket) => {
|
|
478
|
+
const sockets = clientSocketsMap
|
|
479
|
+
? [...clientSocketsMap.values()]
|
|
480
|
+
: [];
|
|
481
|
+
if (originatingSocket) {
|
|
482
|
+
const index = sockets.indexOf(originatingSocket);
|
|
483
|
+
if (index !== -1) sockets.splice(index, 1);
|
|
484
|
+
sockets.unshift(originatingSocket);
|
|
485
|
+
}
|
|
486
|
+
if (sockets.length) clients.set(clientId, sockets);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (data.broadcast) {
|
|
490
|
+
for (const [clientId, clientSocketsMap] of clientsMap.entries()) {
|
|
491
|
+
addClient(
|
|
492
|
+
clientId,
|
|
493
|
+
clientSocketsMap,
|
|
494
|
+
origin?.clientId === clientId ? origin : undefined
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
} else if (origin) {
|
|
498
|
+
addClient(
|
|
499
|
+
origin.clientId,
|
|
500
|
+
clientsMap.get(origin.clientId),
|
|
501
|
+
origin
|
|
502
|
+
);
|
|
503
|
+
} else {
|
|
504
|
+
for (const clientId of clientIds) {
|
|
505
|
+
addClient(clientId, clientsMap.get(clientId));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// The requester receives its response independently of recipient lookup.
|
|
509
|
+
if (origin) {
|
|
510
|
+
addClient(
|
|
511
|
+
origin.clientId,
|
|
512
|
+
clientsMap.get(origin.clientId),
|
|
513
|
+
origin
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return clients;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
console.error("[SocketServer] Error in get method:", error);
|
|
519
|
+
return new Map();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
delete(socket) {
|
|
524
|
+
const organization_id = socket.organization_id;
|
|
525
|
+
const clientId = socket.clientId;
|
|
526
|
+
const socketId = socket.id;
|
|
527
|
+
|
|
528
|
+
const organization = this.organizations.get(organization_id);
|
|
529
|
+
this.removeSubscriptions(socket);
|
|
530
|
+
if (organization && organization.clients) {
|
|
531
|
+
const clientSocketsMap = organization.clients.get(clientId);
|
|
532
|
+
|
|
533
|
+
if (clientSocketsMap) {
|
|
534
|
+
clientSocketsMap.delete(socketId);
|
|
535
|
+
|
|
536
|
+
if (clientSocketsMap.size === 0) {
|
|
537
|
+
organization.clients.delete(clientId);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (organization.clients.size === 0) {
|
|
541
|
+
this.emit("object.update", {
|
|
542
|
+
method: "object.update",
|
|
543
|
+
host: socket.host,
|
|
544
|
+
array: "organizations",
|
|
545
|
+
object: {
|
|
546
|
+
_id: organization_id,
|
|
547
|
+
["$pull.activeHost"]: socket.socketUrl
|
|
548
|
+
},
|
|
549
|
+
organization_id
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// We implement a resilient 60-second grace period (instead of 10 seconds)
|
|
553
|
+
// to gracefully handle page refreshes, cellular/Wi-Fi toggles, or fast disconnect-reconnect tunnels.
|
|
554
|
+
clearTimeout(organization.debounce);
|
|
555
|
+
|
|
556
|
+
organization.debounce = setTimeout(() => {
|
|
557
|
+
const currentOrg = this.organizations.get(organization_id);
|
|
558
|
+
if (currentOrg && currentOrg.clients.size === 0) {
|
|
559
|
+
currentOrg.isRegistered = false;
|
|
560
|
+
|
|
561
|
+
this.organizations.delete(organization_id);
|
|
562
|
+
|
|
563
|
+
// Dispatched locally: Cascades teardown across databases, schedulers, and metrics.
|
|
564
|
+
this.emitOrganizationDisconnected(organization_id, {
|
|
565
|
+
reason: 'idle'
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}, 60000); // 60 seconds resilience guard
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const globalClientMap = this.clients.get(clientId);
|
|
574
|
+
if (globalClientMap) {
|
|
575
|
+
globalClientMap.delete(socketId);
|
|
576
|
+
if (globalClientMap.size === 0) {
|
|
577
|
+
this.clients.delete(clientId);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
this.sockets.delete(socketId);
|
|
582
|
+
|
|
583
|
+
if (socket.user_id) {
|
|
584
|
+
const userSession = this.users.get(socket.user_id);
|
|
585
|
+
if (userSession) {
|
|
586
|
+
userSession.sockets.delete(socketId);
|
|
587
|
+
|
|
588
|
+
if (userSession.sockets.size === 0) {
|
|
589
|
+
clearTimeout(userSession.debounce);
|
|
590
|
+
userSession.debounce = setTimeout(() => {
|
|
591
|
+
this.users.delete(socket.user_id);
|
|
592
|
+
this.emit("userStatus", {
|
|
593
|
+
socket,
|
|
594
|
+
user_id: socket.user_id,
|
|
595
|
+
host: socket.host,
|
|
596
|
+
clientId: socket.clientId,
|
|
597
|
+
userStatus: "off",
|
|
598
|
+
organization_id
|
|
599
|
+
});
|
|
600
|
+
}, 10000);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Explicit cleanup of listeners to protect V8 GC memory allocation bounds
|
|
606
|
+
socket.removeAllListeners();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Instantly evicts one or more organizations from this worker node,
|
|
611
|
+
* closing all active sockets, freeing maps, and emitting the unified
|
|
612
|
+
* orgDisconnected event.
|
|
613
|
+
* Bypasses the 60-second grace period and deregisters each organization from IPC.
|
|
614
|
+
* @param {string|string[]} orgIds - Array of organization IDs to shed
|
|
615
|
+
* @private
|
|
616
|
+
* @environment server
|
|
617
|
+
*/
|
|
618
|
+
destroyOrganizations(orgIds, details = {}) {
|
|
619
|
+
const ids = Array.isArray(orgIds) ? orgIds : [orgIds];
|
|
620
|
+
|
|
621
|
+
for (const orgId of ids) {
|
|
622
|
+
if (details.propagate && typeof this.server?.send === 'function') {
|
|
623
|
+
const distributedEvent = {
|
|
624
|
+
method: 'organization.evicted',
|
|
625
|
+
organization_id: orgId,
|
|
626
|
+
reason: details.reason || 'evicted',
|
|
627
|
+
organizationBalance: details.organizationBalance,
|
|
628
|
+
balance: details.balance,
|
|
629
|
+
currency: details.currency
|
|
630
|
+
};
|
|
631
|
+
Promise.resolve(this.server.send(distributedEvent)).catch(error => {
|
|
632
|
+
console.error(
|
|
633
|
+
`[@cocreate/socket] Failed to propagate eviction for ${orgId}:`,
|
|
634
|
+
error
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const org = this.organizations.get(orgId);
|
|
640
|
+
if (!org) continue;
|
|
641
|
+
|
|
642
|
+
console.log(`[@cocreate/socket] Evicting organization instantly (Shedding/Drain): ${orgId}`);
|
|
643
|
+
|
|
644
|
+
// 1. Clear any active inactivity timeouts to prevent double-processing
|
|
645
|
+
if (org.debounce) {
|
|
646
|
+
clearTimeout(org.debounce);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 2. Identify all sockets associated with this organization
|
|
650
|
+
const orgSockets = [];
|
|
651
|
+
if (org.clients) {
|
|
652
|
+
for (const clientSocketsMap of org.clients.values()) {
|
|
653
|
+
for (const socket of clientSocketsMap.values()) {
|
|
654
|
+
orgSockets.push(socket);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// 3. Terminate all client connections cleanly
|
|
660
|
+
for (const socket of orgSockets) {
|
|
661
|
+
this.removeSubscriptions(socket);
|
|
662
|
+
try {
|
|
663
|
+
// Send a clean departure frame if open
|
|
664
|
+
this._safeSend(socket, { method: "disconnect", reason: "Shedding / Draining Process" });
|
|
665
|
+
socket.bypassDeleteCascade = true; // Mark socket so its 'close' event ignores redundant cascades
|
|
666
|
+
socket.terminate(); // Force terminate the underlying TCP/TLS connection
|
|
667
|
+
} catch (err) {
|
|
668
|
+
console.error(`[@cocreate/socket] Error closing socket during eviction of Org ${orgId}:`, err);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Clear from global tracking maps
|
|
672
|
+
this.sockets.delete(socket.id);
|
|
673
|
+
const globalClientMap = this.clients.get(socket.clientId);
|
|
674
|
+
if (globalClientMap) {
|
|
675
|
+
globalClientMap.delete(socket.id);
|
|
676
|
+
if (globalClientMap.size === 0) {
|
|
677
|
+
this.clients.delete(socket.clientId);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Clean up user sessions
|
|
682
|
+
if (socket.user_id) {
|
|
683
|
+
const userSession = this.users.get(socket.user_id);
|
|
684
|
+
if (userSession) {
|
|
685
|
+
userSession.sockets.delete(socket.id);
|
|
686
|
+
if (userSession.sockets.size === 0) {
|
|
687
|
+
clearTimeout(userSession.debounce);
|
|
688
|
+
this.users.delete(socket.user_id);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 4. Remove the organization completely from the registry
|
|
695
|
+
this.organizations.delete(orgId);
|
|
696
|
+
|
|
697
|
+
// 5. Emit the global process event cascade (database pools close,
|
|
698
|
+
// schedulers cancel, and usage buffers flush).
|
|
699
|
+
const { propagate, ...eventDetails } = details;
|
|
700
|
+
this.emitOrganizationDisconnected(orgId, eventDetails);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
emitOrganizationDisconnected(organization_id, details = {}) {
|
|
705
|
+
const event = {
|
|
706
|
+
organization_id,
|
|
707
|
+
reason: details.reason || 'evicted',
|
|
708
|
+
...details,
|
|
709
|
+
workerId: this.workerId
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
process.emit('orgDisconnected', organization_id, event);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
setOrganizationBalance(data = {}, options = {}) {
|
|
716
|
+
const organization_id = data.organization_id;
|
|
717
|
+
const numericBalance = Number(data.balance);
|
|
718
|
+
const organizationBalance = typeof data.organizationBalance === 'boolean'
|
|
719
|
+
? data.organizationBalance
|
|
720
|
+
: Number.isFinite(numericBalance)
|
|
721
|
+
? numericBalance > 0
|
|
722
|
+
: undefined;
|
|
723
|
+
if (!organization_id || typeof organizationBalance !== 'boolean') return;
|
|
724
|
+
|
|
725
|
+
if (organizationBalance === false) {
|
|
726
|
+
this.organizationBalances.set(organization_id, false);
|
|
727
|
+
} else {
|
|
728
|
+
this.organizationBalances.delete(organization_id);
|
|
729
|
+
}
|
|
730
|
+
const organization = this.organizations.get(organization_id);
|
|
731
|
+
if (organization) {
|
|
732
|
+
organization.organizationBalance = organizationBalance;
|
|
733
|
+
organization.balance = data.balance;
|
|
734
|
+
organization.error = organizationBalance
|
|
735
|
+
? undefined
|
|
736
|
+
: 'Organization balance depleted';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (organizationBalance === false) {
|
|
740
|
+
this.destroyOrganizations(organization_id, {
|
|
741
|
+
reason: 'balance',
|
|
742
|
+
organizationBalance: false,
|
|
743
|
+
balance: data.balance,
|
|
744
|
+
currency: data.currency,
|
|
745
|
+
propagate: options.propagate === true
|
|
746
|
+
});
|
|
747
|
+
} else if (options.propagate === true && typeof this.server?.send === 'function') {
|
|
748
|
+
Promise.resolve(this.server.send({
|
|
749
|
+
method: 'organization.balance',
|
|
750
|
+
organization_id,
|
|
751
|
+
organizationBalance: true,
|
|
752
|
+
balance: data.balance,
|
|
753
|
+
currency: data.currency
|
|
754
|
+
})).catch(error => {
|
|
755
|
+
console.error(
|
|
756
|
+
`[@cocreate/socket] Failed to propagate balance for ${organization_id}:`,
|
|
757
|
+
error
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
getOrganizationBalance(organization_id) {
|
|
764
|
+
return this.organizationBalances.get(organization_id);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Purges all organizations and active connections on this worker instantly.
|
|
769
|
+
* @private
|
|
770
|
+
* @environment server
|
|
771
|
+
*/
|
|
772
|
+
destroyAll() {
|
|
773
|
+
console.log(`[@cocreate/socket] Instantly purging all organization registries...`);
|
|
774
|
+
const allOrgIds = Array.from(this.organizations.keys());
|
|
775
|
+
this.destroyOrganizations(allOrgIds, { reason: 'shutdown' });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async onMessage(socket, message) {
|
|
779
|
+
try {
|
|
780
|
+
this.server.usage.send({
|
|
781
|
+
organization_id: socket.organization_id,
|
|
782
|
+
type: "ingress",
|
|
783
|
+
payload: message
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
let data;
|
|
787
|
+
try {
|
|
788
|
+
data = JSON.parse(message);
|
|
789
|
+
} catch (err) {
|
|
790
|
+
return this._safeSend(socket, { error: "Invalid JSON payload" });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (data.method || data.endpoint) {
|
|
794
|
+
this.Message(socket, data);
|
|
795
|
+
} else {
|
|
796
|
+
data.error = "method or endpoint is required";
|
|
797
|
+
return this._safeSend(socket, data);
|
|
798
|
+
}
|
|
799
|
+
} catch (e) {
|
|
800
|
+
console.log(e);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async Message(socket, data) {
|
|
805
|
+
try {
|
|
806
|
+
const organization_id = socket.organization_id;
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
// Connection identity and scope are authoritative; never accept per-message overrides.
|
|
810
|
+
if (socket.user_id) data.user_id = socket.user_id;
|
|
811
|
+
else delete data.user_id;
|
|
812
|
+
data.socketId = socket.id;
|
|
813
|
+
data.clientId = socket.clientId;
|
|
814
|
+
data.organization_id = organization_id;
|
|
815
|
+
data.host = socket.host;
|
|
816
|
+
// Storage authorization may change organization_id; replies still belong to this connection.
|
|
817
|
+
data.replyOrganizationId = organization_id;
|
|
818
|
+
|
|
819
|
+
const organization = this.organizations.get(organization_id);
|
|
820
|
+
if (organization && organization.organizationBalance == false) {
|
|
821
|
+
data.organizationBalance = false;
|
|
822
|
+
data.error = organization.error;
|
|
823
|
+
return this._safeSend(socket, data);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (
|
|
827
|
+
socket.user_id &&
|
|
828
|
+
socket.expires &&
|
|
829
|
+
new Date(new Date().toISOString()).getTime() >= socket.expires
|
|
830
|
+
) {
|
|
831
|
+
await this.server.send({
|
|
832
|
+
method: "updateUserStatus",
|
|
833
|
+
user_id: socket.user_id,
|
|
834
|
+
host: socket.host,
|
|
835
|
+
clientId: data.clientId,
|
|
836
|
+
userStatus: "off",
|
|
837
|
+
socketId: data.socketId,
|
|
838
|
+
organization_id
|
|
839
|
+
});
|
|
840
|
+
socket.user_id = socket.expires = null;
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const authorizeService = this.authorize;
|
|
845
|
+
if (authorizeService) {
|
|
846
|
+
if (!this.sockets.has(socket.id)) {
|
|
847
|
+
if (
|
|
848
|
+
organization &&
|
|
849
|
+
organization.organizationBalance == false
|
|
850
|
+
) {
|
|
851
|
+
data.organizationBalance = false;
|
|
852
|
+
data.error = organization.error;
|
|
853
|
+
return this._safeSend(socket, data);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const authorized = await authorizeService.check(
|
|
858
|
+
data,
|
|
859
|
+
socket.user_id
|
|
860
|
+
);
|
|
861
|
+
let errors = {};
|
|
862
|
+
if (authorized === false) {
|
|
863
|
+
data.error = "authorization failed";
|
|
864
|
+
return this._safeSend(socket, data);
|
|
865
|
+
} else if (authorized.serverOrganization === false) {
|
|
866
|
+
organization.status = errors.status = false;
|
|
867
|
+
organization.serverOrganization = false;
|
|
868
|
+
organization.error = authorized.error;
|
|
869
|
+
} else if (authorized.serverStorage === false) {
|
|
870
|
+
data.database = process.env.organization_id;
|
|
871
|
+
data.organization_id = process.env.organization_id;
|
|
872
|
+
|
|
873
|
+
const authorized2 = await authorizeService.check(
|
|
874
|
+
data,
|
|
875
|
+
socket.user_id
|
|
876
|
+
);
|
|
877
|
+
if (!authorized2 || authorized2.error) {
|
|
878
|
+
organization.status = errors.status = false;
|
|
879
|
+
organization.error = errors.error = authorized.error;
|
|
880
|
+
if (authorized2.serverOrganization === false) {
|
|
881
|
+
organization.serverOrganization = false;
|
|
882
|
+
}
|
|
883
|
+
if (authorized2.serverStorage === false) {
|
|
884
|
+
organization.serverStorage = false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} else if (authorized.error) {
|
|
888
|
+
organization.status = false;
|
|
889
|
+
organization.error = authorized.error;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (organization && organization.status === false) {
|
|
893
|
+
data.serverOrganization = organization.serverOrganization;
|
|
894
|
+
data.serverStorage = organization.serverStorage;
|
|
895
|
+
data.organizationBalance = organization.organizationBalance;
|
|
896
|
+
data.error = organization.error;
|
|
897
|
+
return this._safeSend(socket, data);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (authorized.authorized) data = authorized.authorized;
|
|
901
|
+
} // End of Authorization Block
|
|
902
|
+
|
|
903
|
+
const moduleName = data.method?.split(".")[0];
|
|
904
|
+
if (
|
|
905
|
+
["storage", "database", "array", "index", "object"].includes(
|
|
906
|
+
moduleName
|
|
907
|
+
) &&
|
|
908
|
+
data.subscription
|
|
909
|
+
) {
|
|
910
|
+
this.subscribe(socket, data.subscription);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// --- ROUTE TO API / CRUD OPERATIONS ---
|
|
914
|
+
if (data.api) {
|
|
915
|
+
this.emit("api", data);
|
|
916
|
+
} else if (data.method === "unsubscribe") {
|
|
917
|
+
this.unsubscribe(socket, data.subscription);
|
|
918
|
+
this._safeSend(socket, data);
|
|
919
|
+
} else if (data.method === "crdt" || data.method === "cursor") {
|
|
920
|
+
this.server.send(data);
|
|
921
|
+
} else {
|
|
922
|
+
if (["storage", "database", "array", "index", "object"].includes(moduleName)) {
|
|
923
|
+
this.emit(data.method, data);
|
|
924
|
+
} else {
|
|
925
|
+
this.emit(moduleName, data);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
} catch (e) {
|
|
930
|
+
console.log(e);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async send(data) {
|
|
935
|
+
data = { ...data };
|
|
936
|
+
delete data.replyOrganizationId;
|
|
937
|
+
const subscriptionDelivery =
|
|
938
|
+
Array.isArray(data.subscriptionKeys) &&
|
|
939
|
+
data.subscriptionKeys.length > 0;
|
|
940
|
+
delete data.wsManager;
|
|
941
|
+
|
|
942
|
+
if (data.method === 'organization.balance') {
|
|
943
|
+
this.setOrganizationBalance(data, { propagate: false });
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (data.method === 'organization.evicted') {
|
|
948
|
+
if (
|
|
949
|
+
typeof data.organizationBalance === 'boolean' ||
|
|
950
|
+
Number.isFinite(Number(data.balance))
|
|
951
|
+
) {
|
|
952
|
+
this.setOrganizationBalance(data, { propagate: false });
|
|
953
|
+
} else {
|
|
954
|
+
this.destroyOrganizations(data.organization_id, {
|
|
955
|
+
reason: data.reason || 'evicted',
|
|
956
|
+
propagate: false
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
for (const property of [
|
|
963
|
+
"broadcast",
|
|
964
|
+
"broadcastClient",
|
|
965
|
+
"broadcastSender"
|
|
966
|
+
]) {
|
|
967
|
+
if (data[property] === "false") data[property] = false;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const sent = [];
|
|
971
|
+
|
|
972
|
+
// --- POST-EXECUTION METADATA LOGGING ---
|
|
973
|
+
if (
|
|
974
|
+
!data.error && // Only log successful operations
|
|
975
|
+
data.success !== false &&
|
|
976
|
+
data.log !== false &&
|
|
977
|
+
data.log !== "false" &&
|
|
978
|
+
data.array !== "activities" &&
|
|
979
|
+
!data.method.endsWith(".read") &&
|
|
980
|
+
data.method !== "updateUserStatus" &&
|
|
981
|
+
data.method !== "userStatus" &&
|
|
982
|
+
data.method !== "crdt" &&
|
|
983
|
+
data.method !== "cursor"
|
|
984
|
+
) {
|
|
985
|
+
let logPayload = {
|
|
986
|
+
method: data.method,
|
|
987
|
+
organization_id: data.organization_id,
|
|
988
|
+
user_id: data.user_id,
|
|
989
|
+
timestamp: new Date().toISOString()
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
if (data.endpoint) logPayload.endpoint = data.endpoint;
|
|
993
|
+
if (data.array) logPayload.array = data.array;
|
|
994
|
+
|
|
995
|
+
if (data.object) {
|
|
996
|
+
if (Array.isArray(data.object)) {
|
|
997
|
+
const skeletonArray = [];
|
|
998
|
+
for (const item of data.object) {
|
|
999
|
+
if (item && item._id) skeletonArray.push({ _id: item._id });
|
|
1000
|
+
}
|
|
1001
|
+
if (skeletonArray.length > 0) logPayload.object = skeletonArray;
|
|
1002
|
+
} else if (data.object._id) {
|
|
1003
|
+
logPayload.object = { _id: data.object._id };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
this.emit("object.create", {
|
|
1008
|
+
method: "object.create",
|
|
1009
|
+
host: data.host,
|
|
1010
|
+
array: "activities",
|
|
1011
|
+
log: false,
|
|
1012
|
+
object: logPayload,
|
|
1013
|
+
organization_id: data.organization_id
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const clients = this.get(data);
|
|
1018
|
+
let cleanData = { ...data };
|
|
1019
|
+
if (cleanData.filter?.query) {
|
|
1020
|
+
cleanData.filter = { ...cleanData.filter, query: { ...cleanData.filter.query } };
|
|
1021
|
+
}
|
|
1022
|
+
if (subscriptionDelivery) cleanData.broadcastClient = false;
|
|
1023
|
+
|
|
1024
|
+
if (
|
|
1025
|
+
cleanData.filter &&
|
|
1026
|
+
cleanData.filter.query &&
|
|
1027
|
+
cleanData.filter.query._id &&
|
|
1028
|
+
cleanData.filter.query._id.$eq === "$user_id"
|
|
1029
|
+
) {
|
|
1030
|
+
delete cleanData.filter.query._id;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const defaultPayloadStr = JSON.stringify(cleanData);
|
|
1034
|
+
|
|
1035
|
+
for (const [clientId, sockets] of clients) {
|
|
1036
|
+
const sessionSocket = sockets[0];
|
|
1037
|
+
if (!sessionSocket) continue;
|
|
1038
|
+
|
|
1039
|
+
let payloadStr = defaultPayloadStr;
|
|
1040
|
+
let finalDataObj = cleanData;
|
|
1041
|
+
|
|
1042
|
+
if (this.authorize) {
|
|
1043
|
+
const authResult = await this.authorize.check(
|
|
1044
|
+
cleanData,
|
|
1045
|
+
sessionSocket.user_id
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
if (authResult === false) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (authResult !== true && authResult && authResult.authorized) {
|
|
1053
|
+
finalDataObj = { ...authResult.authorized };
|
|
1054
|
+
|
|
1055
|
+
if (
|
|
1056
|
+
finalDataObj.filter &&
|
|
1057
|
+
finalDataObj.filter.query &&
|
|
1058
|
+
finalDataObj.filter.query._id &&
|
|
1059
|
+
finalDataObj.filter.query._id.$eq === "$user_id"
|
|
1060
|
+
) {
|
|
1061
|
+
delete finalDataObj.filter.query._id;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
payloadStr = JSON.stringify(finalDataObj);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
for (const recipientSocket of sockets) {
|
|
1069
|
+
if (!await this._safeSend(recipientSocket, payloadStr)) continue;
|
|
1070
|
+
|
|
1071
|
+
sent.push(clientId);
|
|
1072
|
+
this.server.usage.send({
|
|
1073
|
+
organization_id: recipientSocket.organization_id,
|
|
1074
|
+
type: "egress",
|
|
1075
|
+
payload: payloadStr
|
|
1076
|
+
});
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
this.emit("notification", { sent });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Helper to send payloads safely on valid connections without throwing frame failures
|
|
1085
|
+
async _safeSend(socket, payload) {
|
|
1086
|
+
try {
|
|
1087
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
1088
|
+
const message = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
1089
|
+
return await new Promise((resolve) => {
|
|
1090
|
+
socket.send(message, (error) => resolve(!error));
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
console.error("[@cocreate/socket] Error safely writing to socket:", err);
|
|
1095
|
+
}
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export default SocketServer;
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
*
|
|
1104
|
+
* @name default
|
|
1105
|
+
* @kind member
|
|
1106
|
+
* @public
|
|
1107
|
+
* @environment server
|
|
1108
|
+
*/
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* High-density multi-tenant WebSocket orchestration server for real-time state synchronization and cluster-mesh collaboration.
|
|
1112
|
+
*
|
|
1113
|
+
* @module socket
|
|
1114
|
+
* @environment server
|
|
1115
|
+
*/
|