@toruslabs/broadcast-channel
Version:
A BroadcastChannel that works in New Browsers, Old Browsers, WebWorkers
219 lines (212 loc) • 7.7 kB
JavaScript
import { sign, getPublic } from '@toruslabs/eccrypto';
import { keccak256Bytes, utf8ToBytes, encryptData, bytesToHex, decryptData } from '@toruslabs/metadata-helpers';
import { ObliviousSet } from 'oblivious-set';
import { io } from 'socket.io-client';
import { fillOptionsWithDefaults } from '../options.js';
import { microSeconds as microSeconds$1, generateRandomId, log, sleep } from '../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 = microSeconds$1;
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) => {
sleep().then(async () => {
const key = storageKey(channelState.channelName);
const channelEncPrivKey = keccak256Bytes(utf8ToBytes(key));
const encData = await encryptData(channelEncPrivKey, {
token: generateRandomId(),
time: Date.now(),
data: messageJson,
uuid: channelState.uuid
});
const body = {
allowedOrigin: channelState.server.allowed_origin,
sameIpCheck: true,
key: bytesToHex(getPublic(channelEncPrivKey)),
data: encData,
signature: bytesToHex(await sign(channelEncPrivKey, keccak256Bytes(utf8ToBytes(encData))))
};
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 = 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"];
log.error("connect error", err);
});
SOCKET_CONN.on("connect", async () => {
const {
engine
} = SOCKET_CONN.io;
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)
log.debug("upgraded", engine.transport.name); // in most cases, prints "websocket"
});
engine.once("close", reason => {
// called when the underlying connection is closed
log.debug("connection closed", reason);
});
});
SOCKET_CONN.on("error", err => {
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 = keccak256Bytes(utf8ToBytes(key));
const channelPubKey = bytesToHex(getPublic(channelEncPrivKey));
if (socketConn.connected) {
socketConn.emit("v2:check_auth_status", channelPubKey, {
sameIpCheck: true,
allowedOrigin: channelState.server.allowed_origin
});
} else {
socketConn.once("connect", () => {
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 decryptData(channelEncPrivKey, ev);
log.info(decData);
fn(decData);
} catch (error) {
log.error(error);
}
};
socketConn.on("disconnect", () => {
log.debug("socket disconnected");
if (runningChannels.has(channelState.channelName)) {
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) {
options = fillOptionsWithDefaults(options);
const uuid = generateRandomId();
/**
* eMIs
* contains all messages that have been emitted before
* @type {ObliviousSet}
*/
const eMIs = new ObliviousSet(options.server.removeTimeout);
const state = {
channelName,
uuid,
eMIs,
// emittedMessagesIds
server: {
api_url: options.server.api_url,
socket_url: options.server.socket_url,
allowed_origin: options.server.allowed_origin
},
time: microSeconds$1()
};
if (options.server.timeout) state.timeout = options.server.timeout;
setupSocketConnection(options.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;
}
export { averageResponseTime, canBeUsed, close, create, getSocketInstance, microSeconds, onMessage, postMessage, removeStorageEventListener, setupSocketConnection, storageKey, type };