@logux/client
Version:
Logux base components to build web client
414 lines (365 loc) • 11.8 kB
JavaScript
import {
ClientNode,
isFirstOlder,
Log,
MemoryStore,
parseId,
Reconnect,
WsConnection
} from '@logux/core'
import { createNanoEvents } from 'nanoevents'
import { nanoid } from 'nanoid'
import { LoguxUndoError } from '../logux-undo-error/index.js'
import { track } from '../track/index.js'
let ALLOWED_META = ['id', 'time', 'subprotocol']
function tabPing(c) {
localStorage.setItem(c.options.prefix + ':tab:' + c.tabId, Date.now())
}
function cleanTabActions(client, id) {
client.log.removeReason('tab' + id).then(() => {
if (client.isLocalStorage) {
localStorage.removeItem(client.options.prefix + ':tab:' + id)
}
})
}
function isEqual(obj1, obj2) {
if (!obj1 && !obj2) return true
return JSON.stringify(obj1) === JSON.stringify(obj2)
}
function unsubscribeChannel(client, unsubscribe) {
let json = JSON.stringify({ ...unsubscribe, type: 'logux/subscribe' })
let subscribers = client.subscriptions[json]
if (subscribers) {
if (subscribers === 1) {
delete client.subscriptions[json]
} else {
client.subscriptions[json] = subscribers - 1
}
}
}
export class Client {
constructor(opts = {}) {
this.options = opts
if (process.env.NODE_ENV !== 'production') {
if (typeof this.options.server === 'undefined') {
throw new Error('Missed server option in Logux client')
}
if (typeof this.options.subprotocol === 'undefined') {
throw new Error('Missed subprotocol option in Logux client')
}
if (typeof this.options.userId === 'undefined') {
throw new Error(
'Missed userId option in Logux client. ' +
'Pass false if you have no users.'
)
}
if (this.options.userId === false) {
throw new Error('Replace userId: false to userId: "false"')
}
if (typeof this.options.userId !== 'string') {
throw new Error('userId must be a string')
}
if (this.options.userId.includes(':')) {
throw new Error('userId can’t contain colon character')
}
}
if (typeof this.options.prefix === 'undefined') {
this.options.prefix = 'logux'
}
this.isLocalStorage = false
if (typeof localStorage !== 'undefined') {
let random = nanoid()
try {
localStorage.setItem(random, '1')
localStorage.removeItem(random)
this.isLocalStorage = true
} catch {}
}
if (!this.options.time) {
this.clientId = this.options.userId + ':' + this.getClientId()
this.tabId = nanoid(8)
} else {
this.tabId = this.options.time.lastId + 1 + ''
this.clientId = this.options.userId + ':' + this.tabId
}
this.nodeId = this.clientId + ':' + this.tabId
let store = this.options.store || new MemoryStore()
let log
if (this.options.time) {
log = this.options.time.nextLog({ nodeId: this.nodeId, store })
} else {
log = new Log({ nodeId: this.nodeId, store })
}
this.log = log
this.last = {}
this.subscriptions = {}
let subscribing = {}
let unsubscribing = {}
log.on('preadd', (action, meta) => {
if (parseId(meta.id).nodeId === this.nodeId && !meta.subprotocol) {
meta.subprotocol = this.options.subprotocol
}
if (action.type === 'logux/unsubscribe') {
let wasSubscribed = true
let processedOffline = false
for (let id in subscribing) {
let subscribe = subscribing[id]
if (subscribe.channel === action.channel) {
if (isEqual(action.filer, subscribe.filter)) {
wasSubscribed = false
delete subscribing[id]
log.changeMeta(id, { reasons: [] })
break
}
}
}
if (wasSubscribed && this.state === 'disconnected') {
processedOffline = true
unsubscribeChannel(this, action)
}
if (wasSubscribed && !processedOffline) {
meta.sync = true
} else {
delete meta.sync
}
}
if (meta.sync && !meta.resubscribe) meta.reasons.push('syncing')
})
this.emitter = createNanoEvents()
this.on('add', (action, meta) => {
let type = action.type
let last
if (type === 'logux/processed' || type === 'logux/undo') {
this.log.removeReason('syncing', { id: action.id })
}
if (type === 'logux/subscribe' && !meta.resubscribe) {
subscribing[meta.id] = action
} else if (type === 'logux/unsubscribe') {
if (meta.sync) unsubscribing[meta.id] = action
} else if (type === 'logux/processed') {
if (unsubscribing[action.id]) {
unsubscribeChannel(this, unsubscribing[action.id])
} else if (subscribing[action.id]) {
let subscription = subscribing[action.id]
delete subscribing[action.id]
let json = JSON.stringify(subscription)
if (this.subscriptions[json]) {
this.subscriptions[json] += 1
} else {
this.subscriptions[json] = 1
}
last = this.last[subscription.channel]
if (!last || isFirstOlder(last, meta)) {
this.last[subscription.channel] = { id: meta.id, time: meta.time }
}
}
if (this.processing[action.id]) {
this.processing[action.id][1](meta)
delete this.processing[action.id]
}
} else if (type === 'logux/undo') {
if (this.processing[action.id]) {
this.processing[action.id][2](new LoguxUndoError(action))
delete this.processing[action.id]
}
delete subscribing[action.id]
delete unsubscribing[action.id]
} else if (meta.channels) {
if (!meta.id.includes(' ' + this.clientId + ':')) {
meta.channels.forEach(channel => {
last = this.last[channel]
if (!last || isFirstOlder(last, meta)) {
this.last[channel] = { id: meta.id, time: meta.time }
}
})
}
}
})
this.tabPing = 60000
this.tabTimeout = 10 * this.tabPing
let reason = 'tab' + this.tabId
if (this.isLocalStorage) {
let unbind = log.on('add', (action, meta) => {
if (meta.reasons.includes(reason)) {
tabPing(this)
this.pinging = setInterval(() => {
tabPing(this)
}, this.tabPing)
unbind()
}
})
}
let connection
if (typeof this.options.server === 'string') {
let ws = new WsConnection(this.options.server)
connection = new Reconnect(ws, {
attempts: this.options.attempts,
maxDelay: this.options.maxDelay,
minDelay: this.options.minDelay
})
} else {
connection = this.options.server
}
let onSend = async (action, meta) => {
if (!!meta.sync && parseId(meta.id).userId === this.options.userId) {
let filtered = {}
for (let i in meta) {
if (i === 'subprotocol') {
if (meta.subprotocol !== this.options.subprotocol) {
filtered.subprotocol = meta.subprotocol
}
} else if (ALLOWED_META.includes(i)) {
filtered[i] = meta[i]
}
}
return [action, filtered]
} else {
return false
}
}
this.node = new ClientNode(this.nodeId, this.log, connection, {
fixTime: !this.options.time,
onSend,
ping: this.options.ping,
subprotocol: this.options.subprotocol,
timeout: this.options.timeout,
token: this.options.token
})
if (/^ws:\/\//.test(this.options.server) && !opts.allowDangerousProtocol) {
let unbindEnvTest = this.node.on('state', () => {
if (this.node.state === 'synchronized') {
unbindEnvTest()
if (this.node.remoteHeaders.env !== 'development') {
console.error(
'Without SSL, old proxies block WebSockets. ' +
'Use WSS for Logux or set allowDangerousProtocol option.'
)
this.destroy()
}
}
})
}
this.node.on('debug', (type, stack) => {
if (type === 'error') {
console.error('Error on Logux server:\n', stack)
}
})
let disconnected = true
this.node.on('state', () => {
let state = this.node.state
if (state === 'synchronized') {
if (disconnected) {
disconnected = false
for (let i in this.subscriptions) {
let action = JSON.parse(i)
let since = this.last[action.channel]
if (since) action.since = since
this.log.add(action, { resubscribe: true, sync: true })
}
}
} else if (this.node.state === 'disconnected') {
disconnected = true
}
})
this.onUnload = this.onUnload.bind(this)
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('pagehide', this.onUnload)
}
this.processing = {}
}
changeUser(userId, token) {
if (process.env.NODE_ENV !== 'production') {
if (typeof userId !== 'string') {
throw new Error('userId must be a string')
}
if (userId.includes(':')) {
throw new Error('userId can’t contain colon character')
}
}
let wasConnected = this.node.connected
if (wasConnected) this.node.connection.disconnect('destroy')
this.options.userId = userId
this.options.token = token
this.clientId = userId + ':' + this.getClientId()
this.nodeId = this.clientId + ':' + this.tabId
this.log.nodeId = this.nodeId
this.node.localNodeId = this.nodeId
this.node.options.token = token
this.emitter.emit('user', userId)
if (wasConnected) this.node.connection.connect()
}
clean() {
this.destroy()
return this.log.store.clean ? this.log.store.clean() : Promise.resolve()
}
cleanPrevActions() {
if (!this.isLocalStorage) return
for (let i in localStorage) {
let prefix = this.options.prefix + ':tab:'
if (i.slice(0, prefix.length) === prefix) {
let time = parseInt(localStorage.getItem(i))
if (Date.now() - time > this.tabTimeout) {
cleanTabActions(this, i.slice(prefix.length))
}
}
}
}
destroy() {
this.onUnload()
this.node.destroy()
clearInterval(this.pinging)
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('pagehide', this.onUnload)
}
}
getClientId() {
return nanoid(8)
}
on(event, listener) {
if (event === 'state') {
return this.node.emitter.on(event, listener)
} else if (event === 'user') {
return this.emitter.on(event, listener)
} else {
return this.log.emitter.on(event, listener)
}
}
onUnload() {
if (this.pinging) cleanTabActions(this, this.tabId)
}
start(connect = true) {
this.cleanPrevActions()
if (connect) this.node.connection.connect()
}
sync(action, meta = {}) {
meta.sync = true
if (typeof meta.id === 'undefined') {
meta.id = this.log.generateId()
}
this.log.add(action, meta)
return track(this, meta.id)
}
type(type, listener, opts) {
if (typeof type === 'function') type = type.type
return this.log.type(type, listener, opts)
}
waitFor(state) {
if (this.state === state) {
return Promise.resolve()
}
return new Promise(resolve => {
let unbind = this.on('state', () => {
if (this.state === state) {
unbind()
resolve()
}
})
})
}
get connected() {
return this.state !== 'disconnected' && this.state !== 'connecting'
}
get state() {
return this.node.state
}
}