feat(docker): build and serve with Node and npm instead of Nginx
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain; charset=utf-8"
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the HTTP server instance for serving static files from _site
|
||||
* @param {string} [customSiteDir] Optional custom directory to serve from
|
||||
* @returns {http.Server}
|
||||
*/
|
||||
export function createServer(customSiteDir) {
|
||||
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
// Standard security headers
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
const parsedUrl = new URL(req.url, "http://localhost");
|
||||
|
||||
// Health check endpoint for container orchestrators & telemetry
|
||||
if (parsedUrl.pathname === "/healthz" || parsedUrl.pathname === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Path normalization & security validation against directory traversal
|
||||
let decodedPath = "";
|
||||
try {
|
||||
decodedPath = decodeURIComponent(parsedUrl.pathname);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(siteDir, `.${decodedPath}`);
|
||||
|
||||
// Boundary check: ensure target path is within siteDir
|
||||
if (!resolvedPath.startsWith(siteDir)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetFile = resolvedPath;
|
||||
|
||||
// Directory index resolution
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) {
|
||||
targetFile = path.join(targetFile, "index.html");
|
||||
}
|
||||
|
||||
// Clean URL extensionless fallback (/posts/my-post -> /posts/my-post.html)
|
||||
if (!fs.existsSync(targetFile) && fs.existsSync(`${targetFile}.html`)) {
|
||||
targetFile = `${targetFile}.html`;
|
||||
}
|
||||
|
||||
// Check if target file exists and is a regular file
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
||||
const ext = path.extname(targetFile).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
// Cache control: assets get long-term caching, html gets short
|
||||
if (decodedPath.startsWith("/assets/")) {
|
||||
res.setHeader("Cache-Control", "public, max-age=2592000, immutable");
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
const stream = fs.createReadStream(targetFile);
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 Not Found fallback
|
||||
const custom404 = path.join(siteDir, "404.html");
|
||||
if (fs.existsSync(custom404)) {
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
fs.createReadStream(custom404).pipe(res);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("404 Not Found");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Direct CLI execution handler
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
||||
const port = parseInt(process.env.PORT || "8080", 10);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const siteDir = path.resolve(process.cwd(), "_site");
|
||||
|
||||
if (!fs.existsSync(siteDir)) {
|
||||
console.warn(`[Static Server] Warning: '${siteDir}' does not exist yet. Run 'npm run build' first.`);
|
||||
}
|
||||
|
||||
const server = createServer();
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[Static Server] Serving '${siteDir}' on http://${host}:${port}`);
|
||||
});
|
||||
|
||||
// Graceful termination handling
|
||||
const shutdown = (signal) => {
|
||||
console.log(`\n[Static Server] Received ${signal}. Shutting down gracefully...`);
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
Reference in New Issue
Block a user