@anycable/core
Version:
AnyCable JavaScript client library core functionality
206 lines (155 loc) • 4.39 kB
JavaScript
/*eslint n/no-unsupported-features/es-syntax: ["error", {version: "14.0"}] */
import { createNanoEvents } from 'nanoevents'
import { ReasonError } from '../protocol/index.js'
import { stringifyParams } from '../stringify-params/index.js'
import { Presence } from './presence.js'
const STATE = Symbol('state')
export class Channel {
// Unique channel identifier
// static identifier = ''
constructor(params = {}) {
this.emitter = createNanoEvents()
this.params = Object.freeze(params)
this.presence = new Presence(this)
this.initialConnect = true
this[STATE] = 'idle'
}
get identifier() {
if (this._identifier) return this._identifier
// Use Action Cable identifiers as internal identifiers for channels
this._identifier = stringifyParams({
channel: this.channelId,
...this.params
})
return this._identifier
}
get channelId() {
return this.constructor.identifier
}
get state() {
return this[STATE]
}
attached(receiver) {
if (this.receiver) {
if (this.receiver !== receiver) {
throw Error('Already connected to a different receiver')
}
return false
}
this.receiver = receiver
return true
}
connecting() {
this[STATE] = 'connecting'
}
connected() {
if (this.state === 'connected') return
if (this.state === 'closed') return
this[STATE] = 'connected'
let restored = false
if (this.initialConnect) {
this.initialConnect = false
this.emit('connect', { reconnect: false, restored })
} else {
this.emit('connect', { reconnect: true, restored })
}
}
restored() {
if (this.state === 'connected') throw Error('Already connected')
this[STATE] = 'connected'
let restored = true
let reconnect = true
this.initialConnect = false
this.emit('connect', { reconnect, restored })
}
disconnected(err) {
if (this.state === 'disconnected' || this.state === 'closed') return
this[STATE] = 'disconnected'
this.presence.reset()
this.emit('disconnect', err)
}
closed(err) {
if (this.state === 'closed') return
this[STATE] = 'closed'
delete this.receiver
this.initialConnect = true
this.presence.dispose()
this.emit('close', err)
}
disconnect() {
if (this.state === 'idle' || this.state === 'closed') {
return
}
this.receiver.unsubscribe(this)
}
async perform(action, payload) {
if (this.state === 'idle' || this.state === 'closed') {
throw Error('Channel is not subscribed')
}
return this.receiver.perform(this.identifier, action, payload)
}
async send(payload) {
return this.perform(undefined, payload)
}
async whisper(payload) {
try {
await this.perform('$whisper', payload)
} catch (e) {
let logger = this.receiver ? this.receiver.logger : null
if (logger) {
logger.warn('whisper failed: ', e)
}
}
}
receive(msg, meta) {
this.emit('message', msg, meta)
}
on(event, callback) {
return this.emitter.on(event, callback)
}
once(event, callback) {
let unbind = this.emitter.on(event, (...args) => {
unbind()
callback(...args)
})
return unbind
}
emit(event, ...args) {
return this.emitter.emit(event, ...args)
}
ensureSubscribed() {
if (this.state === 'connected') return Promise.resolve()
if (this.state === 'closed') {
return Promise.reject(Error('Channel is unsubscribed'))
}
return this.pendingSubscribe()
}
// This promise resolves when subscription is confirmed
// and rejects when rejected or closed.
// It ignores disconnect events.
pendingSubscribe() {
if (this._pendingSubscribe) return this._pendingSubscribe
this._pendingSubscribe = new Promise((resolve, reject) => {
let unbind = [() => delete this._pendingSubscribe]
unbind.push(
this.on('connect', () => {
unbind.forEach(clbk => clbk())
resolve()
})
)
unbind.push(
this.on('close', err => {
unbind.forEach(clbk => clbk())
reject(
err ||
new ReasonError(
'Channel was disconnected before subscribing',
'canceled'
)
)
})
)
})
return this._pendingSubscribe
}
}