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.
349 lines (304 loc) • 9.96 kB
text/typescript
import { createId } from 'crypto-id';
import { signal, Signal, Writable, writable } from 'easy-signal';
const CONNECTION_TIMEOUT = 5000;
const BASE_RETRY_TIME = 1000;
const MAX_RETRY_BACKOFF = 4;
export interface Client {
deviceId: string;
online: boolean;
connected: boolean;
authed: boolean;
serverTimeOffset: number;
serverVersion: string;
}
export type Unsubscribe = () => void;
export interface ClientAPI<T = {}> {
connect(): Promise<void>;
disconnect(): void;
close(): void;
ping(): Promise<void>;
api: T;
state: Writable<Client>;
send<T = any>(action: string, ...args: [...any[], AbortSignal, GenericFunction]): Promise<T>;
send<T = any>(action: string, ...args: [...any[], GenericFunction]): Promise<T>;
send<T = any>(action: string, ...args: any[]): Promise<T>;
sendAfterAuthed<T = any>(action: string, ...args: [...any[], AbortSignal, GenericFunction]): Promise<T>;
sendAfterAuthed<T = any>(action: string, ...args: [...any[], GenericFunction]): Promise<T>;
sendAfterAuthed<T = any>(action: string, ...args: any[]): Promise<T>;
onMessage: Signal;
auth(idToken?: string): Promise<string>;
pause(pause?: boolean): void;
getNow(): number;
getDate(): Date;
onOpen: Signal<(options: { waitUntil(promise: Promise<any>): void }) => void>;
onClose: Signal<() => void>;
onError: Signal<(error: Error) => void>;
}
export default function createClient<T = {}>(url: string, deviceId: string = createId(), serverTimeOffset = 0): ClientAPI<T> {
const requests = new Map<number, Request>();
const afterConnectedQueue: Array<Request> = [];
const afterAuthedQueue: Array<Request> = [];
const state = writable({
deviceId,
online: globalThis.navigator?.onLine,
connected: false,
authed: false,
serverTimeOffset,
serverVersion: '',
} as Client);
const onMessage = signal();
const onOpen = signal<(options: { waitUntil(promise: Promise<any>): void }) => any>();
const onClose = signal<() => any>();
const onError = signal<(error: Error) => any>();
let socket: WebSocket;
let shouldConnect = false;
let requestNumber = 1;
let retries = 0;
let reconnectTimeout: any;
let connectionTimeout: any;
let closing: any;
let paused: boolean; // use for testing data drop and sync stability/recovery\
let pingDeferred: Deferred = undefined;
globalThis.addEventListener('online', onOnline);
globalThis.addEventListener('offline', onOffline);
function close() {
globalThis.removeEventListener('online', onOnline);
globalThis.removeEventListener('offline', onOffline);
disconnect();
}
function updateData(update: Partial<Client>) {
const obj = state.get();
if (!Object.entries(update).some(([key, value]) => obj[key] !== value)) {
return; // Nothing actually changed
}
state.set(({ ...obj, ...update }));
}
function connect(): Promise<void> {
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: ErrorEvent) => {
onError(event.error);
reject();
closeSocket();
};
socket.onclose = () => {
clearTimeout(closing);
closing = null;
socket.onclose = null;
(socket as any) = 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: any;
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: Promise<any>) => {
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() as Request;
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 as any)();
}
function ping() {
if (pingDeferred) pingDeferred.reject();
return new Promise<void>((resolve, reject) => {
pingDeferred = { resolve, reject };
socket.send('ping');
});
}
function send<T = any>(action: string, ...args: any[]): Promise<T>;
async function send(action: string, ...args: any[]): Promise<any> {
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<void>((resolve, reject) => {
let onMessage: GenericFunction, abortSignal: 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: string, ...args: any[]): Promise<any> {
if (state.get().authed) return send(action, ...args);
return new Promise((resolve, reject) => {
afterAuthedQueue.push({ action, args, resolve, reject });
});
}
async function auth(idToken?: string) {
const uid = await send('auth', idToken);
updateData({ authed: !!uid });
while (afterAuthedQueue.length) {
const { action, args, resolve, reject } = afterAuthedQueue.shift() as Request;
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: any, name?: string) {
return new Proxy(target, {
apply: (_, __, args) => send(name, ...args),
get: (obj, prop: string) =>
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,
};
}
type GenericFunction = (...args: any[]) => any;
interface Deferred {
resolve(value?: unknown): void;
reject(reason?: any): void;
}
interface Request extends Deferred {
action: string;
args: any[];
onMessage?: GenericFunction;
}