UNPKG

@cutls/megalodon

Version:

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

325 lines (324 loc) 7.3 kB
import WS from 'isomorphic-ws' import dayjs from 'dayjs' import { v4 as uuid } from 'uuid' import { EventEmitter } from 'events' import FirefishAPI from './api_client.js' import { UnknownNotificationTypeError } from '../notification.js' import { isBrowser } from '../default.js' export default class WebSocket extends EventEmitter { url channel parser headers listId = null channelSubscriptions = [] _accessToken _reconnectInterval _reconnectMaxAttempts _reconnectCurrentAttempts _connectionClosed _client = null _channelID _pongReceivedTimestamp _heartbeatInterval = 60000 _pongWaiting = false constructor(url, channel, accessToken, listId, userAgent) { super() this.url = url this.parser = new Parser() this.channel = channel this.headers = { 'User-Agent': userAgent } if (listId === undefined) { this.listId = null } else { this.listId = listId } this._accessToken = accessToken this._reconnectInterval = 10000 this._reconnectMaxAttempts = Infinity this._reconnectCurrentAttempts = 0 this._connectionClosed = false this._channelID = uuid() this._pongReceivedTimestamp = dayjs() } start() { this._connectionClosed = false this._resetRetryParams() this._startWebSocketConnection() } _startWebSocketConnection() { this._resetConnection() this._setupParser() this._client = this._connect() 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 } _connect() { const requestURL = `${this.url}?i=${this._accessToken}` if (isBrowser()) { const cli = new WS(requestURL) return cli } else { const options = { headers: this.headers } const cli = new WS(requestURL, options) return cli } } _channel() { if (!this._client) { return } switch (this.channel) { case 'conversation': this._client.send( JSON.stringify({ type: 'connect', body: { channel: 'main', id: this._channelID } }) ) break case 'user': this._client.send( JSON.stringify({ type: 'connect', body: { channel: 'main', id: this._channelID } }) ) this._client.send( JSON.stringify({ type: 'connect', body: { channel: 'homeTimeline', id: this._channelID, params: { withReplies: false } } }) ) break case 'list': this._client.send( JSON.stringify({ type: 'connect', body: { channel: 'userList', id: this._channelID, params: { listId: this.listId, withReplies: false } } }) ) break default: this._client.send( JSON.stringify({ type: 'connect', body: { channel: this.channel, id: this._channelID, params: { withReplies: false } } }) ) break } } _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._bindSocket(this._client) } }, this._reconnectInterval) } reconnect() { this._reconnect() } _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', {}) this._channel() if (!isBrowser()) { setTimeout(() => { client.ping('') }, 10000) } } client.onmessage = (event) => { this.parser.parse(event, this._channelID) } 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', (note) => { this.emit('update', FirefishAPI.Converter.note(note)) }) this.parser.on('notification', (notification) => { const n = FirefishAPI.Converter.notification(notification) if (n instanceof UnknownNotificationTypeError) { console.warn(`Unknown notification event has received: ${notification}`) } else { this.emit('notification', n) } }) this.parser.on('conversation', (note) => { this.emit('conversation', FirefishAPI.Converter.noteToConversation(note)) }) this.parser.on('error', (err) => { this.emit('parser-error', err) }) } _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, channelID) { const data = ev.data const message = data.toString() if (typeof message !== 'string') { this.emit('heartbeat', {}) return } if (message === '') { this.emit('heartbeat', {}) return } let obj let body try { obj = JSON.parse(message) if (obj.type !== 'channel') { return } if (!obj.body) { return } body = obj.body if (body.id !== channelID) { return } } catch (err) { this.emit('error', new Error(`Error parsing websocket reply: ${message}, error message: ${err}`)) return } switch (body.type) { case 'note': this.emit('update', body.body) break case 'notification': this.emit('notification', body.body) break case 'mention': { const note = body.body if (note.visibility === 'specified') { this.emit('conversation', note) } break } case 'renote': case 'followed': case 'follow': case 'unfollow': case 'receiveFollowRequest': case 'meUpdated': case 'readAllNotifications': case 'readAllUnreadSpecifiedNotes': case 'readAllAntennas': case 'readAllUnreadMentions': case 'unreadNotification': break default: this.emit('error', new Error(`Unknown event has received: ${JSON.stringify(body)}`)) break } } }