UNPKG

@cutls/megalodon

Version:

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

303 lines (302 loc) 9.95 kB
import WS from 'isomorphic-ws'; import dayjs from 'dayjs'; import { EventEmitter } from 'events'; import MastodonAPI 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(); } start() { try { this._connectionClosed = false; this._resetRetryParams(); this._startWebSocketConnection(); } catch (err) { console.error(err); } } _startWebSocketConnection() { this._resetConnection(); this._setupParser(); const client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers); this._client = client; if (client) this._bindSocket(client); } stop() { this._connectionClosed = true; this._resetConnection(); this._resetRetryParams(); } subscribe(name, _stream, add) { this._client?.send(JSON.stringify({ type: 'subscribe', stream: name, ...add })); this.channelSubscriptions.push({ type: 'subscribe', stream: name, ...add }); } unsubscribe(stream) { this._client?.send(JSON.stringify({ type: 'unsubscribe', stream })); this.channelSubscriptions = this.channelSubscriptions.filter(ch => !(ch.type === 'subscribe' && ch.stream === stream)); } _resetConnection() { if (this._client) { this._client.close(1000); this._clearBinding(); this._client = null; this.channelSubscriptions = []; } 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'); const client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers); this._client = client; if (client) this._bindSocket(client); } }, this._reconnectInterval); } reconnect() { this._reconnect(); } _connect(url, stream, params, accessToken, headers) { const parameter = stream ? [`stream=${stream}`] : []; if (params) { parameter.push(params); } if (accessToken !== null) { parameter.push(`access_token=${accessToken}`); headers.Authorization = `Bearer ${accessToken}`; } const requestURL = `${url}?${parameter.join('&')}`; if (isBrowser()) { try { const cli = new WS(requestURL); return cli; } catch { return null; } } else { const options = { headers: headers }; try { const cli = new WS(requestURL, options); return cli; } catch { return null; } } } _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 => { const chsRaw = structuredClone(this.channelSubscriptions); const chs = [...new Set(chsRaw.map(e => JSON.stringify(e)))].map(e => JSON.parse(e)); this.channelSubscriptions = []; if (!chs.length) this.emit('connect', {}); if (!isBrowser()) { setTimeout(() => { client.ping(''); }, 10000); } for (const ch of chs) { client.send(JSON.stringify(ch)); this.channelSubscriptions.push(ch); } if (chs.length) { this.emit('connect', {}); } }; 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, ch) => { this.emit('update', MastodonAPI.Converter.status(status), ch); }); this.parser.on('notification', (notification, ch) => { const n = MastodonAPI.Converter.notification(notification); if (n instanceof UnknownNotificationTypeError) { console.warn(`Unknown notification event has received: ${notification}`); } else { this.emit('notification', n, ch); } }); this.parser.on('delete', (id, ch) => { this.emit('delete', id, ch); }); this.parser.on('conversation', (conversation, ch) => { this.emit('conversation', MastodonAPI.Converter.conversation(conversation), ch); }); this.parser.on('status_update', (status, ch) => { this.emit('status_update', MastodonAPI.Converter.status(status), ch); }); 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 ch = []; let event = ''; let payload = ''; let mes = {}; try { const obj = JSON.parse(message); event = obj.event; payload = obj.payload; ch = obj.stream || []; 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, ch); break; case 'notification': this.emit('notification', mes, ch); break; case 'conversation': this.emit('conversation', mes, ch); break; case 'delete': this.emit('delete', payload); break; case 'status.update': this.emit('status_update', mes, ch); break; default: this.emit('error', new Error(`Unknown event has received: ${message}`)); } } }