// web-app.ts // Copyright (C) 2026 Robert Colbert // All Rights Reserved import env from "./config/env.js"; import assert from "node:assert"; import path from "node:path"; import http from "node:http"; import "./lib/db.js"; import redis from "./lib/redis.js"; import cookieParser from "cookie-parser"; import compress from "compression"; import methodOverride from "method-override"; import favicon from "serve-favicon"; import session, { SessionOptions } from "express-session"; import { RedisStore } from "connect-redis"; import express, { NextFunction, Request, Response } from "express"; import morgan from "morgan"; import * as rfs from "rotating-file-stream"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime.js"; dayjs.extend(relativeTime); import numeral from "numeral"; type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined; import { GadgetComponent, GadgetLog } from "@gadget/api"; import { User } from "./models/user.js"; import { ApiController } from "./controllers/api.js"; import { HomeController } from "./controllers/home.js"; import { UserController } from "./controllers/user.js"; import { ApiClientService, SessionService, SocketService, startServices, stopServices, } from "./services/index.js"; class DtpWebAppServer implements GadgetComponent { private log: GadgetLog; private app?: express.Application; private server?: http.Server; get name(): string { return "DtpWebAppServer"; } get slug(): string { return "web-app"; } constructor() { this.log = new GadgetLog(this); } async start(): Promise { this.hookProcessSignals(); await startServices(); await this.createExpressApp(); await this.startHttpServer(); this.log.info( `DTP application server online at http://${env.https.address}:${env.https.port}/`, ); } async stop(): Promise { await this.stopHttpServer(); await stopServices(); return 0; } async createExpressApp(): Promise { this.log.info("creating ExpressJS app"); this.app = express(); this.app.set("trust proxy", true); this.app.set("x-powered-by", false); this.app.locals.env = env; this.app.locals.pkg = env.pkg; this.app.locals.site = env.site; this.app.locals.dayjs = dayjs; this.app.locals.numeral = numeral; if (env.log.https.enabled) { this.log.info("creating HTTPS access log", { name: env.log.https.name, path: env.log.https.path, }); const httpLogStream = rfs.createStream( env.log.https.name || "gadget-code-access.log", { interval: "1d", path: env.log.https.path, compress: "gzip", }, ); this.app.use(morgan(env.log.https.format, { stream: httpLogStream })); } /* * Static file services */ this.app.use( favicon(path.join(env.installDir, "assets", "icon", "icon-32x32.png")), ); this.app.use( "/assets", express.static(path.resolve(env.installDir, "assets")), ); this.app.use( "/client", express.static(path.resolve(env.installDir, "dist", "client")), ); /* * REST, User, and Session support */ this.app.use(async (req: Request, res: Response, next: NextFunction) => { await SessionService.recordWebVisit(req); if (req.query) { res.locals.query = req.query; } const contentType = req.get("content-type") || ""; if (!contentType.includes("application/json")) { return next(); } let data: string = ""; req.on("data", (chunk) => { data += chunk.toString(); }); req.on("end", () => { req.rawBody = data; if (!data) { req.body = {}; return next(); } try { const parsedData = JSON.parse(data); req.body = parsedData; } catch (err) { return next(err); } next(); }); }); this.app.use(express.json()); this.app.use(express.urlencoded({ extended: true })); this.app.use(cookieParser(env.session.secret)); this.app.use(compress()); this.app.use(methodOverride()); const ONE_WEEK = 1000 * 60 * 60 * 24 * 7; const SESSION_DURATION = ONE_WEEK; /* * ExpressJS session configuration */ const store = new RedisStore({ client: redis, }); assert(env.session.secret, "Must define an HTTP session secret"); const sessionConfig: SessionOptions = { name: `mtsess-${env.site.domainKey}-${env.NODE_ENV}`, secret: env.session.secret, resave: true, saveUninitialized: true, proxy: env.session.trustProxy, cookie: { path: "/", httpOnly: true, secure: env.session.cookie.secure, sameSite: env.session.cookie.sameSite as SameSiteOption, maxAge: SESSION_DURATION, }, store, }; this.app.use(session(sessionConfig)); this.app.use(ApiClientService.middleware()); this.app.use(this.restoreUserSession.bind(this)); this.app.use("/", await this.createAppRouter()); this.app.use(this.defaultErrorHandler.bind(this)); } async createAppRouter(): Promise { const router: express.Router = express.Router(); const apiController = new ApiController(); await apiController.start(); this.log.info("mounting ApiController", { route: apiController.route, }); router.use(apiController.route, apiController.router); const userController = new UserController(); await userController.start(); this.log.info("mounting UserController", { route: userController.route, }); router.use(userController.route, userController.router); const homeController = new HomeController(); await homeController.start(); this.log.info("mounting HomeController", { route: homeController.route }); router.use(homeController.route, homeController.router); return router; } async startHttpServer(): Promise { return new Promise(async (resolve) => { assert(this.app, "ExpressJS app instance is required"); this.log.info("starting HTTP server", { address: env.https.address, port: env.https.port, }); /* * Create HTTP server */ this.server = http.createServer(this.app); /* * Start the Socket.IO service */ await SocketService.listen(this.server); /* * Start HTTP server */ this.server.listen( env.https.port, env.https.address, env.https.backlog, () => { this.log.debug("HTTP server started"); resolve(); }, ); }); } async stopHttpServer(): Promise { return new Promise((resolve, reject) => { if (!this.server) { return; } this.log.info("closing all HTTP connections"); this.server.closeAllConnections(); if (!this.server.listening) { delete this.server; return resolve(); } this.log.info("closing HTTP server"); this.server.close((error) => { if (error) { return reject(error); } delete this.server; resolve(); }); }); } async defaultErrorHandler( err: Error, _req: Request, res: Response, _next: NextFunction, ) { this.log.error("ExpressJS error", { error: err }); res.locals.errorCode = err.statusCode || 500; res.locals.error = err; res.status(res.locals.errorCode).json({ success: false, message: err.message, }); } async restoreUserSession( req: Request, res: Response, next: NextFunction, ): Promise { let token; if (req.headers["authorization"]) { const tokens = req.headers["authorization"].split(" "); token = tokens[tokens.length - 1]; } if (!token && (!req.session || !req.session.user)) { return next(); } if (token) { req.user = await SessionService.verifyJsonWebToken(token); } else { req.user = await User.findOne({ _id: req.session.user._id }); } res.locals.user = req.user; if (!res.locals.user) { req.session.destroy((err: Error) => { if (err) { return next(err); } return next(); }); return; } return next(); } hookProcessSignals(): void { process.title = this.name; process.on("unhandledRejection", async (error: Error, p) => { this.log.error("Unhandled rejection", { error: error, promise: p, stack: error.stack, }); const exitCode: number = await this.stop(); process.nextTick(() => { process.exit(exitCode); }); }); process.on("warning", (error) => { if (error.name === "DeprecationWarning") { return; } this.log.alert("warning", { error }); }); process.once("SIGINT", async () => { this.log.info("SIGINT received (requesting shutdown)"); const exitCode = await this.stop(); process.nextTick(() => { process.exit(exitCode); }); }); } } (async () => { try { const server = new DtpWebAppServer(); await server.start(); } catch (error) { console.error("DTP Web App server error", { error }); process.exit(-1); } })();