UNPKG

@meshwatch/backend-core

Version:

Meshwatch backend core services.

1,143 lines (1,111 loc) 47.6 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@hapi/boom'), require('lodash'), require('uuid'), require('aws-sdk/clients/dynamodb'), require('http'), require('https'), require('yup'), require('@meshwatch/node-fetch'), require('url'), require('pg'), require('perf_hooks')) : typeof define === 'function' && define.amd ? define(['exports', '@hapi/boom', 'lodash', 'uuid', 'aws-sdk/clients/dynamodb', 'http', 'https', 'yup', '@meshwatch/node-fetch', 'url', 'pg', 'perf_hooks'], factory) : (global = global || self, factory(global['@meshwatch/backend-core'] = {}, global.Boom, global.lodash, global.uuid, global.DynamoDB, global.http, global.https, global.Yup, global.nodeFetch, global.url, global.pg, global.perf_hooks)); }(this, function (exports, Boom, lodash, uuid, DynamoDB, http, https, Yup, nodeFetch, url, pg, perf_hooks) { 'use strict'; Boom = Boom && Boom.hasOwnProperty('default') ? Boom['default'] : Boom; uuid = uuid && uuid.hasOwnProperty('default') ? uuid['default'] : uuid; DynamoDB = DynamoDB && DynamoDB.hasOwnProperty('default') ? DynamoDB['default'] : DynamoDB; var https__default = 'default' in https ? https['default'] : https; nodeFetch = nodeFetch && nodeFetch.hasOwnProperty('default') ? nodeFetch['default'] : nodeFetch; function validationBoom(errors) { return newBoom(400, 'Something went wrong while validating your payload', errors); } function boomify(error, options = {}) { let statusCode = options.statusCode; if (error instanceof ServiceException) { statusCode = error.httpStatusCode; } const boom = Boom.boomify(error, { ...options, statusCode, }); const payload = { ...boom.output.payload, message: error.message || boom.output.payload.message, }; return fromBoomPayload(payload); } function isBoom(body) { const typedBoom = body; return typedBoom.error !== undefined; } function newBoom(statusCode, message, errors) { const boom = new Boom(message, { statusCode, }); return fromBoomPayload(boom.output.payload, errors); } function fromBoomPayload(boomPayload, errors) { return errors ? { ...boomPayload, errors, } : boomPayload; } class ServiceException extends Error { constructor(message, httpStatusCode, name) { super(message); this.name = name; this.httpStatusCode = httpStatusCode; this.constructor = ServiceException; Error.captureStackTrace(this, ServiceException); Object.setPrototypeOf(this, ServiceException.prototype); } } class DatabaseException extends ServiceException { constructor(message) { super(message, 500, 'DatabaseException'); } } class NotFoundException extends ServiceException { constructor(message) { super(message, 404, 'NotFoundException'); } } class ForbiddenException extends ServiceException { constructor(message) { super(message, 403, 'ForbiddenException'); } } class BadRequestException extends ServiceException { constructor(message) { super(message, 400, 'BadRequestException'); } } class UnreachableCaseError extends ServiceException { constructor(val) { super(`Unreachable case: ${val}`, 500, 'UnreachableCaseError'); } } const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50, }); function getDynamoClients(configOptions) { return { dynamodb: new DynamoDB(configOptions), dynamodbDocumentClient: new DynamoDB.DocumentClient(configOptions), }; } function getDynamoDBClientConfiguration(config = {}) { return { region: process.env.region || 'us-west-2', endpoint: process.env.endpoint || 'http://localhost:8000', accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'accessKeyId', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'secretAccessKey', httpOptions: { agent: httpAgent, }, ...config, }; } const config = getDynamoDBClientConfiguration(); { console.log('Using DynamoDB config: ', config); } const { dynamodb, dynamodbDocumentClient } = getDynamoClients(config); function createTable(dynamodb, params) { return dynamodb .createTable(params) .promise() .then(resp => { { console.log('[DynamoDB]: Created table. Table description JSON:', JSON.stringify(resp, null, 2)); } return resp; }) .catch(err => { { console.error('[DynamoDB]: Unable to create table. Error JSON:', JSON.stringify(err, null, 2)); } return err; }); } function putDocumentToDynamo(dynamodb, params) { const { TableName } = params; // An AttributeValue may not contain an empty string const Item = lodash.pickBy(params.Item, value => value !== ''); { console.log('[DynamoDb]: put to: ' + TableName, 'Item: ' + JSON.stringify(Item)); } return dynamodb .put({ TableName, Item }) .promise() .catch(err => { { console.log('[DynamoDB] write to: ' + TableName + ' failed.', 'Item: ' + JSON.stringify(Item), err); } throw err; }); } function queryTable(dynamodb, params) { const { TableName } = params; { console.log('[DynamoDb]: queryTable: ' + TableName, 'QueryInput: ' + JSON.stringify(params)); } return dynamodb .query(params) .promise() .catch(err => { { console.log('[DynamoDb] query to: ' + TableName + ' failed.', 'QueryInput: ' + JSON.stringify(params), err); } throw err; }); } function deleteItem(dynamodb, params) { const { TableName } = params; { console.log('[DynamoDB]: delete from: ' + TableName, JSON.stringify(params, null, 2)); } return dynamodb .delete(params) .promise() .catch(err => { { console.error('[DynamoDB]: Unable to delete item. Error JSON:', JSON.stringify(err, null, 2)); } throw err; }); } function updateItem(dynamodb, params) { const { TableName } = params; { console.log('[DynamoDB]: update: ' + TableName, JSON.stringify(params, null, 2)); } return dynamodb .update(params) .promise() .catch(err => { { console.error('[DynamoDB]: Unable to update item. Error JSON:', JSON.stringify(err, null, 2)); } throw err; }); } const _MS_PER_DAY = 1000 * 60 * 60 * 24; class DateUtils { static getUnixTimestamp(date = new Date()) { return Math.round(+date / 1000); } static dateFromUnixTimestamp(timestamp) { return new Date(timestamp * 1000); } static daysDateDiff(d1, d2) { const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate()); const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate()); return Math.floor((utc2 - utc1) / _MS_PER_DAY); } } const MONITOR_TABLE_NAME = 'monitor'; const MONITOR_TABLE_SCHEDULER_GSI = 'schedulerGSI'; const MONITOR_TABLE_SCHEMA = { TableName: MONITOR_TABLE_NAME, KeySchema: [ { AttributeName: 'hashKey', KeyType: 'HASH' }, { AttributeName: 'sortKey', KeyType: 'RANGE' }, ], AttributeDefinitions: [ { AttributeName: 'hashKey', AttributeType: 'S' }, { AttributeName: 'sortKey', AttributeType: 'S' }, { AttributeName: 'scheduler', AttributeType: 'S' }, ], ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1, }, GlobalSecondaryIndexes: [ { IndexName: MONITOR_TABLE_SCHEDULER_GSI, KeySchema: [ { AttributeName: 'scheduler', KeyType: 'HASH' }, { AttributeName: 'sortKey', KeyType: 'RANGE' }, ], Projection: { ProjectionType: 'ALL', }, ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1, }, }, ], }; class DynamoMonitorStorage { constructor(documentClient = dynamodbDocumentClient, dynamoDB = dynamodb) { this.createTable = () => { return createTable(this.dynamoDB, MONITOR_TABLE_SCHEMA); }; this.bookmarkMonitor = (userId, monitorId, bookmarked) => { const hashKey = this.encodeHashKey({ userId }); const sortKey = this.encodeSortKey({ id: monitorId }); return updateItem(this.documentClient, { TableName: MONITOR_TABLE_NAME, Key: { hashKey, sortKey, }, UpdateExpression: `set isBookmarked = :isBookmarked`, ExpressionAttributeValues: { ':isBookmarked': bookmarked, }, ReturnValues: 'ALL_NEW', }).then(resp => this.monitorFromDynamoItem(resp.Attributes)); }; this.getMonitor = (userId, monitorId) => { const hashKey = this.encodeHashKey({ userId }); const sortKey = this.encodeSortKey({ id: monitorId }); return queryTable(this.documentClient, { TableName: MONITOR_TABLE_NAME, KeyConditionExpression: `hashKey = :hashKey and sortKey = :sortKey`, ExpressionAttributeValues: { ':hashKey': hashKey, ':sortKey': sortKey, }, }).then(resp => { if (!resp.Items || resp.Items.length === 0) { throw new NotFoundException(`Could not find monitor id = ${monitorId}`); } return this.monitorFromDynamoItem(resp.Items[0]); }); }; this.deleteMonitor = (userId, monitorId) => { const hashKey = this.encodeHashKey({ userId }); const sortKey = this.encodeSortKey({ id: monitorId }); return deleteItem(this.documentClient, { TableName: MONITOR_TABLE_NAME, Key: { hashKey, sortKey, }, }); }; this.searchMonitors = (userId) => { return queryTable(this.documentClient, { TableName: MONITOR_TABLE_NAME, KeyConditionExpression: `hashKey = :hashKey`, ExpressionAttributeValues: { ':hashKey': this.encodeHashKey({ userId }), }, }).then(this.mapDynamoRows); }; this.getMonitorsByScheduler = (scheduler) => { return queryTable(this.documentClient, { TableName: MONITOR_TABLE_NAME, KeyConditionExpression: `scheduler = :scheduler`, IndexName: MONITOR_TABLE_SCHEDULER_GSI, ExpressionAttributeValues: { ':scheduler': scheduler, }, }).then(this.mapDynamoRows); }; this.createMonitor = (createMonitorPayload) => { const monitor = { ...createMonitorPayload, id: uuid.v4(), created: new Date() }; return this._putMonitor(monitor); }; this.updateMonitor = (updateMonitorPayload) => { return this._putMonitor(updateMonitorPayload); }; this._putMonitor = (monitor) => { const Item = this.monitorToDynamoItem(monitor); return putDocumentToDynamo(this.documentClient, { TableName: MONITOR_TABLE_NAME, Item, }).then(_ => monitor); }; this.monitorToDynamoItem = (monitor) => { return { hashKey: this.encodeHashKey(monitor), sortKey: this.encodeSortKey(monitor), name: monitor.name, body: lodash.get(monitor, 'body'), scheduler: monitor.scheduler, apdex: lodash.get(monitor, 'apdex'), endpoint: monitor.endpoint, headers: lodash.get(monitor, 'headers'), isBookmarked: monitor.isBookmarked, location: monitor.location, regions: lodash.get(monitor, 'regions'), type: monitor.type, created: DateUtils.getUnixTimestamp(monitor.created), }; }; this.monitorFromDynamoItem = (Item) => { const { userId } = this.decodeHashKey(Item.hashKey); const { id } = this.decodeSortKey(Item.sortKey); return { userId, id, created: DateUtils.dateFromUnixTimestamp(Item.created), name: Item.name, scheduler: Item.scheduler, apdex: Item.apdex, type: Item.type, headers: Item.headers, body: Item.body, endpoint: Item.endpoint, isBookmarked: Item.isBookmarked, location: Item.location, regions: Item.regions, }; }; this.mapDynamoRows = (resp) => { const rows = resp.Items; return rows.map(this.monitorFromDynamoItem); }; this.documentClient = documentClient; this.dynamoDB = dynamoDB; } /* hashKey */ encodeHashKey({ userId }) { return `user#${userId}`; } decodeHashKey(hashKey) { const s = hashKey.split('#'); return { userId: s[1] }; } /* sortKey */ encodeSortKey({ id }) { return `id#${id}`; } decodeSortKey(sortKey) { const s = sortKey.split('#'); return { id: s[1] }; } } const dynamoMonitorStorage = new DynamoMonitorStorage(); const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const isUUIDv4 = (param) => { return UUID_V4_PATTERN.test(param); }; const CREATE_MONITOR_BASE_SCHEMA_SHAPE = { userId: Yup.string() .test('isUUIDv4', 'uuid/v4 expected but got: `${value}`', value => isUUIDv4(value)) .required('userId is required'), isBookmarked: Yup.boolean().required('isBookmarked is required'), scheduler: Yup.string().required('Scheduler is required, so we know how often to monitor your endpoint'), name: Yup.string().required('Name is required, so you can find your monitors easily'), endpoint: Yup.string().required('Endpoint is required so we know where to send our requests'), type: Yup.string() .oneOf(['latency-check', 'https-check', 'certificate-check']) .required('Monitor type is required'), }; const CREATE_MONITOR_SCHEMA = Yup.object() .shape(CREATE_MONITOR_BASE_SCHEMA_SHAPE) .typeError(params => `Monitor object expected but got: \`${params.originalValue}\``); const UPDATE_MONITOR_SCHEMA = Yup.object() .shape({ ...CREATE_MONITOR_BASE_SCHEMA_SHAPE, created: Yup.date().required('created is required'), id: Yup.string() .test('isUUIDv4', 'uuid/v4 expected but got: `${value}`', value => isUUIDv4(value)) .required('id is required'), }) .typeError(params => `Monitor object expected but got: \`${params.originalValue}\``); /* Latency check */ const CREATE_LATENCY_CHECK_SCHEMA_SHAPE = { ...CREATE_MONITOR_BASE_SCHEMA_SHAPE, body: Yup.string(), headers: Yup.object(), apdex: Yup.number().required('Apdex value is required so we can better measure performance of your endpoint'), regions: Yup.array() .of(Yup.string()) .required('Regions are required so we know from where to monitor your endpoint') .test('isNonEmpty', 'At least one region should be selected but got: `${value}`', (value) => (value || []).length > 0), }; const CREATE_LATENCY_MONITOR_SCHEMA = Yup.object() .shape(CREATE_LATENCY_CHECK_SCHEMA_SHAPE) .typeError(params => `Monitor object expected but got: \`${params.originalValue}\``); const UPDATE_LATENCY_MONITOR_SCHEMA = Yup.object() .shape({ ...CREATE_LATENCY_CHECK_SCHEMA_SHAPE, id: Yup.string() .test('isUUIDv4', 'uuid/v4 expected but got: `${value}`', value => isUUIDv4(value)) .required('id is required'), }) .typeError(params => `Monitor object expected but got: \`${params.originalValue}\``); /* Other */ const COMPLETE_GETTING_STARTED_TASK_SCHEMA = Yup.object().shape({ completedOn: Yup.date().required('created is required'), userId: Yup.string() .test('isUUIDv4', 'uuid/v4 expected but got: `${value}`', value => isUUIDv4(value)) .required('userId is required'), taskName: Yup.string().required('taskName is required'), }); function isLatencyCheckMonitor(payload) { return payload.type === 'latency-check'; } function validateCompleteGettingStartedTaskPayload(payload) { return validate(COMPLETE_GETTING_STARTED_TASK_SCHEMA, payload); } function validateCreateMonitorPayload(payload) { if (isLatencyCheckMonitor(payload)) { return validate(CREATE_LATENCY_MONITOR_SCHEMA, payload); } return validate(CREATE_MONITOR_SCHEMA, payload); } function validateUpdateMonitorPayload(payload) { if (isLatencyCheckMonitor(payload)) { return validate(UPDATE_LATENCY_MONITOR_SCHEMA, payload); } return validate(UPDATE_MONITOR_SCHEMA, payload); } function validate(schema, payload, options = { abortEarly: false, strict: true }) { if (payload === undefined || typeof payload === 'function') { // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion const errors = { non_field_errors: `Object expected but got: \`${payload}\`` }; return Promise.resolve({ error: true, errors, }); } return schema .validate(payload, options) .then(_ => ({ error: false, })) .catch((err) => { const initialValue = {}; return { error: true, errors: err.inner.reduce((acc, validationError) => { const errorPath = validationError.path || 'non_field_errors'; acc[errorPath] = validationError.message; return acc; }, initialValue), }; }); } class Authorization { constructor(userId) { this.canAccessEntity = (entity) => { return entity.userId === this.userId; }; this.cannotAccessEntity = (entity) => { return !this.canAccessEntity(entity); }; this.userId = userId; } } Authorization.of = (userId) => { return new Authorization(userId); }; class BaseService { constructor() { this.errorServiceResponse = (error) => { return this.serviceResponseFromBoom(boomify(error)); }; this.serviceResponseFromBoom = (boom) => { return { statusCode: boom.statusCode, body: boom, }; }; this.serviceResponse = (body) => { return { statusCode: 200, body }; }; this.tryExecute = async (f) => { return f().catch(this.errorServiceResponse); }; } } class MonitoringService extends BaseService { constructor(monitorStorage = dynamoMonitorStorage) { super(); this.bookmarkMonitor = async (userId, monitorId) => { return this.tryExecute(async () => { let monitor = await this.monitorStorage.getMonitor(userId, monitorId); if (Authorization.of(userId).cannotAccessEntity(monitor)) { throw new ForbiddenException('You are not authorized to perform this action'); } monitor = await this.monitorStorage.bookmarkMonitor(monitor.userId, monitor.id, !monitor.isBookmarked); return this.serviceResponse(monitor); }); }; this.getMonitor = async (userId, monitorId) => { return this.tryExecute(async () => { const monitor = await this.monitorStorage.getMonitor(userId, monitorId); if (Authorization.of(userId).cannotAccessEntity(monitor)) { throw new ForbiddenException('You are not authorized to perform this action'); } return this.serviceResponse(monitor); }); }; this.deleteMonitor = async (userId, monitorId) => { return this.tryExecute(async () => { const monitor = await this.monitorStorage.getMonitor(userId, monitorId); if (Authorization.of(userId).cannotAccessEntity(monitor)) { throw new ForbiddenException('You are not authorized to perform this action'); } await this.monitorStorage.deleteMonitor(monitor.userId, monitor.id); return this.serviceResponse(monitor); }); }; this.getMonitorsByScheduler = async (scheduler) => { return this.tryExecute(async () => { const monitors = await this.monitorStorage.getMonitorsByScheduler(scheduler); return this.serviceResponse(monitors); }); }; this.getMonitors = async (userId) => { return this.tryExecute(async () => { const monitors = await this.monitorStorage.searchMonitors(userId); return this.serviceResponse(monitors); }); }; this.updateMonitor = async (userId, updateMonitorPayload) => { const validationResponse = await validateUpdateMonitorPayload(updateMonitorPayload); if (validationResponse.error) { return this.serviceResponseFromBoom(validationBoom(validationResponse.errors)); } return this.tryExecute(async () => { if (Authorization.of(userId).cannotAccessEntity(updateMonitorPayload)) { throw new ForbiddenException('You are not authorized to perform this action'); } const existingMonitor = await this.monitorStorage.getMonitor(userId, updateMonitorPayload.id); if (existingMonitor.type !== updateMonitorPayload.type) { throw new BadRequestException('Monitor type cannot be changes. Please create a new monitor.'); } const monitor = await this.monitorStorage.updateMonitor({ ...updateMonitorPayload, created: existingMonitor.created, }); return { statusCode: 200, body: monitor }; }); }; this.createMonitor = async (userId, newMonitorPayload) => { const validationResponse = await validateCreateMonitorPayload(newMonitorPayload); if (validationResponse.error) { return this.serviceResponseFromBoom(validationBoom(validationResponse.errors)); } return this.tryExecute(async () => { if (Authorization.of(userId).cannotAccessEntity(newMonitorPayload)) { throw new ForbiddenException('You are not authorized to perform this action'); } const monitor = await this.monitorStorage.createMonitor(newMonitorPayload); return { statusCode: 201, body: monitor }; }); }; this.monitorStorage = monitorStorage; } } const monitoringService = new MonitoringService(); const USER_TABLE_NAME = 'user'; const USER_TABLE_SCHEMA = { TableName: USER_TABLE_NAME, KeySchema: [{ AttributeName: 'hashKey', KeyType: 'HASH' }], AttributeDefinitions: [{ AttributeName: 'hashKey', AttributeType: 'S' }], ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1, }, }; const INITIAL_USER_INFO = { gettingStarted: { completedTasks: {}, }, }; class DynamoUserStorage { constructor(documentClient = dynamodbDocumentClient, dynamoDB = dynamodb) { this.createTable = () => { return createTable(this.dynamoDB, USER_TABLE_SCHEMA); }; this.getUserInfo = (userId) => { const hashKey = this.encodeHashKey({ userId }); return queryTable(this.documentClient, { TableName: USER_TABLE_NAME, KeyConditionExpression: `hashKey = :hashKey`, ExpressionAttributeValues: { ':hashKey': hashKey, }, }).then(resp => { if (!resp.Items) { throw new NotFoundException(`Could not find userInfo for user: ${userId}`); } else if (resp.Items.length > 0) { return this.userInfoFromDynamoRow(resp.Items[0]); } { console.log(`No userInfo found for: ${userId}. Creating initial userInfo: `, INITIAL_USER_INFO); } return this.insertUserInfoRow({ hashKey: this.encodeHashKey({ userId }), ...INITIAL_USER_INFO, }); }); }; this.completeGettingStartedTask = ({ userId, taskName, completedOn, }) => { const hashKey = this.encodeHashKey({ userId }); return updateItem(this.documentClient, { TableName: USER_TABLE_NAME, Key: { hashKey, }, UpdateExpression: `set gettingStarted.completedTasks.${taskName} = :created`, ConditionExpression: `attribute_not_exists(gettingStarted.completedTasks.${taskName})`, ExpressionAttributeValues: { ':created': DateUtils.getUnixTimestamp(completedOn), }, ReturnValues: 'UPDATED_NEW', }) .then(resp => { if (!resp.Attributes) { throw new Error(`Could not update completeGettingStartedTask, userId: ${userId}`); } const gettingStarted = resp.Attributes.gettingStarted; const completedTasks = lodash.mapValues(gettingStarted.completedTasks, DateUtils.dateFromUnixTimestamp); return { userId, gettingStarted: { completedTasks, }, }; }) .catch(err => { if (err.name === 'ConditionalCheckFailedException') { throw new BadRequestException(`Task [${taskName}] is already completed`); } else if (err.name === 'ValidationException' && err.message === 'The document path provided in the update expression is invalid for update') { throw new NotFoundException('Getting started task cannot be completed before userInfo is created'); } throw err; }); }; this.insertUserInfoRow = (Item) => { return putDocumentToDynamo(this.documentClient, { TableName: USER_TABLE_NAME, Item, }).then(_ => this.userInfoFromDynamoRow(Item)); }; this.userInfoFromDynamoRow = (dynamoRow) => { const { userId } = this.decodeHashKey(dynamoRow.hashKey); const completedTasks = lodash.mapValues(dynamoRow.gettingStarted.completedTasks, DateUtils.dateFromUnixTimestamp); return { userId, gettingStarted: { ...dynamoRow.gettingStarted, completedTasks, }, }; }; this.documentClient = documentClient; this.dynamoDB = dynamoDB; } /* hashKey */ encodeHashKey({ userId }) { return `user#${userId}`; } decodeHashKey(hashKey) { const s = hashKey.split('#'); return { userId: s[1] }; } } const dynamoUserStorage = new DynamoUserStorage(); class UserService extends BaseService { constructor(userStorage = dynamoUserStorage) { super(); this.getUserInfo = (userId) => { return this.tryExecute(async () => { const userInfo = await this.userStorage.getUserInfo(userId); return this.serviceResponse(userInfo); }); }; this.completeGettingStartedTask = async (userId, taskName) => { const params = { userId, taskName, completedOn: new Date(), }; const validationResponse = await validateCompleteGettingStartedTaskPayload(params); if (validationResponse.error) { return this.serviceResponseFromBoom(validationBoom(validationResponse.errors)); } return this.tryExecute(async () => { const userInfo = await this.userStorage.completeGettingStartedTask(params); return this.serviceResponse(userInfo); }); }; this.userStorage = userStorage; } } const userService = new UserService(); function timeoutable(f, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(`Action timed out using timeout of ${timeout}ms.`); }, timeout); f() .then(resp => { clearTimeout(timeoutId); resolve(resp); }) .catch(err => { clearTimeout(timeoutId); reject(err); }); }); } const CHROME_WINDOWS = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'; const BASE_HTTP_OPTIONS = { port: 80, path: '/', method: 'GET', headers: { 'User-Agent': CHROME_WINDOWS, }, }; class HttpUtils { static hasRedirect(httpResp) { return HttpUtils.isRedirectableStatus(httpResp.statusCode); } static isRedirectableStatus(statusCode) { return statusCode !== undefined ? statusCode >= 300 && statusCode < 400 : false; } } HttpUtils.timeoutableGet = (options, timeout) => { return timeoutable(() => HttpUtils.get(options), timeout); }; HttpUtils.hasHttpsRedirect = (httpResp) => { if (HttpUtils.hasRedirect(httpResp)) { const redirect = httpResp.headers.location; if (redirect && url.parse(redirect).protocol === 'https:') { return true; } } return false; }; HttpUtils.get = (options) => { if (HttpUtils.isRequestOptionsAsObject(options)) { options = { ...BASE_HTTP_OPTIONS, ...options }; } return new Promise((resolve, reject) => { http.get(options, resp => resolve(resp)).on('error', (err) => { reject(err); }); }); }; HttpUtils.isRequestOptionsAsObject = (toBeDetermined) => { return !lodash.isString(toBeDetermined); }; const BASE_HTTPS_OPTIONS = { port: 443, path: '/', method: 'GET', headers: { 'User-Agent': CHROME_WINDOWS, }, }; class MonitorExecutorService { constructor(fetch = nodeFetch) { this.fetch = fetch; } executeMonitorCheck(monitor) { const url$1 = url.parse(monitor.endpoint); switch (monitor.type) { case 'latency-check': return this.latencyCheck({ url: url$1, headers: lodash.get(monitor, 'headers', {}) }); case 'certificate-check': return this.certificateCheck(url$1); default: return this.httpsCheck(url$1); } } latencyCheck({ url, timeout = MonitorExecutorService.DEFAULT_EXECUTOR_TIMEOUT, headers, }) { return new Promise(async (resolve) => { const { href } = url; if (!href) { return resolve({ httpStatus: 400, error: `Expected url, but got \`${href}\`` }); } this.fetch(href, { timeout, headers }) .then(resp => { return resp.blob().then(blob => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const timings = resp.timings; const blobSize = blob.size; resolve({ blobSize, timings, httpStatus: resp.status, }); }); }) .catch((err) => { let errorMessage = err.message; let httpStatus = 400; if (err.message.includes('network timeout')) { errorMessage = `Latency check timed out using timeout of ${timeout}ms.`; httpStatus = 408; // Connection timed out } resolve({ error: errorMessage, httpStatus, }); }); }); } async certificateCheck(url, timeout = MonitorExecutorService.DEFAULT_EXECUTOR_TIMEOUT) { const hostname = url.protocol ? url.hostname : url.href; if (!hostname) { return { hasCertificate: false, error: `Expected hostname, but got \`${hostname}\`` }; } return new Promise(resolve => { const timeoutId = setTimeout(() => { resolve({ hasCertificate: false, error: `Certificate check timed out using timeout of ${timeout}ms.`, }); }, timeout); const options = { ...BASE_HTTPS_OPTIONS, hostname }; const httpsResp = https__default.get(options).on('error', (err) => { clearTimeout(timeoutId); const errorMessage = err.message; resolve({ hasCertificate: false, error: errorMessage }); }); httpsResp.on('socket', (socket) => { socket.on('secureConnect', () => { const peerCertificate = socket.getPeerCertificate(); const certificateCheck = this.parseCertificate(peerCertificate); resolve(certificateCheck); clearTimeout(timeoutId); }); }); }); } async httpsCheck(url, timeout = MonitorExecutorService.DEFAULT_EXECUTOR_TIMEOUT) { const hostname = url.protocol ? url.hostname : url.href; if (!hostname) { return { secure: false, error: `Expected hostname, but got \`${hostname}\`` }; } return HttpUtils.timeoutableGet({ hostname }, timeout) .then(resp => { const secure = HttpUtils.hasHttpsRedirect(resp); return secure ? { secure } : { secure, error: `Could not find https redirect from ${hostname}, statusMessage: ${resp.statusMessage}`, }; }) .catch((err) => { const httpsCheck = { secure: false, error: err.message }; return httpsCheck; }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any parseCertificate(certificate) { const valid_to = new Date(certificate.valid_to); const expires_in = DateUtils.daysDateDiff(new Date(), valid_to); const hasCertificate = expires_in !== undefined && expires_in > 0; return hasCertificate ? { hasCertificate, valid_to, expires_in, } : { hasCertificate, error: 'Could not find PeerCertificate.', }; } } MonitorExecutorService.DEFAULT_EXECUTOR_TIMEOUT = 10000; // 10 seconds const monitorExecutorService = new MonitorExecutorService(); const startTimer = () => { return perf_hooks.performance.now(); }; const poolConfig = { user: process.env.POSTGRES_USER || 'postgres', password: process.env.POSTGRES_PASSWORD || 'postgres', port: parseInt(process.env.POSTGRES_PORT || '5431', 10), database: process.env.POSTGRES_DATABASE_NAME || 'postgres', host: process.env.POSTGRES_HOST || 'localhost', }; { console.log('Using TimescaleDB config: ', poolConfig); } const pgPool = new pg.Pool(poolConfig); pgPool.on('error', err => { console.error('[Postgres]: Unexpected error on idle client', err); }); async function queryPostgres(config, pool = pgPool) { const t0 = startTimer(); { console.log('------------------------------------------'); const text = isConfig(config) ? config.text : config; console.log(`${'[Postgres]: Executing query: '.padEnd(25)} [${text}]`); if (isConfig(config) && config.values) { console.log(`${'[Postgres]: Substitution values: '.padEnd(25)} [${config.values}]`); } } return pool .query(config) .then(resp => { { console.log(`${'[Postgres]: Query took: '.padEnd(25)} [${startTimer() - t0}ms]`); console.log('------------------------------------------'); } return resp; }) .catch((err) => { const errorMessage = `Something went wrong while executing query: ${err.message}`; console.error(`[Postgres]: ${errorMessage}`); throw new DatabaseException(errorMessage); }); } function isConfig(param) { return param.text !== undefined; } const CHECKS_SCHEMA = 'checks'; const LATENCY_TABLE_NAME = 'latency'; const CREATE_TABLE_SQL = `CREATE TABLE ${CHECKS_SCHEMA}.${LATENCY_TABLE_NAME} ( time TIMESTAMPTZ NOT NULL DEFAULT NOW(), user_id UUID NOT NULL, monitor_id UUID NOT NULL, region TEXT NOT NULL, status SMALLINT NOT NULL, error TEXT NULL, total_time REAL NULL, tls_handshake_time REAL NULL, tcp_connection_time REAL NULL, dns_lookup_time REAL NULL, first_byte_time REAL NULL, blob_size REAL NULL );`; const CREATE_HYPERTABLE_SQL = `SELECT create_hypertable('${CHECKS_SCHEMA}.${LATENCY_TABLE_NAME}', 'time');`; class TimescaleLatencyCheckStorage { constructor(postgresPool = pgPool) { this.createTable = () => { return this.queryPostgres(CREATE_TABLE_SQL).then(_ => { return this.queryPostgres(CREATE_HYPERTABLE_SQL); }); }; this.getMonitorChecks = (params) => { const queryConfig = this.getMonitorChecksQueryConfig(params); return this.queryPostgres(queryConfig).then(resp => { const rows = resp.rows; return rows; }); }; this.getMonitorChecksQueryConfig = ({ monitorId, pagination: { limit, offset = 0 }, }) => { return { name: 'get-monitor-latency-checks', text: `SELECT * FROM ${CHECKS_SCHEMA}.${LATENCY_TABLE_NAME} ` + `WHERE ${CHECKS_SCHEMA}.${LATENCY_TABLE_NAME}.monitor_id = $1 ` + 'ORDER BY time DESC LIMIT $2 OFFSET $3', values: [monitorId, limit, offset], }; }; this.insertLatencyCheck = (params) => { const insertLatencyCheckConfig = this.getInsertQueryConfig(params); return this.queryPostgres(insertLatencyCheckConfig).then(resp => { const latencyRow = resp.rows[0]; return latencyRow; }); }; this.postgresPool = postgresPool; } queryPostgres(queryConfig) { return queryPostgres(queryConfig, this.postgresPool); } getInsertQueryConfig({ userId, monitorId, region, data, created, }) { const timings = data.timings || {}; const blobSize = data.blobSize; const error = data.error; return { name: 'insert-latency-check', text: `INSERT INTO ${CHECKS_SCHEMA}.${LATENCY_TABLE_NAME} ` + '(time, user_id, monitor_id, region, status, error, total_time, tls_handshake_time, tcp_connection_time, dns_lookup_time, first_byte_time, blob_size) ' + 'VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *', values: [ created, userId, monitorId, region, data.httpStatus, error, timings.totalTime, timings.tlsHandshakeTime, timings.tcpConnectionTime, timings.dnsLookupTime, timings.firstByteTime, blobSize, ], }; } } const timescaleLatencyCheckStorage = new TimescaleLatencyCheckStorage(); class LatencyCheckService extends BaseService { constructor(latencyCheckStorage = timescaleLatencyCheckStorage, monitorStorage = dynamoMonitorStorage) { super(); this.getLatencyChecks = (userId, monitorId, pagination = { limit: 20 }) => { return this.tryExecute(async () => { const monitor = await this.monitorStorage.getMonitor(userId, monitorId); if (Authorization.of(userId).cannotAccessEntity(monitor)) { // Not reachable - getMonitor will throw 404 throw new ForbiddenException('You are not authorized to perform this action'); } return this.latencyCheckStorage.getMonitorChecks({ monitorId, pagination }).then(resp => { return this.serviceResponse(resp); }); }); }; this.latencyCheckStorage = latencyCheckStorage; this.monitorStorage = monitorStorage; } } const latencyCheckService = new LatencyCheckService(); class TimescaleMonitorCheckStorage { constructor(postgresPool = pgPool) { this.createChecksSchema = () => { return queryPostgres({ name: 'create-schema', text: `CREATE SCHEMA ${CHECKS_SCHEMA};`, }, this.postgresPool); }; this.insertCheckData = (data, executorType) => { switch (executorType) { case 'latency-check': const latencyCheckData = data; return this.latencyStorage.insertLatencyCheck(latencyCheckData); case 'certificate-check': throw new Error('Not implemented'); case 'https-check': throw new Error('Not implemented'); default: throw new UnreachableCaseError(executorType); } }; this.postgresPool = postgresPool; this.latencyStorage = new TimescaleLatencyCheckStorage(postgresPool); } } const timescaleMonitorCheckStorage = new TimescaleMonitorCheckStorage(); async function initializeDynamoDBTables(dynamodb$1 = dynamodb) { await createTable(dynamodb$1, USER_TABLE_SCHEMA); await createTable(dynamodb$1, MONITOR_TABLE_SCHEMA); } async function initiliazeTimescaleDBTables(postgresPool) { await new TimescaleMonitorCheckStorage(postgresPool).createChecksSchema(); await new TimescaleLatencyCheckStorage(postgresPool).createTable(); } async function initializeTables(dynamoDB = dynamodb, postgresPool = pgPool) { await initializeDynamoDBTables(dynamoDB); await initiliazeTimescaleDBTables(postgresPool); } exports.MonitorExecutorService = MonitorExecutorService; exports.MonitoringService = MonitoringService; exports.TimescaleMonitorCheckStorage = TimescaleMonitorCheckStorage; exports.UserService = UserService; exports.boomify = boomify; exports.executorService = monitorExecutorService; exports.initializeTables = initializeTables; exports.isBoom = isBoom; exports.monitorCheckStorage = timescaleMonitorCheckStorage; exports.monitorService = monitoringService; exports.userService = userService; })); //# sourceMappingURL=meshwatchbackend-core.umd.development.js.map