UNPKG

@toruslabs/broadcast-channel

Version:

A BroadcastChannel that works in New Browsers, Old Browsers, WebWorkers

228 lines (224 loc) 8.14 kB
'use strict'; var eccrypto = require('@toruslabs/eccrypto'); var metadataHelpers = require('@toruslabs/metadata-helpers'); var obliviousSet = require('oblivious-set'); var socket_ioClient = require('socket.io-client'); var options = require('../options.js'); var util = require('../util.js'); /** * A localStorage-only method which uses localstorage and its 'storage'-event * This does not work inside of webworkers because they have no access to locastorage * This is basically implemented to support IE9 or your grandmothers toaster. * @link https://caniuse.com/#feat=namevalue-storage * @link https://caniuse.com/#feat=indexeddb */ const microSeconds = util.microSeconds; const KEY_PREFIX = "pubkey.broadcastChannel-"; const type = "server"; let SOCKET_CONN_INSTANCE = null; // used to decide to reconnect socket e.g. when socket connection is disconnected unexpectedly const runningChannels = new Set(); function storageKey(channelName) { return KEY_PREFIX + channelName; } /** * writes the new message to the storage * and fires the storage-event so other readers can find it */ function postMessage(channelState, messageJson) { return new Promise((resolve, reject) => { util.sleep().then(async () => { const key = storageKey(channelState.channelName); const channelEncPrivKey = metadataHelpers.keccak256(Buffer.from(key, "utf8")); const encData = await metadataHelpers.encryptData(channelEncPrivKey.toString("hex"), { token: util.generateRandomId(), time: Date.now(), data: messageJson, uuid: channelState.uuid }); const body = { allowedOrigin: channelState.server.allowed_origin, sameIpCheck: true, key: eccrypto.getPublic(channelEncPrivKey).toString("hex"), data: encData, signature: (await eccrypto.sign(channelEncPrivKey, metadataHelpers.keccak256(Buffer.from(encData, "utf8")))).toString("hex") }; if (channelState.timeout) body.timeout = channelState.timeout; return fetch(`${channelState.server.api_url}/channel/set`, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json; charset=utf-8" } }).then(resolve).catch(reject); }).catch(reject); }); } function getSocketInstance(socketUrl) { if (SOCKET_CONN_INSTANCE) { return SOCKET_CONN_INSTANCE; } const SOCKET_CONN = socket_ioClient.io(socketUrl, { transports: ["websocket", "polling"], // use WebSocket first, if available withCredentials: true, reconnectionDelayMax: 10000, reconnectionAttempts: 10 }); SOCKET_CONN.on("connect_error", err => { // revert to classic upgrade SOCKET_CONN.io.opts.transports = ["polling", "websocket"]; util.log.error("connect error", err); }); SOCKET_CONN.on("connect", async () => { const { engine } = SOCKET_CONN.io; util.log.debug("initially connected to", engine.transport.name); // in most cases, prints "polling" engine.once("upgrade", () => { // called when the transport is upgraded (i.e. from HTTP long-polling to WebSocket) util.log.debug("upgraded", engine.transport.name); // in most cases, prints "websocket" }); engine.once("close", reason => { // called when the underlying connection is closed util.log.debug("connection closed", reason); }); }); SOCKET_CONN.on("error", err => { util.log.error("socket errored", err); SOCKET_CONN.disconnect(); }); SOCKET_CONN_INSTANCE = SOCKET_CONN; return SOCKET_CONN; } function setupSocketConnection(socketUrl, channelState, fn) { const socketConn = getSocketInstance(socketUrl); const key = storageKey(channelState.channelName); const channelEncPrivKey = metadataHelpers.keccak256(Buffer.from(key, "utf8")); const channelPubKey = eccrypto.getPublic(channelEncPrivKey).toString("hex"); if (socketConn.connected) { socketConn.emit("v2:check_auth_status", channelPubKey, { sameIpCheck: true, allowedOrigin: channelState.server.allowed_origin }); } else { socketConn.once("connect", () => { util.log.debug("connected with socket"); socketConn.emit("v2:check_auth_status", channelPubKey, { sameIpCheck: true, allowedOrigin: channelState.server.allowed_origin }); }); } const reconnect = () => { socketConn.once("connect", async () => { if (runningChannels.has(channelState.channelName)) { socketConn.emit("v2:check_auth_status", channelPubKey, { sameIpCheck: true, allowedOrigin: channelState.server.allowed_origin }); } }); }; const visibilityListener = () => { // if channel is closed, then remove the listener. if (!socketConn || !runningChannels.has(channelState.channelName)) { document.removeEventListener("visibilitychange", visibilityListener); return; } // if not connected, then wait for connection and ping server for latest msg. if (!socketConn.connected && document.visibilityState === "visible") { reconnect(); } }; const listener = async ev => { try { const decData = await metadataHelpers.decryptData(channelEncPrivKey.toString("hex"), ev); util.log.info(decData); fn(decData); } catch (error) { util.log.error(error); } }; socketConn.on("disconnect", () => { util.log.debug("socket disconnected"); if (runningChannels.has(channelState.channelName)) { util.log.error("socket disconnected unexpectedly, reconnecting socket"); reconnect(); } }); socketConn.on(`${channelPubKey}_success`, listener); if (typeof document !== "undefined") document.addEventListener("visibilitychange", visibilityListener); return socketConn; } function removeStorageEventListener() { if (SOCKET_CONN_INSTANCE) { SOCKET_CONN_INSTANCE.disconnect(); } } function canBeUsed() { return true; } function create(channelName, options$1) { options$1 = options.fillOptionsWithDefaults(options$1); const uuid = util.generateRandomId(); /** * eMIs * contains all messages that have been emitted before * @type {ObliviousSet} */ const eMIs = new obliviousSet.ObliviousSet(options$1.server.removeTimeout); const state = { channelName, uuid, eMIs, // emittedMessagesIds server: { api_url: options$1.server.api_url, socket_url: options$1.server.socket_url, allowed_origin: options$1.server.allowed_origin }, time: util.microSeconds() }; if (options$1.server.timeout) state.timeout = options$1.server.timeout; setupSocketConnection(options$1.server.socket_url, state, msgObj => { if (!state.messagesCallback) return; // no listener if (msgObj.uuid === state.uuid) return; // own message if (!msgObj.token || state.eMIs.has(msgObj.token)) return; // already emitted // if (msgObj.data.time && msgObj.data.time < state.messagesCallbackTime) return; // too old state.eMIs.add(msgObj.token); state.messagesCallback(msgObj.data); }); runningChannels.add(channelName); return state; } function close(channelState) { runningChannels.delete(channelState.channelName); // give 2 sec for all msgs which are in transit to be consumed // by receiver. // window.setTimeout(() => { // removeStorageEventListener(channelState); // SOCKET_CONN_INSTANCE = null; // }, 1000); } function onMessage(channelState, fn, time) { channelState.messagesCallbackTime = time; channelState.messagesCallback = fn; } function averageResponseTime() { const defaultTime = 500; // TODO: Maybe increase it based on operation return defaultTime; } exports.averageResponseTime = averageResponseTime; exports.canBeUsed = canBeUsed; exports.close = close; exports.create = create; exports.getSocketInstance = getSocketInstance; exports.microSeconds = microSeconds; exports.onMessage = onMessage; exports.postMessage = postMessage; exports.removeStorageEventListener = removeStorageEventListener; exports.setupSocketConnection = setupSocketConnection; exports.storageKey = storageKey; exports.type = type;