UNPKG

@cutls/megalodon

Version:

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

318 lines (317 loc) 10.5 kB
import WS from 'isomorphic-ws'; import { EventEmitter } from 'events'; import MisskeyAPI from './api_client.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; 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 = 'user'; } start() { this._connectionClosed = false; this._resetRetryParams(); this._startWebSocketConnection(); } baseUrlToHost(baseUrl) { const m = baseUrl.match(/(https|wss):\/\/([^/]+)/); if (!m) return ''; return m[2] || ''; } _startWebSocketConnection() { this._resetConnection(); this._setupParser(); this._client = this._connect(); this._bindSocket(this._client); } stop() { this._connectionClosed = true; this._resetConnection(); this._resetRetryParams(); } subscribe(channelID, stream, add) { this._client?.send(JSON.stringify({ type: 'connect', body: { channel: stream, id: channelID, params: add } })); this.channelSubscriptions.push({ type: 'subscribe', stream: channelID, channel: stream, ...add }); } unsubscribe(channelID) { this._client?.send(JSON.stringify({ type: 'disconnect', body: { id: channelID } })); this.channelSubscriptions = this.channelSubscriptions.filter(ch => !(ch.type === 'subscribe' && ch.stream === channelID)); } _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; } _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 } })); break; case 'list': this._client.send(JSON.stringify({ type: 'connect', body: { channel: 'userList', id: this._channelID, params: { listId: this.listId } } })); break; default: this._client.send(JSON.stringify({ type: 'connect', body: { channel: this.channel, id: this._channelID } })); 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 = ({ code }) => { if (code === 1000) { this.emit('close', {}); } else { console.log(`Closed connection with ${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', {}); this._channel(); if (!isBrowser()) { setTimeout(() => { client.ping(''); }, 10000); } for (const ch of this.channelSubscriptions) { const add = { ...ch }; delete add.type; delete add.stream; delete add.channel; this._client?.send(JSON.stringify({ type: 'connect', body: { channel: ch.channel, id: ch.stream, params: add } })); this.channelSubscriptions.push(ch); } if (chs.length) { this.emit('connect', {}); } }; client.onmessage = event => { this.parser.parse(event.data, false, this._channelID); }; client.onerror = event => { this.emit('error', event.error); }; } _setupParser() { this.parser.on('update', (note, ch) => { if (note.id) this._client?.send(JSON.stringify({ type: 's', body: { id: note.id } })); this.emit('update', MisskeyAPI.Converter.note(note, this.baseUrlToHost(this.url)), ch); }); this.parser.on('notification', (notification, ch) => { this.emit('notification', MisskeyAPI.Converter.notification(notification, this.baseUrlToHost(this.url)), ch); }); this.parser.on('conversation', (note, ch) => { this.emit('conversation', MisskeyAPI.Converter.noteToConversation(note, this.baseUrlToHost(this.url)), ch); }); this.parser.on('delete', (noteId, ch) => { this.emit('delete', noteId, ch); }); this.parser.on('error', (err) => { this.emit('parser-error', err); }); } } export class Parser extends EventEmitter { parse(data, isBinary, _channelID) { const message = isBinary ? data : 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 === 'noteUpdated') { if (obj.body.type !== 'deleted') return; const noteId = obj.body.id; this.emit('delete', noteId); } if (obj.type !== 'channel') { return; } if (!obj.body) { return; } body = obj.body; } 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, [body.id]); break; case 'notification': this.emit('notification', body.body, [body.id]); break; case 'mention': { const note = body.body; if (note.visibility === 'specified') { this.emit('conversation', note, [body.id]); } 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; } } }