@anycable/core
Version:
AnyCable JavaScript client library core functionality
242 lines (192 loc) • 5.9 kB
JavaScript
import {
SubscriptionRejectedError,
SubscriptionTimeoutError,
DisconnectedError
} from '../protocol/index.js'
import { stringifyParams } from '../stringify-params/index.js'
import { NoopLogger } from '../logger/index.js'
let commandID = 0
export class ActionCableProtocol {
constructor(opts = {}) {
let { logger } = opts
this.logger = logger || new NoopLogger()
this.pendingSubscriptions = {}
this.pendingUnsubscriptions = {}
// For how long to wait before sending `subscribe` command
// in case `unsubscribe` was sent for the same identifier
this.subscribeCooldownInterval = opts.subscribeCooldownInterval || 250
// For how long to wait for subscription acknoledgement before trying again
// (just once).
this.subscribeRetryInterval = opts.subscribeRetryInterval || 5000
}
attached(cable) {
this.cable = cable
}
subscribe(channel, params) {
let subscriptionPayload = { channel }
if (params) {
Object.assign(subscriptionPayload, params)
}
let identifier = stringifyParams(subscriptionPayload)
if (this.pendingUnsubscriptions[identifier]) {
let cooldown = this.subscribeCooldownInterval * 1.5
this.logger.debug(
`unsubscribed recently, cooldown for ${cooldown}`,
identifier
)
return new Promise(resolve => {
setTimeout(() => {
resolve(this.subscribe(channel, params))
}, cooldown)
})
}
if (this.pendingSubscriptions[identifier]) {
this.logger.warn('subscription is already pending, skipping', identifier)
return Promise.reject(Error('Already subscribing'))
}
let retryInterval = this.subscribeRetryInterval
return new Promise((resolve, reject) => {
let id = ++commandID
this.pendingSubscriptions[identifier] = {
resolve,
reject,
id
}
this.cable.send(this.buildSubscribeRequest(identifier))
this.maybeRetrySubscribe(id, identifier, retryInterval)
})
}
buildSubscribeRequest(identifier) {
return {
command: 'subscribe',
identifier
}
}
maybeRetrySubscribe(id, identifier, retryInterval) {
setTimeout(() => {
let sub = this.pendingSubscriptions[identifier]
if (!sub) return
if (sub.id !== id) return
this.logger.warn(
`no subscription ack received in ${retryInterval}ms, retrying subscribe`,
identifier
)
this.cable.send(this.buildSubscribeRequest(identifier))
this.maybeExpireSubscribe(id, identifier, retryInterval)
}, retryInterval)
}
maybeExpireSubscribe(id, identifier, retryInterval) {
setTimeout(() => {
let sub = this.pendingSubscriptions[identifier]
if (!sub) return
if (sub.id !== id) return
delete this.pendingSubscriptions[identifier]
sub.reject(
new SubscriptionTimeoutError(
`Haven't received subscription ack in ${
retryInterval * 2
}ms for ${identifier}`
)
)
}, retryInterval)
}
unsubscribe(identifier) {
this.cable.send({
command: 'unsubscribe',
identifier
})
this.pendingUnsubscriptions[identifier] = true
setTimeout(() => {
delete this.pendingUnsubscriptions[identifier]
}, this.subscribeCooldownInterval)
return Promise.resolve()
}
perform(identifier, action, payload) {
// Handle whispering separately
if (action === '$whisper') {
return this.whisper(identifier, payload)
}
if (!payload) {
payload = {}
}
payload.action ||= action
this.cable.send({
command: 'message',
identifier,
data: JSON.stringify(payload)
})
return Promise.resolve()
}
whisper(identifier, data) {
this.cable.send({
command: 'whisper',
identifier,
data
})
return Promise.resolve()
}
receive(msg) {
/* eslint-disable consistent-return */
if (typeof msg !== 'object') {
this.logger.error('unsupported message format', { message: msg })
return
}
let { type, identifier, message, reason, reconnect } = msg
if (type === 'ping') {
return this.cable.keepalive(msg.message)
} else {
// Any incoming message may be considered as a heartbeat
this.cable.keepalive()
}
if (type === 'welcome') {
let sessionId = msg.sid
if (sessionId) this.cable.setSessionId(sessionId)
return this.cable.connected()
}
if (type === 'disconnect') {
let err = new DisconnectedError(reason)
this.reset(err)
if (reconnect === false) {
this.cable.closed(err)
} else {
this.cable.disconnected(err)
}
return
}
if (type === 'confirm_subscription') {
let subscription = this.pendingSubscriptions[identifier]
if (!subscription) {
this.logger.error('subscription not found, unsubscribing', {
type,
identifier
})
this.unsubscribe(identifier)
return
}
delete this.pendingSubscriptions[identifier]
return subscription.resolve(identifier)
}
if (type === 'reject_subscription') {
let subscription = this.pendingSubscriptions[identifier]
if (!subscription) {
return this.logger.error('subscription not found', { type, identifier })
}
delete this.pendingSubscriptions[identifier]
return subscription.reject(new SubscriptionRejectedError())
}
if (message) {
return { identifier, message }
}
this.logger.warn(`unknown message type: ${type}`, { message: msg })
}
reset(err) {
// Reject pending subscriptions
for (let identifier in this.pendingSubscriptions) {
this.pendingSubscriptions[identifier].reject(err)
}
this.pendingSubscriptions = {}
}
recoverableClosure() {
return false
}
}