agmission/Development/server/server.js

257 lines
8.7 KiB
JavaScript

/* eslint-disable no-unused-vars */
'use strict';
// Ref: http://javascript.tutorialhorizon.com/2014/09/20/organizing-your-expressjs-routes-in-separate-files/
const express = require('express');
require('express-async-errors');
const rateLimit = require('express-rate-limit')
const debug = require('debug')('agm:server'),
compression = require('compression'),
path = require('path'),
fs = require('fs-extra'),
http = require('http'),
https1 = require('https'),
http2 = require('node:http2'), // Native HTTP/2 server (Express expects HTTP/1.1 req/res; do not advertise h2 unless using a compat layer)
app = express(),
env = require('./helpers/env'),
{ registerFatalHandlers, createServerIgnore } = require('./helpers/process_fatal_handlers'),
dbConnect = require('./helpers/db/connect.js'),
{ checkUser } = require('./middlewares/app_validator.js'),
{ ErrorHandler } = require('./middlewares/error_handler.js'),
errorHandler = require('error-handler').errorHandler;
http.globalAgent.maxSockets = Infinity;
const MAX_REQ_BDY_MB = env.MAX_REQ_BDY_MB || '150mb'
// Load ENV vars specified in the .env file (within the same folder)
debug("Is in Production: ", env.PRODUCTION);
app.isProd = env.PRODUCTION;
process.setMaxListeners(0);
// DISABLED (intentionally): error-handler's process hooks use key-file-storage (read/modify/write)
// which has produced corrupted *.rlog JSON under concurrent failures. It also calls process.exit(1)
// for uncaughtException/unhandledRejection, which caused crash loops when the log file was malformed.
// We rely on stdout/PM2 logs + our own guarded process handlers below.
// errorHandler && (errorHandler.registerUnCaughtProcessErrorsHandler(process, errorLogPath));
// Register guarded process-level fatal handlers (atomic .rlog + optional email + optional exit)
registerFatalHandlers(process, {
env,
debug,
kindPrefix: 'server',
reportFilePath: env.FATAL_REPORT_FILE,
ignore: createServerIgnore(),
});
app.set('trust proxy', env.APP_RATE_TRUST_PROXIES /* number of proxies between user and server */);
if (!env.PRODUCTION && (env.INV_IMG_VIR_DIR && env.INV_UPLOAD_DIR)) {
// Map static resources, cached for 8 hours
app.use(env.INV_IMG_VIR_DIR, express.static(env.INV_UPLOAD_DIR, { maxAge: 31557600 }));
}
// Serve static files from public directory (e.g., DLQ monitor HTML)
app.use(express.static(path.join(__dirname, 'public')));
// Also serve under "/public" prefix to match links like /public/dlq-monitor.html
app.use('/public', express.static(path.join(__dirname, 'public')));
// Global middleware to handle request/response errors gracefully
app.use((req, res, next) => {
// Handle request errors (client disconnect, etc.)
req.on('error', (err) => {
debug('Request error (client disconnect):', err.message);
});
// Explicitly handle aborted requests and tear down the socket
req.on('aborted', () => {
try {
if (!res.headersSent) {
res.status(499).end(); // 499 Client Closed Request (nginx convention)
}
} catch {}
try {
if (res && typeof res.destroy === 'function') res.destroy();
else if (req && typeof req.destroy === 'function') req.destroy();
} catch {}
});
// Handle response errors
res.on('error', (err) => {
debug('Response error:', err.message);
});
next();
});
// for parsing application/x-www-form-urlencoded
app.use(express.urlencoded({ limit: MAX_REQ_BDY_MB, extended: true, parameterLimit: 100000 }));
// Rate limiting middleware. Define a rate limiter
const apiLimiter = rateLimit({
windowMs: (env.APP_RATE_MINS || 15) * 60 * 1000, // 15 minutes
max: env.APP_RATE_REQS || 100, // Limit each IP to 100 requests per windowMs
skipFailedRequests: env.APP_RATE_SKIPFAIL || true, // Skip failed requests
message: {
error: "Too many requests, please try again later."
}
});
// Apply the rate limiter to all routes in this router
app.use(apiLimiter);
// Handle webhook events from Stripe
require('./routes/subscription_webhooks')(express, app);
function shouldCompress(req, res) {
if (req.headers['x-no-compression']) {
return false; // don't compress responses with this request header
}
// fallback to standard filter function
return compression.filter(req, res);
}
// Enable the compression middleware
app.use(compression({ filter: shouldCompress }));
// Parsers for POST JSON data
app.use(express.json({ limit: MAX_REQ_BDY_MB }));
// Allow all CORS requests
// Ref at http://restlet.com/company/blog/2015/12/15/understanding-and-using-cors
app.use(function (req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Origin, Methods, X-Requested-With, Content-Type, Content-Disposition, Accept');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, PATCH, DELETE, OPTIONS');
next();
});
// Fast-path CORS preflight to avoid unnecessary fallthrough
app.use(function (req, res, next) {
if (req.method === 'OPTIONS') return res.status(204).end();
next();
});
async function setupRoutes() {
// const resLogger = async (req, res, next) => {
// debug('Request:', req.url);
// next();
// };
// Handle routes with middelware functions
app.use(checkUser/*, resLogger*/);
// REGISTER OUR ROUTES
require('./routes')(app);
require('./routes/dlq')(app); // Global DLQ management routes (all queues)
app.use(ErrorHandler);
// Universal 404 for any method to avoid finalhandler on http2
app.use((req, res) => {
if (res.headersSent) return;
// For assets, send minimal text to ensure sockets close
res.status(404).type('text/plain').end('Not Found');
});
// Avoid logging aborted requests
// Ref: https://github.com/nodejs/help/issues/2155
app.use((err, req, res, next) => {
if (err && err.code === 'ECONNABORTED') {
res.status(400).end(); // Don't process this error any further to avoid its logging
} else
next(err);
});
// Centralized error handler to always finalize a response
app.use((err, req, res, next) => {
debug('App error handled:', err && (err.message || err));
if (res.headersSent) return;
try {
res.status(500).send({ error: 'Internal Server Error' });
} catch {
// Response failed, destroy socket
try {
if (res && typeof res.destroy === 'function') res.destroy();
else if (req && typeof req.destroy === 'function') req.destroy();
} catch { }
}
});
}
/**
* Preload some common libs, ussually written in ESM modules that require asynchronously loaded or import..
* instead of using requires in CommonJS ones.
*/
async function preloadLibs() {
let mod = await import('@mickeyjohn/geodesy/utm.js');
app.locals.UTM = mod.default;
app.locals.LatLonUTM = mod.LatLon; // More accuracy, for converting from ll to utm
app.locals.Dms = mod.Dms;
mod = await import('@mickeyjohn/geodesy/latlon-spherical.js');
app.locals.LatLonSP = mod.default;
}
async function ensureFolders() {
try {
await fs.ensureDir(env.INV_UPLOAD_DIR);
await fs.ensureDir(env.UNZIP_DIR);
await fs.ensureDir(env.REPORT_DIR);
await fs.ensureDir(env.TEMP_DIR);
await fs.ensureDir(env.AREAS_UPLOAD_DIR);
} catch (error) {
debug(error);
}
}
const port = env.AGM_PORT || '4000';
const serverFactory = () => {
const tlsOpts = { key: fs.readFileSync(env.SSL_KEY), cert: fs.readFileSync(env.SSL_CERT) };
if (env.HTTP2_ENABLED) {
// WARNING: Express does not speak native HTTP/2 streams.
// If you advertise h2, browsers may negotiate HTTP/2 and requests can hang.
// Use a reverse proxy (nginx) to terminate HTTP/2 and proxy HTTP/1.1 upstream.
const alpn = env.HTTP2_ADVERTISE_H2 ? ['h2', 'http/1.1'] : ['http/1.1'];
return http2.createSecureServer({ ...tlsOpts, allowHTTP1: true, ALPNProtocols: alpn }, app);
}
// Default to stable HTTPS/1.1 to avoid Express/finalhandler issues under HTTP/2
return https1.createServer(tlsOpts, app);
};
serverFactory()
.listen(port, async (error) => {
const onAppErr = (err) => {
debug(err);
process.exit(1);
}
if (error) return onAppErr(error);
try {
await ensureFolders();
await preloadLibs();
const conn = await dbConnect();
// Set up error handler for future connection issues
conn.on('error', onAppErr);
// Since connection is already established, execute the setup directly
debug('✅-> MongoDB connected');
await setupRoutes();
// Start Job Importer queue consumer
const jobQueuer = require('./helpers/job_queue').getInstance();
jobQueuer.start();
const protoLabel = env.HTTP2_ENABLED
? (env.HTTP2_ADVERTISE_H2 ? 'HTTPS-v2' : 'HTTPS (HTTP/1.1 only)')
: 'HTTPS';
debug(`${protoLabel} AgMission Server is listening on port ${port}`);
} catch (error) {
onAppErr(error);
}
});