aboutsummaryrefslogtreecommitdiffstats
path: root/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'server.js')
-rw-r--r--server.js231
1 files changed, 231 insertions, 0 deletions
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2af5649
--- /dev/null
+++ b/server.js
@@ -0,0 +1,231 @@
+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<string, string>}
+ * 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.<string, string>} 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<unknown>} 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}
+ `);
+});