UNPKG

megalodon

Version:

Fediverse API client for node.js and browser

253 lines (252 loc) 8.23 kB
import WS from 'isomorphic-ws'; import dayjs from 'dayjs'; import { EventEmitter } from 'events'; import PleromaAPI from './api_client.js'; import { UnknownNotificationTypeError } from '../notification.js'; import { isBrowser } from '../default.js'; export default class WebSocket extends EventEmitter { url; stream; params; parser; headers; _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() { 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(); } _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); } _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', PleromaAPI.Converter.status(status)); }); this.parser.on('notification', (notification) => { const n = PleromaAPI.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('conversation', (conversation) => { this.emit('conversation', PleromaAPI.Converter.conversation(conversation)); }); this.parser.on('status_update', (status) => { this.emit('status_update', PleromaAPI.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 'conversation': this.emit('conversation', 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}`)); } } }