websocket-rpc-protocol
Version:
A JSON RPC protocol for working over websockets. Sheds the weight of JSON RPC to simplify argument names and adds features.
281 lines • 9.83 kB
JavaScript
import { createId } from 'crypto-id';
import { signal, writable } from 'easy-signal';
const CONNECTION_TIMEOUT = 5000;
const BASE_RETRY_TIME = 1000;
const MAX_RETRY_BACKOFF = 4;
export default function createClient(url, deviceId = createId(), serverTimeOffset = 0) {
const requests = new Map();
const afterConnectedQueue = [];
const afterAuthedQueue = [];
const state = writable({
deviceId,
online: globalThis.navigator?.onLine,
connected: false,
authed: false,
serverTimeOffset,
serverVersion: '',
});
const onMessage = signal();
const onOpen = signal();
const onClose = signal();
const onError = signal();
let socket;
let shouldConnect = false;
let requestNumber = 1;
let retries = 0;
let reconnectTimeout;
let connectionTimeout;
let closing;
let paused; // use for testing data drop and sync stability/recovery\
let pingDeferred = undefined;
globalThis.addEventListener('online', onOnline);
globalThis.addEventListener('offline', onOffline);
function close() {
globalThis.removeEventListener('online', onOnline);
globalThis.removeEventListener('offline', onOffline);
disconnect();
}
function updateData(update) {
const obj = state.get();
if (!Object.entries(update).some(([key, value]) => obj[key] !== value)) {
return; // Nothing actually changed
}
state.set(({ ...obj, ...update }));
}
function connect() {
clearTimeout(reconnectTimeout);
clearTimeout(connectionTimeout);
return new Promise((resolve, reject) => {
shouldConnect = true;
if (!state.get().online) {
return reject(new Error('offline'));
}
else if (socket && socket.readyState <= WebSocket.OPEN && state.get().connected) {
return;
}
try {
socket = new WebSocket(url);
connectionTimeout = setTimeout(() => {
if (socket && socket.readyState >= WebSocket.CLOSING) {
socket.close();
}
}, CONNECTION_TIMEOUT);
}
catch (err) {
reject(err);
}
socket.onerror = (event) => {
onError(event.error);
reject();
closeSocket();
};
socket.onclose = () => {
clearTimeout(closing);
closing = null;
socket.onclose = null;
socket = null;
if (state.get().connected) {
updateData({ connected: false, authed: false });
}
onClose();
requests.forEach((request, key) => {
request.reject(new Error('CONNECTION_CLOSED'));
requests.delete(key);
});
if (shouldConnect && state.get().online) {
const backoff = Math.round(Math.random() * (Math.pow(2, retries) - 1) * BASE_RETRY_TIME);
retries = Math.min(MAX_RETRY_BACKOFF, retries + 1);
reconnectTimeout = setTimeout(() => {
connect().catch(err => { });
}, backoff);
}
};
socket.onmessage = async (event) => {
if (event.data === 'pong') {
pingDeferred?.resolve();
pingDeferred = undefined;
return;
}
if (paused)
return;
let data;
try {
data = JSON.parse(event.data);
}
catch (err) {
console.error('Unparseable data from socket:', event.data);
return;
}
if (data.ts) {
// Connected!
clearTimeout(connectionTimeout);
retries = 0;
const serverTimeOffset = data.ts - Date.now();
const serverVersion = data.v;
const promises = [];
const options = {
waitUntil: (promise) => {
promises.push(promise);
},
};
onOpen(options);
if (promises.length)
await Promise.all(promises);
updateData({ connected: true, serverTimeOffset, serverVersion });
while (afterConnectedQueue.length) {
const { action, args, resolve, reject } = afterConnectedQueue.shift();
send(action, ...args).then(resolve, reject);
}
resolve();
return;
}
if (data.p) {
onMessage(data.d);
return;
}
const request = requests.get(data.r);
if (!request)
return; // for now
if (data.err) {
console.log('Error with send', request.action, request.args);
request.reject(new Error(data.err));
}
else {
if (data.s) {
if (request.onMessage)
request.onMessage(data.d);
}
else {
request.resolve(data.d);
}
}
};
});
}
function disconnect() {
shouldConnect = false;
clearTimeout(reconnectTimeout);
clearTimeout(connectionTimeout);
closeSocket();
}
function pause(pause = true) {
paused = pause;
}
function closeSocket() {
if (!socket)
return;
updateData({ connected: false, authed: false });
socket.close(1000);
if (socket)
socket.onclose();
}
function ping() {
if (pingDeferred)
pingDeferred.reject();
return new Promise((resolve, reject) => {
pingDeferred = { resolve, reject };
socket.send('ping');
});
}
async function send(action, ...args) {
if (!socket || socket.readyState > 1 || closing) {
return Promise.reject(new Error('CONNECTION_CLOSED'));
}
else if (socket.readyState === WebSocket.CONNECTING) {
return new Promise((resolve, reject) => {
afterConnectedQueue.push({ action, args, resolve, reject });
});
}
while (args.length && args[args.length - 1] === undefined)
args.pop();
const r = requestNumber++;
return new Promise((resolve, reject) => {
let onMessage, abortSignal;
if (typeof args[args.length - 1] === 'function') {
onMessage = args.pop();
if (args[args.length - 1] instanceof AbortSignal) {
abortSignal = args.pop();
abortSignal.onabort = () => {
try {
if (abortSignal.reason)
reject(abortSignal.reason);
else
resolve();
send('_abort', r);
}
catch (err) { }
};
}
}
requests.set(r, { action, args, resolve, reject, onMessage });
try {
socket.send(JSON.stringify({ r, a: action, d: args.length ? args : undefined }));
}
catch (err) {
console.error('Exception thrown from WebSocket.send():', err.message, 'Closing connection.');
}
}).finally(() => {
requests.delete(r);
if (closing && !requests.size && socket) {
closeSocket();
}
});
}
function sendAfterAuthed(action, ...args) {
if (state.get().authed)
return send(action, ...args);
return new Promise((resolve, reject) => {
afterAuthedQueue.push({ action, args, resolve, reject });
});
}
async function auth(idToken) {
const uid = await send('auth', idToken);
updateData({ authed: !!uid });
while (afterAuthedQueue.length) {
const { action, args, resolve, reject } = afterAuthedQueue.shift();
send(action, ...args).then(resolve, reject);
}
return uid;
}
function getNow() {
return Date.now() + state.get().serverTimeOffset;
}
function getDate() {
return new Date(getNow());
}
function onOnline() {
updateData({ online: true });
if (shouldConnect) {
connect().catch(err => { });
}
}
function onOffline() {
updateData({ online: false });
closeSocket();
}
function proxy(target, name) {
return new Proxy(target, {
apply: (_, __, args) => send(name, ...args),
get: (obj, prop) => prop in obj ? obj[prop] : (obj[prop] = proxy(() => { }, name ? `${name}.${prop}` : prop)),
});
}
return {
api: proxy({}),
state,
connect,
disconnect,
close,
ping,
pause,
send,
sendAfterAuthed,
auth,
getNow,
getDate,
onMessage,
onOpen,
onClose,
onError,
};
}
//# sourceMappingURL=client.js.map