UNPKG

megalodon

Version:

Fediverse API client for node.js and browser

315 lines (314 loc) 9.99 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; _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(); } _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); } _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; } } }