Files
blog/apps/frontend/server.js
T
alex 03bdd22059
Build and Publish Docker Image / build (push) Successful in 1m35s
fix health check + build
2026-09-07 00:05:33 -04:00

174 lines
5.2 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Readable } from "node:stream";
import express from "express";
const root = path.dirname(fileURLToPath(import.meta.url));
const isProd =
process.argv.includes("--prod") || process.env.NODE_ENV === "production";
const port = Number(process.env.PORT ?? 5173);
const backendOrigin = process.env.BACKEND_ORIGIN ?? "http://localhost:3000";
const app = express();
// Liveness only, so it must not touch the backend: the container healthcheck
// reads this, and an unhealthy container is dropped by Traefik's Docker
// provider - which would take the router, and with it the certificate, down.
app.get("/healthz", (req, res) => res.status(200).type("text/plain").end("ok"));
/** @type {import("vite").ViteDevServer | undefined} */
let vite;
if (isProd) {
// Built assets, plus the same /api -> backend rewrite that vite.config.ts
// sets up for dev, so the browser talks to one origin either way.
app.use(express.static(path.join(root, "dist/client"), { index: false }));
app.use("/api", proxyToBackend);
} else {
const { createServer } = await import("vite");
vite = await createServer({
root,
appType: "custom",
server: { middlewareMode: true },
});
// Includes Vite's own /api proxy, asset serving and HMR.
app.use(vite.middlewares);
}
const prodTemplate = isProd
? await fs.readFile(path.join(root, "dist/client/index.html"), "utf-8")
: null;
app.use(async (req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") return next();
const url = req.originalUrl;
try {
let template;
let entry;
if (vite) {
template = await vite.transformIndexHtml(
url,
await fs.readFile(path.join(root, "index.html"), "utf-8"),
);
entry = await vite.ssrLoadModule("/src/entry-server.tsx");
} else {
template = prodTemplate;
entry = await import(
pathToFileURL(path.join(root, "dist/server/entry-server.js")).href
);
}
const result = await entry.render(url, (apiPath) =>
fetch(new URL(apiPath, backendOrigin), {
headers: req.headers.cookie ? { cookie: req.headers.cookie } : {},
}),
);
const status = result.kind === "not-found" ? 404 : 200;
const html =
result.kind === "ssr"
? fillTemplate(template, result.head, result.html, result.data)
: fillTemplate(template, entry.defaultHead, "", null);
res.status(status).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
next(e);
}
});
app.listen(port, () => {
console.log(
`${isProd ? "Production" : "Dev"} server listening on http://localhost:${port}`,
);
});
/**
* Fills the SSR placeholders in index.html. `markup` is empty and `data` null
* for routes that aren't server-rendered, which leaves the original
* client-only shell.
*/
function fillTemplate(template, head, markup, data) {
const headTags = [
`<title>${escapeHtml(head.title)}</title>`,
`<meta name="description" content="${escapeHtml(head.description)}" />`,
].join("\n ");
const dataTag = data
? `<script>window.__SSR_DATA__ = ${serialize(data)}</script>`
: "";
return template
.replace("<!--ssr-head-->", headTags)
.replace("<!--ssr-outlet-->", markup)
.replace("<!--ssr-data-->", dataTag);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** JSON for inline <script>: `<` is escaped so post content can't close it. */
function serialize(data) {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
const REQUEST_HEADER_SKIP = new Set(["host", "connection", "content-length"]);
const RESPONSE_HEADER_SKIP = new Set([
"connection",
"content-encoding",
"content-length",
"keep-alive",
"set-cookie",
"transfer-encoding",
]);
/** Minimal /api -> backend proxy, mirroring the dev proxy's path rewrite. */
async function proxyToBackend(req, res, next) {
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (REQUEST_HEADER_SKIP.has(key)) continue;
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
try {
const upstream = await fetch(new URL(req.url, backendOrigin), {
method: req.method,
headers,
body: hasBody ? Readable.toWeb(req) : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
});
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
// fetch already decoded the body, hop-by-hop headers belong to this
// connection, and set-cookie needs its own pass to survive as separate
// headers rather than one comma-joined value.
if (RESPONSE_HEADER_SKIP.has(key)) return;
res.setHeader(key, value);
});
for (const cookie of upstream.headers.getSetCookie()) {
res.append("set-cookie", cookie);
}
if (upstream.body) {
Readable.fromWeb(upstream.body).pipe(res);
} else {
res.end();
}
} catch (e) {
next(e);
}
}