@cocreate/socket 0.0.0-stage → 3.0.1

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,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
+ */