UNPKG

@anycable/echo

Version:

AnyCable broadcaster adapter for Laravel Echo

335 lines (283 loc) 7.3 kB
/** * Base channel class for AnyCable Echo channels */ export class PublicChannel { constructor(cable, name, options) { this.cable = cable this.name = name this.options = options this.subscription = null this.listeners = new Map() this.subscribed_callbacks = [] this.error_callbacks = [] this.subscribe() } /** * Subscribe to the channel */ async subscribe() { try { this.subscription = await this.createStreamSubscription(this.name) } catch (error) { this.cable.logger.error('failed to create a subscription:', error) this.error_callbacks.forEach(callback => callback(error)) return } this.subscription.on('connect', () => { this.subscribed_callbacks.forEach(callback => callback()) }) this.subscription.on('disconnect', error => { this.error_callbacks.forEach(callback => callback(error)) }) this.subscription.on('message', data => { this.handleMessage(data) }) } async createStreamSubscription(name) { return this.cable.streamFrom(name) } /** * Handle incoming messages. * * Messages are expected to be in the form: `{"event": "<string>", "data": <object>} */ handleMessage(msg) { let { event, data } = msg if (!event) return let listeners = this.listeners.get(event) if (listeners) { listeners.forEach(callback => { try { callback(data) } catch (e) { this.cable.error('error in event listener:', e) } }) } } /** * Listen for an event on the channel */ listen(event, callback) { event = this.options.eventFormatter.format(event) if (!this.listeners.has(event)) { this.listeners.set(event, []) } this.listeners.get(event).push(callback) return this } /** * Listen for whisper events (client events) */ listenForWhisper(event, callback) { return this.listen('.client-' + event, callback) } /** * Listen for Laravel notifications */ notification(callback) { return this.listen( 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', callback ) } /** * Stop listening for an event */ stopListening(event, callback) { event = this.options.eventFormatter.format(event) let listeners = this.listeners.get(event) if (listeners) { if (callback) { let index = listeners.indexOf(callback) if (index > -1) { listeners.splice(index, 1) } } else { this.listeners.set(event, []) } } return this } /** * Stop listening for whisper events */ stopListeningForWhisper(event, callback) { return this.stopListening('.client-' + event, callback) } /** * Register a callback for successful subscription */ subscribed(callback) { this.subscribed_callbacks.push(callback) // If already connected, call immediately if (this.subscription && this.subscription.state === 'connected') { callback() } return this } /** * Register a callback for errors */ error(callback) { this.error_callbacks.push(callback) return this } /** * Unsubscribe from the channel */ unsubscribe() { if (this.subscription) { this.subscription.disconnect() this.subscription = null } this.listeners.clear() this.subscribed_callbacks = [] this.error_callbacks = [] } } /** * Private channel implementation with Laravel authentication */ export class PrivateChannel extends PublicChannel { constructor(cable, name, options) { super(cable, name, options) this.isAuthenticated = false this.authData = null } /** * Create a signed stream subscription */ async createStreamSubscription(name) { this.authData = await this.authenticate(name) this.isAuthenticated = true if (this.authData.signed_stream_name) { return this.cable.streamFromSigned(this.authData.signed_stream_name) } else { throw new Error('no signed stream name in auth response') } } /** * Authenticate with Laravel broadcasting endpoint */ async authenticate(name) { let authEndpoint = this.options.authEndpoint let headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', ...(this.options.auth?.headers || {}) } let body = { socket_id: this.cable.sessionId || 'anycable-session', channel_name: name } let response = await fetch(authEndpoint, { method: 'POST', headers, body: JSON.stringify(body), credentials: 'same-origin' }) if (!response.ok) { throw new Error( `Authentication failed: ${response.status} ${response.statusText}` ) } let data = await response.json() return data } /** * Send a whisper event to other clients */ whisper(eventName, data) { if (this.subscription && this.isAuthenticated) { this.subscription.whisper({ event: `client-${eventName}`, data }) } return this } } export class PresenceChannel extends PrivateChannel { constructor(cable, name, options) { super(cable, name, options) this.members = new Map() this.me = null } /** * Create a signed stream subscription and join the presence set */ async createStreamSubscription(name) { let subscription = await super.createStreamSubscription(name) if (!this.authData) { throw new Error( 'user presence data is missing in the authorization response' ) } this.me = this.authData.presence subscription.presence.join(this.me.id.toString(), this.me) // trigger initial presence set request subscription.presence.info() subscription.on('presence', ev => { let { type, info, id, records } = ev if (type === 'join') { this.members.set(id, info) this.triggerPresenceEvent('joining', info) } if (type === 'leave') { let userInfo = this.members.get(id) if (userInfo) { this.members.delete(id) this.triggerPresenceEvent('leaving', userInfo) } } if (type === 'info') { this.members.clear() records.forEach(entry => { this.members.set(entry.id, entry.info) }) this.triggerPresenceEvent('here', Array.from(this.members.values())) } }) return subscription } /** * Trigger presence-specific events */ triggerPresenceEvent(event, data) { let listeners = this.listeners.get(event) if (listeners) { listeners.forEach(callback => { try { callback(data) } catch (e) { this.cable.logger.error('error in presence event listener:', e) } }) } } /** * Get current members */ getMembers() { return Array.from(this.members.values()) } /** * Listen for users joining */ here(callback) { return this.listen('.here', callback) } /** * Listen for users joining */ joining(callback) { return this.listen('.joining', callback) } /** * Listen for users leaving */ leaving(callback) { return this.listen('.leaving', callback) } }