diff --git a/.gitignore b/.gitignore index ee23ad8..959bead 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/** package-lock.json browser.js test/auth.js +.vscode docs-out diff --git a/src/client.js b/src/client.js index f030a08..a653652 100644 --- a/src/client.js +++ b/src/client.js @@ -4,12 +4,38 @@ const EventEmitter = require('events'); const { setTimeout, clearTimeout } = require('timers'); const fetch = require('node-fetch'); const transports = require('./transports'); -const { RPCCommands, RPCEvents, RelationshipTypes } = require('./constants'); +const { BASE_API_URL, RPCCommands, RPCEvents, RelationshipTypes } = require('./constants'); const { pid: getPid, uuid } = require('./util'); - -function subKey(event, args) { - return `${event}${JSON.stringify(args)}`; -} +const { Error, TypeError, RangeError } = require('./errors'); + +const subKey = (event, args) => `${event}${JSON.stringify(args)}`; + +const formatVoiceSettings = (data) => ({ + automaticGainControl: data.automatic_gain_control, + echoCancellation: data.echo_cancellation, + noiseSuppression: data.noise_suppression, + qos: data.qos, + silenceWarning: data.silence_warning, + deaf: data.deaf, + mute: data.mute, + input: { + availableDevices: data.input.available_devices, + device: data.input.device_id, + volume: data.input.volume, + }, + output: { + availableDevices: data.output.available_devices, + device: data.output.device_id, + volume: data.output.volume, + }, + mode: { + type: data.mode.type, + autoThreshold: data.mode.auto_threshold, + threshold: data.mode.threshold, + shortcut: data.mode.shortcut, + delay: data.mode.delay, + }, +}); /** * @typedef {RPCClientOptions} @@ -48,35 +74,17 @@ class RPCClient extends EventEmitter { const Transport = transports[options.transport]; if (!Transport) { - throw new TypeError('RPC_INVALID_TRANSPORT', options.transport); + throw new TypeError('INVALID_TYPE', 'options.transport', '\'ipc\' or \'websocket\'', options.transport); } - this.fetch = (method, path, { data, query } = {}) => - fetch(`${this.fetch.endpoint}${path}${query ? new URLSearchParams(query) : ''}`, { - method, - body: data, - headers: { - Authorization: `Bearer ${this.accessToken}`, - }, - }).then(async (r) => { - const body = await r.json(); - if (!r.ok) { - const e = new Error(r.status); - e.body = body; - throw e; - } - return body; - }); - - this.fetch.endpoint = 'https://discord.com/api'; - /** * Raw transport userd * @type {RPCTransport} * @private */ this.transport = new Transport(this); - this.transport.on('message', this._onRpcMessage.bind(this)); + this._onRpcClose = this._onRpcClose.bind(this); + this._onRpcMessage = this._onRpcMessage.bind(this); /** * Map of nonces being expected from the transport @@ -92,7 +100,42 @@ class RPCClient extends EventEmitter { */ this._subscriptions = new Map(); - this._connectPromise = undefined; + this._connectPromise = null; + + /** + * Whether or not the client is connected + * @type {boolean} + */ + this.connected = false; + } + + /** + * @typedef {Object} RequestOptions + * @prop {Record} [data] Request data + * @prop {string|[string, string][]|URLSearchParams} [query] Request query + */ + + /** + * Makes an API Request. + * @param {string} method Request method + * @param {string} path Request path + * @param {RequestOptions} [options] Request Options + */ + async fetch(method, path, { data, query } = {}) { + const response = await fetch(`${BASE_API_URL}/${path}${query ? `?${new URLSearchParams(query)}` : ''}`, { + method, + body: data, + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + const body = await response.json(); + if (!response.ok) { + const error = new Error(response.status); + error.body = body; + throw error; + } + return body; } /** @@ -103,21 +146,32 @@ class RPCClient extends EventEmitter { return this._connectPromise; } this._connectPromise = new Promise((resolve, reject) => { - this.clientId = clientId; - const timeout = setTimeout(() => reject(new Error('RPC_CONNECTION_TIMEOUT')), 10e3); - timeout.unref(); - this.once('connected', () => { + /* eslint-disable no-use-before-define */ + const removeListeners = () => { + this.transport.off('close', onClose); + this.off('connected', onConnect); + this.off('destroyed', onClose); clearTimeout(timeout); + }; + /* eslint-enable no-use-before-define */ + const onConnect = (() => { + this.connected = true; + removeListeners(); resolve(this); }); - this.transport.once('close', () => { - this._expecting.forEach((e) => { - e.reject(new Error('connection closed')); - }); - this.emit('disconnected'); - reject(new Error('connection closed')); - }); - this.transport.connect().catch(reject); + const onClose = (error) => { + removeListeners(); + this.destroy(); + reject(error || new Error('CONNECTION_CLOSED')); + }; + this.once('destroyed', onClose); + this.once('connected', onConnect); + this.transport.once('close', onClose); + this._setupListeners(); + + this.clientId = clientId; + this.transport.connect().catch(onClose); + const timeout = setTimeout(onClose, 10e3).unref(); }); return this._connectPromise; } @@ -160,42 +214,81 @@ class RPCClient extends EventEmitter { * @returns {Promise} * @private */ - request(cmd, args, evt) { + request(command, args, event) { + if (!this.connected) { + return Promise.reject(new Error('NOT_CONNECTED')); + } return new Promise((resolve, reject) => { const nonce = uuid(); - this.transport.send({ cmd, args, evt, nonce }); + this.transport.send({ cmd: command, args, evt: event, nonce }); this._expecting.set(nonce, { resolve, reject }); }); } + /** + * Add event listeners to transport + * @private + */ + _setupListeners() { + this.transport.on('message', this._onRpcMessage); + this.transport.once('close', this._onRpcClose); + } + + /** + * Remove all attached event listeners on transport + * @param {boolean} [emitClose=false] Whether to emit the `close` event rather than clearing it + * @private + */ + _removeListeners(emitClose = false) { + this.transport.off('message', this._onRpcMessage); + if (emitClose) { + this.transport.emit('close'); + } else { + this.transport.off('close', this._onRpcClose); + } + } + + /** + * RPC Close handler. + * @private + */ + _onRpcClose() { + for (const { reject } of this._expecting) { + reject(new Error('CONNECTION_CLOSED')); + } + this._expecting.clear(); + } + /** * Message handler * @param {Object} message message * @private */ - _onRpcMessage(message) { - if (message.cmd === RPCCommands.DISPATCH && message.evt === RPCEvents.READY) { - if (message.data.user) { - this.user = message.data.user; - } - this.emit('connected'); - } else if (this._expecting.has(message.nonce)) { - const { resolve, reject } = this._expecting.get(message.nonce); - if (message.evt === 'ERROR') { - const e = new Error(message.data.message); - e.code = message.data.code; - e.data = message.data; - reject(e); + _onRpcMessage({ args, cmd: command, data, evt: event, nonce }) { + if (nonce && this._expecting.has(nonce)) { + const { resolve, reject } = this._expecting.get(nonce); + if (event === 'ERROR') { + const error = new Error(data.message); + error.code = data.code; + error.data = data; + reject(error); } else { - resolve(message.data); + resolve(data); } - this._expecting.delete(message.nonce); - } else { - const subid = subKey(message.evt, message.args); - if (!this._subscriptions.has(subid)) { + this._expecting.delete(nonce); + } else if (command === RPCCommands.DISPATCH) { + if (event === RPCEvents.READY) { + if (data.user) { + this.user = data.user; + } + this.emit('connected'); return; } - this._subscriptions.get(subid)(message.data); + const subId = subKey(event, args); + if (!this._subscriptions.has(subId)) { + return; + } + this._subscriptions.get(subId)(args); } } @@ -207,7 +300,7 @@ class RPCClient extends EventEmitter { */ async authorize({ scopes, clientSecret, rpcToken, redirectUri } = {}) { if (clientSecret && rpcToken === true) { - const body = await this.fetch('POST', '/oauth2/token/rpc', { + const body = await this.fetch('POST', 'oauth2/token/rpc', { data: new URLSearchParams({ client_id: this.clientId, client_secret: clientSecret, @@ -222,7 +315,7 @@ class RPCClient extends EventEmitter { rpc_token: rpcToken, }); - const response = await this.fetch('POST', '/oauth2/token', { + const response = await this.fetch('POST', 'oauth2/token', { data: new URLSearchParams({ client_id: this.clientId, client_secret: clientSecret, @@ -241,15 +334,13 @@ class RPCClient extends EventEmitter { * @returns {Promise} * @private */ - authenticate(accessToken) { - return this.request('AUTHENTICATE', { access_token: accessToken }) - .then(({ application, user }) => { - this.accessToken = accessToken; - this.application = application; - this.user = user; - this.emit('ready'); - return this; - }); + async authenticate(accessToken) { + const { application, user } = await this.request('AUTHENTICATE', { access_token: accessToken }); + this.accessToken = accessToken; + this.application = application; + this.user = user; + this.emit('ready'); + return this; } @@ -268,8 +359,9 @@ class RPCClient extends EventEmitter { * @param {number} [timeout] Timeout request * @returns {Promise>} */ - getGuilds(timeout) { - return this.request(RPCCommands.GET_GUILDS, { timeout }); + async getGuilds(timeout) { + const { guilds } = await this.request(RPCCommands.GET_GUILDS, { timeout }); + return guilds; } /** @@ -320,16 +412,16 @@ class RPCClient extends EventEmitter { */ setCertifiedDevices(devices) { return this.request(RPCCommands.SET_CERTIFIED_DEVICES, { - devices: devices.map((d) => ({ - type: d.type, - id: d.uuid, - vendor: d.vendor, - model: d.model, - related: d.related, - echo_cancellation: d.echoCancellation, - noise_suppression: d.noiseSuppression, - automatic_gain_control: d.automaticGainControl, - hardware_mute: d.hardwareMute, + devices: devices.map((data) => ({ + type: data.type, + id: data.uuid, + vendor: data.vendor, + model: data.model, + related: data.related, + echo_cancellation: data.echoCancellation, + noise_suppression: data.noiseSuppression, + automatic_gain_control: data.automaticGainControl, + hardware_mute: data.hardwareMute, })), }); } @@ -387,34 +479,9 @@ class RPCClient extends EventEmitter { * Get current voice settings * @returns {Promise} */ - getVoiceSettings() { - return this.request(RPCCommands.GET_VOICE_SETTINGS) - .then((s) => ({ - automaticGainControl: s.automatic_gain_control, - echoCancellation: s.echo_cancellation, - noiseSuppression: s.noise_suppression, - qos: s.qos, - silenceWarning: s.silence_warning, - deaf: s.deaf, - mute: s.mute, - input: { - availableDevices: s.input.available_devices, - device: s.input.device_id, - volume: s.input.volume, - }, - output: { - availableDevices: s.output.available_devices, - device: s.output.device_id, - volume: s.output.volume, - }, - mode: { - type: s.mode.type, - autoThreshold: s.mode.auto_threshold, - threshold: s.mode.threshold, - shortcut: s.mode.shortcut, - delay: s.mode.delay, - }, - })); + async getVoiceSettings() { + const data = await this.request(RPCCommands.GET_VOICE_SETTINGS); + return formatVoiceSettings(data); } /** @@ -423,8 +490,8 @@ class RPCClient extends EventEmitter { * @param {Object} args Settings * @returns {Promise} */ - setVoiceSettings(args) { - return this.request(RPCCommands.SET_VOICE_SETTINGS, { + async setVoiceSettings(args) { + const data = await this.request(RPCCommands.SET_VOICE_SETTINGS, { automatic_gain_control: args.automaticGainControl, echo_cancellation: args.echoCancellation, noise_suppression: args.noiseSuppression, @@ -448,6 +515,7 @@ class RPCClient extends EventEmitter { delay: args.mode.delay, } : undefined, }); + return formatVoiceSettings(data); } /** @@ -458,85 +526,112 @@ class RPCClient extends EventEmitter { * @param {Function} callback Callback handling keys * @returns {Promise} */ - captureShortcut(callback) { - const subid = subKey(RPCEvents.CAPTURE_SHORTCUT_CHANGE); + async captureShortcut(callback) { + const subId = subKey(RPCEvents.CAPTURE_SHORTCUT_CHANGE); const stop = () => { - this._subscriptions.delete(subid); + this._subscriptions.delete(subId); return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'STOP' }); }; - this._subscriptions.set(subid, ({ shortcut }) => { + this._subscriptions.set(subId, ({ shortcut }) => { callback(shortcut, stop); }); - return this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'START' }) - .then(() => stop); + await this.request(RPCCommands.CAPTURE_SHORTCUT, { action: 'START' }); + return stop; } + /** + * @typedef {Date|number|string} DateResolvable + */ + + /** + * @typedef {Object} PresenceButton + * @prop {string} label The label for the button + * @prop {string} url The URL opened when the button is clicked + */ + + /** + * @typedef {Object} PresenceData + * @prop {DateResolvable} [endTimestamp] End of the activity + * @prop {DateResolvable} [startTimestamp] Start of this activity + * @prop {string} [largeImageKey] The asset name for the large image + * @prop {string} [largeImageText] The hover text for the large image + * @prop {string} [smallImageKey] The asset name for the small image + * @prop {string} [smallImageText] The hover text for the small image + * @prop {string} [partyId] The party ID + * @prop {number} [partyMax] The party max + * @prop {number} [partySize] The party size + * @prop {string} [joinSecret] The join secret + * @prop {string} [matchSecret] The match secret + * @prop {string} [spectateSecret] The spectate secret + * @prop {boolean} [instance] Whether this activity is an instanced game session + * @prop {PresenceButton[]} [buttons] Buttons for the Presence + */ + /** * Sets the presence for the logged in user. - * @param {object} args The rich presence to pass. + * @param {PresenceData} data The rich presence to pass. * @param {number} [pid] The application's process ID. Defaults to the executing process' PID. * @returns {Promise} */ - setActivity(args = {}, pid = getPid()) { - let timestamps; - let assets; - let party; - let secrets; - if (args.startTimestamp || args.endTimestamp) { - timestamps = { - start: args.startTimestamp, - end: args.endTimestamp, - }; - if (timestamps.start instanceof Date) { - timestamps.start = Math.round(timestamps.start.getTime()); - } - if (timestamps.end instanceof Date) { - timestamps.end = Math.round(timestamps.end.getTime()); - } - if (timestamps.start > 2147483647000) { - throw new RangeError('timestamps.start must fit into a unix timestamp'); + setActivity(data = {}, pid = getPid()) { + const activity = { + instance: Boolean(data.instance), + }; + + if ('buttons' in data) { + activity.buttons = data.buttons; + } + + const timestamps = activity.timestamps = {}; + if ('endTimestamp' in data) { + const timestamp = timestamps.end = new Date(data.endTimestamp).getTime(); + if (timestamp > 2147483647000) { + throw new RangeError('TIMESTAMP_TOO_LARGE', 'args.endTimestamp'); } - if (timestamps.end > 2147483647000) { - throw new RangeError('timestamps.end must fit into a unix timestamp'); + } + if ('startTimestamp' in data) { + const timestamp = timestamps.start = new Date(data.startTimestamp).getTime(); + if (timestamp > 2147483647000) { + throw new RangeError('TIMESTAMP_TOO_LARGE', 'args.startTimestamp'); } } - if ( - args.largeImageKey || args.largeImageText - || args.smallImageKey || args.smallImageText - ) { - assets = { - large_image: args.largeImageKey, - large_text: args.largeImageText, - small_image: args.smallImageKey, - small_text: args.smallImageText, - }; + + const assets = activity.assets = {}; + if ('largeImageKey' in data) { + assets.large_image = data.largeImageKey; } - if (args.partySize || args.partyId || args.partyMax) { - party = { id: args.partyId }; - if (args.partySize || args.partyMax) { - party.size = [args.partySize, args.partyMax]; - } + if ('largeImageText' in data) { + assets.large_text = data.largeImageText; } - if (args.matchSecret || args.joinSecret || args.spectateSecret) { - secrets = { - match: args.matchSecret, - join: args.joinSecret, - spectate: args.spectateSecret, - }; + if ('smallImageKey' in data) { + assets.small_image = data.smallImageKey; + } + if ('smallImageText' in data) { + assets.small_text = data.smallImageText; + } + + const party = activity.party = {}; + if ('partyId' in data) { + party.id = data.partyId; + } + if ('partyMax' in data && 'partySize' in data) { + party.size = [data.partySize, data.partyMax]; + } + + const secrets = activity.secrets = {}; + if ('joinSecret' in data) { + secrets.join = data.joinSecret; + } + if ('matchSecret' in data) { + secrets.match = data.matchSecret; + } + if ('spectateSecret' in data) { + secrets.spectate = data.spectateSecret; } return this.request(RPCCommands.SET_ACTIVITY, { pid, - activity: { - state: args.state, - details: args.details, - timestamps, - assets, - party, - secrets, - buttons: args.buttons, - instance: !!args.instance, - }, + activity, }); } @@ -546,8 +641,8 @@ class RPCClient extends EventEmitter { * @param {number} [pid] The application's process ID. Defaults to the executing process' PID. * @returns {Promise} */ - clearActivity(pid = getPid()) { - return this.request(RPCCommands.SET_ACTIVITY, { + async clearActivity(pid = getPid()) { + await this.request(RPCCommands.SET_ACTIVITY, { pid, }); } @@ -557,8 +652,8 @@ class RPCClient extends EventEmitter { * @param {User} user The user to invite * @returns {Promise} */ - sendJoinInvite(user) { - return this.request(RPCCommands.SEND_ACTIVITY_JOIN_INVITE, { + async sendJoinInvite(user) { + await this.request(RPCCommands.SEND_ACTIVITY_JOIN_INVITE, { user_id: user.id || user, }); } @@ -568,8 +663,8 @@ class RPCClient extends EventEmitter { * @param {User} user The user whose game you want to request to join * @returns {Promise} */ - sendJoinRequest(user) { - return this.request(RPCCommands.SEND_ACTIVITY_JOIN_REQUEST, { + async sendJoinRequest(user) { + await this.request(RPCCommands.SEND_ACTIVITY_JOIN_REQUEST, { user_id: user.id || user, }); } @@ -579,8 +674,8 @@ class RPCClient extends EventEmitter { * @param {User} user The user whose request you wish to reject * @returns {Promise} */ - closeJoinRequest(user) { - return this.request(RPCCommands.CLOSE_ACTIVITY_JOIN_REQUEST, { + async closeJoinRequest(user) { + await this.request(RPCCommands.CLOSE_ACTIVITY_JOIN_REQUEST, { user_id: user.id || user, }); } @@ -593,8 +688,8 @@ class RPCClient extends EventEmitter { }); } - updateLobby(lobby, { type, owner, capacity, metadata } = {}) { - return this.request(RPCCommands.UPDATE_LOBBY, { + async updateLobby(lobby, { type, owner, capacity, metadata } = {}) { + await this.request(RPCCommands.UPDATE_LOBBY, { id: lobby.id || lobby, type, owner_id: (owner && owner.id) || owner, @@ -603,8 +698,8 @@ class RPCClient extends EventEmitter { }); } - deleteLobby(lobby) { - return this.request(RPCCommands.DELETE_LOBBY, { + async deleteLobby(lobby) { + await this.request(RPCCommands.DELETE_LOBBY, { id: lobby.id || lobby, }); } @@ -616,34 +711,34 @@ class RPCClient extends EventEmitter { }); } - sendToLobby(lobby, data) { - return this.request(RPCCommands.SEND_TO_LOBBY, { - id: lobby.id || lobby, + async sendToLobby(lobby, data) { + await this.request(RPCCommands.SEND_TO_LOBBY, { + lobby_id: lobby.id || lobby, data, }); } - disconnectFromLobby(lobby) { - return this.request(RPCCommands.DISCONNECT_FROM_LOBBY, { + async disconnectFromLobby(lobby) { + await this.request(RPCCommands.DISCONNECT_FROM_LOBBY, { id: lobby.id || lobby, }); } - updateLobbyMember(lobby, user, metadata) { - return this.request(RPCCommands.UPDATE_LOBBY_MEMBER, { + async updateLobbyMember(lobby, user, metadata) { + await this.request(RPCCommands.UPDATE_LOBBY_MEMBER, { lobby_id: lobby.id || lobby, user_id: user.id || user, metadata, }); } - getRelationships() { + async getRelationships() { const types = Object.keys(RelationshipTypes); - return this.request(RPCCommands.GET_RELATIONSHIPS) - .then((o) => o.relationships.map((r) => ({ - ...r, - type: types[r.type], - }))); + const { relationships } = await this.request(RPCCommands.GET_RELATIONSHIPS); + return relationships.map((data) => ({ + ...data, + type: types[data.type], + })); } /** @@ -653,19 +748,20 @@ class RPCClient extends EventEmitter { * @param {Function} callback Callback when an event for the subscription is triggered * @returns {Promise} */ - subscribe(event, args, callback) { + async subscribe(event, args, callback) { if (!callback && typeof args === 'function') { callback = args; args = undefined; } - return this.request(RPCCommands.SUBSCRIBE, args, event).then(() => { - const subid = subKey(event, args); - this._subscriptions.set(subid, callback); - return { - unsubscribe: () => this.request(RPCCommands.UNSUBSCRIBE, args, event) - .then(() => this._subscriptions.delete(subid)), - }; - }); + await this.request(RPCCommands.SUBSCRIBE, args, event); + const subId = subKey(event, args); + this._subscriptions.set(subId, callback); + return { + unsubscribe: async () => { + await this.request(RPCCommands.UNSUBSCRIBE, args, event); + this._subscriptions.delete(subId); + }, + }; } /** @@ -673,6 +769,8 @@ class RPCClient extends EventEmitter { */ async destroy() { await this.transport.close(); + this._removeListeners(true); + this.emit('destroyed'); } } diff --git a/src/constants.js b/src/constants.js index 441f832..050d81d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,13 +1,14 @@ 'use strict'; -function keyMirror(arr) { +const keyMirror = (arr) => { const tmp = {}; for (const value of arr) { tmp[value] = value; } return tmp; -} +}; +exports.BASE_API_URL = 'https://discord.com/api'; exports.browser = typeof window !== 'undefined'; diff --git a/src/errors/index.js b/src/errors/index.js new file mode 100644 index 0000000..3e6de52 --- /dev/null +++ b/src/errors/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const errorMessages = { + INVALID_TYPE: (prop, expected, found) => + `Recieved '${prop}' is expected to be ${expected}${found ? ` Recieved ${found}` : ''}.`, + CONNECTION_CLOSED: 'Connection closed.', + CONNECTION_TIMEOUT: 'Connection timed out.', + COULD_NOT_CONNECT: 'Couldn\'t connect.', + COULD_NOT_FIND_ENDPOINT: 'Couldn\'t find the RPC API Endpoint.', + TIMESTAMP_TOO_LARGE: (name) => `'${name}' Must fit into a unix timestamp.`, + NOT_CONNECTED: 'The client isn\'t connected', +}; + +const makeError = (BaseClass) => { + class RPCError extends BaseClass { + constructor(code, ...args) { + const message = errorMessages[code] || code; + super(typeof message === 'function' ? message(...args) : message); + + this.code = code; + } + + get name() { + return errorMessages[this.code] ? `${super.name} [${this.code}]` : super.name; + } + } + + return RPCError; +}; + +exports.Error = makeError(Error); +exports.TypeError = makeError(TypeError); +exports.RangeError = makeError(RangeError); diff --git a/src/transports/ipc.js b/src/transports/ipc.js index f050a01..5cbe55a 100644 --- a/src/transports/ipc.js +++ b/src/transports/ipc.js @@ -4,6 +4,7 @@ const net = require('net'); const EventEmitter = require('events'); const fetch = require('node-fetch'); const { uuid } = require('../util'); +const { Error } = require('../errors'); const OPCodes = { HANDSHAKE: 0, @@ -13,91 +14,86 @@ const OPCodes = { PONG: 4, }; -function getIPCPath(id) { +const getIPCPath = (id) => { if (process.platform === 'win32') { return `\\\\?\\pipe\\discord-ipc-${id}`; } const { env: { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } } = process; const prefix = XDG_RUNTIME_DIR || TMPDIR || TMP || TEMP || '/tmp'; return `${prefix.replace(/\/$/, '')}/discord-ipc-${id}`; -} +}; -function getIPC(id = 0) { - return new Promise((resolve, reject) => { - const path = getIPCPath(id); - const onerror = () => { - if (id < 10) { - resolve(getIPC(id + 1)); - } else { - reject(new Error('Could not connect')); - } - }; - const sock = net.createConnection(path, () => { - sock.removeListener('error', onerror); - resolve(sock); - }); - sock.once('error', onerror); +const getIPC = (id = 0) => new Promise((resolve, reject) => { + const path = getIPCPath(id); + const onError = () => { + if (id < 10) { + resolve(getIPC(id + 1)); + } else { + reject(new Error('COULD_NOT_CONNECT')); + } + }; + const socket = net.createConnection(path, () => { + socket.removeListener('error', onError); + resolve(socket); }); -} + socket.once('error', onError); +}); -async function findEndpoint(tries = 0) { +const findEndpoint = async (tries = 0) => { if (tries > 30) { - throw new Error('Could not find endpoint'); + throw new Error('COULD_NOT_FIND_ENDPOINT'); } const endpoint = `http://127.0.0.1:${6463 + (tries % 10)}`; try { - const r = await fetch(endpoint); - if (r.status === 404) { + const response = await fetch(endpoint); + if (response.status === 404) { return endpoint; } - return findEndpoint(tries + 1); - } catch (e) { - return findEndpoint(tries + 1); - } -} + } catch { } // eslint-disable-line no-empty + return findEndpoint(tries + 1); +}; -function encode(op, data) { +const encode = (op, data) => { data = JSON.stringify(data); - const len = Buffer.byteLength(data); - const packet = Buffer.alloc(8 + len); + const length = Buffer.byteLength(data); + const packet = Buffer.alloc(length + 8); packet.writeInt32LE(op, 0); - packet.writeInt32LE(len, 4); - packet.write(data, 8, len); + packet.writeInt32LE(length, 4); + packet.write(data, 8, length); return packet; -} - -const working = { - full: '', - op: undefined, }; -function decode(socket, callback) { - const packet = socket.read(); - if (!packet) { - return; - } +const decode = (socket) => { + let op; + let jsonString = ''; - let { op } = working; - let raw; - if (working.full === '') { - op = working.op = packet.readInt32LE(0); - const len = packet.readInt32LE(4); - raw = packet.slice(8, len + 8); - } else { - raw = packet.toString(); - } + const read = () => { + const packet = socket.read(); + if (!packet) { + return null; + } + let part; + + if (jsonString === '') { + op = packet.readInt32LE(0); + const length = packet.readInt32LE(4); + part = packet.slice(8, length + 8); + } else { + part = packet.toString(); + } - try { - const data = JSON.parse(working.full + raw); - callback({ op, data }); // eslint-disable-line callback-return - working.full = ''; - working.op = undefined; - } catch (err) { - working.full += raw; - } + jsonString += part; - decode(socket, callback); -} + try { + const data = JSON.parse(jsonString); + return { data, op }; + } catch { + return read(); + } + }; + + return read(); +}; class IPCTransport extends EventEmitter { constructor(client) { @@ -108,6 +104,7 @@ class IPCTransport extends EventEmitter { async connect() { const socket = this.socket = await getIPC(); + socket.on('close', this.onClose.bind(this)); socket.on('error', this.onClose.bind(this)); this.emit('open'); @@ -117,38 +114,41 @@ class IPCTransport extends EventEmitter { })); socket.pause(); socket.on('readable', () => { - decode(socket, ({ op, data }) => { - switch (op) { - case OPCodes.PING: - this.send(data, OPCodes.PONG); - break; - case OPCodes.FRAME: - if (!data) { - return; - } - if (data.cmd === 'AUTHORIZE' && data.evt !== 'ERROR') { - findEndpoint() - .then((endpoint) => { - this.client.request.endpoint = endpoint; - }) - .catch((e) => { - this.client.emit('error', e); - }); - } - this.emit('message', data); - break; - case OPCodes.CLOSE: - this.emit('close', data); - break; - default: - break; - } - }); + const decoded = decode(socket); + if (!decoded) { + return; + } + const { data, op } = decoded; + switch (op) { + case OPCodes.PING: + this.send(data, OPCodes.PONG); + break; + case OPCodes.FRAME: + if (!data) { + return; + } + if (data.cmd === 'AUTHORIZE' && data.evt !== 'ERROR') { + findEndpoint() + .then((endpoint) => { + this.client.request.endpoint = endpoint; + }) + .catch((error) => { + this.client.emit('error', error); + }); + } + this.emit('message', data); + break; + case OPCodes.CLOSE: + this.emit('close', data); + break; + default: + break; + } }); } - onClose(e) { - this.emit('close', e); + onClose(event) { + this.emit('close', event); } send(data, op = OPCodes.FRAME) { @@ -156,8 +156,10 @@ class IPCTransport extends EventEmitter { } async close() { - return new Promise((r) => { - this.once('close', r); + return new Promise((resolve) => { + this.once('close', () => { + resolve(); + }); this.send({}, OPCodes.CLOSE); this.socket.end(); });