UNPKG

wspromisify

Version:

Wraps your WebSockets into Promise-based class with full d.ts typings on client & server

690 lines (680 loc) 26.3 kB
'use strict'; const __ = Symbol('Placeholder'); const countArgs = (s) => { let i = 0; for (const v of s) v !== __ && i++; return i; }; // TODO: try to make it mutable. // { 0: __, 1: 10 }, [ 11 ] const addArgs = (args, _args) => { const len = args.length; const new_args = args.slice(); const _args_len = _args.length; let _args_left = _args_len; let i = 0; for (; _args_left && i < len; i++) { if (new_args[i] === __) { new_args[i] = _args[_args_len - _args_left]; _args_left--; } } for (i = len; _args_left; i++, _args_left--) { new_args[i] = _args[_args_len - _args_left]; } return new_args; }; const _curry = (fn, args, new_args) => { const args2add = fn.length - args.length - countArgs(new_args); if (args2add < 1) { return fn(...addArgs(args, new_args)); } else { const curried = (...__args) => _curry(fn, addArgs(args, new_args), __args); curried.$args_left = args2add; return curried; } }; const curry = (fn) => ((...args) => fn.length > countArgs(args) ? _curry(fn, [], args) : fn(...args)); const endlessph = (fn) => { function _endlessph(a) { return a === __ ? fn : fn(a); } return _endlessph; }; const zero = 0; function curry2(fn) { function curried2(a, ...args) { return args.length > zero ? a === __ ? endlessph((a) => fn(a, args[zero])) : fn(a, args[zero]) : (b) => fn(a, b); } return curried2; } function curry3(fn) { // type p0 = Parameters<Func>[0] // type p1 = Parameters<Func>[1] // type p2 = Parameters<Func>[2] // type ReturnT = ReturnType<Func> // TODO: optimize. // Cannot use ts-toolbelt due to this error: // Excessive stack depth comparing types 'GapsOf<?, L2>' and 'GapsOf<?, L2>' return curry(fn); } const length = (s) => s.length; const is_typed_arr = (x) => ArrayBuffer.isView(x); const unsafe_props = { '__proto__': true, 'constructor': true, 'prototype': true }; const undef = undefined; const nul = null; const inf$1 = Infinity; const to = (s) => typeof s; const isNull$1 = (s) => (s === nul); const isUndef = (s) => (s === undef); const isNum = (s) => (to(s) == 'number'); const isNil = (s) => (isNull$1(s) || isUndef(s)); const isSafe = (prop) => !(prop in unsafe_props); const { isNaN } = Number; // It's faster that toUpperCase() ! const caseMap = { u: 'U', b: 'B', n: 'N', s: 'S', f: 'F', o: 'O' }; const symbol = Symbol(); const cap_type = (t) => caseMap[t[0]] + t.slice(1); const type = (s) => { const t = to(s); return t === 'object' ? isNull$1(s) ? 'Null' : (s.constructor?.name || cap_type(t)) : t === 'number' && isNaN(s) ? 'NaN' : cap_type(t); }; const typeIs = curry2((t, s) => type(s) === t); const eq = curry2((a, b) => a === b); const equals = curry2((a, b) => { if (a === b) return true; const typea = type(a); const ta = is_typed_arr(a); if (eq(typea, type(b)) && (eq(typea, 'Object') || eq(typea, 'Array') || ta)) { if (ta) { if (typea === 'Buffer') return a.equals(b); const len = length(a); if (len !== length(b)) return false; for (let i = 0; i < len; i++) if (a[i] !== b[i]) return false; return true; } if (isNull$1(a) || isNull$1(b)) return eq(a, b); for (const v of [a, b]) for (const k in v) if (!(v === b && (k in a)) && !(v === a && (k in b) && equals(a[k], b[k]))) return false; return true; } return false; }); const always = (s) => () => s; const identity = (s) => s; const z$1 = 0; /* qflat, qflatShallow, qreduceAsync */ const qappend = curry2((s, xs) => { xs.push(s); return xs; }); const qreduce = curry3((fn, accum, arr) => arr.reduce(fn, accum)); // strategy is for arrays: 1->replace, 2->merge, 3->push. const mergeDeep$1 = (strategy) => curry2((o1, o2) => { for (let k in o2) { if (isSafe(k)) switch (type(o2[k])) { case 'Array': if (strategy > 1 && type(o1[k]) === 'Array') switch (strategy) { case 2: const o1k = o1[k], o2k = o2[k]; for (const i in o2k) if (o1k[i]) mergeDeep$1(strategy)(o1k[i], o2k[i]); else o1k[i] = o2k[i]; break; case 3: o1[k].push(...o2[k]); } else o1[k] = o2[k]; break; case 'Object': if (type(o1[k]) === 'Object') { mergeDeep$1(strategy)(o1[k], o2[k]); break; } default: o1[k] = o2[k]; break; } } return o1; }); const qmergeDeep = mergeDeep$1(1); /** Should be faster than .splice() 'cause does not make a new array. */ const rmel = (index, xs) => { const len = length(xs); for (let i = index; i < len; i++) xs[i] = xs[i + 1]; xs.length = len - 1; return xs; }; const seen = new Set(); const quniqWith = curry2((getter, xs) => { let size = length(xs), cur; for (let i = z$1; i < size; i++) { const x = xs[i]; cur = getter(x); if (seen.has(cur)) { rmel(i, xs); size--; i--; } else seen.add(cur); } seen.clear(); return xs; }); quniqWith(identity); const ifElse = curry((cond, pipeYes, pipeNo, s) => cond(s) ? pipeYes(s) : pipeNo(s)); const compose = ((...fns) => (...args) => { let first = true; let s; for (let i = length(fns) - 1; i > -1; i--) { if (first) { first = false; s = fns[i](...args); } else s = s === __ ? fns[i]() : fns[i](s); } return s; }); const nth = curry2((i, data) => data[i]); // FIXME: these types. Somewhere in curry2. // const x = nth(0)([1,2,3]) // const y = nth(0)('123') // const z = nth(0)(new Uint8Array([0,2,3])) const slice = curry3((from, to, o) => o.slice(from, (isNum(to) ? to : inf$1))); /** @returns first element of an array or a string. */ const head = nth(0); /** @returns all elements of an array or a string after first one. */ slice(1, inf$1); /**@param a @param b @returns a×b */ const multiply = curry2((a, b) => a * b); const find = curry2((fn, s) => s.find(fn)); const tap = curry2((fn, x) => { fn(x); return x; }); const T = always(true); const F$1 = always(false); const noop = (() => { }); /** @param cond (x, y): bool @param xs any[] @returns xs without duplicates, using cond as a comparator. */ const uniqWith = curry2((cond, xs) => qreduce((accum, x) => find((y) => cond(x, y), accum) ? accum : qappend(x, accum), [], xs)); /** @param xs any[] @returns xs without duplicates. */ uniqWith(equals); const once = (fn) => { let done = false, cache; return function (...args) { if (done) return cache; done = true; return cache = fn(...args); }; }; const _pathOr = (_default, path, o) => length(path) ? isNil(o) ? _default : compose((k) => k in o ? _pathOr(_default, slice(1, inf$1, path), o[k]) : _default, head)(path) : o; const pathOr = curry3(_pathOr); // it's more performant due to recursion there. pathOr(undef); compose(ifElse(equals(symbol), F$1, T), pathOr(symbol)); const t=Symbol("Placeholder"),r=r=>{let n=0;for(const e of r)e!==t&&n++;return n},n=(r,n)=>{const e=r.length,s=r.slice(),i=n.length;let o=i,c=0;for(;o&&c<e;c++)s[c]===t&&(s[c]=n[i-o],o--);for(c=e;o;c++,o--)s[c]=n[i-o];return s},e=(t,s,i)=>{const o=t.length-s.length-r(i);if(o<1)return t(...n(s,i));{const r=(...r)=>e(t,n(s,i),r);return r.$args_left=o,r}},s=t=>(...n)=>t.length>r(n)?e(t,[],n):t(...n);function i(r){return function(n,...e){return e.length>0?n===t?(r=>function(n){return n===t?r:r(n)})((t=>r(t,e[0]))):r(n,e[0]):t=>r(n,t)}}function o(t){return s(t)}const c=t=>t.length,l=/^(.*?)(8|16|32|64)(Clamped)?Array$/,u=void 0,a=1/0,f=t=>typeof t,h=t=>null===t,d=t=>"number"==f(t),b=t=>h(t)||(t=>t===u)(t),p={u:"U",b:"B",n:"N",s:"S",f:"F"},m=Symbol(),g=t=>{const r=f(t);return "object"===r?h(t)?"Null":t.constructor.name:p[r[0]]+r.slice(1)},A=i(((t,r)=>g(r)===t)),y=i(((t,r)=>t===r)),B=i(((t,r)=>{const n=g(t);if(y(n,g(r))&&(y(n,"Object")||y(n,"Array")||(e=n,l.test(e)))){if(h(t)||h(r))return y(t,r);if(y(t,r))return true;for(const n of [t,r])for(const e in n)if(!(y(n,r)&&e in t||y(n,t)&&e in r&&B(t[e],r[e])))return false;return true}var e;return y(t,r)})),C=i(((t,r)=>(r.push(t),r))),z=o(((t,r,n)=>n.reduce(t,r))),S=s(((t,r,n,e)=>t(e)?r(e):n(e))),_=o(((t,r,n)=>S(t,r,q,n))),j=(...r)=>(...n)=>{let e,s=true;for(let i=c(r)-1;i>-1;i--)s?(s=false,e=r[i](...n)):e=e===t?r[i]():r[i](e);return e},v=i(((t,r)=>r[t])),w=o(((t,r,n)=>n.slice(t,d(r)?r:a))),N=v(0);w(1,a);const E=i(((t,r)=>r.find(t))),O=t=>()=>t,q=t=>t,x=i(((t,r)=>r.split(t))),F=O(true),I=O(false),M=i(((t,r)=>z(((r,n)=>E((r=>t(n,r)),r)?r:C(n,r)),[],r)))(B),P=(t,r,n)=>c(r)?b(n)?t:j((e=>e in n?P(t,w(1,a,r),n[e]):t),N)(r):n,U=o(P);U(u),j(S(B(m),I,F),U(m));const W=i(((t,r)=>r.map(t))),{floor:$}=Math,k="0123456789abcdefghijklmnopqrstuvwxyz",D=A("String"),G=_(D,x("")),H=j((t=>Object.fromEntries(t)),W(((t,r)=>[t,r])),G);class J{is_str;delim;abc;abclen;c2pos;standard;setABC(t,r=""){if(this.is_str=D(t),this.delim=r,!j(y(c(n=t)),c,M,G)(n))throw new Error("Not all chars are unique!");var n;this.abc=t,this.abclen=c(t),this.standard=!!this.is_str&&k.startsWith(t),this.c2pos=H(t);}zip(t){const{abc:r,abclen:n,delim:e}=this;let s="",i=true;for(;t>0;)s=r[t%n]+(i?"":e)+s,t=$(t/n),i=false;return s||"0"}unzip(t){const{standard:r,abclen:n,c2pos:e,delim:s,is_str:i}=this;if("0"===t)return 0;if(r)return parseInt(t,n);const o=i?t:t.split(s),l=c(o);let u=0;for(let t=0;t<l;t++)u+=e[o[t]]*n**(l-t-1);return u}constructor(t,r){r?this.setABC(t,r):this.setABC(t||k+"ABCDEFGHIJKLMNOPQRSTUVWXYZ");}}const K=new J;K.setABC.bind(K);K.zip.bind(K);K.unzip.bind(K); const native_ws = (() => { try { return WebSocket || null; } catch { return null; } })(); const add_event = (o, e, handler) => { return o.addEventListener(e, handler); }; const rm_event = (o, e, handler) => { return o.removeEventListener(e, handler); }; const sett = (a, b) => setTimeout(b, a); const { min, random: random$1 } = Math; const default_config = () => ({ // Debug features. log: (() => null), timer: false, // Set up. url: '', timeout: 1.4, reconnect: { stop_after: 45, on_timeout: true, on_break: true, time_fn: ({ base, max, jitter }, attempt) => min(max, base ** (attempt + random$1() * jitter)), params: { base: 2, max: 20, jitter: .1 } }, max_idle_time: Infinity, lazy: false, socket: null, adapter: ((host, protocols) => new WebSocket(host, protocols)), encode: (key, data, { server }) => JSON.stringify({ [server.id_key]: key, [server.data_key]: data }), decode: (rawMessage) => JSON.parse(rawMessage), protocols: [], pipes: [], server: { id_key: 'id', data_key: 'data' }, ping: { interval: 55, timeout: 30, out: 'ping', in: 'pong' } }); const processConfig = (config) => { if (native_ws === null && !('adapter' in config)) throw new Error(` This platform has no native WebSocket implementation. Please use 'ws' package as an adapter. See https://github.com/houd1ni/WebsocketPromisify/issues/23 `); const full_config = qmergeDeep(default_config(), config); const url = full_config.url; if (url[0] == '/') try { const protocol = location.protocol.includes('s:') ? 'wss' : 'ws'; full_config.url = `${protocol}://${location.hostname}:${location.port}${url}`; } catch (e) { throw new Error('WSP: URL starting with / in non-browser environment!'); } return full_config; }; const { random } = Math; const MAX_32 = 2 ** 31 - 1; const nil = null, inf = Infinity; const resolved = Promise.resolve(nil); const label_message = 'message'; const label_message_ext = 'message-ext'; const zipnum = new J(); const dnow = () => Date.now(); const now = () => dnow() / 1e3; const ms = multiply(1e3); const clearTO = (to) => to && clearTimeout(to); const isNull = (x) => x === null; const isStr = typeIs('String'); const isObj = typeIs('Object'); const timeout_rm = (q, ff, rj, timeout = .5) => { const timeout_ms = ms(timeout); const rm = setTimeout(() => { const i = q.indexOf(ff); if (~i) { q.splice(i); rj(`could not close in ${timeout_ms}ms!`); } }, timeout_ms * 1e3); q.push((...ps) => { clearTO(rm); ff(...ps); }); }; const genid = (q) => { const id = zipnum.zip((random() * (MAX_32 - 10)) | 0); return q.has(id) ? genid(q) : id; }; const call_q = (q, ...args) => { for (const fn of q) fn(...args); return q; }; const clear_q = (q) => { q.splice(0); return q; }; const default_router = (d, next) => next(d); class WebSocketClient { ws = nil; intentionally_closed = false; reconnect_timeout = nil; queue = { send: new Map(), on_ready: [], on_close: [], on_ready_fail: [] }; handlers = { open: [], close: [], message: [], [label_message_ext]: [], error: [], timeout: [] }; config = {}; ping_timer = nil; idle_timer = nil; zombie_timer = nil; router = default_router; get opened() { return this.ws?.readyState === 1; } // The only opened state. call(event_name, ...args) { for (const h of this.handlers[event_name]) h(...args); } log(event, message = nil, time = nil) { const { config } = this; setTimeout(() => { if (isNull(time)) if (config.timer) config.log(event, nil, message); else config.log(event, message); else config.log(event, time, message); }); } resetPing() { const { config: { ping }, ping_timer } = this; if (ping) { clearTO(ping_timer); this.ping_timer = sett(ms(ping.interval), async () => { const { ping_timer, opened } = this; if (opened) { this.ws.send(ping.out); this.resetPing(); } else clearTO(ping_timer); }); } } resetZombieProbe() { const { config } = this; if (config.ping) { const z_timeout = config.ping.timeout; clearTO(this.zombie_timer); if (z_timeout !== Infinity) this.zombie_timer = sett(ms(z_timeout || config.timeout), () => this.close().catch(noop)); } } // FIXME: Make some version where it could work faster (for streaming). resetIdle() { const { config: { max_idle_time: time }, idle_timer } = this; if (time !== Infinity) { clearTO(idle_timer); this.idle_timer = sett(ms(time), () => this.opened && this.close()); } } _reconnecting = false; reconnect_start = 0; async reconnect(attempt = 0) { if (this._reconnecting && attempt === 0) return; const { reconnect } = this.config; if (!reconnect) throw new Error('WSC: reconnecting is disabled, but reconned h.b. called!'); this.log('reconnect'); this._reconnecting = true; this.reconnect_start = now(); if (!isNil(this.ws)) this.terminate(); const { queue } = this; if (attempt > 0 && isNil(await this.connect())) { // connected. clear_q(call_q(queue.on_ready)); clear_q(queue.on_ready_fail); this._reconnecting = false; this.reconnect_timeout = nil; } else { const { stop_after, time_fn, params } = reconnect; if (now() - this.reconnect_start > stop_after) { // give up. this.terminate(); clear_q(call_q(queue.on_ready_fail)); clear_q(queue.on_ready); this._reconnecting = false; this.reconnect_timeout = nil; } else this.reconnect_timeout = sett(// try more. ms(time_fn(params, attempt)), this.reconnect.bind(this, attempt + 1)); } } resetReconnect() { if (!isNull(this.reconnect_timeout)) { clearTO(this.reconnect_timeout); this.reconnect_timeout = nil; } } initSocket(ws) { const { queue, config, router } = this; const { reconnect } = config; this.ws = ws; clear_q(call_q(this.queue.on_ready)); const { id_key, data_key } = config.server; // works also on previously opened sockets that do not fire 'open' event. this.call('open', ws); for (const { msg } of queue.send.values()) ws.send(msg); this.resetReconnect(); this.resetZombieProbe(); this.resetPing(); this.resetIdle(); add_event(ws, 'close', async (...e) => { this.ws = nil; clear_q(call_q(queue.on_close)); this.call('close', ...e); if (!this.intentionally_closed && reconnect && reconnect.on_break) this.reconnect(); }); const { ping } = config; const handle_msg = (raw) => { try { const data = config.decode(raw); const { send: send_q } = this.queue; if (isObj(data) && id_key in data) { const id = data[id_key]; if (send_q.has(id)) { const q = send_q.get(id); const d = data[data_key]; const time = q.sent_time ? (dnow() - q.sent_time) : nil; this.log(label_message, d, time); this.call(label_message, d); q.ff(d); } } else { this.log(label_message_ext, data); this.call(label_message_ext, { data }); } } catch (err) { console.error(err, `WSP: Decode error. Got: ${raw}`); } }; add_event(ws, label_message, (e) => { const raw = isStr(e.data) ? e.data : new Uint8Array(e.data); this.resetZombieProbe(); this.resetPing(); if (!ping || !equals(raw, ping.in)) router(raw, handle_msg); }); } _opening = false; /** returns status if won't open or null if ok. */ connect() { if (this.opened || this._opening) return resolved; return this.opened || this._opening ? resolved : new Promise((ff) => { this._opening = true; const config = this.config; const ws = config.socket || config.adapter(config.url, config.protocols); if (isNil(ws) || ws.readyState > 1) { this._opening = false; this.ws = nil; this.log('error', 'ready() on closing or closed state! status 2.'); return ff(2); } const ffo = once((s) => { this._opening = false; ff(s); }); add_event(ws, 'error', once((e) => { this.ws = nil; // Some network error: Connection refused or so. this.log('error', 'status 3. Err: ' + (e.message || e)); this.call('error', e); ffo(3); })); if (ws.readyState) { // Because 'open' won't be envoked on opened socket. this.initSocket(ws); ffo(nil); } else add_event(ws, 'open', once(() => { this.log('open'); this.initSocket(ws); ffo(nil); })); }); } get socket() { return this.ws; } async ready(timeout = inf) { return new Promise((ff, rj) => { const { on_ready } = this.queue; if (this.config.lazy || this.opened) ff(); else if (timeout === inf) on_ready.push(ff); else timeout_rm(on_ready, ff, rj); }); } on(event_name, handler, predicate = T, raw = false) { const _handler = (event) => predicate(event) && handler(event); if (raw) add_event(this.ws, event_name, _handler); else this.handlers[event_name].push(_handler); return _handler; } off(event_name, handler, raw = false) { if (raw) return rm_event(this.ws, event_name, handler); const handlers = this.handlers[event_name]; const i = handlers.indexOf(handler); if (~i) handlers.splice(i, 1); } terminate() { this.ws?.close(); this.ws = nil; this.intentionally_closed = true; } close(timeout = .5) { return new Promise((ff, rj) => { if (isNull(this.ws)) ff(nil); else { timeout_rm(this.queue.on_close, ff, rj, timeout); this.terminate(); } }); } open() { if (!this.opened) { this.intentionally_closed = false; return this.connect(); } } addEventListener(e, cb, opts = {}) { return this.on(e, cb, opts.predicate, opts.raw); } removeEventListener(e, handler, opts = {}) { return this.off(e, handler, opts.raw); } // TODO: Сделать сэттер элементов конфигурации чтобы двигать таймауты. // И эвент, когда схема наша, а соответствующего элемента очереди не ма. // Или добавить флажок к эвенту 'message'.F // И событие 'line' со значением on: boolean. Критерии? async prepareMessage(message_data, opts = {}) { this.log('send', message_data); const { config, queue: { send: send_q, on_ready_fail } } = this; const { pipes, server: { data_key } } = config; const { reconnect } = config; const { top } = opts; const id = genid(send_q); if (isObj(top) && data_key in top) throw new Error(` Attempting to set data key/token via send() options! `); for (const pipe of pipes) message_data = pipe(message_data); const [msg, err] = await Promise.all([ config.encode(id, message_data, config), this.connect() ]); if (err) throw new Error('ERR while opening connection > ' + err); const timeout_time = top?.timeout || config.timeout; const cleanup = tap(() => send_q.delete(id)); const timeout = (rj) => sett(ms(timeout_time), () => { if (send_q.has(id)) { this.call('timeout', message_data); const reject = () => { cleanup(); rj({ 'Websocket timeout expired': timeout_time, 'for the message': message_data }); }; if (reconnect && reconnect.on_timeout) { on_ready_fail.push(reject); this.reconnect(); } else reject(); } }); const send = () => this.opened && (this.ws.send(msg), this.resetPing(), this.resetIdle()); return { id, msg, timeout, cleanup, send }; } /** .send(your_data) wraps request to server with {id: `unique_id`, data: `actually your data`}, returns a Promise that will be rejected after a timeout or resolved if server returns the same signature: {id: `same_hash`, data: `response data`}. */ async send(message_data, opts = {}) { const { id, msg, timeout, cleanup, send } = await this.prepareMessage(message_data, opts); const { queue: { send: send_q }, config } = this; return new Promise((ff, rj) => { const to = timeout(rj); send_q.set(id, { msg, sent_time: config.timer ? dnow() : nil, ff(x) { clearTO(to); ff(x); } }); send(); }).finally(cleanup); } // TODO: stream timeouts in the config ?.. async *stream(message_data, opts = {}) { const { id, msg, timeout, cleanup, send } = await this.prepareMessage(message_data, opts); const { queue: { send: send_q }, config } = this; let done = false, fulfill, to = nil; send_q.set(id, { msg, ff: (msg) => { if (msg?.done) { delete msg.done; done = true; setTimeout(cleanup); } fulfill(msg); }, sent_time: config.timer ? dnow() : nil }); send(); while (!done) yield await new Promise((ff, rj) => { to = timeout(rj); fulfill = ff; }).catch(cleanup).finally(() => clearTO(to)); } route(handler) { this.router = handler; } constructor(user_config) { this.config = processConfig(user_config); if (!this.config.lazy) this.connect(); } } exports.WebSocketClient = WebSocketClient;