@getanthill/datastore
Version:
Event-Sourced Datastore
436 lines (368 loc) • 12.9 kB
text/typescript
import type { Server } from 'node:http';
import type { MongoDbConnector } from '@getanthill/mongodb-connector';
import type { ProcessDestroySignal, Services } from './typings';
import path from 'node:path';
import express, { type RequestHandler } from 'express';
import helmet from 'helmet';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import compression from 'compression';
import { init as initModels } from './models';
export default class App {
services: Services;
express: express.Express | null = null;
server: Server | null = null;
isAlive = false;
constructor(services: Services) {
this.services = services;
}
bind() {
process.once('SIGTERM', this.signalHandler('SIGTERM'));
process.once('SIGINT', this.signalHandler('SIGINT'));
process.once('uncaughtException', this.errorHandler('uncaughtException'));
process.once('unhandledRejection', this.errorHandler('unhandledRejection'));
return this;
}
async start() {
if (this.server) {
return;
}
const tic = Date.now();
const { logger } = this.services.telemetry;
logger.debug('[App] Starting telemetry');
await this.services.telemetry.start();
this.services.metrics.incrementProcessStatus({ state: 'starting' });
logger.info('[App] Starting');
this.express = express();
this.isAlive = false;
logger.debug('[App] Configured features', {
features: this.services.config.features,
});
this.express
.use((req, res, next) => {
res.locals.tic = Date.now();
next();
})
.use(
helmet({
contentSecurityPolicy:
this.services.config.mode === 'development' ? false : undefined,
}),
)
.disable('x-powered-by');
// Configure CORS headers
const corsMiddleware = async (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (this.services.config.features.cors.isEnabled === false) {
res.header({
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': req.get('origin'),
'Access-Control-Allow-Methods': '*',
'Access-Control-Expose-Headers': '*',
'Access-Control-Request-Headers': '*',
'Access-Control-Request-Method': '*',
});
} else {
this.services.config.features.cors.allowCredentials !== undefined &&
res.header(
'Access-Control-Allow-Credentials',
this.services.config.features.cors.allowCredentials,
);
res.header(
'Access-Control-Allow-Headers',
`${this.services.config.features.cors.allowHeaders},authorization,csrf-token,page,page-size,decrypt,content-type,cache-control,cursor-last-id,cursor-last-correlation-id`,
);
this.services.config.features.cors.allowMethods !== undefined &&
res.header(
'Access-Control-Allow-Methods',
this.services.config.features.cors.allowMethods,
);
res.header(
'Access-Control-Allow-Origin',
this.services.config.features.cors.allowOrigin ?? req.get('origin'),
);
this.services.config.features.cors.exposeHeaders !== undefined &&
res.header(
'Access-Control-Expose-Headers',
this.services.config.features.cors.exposeHeaders,
);
this.services.config.features.cors.requestHeaders !== undefined &&
res.header(
'Access-Control-Request-Headers',
this.services.config.features.cors.requestHeaders,
);
this.services.config.features.cors.requestMethod !== undefined &&
res.header(
'Access-Control-Request-Method',
this.services.config.features.cors.requestMethod,
);
}
if (req.method === 'OPTIONS') {
return res.send();
}
return next();
};
this.express.use(corsMiddleware as RequestHandler);
/**
* Heartbeat route (Unauthenticated)
*/
const availabilityMiddleware = async (
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (this.isAlive === false) {
logger.debug('[App] Readiness - Service unavailable');
return res
.status(503)
.json({ status: 503, message: 'Service Unavailable' });
}
logger.debug('[App] Readiness - Service ready');
return next();
};
this.express
.use(compression())
.get('/ready', (_req, res): void => {
res.json({ is_ready: true });
})
.use(availabilityMiddleware as RequestHandler)
.get('/heartbeat', async (req, res): Promise<void> => {
res.json({ is_alive: this.isAlive });
});
// Listen
logger.debug('[App] Starting HTTP server', {
port: this.services.config.port,
});
this.server = this.express.listen(this.services.config.port, () => {
logger.info('[App] Listening', {
port: this.services.config.port,
pid: process.pid,
});
});
logger.debug('[App] Connecting MongoDb...', {
configs: this.services.config.mongodb.databases.map(
(d: MongoDbConnector.DatabaseConfig) => ({
name: d.name,
options: d.options,
}),
),
});
await this.services.mongodb.connect();
logger.info('[App] MongoDb connected');
logger.debug('[App] Models initialization...');
this.services.models = initModels(
this.services.config.models,
this.services,
);
if (this.services.config.features.initInternalModels === true) {
logger.debug('[App] Internal models indexes creation');
this.services.models
.initInternalModels()
.then(() => {
logger.debug('[App] Internal models indexes created');
})
.catch((err) => {
logger.warn('[App] Internal models indexes creation failed', err);
});
}
logger.debug('[App] Loading models definitions');
await this.services.models.load();
logger.info('[App] Models initialized', {
count: this.services.models.MODELS.size,
models: Array.from(this.services.models.MODELS.keys()),
});
if (this.services.config.features.api.templates === true) {
this.express.use(
'/templates',
express.static(path.resolve(__dirname, '../templates')),
);
}
if (this.services.config.security.accessTokenByCookie === true) {
this.express.use(cookieParser());
// Authentication layer
this.express
.use(
bodyParser.json({
limit: this.services.config.features.api.json.limit,
}),
)
.post('/auth', (req, res) => {
const body = req.body;
for (const key in body) {
const maxAge =
(this.services.config.features.cookies.maxAges[key] ??
this.services.config.features.cookies.options.maxAge) * 1000;
res.cookie(key, body[key], {
maxAge,
httpOnly: this.services.config.features.cookies.options.httpOnly,
secure: this.services.config.features.cookies.options.secure,
sameSite: this.services.config.features.cookies.options.sameSite,
domain:
this.services.config.features.cookies.options.domain ??
req.header('host'),
});
}
res.json({ is_authenticated: true });
})
.get('/auth', (req, res) => {
const response: Record<string, string> = {};
const cookies = ((req.query.cookies as string[]) ?? []).filter(
(c) => c !== 'token',
);
for (const cookie of cookies) {
response[cookie] = req.cookies[cookie];
res.cookie(cookie, '', {
httpOnly: this.services.config.features.cookies.options.httpOnly,
secure: this.services.config.features.cookies.options.secure,
sameSite: this.services.config.features.cookies.options.sameSite,
domain:
this.services.config.features.cookies.options.domain ??
req.header('host'),
maxAge: 0,
});
}
res.json(response);
});
}
// OpenAPI 3.0 middleware
this.express.use('/api', await this.services.api(this.services));
this.isAlive = true;
logger.info('[App] Datastore is up', {
tic_time_in_milliseconds: Date.now() - tic,
});
let withEvents = false;
if (this.services.config.features?.mqtt?.isEnabled === true) {
logger.debug('[App] Connecting to MQTT...');
await this.services.mqtt.connect();
logger.info('[App] Connected to MQTT');
withEvents = true;
}
if (this.services.config.features?.amqp?.isEnabled === true) {
logger.debug('[App] Connecting to AMQP...');
await this.services.amqp.connect();
logger.info('[App] Connected to AMQP');
withEvents = true;
}
withEvents === true && (await this.services.events(this.services));
this.services.metrics.incrementProcessStatus({ state: 'started' });
// Build Data Model graph
this.services.models.getGraph();
return this;
}
stop() {
this.services.signals.emit('stop');
return new Promise((resolve, reject) => {
if (this.server) {
this.server.close((err) => {
if (err) {
return reject(err);
}
this.server = null;
this.express = null;
if (this.services.config.features?.mqtt?.isEnabled === true) {
this.services.mqtt
.end()
.then(() => {
this.services.telemetry.logger.info(
'[destroy] MQTT client stopped',
err,
);
})
.catch((err) => {
this.services.telemetry.logger.error(
'[destroy] MQTT client stopping error',
err,
);
});
}
if (this.services.config.features?.amqp?.isEnabled === true) {
this.services.amqp
.end()
.then(() => {
this.services.telemetry.logger.info(
'[destroy] AMQP client stopped',
err,
);
})
.catch((err) => {
this.services.telemetry.logger.error(
'[destroy] AMQP client stopping error',
err,
);
});
}
this.services.mongodb
.disconnect()
.then(() => {
this.isAlive === true && resolve(this);
})
.catch(() => {
this.isAlive === true && resolve(this);
});
this.isAlive === false && resolve(this);
});
} else {
return resolve(this);
}
});
}
async restart() {
await this.stop();
await this.start();
return this;
}
signalHandler(signal: ProcessDestroySignal): () => Promise<NodeJS.Timeout> {
return () => {
return this.destroy(signal);
};
}
errorHandler(
signal: ProcessDestroySignal,
): (err: Error) => Promise<NodeJS.Timeout> {
return (err) => {
return this.destroy(signal, err);
};
}
/**
* Cleanup and stop the process properly, then exit the process.
* @param signal - Signal to stop the process with
* @param err - Error that caused the destruction of the process
*/
destroy(signal: ProcessDestroySignal, err?: Error): Promise<NodeJS.Timeout> {
this.services.metrics.incrementProcessStatus({
state: err ? 'crashing' : 'stopping',
});
const { logger } = this.services.telemetry;
if (err) {
logger.error('[destroy] Application error', { err, signal });
}
logger.info('[destroy] Stopping application', { err, signal });
return this.stop()
.then(() => {
logger.info('[destroy] Application stopped', err);
this.services.metrics.incrementProcessStatus({
state: err ? 'crashed' : 'stopped',
});
const timeout = setTimeout(() => {
timeout.unref();
process.exit(err ? 1 : 0);
}, this.services.config.exitTimeout);
return timeout;
})
.catch((err_) => {
logger.error('[destroy] Application crashed', {
err: err_,
firstErr: err,
});
const timeout = setTimeout(() => {
timeout.unref();
process.exit(1);
}, this.services.config.exitTimeout);
return timeout;
});
}
}