implement ssr
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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();
|
||||
|
||||
/** @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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user