@toruslabs/broadcast-channel
Version:
A BroadcastChannel that works in New Browsers, Old Browsers, WebWorkers
228 lines (224 loc) • 8.14 kB
JavaScript
;
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;