space/packages/ketchup-client/index.ts
2021-05-16 15:34:20 +10:00

76 lines
2.5 KiB
TypeScript

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<State, Action> extends EventEmitter {
readonly ws: Websocket
synced?: KetchupSynced<State, Action>
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<State, Action>) {
switch (msg.message) {
case 'handshake':
const hMsg = msg as HandshakeServerMessage<State, Action>
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<Action>)
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<Action>))
}
}
}