174 lines
5.2 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|