UNPKG

@cutls/megalodon

Version:

Mastodon, Pleroma, Misskey API client for node.js and browser

248 lines (247 loc) 6.17 kB
import WS from 'isomorphic-ws' import dayjs from 'dayjs' import { EventEmitter } from 'events' import GotosocialAPI from './api_client.js' import { UnknownNotificationTypeError } from '../notification.js' import { isBrowser } from '../default.js' export default class Streaming extends EventEmitter { url stream params parser headers channelSubscriptions _accessToken _reconnectInterval _reconnectMaxAttempts _reconnectCurrentAttempts _connectionClosed _client _pongReceivedTimestamp _heartbeatInterval = 60000 _pongWaiting = false constructor(url, stream, params, accessToken, userAgent) { super() this.url = url this.stream = stream if (params === undefined) { this.params = null } else { this.params = params } this.parser = new Parser() this.headers = { 'User-Agent': userAgent } this._accessToken = accessToken this._reconnectInterval = 10000 this._reconnectMaxAttempts = Infinity this._reconnectCurrentAttempts = 0 this._connectionClosed = false this._client = null this._pongReceivedTimestamp = dayjs() this.channelSubscriptions = [] } start() { this._connectionClosed = false this._resetRetryParams() this._startWebSocketConnection() } _startWebSocketConnection() { this._resetConnection() this._setupParser() this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers) this._bindSocket(this._client) } stop() { this._connectionClosed = true this._resetConnection() this._resetRetryParams() } subscribe(_name, _stream, _add) {} unsubscribe(_stream) {} _resetConnection() { if (this._client) { this._client.close(1000) this._clearBinding() this._client = null } if (this.parser) { this.parser.removeAllListeners() } } _resetRetryParams() { this._reconnectCurrentAttempts = 0 } _reconnect() { setTimeout(() => { if (this._client && this._client.readyState === WS.CONNECTING) { return } if (this._reconnectCurrentAttempts < this._reconnectMaxAttempts) { this._reconnectCurrentAttempts++ this._clearBinding() if (this._client) { if (isBrowser()) { this._client.close() } else { this._client.terminate() } } console.log('Reconnecting') this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers) this._bindSocket(this._client) } }, this._reconnectInterval) } reconnect() { this._reconnect() } _connect(url, stream, params, accessToken, headers) { const parameter = [`stream=${stream}`] if (params) { parameter.push(params) } if (accessToken !== null) { parameter.push(`access_token=${accessToken}`) } const requestURL = `${url}?${parameter.join('&')}` if (isBrowser()) { const cli = new WS(requestURL) return cli } else { const options = { headers: headers } const cli = new WS(requestURL, options) return cli } } _clearBinding() { if (this._client && !isBrowser()) { this._client.removeAllListeners('close') this._client.removeAllListeners('pong') this._client.removeAllListeners('open') this._client.removeAllListeners('message') this._client.removeAllListeners('error') } } _bindSocket(client) { client.onclose = (event) => { if (event.code === 1000) { this.emit('close', {}) } else { console.log(`Closed connection with ${event.code}`) if (!this._connectionClosed) { this._reconnect() } } } client.onopen = (_event) => { this.emit('connect', {}) if (!isBrowser()) { setTimeout(() => { client.ping('') }, 10000) } } client.onmessage = (event) => { this.parser.parse(event) } client.onerror = (event) => { this.emit('error', event.error) } if (!isBrowser()) { client.on('pong', () => { this._pongWaiting = false this.emit('pong', {}) this._pongReceivedTimestamp = dayjs() setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) }) } } _setupParser() { this.parser.on('update', (status) => { this.emit('update', GotosocialAPI.Converter.status(status)) }) this.parser.on('notification', (notification) => { const n = GotosocialAPI.Converter.notification(notification) if (n instanceof UnknownNotificationTypeError) { console.warn(`Unknown notification event has received: ${notification}`) } else { this.emit('notification', n) } }) this.parser.on('delete', (id) => { this.emit('delete', id) }) this.parser.on('status_update', (status) => { this.emit('status_update', GotosocialAPI.Converter.status(status)) }) this.parser.on('error', (err) => { this.emit('parser-error', err) }) this.parser.on('heartbeat', (_) => { this.emit('heartbeat', 'heartbeat') }) } _checkAlive(timestamp) { const now = dayjs() if (now.diff(timestamp) > this._heartbeatInterval - 1000 && !this._connectionClosed) { if (this._client && this._client.readyState !== WS.CONNECTING) { this._pongWaiting = true this._client.ping('') setTimeout(() => { if (this._pongWaiting) { this._pongWaiting = false this._reconnect() } }, 10000) } } } } export class Parser extends EventEmitter { parse(ev) { const data = ev.data const message = data.toString() if (typeof message !== 'string') { this.emit('heartbeat', {}) return } if (message === '') { this.emit('heartbeat', {}) return } let event = '' let payload = '' let mes = {} try { const obj = JSON.parse(message) event = obj.event payload = obj.payload mes = JSON.parse(payload) } catch (err) { if (event !== 'delete') { this.emit('error', new Error(`Error parsing websocket reply: ${message}, error message: ${err}`)) return } } switch (event) { case 'update': this.emit('update', mes) break case 'notification': this.emit('notification', mes) break case 'delete': this.emit('delete', payload) break case 'status.update': this.emit('status_update', mes) break default: this.emit('error', new Error(`Unknown event has received: ${message}`)) } } }