@logux/server
Version:
Build own Logux server or make proxy between WebSocket and HTTP backend on any language
1,046 lines (943 loc) • 29.6 kB
JavaScript
import { LoguxNotFoundError } from '@logux/actions'
import { Log, MemoryStore, parseId, ServerConnection } from '@logux/core'
import { createNanoEvents } from 'nanoevents'
import { nanoid } from 'nanoid'
import { promises as fs } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import UrlPattern from 'url-pattern'
import { WebSocketServer } from 'ws'
import { bindBackendProxy } from '../bind-backend-proxy/index.js'
import { bindControlServer } from '../bind-control-server/index.js'
import { Context } from '../context/index.js'
import { createHttpServer } from '../create-http-server/index.js'
import { ServerClient } from '../server-client/index.js'
const SKIP_PROCESS = Symbol('skipProcess')
const RESEND_META = ['channels', 'users', 'clients', 'nodes']
function optionError(msg) {
let error = new Error(msg)
error.logux = true
error.note = 'Check server constructor and Logux Server documentation'
throw error
}
let helloCache
async function readHello() {
if (!helloCache) {
let hello = await fs.readFile(
join(fileURLToPath(import.meta.url), '..', 'hello.html')
)
helloCache = hello.toString()
}
return helloCache
}
export async function wasNot403(cb) {
try {
await cb()
return true
} catch (e) {
if (e.name === 'ResponseError' && e.statusCode === 403) {
return false
}
throw e
}
}
function normalizeTypeCallbacks(name, callbacks) {
if (callbacks && callbacks.accessAndProcess) {
callbacks.access = (ctx, ...args) => {
return wasNot403(async () => {
await callbacks.accessAndProcess(ctx, ...args)
ctx[SKIP_PROCESS] = true
})
}
callbacks.process = async (ctx, ...args) => {
if (!ctx[SKIP_PROCESS]) await callbacks.accessAndProcess(ctx, ...args)
}
}
if (!callbacks || !callbacks.access) {
throw new Error(`${name} must have access callback`)
}
}
function normalizeChannelCallbacks(pattern, callbacks) {
if (callbacks && callbacks.accessAndLoad) {
callbacks.access = (ctx, ...args) => {
return wasNot403(async () => {
try {
ctx.data.load = await callbacks.accessAndLoad(ctx, ...args)
} catch (e) {
if (e.name === 'LoguxNotFoundError') {
ctx.data.notFound = true
} else if (e.name === 'ResponseError' && e.statusCode === 404) {
ctx.data.notFound = true
} else {
throw e
}
}
})
}
callbacks.load = ctx => {
if (ctx.data.notFound) {
throw new LoguxNotFoundError()
} else {
return ctx.data.load
}
}
}
if (!callbacks || !callbacks.access) {
throw new Error(`Channel ${pattern} must have access callback`)
}
}
function subscriberFilterId(action) {
return JSON.stringify(action.filter || {})
}
export class BaseServer {
constructor(opts = {}) {
this.options = opts
this.env = this.options.env || process.env.NODE_ENV || 'development'
if (
typeof this.options.subprotocol === 'undefined' &&
typeof this.options.backend === 'undefined'
) {
throw optionError('Missed `subprotocol` option in server constructor')
}
if (
typeof this.options.supports === 'undefined' &&
typeof this.options.backend === 'undefined'
) {
throw optionError('Missed `supports` option in server constructor')
}
if (this.options.key && !this.options.cert) {
throw optionError('You must set `cert` option if you use `key` option')
}
if (!this.options.key && this.options.cert) {
throw optionError('You must set `key` option if you use `cert` option')
}
if (!this.options.server) {
if (!this.options.port) this.options.port = 31337
if (!this.options.host) this.options.host = '127.0.0.1'
}
this.nodeId = `server:${this.options.id || nanoid(8)}`
if (this.options.fileUrl) {
this.options.root = dirname(fileURLToPath(this.options.fileUrl))
}
this.options.root = this.options.root || process.cwd()
this.options.controlMask = this.options.controlMask || '127.0.0.1/8'
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.logger = console
this.contexts = new WeakMap()
this.log = log
let cleaned = {}
this.on('preadd', (action, meta) => {
let isLogux = action.type.slice(0, 6) === 'logux/'
if (!meta.server) {
meta.server = this.nodeId
}
if (!meta.status && !isLogux) {
meta.status = 'waiting'
}
if (meta.id.split(' ')[1] === this.nodeId) {
if (!meta.subprotocol) {
meta.subprotocol = this.options.subprotocol
}
if (
!isLogux &&
!this.options.backend &&
!this.types[action.type] &&
!this.getRegexProcessor(action.type)
) {
meta.status = 'processed'
}
}
this.replaceResendShortcuts(meta)
})
this.on('add', async (action, meta) => {
let start = Date.now()
if (meta.reasons.length === 0) {
cleaned[meta.id] = true
this.emitter.emit('report', 'addClean', { action, meta })
} else {
this.emitter.emit('report', 'add', { action, meta })
}
if (this.destroying && !this.actionToQueue.has(meta.id)) {
return
}
if (action.type === 'logux/subscribe') {
if (meta.server === this.nodeId) {
this.subscribeAction(action, meta, start)
}
return
}
if (action.type === 'logux/unsubscribe') {
if (meta.server === this.nodeId) {
this.unsubscribeAction(action, meta)
}
return
}
let processor = this.getProcessor(action.type)
if (processor && processor.resend && meta.status === 'waiting') {
let ctx = this.createContext(action, meta)
let resend
try {
resend = await processor.resend(ctx, action, meta)
} catch (e) {
this.undo(action, meta, 'error')
this.emitter.emit('error', e, action, meta)
this.finally(processor, ctx, action, meta)
return
}
if (resend) {
if (typeof resend === 'string') {
resend = { channels: [resend] }
} else if (Array.isArray(resend)) {
resend = { channels: resend }
} else {
this.replaceResendShortcuts(resend)
}
let diff = {}
for (let i of RESEND_META) {
if (resend[i]) diff[i] = resend[i]
}
await this.log.changeMeta(meta.id, diff)
meta = { ...meta, ...diff }
}
}
if (this.isUseless(action, meta)) {
this.emitter.emit('report', 'useless', { action, meta })
}
await this.sendAction(action, meta)
if (meta.status === 'waiting') {
if (!processor) {
this.internalUnknownType(action, meta)
return
}
if (processor.process) {
this.processAction(processor, action, meta, start)
} else {
this.emitter.emit('processed', action, meta, 0)
this.finally(
processor,
this.createContext(action, meta),
action,
meta
)
this.markAsProcessed(meta)
}
} else {
this.emitter.emit('processed', action, meta, 0)
this.finally(processor, this.createContext(action, meta), action, meta)
}
})
this.on('clean', (action, meta) => {
if (cleaned[meta.id]) {
delete cleaned[meta.id]
return
}
this.emitter.emit('report', 'clean', { actionId: meta.id })
})
this.emitter = createNanoEvents()
this.on('fatal', err => {
this.emitter.emit('report', 'error', { err, fatal: true })
})
this.on('error', (err, action, meta) => {
if (meta) {
this.emitter.emit('report', 'error', { actionId: meta.id, err })
} else if (err.nodeId) {
this.emitter.emit('report', 'error', { err, nodeId: err.nodeId })
} else if (err.connectionId) {
this.emitter.emit('report', 'error', {
connectionId: err.connectionId,
err
})
}
if (this.env === 'development') this.debugError(err)
})
this.on('clientError', err => {
if (err.nodeId) {
this.emitter.emit('report', 'clientError', { err, nodeId: err.nodeId })
} else if (err.connectionId) {
this.emitter.emit('report', 'clientError', {
connectionId: err.connectionId,
err
})
}
})
this.on('connected', client => {
this.emitter.emit('report', 'connect', {
connectionId: client.key,
ipAddress: client.remoteAddress
})
})
this.on('disconnected', client => {
if (!client.zombie) {
if (client.nodeId) {
this.emitter.emit('report', 'disconnect', { nodeId: client.nodeId })
} else {
this.emitter.emit('report', 'disconnect', {
connectionId: client.key
})
}
}
})
this.unbind = []
this.connected = new Map()
this.nodeIds = new Map()
this.clientIds = new Map()
this.userIds = new Map()
this.types = {}
this.regexTypes = new Map()
this.processing = 0
this.lastClient = 0
this.channels = []
this.subscribers = {}
this.authAttempts = {}
this.unknownTypes = {}
this.wrongChannels = {}
this.timeouts = {}
this.lastTimeout = 0
this.typeToQueue = new Map()
this.queues = new Map()
this.actionToQueue = new Map()
this.controls = {
'GET /': {
async request() {
return {
body: await readHello(),
headers: { 'Content-Type': 'text/html' }
}
},
safe: true
},
'GET /health': {
request: () => ({
body: 'Logux Server: OK\n',
headers: { 'Content-Type': 'text/plain' }
}),
safe: true
}
}
this.listenNotes = {}
bindBackendProxy(this)
let end = (actionId, queue, queueKey, ...args) => {
this.actionToQueue.delete(actionId)
if (queue.length() === 0) {
this.queues.delete(queueKey)
}
queue.next(...args)
}
let undoRemainingTasks = queue => {
let remainingTasks = queue.getQueue()
if (remainingTasks) {
for (let task of remainingTasks) {
this.undo(task.action, task.meta, 'error')
this.actionToQueue.delete(task.meta.id)
}
}
queue.killAndDrain()
}
this.on('error', (e, action, meta) => {
let queueKey = this.actionToQueue.get(meta?.id)
if (queueKey) {
let queue = this.queues.get(queueKey)
undoRemainingTasks(queue)
end(meta.id, queue, queueKey, e)
}
})
this.on('processed', (action, meta) => {
if (action.type === 'logux/undo') {
let queueKey = this.actionToQueue.get(action.id)
if (queueKey) {
let queue = this.queues.get(queueKey)
undoRemainingTasks(queue)
end(action.id, queue, queueKey, null, meta)
}
} else if (action.type === 'logux/processed') {
let queueKey = this.actionToQueue.get(action.id)
if (queueKey) {
let queue = this.queues.get(queueKey)
end(action.id, queue, queueKey, null, meta)
}
} else if (
action.type !== 'logux/subscribed' &&
action.type !== 'logux/unsubscribed'
) {
let queueKey = this.actionToQueue.get(meta.id)
if (queueKey) {
let queue = this.queues.get(queueKey)
end(meta.id, queue, queueKey, null, meta)
}
}
})
this.unbind.push(() => {
for (let i of this.connected.values()) i.destroy()
for (let i in this.timeouts) {
clearTimeout(this.timeouts[i])
}
})
this.unbind.push(() => {
return new Promise(resolve => {
if (this.processing === 0) {
resolve()
} else {
this.on('processed', () => {
if (this.processing === 0) resolve()
})
}
})
})
this.unbind.push(() => {
return Promise.allSettled(
[...this.queues.values()].map(queue => {
return new Promise(resolve => {
queue.drain = resolve
})
})
)
})
}
addClient(connection) {
this.lastClient += 1
let key = this.lastClient.toString()
let client = new ServerClient(this, connection, key)
this.connected.set(key, client)
return this.lastClient
}
auth(authenticator) {
this.authenticator = authenticator
}
buildUndo(action, meta, reason, extra) {
let undoMeta = { status: 'processed' }
if (meta.users) undoMeta.users = meta.users.slice(0)
if (meta.nodes) undoMeta.nodes = meta.nodes.slice(0)
if (meta.clients) undoMeta.clients = meta.clients.slice(0)
if (meta.reasons) undoMeta.reasons = meta.reasons.slice(0)
if (meta.channels) undoMeta.channels = meta.channels.slice(0)
if (meta.excludeClients) {
undoMeta.excludeClients = meta.excludeClients.slice(0)
}
let undoAction = {
...extra,
action,
id: meta.id,
reason,
type: 'logux/undo'
}
return [undoAction, undoMeta]
}
channel(pattern, callbacks, options = {}) {
normalizeChannelCallbacks(`Channel ${pattern}`, callbacks)
let channel = Object.assign({}, callbacks)
if (typeof pattern === 'string') {
channel.pattern = new UrlPattern(pattern, {
segmentValueCharset: '^/'
})
} else {
channel.regexp = pattern
}
channel.queueName = options.queue || 'main'
this.channels.push(channel)
}
createContext(action, meta) {
let context = this.contexts.get(action)
if (!context) {
context = new Context(this, meta)
this.contexts.set(action, context)
}
return context
}
debugActionError(meta, msg) {
if (this.env === 'development') {
let clientId = parseId(meta.id).clientId
if (this.clientIds.has(clientId)) {
this.clientIds.get(clientId).connection.send(['debug', 'error', msg])
}
}
}
debugError(error) {
for (let i of this.connected.values()) {
if (i.connection.connected) {
try {
i.connection.send(['debug', 'error', error.stack])
} catch {}
}
}
}
denyAction(action, meta) {
this.emitter.emit('report', 'denied', { actionId: meta.id })
this.undo(action, meta, 'denied')
this.debugActionError(meta, `Action "${meta.id}" was denied`)
}
destroy() {
this.destroying = true
this.emitter.emit('report', 'destroy')
return Promise.all(this.unbind.map(i => i()))
}
finally(processor, ctx, action, meta) {
this.contexts.delete(action)
if (processor && processor.finally) {
try {
processor.finally(ctx, action, meta)
} catch (err) {
this.emitter.emit('error', err, action, meta)
}
}
}
getProcessor(type) {
return (
this.types[type] || this.getRegexProcessor(type) || this.otherProcessor
)
}
getRegexProcessor(type) {
for (let regexp of this.regexTypes.keys()) {
if (type.match(regexp) !== null) {
return this.regexTypes.get(regexp)
}
}
return undefined
}
http(listener) {
if (this.options.disableHttpServer) {
throw new Error(
'`server.http()` can not be called when `disableHttpServer` enabled'
)
}
this.httpListener = listener
}
internalUnknownType(action, meta) {
this.contexts.delete(action)
this.log.changeMeta(meta.id, { status: 'error' })
this.emitter.emit('report', 'unknownType', {
actionId: meta.id,
type: action.type
})
if (parseId(meta.id).userId !== 'server') {
this.undo(action, meta, 'unknownType')
}
this.debugActionError(meta, `Action with unknown type ${action.type}`)
}
internalWrongChannel(action, meta) {
this.contexts.delete(action)
this.emitter.emit('report', 'wrongChannel', {
actionId: meta.id,
channel: action.channel
})
this.undo(action, meta, 'wrongChannel')
this.debugActionError(meta, `Wrong channel name ${action.channel}`)
}
isBruteforce(ip) {
let attempts = this.authAttempts[ip]
return attempts && attempts >= 3
}
isUseless(action, meta) {
if (
meta.status !== 'processed' ||
this.types[action.type] ||
this.getRegexProcessor(action.type)
) {
return false
}
for (let i of ['channels', 'nodes', 'clients', 'users']) {
if (Array.isArray(meta[i]) && meta[i].length > 0) return false
}
return true
}
async listen() {
if (!this.authenticator) {
throw new Error('You must set authentication callback by server.auth()')
}
this.httpServer = await createHttpServer(this.options)
this.ws = new WebSocketServer({ server: this.httpServer })
if (!this.options.server) {
await new Promise((resolve, reject) => {
this.ws.on('error', reject)
this.httpServer.listen(this.options.port, this.options.host, resolve)
})
}
bindControlServer(this, this.httpListener)
this.unbind.push(
() =>
new Promise(resolve => {
this.ws.on('close', resolve)
this.ws.close()
})
)
if (this.httpServer) {
this.unbind.push(
() =>
new Promise(resolve => {
this.httpServer.on('close', resolve)
this.httpServer.close()
})
)
}
let pkg = JSON.parse(
await fs.readFile(
join(fileURLToPath(import.meta.url), '..', '..', 'package.json')
)
)
this.ws.on('connection', (ws, req) => {
ws.upgradeReq = req
this.addClient(new ServerConnection(ws))
})
this.emitter.emit('report', 'listen', {
backend: this.options.backend,
cert: !!this.options.cert,
controlMask: this.options.controlMask,
controlSecret: this.options.controlSecret,
environment: this.env,
host: this.options.host,
loguxServer: pkg.version,
nodeId: this.nodeId,
notes: this.listenNotes,
port: this.options.port,
redis: this.options.redis,
server: !!this.options.server,
subprotocol: this.options.subprotocol,
supports: this.options.supports
})
}
markAsProcessed(meta) {
this.log.changeMeta(meta.id, { status: 'processed' })
let data = parseId(meta.id)
if (data.userId !== 'server') {
this.log.add(
{ id: meta.id, type: 'logux/processed' },
{ clients: [data.clientId], status: 'processed' }
)
}
}
on(event, listener) {
if (event === 'preadd' || event === 'add' || event === 'clean') {
return this.log.emitter.on(event, listener)
} else {
return this.emitter.on(event, listener)
}
}
otherChannel(callbacks) {
normalizeChannelCallbacks('Unknown channel', callbacks)
if (this.otherSubscriber) {
throw new Error('Callbacks for unknown channel are already defined')
}
let channel = Object.assign({}, callbacks)
channel.pattern = {
match(name) {
return [name]
}
}
this.otherSubscriber = channel
}
otherType(callbacks) {
if (this.otherProcessor) {
throw new Error('Callbacks for unknown types are already defined')
}
normalizeTypeCallbacks('Unknown type', callbacks)
this.otherProcessor = callbacks
}
performUnsubscribe(clientNodeId, action, meta) {
if (this.subscribers[action.channel]) {
let subscriber = this.subscribers[action.channel][clientNodeId]
if (subscriber) {
if (subscriber.unsubscribe) {
subscriber.unsubscribe(action, meta)
this.contexts.delete(action)
}
let filterId = subscriberFilterId(action)
delete subscriber.filters[filterId]
if (Object.keys(subscriber.filters).length === 0) {
delete this.subscribers[action.channel][clientNodeId]
}
if (Object.keys(this.subscribers[action.channel]).length === 0) {
delete this.subscribers[action.channel]
}
}
}
this.emitter.emit('unsubscribed', action, meta, clientNodeId)
this.emitter.emit('report', 'unsubscribed', {
actionId: meta.id,
channel: action.channel
})
}
process(action, meta = {}) {
return new Promise((resolve, reject) => {
let unbindError = this.on('error', (e, errorAction) => {
if (errorAction === action) {
unbindError()
unbindProcessed()
reject(e)
}
})
let unbindProcessed = this.on('processed', (processed, processedMeta) => {
if (processed === action) {
unbindError()
unbindProcessed()
resolve(processedMeta)
}
})
this.log.add(action, meta)
})
}
async processAction(processor, action, meta, start) {
let ctx = this.createContext(action, meta)
let latency
this.processing += 1
try {
await processor.process(ctx, action, meta)
latency = Date.now() - start
this.markAsProcessed(meta)
} catch (e) {
this.log.changeMeta(meta.id, { status: 'error' })
this.undo(action, meta, 'error')
this.emitter.emit('error', e, action, meta)
} finally {
this.finally(processor, ctx, action, meta)
}
if (typeof latency === 'undefined') latency = Date.now() - start
this.processing -= 1
this.emitter.emit('processed', action, meta, latency)
}
rememberBadAuth(ip) {
this.authAttempts[ip] = (this.authAttempts[ip] || 0) + 1
this.setTimeout(() => {
if (this.authAttempts[ip] === 1) {
delete this.authAttempts[ip]
} else {
this.authAttempts[ip] -= 1
}
}, 3000)
}
replaceResendShortcuts(meta) {
if (meta.channel) {
meta.channels = [meta.channel]
delete meta.channel
}
if (meta.user) {
meta.users = [meta.user]
delete meta.user
}
if (meta.client) {
meta.clients = [meta.client]
delete meta.client
}
if (meta.node) {
meta.nodes = [meta.node]
delete meta.node
}
}
async sendAction(action, meta) {
let from = parseId(meta.id).clientId
let ignoreClients = new Set(meta.excludeClients || [])
ignoreClients.add(from)
if (meta.nodes) {
for (let id of meta.nodes) {
let client = this.nodeIds.get(id)
if (client) {
ignoreClients.add(client.clientId)
client.node.onAdd(action, meta)
}
}
}
if (meta.clients) {
for (let id of meta.clients) {
if (this.clientIds.has(id)) {
let client = this.clientIds.get(id)
ignoreClients.add(client.clientId)
client.node.onAdd(action, meta)
}
}
}
if (meta.users) {
for (let userId of meta.users) {
let users = this.userIds.get(userId)
if (users) {
for (let client of users) {
if (!ignoreClients.has(client.clientId)) {
ignoreClients.add(client.clientId)
client.node.onAdd(action, meta)
}
}
}
}
}
if (meta.channels) {
for (let channel of meta.channels) {
if (this.subscribers[channel]) {
for (let nodeId in this.subscribers[channel]) {
let clientId = parseId(nodeId).clientId
if (!ignoreClients.has(clientId)) {
let subscriber = this.subscribers[channel][nodeId]
if (subscriber) {
let ctx = this.createContext(action, meta)
let client = this.clientIds.get(clientId)
for (let filter of Object.values(subscriber.filters)) {
filter =
typeof filter === 'function'
? await filter(ctx, action, meta)
: filter
if (filter && client) {
ignoreClients.add(clientId)
client.node.onAdd(action, meta)
}
}
}
}
}
}
}
}
}
setTimeout(callback, ms) {
this.lastTimeout += 1
let id = this.lastTimeout
this.timeouts[id] = setTimeout(() => {
delete this.timeouts[id]
callback()
}, ms)
}
subscribe(nodeId, channel) {
if (!this.subscribers[channel] || !this.subscribers[channel][nodeId]) {
if (!this.subscribers[channel]) {
this.subscribers[channel] = {}
}
this.subscribers[channel][nodeId] = { filters: { '{}': true } }
this.log.add({ channel, type: 'logux/subscribed' }, { nodes: [nodeId] })
}
}
async subscribeAction(action, meta, start) {
if (typeof action.channel !== 'string') {
this.wrongChannel(action, meta)
return
}
let channels = this.channels
if (this.otherSubscriber) {
channels = this.channels.concat([this.otherSubscriber])
}
let match
for (let channel of channels) {
if (channel.pattern) {
match = channel.pattern.match(action.channel)
} else {
match = action.channel.match(channel.regexp)
}
let subscribed = false
if (match) {
let ctx = this.createContext(action, meta)
ctx.params = match
try {
let access = await channel.access(ctx, action, meta)
if (this.wrongChannels[meta.id]) {
delete this.wrongChannels[meta.id]
return
}
if (!access) {
this.denyAction(action, meta)
return
}
let client = this.clientIds.get(ctx.clientId)
if (!client) {
this.emitter.emit('subscriptionCancelled')
return
}
let filterId = subscriberFilterId(action)
let filters = { [filterId]: true }
if (channel.filter) {
let filter = await channel.filter(ctx, action, meta)
filters = { [filterId]: filter }
}
this.emitter.emit('report', 'subscribed', {
actionId: meta.id,
channel: action.channel
})
if (!this.subscribers[action.channel]) {
this.subscribers[action.channel] = {}
this.emitter.emit('subscribing', action, meta)
}
let subscriber = this.subscribers[action.channel][ctx.nodeId]
if (subscriber) {
filters = { ...subscriber.filters, ...filters }
}
this.subscribers[action.channel][ctx.nodeId] = {
filters,
unsubscribe: channel.unsubscribe
? (unsubscribeAction, unsubscribeMeta) =>
channel.unsubscribe(ctx, unsubscribeAction, unsubscribeMeta)
: undefined
}
subscribed = true
if (channel.load) {
let sendBack = await channel.load(ctx, action, meta)
if (Array.isArray(sendBack)) {
await Promise.all(
sendBack.map(i => {
return Array.isArray(i) ? ctx.sendBack(...i) : ctx.sendBack(i)
})
)
} else if (sendBack) {
await ctx.sendBack(sendBack)
}
}
this.emitter.emit('subscribed', action, meta, Date.now() - start)
this.markAsProcessed(meta)
} catch (e) {
if (e.name === 'LoguxNotFoundError') {
this.undo(action, meta, 'notFound')
} else {
this.emitter.emit('error', e, action, meta)
this.undo(action, meta, 'error')
}
if (subscribed) {
this.unsubscribe(action, meta)
}
} finally {
this.finally(channel, ctx, action, meta)
}
break
}
}
if (!match) this.wrongChannel(action, meta)
}
type(name, callbacks, options = {}) {
let queue = options.queue || 'main'
this.typeToQueue.set(name, queue)
if (typeof name === 'function') name = name.type
normalizeTypeCallbacks(`Action type ${name}`, callbacks)
if (name instanceof RegExp) {
this.regexTypes.set(name, callbacks)
} else {
if (this.types[name]) {
throw new Error(`Action type ${name} was already defined`)
}
this.types[name] = callbacks
}
}
undo(action, meta, reason = 'error', extra = {}) {
let clientId = parseId(meta.id).clientId
let [undoAction, undoMeta] = this.buildUndo(action, meta, reason, extra)
undoMeta.clients = (undoMeta.clients || []).concat([clientId])
return this.log.add(undoAction, undoMeta)
}
unknownType(action, meta) {
this.internalUnknownType(action, meta)
this.unknownTypes[meta.id] = true
}
unsubscribe(action, meta) {
let clientNodeId = meta.id.split(' ')[1]
this.performUnsubscribe(clientNodeId, action, meta)
}
unsubscribeAction(action, meta) {
if (typeof action.channel !== 'string') {
this.wrongChannel(action, meta)
return
}
this.unsubscribe(action, meta)
this.markAsProcessed(meta)
this.contexts.delete(action)
}
wrongChannel(action, meta) {
this.internalWrongChannel(action, meta)
this.wrongChannels[meta.id] = true
}
}