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.
 
 
 
 

307 lines
8.7 KiB

// dtp-chat.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import 'dotenv/config';
import path, { dirname } from 'path';
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 * as glob from 'glob';
import * as rfs from 'rotating-file-stream';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import WEBPACK_CONFIG from './webpack.config.js';
import mongoose from 'mongoose';
import { Redis } from 'ioredis';
import { SiteLog } from './lib/site-lib.js';
import { SiteTripwire } from './lib/site-tripwire.js';
import express from 'express';
import morgan from 'morgan';
import { Emitter } from '@socket.io/redis-emitter';
const APP_CONFIG = {
pkg: require('./package.json'),
};
class Harness {
constructor ( ) {
this.config = {
root: __dirname,
};
this.log = new SiteLog(this, 'Harness');
this.models = [ ];
}
async start ( ) {
this.log.info('loading SiteTripwire (known-malicious request protection)');
this.tripwire = new SiteTripwire(this);
await this.tripwire.start();
await this.startMongoDB();
await this.loadModels();
await this.connectRedis();
await this.startExpressJS();
}
async startMongoDB ( ) {
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 modelScripts = glob.sync(path.join(this.config.root, 'app', 'models', '*.js'));
this.log.info('loading models', { count: modelScripts.length });
for (const modelScript of modelScripts) {
const model = (await import(modelScript)).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.DTP_REDIS_HOST,
port: parseInt(process.env.DTP_REDIS_PORT || '6379', 10),
password: process.env.DTP_REDIS_PASSWORD,
keyPrefix: process.env.DTP_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 startExpressJS ( ) {
this.app = express();
this.app.locals.config = APP_CONFIG;
this.app.locals.pkg = APP_CONFIG.pkg; // convenience
this.app.locals.site = (await import(path.join(this.config.root, 'config', 'site.js'))).default;
this.app.set('view engine', 'pug');
this.app.set('views', path.join(__dirname, 'app', 'views'));
this.app.set('x-powered-by', false);
this.app.use(this.tripwire.guard.bind(this.tripwire));
/*
* HTTP request logging
*/
if (process.env.DTP_LOG_FILE === 'enabled') {
const httpLogStream = rfs.createStream(process.env.DTP_LOG_FILE_NAME_HTTP || 'dtp-sites-access.log', {
interval: '1d',
path: process.env.DTP_LOG_FILE_PATH || '/tmp',
compress: 'gzip',
});
this.app.use(morgan(process.env.DTP_LOG_HTTP_FORMAT || 'combined', { stream: httpLogStream }));
}
function cacheOneDay (req, res, next) {
res.set('Cache-Control', 'public, maxage=86400, s-maxage=86400, immutable');
return next();
}
function serviceWorkerAllowed (req, res, next) {
res.set('Service-Worker-Allowed', '/');
return next();
}
/*
* Static content and file services
*/
const staticOptions = {
cacheControl: true,
immutable: true,
maxAge: '4h',
dotfiles: 'ignore',
};
this.app.use('/fontawesome', cacheOneDay, express.static(path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free')));
this.app.use('/uikit', cacheOneDay, express.static(path.join(__dirname, 'node_modules', 'uikit')));
this.app.use('/pretty-checkbox', cacheOneDay, express.static(path.join(__dirname, 'node_modules', 'pretty-checkbox', 'dist')));
this.app.use('/dist', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'dist')));
this.app.use('/static', cacheOneDay, serviceWorkerAllowed, express.static(path.join(__dirname, 'client', 'static'), staticOptions));
/*
* Webpack integration
*/
if (process.env.NODE_ENV !== 'production') {
this.compiler = webpack(WEBPACK_CONFIG);
this.webpackDevMiddleware = webpackDevMiddleware(this.compiler, {
publicPath: WEBPACK_CONFIG.output.publicPath,
writeToDisk: true,
});
this.app.use(this.webpackDevMiddleware);
}
this.app.use((req, res, next) => {
res.locals.dtp = {
request: req,
};
return next();
});
await this.loadControllers();
/*
* Start the ExpressJS server
*/
const host = process.env.DTP_HTTP_HOST || '127.0.0.1';
const port = parseInt(process.env.DTP_HTTP_PORT || '3000', 10);
this.log.info('Starting application server', { host, port });
this.app.listen(port, host, ( ) => {
this.log.info(`${APP_CONFIG.pkg.name} online.`);
});
}
async loadControllers ( ) {
const scripts = glob.sync(path.join(this.config.root, 'app', 'controllers', '*.js'));
const inits = [ ];
this.controllers = { };
for await (const script of scripts) {
try {
const file = path.parse(script);
this.log.info('loading controller', { name: file.base });
let controller = await import(script);
controller = controller.default;
controller.instance = controller.create(this);
this.controllers[controller.slug] = controller;
inits.push(controller);
} catch (error) {
this.log.error('failed to load controller', { error });
throw new Error('failed to load controller', { cause: error });
}
}
for await (const controller of inits) {
if (controller.isHome) {
continue;
}
await controller.instance.start();
}
/*
* Start the Home controller
*/
await this.controllers.home.instance.start();
}
}
(async ( ) => {
try {
const harness = new Harness();
await harness.start();
} catch (error) {
console.error('failed to start application harness', error);
}
})();