From 6a8f4ffd33f7e5f136237af369a978d4e186c2a1 Mon Sep 17 00:00:00 2001 From: Alexis Tyler Date: Thu, 28 Jan 2021 17:15:06 +1030 Subject: [PATCH] chore: fix lint issues --- app/core/api-manager.ts | 109 +++++------ app/core/core.ts | 2 +- app/core/default-permissions.ts | 2 +- app/core/discovery/announce.ts | 6 +- app/core/discovery/listen.ts | 12 +- app/core/errors/api-key-error.ts | 1 + app/core/errors/app-error.ts | 4 +- app/core/errors/param-invalid-error.ts | 5 +- app/core/modules/get-plugins.ts | 3 +- app/core/plugin-manager.ts | 2 +- app/core/states/network.ts | 2 +- app/core/states/slots.ts | 2 +- app/core/states/smb-sec.ts | 2 +- app/core/states/users.ts | 2 +- app/core/states/var.ts | 3 +- app/core/utils/clients/docker.ts | 2 +- app/core/utils/clients/nchan.ts | 3 +- app/core/utils/misc/get-machine-id.ts | 4 +- app/core/utils/misc/global-error-handler.ts | 6 +- app/core/utils/misc/parse-config.ts | 3 +- app/core/utils/validation/index.ts | 1 + app/core/utils/validation/is-node-error.ts | 6 + app/core/watchers/states.ts | 2 +- app/graphql/index.ts | 13 +- app/graphql/resolvers/query/display.ts | 2 +- app/mothership/custom-socket.ts | 150 ++++++++------- app/mothership/sockets/internal-graphql.ts | 51 ++--- app/mothership/sockets/mothership.ts | 200 +++++++++++--------- app/mothership/subscribe-to-servers.ts | 3 +- app/mothership/utils.ts | 2 +- app/run.ts | 25 +-- app/server.ts | 1 - 32 files changed, 342 insertions(+), 289 deletions(-) create mode 100644 app/core/utils/validation/is-node-error.ts diff --git a/app/core/api-manager.ts b/app/core/api-manager.ts index 8077fbf97..de2e2eca7 100644 --- a/app/core/api-manager.ts +++ b/app/core/api-manager.ts @@ -11,9 +11,9 @@ import dotProp from 'dot-prop'; import { Cache as MemoryCache } from 'clean-cache'; import { validate as validateArgument } from 'bycontract'; import { Mutex, MutexInterface } from 'async-mutex'; -import { validateApiKeyFormat, loadState, validateApiKey } from './utils'; +import { validateApiKeyFormat, loadState, validateApiKey, isNodeError } from './utils'; import { paths } from './paths'; -import { coreLogger } from './log'; +import { coreLogger, log } from './log'; export interface CacheItem { /** Machine readable name of the key. */ @@ -54,53 +54,6 @@ export class ApiManager extends EventEmitter { private readonly keys = new MemoryCache(Number(toMillisecond('1y'))); private lock?: MutexInterface; - private async getLock() { - if (!this.lock) { - this.lock = new Mutex(); - } - - const release = await this.lock.acquire(); - return { - release - }; - } - - private async checkKey(filePath: string, force = false) { - const lock = await this.getLock(); - try { - const file = loadState<{ remote: { apikey: string } }>(filePath); - const apiKey = dotProp.get(file, 'remote.apikey')! as string; - - // Same key as current - if (!force && (apiKey === this.getKey('my_servers')?.key)) { - coreLogger.debug('%s was updated but the API key didn\'t change', filePath); - return; - } - - // Ensure key format is valid before validating - validateApiKeyFormat(apiKey); - - // Ensure key is valid before connecting - await validateApiKey(apiKey); - - // Add the new key - this.replace('my_servers', apiKey, { - userId: '0' - }); - } catch (error) { - // File was deleted - if (error.code === 'ENOENT') { - coreLogger.debug('%s was deleted, removing "my_servers" API key.', filePath); - } else { - coreLogger.debug('%s, removing "my_servers" API key.', error.message); - } - - // Reset key as it's not valid anymore - this.expire('my_servers'); - } finally { - lock.release(); - } - } constructor(options: Options = { watch: true }) { super({ @@ -109,7 +62,7 @@ export class ApiManager extends EventEmitter { // Return or create the singleton class if (ApiManager.instance) { - // @eslint-disable-next-line no-constructor-return + // eslint-disable-next-line no-constructor-return return ApiManager.instance; } @@ -130,7 +83,9 @@ export class ApiManager extends EventEmitter { } // Load inital keys in - this.checkKey(configPath, true); + this.checkKey(configPath, true).catch(error => { + log.debug('Failing loading inital keys'); + }); } /** @@ -268,7 +223,6 @@ export class ApiManager extends EventEmitter { .filter(([, item]) => this.isValid(item.value.key)) .map(([name, item]) => ({ name, - // @ts-expect-error key: item.value.key, userId: item.value.userId, expiresAt: item.expiresAt @@ -287,7 +241,6 @@ export class ApiManager extends EventEmitter { const keyObject = Object .entries(this.keys.items) - // @ts-expect-error .find(([_, item]) => item.value.key === key); if (!keyObject) { @@ -296,6 +249,56 @@ export class ApiManager extends EventEmitter { return keyObject[0]; } + + private async getLock() { + if (!this.lock) { + this.lock = new Mutex(); + } + + const release = await this.lock.acquire(); + return { + release + }; + } + + private async checkKey(filePath: string, force = false) { + const lock = await this.getLock(); + try { + const file = loadState<{ remote: { apikey: string } }>(filePath); + const apiKey = dotProp.get(file, 'remote.apikey')! as string; + + // Same key as current + if (!force && (apiKey === this.getKey('my_servers')?.key)) { + coreLogger.debug('%s was updated but the API key didn\'t change', filePath); + return; + } + + // Ensure key format is valid before validating + validateApiKeyFormat(apiKey); + + // Ensure key is valid before connecting + await validateApiKey(apiKey); + + // Add the new key + this.replace('my_servers', apiKey, { + userId: '0' + }); + } catch (error: unknown) { + if (isNodeError(error)) { + // File was deleted + if (error?.code === 'ENOENT') { + coreLogger.debug('%s was deleted, removing "my_servers" API key.', filePath); + } else { + coreLogger.debug('%s, removing "my_servers" API key.', error.message); + } + } + + // Reset key as it's not valid anymore + this.expire('my_servers'); + } finally { + lock.release(); + } + } } export const apiManager = new ApiManager(); diff --git a/app/core/core.ts b/app/core/core.ts index 97a1c63cf..d97218bdd 100644 --- a/app/core/core.ts +++ b/app/core/core.ts @@ -243,7 +243,7 @@ export const loadServer = async (name: string, server: typeof Server): Promise => { - const name = varState.data?.name; - const localTld = varState.data?.localTld; - const version = varState.data?.version; + const name: string = varState.data?.name; + const localTld: string = varState.data?.localTld; + const version: string = varState.data?.version; if (!name || !localTld || !version) { throw new AppError('Missing require fields to announce.'); diff --git a/app/core/discovery/listen.ts b/app/core/discovery/listen.ts index 78de2a3d7..2856ef266 100644 --- a/app/core/discovery/listen.ts +++ b/app/core/discovery/listen.ts @@ -4,14 +4,20 @@ import { log, discoveryLogger } from '../log'; /** * Listen to devices on the local network via mDNS. */ -export const listen = (): void => { +export const listen = async () => { stw .on('up', service => { if (service.type === 'unraid') { if (service.txt?.is_setup === 'false') { const ipv4 = service.addresses.find(address => address.includes('.')); const ipv6 = service.addresses.find(address => address.includes(':')); - discoveryLogger.info(`Found a new local server [${ipv4 ?? ipv6}], visit your my servers dashboard to claim.`); + const ipAddress = ipv4 ?? ipv6; + // No ip? + if (!ipAddress) { + return; + } + + discoveryLogger.info(`Found a new local server [${ipAddress}], visit your my servers dashboard to claim.`); } } // Console.log(`${service.name} is up! (from ${referrer.address}`); @@ -20,5 +26,5 @@ export const listen = (): void => { discoveryLogger.debug(`${remoteService.name} is down! (from ${referrer.address})`); }); - stw.listen(); + await stw.listen(); }; diff --git a/app/core/errors/api-key-error.ts b/app/core/errors/api-key-error.ts index 93df31a82..a0619b176 100644 --- a/app/core/errors/api-key-error.ts +++ b/app/core/errors/api-key-error.ts @@ -9,6 +9,7 @@ import { AppError } from './app-error'; * API key error. */ export class ApiKeyError extends AppError { + // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(message: string) { super(message); } diff --git a/app/core/errors/app-error.ts b/app/core/errors/app-error.ts index 5f010e714..3f82ab20e 100644 --- a/app/core/errors/app-error.ts +++ b/app/core/errors/app-error.ts @@ -8,10 +8,10 @@ */ export class AppError extends Error { /** The HTTP status associated with this error. */ - status: number; + public status: number; /** Should we kill the application when thrown. */ - fatal = false; + public fatal = false; constructor(message: string, status?: number) { // Calling parent constructor of base Error class. diff --git a/app/core/errors/param-invalid-error.ts b/app/core/errors/param-invalid-error.ts index 48965f28d..605aa048c 100644 --- a/app/core/errors/param-invalid-error.ts +++ b/app/core/errors/param-invalid-error.ts @@ -3,14 +3,15 @@ * Written by: Alexis Tyler */ +import { format } from 'util'; import { AppError } from './app-error'; /** * Invalid param provided to module */ export class ParamInvalidError extends AppError { - constructor(parameterName: string, parameter) { + constructor(parameterName: string, parameter: any) { // Overriding both message and status code. - super(`Param invalid: ${parameterName} = ${parameter}`, 500); + super(format('Param invalid: %s = %s', parameterName, parameter), 500); } } diff --git a/app/core/modules/get-plugins.ts b/app/core/modules/get-plugins.ts index fcdb551b6..a35a7d1c4 100644 --- a/app/core/modules/get-plugins.ts +++ b/app/core/modules/get-plugins.ts @@ -5,9 +5,8 @@ import { CoreContext, CoreResult } from '../types'; import { ParamInvalidError } from '../errors'; -import { pluginManager } from '../plugin-manager'; +import { Plugin, pluginManager } from '../plugin-manager'; import { ensurePermission } from '../utils'; -import { Plugin } from '../plugin-manager'; interface Context extends CoreContext { readonly query: { diff --git a/app/core/plugin-manager.ts b/app/core/plugin-manager.ts index ab4e0da89..394fffc01 100644 --- a/app/core/plugin-manager.ts +++ b/app/core/plugin-manager.ts @@ -179,7 +179,7 @@ export class PluginManager { try { coreLogger.debug('Plugin "%s" loading main file.', pluginName); plugin = require(packageMainPath); - } catch (error) { + } catch (error: unknown) { coreLogger.error('Plugin "%s" failed to load: %s', pluginName, error); // Disable plugin as it failed to load it's init file diff --git a/app/core/states/network.ts b/app/core/states/network.ts index afc04e4b4..f1524b861 100644 --- a/app/core/states/network.ts +++ b/app/core/states/network.ts @@ -64,8 +64,8 @@ const parse = (state: NetworkIni) => { * Network */ class Network extends ArrayState { - public channel = 'network'; private static instance: Network; + public channel = 'network'; constructor() { super(); diff --git a/app/core/states/slots.ts b/app/core/states/slots.ts index 27ab93688..e0cdea2d8 100644 --- a/app/core/states/slots.ts +++ b/app/core/states/slots.ts @@ -70,8 +70,8 @@ const parse = (state: SlotIni[]) => { * Slots */ class Slots extends ArrayState { - public channel = 'slots'; private static instance: Slots; + public channel = 'slots'; constructor() { super(); diff --git a/app/core/states/smb-sec.ts b/app/core/states/smb-sec.ts index 427326d77..9e7ce3ec8 100644 --- a/app/core/states/smb-sec.ts +++ b/app/core/states/smb-sec.ts @@ -59,8 +59,8 @@ const parse = (state: SmbSecIni[]) => { }; class SmbSec extends ArrayState { - public channel = 'smb-sec'; private static instance: SmbSec; + public channel = 'smb-sec'; constructor() { super(); diff --git a/app/core/states/users.ts b/app/core/states/users.ts index 8668b0344..4b1c2752d 100644 --- a/app/core/states/users.ts +++ b/app/core/states/users.ts @@ -36,8 +36,8 @@ const parseUser = (state: UserIni): User => { const parse = (states: UserIni[]): User[] => Object.values(states).map(parseUser); class Users extends ArrayState { - public channel = 'users'; private static instance: Users; + public channel = 'users'; constructor() { super(); diff --git a/app/core/states/var.ts b/app/core/states/var.ts index 17ce54e79..97d7c3088 100644 --- a/app/core/states/var.ts +++ b/app/core/states/var.ts @@ -277,13 +277,14 @@ interface ParseOptions { } class VarState extends State { - public channel = 'var'; private static instance: VarState; + public channel = 'var'; constructor() { super(); if (VarState.instance) { + // eslint-ignore-next-line no-constructor-return return VarState.instance; } diff --git a/app/core/utils/clients/docker.ts b/app/core/utils/clients/docker.ts index bfe997f64..3e23b989c 100644 --- a/app/core/utils/clients/docker.ts +++ b/app/core/utils/clients/docker.ts @@ -25,4 +25,4 @@ const client = new Docker({ /** * Docker client */ -export const docker = (pify(client) as Promisify); +export const docker = (pify(client)); diff --git a/app/core/utils/clients/nchan.ts b/app/core/utils/clients/nchan.ts index ce48c7c3a..5d4701526 100644 --- a/app/core/utils/clients/nchan.ts +++ b/app/core/utils/clients/nchan.ts @@ -8,13 +8,12 @@ import fetch from 'node-fetch'; import { debugTimer, parseConfig, sleep } from '..'; import * as states from '../../states'; import { coreLogger } from '../../log'; -import { varState } from '../../states'; import { AppError } from '../../errors'; const data = {}; const getSubEndpoint = () => { - const httpPort = varState.data?.port; + const httpPort = states.varState.data?.port; return `http://localhost:${httpPort}/sub`; }; diff --git a/app/core/utils/misc/get-machine-id.ts b/app/core/utils/misc/get-machine-id.ts index 4d0b88973..e0b7943e2 100644 --- a/app/core/utils/misc/get-machine-id.ts +++ b/app/core/utils/misc/get-machine-id.ts @@ -5,9 +5,9 @@ import { FileMissingError } from '../../errors'; const cache = new CacheManager('unraid:utils:misc/get-machine-id'); -export const getMachineId = async () => { +export const getMachineId = async (): Promise => { const path = paths.get('machine-id'); - let machineId = cache.get('machine-id'); + let machineId: string = cache.get('machine-id'); if (!path) { const error = new FileMissingError('/etc/machine-id'); diff --git a/app/core/utils/misc/global-error-handler.ts b/app/core/utils/misc/global-error-handler.ts index a9870f47b..36718c93b 100644 --- a/app/core/utils/misc/global-error-handler.ts +++ b/app/core/utils/misc/global-error-handler.ts @@ -15,13 +15,13 @@ import { exitApp } from '..'; export const globalErrorHandler = (error: Error) => { try { exitApp(error, 1); - } catch (error_) { + } catch (error: unknown) { // We should only end up here if `Errors` or `Core.log` have an issue loading. // Log last error - console.error(error_); + console.error(error); // Kill application - process.exit(1); // eslint-disable-line unicorn/no-process-exit + process.exit(1); } }; diff --git a/app/core/utils/misc/parse-config.ts b/app/core/utils/misc/parse-config.ts index 34d5fc621..c91d1b2f0 100644 --- a/app/core/utils/misc/parse-config.ts +++ b/app/core/utils/misc/parse-config.ts @@ -101,7 +101,6 @@ export const parseConfig = (options: Options): T => { let data: Record; if (filePath) { data = multiIniRead(filePath, { - // eslint-disable-next-line camelcase keep_quotes: false }); } else { @@ -117,7 +116,7 @@ export const parseConfig = (options: Options): T => { // Remove quotes around keys const dataWithoutQuoteKeys = mapObject(data, (key, value) => { // @SEE: https://stackoverflow.com/a/19156197/2311366 - return [(key as string).replace(/^"(.+(?="$))"$/, '$1'), value]; + return [(key).replace(/^"(.+(?="$))"$/, '$1'), value]; }); // Result object with array items as actual arrays diff --git a/app/core/utils/validation/index.ts b/app/core/utils/validation/index.ts index 53547aea1..f50f2a7dd 100644 --- a/app/core/utils/validation/index.ts +++ b/app/core/utils/validation/index.ts @@ -2,3 +2,4 @@ export * from './context'; export * from './has-fields'; +export * from './is-node-error'; diff --git a/app/core/utils/validation/is-node-error.ts b/app/core/utils/validation/is-node-error.ts new file mode 100644 index 000000000..0bd1279b8 --- /dev/null +++ b/app/core/utils/validation/is-node-error.ts @@ -0,0 +1,6 @@ +/** + * A typeguarded version of `instanceof Error` for NodeJS. + */ +export function isNodeError Error>(value: unknown, errorType?: T): value is InstanceType & NodeJS.ErrnoException { + return value instanceof (errorType ? errorType : Error); +} diff --git a/app/core/watchers/states.ts b/app/core/watchers/states.ts index 548f90048..2c42dd0fa 100644 --- a/app/core/watchers/states.ts +++ b/app/core/watchers/states.ts @@ -37,7 +37,7 @@ export const states = () => { // Reload state try { state.reset(); - } catch (error) { + } catch (error: unknown) { coreLogger.error('failed resetting state', error); } }; diff --git a/app/graphql/index.ts b/app/graphql/index.ts index be966af84..97328cb6d 100644 --- a/app/graphql/index.ts +++ b/app/graphql/index.ts @@ -19,6 +19,7 @@ import { typeDefs } from './schema'; import * as resolvers from './resolvers'; import { wsHasConnected, wsHasDisconnected } from '../ws'; import { MOTHERSHIP_RELAY_WS_LINK } from '../consts'; +import { isNodeError } from '../core/utils'; const baseTypes = [gql` scalar JSON @@ -198,10 +199,12 @@ class FuncDirective extends SchemaDirectiveVisitor { } else { func = getCoreModule(moduleName); } - } catch (error) { - // Rethrow clean error message about module being missing - if (error.code === 'MODULE_NOT_FOUND') { - throw new AppError(`Cannot find ${pluginName ? 'Plugin: "' + pluginName + '" ' : ''}Module: "${pluginName ? pluginModuleName : moduleName}"`); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + // Rethrow clean error message about module being missing + if (error.code === 'MODULE_NOT_FOUND') { + throw new AppError(`Cannot find ${pluginName ? 'Plugin: "' + pluginName + '" ' : ''}Module: "${pluginName ? pluginModuleName : moduleName}"`); + } } // In production let's just throw an internal error @@ -389,7 +392,7 @@ export const graphql = { user, websocketId }); return; - } catch (error) { + } catch (error: unknown) { reject(error); } }), diff --git a/app/graphql/resolvers/query/display.ts b/app/graphql/resolvers/query/display.ts index 0322ee84f..f11c5584e 100644 --- a/app/graphql/resolvers/query/display.ts +++ b/app/graphql/resolvers/query/display.ts @@ -125,7 +125,7 @@ export default async () => { url: serverCase } }; - } catch (error) { + } catch (error: unknown) { return { case: states.couldNotReadImage }; diff --git a/app/mothership/custom-socket.ts b/app/mothership/custom-socket.ts index 80ba2b870..5707c3098 100644 --- a/app/mothership/custom-socket.ts +++ b/app/mothership/custom-socket.ts @@ -3,7 +3,7 @@ import { Mutex, MutexInterface } from 'async-mutex'; import { ONE_SECOND, ONE_MINUTE } from '../consts'; import { log } from '../core'; import { AppError } from '../core/errors'; -import { sleep } from '../core/utils'; +import { isNodeError, sleep } from '../core/utils'; import { backoff } from './utils'; export interface WebSocketWithHeartBeat extends WebSocket { @@ -52,7 +52,11 @@ export class CustomSocket { // Connect right away if (!options.lazy) { - this.connect(); + this.connect().catch((error: unknown) => { + if (isNodeError(error)) { + log.error('Failed connecting with error %s', error.message); + } + }); } } @@ -83,19 +87,66 @@ export class CustomSocket { // Reset connection attempts customSocket.connectionAttempts = 0; - } catch (error) { - this.close(error.code.length === 4 ? error.code : `4${error.code}`, JSON.stringify({ - message: error.message ?? 'Internal Server Error' - })); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + this.close(Number(error.code?.length === 4 ? error.code : `4${error.code}`), JSON.stringify({ + message: error.message ?? 'Internal Server Error' + })); + } } }; } protected onDisconnect() { + const responses = { + // OK + 4200: async () => { + // This is usually because the API key is updated + // Let's reset the reconnect count so we reconnect instantly + customSocket.connectionAttempts = 0; + }, + // Unauthorized - Invalid/missing API key. + 4401: async () => { + customSocket.logger.debug('Invalid API key, waiting for new key...'); + }, + // Rate limited + 4429: async message => { + try { + let interval: NodeJS.Timeout | undefined; + const retryAfter = parseInt(message['Retry-After'], 10) || 30; + customSocket.logger.debug('Rate limited, retrying after %ss', retryAfter); + + // Less than 30s + if (retryAfter <= 30) { + let seconds = retryAfter; + + // Print retry once per second + interval = setInterval(() => { + seconds--; + customSocket.logger.debug('Retrying connection in %ss', seconds); + }, ONE_SECOND); + } + + if (retryAfter >= 1) { + await sleep(ONE_SECOND * retryAfter); + } + + if (interval) { + clearInterval(interval); + } + } catch {} + }, + // Server Error + 4500: async () => { + // Something went wrong on the connection + // Let's wait an extra bit + await sleep(ONE_SECOND * 5); + } + }; const customSocket = this; return async function (this: WebSocketWithHeartBeat, code: number, _message: string) { try { - const message = _message.trim() === '' ? { message: '' } : JSON.parse(_message); + const message: { message?: string } = _message.trim() === '' ? { message: '' } : JSON.parse(_message); customSocket.logger.debug('Connection closed with code=%s reason="%s"', code, code === 1006 ? 'Terminated' : message.message); // Stop ws heartbeat @@ -103,58 +154,17 @@ export class CustomSocket { clearTimeout(this.heartbeat); } - // Http 4XX error - if (code >= 4400 && code <= 4499) { - // Unauthorized - Invalid/missing API key. - if (code === 4401) { - customSocket.logger.debug('Invalid API key, waiting for new key...'); - return; - } - - // Rate limited - if (code === 4429) { - try { - let interval: NodeJS.Timeout | undefined; - const retryAfter = parseInt(message['Retry-After'], 10) || 30; - customSocket.logger.debug('Rate limited, retrying after %ss', retryAfter); - - // Less than 30s - if (retryAfter <= 30) { - let seconds = retryAfter; - - // Print retry once per second - interval = setInterval(() => { - seconds--; - customSocket.logger.debug('Retrying connection in %ss', seconds); - }, ONE_SECOND); - } - - if (retryAfter >= 1) { - await sleep(ONE_SECOND * retryAfter); - } - - if (interval) { - clearInterval(interval); - } - } catch {} - } + // Known status code + if (Object.keys(responses).includes(`${code}`)) { + await responses[code](message); + } else { + // Unknown status code + await responses[4500](); } - - // We likely closed this - // This is usually because the API key is updated - if (code === 4200) { - // Reconnect - customSocket.connect(); - return; + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + customSocket.logger.debug('Connection closed with code=%s reason="%s"', code, error.message); } - - // Something went wrong on the connection - // Let's wait an extra bit - if (code === 4500) { - await sleep(ONE_SECOND * 5); - } - } catch (error) { - customSocket.logger.debug('Connection closed with code=%s reason="%s"', code, error.message); } try { @@ -163,15 +173,17 @@ export class CustomSocket { // Reconnect await customSocket.connect(customSocket.connectionAttempts + 1); - } catch (error) { - customSocket.logger.debug('Failed reconnecting to %s reason="%s"', customSocket.uri, error.message); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + customSocket.logger.debug('Failed reconnecting to %s reason="%s"', customSocket.uri, error.message); + } } }; } public onMessage() { const customSocket = this; - return async function (message: string, ...args) { + return async function (message: string, ...args: any[]) { customSocket.logger.silly('message="%s" args="%s"', message, ...args); }; } @@ -217,8 +229,10 @@ export class CustomSocket { // Failed replying as socket isn't open this.logger.error('Failed replying to %s. state=%s message="%s"', client?.url, client.readyState, message); - } catch (error) { - this.logger.error('Failed replying to %s.', client?.url, error); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + this.logger.error('Failed replying to %s.', client?.url, error); + } } } @@ -276,8 +290,10 @@ export class CustomSocket { // Log we connected this.logger.debug('Connected to %s', this.uri); - } catch (error) { - this.logger.error('Failed connecting reason=%s', error.message); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + this.logger.error('Failed connecting reason=%s', error.message); + } } finally { lock.release(); } @@ -290,8 +306,10 @@ export class CustomSocket { // 4200 === ok this.connection.close(4200); } - } catch (error) { - this.logger.error('Failed disconnecting reason=%s', error.message); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + this.logger.error('Failed disconnecting reason=%s', error.message); + } } finally { lock.release(); } diff --git a/app/mothership/sockets/internal-graphql.ts b/app/mothership/sockets/internal-graphql.ts index f2409b41e..b291aee5f 100644 --- a/app/mothership/sockets/internal-graphql.ts +++ b/app/mothership/sockets/internal-graphql.ts @@ -1,6 +1,6 @@ import { INTERNAL_WS_LINK } from '../../consts'; import { apiManager, relayLogger } from '../../core'; -import { sleep } from '../../core/utils'; +import { isNodeError, sleep } from '../../core/utils'; import { AppError } from '../../core/errors'; import { CustomSocket, WebSocketWithHeartBeat } from '../custom-socket'; import { MothershipSocket } from './mothership'; @@ -17,33 +17,27 @@ export class InternalGraphql extends CustomSocket { }); } - protected async getApiKey() { - const key = apiManager.getKey('my_servers'); - if (!key) { - throw new AppError('No API key found.'); - } - - return key.key; - } - onMessage() { - const internalGraphql = this; + const mothership = this.mothership; return async function (this: WebSocketWithHeartBeat, data: string) { try { - internalGraphql.mothership?.connection?.send(data); - } catch (error) { - // Relay socket is closed, close internal one - if (error.message.includes('WebSocket is not open')) { - this.close(4200, JSON.stringify({ - message: error.emss - })); + mothership?.connection?.send(data); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + // Relay socket is closed, close internal one + if (error.message.includes('WebSocket is not open')) { + this.close(4200, JSON.stringify({ + message: error.message + })); + } } } }; } onError() { - const internalGraphql = this; + const connect = this.connect.bind(this); + const logger = this.logger; return async function (error: NodeJS.ErrnoException) { if (error.message === 'WebSocket was closed before the connection was established') { // Likely the internal relay-ws connection was started but then mothership @@ -59,19 +53,19 @@ export class InternalGraphql extends CustomSocket { await sleep(1000); // Re-connect to internal graphql server - internalGraphql.connect(); + connect(); return; } - internalGraphql.logger.error(error); + logger.error(error); }; } onConnect() { - const internalGraphql = this; + const apiKey = this.apiKey; return async function (this: WebSocketWithHeartBeat) { // No API key, close internal connection - if (!internalGraphql.apiKey) { + if (!apiKey) { this.close(4200, JSON.stringify({ message: 'No API key' })); @@ -81,9 +75,18 @@ export class InternalGraphql extends CustomSocket { this.send(JSON.stringify({ type: 'connection_init', payload: { - 'x-api-key': internalGraphql.apiKey + 'x-api-key': apiKey } })); }; } + + protected async getApiKey() { + const key = apiManager.getKey('my_servers'); + if (!key) { + throw new AppError('No API key found.'); + } + + return key.key; + } } diff --git a/app/mothership/sockets/mothership.ts b/app/mothership/sockets/mothership.ts index 948b0d06f..6df31616d 100644 --- a/app/mothership/sockets/mothership.ts +++ b/app/mothership/sockets/mothership.ts @@ -1,6 +1,6 @@ import { MOTHERSHIP_RELAY_WS_LINK, ONE_MINUTE } from '../../consts'; import { mothershipLogger, apiManager } from '../../core'; -import { getMachineId, sleep } from '../../core/utils'; +import { getMachineId, isNodeError, sleep } from '../../core/utils'; import { varState, networkState } from '../../core/states'; import { subscribeToServers } from '../subscribe-to-servers'; import { AppError } from '../../core/errors'; @@ -24,16 +24,107 @@ export class MothershipSocket extends CustomSocket { }); } - private connectToInternalGraphql(options: InternalGraphql['options'] = {}) { - this.internalGraphqlSocket = new InternalGraphql(options); + onConnect() { + const connectToInternalGraphql = this.connectToInternalGraphql.bind(this); + const connectToMothershipsGraphql = this.connectToMothershipsGraphql.bind(this); + const onConnect = super.onConnect.bind(this); + return async function (this: WebSocketWithHeartBeat) { + try { + // Run super + onConnect(); + + // Connect to local graphql + connectToInternalGraphql(); + + // Sub to /servers on mothership + await connectToMothershipsGraphql(); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + const code = (error.code) ?? 500; + this.close(`${code}`.length === 4 ? Number(code) : Number(`4${code}`), JSON.stringify({ + message: error.message ?? 'Internal Server Error' + })); + } + } + }; } - private async connectToMothershipsGraphql() { - this.mothershipServersEndpoint = await subscribeToServers(this.apiKey); + onDisconnect() { + const internalGraphqlSocket = this.internalGraphqlSocket; + const disconnectFromMothershipsGraphql = this.disconnectFromMothershipsGraphql.bind(this); + const logger = this.logger; + const onDisconnect = super.onDisconnect.bind(this); + return async function (this: WebSocketWithHeartBeat, code: number, _message: string) { + try { + // Close connection to local graphql endpoint + internalGraphqlSocket?.connection?.close(200); + + // Close connection to motherships's server's endpoint + await disconnectFromMothershipsGraphql(); + + // Process disconnection + onDisconnect(); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + logger.debug('Connection closed with code=%s reason="%s"', code, error.message); + } + } + }; } - private async disconnectFromMothershipsGraphql() { - this.mothershipServersEndpoint?.unsubscribe(); + // When we get a message from relay send it through to our local graphql instance + onMessage() { + const internalGraphqlSocket = this.internalGraphqlSocket; + const sendMessage = this.sendMessage.bind(this); + const logger = this.logger; + return async function (this: WebSocketWithHeartBeat, data: string) { + try { + await sendMessage(internalGraphqlSocket?.connection, data); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + // Something weird happened while processing the message + // This is likely a malformed message + logger.error('Failed sending message to relay.', error); + } + } + }; + } + + onError() { + const logger = this.logger; + return async function (this: WebSocketWithHeartBeat, error: NodeJS.ErrnoException) { + try { + logger.error(error); + + // The relay is down + if (error.message.includes('502')) { + // Sleep for 30 seconds + await sleep(ONE_MINUTE / 2); + } + + // Connection refused, aka couldn't connect + // This is usually because the address is wrong or offline + if (error.code === 'ECONNREFUSED') { + // @ts-expect-error + logger.debug('Couldn\'t connect to %s:%s', error.address, error.port); + return; + } + + // Closed before connection started + if (error.message.toString().includes('WebSocket was closed before the connection was established')) { + logger.debug(error.message); + return; + } + + throw error; + } catch { + // Unknown error + logger.error('socket error', error); + } finally { + // Kick the connection + this.close(4500, JSON.stringify({ message: error.message })); + } + }; } protected async getApiKey() { @@ -48,8 +139,8 @@ export class MothershipSocket extends CustomSocket { protected async getHeaders() { const apiKey = apiManager.getKey('my_servers')?.key!; const keyFile = varState.data?.regFile ? readFileIfExists(varState.data?.regFile).toString('base64') : ''; - const serverName = `${varState.data?.name}`; - const lanIp = networkState.data.find(network => network.ipaddr[0]).ipaddr[0] || ''; + const serverName = `${varState.data?.name as string}`; + const lanIp: string = networkState.data.find(network => network.ipaddr[0]).ipaddr[0] || ''; const machineId = `${await getMachineId()}`; return { @@ -62,94 +153,15 @@ export class MothershipSocket extends CustomSocket { }; } - onConnect() { - const mothership = this; - const onConnect = super.onConnect; - return async function (this: WebSocketWithHeartBeat) { - try { - // Run super - onConnect(); - - // Connect to local graphql - mothership.connectToInternalGraphql(); - - // Sub to /servers on mothership - mothership.connectToMothershipsGraphql(); - } catch (error) { - this.close(error.code.length === 4 ? error.code : `4${error.code}`, JSON.stringify({ - message: error.message ?? 'Internal Server Error' - })); - } - }; + private connectToInternalGraphql(options: InternalGraphql['options'] = {}) { + this.internalGraphqlSocket = new InternalGraphql(options); } - onDisconnect() { - const mothership = this; - const onDisconnect = super.onDisconnect; - return async function (this: WebSocketWithHeartBeat, code: number, _message: string) { - try { - // Close connection to local graphql endpoint - mothership.internalGraphqlSocket?.connection?.close(200); - - // Close connection to motherships's server's endpoint - mothership.disconnectFromMothershipsGraphql(); - - // Process disconnection - onDisconnect(); - } catch (error) { - mothership.logger.debug('Connection closed with code=%s reason="%s"', code, error.message); - } - }; + private async connectToMothershipsGraphql() { + this.mothershipServersEndpoint = await subscribeToServers(this.apiKey); } - // When we get a message from relay send it through to our local graphql instance - onMessage() { - const mothership = this; - return async function (this: WebSocketWithHeartBeat, data: string) { - try { - await mothership.sendMessage(mothership.internalGraphqlSocket?.connection, data); - } catch (error) { - // Something weird happened while processing the message - // This is likely a malformed message - mothership.logger.error('Failed sending message to relay.', error); - } - }; - } - - onError() { - const mothership = this; - return async function (this: WebSocketWithHeartBeat, error: NodeJS.ErrnoException) { - try { - mothership.logger.error(error); - - // The relay is down - if (error.message.includes('502')) { - // Sleep for 30 seconds - await sleep(ONE_MINUTE / 2); - } - - // Connection refused, aka couldn't connect - // This is usually because the address is wrong or offline - if (error.code === 'ECONNREFUSED') { - // @ts-expect-error - mothership.logger.debug('Couldn\'t connect to %s:%s', error.address, error.port); - return; - } - - // Closed before connection started - if (error.toString().includes('WebSocket was closed before the connection was established')) { - mothership.logger.debug(error.message); - return; - } - - throw error; - } catch { - // Unknown error - mothership.logger.error('socket error', error); - } finally { - // Kick the connection - this.close(4500, JSON.stringify({ message: error.message })); - } - }; + private async disconnectFromMothershipsGraphql() { + this.mothershipServersEndpoint?.unsubscribe(); } } diff --git a/app/mothership/subscribe-to-servers.ts b/app/mothership/subscribe-to-servers.ts index 1083b52e0..4fac0296b 100644 --- a/app/mothership/subscribe-to-servers.ts +++ b/app/mothership/subscribe-to-servers.ts @@ -1,8 +1,7 @@ -import { pubsub } from '../core'; +import { pubsub, log as logger } from '../core'; import { SubscriptionClient } from 'graphql-subscriptions-client'; import { MOTHERSHIP_GRAPHQL_LINK, ONE_SECOND } from '../consts'; import { userCache, CachedServers } from '../cache'; -import { log as logger } from '../core'; const log = logger.createChild({ prefix: 'subscribe-to-servers' }); const client = new SubscriptionClient(MOTHERSHIP_GRAPHQL_LINK, { diff --git a/app/mothership/utils.ts b/app/mothership/utils.ts index 6d35353c4..4609b94e8 100644 --- a/app/mothership/utils.ts +++ b/app/mothership/utils.ts @@ -16,7 +16,7 @@ export const applyJitter = (value: number) => { }; export const backoff = (attempt: number, maxDelay: number, multiplier: number) => { - const delay = applyJitter(Math.pow(2.0, attempt - 1.0) * 0.5); + const delay = applyJitter(2.0 ** (attempt - 1.0) * 0.5); return Math.round(Math.min(delay * multiplier, maxDelay)); }; diff --git a/app/run.ts b/app/run.ts index 81b4435e8..8e6356db2 100644 --- a/app/run.ts +++ b/app/run.ts @@ -1,6 +1,7 @@ -import type { CoreResult } from './core/types'; +import type { CoreContext, CoreResult } from './core/types'; import { pubsub, coreLogger } from './core'; -import { debugTimer } from './core/utils'; +import { debugTimer, isNodeError } from './core/utils'; +import { AppError } from './core/errors'; /** * Publish update to topic channel. @@ -26,7 +27,7 @@ export const publish = async (channel: string, mutation: string, node?: Record; - moduleToRun?: (context: any) => CoreResult; + moduleToRun?: (context: CoreContext) => Promise; context?: any; } @@ -57,14 +58,16 @@ export const run = async (channel: string, mutation: string, options: RunOptions coreLogger.silly(`run:${moduleToRun.name}`, JSON.stringify(result.json)); // Save result - publish(channel, mutation, result.json); - } catch (error: any) { - // Ensure we aren't leaking anything in production - if (process.env.NODE_ENV === 'production') { - coreLogger.debug('Error:', error.message); - } else { - const logger = coreLogger[error.status && error.status >= 400 ? 'error' : 'warn'].bind(coreLogger); - logger('Error:', error.message); + await publish(channel, mutation, result.json); + } catch (error: unknown) { + if (isNodeError(error, AppError)) { + // Ensure we aren't leaking anything in production + if (process.env.NODE_ENV === 'production') { + coreLogger.debug('Error:', error.message); + } else { + const logger = coreLogger[error.status && error.status >= 400 ? 'error' : 'warn'].bind(coreLogger); + logger('Error:', error.message); + } } } diff --git a/app/server.ts b/app/server.ts index 68d3876d0..3bd0d06ff 100644 --- a/app/server.ts +++ b/app/server.ts @@ -84,7 +84,6 @@ app.get('/', (_, res) => { }); // Handle errors by logging them and returning a 500. -// eslint-disable-next-line @typescript-eslint/no-var-requires app.use((error, _, res, __) => { log.error(error); if (error.stack) {