import { ExponentialBackoff, Websocket, WebsocketBuilder } from 'websocket-ts' import { ActionClientMessage, ActionServerMessage, ConfirmServerMessage, HandshakeClientMessage, HandshakeServerMessage, ServerMessage, KetchupSynced } from 'ketchup-common' import EventEmitter from 'events' export default class KetchupClient extends EventEmitter { readonly ws: Websocket synced?: KetchupSynced clientId?: number lastTimestamp?: number constructor( url: string, readonly reducer: (state: State, action: Action) => State, private readonly timeSource = Date.now ) { super() this.ws = new WebsocketBuilder(url) .onClose(() => { this.synced = undefined this.clientId = undefined this.lastTimestamp = undefined }) .onOpen(ws => { ws.send(JSON.stringify({ message: 'handshake', timestamp: timeSource(), } as HandshakeClientMessage)) }) .onMessage((_, event) => { this.onMessage(JSON.parse(event.data)) }) .withBackoff(new ExponentialBackoff(100, 7)) .build() } private onMessage(msg: ServerMessage) { switch (msg.message) { case 'handshake': const hMsg = msg as HandshakeServerMessage this.clientId = hMsg.clientId this.synced = new KetchupSynced(hMsg.confState, hMsg.confBefore, this.reducer, hMsg.unconfActions) this.synced.on('projection', proj => this.emit('projection', proj)) this.emit('projection', this.synced.projState) break case 'action': this.synced!.tryProcess(msg as ActionServerMessage) break case 'confirm': this.synced!.confirmBefore((msg as ConfirmServerMessage).confBefore) break default: throw new Error(`Unknown message from server: ${msg}`) } } dispatch(action: Action, timestamp = this.timeSource()) { // In case of timestamp collision, shift event 1ms into future until timestamp is novel. // TODO: Better handling of events being submitted out of timestamp order if (this.lastTimestamp !== undefined && timestamp <= this.lastTimestamp) { timestamp = this.lastTimestamp + 1 } if (this.synced?.tryProcess({ timestamp, action, clientId: this.clientId! })) { this.lastTimestamp = timestamp this.ws.send(JSON.stringify({ message: 'action', action, timestamp, } as ActionClientMessage)) } } }