UNPKG

@gang-js/core

Version:

a state sharing algorithm

353 lines (352 loc) 14.9 kB
import { BehaviorSubject, Subject, ReplaySubject } from 'rxjs'; import { GangContext } from '../context'; import { GangConnectionState, GangCommandWrapper, NO_RETRY, RETRY_INIT, RETRY_MAX, GangEventTypes, GANG_COMMAND_TIMEOUT_DEFAULT_MS, GANG_COMMAND_TIMEDOUT } from '../models'; import { GangUrlBuilder, clean } from './utils'; export class GangService { get connectionState() { return this.connectionSubject.value; } get isConnected() { return this.connectionSubject.value === GangConnectionState.connected; } constructor(webSocketFactory, settings = GangContext.defaultSettings, initialState = null) { this.webSocketFactory = webSocketFactory; this.settings = settings; this.initialState = initialState; this.retry = RETRY_INIT; this.retryingIn = undefined; this.unsentCommands = []; this.sn = 0; this.onConnection = this.connectionSubject = new BehaviorSubject(GangConnectionState.disconnected); this.onConnectionRetry = this.connectionRetrySubject = new Subject(); this.onAuthenticated = this.authenticatedSubject = new ReplaySubject(1); this.onMemberConnected = this.memberConnectedSubject = new Subject(); this.onMemberDisconnected = this.memberDisconnectedSubject = new Subject(); this.onCommand = this.commandSubject = new Subject(); this.onReceipt = this.receiptSubject = new Subject(); this.onState = this.stateSubject = new BehaviorSubject(initialState); } /** * Connect to gang */ async connect(parameters) { if (this.isConnected) await this.disconnect('reconnect'); this.connectionParameters = Object.assign(Object.assign({}, this.connectionParameters), clean(parameters)); GangContext.logger.debug('GangService.connect', this.connectionParameters); return new Promise((resolve) => { this.connectionSubject.next(GangConnectionState.connecting); const connectionUrlBuilder = GangUrlBuilder.from(this.settings.rootUrl + this.connectionParameters.path) .setAll(this.connectionParameters.properties) .set('gangId', this.connectionParameters.gangId) .set('token', this.connectionParameters.token); const connectionUrl = connectionUrlBuilder.build(); GangContext.logger.debug('GangService.connecting', this.settings.rootUrl + this.connectionParameters.path, connectionUrl); const retryConnect = (() => { if (this.retry === NO_RETRY || this.retrying || this.isConnected) return; GangContext.logger.debug('GangService.retryConnect in', this.retry); this.retryingIn = this.retry; this.retrying = window.setInterval(() => { this.retryingIn--; this.connectionRetrySubject.next(this.retryingIn); if (this.retryingIn === 0) { clearRetryConnect(); this.connect(parameters).catch(() => { // do nothing. }); } }, 1000); if (this.retry < RETRY_MAX) this.retry *= 2; }).bind(this); const clearRetryConnect = (() => { if (this.retrying) { clearInterval(this.retrying); this.retrying = null; } }).bind(this); try { this.webSocket = this.webSocketFactory(connectionUrl, (e) => { GangContext.logger.debug('GangService.onopen', e); this.connectionSubject.next(GangConnectionState.connected); this.retry = RETRY_INIT; this.retryingIn = undefined; resolve(true); let wrapper; while ((wrapper = this.unsentCommands.shift())) this.sendCommandWrapper(wrapper); window.removeEventListener('online', this.online); this.offline = () => { GangContext.logger.debug('GangService.offline'); this.isConnected && this.disconnect(null); }; window.addEventListener('offline', this.offline); clearRetryConnect(); }, (e) => { GangContext.logger.debug('GangService.onerror', e); this.connectionSubject.next(GangConnectionState.error); resolve(false); }, (e) => { GangContext.logger.debug('GangService.onclose', e); this.connectionSubject.next(GangConnectionState.disconnected); window.removeEventListener('offline', this.offline); if (!e.reason) { this.online = () => { GangContext.logger.debug('GangService.online'); !this.isConnected && this.connect(parameters).catch(() => { // do nothing. }); }; window.addEventListener('online', this.online); retryConnect(); } }); this.webSocket.subscribe((e) => { GangContext.logger.debug('GangService.webSocket.message', e); switch (e.type) { case GangEventTypes.Host: this.isHost = true; this.memberId = e.auth.memberId; this.application = e.auth.application; this.authenticate(e.auth.token); break; case GangEventTypes.Member: this.isHost = false; this.memberId = e.auth.memberId; this.application = e.auth.application; this.authenticate(e.auth.token); break; case GangEventTypes.Denied: this.memberId = e.auth.memberId; this.application = e.auth.application; this.authenticate(null); break; case GangEventTypes.MemberConnected: this.memberConnectedSubject.next(e.memberId); break; case GangEventTypes.MemberDisconnected: this.memberDisconnectedSubject.next(e.memberId); break; case GangEventTypes.Command: this.commandSubject.next(e.wrapper); break; case GangEventTypes.CommandReceipt: this.receiptSubject.next(e.rsn); this.unsentCommands = this.unsentCommands.filter((w) => w.sn !== e.rsn); break; case GangEventTypes.State: this.stateSubject.next(Object.assign(Object.assign({}, this.stateSubject.value), e.state)); break; } }); } catch (err) { GangContext.logger.debug('GangService.connect', err); } retryConnect(); }); } authenticate(token) { this.isAuthenticated = !!token; this.authenticatedSubject.next(token); const state = this.initialState; this.stateSubject.next(state); } disconnect(reason = 'disconnected') { GangContext.logger.debug('GangService.disconnect'); return new Promise((resolve) => { if (this.isConnected) { let wait = null; wait = this.connectionSubject.subscribe((state) => { if (state === GangConnectionState.disconnected) { GangContext.logger.debug('GangService.disconnect disconnected'); wait === null || wait === void 0 ? void 0 : wait.unsubscribe(); resolve(); } }); this.retry = NO_RETRY; this.webSocket.close(reason); } else { resolve(); } }); } /** Set the local current state, ie not sent to the server * * @param state the passed state will be shallow merged with the current state */ setState(state) { this.stateSubject.next(Object.assign(Object.assign({}, this.stateSubject.value), state)); } /** * Map gang events to the component * * @param component the component to map to */ mapEvents(component) { const subs = []; [ 'Connection', 'Authenticated', 'State', 'Command', 'MemberConnected', 'MemberDisconnected', 'ConnectionRetry' ].forEach((key) => { const componentKey = `onGang${key}`; const serviceKey = `on${key}`; if (component[componentKey]) subs.push(this[serviceKey].subscribe((e) => component[componentKey](e))); else if (component[serviceKey] !== undefined) console.warn(`${serviceKey} changed to ${componentKey}, please update your code`, component); }); const disconnectedCallback = component.disconnectedCallback; component.disconnectedCallback = () => { if (disconnectedCallback) disconnectedCallback(); subs.forEach((sub) => sub.unsubscribe()); }; } /** * Map the actions to a component, injecting this service * * @param component the component to map to * @param actions a map of the executors e.g. { actionOne, actionTwo } */ mapActions(component, actions) { Object.keys(actions).forEach((key) => { component[key] = actions[key](this); }); } /** * Executes a command locally no data is sent to the host * * @param type Command type name * @param data Command data */ executeCommand(type, data) { const wrapper = new GangCommandWrapper(type, data); GangContext.logger.debug('GangService.executeCommand', { wrapper, isConnected: this.isConnected }); this.commandSubject.next(wrapper); } /** * Sends a command to the host member * await this if you expect a reply command from the host * * @param type Command type name * @param data Command data * * @returns a IGangCommandSent */ sendCommand(type, data) { const sn = ++this.sn; const wrapper = new GangCommandWrapper(type, data, sn); GangContext.logger.debug('GangService.sendCommand', { wrapper, isConnected: this.isConnected }); this.unsentCommands = [...this.unsentCommands, wrapper]; if (!this.isConnected) return this.emptySent(wrapper.sn); if (this.isHost) { this.commandSubject.next(wrapper); return this.emptySent(wrapper.sn); } return this.sendCommandWrapper(wrapper); } sendCommandWrapper(wrapper) { this.send(JSON.stringify({ type: wrapper.type, data: wrapper.data }), wrapper.sn); GangContext.logger.debug('GangService.sendCommandWrapper', wrapper); return { sn: wrapper.sn, wait: (options) => new Promise((resolve, reject) => { const commands = this.onCommand.subscribe((w) => { if (w.rsn == wrapper.sn) { commands.unsubscribe(); receipts.unsubscribe(); resolve(w); } }); const receipts = this.onReceipt.subscribe((r) => { if (r == wrapper.sn) { commands.unsubscribe(); receipts.unsubscribe(); resolve(null); } }); setTimeout(() => { commands.unsubscribe(); receipts.unsubscribe(); reject(GANG_COMMAND_TIMEDOUT); }, (options === null || options === void 0 ? void 0 : options.timeout) || GANG_COMMAND_TIMEOUT_DEFAULT_MS); }) }; } emptySent(sn) { return { sn, wait: () => Promise.resolve(null) }; } sendState(state) { if (!this.isHost) throw new Error('only host can send state'); this.stateSubject.next(state); this.send(JSON.stringify(state)); GangContext.logger.debug('GangService.sendState', state); } send(data, sn) { let a = Uint8Array.from(data, (x) => x.charCodeAt(0)); if (sn) { const sna = Uint32Array.from([sn]); a = new Uint8Array([...new Uint8Array(sna.buffer), ...a]); } try { this.webSocket.send(a.buffer); } catch (err) { GangContext.logger.error('GangService.send error', err); } } waitForCommand(type, predicate, options) { const test = (c) => { return (!type || type === c.type) && (!predicate || predicate(c.data)); }; return new Promise((resolve, reject) => { const sub = this.onCommand.subscribe((c) => { if (test(c)) { sub.unsubscribe(); resolve(); } }); setTimeout(() => { sub.unsubscribe(); reject(); }, (options === null || options === void 0 ? void 0 : options.timeout) || 10000); }); } waitForState(predicate, options) { return new Promise((resolve, reject) => { const sub = this.onState.subscribe((s) => { if (predicate(s)) { sub.unsubscribe(); resolve(); } }); setTimeout(() => { sub.unsubscribe(); reject(); }, (options === null || options === void 0 ? void 0 : options.timeout) || 10000); }); } }