import { readFileSync, statSync } from "node:fs"; import { createSecureServer, constants as httpConstants } from "node:http2"; import path from "node:path"; // Environment variables with fallbacks const port = process.env.PORT ?? process.argv[2] ?? 8433; const serverRoot = process.env.SERVER_ROOT ?? "./app"; const keyPath = process.env.KEY_PATH ?? "./server.key"; const certPath = process.env.CERT_PATH ?? "./server.pem"; const hstsMaxAge = process.env.HSTS_MAX_AGE ?? "31536000"; const corsOrigin = process.env.CORS_ORIGIN ?? "*"; /** * @type {Map} * Maps file extensions to MIME types for proper content-type headers */ const mimeType = new Map([ [".ico", "image/x-icon"], [".html", "text/html"], [".js", "text/javascript"], [".json", "application/json"], [".css", "text/css"], [".png", "image/png"], [".jpg", "image/jpeg"], [".wav", "audio/wav"], [".mp3", "audio/mpeg"], [".svg", "image/svg+xml"], [".pdf", "application/pdf"], [".zip", "application/zip"], [".doc", "application/msword"], [".eot", "application/vnd.ms-fontobject"], [".ttf", "application/x-font-ttf"], ]); const { HTTP2_HEADER_PATH: HEADER_PATH, HTTP2_HEADER_METHOD: HEADER_METHOD, HTTP_STATUS_NOT_FOUND: STATUS_NOT_FOUND, HTTP_STATUS_INTERNAL_SERVER_ERROR: STATUS_SERVER_ERROR, } = httpConstants; const HEADER_ORIGIN = "origin"; /** * @type {import('node:http2').SecureServerOptions} * TLS options for the HTTP/2 server */ let options; try { options = { cert: readFileSync(certPath), key: readFileSync(keyPath), }; } catch (error) { if (error && "code" in error && error.code === "ENOENT") { console.error(`Certificate error: Could not find key or cert files at ${keyPath}, ${certPath}`); console.error("Please set KEY_PATH and CERT_PATH environment variables"); process.exit(1); } throw error; } /** * HTTP/2 secure server instance * @type {import('node:http2').Http2SecureServer} */ const server = createSecureServer(options); /** * Handles stream errors and sends appropriate HTTP responses * @param {unknown} err - The error object * @param {import('node:http2').ServerHttp2Stream} stream - The HTTP/2 stream to respond on * @param {string | undefined} origin - The request origin for CORS headers */ const handleStreamError = (err, stream, origin) => { console.error("Stream error:", err); let status = STATUS_SERVER_ERROR; if (err && "code" in err && typeof err.code === "string") { status = err.code === "ENOENT" ? STATUS_NOT_FOUND : STATUS_SERVER_ERROR; } const body = status === STATUS_NOT_FOUND ? "Not Found" : "Internal Server Error"; stream.respond({ ":status": status, ...getResponseHeaders("text/plain", origin), "content-length": body.length.toString(), }); stream.end(body); }; /** * Generates common response headers including HSTS and CORS * @param {string} mimeType - The MIME type for content-type header * @param {string|undefined} ro - The Origin header from the request * @returns {Object.} Object containing response headers */ const getResponseHeaders = (mimeType, ro) => { let acao = "*"; if (corsOrigin !== "*") { const allowed = corsOrigin.split(",").map((s) => s.trim()); acao = ro && allowed.includes(ro) ? ro : allowed[0] || ""; } return { "access-control-allow-credentials": "true", "access-control-allow-headers": "Content-Type, Authorization", "access-control-allow-methods": "GET, HEAD, OPTIONS", "access-control-allow-origin": acao, "content-type": mimeType, "strict-transport-security": `max-age=${hstsMaxAge}; includeSubDomains`, }; }; /** * Handles incoming HTTP/2 streams * @param {import('node:http2').ServerHttp2Stream} stream - The HTTP/2 stream * @param {import('node:http2').IncomingHttpHeaders} headers - Request headers * @param {number} flags - Stream flags */ server.on("stream", (stream, headers) => { /** @type {string} */ const reqPath = headers[HEADER_PATH] || "/"; /** @type {string} */ const reqMethod = headers[HEADER_METHOD] || "GET"; /** @type {string | undefined} */ const requestOrigin = typeof headers[HEADER_ORIGIN] === "string" ? headers[HEADER_ORIGIN] : undefined; // Handle CORS preflight requests if (reqMethod === "OPTIONS") { stream.respond({ ":status": 204, ...getResponseHeaders("text/plain", requestOrigin), "content-length": "0", }); stream.end(); return; } // Only allow GET and HEAD methods if (!["GET", "HEAD"].includes(reqMethod)) { stream.respond({ ":status": 405, allow: "GET, HEAD, OPTIONS", ...getResponseHeaders("text/plain", requestOrigin), }); stream.end("Method Not Allowed"); return; } let fullPath = path.join(serverRoot, reqPath); try { const stats = statSync(fullPath); if (stats.isDirectory()) { fullPath = path.join(fullPath, "index.html"); } } catch (error) { handleStreamError(error, stream, requestOrigin); return; } const ext = path.extname(fullPath); const responseMimeType = mimeType.get(ext) || "text/plain"; stream.respondWithFile( fullPath, { ...getResponseHeaders(responseMimeType, requestOrigin), }, { onError: (err) => handleStreamError(err, stream, requestOrigin), }, ); }); /** * Handles graceful shutdown of the server * @param {string} signal - The signal that triggered shutdown (SIGTERM, SIGINT, etc.) */ const gracefulShutdown = (signal) => { console.log(`\nReceived ${signal}, shutting down gracefully...`); server.close((err) => { if (err) { console.error("Error during server shutdown:", err); process.exit(1); } console.log("Server closed successfully"); process.exit(0); }); // Force close after 10 seconds setTimeout(() => { console.error("Forcing server closure after timeout"); process.exit(1); }, 10000); }; process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); /** * Handles uncaught exceptions * @param {Error} error - The uncaught error */ process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); gracefulShutdown("uncaughtException"); }); /** * Handles unhandled promise rejections * @param {any} reason - The rejection reason * @param {Promise} promise - The promise that was rejected */ process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason); gracefulShutdown("unhandledRejection"); }); /** * Starts the HTTP/2 server and begins listening for connections */ server.listen(Number.parseInt(port.toString(), 10), "0.0.0.0", () => { console.log(` Server running on https://localhost:${port} Environment: ${process.env.NODE_ENV || "development"} Server root: ${serverRoot} CORS origin: ${corsOrigin} HSTS max-age: ${hstsMaxAge} `); });