DTP Base provides a scalable and secure Node.js application development harness ready for production service.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

303 lines
8.9 KiB

// site-runtime.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import 'dotenv/config';
import path, { dirname } from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
import { createRequire } from 'module';
const require = createRequire(import.meta.url); // jshint ignore:line
import numeral from 'numeral';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(relativeTime);
import * as Marked from 'marked';
import hljs from 'highlight.js';
import mongoose from 'mongoose';
import { Redis } from 'ioredis';
import { SiteLog } from './site-log.js';
import { SiteTripwire } from './site-tripwire.js';
import { Emitter } from '@socket.io/redis-emitter';
export class SiteRuntime {
static get name ( ) { return 'SiteRuntime'; }
static get slug ( ) { return 'runtime'; }
constructor (RuntimeClass, rootDir) {
this.config = { root: rootDir };
this.models = [ ];
this.log = new SiteLog(this, RuntimeClass);
}
async start ( ) {
await this.loadConfig();
this.log.info('loading SiteTripwire (known-malicious request protection)');
this.tripwire = new SiteTripwire(this);
await this.tripwire.start();
await this.connectMongoDB();
await this.connectRedis();
await this.loadModels();
await this.loadServices();
process.on('unhandledRejection', async (error, p) => {
this.log.error('Unhandled rejection', {
error: error,
promise: p,
stack: error.stack
});
const exitCode = await this.shutdown();
await this.terminate(exitCode);
});
process.on('warning', (error) => {
this.log.alert('warning', { error });
});
process.once('SIGINT', async ( ) => {
this.log.info('SIGINT received (requesting shutdown)');
const exitCode = await this.shutdown();
this.terminate(exitCode);
});
}
async shutdown ( ) {
this.log.alert('System runtime shutting down');
}
terminate (exitCode = 0) {
process.nextTick(( ) => {
process.exit(exitCode);
});
}
async loadConfig ( ) {
this.log.debug('loading config', { root: this.config.root });
this.config.pkg = require(path.join(this.config.root, 'package.json'));
this.config.site = (await import(path.join(this.config.root, 'config', 'site.js'))).default;
this.config.limiter = (await import(path.join(this.config.root, 'config', 'limiter.js'))).default;
this.config.jobQueues = (await import(path.join(this.config.root, 'config', 'job-queues.js'))).default;
}
async connectMongoDB ( ) {
try {
this.log.info('starting MongoDB');
/*
* For some time, strictQuery=true was the Mongoose default. It's being
* changed. The option has to be set manually now.
*/
mongoose.set('strictQuery', true);
this.log.info('connecting to MongoDB database', {
pid: process.pid,
host: process.env.MONGODB_HOST,
database: process.env.MONGODB_DATABASE,
});
const mongoConnectUri = `mongodb://${process.env.DTP_MONGODB_HOST}/${process.env.DTP_MONGODB_DATABASE}`;
await mongoose.connect(mongoConnectUri, {
socketTimeoutMS: 0,
dbName: process.env.MONGODB_DATABASE,
});
this.db = mongoose.connection;
this.log.info('connected to MongoDB');
} catch (error) {
this.log.error('failed to connect to database', { error });
throw error;
}
}
async loadModels ( ) {
const basePath = path.join(this.config.root, 'app', 'models');
const entries = await fs.promises.readdir(basePath, { withFileTypes: true });
this.log.info('loading models', { count: entries.length });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const filename = path.join(basePath, entry.name);
const model = (await import(filename)).default;
if (this.models[model.modelName]) {
this.log.error('model name collision', { name: model.modelName });
process.exit(-1);
}
this.models.push(model);
this.log.info('model loaded', { name: model.modelName});
}
}
async resetIndexes (target) {
if (target === 'all') {
for (const model of this.models) {
await this.resetIndex(model);
}
return;
}
const model = this.models.find((model) => model.modelName === target);
if (!model) {
throw new Error(`requested Mongoose model does not exist: ${target}`);
}
return this.resetIndex(model);
}
async resetIndex (model) {
return new Promise(async (resolve, reject) => {
this.log.info('dropping model indexes', { model: model.modelName });
model.collection.dropIndexes((err) => {
if (err) {
return reject(err);
}
this.log.info('creating model indexes', { model: model.modelName });
model.ensureIndexes((err) => {
if (err) {
return reject(err);
}
return resolve(model);
});
});
});
}
async connectRedis ( ) {
try {
const options = {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
keyPrefix: process.env.REDIS_KEY_PREFIX,
lazyConnect: false,
};
this.log.info('connecting to Redis', {
host: options.host,
port: options.port,
prefix: options.keyPrefix || 'dtp',
});
this.redis = new Redis(options);
this.redis.setMaxListeners(64); // prevents warnings/errors with Bull Queue
this.log.info('creating Socket.io Emitter');
this.emitter = new Emitter(this.redis);
this.log.info('Redis connected');
} catch (error) {
this.log.error('failed to connect to Redis', error);
throw new Error('failed to connect to Redis', { cause: error });
}
}
async getRedisKeys (pattern) {
return new Promise((resolve, reject) => {
return this.redis.keys(pattern, (err, response) => {
if (err) {
return reject(err);
}
return resolve(response);
});
});
}
async loadServices ( ) {
const basePath = path.join(this.config.root, 'app', 'services');
const entries = await fs.promises.readdir(basePath, { withFileTypes: true });
const inits = [ ];
this.services = { };
for await (const entry of entries) {
if (!entry.isFile()) {
continue;
}
try {
const ServiceClass = (await import(path.join(basePath, entry.name))).default;
this.log.info('loading service', {
script: entry.name,
name: ServiceClass.name,
slug: ServiceClass.slug,
});
const service = new ServiceClass(this);
this.services[ServiceClass.slug] = service;
inits.push(service);
} catch (error) {
this.log.error('failed to load service', { script: entry, error });
throw new Error('failed to load service', { cause: error });
}
}
for await (const service of inits) {
await service.start();
}
}
async populateViewModel (viewModel) {
viewModel.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV !== 'production');
viewModel.dtp = this;
viewModel.pkg = this.config.pkg;
viewModel.dayjs = dayjs;
viewModel.numeral = numeral;
viewModel.hljs = hljs;
// viewModel.anchorme = require('anchorme').default;
// viewModel.Color = require('color');
// viewModel.numberToWords = require('number-to-words');
viewModel.uuidv4 = (await import('uuid')).v4;
/*
* Set up the protected markdown renderer that will refuse to process links and images
* for security reasons.
*/
function safeImageRenderer (href, title, text) { return text; }
function safeLinkRenderer (href, title, text) { return text; }
function confirmedLinkRenderer (href, title, text) {
return `<a href="${href}" uk-tooltip="${title || 'Visit link...'}" onclick="return dtp.app.confirmNavigation(event);">${text}</a>`;
}
viewModel.fullMarkedRenderer = new Marked.Renderer();
viewModel.fullMarkedRenderer.image = safeImageRenderer;
viewModel.fullMarkedRenderer.link = confirmedLinkRenderer;
viewModel.safeMarkedRenderer = new Marked.Renderer();
viewModel.safeMarkedRenderer.image = safeImageRenderer;
viewModel.safeMarkedRenderer.link = safeLinkRenderer;
viewModel.markedConfigChat = {
renderer: this.safeMarkedRenderer,
highlight: function(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
langPrefix: 'hljs language-',
pedantic: false,
gfm: true,
breaks: true,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
};
Marked.setOptions(viewModel.markedConfigChat);
viewModel.marked = Marked;
}
}