@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.
@@ -0,0 +1,785 @@
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
+ * Commercial Licensing Information:
20
+ * For commercial use of this software without the copyleft provisions of the AGPLv3,
21
+ * you must obtain a commercial license from CoCreate LLC.
22
+ * For details, visit <https://CoCreateJS.com/licenses/> or contact us at sales@CoCreateJS.com.
23
+ */
24
+
25
+ import { ObjectId, uuid, getClientInfo } from "@cocreate/utils";
26
+ import config from "@cocreate/config";
27
+
28
+ const isBrowser = typeof window !== "undefined";
29
+
30
+ function getBrowserLocation() {
31
+ if (!isBrowser) return;
32
+ if (window.location.host) return window.location;
33
+
34
+ try {
35
+ if (window.parent !== window && window.parent.location.host) {
36
+ return window.parent.location;
37
+ }
38
+ } catch {
39
+ // Cross-origin parents are intentionally inaccessible.
40
+ }
41
+
42
+ return window.location;
43
+ }
44
+
45
+ let WS_Impl;
46
+
47
+ function getIndexeddb() {
48
+ return isBrowser ? window.CoCreate?.indexeddb : null;
49
+ }
50
+
51
+ async function getWebSocketImpl() {
52
+ if (WS_Impl) return WS_Impl;
53
+ if (isBrowser) {
54
+ WS_Impl = window.WebSocket;
55
+ } else {
56
+ const wsModule = await import("ws");
57
+ WS_Impl = wsModule.default || wsModule;
58
+ }
59
+ return WS_Impl;
60
+ }
61
+
62
+ const socketsByUrl = new Map();
63
+ const socketsById = new Map();
64
+ const delay = 1000 + Math.floor(Math.random() * 3000);
65
+
66
+ let organizationPromise = null;
67
+
68
+ async function getOrganization() {
69
+ let organization_id = config.get("organization_id");
70
+ if (!organization_id || organization_id === "canceled") {
71
+ if (isBrowser) {
72
+ const Organization = await import("@cocreate/organizations");
73
+ organization_id = await Organization.default.get();
74
+ } else {
75
+ organization_id = process.env.ORGANIZATION_ID || "";
76
+ }
77
+ }
78
+ if (organization_id) config.set("organization_id", organization_id);
79
+
80
+ return organization_id;
81
+ }
82
+
83
+ const CoCreateSocketClient = {
84
+ /**
85
+ *
86
+ * @public
87
+ * @environment client
88
+ * @environment server
89
+ */
90
+ frameId: uuid(8),
91
+ /**
92
+ *
93
+ * @public
94
+ * @environment client
95
+ * @environment server
96
+ */
97
+ connected: false,
98
+ listeners: new Map(),
99
+ /**
100
+ *
101
+ * @public
102
+ * @environment client
103
+ * @environment server
104
+ */
105
+ messageEvents: {},
106
+ /**
107
+ *
108
+ * @public
109
+ * @environment client
110
+ * @environment server
111
+ */
112
+ messageQueue: new Map(),
113
+ /**
114
+ *
115
+ * @public
116
+ * @environment client
117
+ * @environment server
118
+ */
119
+ configQueue: new Map(),
120
+ /**
121
+ *
122
+ * @public
123
+ * @environment client
124
+ * @environment server
125
+ */
126
+ maxReconnectDelay: 600000,
127
+ /**
128
+ *
129
+ * @public
130
+ * @environment client
131
+ * @environment server
132
+ */
133
+ organization: false,
134
+ /**
135
+ *
136
+ * @public
137
+ * @environment client
138
+ * @environment server
139
+ */
140
+ serverStorage: true,
141
+ /**
142
+ *
143
+ * @public
144
+ * @environment client
145
+ * @environment server
146
+ */
147
+ serverOrganization: true,
148
+ /**
149
+ *
150
+ * @public
151
+ * @environment client
152
+ * @environment server
153
+ */
154
+ organizationBalance: true,
155
+ /**
156
+ *
157
+ * @public
158
+ * @environment client
159
+ * @environment server
160
+ */
161
+ organization_id: async () => {
162
+ return (
163
+ organizationPromise ||
164
+ (organizationPromise = getOrganization())
165
+ );
166
+ },
167
+
168
+ async init() {
169
+ this.clientId = config.get("clientId");
170
+ this.clientInfo = await getClientInfo();
171
+ if (!this.clientId || this.clientId === "undefined") {
172
+ this.clientId = ObjectId().toString()
173
+ config.set("clientId", this.clientId);
174
+ }
175
+ if (this.clientId && this.clientId.length !== 24 && ObjectId) {
176
+ this.clientId = ObjectId().toString();
177
+ config.set("clientId", this.clientId);
178
+ }
179
+
180
+ // Strictly retrieve host using isomorphic config.get
181
+ this.host = config.get("host");
182
+ if (!this.host || this.host === "undefined") {
183
+ if (isBrowser) {
184
+ // Browser: Fall back cleanly to client's current domain
185
+ this.host = getBrowserLocation().host;
186
+ } else {
187
+ // Server: Leave empty so getUrls can safely report configuration parameters are missing
188
+ this.host = "";
189
+ }
190
+ config.set("host", this.host);
191
+ }
192
+
193
+ this.balancer = config.get("balancer");
194
+ if (!this.balancer || this.balancer === "undefined") {
195
+ this.balancer = "";
196
+ config.set("balancer", this.balancer);
197
+ }
198
+ },
199
+
200
+ set(socket) {
201
+ socketsByUrl.set(socket.url, socket);
202
+ socketsById.set(socket.id, socket);
203
+ },
204
+
205
+ get(key) {
206
+ return socketsByUrl.get(key) || socketsById.get(key);
207
+ },
208
+
209
+ has(key) {
210
+ return socketsByUrl.has(key) || socketsById.has(key);
211
+ },
212
+
213
+ delete(key) {
214
+ let socket;
215
+ if (typeof key === "string") socket = this.get(key);
216
+ if (!socket || (!socket.id && !socket.url)) return;
217
+ socketsByUrl.delete(socket.url);
218
+ socketsById.delete(socket.id);
219
+
220
+ if (socket.readyState === 1) socket.close();
221
+ },
222
+
223
+ async create(data = {}) {
224
+ const self = this;
225
+
226
+ if (this.organization === "canceled") return;
227
+
228
+ const urls = await this.getUrls(data);
229
+ for (let url of urls) {
230
+ let socket = this.get(url);
231
+ if (socket) return;
232
+
233
+ this.set({ url });
234
+
235
+ try {
236
+ let token = null;
237
+ if (isBrowser) {
238
+ token = config.get("token");
239
+ }
240
+
241
+ const socketId = uuid(8);
242
+
243
+ const options = {
244
+ socketId,
245
+ clientId: this.clientId,
246
+ clientInfo: this.clientInfo,
247
+ user_id: data.user_id || this.user_id,
248
+ token: token || ""
249
+ };
250
+
251
+ if (isBrowser) {
252
+ options.lastSynced = config.get(url);
253
+ }
254
+
255
+ let opt = JSON.stringify(options);
256
+ opt = encodeURIComponent(opt);
257
+
258
+ const WebSocketClass = await getWebSocketImpl();
259
+ socket = new WebSocketClass(url, opt);
260
+ socket.id = options.socketId;
261
+ socket.connected = false;
262
+ socket.clientId = this.clientId;
263
+ socket.frameId = this.frameId;
264
+ socket.organization_id =
265
+ data.organization_id ||
266
+ (await this.organization_id());
267
+ socket.user_id = data.user_id || this.user_id;
268
+ socket.host = data.host || this.host;
269
+ socket.key = url;
270
+
271
+ this.set(socket);
272
+ } catch (error) {
273
+ console.error("[@cocreate/socket] Connection setup failed:", error);
274
+ return;
275
+ }
276
+
277
+ const notifyDisconnect = (event) => {
278
+ socket.connected = false;
279
+ self.connected = false;
280
+ if (socket.__disconnectNotified) return;
281
+ socket.__disconnectNotified = true;
282
+ self.__fireListeners({
283
+ method: "disconnect",
284
+ socketId: socket.id,
285
+ url: socket.url,
286
+ event
287
+ });
288
+ };
289
+
290
+ socket.onclose = function (event) {
291
+ notifyDisconnect(event);
292
+ switch (event.code) {
293
+ case 1000:
294
+ console.log("websocket: closed");
295
+ break;
296
+ default:
297
+ self.reconnect(data, socket);
298
+ break;
299
+ }
300
+ };
301
+
302
+ socket.onerror = function (event) {
303
+ notifyDisconnect(event);
304
+ if (!isBrowser) console.error("[@cocreate/socket] Server socket transport error:", event.error || event);
305
+ else if (!window.navigator.onLine)
306
+ console.log("offline");
307
+
308
+ self.reconnect(data, socket);
309
+ };
310
+
311
+ socket.onmessage = function (message) {
312
+ try {
313
+ message = JSON.parse(message.data);
314
+ // Transport open precedes server authentication and message setup.
315
+ // Flush requests only after the server confirms it is ready.
316
+ if (message.method === "connect") {
317
+ self.connected = true;
318
+ socket.connected = true;
319
+ socket.__disconnectNotified = false;
320
+ delete data.currentReconnectDelay;
321
+ self.checkMessageQueue(data);
322
+ } else if (message.method === "disconnect") {
323
+ socket.connected = false;
324
+ self.connected = false;
325
+ socket.__disconnectNotified = true;
326
+ }
327
+ message.receiverFrameId = self.frameId;
328
+ if (message.error) {
329
+ console.error(message.method, message.error);
330
+ if (message.serverOrganization === false)
331
+ self.serverOrganization =
332
+ self.serverStorage = false;
333
+ else if (message.serverStorage === false)
334
+ self.serverStorage = false;
335
+ else if (message.organizationBalance === false)
336
+ self.organizationBalance = false;
337
+ }
338
+
339
+ if (
340
+ message.method != "connect" &&
341
+ typeof message == "object"
342
+ ) {
343
+ if (isBrowser && message._id) {
344
+ let lastSynced = config.get(socket.url);
345
+
346
+ if (!lastSynced) {
347
+ config.set(socket.url, message._id);
348
+ } else if (lastSynced !== message._id) {
349
+ if (
350
+ ObjectId(lastSynced).timestamp <
351
+ ObjectId(message._id).timestamp
352
+ ) {
353
+ config.set(socket.url, message._id);
354
+ }
355
+ }
356
+
357
+ let Data = {
358
+ method: "object.delete",
359
+ database: "system",
360
+ array: "messageQueue",
361
+ filter: {
362
+ query: {
363
+ "updated.on": {
364
+ $gt: message.timestamp
365
+ },
366
+ status: "queued"
367
+ }
368
+ }
369
+ };
370
+
371
+ let type = message.method.split(".")[0];
372
+ if (Array.isArray(message[type])) {
373
+ for (let item of message[type]) {
374
+ if (type == "object") {
375
+ Data.filter.query["data._id"] =
376
+ item._id;
377
+ } else if (
378
+ [
379
+ "database",
380
+ "array",
381
+ "index"
382
+ ].includes(type)
383
+ ) {
384
+ Data.filter.query[
385
+ "data.name"
386
+ ] = item.name;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ message.status = "received";
394
+
395
+ if (message.broadcastClient && isBrowser)
396
+ self.broadcastLocalMessage(message).catch((error) => {
397
+ console.error("Failed to broadcast local socket message", error);
398
+ });
399
+
400
+ if (message && message.uuid) {
401
+ if (isBrowser) {
402
+ const event = new window.CustomEvent(
403
+ message.uuid,
404
+ {
405
+ detail: message
406
+ }
407
+ );
408
+ window.dispatchEvent(event);
409
+ } else {
410
+ process.emit(message.uuid, message);
411
+ }
412
+ }
413
+
414
+ const isSender =
415
+ message.clientId === self.clientId &&
416
+ message.frameId === self.frameId;
417
+ if (
418
+ message.method === "connect" ||
419
+ !isSender ||
420
+ message.broadcastSender !== false
421
+ )
422
+ self.__fireListeners(message);
423
+ } catch (e) {
424
+ console.log(e);
425
+ }
426
+ };
427
+ }
428
+ },
429
+
430
+ __fireListeners(data) {
431
+ /**
432
+ *
433
+ * @public
434
+ * @environment client
435
+ * @environment server
436
+ */
437
+ const listeners = this.listeners.get(data.method) || [];
438
+ listeners.forEach((listener) => {
439
+ listener(data);
440
+ });
441
+
442
+ if (data.method.includes(".")) {
443
+ const listeners =
444
+ this.listeners.get(data.method.split(".")[0]) || [];
445
+ listeners.forEach((listener) => {
446
+ listener(data);
447
+ });
448
+ }
449
+ },
450
+
451
+ checkMessageQueue(config) {
452
+ if (this.messageQueue.size > 0) {
453
+ for (let [uuid, data] of this.messageQueue) {
454
+ this.send(data);
455
+ this.messageQueue.delete(uuid);
456
+ }
457
+ }
458
+
459
+ const storage = getIndexeddb();
460
+ if (isBrowser && storage) {
461
+ storage
462
+ .send({
463
+ method: "object.delete",
464
+ database: "system",
465
+ array: "messageQueue",
466
+ filter: {
467
+ query: { status: "queued" }
468
+ },
469
+ organization_id: config.organization_id
470
+ })
471
+ .then((data) => {
472
+ if (data) {
473
+ for (let i = 0; i < data.object.length; i++) {
474
+ this.send(data.object[i].data);
475
+ }
476
+ }
477
+ });
478
+ }
479
+ },
480
+
481
+ send(data = {}) {
482
+ return new Promise(async (resolve, reject) => {
483
+ data.clientId = this.clientId;
484
+ data.frameId = this.frameId;
485
+
486
+ if (!data.timeStamp)
487
+ data.timeStamp = new Date().toISOString();
488
+
489
+ if (!data.organization_id)
490
+ data.organization_id = await this.organization_id();
491
+
492
+ if (!data.apikey && this.apikey) data.apikey = this.apikey;
493
+
494
+ if (!data.user_id && this.user_id)
495
+ data.user_id = this.user_id;
496
+
497
+ if (!data.uuid) data.uuid = uuid();
498
+
499
+ if (!data.namespace) delete data.namespace;
500
+
501
+ if (!data.room) delete data.room;
502
+
503
+ const UUID = data.uuid;
504
+ const sockets = await this.getSockets(data);
505
+
506
+ for (let socket of sockets) {
507
+ data.socketId = socket.id;
508
+ if (!this.messageEvents[UUID])
509
+ this.addListener(UUID, resolve);
510
+
511
+ if (
512
+ socket.connected &&
513
+ this.serverOrganization &&
514
+ this.serverStorage &&
515
+ this.organizationBalance
516
+ ) {
517
+ delete data.status;
518
+
519
+ socket.send(JSON.stringify(data));
520
+ data.status = "sent";
521
+ } else {
522
+ data.status = "queued";
523
+ }
524
+
525
+ if (isBrowser) {
526
+ if (data.status !== "sent") data.status = "queued";
527
+
528
+ const storage = getIndexeddb();
529
+ if (storage && data.status === "queued") {
530
+ storage.send({
531
+ method: "object.update",
532
+ database: "system",
533
+ array: "messageQueue",
534
+ upsert: true,
535
+ object: {
536
+ _id: UUID,
537
+ data,
538
+ status: "queued"
539
+ },
540
+ organization_id: data.organization_id
541
+ });
542
+ } else if (data.status === "queued") {
543
+ this.messageQueue.set(UUID, data);
544
+ }
545
+ } else if (data.status === "queued") {
546
+ this.messageQueue.set(UUID, data);
547
+ }
548
+ }
549
+ });
550
+ },
551
+
552
+ addListener(UUID, resolve) {
553
+ this.messageEvents[UUID] = resolve;
554
+ if (isBrowser) {
555
+ const self = this;
556
+ window.addEventListener(
557
+ UUID,
558
+ function (event) {
559
+ delete self.messageEvents[UUID];
560
+ resolve(event.detail);
561
+ },
562
+ { once: true }
563
+ );
564
+ } else {
565
+ process.once(UUID, (data) => {
566
+ delete this.messageEvents[UUID];
567
+ resolve(data);
568
+ });
569
+ }
570
+ },
571
+
572
+ listen(action, callback) {
573
+ if (!this.listeners.get(action)) {
574
+ this.listeners.set(action, [callback]);
575
+ } else {
576
+ this.listeners.get(action).push(callback);
577
+ }
578
+ },
579
+
580
+ reconnect(config, socket) {
581
+ let self = this;
582
+ if (!config.currentReconnectDelay)
583
+ config.currentReconnectDelay = delay;
584
+
585
+ let url = socket.url;
586
+ if (isBrowser && !window.navigator.onLine) {
587
+ const online = () => {
588
+ window.removeEventListener("online", online);
589
+ self.delete(url);
590
+ self.create(config);
591
+ };
592
+ window.addEventListener("online", online);
593
+ } else {
594
+ setTimeout(() => {
595
+ if (
596
+ !self.maxReconnectDelay ||
597
+ config.currentReconnectDelay <
598
+ self.maxReconnectDelay
599
+ ) {
600
+ self.delete(url);
601
+ config.currentReconnectDelay *= 2;
602
+ self.create(config);
603
+ }
604
+ }, config.currentReconnectDelay);
605
+ }
606
+ },
607
+
608
+ async getUrls(data = {}) {
609
+ let protocol = "wss";
610
+ let host = data.host || config.get("host"); // Strictly resolved via isomorphic configuration
611
+
612
+ if (!host && isBrowser) {
613
+ host = getBrowserLocation().host;
614
+ }
615
+
616
+ if (isBrowser) {
617
+ if (location.protocol.startsWith("about:")) {
618
+ if (
619
+ window.parent &&
620
+ window.parent.location.protocol !== "https:"
621
+ ) {
622
+ protocol = "ws";
623
+ }
624
+ } else if (location.protocol !== "https:") protocol = "ws";
625
+ } else {
626
+ if (typeof host === "string") {
627
+ if (host.includes("localhost") || host.includes("127.0.0.1")) {
628
+ protocol = "ws";
629
+ }
630
+ }
631
+ }
632
+
633
+ let url,
634
+ urls = [],
635
+ hostUrls = [];
636
+ let balancer = data.balancer || config.get("balancer"); // Strictly resolved via isomorphic configuration
637
+
638
+ if (typeof host === "string" && host) {
639
+ host = host.split(",");
640
+ for (let i = 0; i < host.length; i++) {
641
+ host[i] = host[i].trim();
642
+ if (host[i][host[i].length - 1] === "/")
643
+ host[i] = host[i].slice(0, -1);
644
+
645
+ if (host[i].includes("://")) url = `${host[i]}`;
646
+ else url = `${protocol}://${host[i]}`;
647
+
648
+ url += `/${
649
+ data.organization_id ||
650
+ (await this.organization_id()) ||
651
+ ""
652
+ }`;
653
+ if (balancer == "mesh") urls.push(url);
654
+ else {
655
+ let socket = this.get(url);
656
+ if (socket !== false) {
657
+ urls.push(url);
658
+ break;
659
+ } else hostUrls.push(url);
660
+ }
661
+ }
662
+ if (!urls.length && hostUrls.length) {
663
+ for (let i = 0; i < hostUrls.length; i++) {
664
+ this.delete(hostUrls[i]);
665
+ urls.push(hostUrls[i]);
666
+ }
667
+ }
668
+ } else if (isBrowser) {
669
+ let newhost = getBrowserLocation().host;
670
+ url = [`${protocol}://${newhost}`];
671
+ url += `/${
672
+ data.organization_id ||
673
+ (await this.organization_id()) ||
674
+ ""
675
+ }`;
676
+ urls.push(url);
677
+ } else {
678
+ console.log("missing host");
679
+ }
680
+
681
+ return urls;
682
+ },
683
+
684
+ async getSockets(data) {
685
+ let sockets = [];
686
+ let urls = await this.getUrls(data);
687
+ for (let url of urls) {
688
+ let socket = this.get(url);
689
+ if (!socket) {
690
+ await this.create(data);
691
+ socket = this.get(url);
692
+ if (socket) sockets.push(socket);
693
+ } else {
694
+ sockets.push(socket);
695
+ }
696
+ }
697
+ return sockets;
698
+ },
699
+
700
+ sendLocalMessage(data) {
701
+ this.__fireListeners(data);
702
+ },
703
+
704
+ async broadcastLocalMessage(message) {
705
+ const storage = getIndexeddb();
706
+ if (!storage) return;
707
+
708
+ const _id = ObjectId().toString();
709
+ const organization_id = message.organization_id ||
710
+ await this.organization_id();
711
+
712
+ await storage.send({
713
+ method: "object.create",
714
+ database: "system",
715
+ array: "localSocketMessages",
716
+ object: {
717
+ _id,
718
+ message
719
+ },
720
+ organization_id
721
+ });
722
+
723
+ config.set(
724
+ "localSocketMessage",
725
+ JSON.stringify({ _id, organization_id })
726
+ );
727
+
728
+ storage.send({
729
+ method: "object.delete",
730
+ database: "system",
731
+ array: "localSocketMessages",
732
+ filter: {
733
+ query: {
734
+ "created.on": {
735
+ $lte: new Date(Date.now() - 5000).toISOString()
736
+ }
737
+ }
738
+ },
739
+ organization_id
740
+ }).catch((error) => {
741
+ console.error("Failed to clean up local socket messages", error);
742
+ });
743
+ },
744
+
745
+ async receiveLocalMessage(reference) {
746
+ const storage = getIndexeddb();
747
+ if (!storage || !reference?._id || !reference.organization_id) return;
748
+
749
+ const data = await storage.send({
750
+ method: "object.read",
751
+ database: "system",
752
+ array: "localSocketMessages",
753
+ object: { _id: reference._id },
754
+ organization_id: reference.organization_id
755
+ });
756
+ const message = data?.object?.[0]?.message;
757
+ if (message) this.sendLocalMessage(message);
758
+ }
759
+ };
760
+
761
+ CoCreateSocketClient.init();
762
+
763
+ if (isBrowser) {
764
+ window.onstorage = async (e) => {
765
+ if (e.key == "localSocketMessage" && e.newValue) {
766
+ try {
767
+ const reference = JSON.parse(e.newValue);
768
+ await CoCreateSocketClient.receiveLocalMessage(reference);
769
+ } catch (error) {
770
+ console.error("Failed to receive local socket message", error);
771
+ }
772
+ }
773
+ };
774
+ }
775
+
776
+ export { CoCreateSocketClient as SocketClient };
777
+ export default CoCreateSocketClient;
778
+
779
+ /**
780
+ * A simple socket component in vanilla javascript. Easily configured using HTML5 attributes and/or JavaScript API.
781
+ *
782
+ * @module socket
783
+ * @environment client
784
+ * @environment server
785
+ */