Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+383
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* The production web server.
|
||||
*
|
||||
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
|
||||
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
|
||||
* than pointing a `root` at the built tree, so that nothing outside this file decides
|
||||
* what is readable. That is not a stylistic preference — it removes two whole classes
|
||||
* of failure the file-serving arrangement kept producing:
|
||||
*
|
||||
* traversal permissions nginx runs as www-data and the build runs as cashumints,
|
||||
* so every directory from / down to dist had to be traversable
|
||||
* by a user with no other business in it. One 0700 home
|
||||
* directory anywhere in the chain took the site down, and
|
||||
* `try_files` reports a permission error as a plain miss, so
|
||||
* the symptom was a blanket 404 or an internal-redirect loop
|
||||
* ending in 500 — never the actual cause.
|
||||
*
|
||||
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
|
||||
* alternation of 23 codes. Adding a language meant editing a
|
||||
* file that is not in this repository, and forgetting to was
|
||||
* silent. Locale 404s are resolved by looking in the built
|
||||
* tree now, so the list cannot drift.
|
||||
*
|
||||
* Reading files is all it does. There is no template, no database handle and no route
|
||||
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
|
||||
* them there directly.
|
||||
*
|
||||
* Env:
|
||||
* SITE_PORT 8789 port to listen on
|
||||
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
|
||||
* WEB_ROOT ./dist the tree to serve
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function int(raw, fallback) {
|
||||
const n = Number.parseInt(raw ?? '', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree to serve, absolute.
|
||||
*
|
||||
* Defaults to the build output next to this file, which is what `pnpm start` and the
|
||||
* tests use. In production it points at a copy outside the checkout: `pnpm build`
|
||||
* empties dist before it writes, so serving dist directly means a rebuild takes the
|
||||
* whole site down for the length of the build. See cashumints-web.service.
|
||||
*/
|
||||
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
|
||||
|
||||
const PORT = int(process.env.SITE_PORT, 8789);
|
||||
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
|
||||
|
||||
/** One line per event, key=value after the message. Matches the API's log format. */
|
||||
function log(level, msg, fields = {}) {
|
||||
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
|
||||
}
|
||||
const line = parts.join(' ');
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
const TYPES = new Map(Object.entries({
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
'.wasm': 'application/wasm',
|
||||
}));
|
||||
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
||||
const WEEK = 'public, max-age=604800';
|
||||
const HOUR = 'public, max-age=3600';
|
||||
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
|
||||
const REVALIDATE = 'public, max-age=0, must-revalidate';
|
||||
|
||||
/**
|
||||
* How long a response may be reused.
|
||||
*
|
||||
* Everything Vite and the card builder emit carries a content hash in its filename, so
|
||||
* those are immutable for a year — a changed file is a changed URL. Markup is the
|
||||
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
|
||||
* revalidates every time and the ETag below turns that into a 304 in the usual case.
|
||||
*
|
||||
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
|
||||
* their own, so it gets an hour rather than a year.
|
||||
*/
|
||||
export function cacheControl(urlPath, ext) {
|
||||
if (ext === '.html') return REVALIDATE;
|
||||
if (urlPath === '/og/default.png') return HOUR;
|
||||
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
|
||||
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
|
||||
return WEEK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
|
||||
*
|
||||
* Returns the *relative* path so the caller can join it against ROOT; every segment is
|
||||
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
|
||||
* refused outright: a static build emits none, so a request for one is either a probe
|
||||
* or a mistake, and neither should be answered with bytes.
|
||||
*/
|
||||
export function safePath(urlPath) {
|
||||
if (urlPath.includes('\0')) return null;
|
||||
|
||||
const normalized = path.posix.normalize(urlPath);
|
||||
if (!normalized.startsWith('/')) return null;
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
if (segment === '..' || segment.startsWith('.')) return null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function statFile(file) {
|
||||
try {
|
||||
const stats = await fsp.stat(file);
|
||||
return stats.isFile() ? stats : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates for one request path, in order.
|
||||
*
|
||||
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
|
||||
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
|
||||
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
|
||||
* files at the root, /404.html among them.
|
||||
*/
|
||||
export function candidates(segments) {
|
||||
const rel = segments.join('/');
|
||||
if (rel === '') return ['index.html'];
|
||||
return [rel, `${rel}/index.html`, `${rel}.html`];
|
||||
}
|
||||
|
||||
/**
|
||||
* The 404 body for a path, and it is not always the English one.
|
||||
*
|
||||
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
|
||||
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
|
||||
* config. Which prefixes count is decided by what the build actually emitted: if
|
||||
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
|
||||
*/
|
||||
async function notFoundBody(segments) {
|
||||
const first = segments[0];
|
||||
if (first) {
|
||||
const localized = path.join(ROOT, first, '404', 'index.html');
|
||||
const stats = await statFile(localized);
|
||||
if (stats) return { file: localized, stats };
|
||||
}
|
||||
const fallback = path.join(ROOT, '404.html');
|
||||
const stats = await statFile(fallback);
|
||||
return stats ? { file: fallback, stats } : null;
|
||||
}
|
||||
|
||||
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
|
||||
function etagFor(stats) {
|
||||
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
|
||||
}
|
||||
|
||||
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
|
||||
function etagMatches(header, etag) {
|
||||
if (!header) return false;
|
||||
if (header.trim() === '*') return true;
|
||||
const bare = etag.replace(/^W\//, '');
|
||||
return header
|
||||
.split(',')
|
||||
.map((candidate) => candidate.trim().replace(/^W\//, ''))
|
||||
.includes(bare);
|
||||
}
|
||||
|
||||
function notModified(req, etag, lastModified) {
|
||||
if (etagMatches(req.headers['if-none-match'], etag)) return true;
|
||||
|
||||
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
|
||||
if (req.headers['if-none-match']) return false;
|
||||
const since = Date.parse(req.headers['if-modified-since'] ?? '');
|
||||
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers every response carries.
|
||||
*
|
||||
* No CSP here on purpose. The site loads an analytics script from another origin, the
|
||||
* review islands open websockets to whatever relays are configured and the status
|
||||
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
|
||||
* worth having has to be derived from those lists rather than guessed at — and a wrong
|
||||
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
|
||||
* terminates TLS.
|
||||
*/
|
||||
function baseHeaders() {
|
||||
return {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Frame-Options': 'DENY',
|
||||
};
|
||||
}
|
||||
|
||||
function send(res, status, headers, body) {
|
||||
res.writeHead(status, { ...baseHeaders(), ...headers });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
/** Stream a file, or just its headers for HEAD. */
|
||||
function sendFile(req, res, status, file, stats, urlPath) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const etag = etagFor(stats);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
|
||||
'Content-Length': stats.size,
|
||||
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
|
||||
ETag: etag,
|
||||
'Cache-Control': cacheControl(urlPath, ext),
|
||||
...baseHeaders(),
|
||||
};
|
||||
|
||||
// A 304 must not carry a body or a Content-Length describing one.
|
||||
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
|
||||
delete headers['Content-Length'];
|
||||
delete headers['Content-Type'];
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(status, headers);
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(file);
|
||||
stream.on('error', (err) => {
|
||||
log('error', 'read failed', { file, err: err.message });
|
||||
res.destroy();
|
||||
});
|
||||
// Kill the read when the client hangs up mid-transfer rather than draining the file.
|
||||
res.on('close', () => stream.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
|
||||
return;
|
||||
}
|
||||
|
||||
let urlPath;
|
||||
try {
|
||||
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
} catch {
|
||||
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = safePath(urlPath);
|
||||
if (!segments) {
|
||||
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates(segments)) {
|
||||
const file = path.join(ROOT, candidate);
|
||||
// Belt and braces: safePath already refused `..`, this refuses anything that still
|
||||
// resolved outside the tree, a symlink in the build output included.
|
||||
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
|
||||
|
||||
const stats = await statFile(file);
|
||||
if (stats) {
|
||||
sendFile(req, res, 200, file, stats, urlPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const miss = await notFoundBody(segments);
|
||||
if (!miss) {
|
||||
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
|
||||
return;
|
||||
}
|
||||
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
|
||||
// URL indexed as a real page.
|
||||
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
|
||||
}
|
||||
|
||||
export function createServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
log('error', 'request failed', { path: req.url, err: err.message });
|
||||
if (!res.headersSent) {
|
||||
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
|
||||
*
|
||||
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
|
||||
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
|
||||
* one to close, which is the race-free direction.
|
||||
*/
|
||||
server.keepAliveTimeout = 65_000;
|
||||
server.headersTimeout = 66_000;
|
||||
|
||||
// A malformed request line should not take the process with it.
|
||||
server.on('clientError', (err, socket) => {
|
||||
if (err.code === 'ECONNRESET' || !socket.writable) return;
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to start on an empty or unreadable root.
|
||||
*
|
||||
* The failure this prevents is the one that is hardest to see: a process that starts
|
||||
* cleanly, answers every request with a 404 and looks healthy to anything watching the
|
||||
* port. Exiting non-zero puts the reason in `systemctl status` instead.
|
||||
*/
|
||||
async function checkRoot() {
|
||||
const index = path.join(ROOT, 'index.html');
|
||||
if (await statFile(index)) return;
|
||||
log('error', 'web root has no index.html', { root: ROOT, hint: 'run pnpm build, then publish it to WEB_ROOT' });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Only when run directly, so the tests can import the pieces above. */
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await checkRoot();
|
||||
|
||||
const server = createServer();
|
||||
server.listen(PORT, HOST, () => {
|
||||
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log('info', 'shutting down', { signal });
|
||||
|
||||
server.close(() => process.exit(0));
|
||||
// Idle keep-alive connections would otherwise hold the close open for a minute.
|
||||
server.closeIdleConnections();
|
||||
setTimeout(() => {
|
||||
server.closeAllConnections();
|
||||
process.exit(0);
|
||||
}, 10_000).unref();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user