UNPKG

socio

Version:

A WebSocket Real-Time Communication (RTC) API framework.

709 lines (708 loc) 31.7 kB
import pako from 'pako'; import * as diff_lib from 'recursive-diff'; import { LogHandler, E } from './logging.js'; import { socio_decode, socio_encode, clamp, ServerMessageKind, ClientMessageKind, initLifecycleHooks } from './utils.js'; import { ErrorOrigin } from './logging.js'; export class SocioClient extends LogHandler { #ws = null; #client_id = ''; #latency = 0; #ready_state = new ReadyState(); #authenticated = false; #queries = new Map(); #props = new Map(); static #key = 1; config; key_generator; lifecycle_hooks; rpc_dict = {}; constructor({ url, name, logging = { verbose: false, hard_crash: false }, keep_alive = true, reconnect_tries = 1, persistent = false, hooks = {}, allow_rpc = false, } = {}) { super({ ...logging, prefix: name ? `SocioClient:${name}` : 'SocioClient' }); this.config = { url, name, logging, keep_alive, reconnect_tries, persistent, allow_rpc }; this.lifecycle_hooks = { ...initLifecycleHooks(), ...hooks }; if (url) { this.Connect(); } } Connect({ url = this.config?.url, keep_alive = this.config?.keep_alive || false, reconnect_tries = this.config?.reconnect_tries || 0 } = {}) { if (!url) throw new E('Must provide a WebSocket URL to connect to! [#no-url]'); if (this.#ws && this.#ws.readyState === WebSocket.OPEN) throw new E('Socio WebSocket is already connected! Please disconnect first before connecting again or create a new instance. [#already-connected]'); this.#latency = (new Date()).getTime(); this.#connect(url, keep_alive, this.verbose || false, reconnect_tries); if (this.verbose) { if (window && url.startsWith('ws://')) this.HandleInfo('WARNING, UNSECURE WEBSOCKET URL CONNECTION! Please use wss:// and https:// protocols in production to protect against man-in-the-middle attacks. You need to host an https server with bought SCTs - Signed Certificate Timestamps (keys) - from an authority.'); } return this.ready(); } async #connect(url, keep_alive, verbose, reconnect_tries) { this.#ws = new WebSocket(url); this.#ws.binaryType = 'arraybuffer'; this.#ws.addEventListener('message', this.#message.bind(this)); if (keep_alive && reconnect_tries) { this.#ws.addEventListener("close", (event) => { this.#RetryConn(url, keep_alive, verbose, reconnect_tries, event); }); this.#ws.addEventListener("error", (event) => { this.#RetryConn(url, keep_alive, verbose, reconnect_tries, event); }); } else { const notify = (event) => { if (event instanceof CloseEvent) this.HandleInfo('Connection closed.'); else this.#HandleClientError(new E(`Socio failed to connect [${url}]`, event)); if (this.lifecycle_hooks.discon) this.lifecycle_hooks.discon(this, url, keep_alive, verbose, reconnect_tries, event); }; this.#ws.addEventListener("close", notify); this.#ws.addEventListener("error", notify); } } #RetryConn(url, keep_alive, verbose, reconnect_tries, event) { this.#HandleClientError(new E(`"${this.config.name || ''}" WebSocket closed. Retrying... Event details:`, event)); this.#resetConn(); this.#connect(url, keep_alive, verbose, reconnect_tries - 1); if (this.lifecycle_hooks.discon) this.lifecycle_hooks.discon(this, url, keep_alive, verbose, reconnect_tries - 1, event); } #resetConn() { this.#client_id = ''; this.#ws = null; this.#latency = Infinity; this.#ready_state = new ReadyState(); this.#authenticated = false; this.#queries.clear(); this.#props.clear(); } async #message(event) { try { const { kind, data } = socio_decode(new Uint8Array(event.data)); this.HandleInfo('recv:', ClientMessageKind[kind], data); if (typeof data.id === 'number' && typeof SocioClient.#key === 'number' && data.id > SocioClient.#key) SocioClient.#key = data.id; if (this.lifecycle_hooks.msg) if (await this.lifecycle_hooks.msg(this, kind, data)) return; switch (kind) { case ClientMessageKind.CON: { this.#client_id = data; this.#latency = (new Date()).getTime() - this.#latency; let successful_recon = false; if (this.config.persistent) { successful_recon = await this.#TryReconnect(); await this.#GetReconToken(); } this.#ready_state.resolve(true); if (this.verbose) this.done(`Socio WebSocket [${this.config?.name || this.#client_id || 'NAME'}] connected.`); if (this.config?.name && successful_recon !== true) this.IdentifySelf(this.config.name); break; } case ClientMessageKind.UPD: { this.#FindID(kind, data.id); const q = this.#queries.get(data.id); if (data.result.success === 1 && q.onUpdate.success) { q.onUpdate.success((data.result.res)); } else { if (q.onUpdate?.error) { q.onUpdate.error(data.result.error); } else { throw new E('Subscription query doesnt handle incoming server error:', data.result.error); } } break; } case ClientMessageKind.PONG: { this.#FindID(kind, data?.id); this.HandleInfo('pong', data?.id); break; } case ClientMessageKind.AUTH: { this.#FindID(kind, data?.id); if (data?.result?.success !== 1) this.HandleInfo(`AUTH returned FALSE, which means websocket has not authenticated.`); this.#authenticated = data.result.success === 1; this.#queries.get(data.id).res(this.#authenticated); this.#queries.delete(data.id); break; } case ClientMessageKind.GET_PERM: { this.#FindID(kind, data?.id); if (data?.result?.success !== 1) this.HandleInfo(`Server rejected grant perm for ${data?.verb} on ${data?.table}.`); else { const q = this.#queries.get(data.id); q.res(data?.result.success === 1 ? true : false); } this.#queries.delete(data.id); break; } case ClientMessageKind.RES: { this.#HandleBasicPromiseMessage(kind, data); break; } case ClientMessageKind.PROP_UPD: { if (data.hasOwnProperty('prop') && data.hasOwnProperty('id') && (data.hasOwnProperty('prop_val') || data.hasOwnProperty('prop_val_diff'))) { const prop = this.#props.get(data.prop); let prop_val; if (prop) { if (data.hasOwnProperty('prop_val')) { prop_val = data.prop_val; } else if (data.hasOwnProperty('prop_val_diff')) { prop_val = diff_lib.applyDiff(prop.val, data.prop_val_diff); } else throw new E('Prop upd data didnt have either factual val or diff val to use. [#prop-upd-no-val]', { kind, data }); prop.val = prop_val; for (const callback of Object.values(prop.subs)) { if (callback !== null) { callback(prop.val, data?.prop_val_diff || undefined); } } } else { throw new E('Prop not found by name. [#prop-name-not-found]', { data, prop_name: data.prop }); } } else throw new E('Not enough prop info sent from server to perform prop update.', { data: data }); break; } case ClientMessageKind.PROP_DROP: { if (data?.prop && data.hasOwnProperty('id')) { if (this.#props.has(data.prop)) { const prop = this.#props.get(data.prop); if (prop) { prop.subs = {}; this.#props.delete(data.prop); } if (this.lifecycle_hooks.prop_drop) this.lifecycle_hooks.prop_drop(this, data.prop, data.id); } else throw new E('Cant drop unsubbed prop!', data); } else throw new E('Not enough prop info sent from server to perform prop drop.', data); break; } case ClientMessageKind.CMD: { if (this.lifecycle_hooks.cmd) this.lifecycle_hooks.cmd(data); break; } case ClientMessageKind.RECON: { if (data?.id) { this.#FindID(kind, data.id); this.#queries.get(data.id).res(data); this.#queries.delete(data.id); } else this.#Reconnect(data); break; } case ClientMessageKind.RECV_FILES: { this.#FindID(kind, data?.id); let resolve_with = null; let error = null; if (data.result.success === 1) { if (data?.files) { resolve_with = ParseSocioFiles(data.files); } else { resolve_with = null; error = 'Received 0 files. Something must\'ve gone wrong, bcs success was true. [#recv-0-files]'; } } else { resolve_with = null; error = 'File receive bad result. [#recv-files-bad]'; } ; this.#queries.get(data.id)?.res(resolve_with); this.#queries.delete(data.id); if (error) { const file_count = Object.keys(data?.files || {}).length; this.#HandleServerError(error, data?.result?.error, 'files received: ' + file_count); throw new E(error, { err_msg: data.result.error, file_count }); } break; } case ClientMessageKind.TIMEOUT: { if (this.lifecycle_hooks.timeout) this.lifecycle_hooks.timeout(this); break; } case ClientMessageKind.RPC: { if (this.config.allow_rpc !== true) { this.HandleDebug('Received RPC, but the client hasnt enabled it. [#rpc-client-not-enabled]', data); return; } if (this.lifecycle_hooks.rpc) { const res = await this.lifecycle_hooks.rpc(this, data.origin_client, data.f_name, data.args); if (res !== undefined) { this.Send(ServerMessageKind.OK, { id: data.id, return: res }); return; } } let result = undefined; if (data.f_name in this.rpc_dict) result = await this.rpc_dict[data.f_name](data.origin_client, ...data.args); else if (data.target_client === null && data.f_name in this) result = await this[data.f_name](...data.args); else this.HandleDebug('Received RPC, but the function name doesnt exist on this client. [#rpc-client-no-function]', data); this.Send(ServerMessageKind.OK, { id: data.id, return: result }); break; } default: { const exhaustiveCheck = kind; throw new E(`Unrecognized message kind!`, { kind, data }); } } } catch (e) { this.#HandleClientError(e); } } Send(kind, ...data) { try { if (data.length < 1) throw new E('Not enough arguments to send data! kind;data:', kind, ...data); this.#ws?.send(socio_encode(Object.assign({}, { kind, data: data[0] }, ...data.slice(1)))); this.HandleInfo('sent:', ServerMessageKind[kind], ...data); } catch (e) { this.#HandleClientError(e); } } SendFiles(files, other_data = undefined) { const { id, prom } = this.CreateQueryPromise(); (async () => { const proc_files = new Map(); for (const file of files) { const meta = { lastModified: file.lastModified, size: file.size, type: file.type }; const file_bytes_buffer = await file.arrayBuffer(); proc_files.set(file.name, { meta, bin: pako.deflate(file_bytes_buffer) }); } const socio_form_data = { id, files: Object.fromEntries(proc_files) }; if (other_data) socio_form_data['data'] = other_data; this.Send(ServerMessageKind.UP_FILES, socio_form_data); this.#UpdateQueryPromisePayloadSize(id); })(); return prom; } SendBinary(blob) { if (this.#queries.get('BLOB')) throw new E('BLOB already being uploaded. Wait until the last query completes!'); const start_buff = this.#ws?.bufferedAmount || 0; this.#ws?.send(blob); this.HandleInfo('sent: BLOB'); const prom = new Promise((res) => { this.#queries.set('BLOB', { res, prom, start_buff, payload_size: (this.#ws?.bufferedAmount || 0) - start_buff, full_meta: false }); }); return prom; } CreateQueryPromise({ full_meta = false } = {}) { const id = this.GenKey; const prom = new Promise((res) => { this.#queries.set(id, { res, prom: null, start_buff: this.#ws?.bufferedAmount || 0, full_meta }); }); this.#queries.get(id).prom = prom; return { id, prom }; } #UpdateQueryPromisePayloadSize(query_id) { if (!this.#queries.has(query_id)) return; this.#queries.get(query_id).payload_size = (this.#ws?.bufferedAmount || 0) - this.#queries.get(query_id)?.start_buff || 0; } Serv(data) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.SERV, { id, data }); this.#UpdateQueryPromisePayloadSize(id); return prom; } GetFiles(data) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.GET_FILES, { id, data }); this.#UpdateQueryPromisePayloadSize(id); return prom; } Ping(id_num = undefined) { this.Send(ServerMessageKind.PING, { id: typeof id_num === 'number' ? id_num : this.GenKey }); } async DiscoverSessions(by = 'ID') { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.DISCOVERY, { id }); let clients = await prom; if (by === 'NAME') { return Object.fromEntries(Object.entries(clients).map(([id, meta]) => [ meta?.name ?? id, { ...meta, id }, ])); } else if (by === 'AS_ARRAY') { return Object.entries(clients).map(([id, meta]) => ({ ...meta, id })); } else { return clients; } } UnsubscribeAll({ props = true, queries = true, force = false } = {}) { if (props) for (const p of [...this.#props.keys()]) this.UnsubscribeProp(p, force); if (queries) for (const q of [...this.#queries.keys()]) this.Unsubscribe(q, force); } IdentifySelf(name) { if (!name) throw new E('Must provide a unique string name to indetify this session globally.'); const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.IDENTIFY, { id, name }); return prom; } Subscribe({ sql = undefined, endpoint = undefined, params = null } = {}, onUpdate = null, status_callbacks = {}, rate_limit = null) { if (sql && endpoint) throw new E('Can only subscribe to either literal SQL query string or endpoint keyname, not both!'); if (typeof onUpdate !== "function") throw new E('Subscription onUpdate is not function, but has to be.'); if (status_callbacks?.error && typeof status_callbacks.error !== "function") throw new E('Subscription error is not function, but has to be.'); try { const id = this.GenKey; const callbacks = { success: onUpdate, ...status_callbacks }; this.#queries.set(id, { sql, endpoint, params, onUpdate: callbacks }); this.Send(ServerMessageKind.SUB, { id, sql, endpoint, params, rate_limit }); return id; } catch (e) { this.#HandleClientError(e); return null; } } async Unsubscribe(sub_id, force = false) { try { if (this.#queries.has(sub_id)) { if (force) this.#queries.delete(sub_id); const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.UNSUB, { id, unreg_id: sub_id }); const res = await prom; if (res === 1) this.#queries.delete(sub_id); return res; } else throw new E('Cannot unsubscribe query, because provided ID is not currently tracked.', sub_id); } catch (e) { this.#HandleClientError(e); return false; } } Query(sql, params = null, { sql_is_endpoint = undefined, onUpdate, freq_ms = undefined } = {}) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.SQL, { id, sql, params, sql_is_endpoint }); this.#UpdateQueryPromisePayloadSize(id); if (onUpdate) this.TrackProgressOfQueryID(id, onUpdate, freq_ms); return prom; } async SetProp(prop_name, new_val, prop_upd_as_diff) { try { const { id, prom } = this.CreateQueryPromise({ full_meta: true }); this.Send(ServerMessageKind.PROP_SET, { id, prop: prop_name, prop_val: new_val, prop_upd_as_diff }); this.#UpdateQueryPromisePayloadSize(id); const res = await prom; if (res?.result?.success === 1) { const prop = this.#props.get(prop_name); if (prop) { prop.val = new_val; } } return res; } catch (e) { this.#HandleClientError(e); return null; } } GetProp(prop_name, local = false) { if (local) return { result: { success: 1, res: this.#props.get(prop_name)?.val } }; else { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.PROP_GET, { id, prop: prop_name }); this.#UpdateQueryPromisePayloadSize(id); return prom; } } SubscribeProp(prop_name, onUpdate, { rate_limit = null, receive_initial_update = true } = {}) { if (typeof onUpdate !== "function") throw new E('Subscription onUpdate is not function, but has to be.'); try { const prop = this.#props.get(prop_name); if (prop) { const id = this.GenKey; prop.subs[id] = onUpdate; return Promise.resolve({ id, result: { success: 1, res: prop.val } }); } else { const { id, prom } = this.CreateQueryPromise(); this.#props.set(prop_name, { val: undefined, subs: { [id]: onUpdate } }); this.Send(ServerMessageKind.PROP_SUB, { id, prop: prop_name, rate_limit, data: { receive_initial_update } }); return prom; } } catch (e) { this.#HandleClientError(e); return Promise.resolve({ result: { success: 0 } }); } } async UnsubscribeProp(prop_name, force = false) { try { if (this.#props.get(prop_name)) { if (force) this.#props.delete(prop_name); const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.PROP_UNSUB, { id, prop: prop_name }); const res = await prom; if (res === 1) this.#props.delete(prop_name); return res; } else throw new E('Cannot unsubscribe query, because provided prop_name is not currently tracked.', prop_name); } catch (e) { this.#HandleClientError(e); return false; } } RegisterProp(prop_name, initial_value = null, prop_reg_opts = {}) { try { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.PROP_REG, { id, prop: prop_name, initial_value, opts: prop_reg_opts }); this.#UpdateQueryPromisePayloadSize(id); return prom; } catch (e) { this.#HandleClientError(e); return null; } } async Prop(prop_name, { prop_sub_opts = {}, prop_upd_as_diff = false } = {}) { const prop = await this.GetProp(prop_name, false); if (prop === undefined) { this.#HandleClientError(new E(`Couldnt retrieve server prop [${prop_name}]`, { prop_name, prop })); return undefined; } if (typeof prop !== 'object') { this.#HandleClientError(new E(`Can only proxy js objects, but [${prop_name}] is not an object.`, { prop_name, prop })); return undefined; } let from_sub = false; const LocalSetProp = this.SetProp.bind(this); const prop_proxy = new Proxy(prop, { get(p, property) { return p[property]; }, set(p, property, new_val) { p[property] = new_val; if (from_sub !== true) LocalSetProp(prop_name, p, prop_upd_as_diff); return true; } }); await this.SubscribeProp(prop_name, (new_val) => { from_sub = true; for (const [key, val] of Object.entries(new_val)) prop_proxy[key] = val; from_sub = false; }, prop_sub_opts); return prop_proxy; } Authenticate(params = {}) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.AUTH, { id, params }); this.#UpdateQueryPromisePayloadSize(id); return prom; } get authenticated() { return this.#authenticated === true; } AskPermission(verb = '', table = '') { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.GET_PERM, { id, verb: verb, table: table }); this.#UpdateQueryPromisePayloadSize(id); return prom; } async RPC(target_client, f_name, ...args) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.RPC, { ...{ target_client, origin_client: this.client_id, f_name, args }, id }); return await prom; } #FindID(kind, id) { if (!this.#queries.has(id)) throw new E(`A received socio message [querry_id ${id}, ${ClientMessageKind[kind]}] is not currently in tracked queries!`); } #HandleBasicPromiseMessage(kind, data) { this.#FindID(kind, data?.id); const q = this.#queries.get(data.id); if (q.hasOwnProperty('res')) { q.res(q?.full_meta ? data : data?.result?.res); if (data.result.success !== 1) { this.#HandleServerError(data.result?.error); } } else if (q.hasOwnProperty('onUpdate')) if (data.result.success === 1) { if (q.onUpdate?.success) q.onUpdate.success(data.result.res); } else { if (q.onUpdate?.error) q.onUpdate.error(data.result.error); else this.#HandleServerError(``, data.result?.error); } this.#queries.delete(data.id); } #HandleServerError(...error_msgs) { if (this.lifecycle_hooks.server_error) this.lifecycle_hooks.server_error(this, error_msgs); else this.HandleError(new E(...error_msgs), ErrorOrigin.SERVER); } #HandleClientError(...error_msgs) { this.HandleError(new E(...error_msgs), ErrorOrigin.CLIENT); } get GenKey() { return this?.key_generator ? this.key_generator() : ++SocioClient.#key; } get client_id() { return this.#client_id; } get web_socket() { return this.#ws; } get client_address_info() { return { url: this.#ws?.url, protocol: this.#ws?.protocol, extensions: this.#ws?.extensions }; } get latency() { return this.#latency; } ready() { return this.#ready_state.promise; } Close() { this.#ws?.close(); } get is_ready() { return this.#ready_state.is_ready === true; } async #GetReconToken(name = this.config.name) { const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.RECON, { id, type: 'GET' }); const token = await prom; localStorage.setItem(`Socio_recon_token_${name}`, token); } RefreshReconToken(name = this.config.name) { return this.#GetReconToken(name); } async #TryReconnect(name = this.config.name) { const key = `Socio_recon_token_${name}`; const token = localStorage.getItem(key); if (token) { localStorage.removeItem(key); const { id, prom } = this.CreateQueryPromise(); this.Send(ServerMessageKind.RECON, { id, type: 'USE', token }); const res = await prom; this.#Reconnect(res); return res.result.success === 1; } else return false; } #Reconnect(data) { if (data.result.success === 1) { this.#authenticated = data.auth; this.config.name = data.name; this.done(`${this.config.name} reconnected successfully. ${data.old_client_id} -> ${this.#client_id} (old client ID -> new/current client ID)`, data); } else { const error = 'Failed to reconnect'; this.#HandleClientError(new E(error, data)); this.#HandleServerError(error, data.result?.error); } } LogMaps() { this.debug('queries', [...this.#queries.entries()]); this.debug('props', [...this.#props.entries()]); } TrackProgressOfQueryPromise(prom, onUpdate, freq_ms = 33.34) { for (const [id, q] of this.#queries) { if (q?.prom == prom) { return this.#CreateProgTrackingTimer(id, q.start_buff, q.payload_size || 0, onUpdate, freq_ms); } } return null; } TrackProgressOfQueryID(query_id, onUpdate, freq_ms = 33.34) { const q = this.#queries.get(query_id); if (q) return this.#CreateProgTrackingTimer(query_id, q.start_buff, q.payload_size || 0, onUpdate, freq_ms); else return null; } #CreateProgTrackingTimer(query_id, start_buff, payload_size, onUpdate, freq_ms = 33.34) { let last_buff_size = this.#ws?.bufferedAmount || 0; const intervalID = setInterval(() => { if (!payload_size) { payload_size = this.#queries.get(query_id)?.payload_size || 0; if (!payload_size) return; last_buff_size = this.#ws?.bufferedAmount || 0; } const later_payload_ids = Array.from(this.#queries.keys()).filter(id => id > query_id); const later_payloads_size = later_payload_ids.map(p_id => this.#queries.get(p_id)?.payload_size || 0).reduce((sum, payload) => sum += payload, 0); const now_buff_size = (this.#ws?.bufferedAmount || 0) - later_payloads_size; const delta_buff = (last_buff_size - now_buff_size) || 1_000; last_buff_size = now_buff_size; start_buff -= delta_buff; const p = (start_buff * -100) / payload_size; onUpdate(clamp(p, 0, 100)); if (p >= 100 || (this.#ws?.bufferedAmount || 0) === 0) { onUpdate(100); clearInterval(intervalID); } }, freq_ms); return intervalID; } } export function ParseSocioFiles(files) { if (!files) return []; const files_array = []; const entries = files instanceof Map ? files.entries() : Object.entries(files); for (const [filename, file_data] of entries) files_array.push(new File([pako.inflate(file_data.bin)], filename, { type: file_data.meta.type, lastModified: file_data.meta.lastModified })); return files_array; } export function SocioFileBase64ToUint8Array(base64 = '') { return pako.inflate(Uint8Array.from(window.atob(base64), (v) => v.charCodeAt(0))); } export function Uint8ArrayToSocioFileBase64(file_bin, chunkSize = 0x8000) { const compressedData = pako.deflate(file_bin); let binaryString = ''; for (let i = 0; i < compressedData.length; i += chunkSize) { const chunk = compressedData.subarray(i, i + chunkSize); binaryString += String.fromCharCode(...chunk); } return window.btoa(binaryString); } class ReadyState { promise; #resolve; is_ready = false; constructor() { this.promise = new Promise(res => { this.#resolve = res; }); } resolve(value) { this.is_ready = true; this.#resolve(value); } }