implement ssr
This commit is contained in:
@@ -4,10 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<!--ssr-head-->
|
||||||
|
<!--ssr-data-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"><!--ssr-outlet--></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/entry-client.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,14 +4,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node server.js",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
|
||||||
"lint": "eslint .",
|
"start": "node server.js --prod",
|
||||||
"preview": "vite preview"
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import Home from "./pages/home";
|
||||||
|
import LogIn from "./pages/login";
|
||||||
|
import Post from "./pages/post";
|
||||||
|
import Editor from "./pages/editor";
|
||||||
|
|
||||||
|
/** The route tree, shared by the browser and server entry points. */
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/login" element={<LogIn />} />
|
||||||
|
<Route path="/post/:slug" element={<Post />} />
|
||||||
|
<Route path="/editor" element={<Editor />} />
|
||||||
|
<Route path="/editor/:slug" element={<Editor />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useHydrated } from "../ssr/hydrated";
|
||||||
|
|
||||||
|
// The server render and the hydration pass use a fixed locale and timezone so
|
||||||
|
// both produce identical markup. Once hydrated we switch to the visitor's own
|
||||||
|
// locale and timezone, which is what the page showed before it was
|
||||||
|
// server-rendered.
|
||||||
|
const stableFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FormattedDate({ value }: { value: string }) {
|
||||||
|
const hydrated = useHydrated();
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<time dateTime={date.toISOString()}>
|
||||||
|
{hydrated ? date.toLocaleString() : stableFormatter.format(date)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Post } from "../utils/types";
|
import type { Post } from "../utils/types";
|
||||||
|
import FormattedDate from "./formattedDate";
|
||||||
|
|
||||||
export default function PostRow({ post }: { post: Post }) {
|
export default function PostRow({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
@@ -8,7 +9,7 @@ export default function PostRow({ post }: { post: Post }) {
|
|||||||
<h1>{post.title}</h1>
|
<h1>{post.title}</h1>
|
||||||
<p>{post.description}</p>
|
<p>{post.description}</p>
|
||||||
<p className="text-xs opacity-50">
|
<p className="text-xs opacity-50">
|
||||||
Posted on {new Date(post.createdAt).toLocaleString()}
|
Posted on <FormattedDate value={post.createdAt} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createRoot, hydrateRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./providers/AuthProvider";
|
||||||
|
import { SsrDataProvider } from "./ssr/context";
|
||||||
|
import type { SsrData } from "./ssr/types";
|
||||||
|
import App from "./app";
|
||||||
|
import "./assets/index.css";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__SSR_DATA__?: SsrData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssrData = window.__SSR_DATA__ ?? null;
|
||||||
|
delete window.__SSR_DATA__;
|
||||||
|
|
||||||
|
const tree = (
|
||||||
|
<SsrDataProvider data={ssrData}>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</SsrDataProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById("root")!;
|
||||||
|
|
||||||
|
// Server-rendered pages ship markup to hydrate; every other route is still a
|
||||||
|
// plain client-side render into an empty root.
|
||||||
|
if (ssrData) {
|
||||||
|
hydrateRoot(container, tree);
|
||||||
|
} else {
|
||||||
|
createRoot(container).render(tree);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { StaticRouter } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./providers/AuthProvider";
|
||||||
|
import { SsrDataProvider } from "./ssr/context";
|
||||||
|
import { matchSsrRoute, type ApiFetch } from "./ssr/routes";
|
||||||
|
import type { SsrData, SsrHead } from "./ssr/types";
|
||||||
|
import App from "./app";
|
||||||
|
|
||||||
|
type RenderResult =
|
||||||
|
/** Not a server-rendered route - serve the client-only shell. */
|
||||||
|
| { kind: "spa" }
|
||||||
|
/** Server-rendered route whose content is missing - shell, with a 404. */
|
||||||
|
| { kind: "not-found" }
|
||||||
|
| { kind: "ssr"; html: string; data: SsrData; head: SsrHead };
|
||||||
|
|
||||||
|
async function render(url: string, api: ApiFetch): Promise<RenderResult> {
|
||||||
|
const pathname = new URL(url, "http://ssr.invalid").pathname;
|
||||||
|
const matched = matchSsrRoute(pathname);
|
||||||
|
if (!matched) return { kind: "spa" };
|
||||||
|
|
||||||
|
const loaded = await matched.route.load(api, matched.params);
|
||||||
|
if (!loaded) return { kind: "not-found" };
|
||||||
|
|
||||||
|
// The visitor's session is deliberately not resolved here: auth-dependent
|
||||||
|
// chrome renders logged-out on the server and AuthProvider fills it in after
|
||||||
|
// hydration, which keeps these pages cacheable and hydration-safe.
|
||||||
|
const html = renderToString(
|
||||||
|
<SsrDataProvider data={loaded.data}>
|
||||||
|
<AuthProvider>
|
||||||
|
<StaticRouter location={url}>
|
||||||
|
<App />
|
||||||
|
</StaticRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</SsrDataProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { kind: "ssr", html, data: loaded.data, head: loaded.head };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { render };
|
||||||
|
export { defaultHead } from "./ssr/routes";
|
||||||
|
export type { RenderResult };
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
|
||||||
import { AuthProvider } from "./providers/AuthProvider";
|
|
||||||
import "./assets/index.css";
|
|
||||||
|
|
||||||
import Home from "./pages/home";
|
|
||||||
import LogIn from "./pages/login";
|
|
||||||
import Post from "./pages/post";
|
|
||||||
import Editor from "./pages/editor";
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
element: <Home />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/login",
|
|
||||||
element: <LogIn />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/post/:slug",
|
|
||||||
element: <Post />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/editor",
|
|
||||||
element: <Editor />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/editor/:slug",
|
|
||||||
element: <Editor />,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<AuthProvider>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</AuthProvider>,
|
|
||||||
);
|
|
||||||
@@ -3,15 +3,21 @@ import PostRow from "../components/postRow";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../providers/AuthProvider";
|
import { useAuth } from "../providers/AuthProvider";
|
||||||
|
import { useSsrData } from "../ssr/context";
|
||||||
import type { Post } from "../utils/types";
|
import type { Post } from "../utils/types";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [posts, setPosts] = useState<Post[] | null>(null);
|
const ssr = useSsrData("home");
|
||||||
const [loaded, setLoaded] = useState<boolean>(false);
|
const [seededByServer] = useState<boolean>(() => ssr !== null);
|
||||||
|
const [posts, setPosts] = useState<Post[] | null>(ssr?.posts ?? null);
|
||||||
|
const [loaded, setLoaded] = useState<boolean>(seededByServer);
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Already rendered on the server with the post list baked in.
|
||||||
|
if (seededByServer) return;
|
||||||
|
|
||||||
fetch("/api/posts", {
|
fetch("/api/posts", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -25,7 +31,7 @@ export default function Home() {
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [seededByServer]);
|
||||||
|
|
||||||
const PostList = () => {
|
const PostList = () => {
|
||||||
const mappedPosts = posts?.map((v, k) => {
|
const mappedPosts = posts?.map((v, k) => {
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ import Layout from "../components/layout";
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../providers/AuthProvider";
|
import { useAuth } from "../providers/AuthProvider";
|
||||||
|
import { useSsrData } from "../ssr/context";
|
||||||
|
import FormattedDate from "../components/formattedDate";
|
||||||
import type { Post } from "../utils/types";
|
import type { Post } from "../utils/types";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
|
|
||||||
export default function Post() {
|
export default function Post() {
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
const ssr = useSsrData("post");
|
||||||
|
const [seededByServer] = useState<boolean>(() => ssr !== null);
|
||||||
|
const [post, setPost] = useState<Post | null>(ssr?.post ?? null);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Already rendered on the server with the post baked in.
|
||||||
|
if (seededByServer) return;
|
||||||
|
|
||||||
if (params.slug) {
|
if (params.slug) {
|
||||||
fetch("/api/posts/" + params.slug, {
|
fetch("/api/posts/" + params.slug, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -25,7 +32,7 @@ export default function Post() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [seededByServer, params.slug]);
|
||||||
|
|
||||||
function deletePost() {
|
function deletePost() {
|
||||||
if (!auth.user || !post) return;
|
if (!auth.user || !post) return;
|
||||||
@@ -63,9 +70,12 @@ export default function Post() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm opacity-50">
|
<p className="text-sm opacity-50">
|
||||||
Posted {new Date(post.createdAt).toLocaleString()}{" "}
|
Posted <FormattedDate value={post.createdAt} />{" "}
|
||||||
{post.updatedAt !== post.createdAt &&
|
{post.updatedAt !== post.createdAt && (
|
||||||
"- updated on " + new Date(post.updatedAt).toLocaleString()}
|
<>
|
||||||
|
- updated on <FormattedDate value={post.updatedAt} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{auth.user && (
|
{auth.user && (
|
||||||
<div className="inline-block space-x-2">
|
<div className="inline-block space-x-2">
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import { useHydrated } from "./hydrated";
|
||||||
|
import type { SsrData } from "./types";
|
||||||
|
|
||||||
|
const SsrDataContext = createContext<SsrData | null>(null);
|
||||||
|
|
||||||
|
export function SsrDataProvider({
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
data: SsrData | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
// The prefetched payload seeds the server render and the hydration pass that
|
||||||
|
// has to match it, then goes away: from there on, client-side navigation
|
||||||
|
// fetches fresh data rather than replaying a payload from page load.
|
||||||
|
const hydrated = useHydrated();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SsrDataContext value={hydrated ? null : data}>{children}</SsrDataContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the server-prefetched data for a page, or null when the page wasn't
|
||||||
|
* server-rendered (or is being mounted by a client-side navigation).
|
||||||
|
*/
|
||||||
|
export function useSsrData<T extends SsrData["route"]>(
|
||||||
|
route: T,
|
||||||
|
): Extract<SsrData, { route: T }> | null {
|
||||||
|
const data = useContext(SsrDataContext);
|
||||||
|
if (data?.route !== route) return null;
|
||||||
|
return data as Extract<SsrData, { route: T }>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const subscribe = () => () => {};
|
||||||
|
const onClient = () => true;
|
||||||
|
const onServer = () => false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* False while rendering on the server and during the hydration pass, true
|
||||||
|
* afterwards. Lets a component render server-safe markup first and switch to
|
||||||
|
* browser-only output once hydration has matched.
|
||||||
|
*/
|
||||||
|
export function useHydrated() {
|
||||||
|
return useSyncExternalStore(subscribe, onClient, onServer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { matchPath } from "react-router-dom";
|
||||||
|
import type { Post } from "../utils/types";
|
||||||
|
import type { SsrData, SsrHead } from "./types";
|
||||||
|
|
||||||
|
/** Fetches a path against the backend API, carrying the visitor's cookies. */
|
||||||
|
type ApiFetch = (path: string) => Promise<Response>;
|
||||||
|
|
||||||
|
type SsrRoute = {
|
||||||
|
path: string;
|
||||||
|
load: (
|
||||||
|
api: ApiFetch,
|
||||||
|
params: Record<string, string | undefined>,
|
||||||
|
) => Promise<{ data: SsrData; head: SsrHead } | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SITE_NAME = "Alex / dev + QA";
|
||||||
|
const SITE_DESCRIPTION =
|
||||||
|
"random thoughts, sometimes LinkedIn slop, sometimes just about my interests.";
|
||||||
|
|
||||||
|
const defaultHead: SsrHead = {
|
||||||
|
title: SITE_NAME,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only routes that get server-rendered. Anything not listed here is served
|
||||||
|
* as the plain client-only SPA shell, so /login and /editor keep behaving
|
||||||
|
* exactly as they did before.
|
||||||
|
*
|
||||||
|
* A loader returning null means "this page has no content" and the server
|
||||||
|
* answers 404 with the shell.
|
||||||
|
*/
|
||||||
|
const ssrRoutes: SsrRoute[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
load: async (api) => {
|
||||||
|
const res = await api("/posts");
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const posts: Post[] = await res.json();
|
||||||
|
return {
|
||||||
|
data: { route: "home", posts },
|
||||||
|
head: defaultHead,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/post/:slug",
|
||||||
|
load: async (api, params) => {
|
||||||
|
if (!params.slug) return null;
|
||||||
|
|
||||||
|
const res = await api("/posts/" + encodeURIComponent(params.slug));
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const post: Post = await res.json();
|
||||||
|
return {
|
||||||
|
data: { route: "post", post },
|
||||||
|
head: {
|
||||||
|
title: `${post.title} - ${SITE_NAME}`,
|
||||||
|
description: post.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchSsrRoute(pathname: string) {
|
||||||
|
for (const route of ssrRoutes) {
|
||||||
|
const match = matchPath(route.path, pathname);
|
||||||
|
if (match) return { route, params: match.params };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { matchSsrRoute, defaultHead };
|
||||||
|
export type { ApiFetch };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Post } from "../utils/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data the server prefetched for a server-rendered page. It is embedded in the
|
||||||
|
* HTML response so hydration starts from exactly what was rendered instead of
|
||||||
|
* re-fetching on mount.
|
||||||
|
*/
|
||||||
|
type SsrData =
|
||||||
|
| { route: "home"; posts: Post[] }
|
||||||
|
| { route: "post"; post: Post };
|
||||||
|
|
||||||
|
/** Page-specific <head> content. Server-side only, never sent to the client. */
|
||||||
|
type SsrHead = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { SsrData, SsrHead };
|
||||||
@@ -3,8 +3,12 @@ import { defineConfig } from "vite";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ isSsrBuild }) => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
// Static files belong with the client build only.
|
||||||
|
copyPublicDir: !isSsrBuild,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -14,4 +18,4 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|||||||
Generated
+6
@@ -279,6 +279,9 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
|
version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
|
||||||
|
express:
|
||||||
|
specifier: ^5.2.1
|
||||||
|
version: 5.2.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
@@ -298,6 +301,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.10.0(jiti@2.7.0))
|
version: 10.0.1(eslint@10.10.0(jiti@2.7.0))
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^5.0.6
|
||||||
|
version: 5.0.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.13.3
|
specifier: ^24.13.3
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
|
|||||||
Reference in New Issue
Block a user