diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 0fca6f0..a675eed 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -4,10 +4,11 @@ - frontend + + -
- +
+ diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 37d569f..ab0824f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -4,14 +4,15 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "dev": "node server.js", + "build": "tsc -b && vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server", + "start": "node server.js --prod", + "lint": "eslint ." }, "dependencies": { "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", + "express": "^5.2.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-markdown": "^10.1.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/apps/frontend/server.js b/apps/frontend/server.js new file mode 100644 index 0000000..3858104 --- /dev/null +++ b/apps/frontend/server.js @@ -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 = [ + `${escapeHtml(head.title)}`, + ``, + ].join("\n "); + + const dataTag = data + ? `` + : ""; + + return template + .replace("", headTags) + .replace("", markup) + .replace("", dataTag); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** JSON for inline