UNPKG

@cocreate/socket-client

Version:

A simple socket component in vanilla javascript. Easily configured using HTML5 attributes and/or JavaScript API.

670 lines (591 loc) 18.3 kB
/******************************************************************************** * Copyright (C) 2023 CoCreate and Contributors. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. ********************************************************************************/ /** * Commercial Licensing Information: * For commercial use of this software without the copyleft provisions of the AGPLv3, * you must obtain a commercial license from CoCreate LLC. * For details, visit <https://cocreate.app/licenses/> or contact us at sales@cocreate.app. */ (function (root, factory) { if (typeof define === "function" && define.amd) { // AMD. Register as an anonymous module. define([ "@cocreate/uuid", "@cocreate/indexeddb", "@cocreate/config" ], function (uuid, indexeddb, config) { return factory( true, WebSocket, uuid, (indexeddb = indexeddb.default), (config = config.default) ); }); } else if (typeof module === "object" && module.exports) { const WebSocket = require("ws"); const uuid = require("@cocreate/uuid"); module.exports = factory(false, WebSocket, uuid); } else { // Browser globals (root is window) root.returnExports = factory( true, WebSocket, root["@cocreate/uuid"], root["@cocreate/indexeddb"], root["@cocreate/config"] ); } })( typeof self !== "undefined" ? self : this, function (isBrowser, WebSocket, uuid, indexeddb, config) { const socketsByUrl = new Map(); const socketsById = new Map(); const delay = 1000 + Math.floor(Math.random() * 3000); let organizationPromise = null; async function getOrganization() { let organization_id = config.get("organization_id"); if (!organization_id || organization_id === "canceled") { const Organization = await import("@cocreate/organizations"); organization_id = await Organization.default.get(); } if (organization_id) config.set("organization_id", organization_id); return organization_id; } const CoCreateSocketClient = { frameId: uuid.generate(8), connected: false, listeners: new Map(), messageEvents: {}, messageQueue: new Map(), // required per url already per url when isBrowser and indexeddb. configQueue: new Map(), maxReconnectDelay: 600000, organization: false, // required per url serverStorage: true, // required per url serverOrganization: true, // required per url organizationBalance: true, // required per url organization_id: async () => { return ( organizationPromise || (organizationPromise = getOrganization()) ); }, //TODO: on app start up we can get the port and ip and public dns. Using config we can define if this app is behind an lb. // If behind an lb it can create a socket connection to the lb node in order to add node to the lb backend list. async init() { function defaultHost() { let host = window.location.host; if (host.startsWith("127.0.0.1")) host = "cocreate.app"; return host; } const defaults = { clientId: indexeddb.ObjectId().toString(), host: defaultHost() }; const keys = [ "clientId", "apikey", "host", "user_id", "balancer" ]; for (let i = 0; i < keys.length; i++) { this[keys[i]] = config.get(keys[i]); if (!this[keys[i]] || this[keys[i]] === "undefined") this[keys[i]] = defaults[keys[i]] || ""; config.set(keys[i], this[keys[i]]); } }, set(socket) { socketsByUrl.set(socket.url, socket); socketsById.set(socket.id, socket); }, get(key) { return socketsByUrl.get(key) || socketsById.get(key); }, has(key) { return socketsByUrl.has(key) || socketsById.has(key); }, delete(key) { let socket; if (typeof key === "string") socket = this.get(key); if (!socket || (!socket.id && !socket.url)) return; socketsByUrl.delete(socket.url); socketsById.delete(socket.id); if (socket.readyState === WebSocket.OPEN) socket.close(); }, /** * config: {organization_id, namespace, room, host} */ async create(data = {}) { const self = this; if (this.organization === "canceled") return; const urls = await this.getUrls(data); for (let url of urls) { let socket = this.get(url); if (socket) return; this.set({ url }); try { let token = null; if (isBrowser) { token = config.get("token"); } let socketId; if (isBrowser) socketId = sessionStorage.getItem("socketId"); // else // socketId = config.get('socketId'); if (!socketId) { socketId = uuid.generate(8); if (isBrowser) socketId = sessionStorage.setItem( "socketId", socketId ); // else // socketId = config.set('socketId', socketId); } const options = { socketId, clientId: this.clientId, user_id: data.user_id || this.user_id, token: token || "" }; if (isBrowser) { options.lastSynced = config.get(url); } let opt = JSON.stringify(options); opt = encodeURIComponent(opt); socket = new WebSocket(url, opt); socket.id = options.socketId; socket.connected = false; socket.clientId = this.clientId; socket.frameId = this.frameId; socket.organization_id = data.organization_id || (await this.organization_id()); socket.user_id = data.user_id || this.user_id; socket.host = data.host || this.host; socket.key = url; this.set(socket); } catch (error) { console.log(error); return; } socket.onopen = function (event) { self.connected = true; socket.connected = true; delete data.currentReconnectDelay; self.checkMessageQueue(data); }; socket.onclose = function (event) { socket.connected = false; switch (event.code) { case 1000: // close normal console.log("websocket: closed"); break; default: self.reconnect(data, socket); break; } }; socket.onerror = function (event) { if (!isBrowser) console.log(event.error); else if (!window.navigator.onLine) console.log("offline"); self.reconnect(data, socket); }; socket.onmessage = function (message) { try { message = JSON.parse(message.data); if (message.error) { console.error(message.method, message.error); if (message.serverOrganization === false) self.serverOrganization = self.serverStorage = false; else if (message.serverStorage === false) self.serverStorage = false; else if (message.organizationBalance === false) self.organizationBalance = false; } if ( message.method != "connect" && typeof message == "object" ) { if (isBrowser && message._id) { let lastSynced = config.get(socket.url); if (!lastSynced) { config.set(socket.url, message._id); } else if (lastSynced !== message._id) { if ( indexeddb.ObjectId(lastSynced) .timestamp < indexeddb.ObjectId(message._id) .timestamp ) { config.set(socket.url, message._id); } } // here we can handle crud types inorder to avoid conflicts simply by deleting queued items prior to sending let Data = { method: "object.delete", array: "message_log", $filter: { query: { "modified.on": { $gt: message.timestamp }, status: "queued" } } }; // TODO: we need to delete queued items based on some conditions to prevent conflicts // what are the conditions? let type = message.method.split(".")[0]; if (Array.isArray(message[type])) { for (let item of message[type]) { if (type == "object") { Data.$filter.query["data._id"] = item._id; } else if ( [ "database", "array", "index" ].includes(type) ) { Data.$filter.query[ "data.name" ] = item.name; } } // indexeddb.send(Data) } } } if ( message.broadcastClient && message.broadcastBrowser !== false && isBrowser && message.broadcastBrowser && !message.method.endsWith(".read") ) config.set( "localSocketMessage", JSON.stringify(message) ); message.status = "received"; if (message && message.uid) { if (isBrowser) { const event = new window.CustomEvent( message.uid, { detail: message } ); window.dispatchEvent(event); } else { process.emit(message.uid, message); } } self.__fireListeners(message); } catch (e) { console.log(e); } }; } }, __fireListeners(data) { const listeners = this.listeners.get(data.method) || []; listeners.forEach((listener) => { listener(data); }); if (data.method.includes(".")) { const listeners = this.listeners.get(data.method.split(".")[0]) || []; listeners.forEach((listener) => { listener(data); }); } }, // TODO: could be rquired in the serverside when handeling server to server mesh socket using crud-server instead checkMessageQueue(config) { if (isBrowser && indexeddb) { indexeddb .send({ method: "object.delete", array: "message_log", $filter: { query: { status: "queued" } }, organization_id: config.organization_id }) .then((data) => { if (data) { for (let i = 0; i < data.object.length; i++) { this.send(data.object[i].data); } } }); } else { // TODO: set and get messageQueue per socket.url if (this.messageQueue.size > 0) { for (let [uid, data] of this.messageQueue) { this.send(data); this.messageQueue.delete(uid); } } } }, send(data = {}) { return new Promise(async (resolve, reject) => { data.clientId = this.clientId; data.frameId = this.frameId; if (!data.timeStamp) data.timeStamp = new Date().toISOString(); if (!data.organization_id) data.organization_id = await this.organization_id(); if (!data.apikey && this.apikey) data.apikey = this.apikey; if (!data.user_id && this.user_id) data.user_id = this.user_id; if (data.broadcast === "false" || data.broadcast === false) data.broadcast = false; else data.broadcast = true; if ( data.broadcastClient === "false" || data.broadcastClient === false ) data.broadcastClient = false; else data.broadcastClient = true; if ( data.broadcastSender === "false" || data.broadcastSender === false ) data.broadcastSender = false; else data.broadcastSender = true; if (!data.uid) data.uid = uuid.generate(); if (!data.namespace) delete data.namespace; if (!data.room) delete data.room; let broadcastBrowser = false; if ( data.broadcastBrowser === "false" || data.broadcastBrowser === false ) broadcastBrowser = false; else broadcastBrowser = true; delete data.broadcastBrowser; const uid = data.uid; const sockets = await this.getSockets(data); let isAwait; for (let socket of sockets) { data.socketId = socket.id; if (!this.messageEvents[uid]) this.addListener(uid, resolve); if ( socket.connected && this.serverOrganization && this.serverStorage && this.organizationBalance ) { if (data.status === "resolve") resolve(data); if (data.status === "await") isAwait = true; delete data.status; socket.send(JSON.stringify(data)); data.status = "sent"; } else if (!data.status) { data.status = "queued"; } else if ( data.status !== "queued" && data.status !== "await" ) { resolve(data); } if (isBrowser) { if (!isAwait) { if (data.broadcastSender) this.sendLocalMessage(data); // TODO: set in indexeddb if above a specific size if (broadcastBrowser) config.set( "localSocketMessage", JSON.stringify(data) ); } if (data.status !== "sent") data.status = "queued"; if (indexeddb && data.status === "queued") { indexeddb.send({ method: "object.update", array: "message_log", upsert: true, object: { _id: uid, data, status: "queued" }, organization_id: data.organization_id }); } } else if (!indexeddb && data.status === "queued") this.messageQueue.set(uid, data); } }); }, addListener(uid, resolve) { this.messageEvents[uid] = resolve; if (isBrowser) { const self = this; window.addEventListener( uid, function (event) { delete self.messageEvents[uid]; // here we have access to request and new data resolve(event.detail); }, { once: true } ); } else { process.once(uid, (data) => { delete this.messageEvents[uid]; resolve(data); }); } }, listen(action, callback) { if (!this.listeners.get(action)) { this.listeners.set(action, [callback]); } else { this.listeners.get(action).push(callback); } }, reconnect(config, socket) { let self = this; if (!config.currentReconnectDelay) config.currentReconnectDelay = delay; let url = socket.url; if (isBrowser && !window.navigator.onLine) { const online = () => { window.removeEventListener("online", online); self.delete(url); self.create(config); }; window.addEventListener("online", online); } else { setTimeout(() => { if ( !self.maxReconnectDelay || config.currentReconnectDelay < self.maxReconnectDelay ) { self.delete(url); config.currentReconnectDelay *= 2; self.create(config); } }, config.currentReconnectDelay); } }, async getUrls(data = {}) { let protocol = "wss"; if (isBrowser) { if (location.protocol.startsWith("about:")) { if ( window.parent && window.parent.location.protocol !== "https:" ) { protocol = "ws"; } } else if (location.protocol !== "https:") protocol = "ws"; } let url, urls = [], hostUrls = []; let host = data.host || this.host; let balancer = data.balancer || this.balancer; if (typeof host === "string") { host = host.split(","); for (let i = 0; i < host.length; i++) { host[i] = host[i].trim(); if (host[i][host[i].length - 1] === "/") host[i] = host[i].slice(0, -1); if (host[i].includes("://")) url = `${host[i]}`; else url = `${protocol}://${host[i]}`; url += `/${ data.organization_id || (await this.organization_id()) || "" }`; if (balancer == "mesh") urls.push(url); else { let socket = this.get(url); if (socket !== false) { urls.push(url); break; } else hostUrls.push(url); } } if (!urls.length && hostUrls.length) { for (let i = 0; i < hostUrls.length; i++) { this.delete(hostUrls[i]); urls.push(hostUrls[i]); } } } else if (isBrowser) { let newhost = window.location.host; if (newhost.startsWith("127.0.0.1")) newhost = "cocreate.app"; url = [`${protocol}://${newhost}`]; url += `/${ data.organization_id || (await this.organization_id()) || "" }`; urls.push(url); } else { console.log("missing host"); } return urls; }, async getSockets(data) { let sockets = []; let urls = await this.getUrls(data); for (let url of urls) { let socket = this.get(url); if (!socket) { await this.create(data); socket = this.get(url); if (socket) sockets.push(socket); } else { sockets.push(socket); } } return sockets; }, sendLocalMessage(data) { this.__fireListeners(data); } }; if (isBrowser) { CoCreateSocketClient.init(); window.onstorage = (e) => { if (e.key == "localSocketMessage" && indexeddb && e.newValue) { let data = JSON.parse(e.newValue); CoCreateSocketClient.sendLocalMessage(data); } }; } return CoCreateSocketClient; } );