@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
286 lines (252 loc) • 10.9 kB
JavaScript
/**
* A service dedicated to creating and configuring an Express.js application
* instance based on server configuration data.
* @module src/runtime/express/Express.js
* @namespace ExpressService
*/
import fs from 'fs-extra';
import express from 'express';
import fileUpload from 'express-fileupload';
import swaggerUi from 'swagger-ui-express';
import compression from 'compression';
import { createServer } from 'http';
import { loggerFactory, loggerMiddleware } from '../../server/logger.js';
import { getCapVariableName, newInstance } from '../../client/components/core/CommonJs.js';
import { MailerProvider } from '../../mailer/MailerProvider.js';
import { DataBaseProviderService } from '../../db/DataBaseProvider.js';
import { createPeerServer } from '../../server/peer.js';
import { createValkeyConnection } from '../../server/valkey.js';
import { applySecurity, authMiddlewareFactory } from '../../server/auth.js';
import { ssrMiddlewareFactory } from '../../client-builder/ssr.js';
import { buildSwaggerUiOptions } from '../../client-builder/client-build-docs.js';
import { shellExec } from '../../server/process.js';
import { devProxyHostFactory, isDevProxyContext, isTlsDevProxy } from '../../server/conf.js';
import Underpost from '../../index.js';
const logger = loggerFactory(import.meta);
/**
* @class ExpressService
* @description A service dedicated to creating and configuring an Express.js application
* instance based on server configuration data.
* @memberof ExpressService
*/
class ExpressService {
/**
* Creates and configures a complete Express application instance for a specific host/path configuration.
*
* @method createApp
* @memberof ExpressService
* @param {string} config.host - The host name for the instance (e.g., 'www.example.com').
* @param {string} config.path - The URL path for the instance (e.g., '/', '/api/v1').
* @param {number} config.port - The primary listening port for the instance.
* @param {string} config.client - The client associated with the instance (used for SSR/Mailer configuration lookup).
* @param {string[]} [config.apis] - A list of API names to load and attach routers for.
* @param {string[]} config.origins - Allowed origins for CORS.
* @param {string} [config.directory] - The directory for static files (if overriding default).
* @param {boolean} [config.useLocalSsl] - Whether to use local SSL for the instance.
* @param {string} [config.ws] - The WebSocket server name to use.
* @param {object} [config.mailer] - Mailer configuration.
* @param {object} [config.db] - Database configuration.
* @param {string} [config.redirect] - URL or flag to indicate an HTTP redirect should be configured.
* @param {boolean} [config.peer] - Whether to enable the peer server.
* @param {object} [config.valkey] - Valkey connection configuration.
* @param {string} [config.apiBaseHost] - Base host for the API (if running separate API).
* @param {string} config.redirectTarget - The full target URL for redirection (used if `redirect` is true).
* @param {string} config.rootHostPath - The root path for public host assets (e.g., `/public/hostname`).
* @param {object} config.confSSR - The SSR configuration object, used to look up Mailer templates.
* @param {import('prom-client').Counter<string>} config.promRequestCounter - Prometheus request counter instance.
* @param {import('prom-client').Registry} config.promRegister - Prometheus register instance for metrics.
* @returns {Promise<{portsUsed: number}>} An object indicating how many additional ports were used (e.g., for PeerServer).
*/
async createApp({
host,
path,
port,
client,
apis,
origins,
directory,
useLocalSsl,
ws,
mailer,
db,
redirect,
peer,
valkey,
apiBaseHost,
grpc,
redirectTarget,
rootHostPath,
confSSR,
promRequestCounter,
promRegister,
}) {
let portsUsed = 0;
const runningData = {
host,
path,
runtime: 'nodejs',
client,
meta: import.meta,
apis,
};
const app = express();
if (origins && isDevProxyContext())
origins.push(devProxyHostFactory({ host, includeHttp: true, tls: isTlsDevProxy() }));
app.set('trust proxy', 1);
app.use((req, res, next) => {
res.on('finish', () => {
promRequestCounter.inc({
instance: `${host}:${port}${path}`,
method: req.method,
status_code: res.statusCode,
});
});
return next();
});
// Metrics endpoint
app.get(`${path === '/' ? '' : path}/metrics`, async (req, res) => {
res.set('Content-Type', promRegister.contentType);
return res.end(await promRegister.metrics());
});
// Logging, Compression, and Body Parsers
app.use(loggerMiddleware(import.meta));
app.use(compression({ filter: (req, res) => !req.headers['x-no-compression'] && compression.filter(req, res) }));
app.use(express.json({ limit: '100MB' }));
app.use(express.urlencoded({ extended: true, limit: '20MB' }));
app.use(fileUpload());
if (process.env.NODE_ENV === 'development') app.set('json spaces', 2);
// Language handling middleware
app.use((req, res, next) => {
const lang = req.headers['accept-language'] || 'en';
req.lang = typeof lang === 'string' && lang.toLowerCase().match('es') ? 'es' : 'en';
return next();
});
// Static file serving
app.use(
'/',
express.static(directory ? directory : `.${rootHostPath}`, {
setHeaders: (res, filePath) => {
if (filePath.includes('/assets/')) {
res.set('Access-Control-Allow-Origin', '*');
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
}
},
}),
);
// Handle redirection-only instances
if (redirect) {
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.url.startsWith(`/.well-known/acme-challenge`)) {
return res.status(302).redirect(redirectTarget + req.url);
}
return next();
});
await Underpost.start.listenPortController(app, port, runningData);
return { portsUsed };
}
// Create HTTP server for regular instances (required for WebSockets)
const server = createServer({}, app);
if (!apiBaseHost) {
// Swagger path definition
const swaggerJsonPath = `./public/${host}${path === '/' ? path : `${path}/`}swagger-output.json`;
const swaggerPath = `${path === '/' ? `/api-docs` : `${path}/api-docs`}`;
// Flag swagger requests before security middleware
if (fs.existsSync(swaggerJsonPath)) {
app.use(swaggerPath, (req, res, next) => {
res.locals.isSwagger = true;
next();
});
}
// Security and CORS — must run before swagger-ui so CSP headers are included in swagger responses
if (process.env.NODE_ENV === 'development' && useLocalSsl)
origins = origins.map((origin) => origin.replace('http', 'https'));
applySecurity(app, {
origin: origins,
});
// Swagger UI setup (after security middleware so responses carry correct CSP headers)
if (fs.existsSync(swaggerJsonPath)) {
const swaggerDoc = JSON.parse(fs.readFileSync(swaggerJsonPath, 'utf8'));
const swaggerUiOptions = await buildSwaggerUiOptions();
app.use(swaggerPath, swaggerUi.serve, swaggerUi.setup(swaggerDoc, swaggerUiOptions));
}
// Database and Valkey connections
if (db && apis) await DataBaseProviderService.load({ apis, host, path, db });
if (valkey) await createValkeyConnection({ host, path }, valkey);
// Mailer setup
if (mailer) {
const mailerSsrConf = confSSR[getCapVariableName(client)];
await MailerProvider.load({
id: `${host}${path}`,
meta: `mailer-${host}${path}`,
host,
path,
...mailer,
templates: mailerSsrConf ? mailerSsrConf.mailer : {},
});
}
// gRPC server
if (path === '/' && grpc && grpc.module) {
const { GrpcServer } = await import(`../../grpc/${grpc.module}/grpc-server.js`);
await GrpcServer.start({ host, path, port: grpc.port || 50051 });
}
// API router loading
if (apis && apis.length > 0) {
const authMiddleware = authMiddlewareFactory({ host, path });
const apiPath = `${path === '/' ? '' : path}/${process.env.BASE_API}`;
for (const api of apis) {
logger.info(`Build api server`, `${host}${apiPath}/${api}`);
const { ApiRouter } = await import(`../../api/${api}/${api}.router.js`);
const router = ApiRouter({ app, host, path, apiPath, mailer, db, authMiddleware, origins });
app.use(`${apiPath}/${api}`, router);
}
}
// WebSocket server setup
if (ws) {
const { createIoServer } = await import(`../../ws/${ws}/${ws}.ws.server.js`);
const { options, meta, ioServer } = await createIoServer(server, { host, path, db, port, origins });
// Listen on the main port for the WS server
await Underpost.start.listenPortController(ioServer, port, {
runtime: 'nodejs',
client: null,
host,
path: options.path,
meta,
});
}
// Peer server setup
if (peer) {
portsUsed++; // Peer server uses one additional port
const peerPort = newInstance(port + portsUsed); // portsUsed is 1 here
const { options, meta, peerServer } = await createPeerServer({
port: peerPort,
origins,
path,
});
await Underpost.start.listenPortController(peerServer, peerPort, {
runtime: 'nodejs',
client: null,
host,
path: options.path,
meta,
});
}
}
// SSR middleware loading
const ssr = await ssrMiddlewareFactory({ app, directory, rootHostPath, path });
for (const [_, ssrMiddleware] of Object.entries(ssr)) app.use(ssrMiddleware);
// Start listening on the main port
// When behind a dev proxy (isDevProxyContext), the proxy handles TLS termination,
// so backend servers should listen on plain HTTP to be reachable via http://localhost:PORT
if (
(useLocalSsl || process.argv.find((arg) => arg === 'tls')) &&
process.env.NODE_ENV === 'development' &&
!isDevProxyContext()
) {
if (!Underpost.tls.validateSecureContext()) shellExec(`node bin/deploy tls`);
const { ServerSSL } = await Underpost.tls.createSslServer(app);
await Underpost.start.listenPortController(ServerSSL, port, runningData);
} else await Underpost.start.listenPortController(server, port, runningData);
return { portsUsed };
}
}
export default new ExpressService();