diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-10-29 15:18:30 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-03 10:54:48 +0200 |
| commit | b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch) | |
| tree | efc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /server.js | |
| download | housing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst | |
Initial commit
Diffstat (limited to 'server.js')
| -rw-r--r-- | server.js | 231 |
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} + `); +}); |
