UNPKG

@anycable/core

Version:

AnyCable JavaScript client library core functionality

697 lines (545 loc) 16.5 kB
/*eslint n/no-unsupported-features/es-syntax: ["error", {version: "14.0"}] */ import { createNanoEvents } from 'nanoevents' import { Hub } from '../hub/index.js' import { ReasonError, DisconnectedError, SubscriptionRejectedError } from '../protocol/index.js' import { NoopLogger } from '../logger/index.js' import { Channel } from '../channel/index.js' export class NoConnectionError extends ReasonError { constructor() { super('No connection', 'closed') this.name = 'NoConnectionError' } } export class GhostChannel extends Channel { static identifier = '__ghost__' constructor(channelId, params) { super(params) this.channelId = channelId } set channelId(val) { this._channelId = val } get channelId() { return this._channelId } } export const PUBSUB_CHANNEL = '$pubsub' export class PubSubChannel extends Channel { static identifier = PUBSUB_CHANNEL async perform(action, payload) { if (action.startsWith('$')) { return super.perform(action, payload) } throw Error('not implemented') } } export const STATE = Symbol('state') export class Cable { constructor({ transport, protocol, encoder, logger, lazy, hubOptions, performFailures, transportConfigurator }) { this.emitter = createNanoEvents() this.transport = transport this.encoder = encoder this.logger = logger || new NoopLogger() this.protocol = protocol this.performFailures = performFailures || 'throw' this.protocol.attached(this) this.hub = new Hub(hubOptions || {}) this[STATE] = 'idle' this.handleClose = this.handleClose.bind(this) this.handleIncoming = this.handleIncoming.bind(this) this.transportConfigurator = transportConfigurator this.transport.on('close', this.handleClose) this.transport.on('data', this.handleIncoming) this.initialConnect = true this.recovering = false if (lazy === false) { this.connect().catch(() => {}) } } get state() { return this[STATE] } async connect() { if (this.state === 'connected') return Promise.resolve() if (this.state === 'connecting') { return this.pendingConnect() } let wasIdle = this.state === 'idle' this[STATE] = 'connecting' let promise = this.pendingConnect() this.logger.debug('connecting') try { if (this.transportConfigurator) { await this.transportConfigurator(this.transport, { initial: wasIdle }) } await this.transport.open() } catch (err) { this.handleClose(err) } return promise } setSessionId(sessionId) { this.sessionId = sessionId this.transport.setParam('sid', sessionId) } connected() { if (this.state === 'connected') return this.logger.info('connected') this[STATE] = 'connected' if (this.recovering) { // Make sure channels moved to disconnect state this.hub.subscriptions .all() .forEach(subscription => subscription.notify( 'disconnected', new DisconnectedError('recovery_failed') ) ) } // Re-subscribe channels this.hub.subscriptions .all() .forEach(subscription => this._resubscribe(subscription)) let restored = false this.recovering = false if (this.initialConnect) { this.initialConnect = false this.emit('connect', { reconnect: false, restored }) } else { this.emit('connect', { reconnect: true, restored }) } } restored(remoteIds) { this.logger.info('connection recovered', { remoteIds }) this[STATE] = 'connected' this.hub.subscriptions.all().forEach(subscription => { if ( remoteIds && subscription.remoteId && remoteIds.includes(subscription.remoteId) ) { subscription.notify('restored') } else { subscription.notify( 'disconnected', new DisconnectedError('recovery_failed') ) this._resubscribe(subscription) } }) let reconnect = !this.initialConnect let restored = true this.recovering = false this.initialConnect = false this.emit('connect', { reconnect, restored }) } notify(event, identifier, data) { if (identifier && typeof identifier !== 'string') { data = identifier identifier = undefined } // If identifier is present then it's a channel-level notification if (!identifier) { this.emit('info', { type: event, data }) } else { this.hub.notify(identifier, 'info', { type: event, data }) } } handleClose(err) { this.logger.debug('transport closed', { error: err }) this.disconnected(new DisconnectedError(err, 'transport_closed')) } disconnected(err) { if (!(this.state === 'connected' || this.state === 'connecting')) { return } this.logger.info('disconnected', { reason: err }) this[STATE] = 'disconnected' this.recovering = this.protocol.recoverableClosure(err) if (this.recovering) { this.hub.subscriptions .all() .forEach(subscription => subscription.notify('connecting')) } else { this.hub.subscriptions.all().forEach(subscription => { subscription.notify('disconnected', err) }) } this.protocol.reset(err) this.hub.close() this.transport.close() this.emit('disconnect', err) } closed(reason) { if (this.state === 'closed' || this.state === 'idle') return let err if (reason) { err = reason instanceof DisconnectedError ? reason : new DisconnectedError(reason, undefined) } this.logger.info('closed', { reason: reason || 'user' }) this[STATE] = 'closed' // Channels must transition to the disconnected phase, // since they got reconnected as soon as cable reconnects let channelErr = err || new DisconnectedError('cable_closed') this.hub.subscriptions .all() .forEach(subscription => subscription.notify('disconnected', channelErr)) this.hub.close() this.protocol.reset() this.transport.close() this.initialConnect = true this.emit('close', err) } disconnect() { this.closed() } handleIncoming(raw) { if (this.state === 'closed' || this.state === 'idle') { return } let data = this.encoder.decode(raw) if (data === undefined) { this.logger.error('failed to decode message', { message: raw }) return } this.logger.debug('incoming data', data) let processed = this.protocol.receive(data) if (processed) { this.logger.debug('processed incoming message', processed) let { type, identifier, message, meta } = processed if (type) { this.hub.notify(identifier, type, message) } else { this.hub.transmit(identifier, message, meta) } } } send(msg) { if (this.state === 'closed') { throw Error('Cable is closed') } let data = this.encoder.encode(msg) if (data === undefined) { this.logger.error('failed to encode message', { message: msg }) return } this.logger.debug('outgoing message', msg) this.transport.send(data) } keepalive(msg) { this.emit('keepalive', msg) } streamFrom(name) { let channel = new PubSubChannel({ stream_name: name }) return this.subscribe(channel) } streamFromSigned(name) { let channel = new PubSubChannel({ signed_stream_name: name }) return this.subscribe(channel) } subscribeTo(ChannelClass, params) { let channel let ghostName if (typeof ChannelClass === 'string') { ghostName = ChannelClass ChannelClass = GhostChannel } channel = ghostName ? new ChannelClass(ghostName, params) : new ChannelClass(params) return this.subscribe(channel) } subscribe(channel) { // Return if channel has been already attached to the cable if (!channel.attached(this)) return channel let identifier = channel.identifier channel.connecting() let subscription = this.hub.subscriptions.get(identifier) || this.hub.subscriptions.create(identifier, { subscribe: sub => { return this._subscribe(sub, channel.channelId, channel.params) }, unsubscribe: sub => this._unsubscribe(sub) }) subscription.add(channel) if ( subscription.intent === 'subscribed' && subscription.state === 'connected' ) { channel.connected() } subscription.ensureSubscribed() return channel } async _resubscribe(subscription) { if (subscription.intent !== 'subscribed') return let channel = subscription.channels[0] if (!channel) return subscription.notify('connecting') subscription.ensureResubscribed() } async _subscribe(subscription, channelId, params) { let identifier = subscription.id if (this.state === 'idle') { // Trigger connection initialization if it is lazy this.connect().catch(() => {}) } // We will call _subscribe again as soon as cable connected if (this.state !== 'connected') { this.logger.debug('cancel subscribe, no connection', { identifier }) return } this.logger.debug('acquiring subscribe lock', { identifier }) let lock = await subscription.acquire('subscribed') if (lock.canceled) { this.logger.debug('subscribe lock has been canceled', { identifier }) lock.release() return } this.logger.debug('subscribe lock has been acquired', { identifier }) if (subscription.intent !== 'subscribed') { this.logger.debug('cancel subscribe request, already unsubscribed') lock.release() return } // We need to check one more time after a lock has been acquired if (this.state !== 'connected') { this.logger.debug('cancel subscribe, no connection', { identifier }) lock.release() return } // Finally, check whether we already connected — then we can skip the action if (subscription.state === 'connected') { this.logger.debug('already connected, skip subscribe command', { identifier }) subscription.notify('connected') lock.release() return } let channelMeta = { identifier: channelId, params } this.logger.debug('subscribing', channelMeta) try { let remoteId = await this.protocol.subscribe(channelId, params) this.hub.subscribe(identifier, remoteId) this.logger.debug('subscribed', { ...channelMeta, remoteId }) subscription.notify('connected') } catch (err) { if (err) { if (err instanceof SubscriptionRejectedError) { this.logger.warn('rejected', channelMeta) } if (err instanceof DisconnectedError) { this.logger.debug( 'disconnected during subscription; will retry on connect', channelMeta ) lock.release() return } this.logger.error('failed to subscribe', { error: err, ...channelMeta }) } subscription.close(err) this.hub.unsubscribe(identifier) } lock.release() } unsubscribe(channel) { let identifier = channel.identifier let subscription = this.hub.subscriptions.get(identifier) if (!subscription) { throw Error(`Subscription not found: ${identifier}`) } subscription.remove(channel) channel.closed() subscription.maybeUnsubscribe() } async _unsubscribe(subscription) { let identifier = subscription.id this.logger.debug('acquiring unsubscribe lock', { identifier }) let lock = await subscription.acquire('unsubscribed') if (lock.canceled) { this.logger.debug('unsubscribe lock has been canceled', { identifier }) lock.release() return } this.logger.debug('unsubscribe lock has been acquired', { identifier }) // Check if we still want to unsubscribe if (subscription.intent !== 'unsubscribed') { this.logger.debug('cancel unsubscribe, no longer needed', { identifier, intent: subscription.intent }) lock.release() return } // Finally, check whether we already disconnected — then we can skip the action if ( subscription.state === 'disconnected' || subscription.state === 'closed' ) { this.logger.debug( `already ${subscription.state} connected, skip unsubscribe command`, { identifier } ) lock.release() return } let remoteId = subscription.remoteId this.logger.debug('unsubscribing...', { remoteId }) if (this.state !== 'connected') { this.logger.debug('unsubscribe skipped (cable is not connected)', { id: identifier }) subscription.close() this.hub.unsubscribe(identifier) lock.release() return } try { await this.protocol.unsubscribe(remoteId) this.logger.debug('unsubscribed remotely', { id: identifier }) } catch (err) { if (err) { // We assume that server unsubscribes subscriptions on disconnect, // So we can mark it as closed locally. if (err instanceof DisconnectedError) { this.logger.debug( 'cable disconnected during the unsubscribe command execution', { id: identifier, error: err } ) } else { this.logger.error('unsubscribe failed', { id: identifier, error: err }) } } } if (subscription.intent === 'unsubscribed') { subscription.close() this.hub.unsubscribe(identifier) } else { // We rely on state when performing commands, // make sure it's set to closed here (it shouldn't stay connected) subscription.state = 'closed' } lock.release() } async perform(identifier, action, payload) { if (this.performFailures === 'throw') { return this._perform(identifier, action, payload) } try { return await this._perform(identifier, action, payload) } catch (err) { if (this.performFailures === 'warn') { this.logger.warn('perform failed', { error: err }) } return undefined } } async _perform(identifier, action, payload) { if (this.state === 'connecting') { await this.pendingConnect() } if (this.state === 'closed' || this.state === 'disconnected') { throw new NoConnectionError() } let subscription = this.hub.subscriptions.get(identifier) if (!subscription) { throw Error(`Subscription not found: ${identifier}`) } await subscription.pending('subscribed') if (subscription.intent !== 'subscribed') { throw Error(`Subscription is closed: ${identifier}`) } let remoteId = subscription.remoteId let performMeta = { id: remoteId, action, payload } this.logger.debug('perform', performMeta) try { let res = await this.protocol.perform(remoteId, action, payload) if (res) { this.logger.debug('perform result', { message: res, request: performMeta }) } return res } catch (err) { this.logger.error('perform failed', { error: err, request: performMeta }) throw err } } 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) } pendingConnect() { if (this._pendingConnect) return this._pendingConnect this._pendingConnect = new Promise((resolve, reject) => { let unbind = [() => delete this._pendingConnect] unbind.push( this.on('connect', () => { unbind.forEach(clbk => clbk()) resolve() }) ) unbind.push( this.on('close', err => { unbind.forEach(clbk => clbk()) reject(err) }) ) unbind.push( this.on('disconnect', err => { unbind.forEach(clbk => clbk()) reject(err) }) ) }) return this._pendingConnect } }