···11+import type { ModuleNode, ViteDevServer } from "vite";
22+33+/**
44+ * Walk the Vite module graph from an SSR entry and collect all CSS
55+ * module URLs (regular .css, vanilla-extract virtual CSS, etc.).
66+ * Then transform each one via the client pipeline and extract the
77+ * raw CSS from the JS wrapper so we can inline it as <style> tags.
88+ *
99+ * Dev-only — production serves hashed stylesheets from the Vite manifest.
1010+ */
1111+export async function collectSsrCss(server: ViteDevServer, entryUrl: string): Promise<string> {
1212+ const cssUrls = new Set<string>();
1313+ const visited = new Set<string>();
1414+1515+ function walk(mod: ModuleNode | undefined) {
1616+ if (!mod || visited.has(mod.url)) return;
1717+ visited.add(mod.url);
1818+ if (/\.css($|\?)/.test(mod.url)) {
1919+ cssUrls.add(mod.url);
2020+ }
2121+ for (const dep of mod.ssrImportedModules) {
2222+ walk(dep);
2323+ }
2424+ }
2525+2626+ const resolved = await server.moduleGraph.resolveUrl(entryUrl);
2727+ walk(server.moduleGraph.getModuleById(resolved[1]));
2828+2929+ const styles: string[] = [];
3030+ for (const url of cssUrls) {
3131+ try {
3232+ const result = await server.transformRequest(url);
3333+ if (!result?.code) continue;
3434+ // Vite wraps CSS in a JS module with: const __vite__css = "...css..."
3535+ const match = result.code.match(/__vite__css\s*=\s*("[\s\S]*?")\s*[\n;]/);
3636+ if (match?.[1]) {
3737+ styles.push(JSON.parse(match[1]));
3838+ }
3939+ } catch {
4040+ // skip modules that fail to transform
4141+ }
4242+ }
4343+4444+ return styles.map((css) => `<style data-vite-dev-css>${css}</style>`).join("\n");
4545+}
+85
packages/app/src/ssr-prefetch-orchestrator.ts
···11+import { ssrPrefetch } from "./ssr-prefetch.ts";
22+33+export interface PrefetchOptions {
44+ url: string;
55+ sphereHandle: string | undefined;
66+ isMultiSphere: boolean;
77+ isAuthenticated: boolean;
88+ hasSphere: boolean;
99+ fetchApi: (path: string) => Response | Promise<Response>;
1010+}
1111+1212+/**
1313+ * Orchestrates the full SSR prefetch tree: dashboard data (multi-sphere, no handle),
1414+ * per-route module data via `ssrPrefetch`, and nested comment fetches for feature
1515+ * requests and kanban tasks. Failures are swallowed — the client will refetch.
1616+ *
1717+ * `fetchApi` is injected: dev passes an HTTP fetcher with cookie forwarding,
1818+ * prod passes `app.request` for in-process calls.
1919+ */
2020+export async function prefetchPageData({
2121+ url,
2222+ sphereHandle,
2323+ isMultiSphere,
2424+ isAuthenticated,
2525+ hasSphere,
2626+ fetchApi,
2727+}: PrefetchOptions): Promise<Record<string, unknown>> {
2828+ const pageData: Record<string, unknown> = {};
2929+3030+ // Dashboard: prefetch user's spheres and pending invitations
3131+ if (isMultiSphere && !sphereHandle && isAuthenticated) {
3232+ try {
3333+ const res = await fetchApi("/api/spheres?member=true");
3434+ if (res.ok) pageData["my-spheres"] = await res.json();
3535+ } catch {
3636+ /* prefetch failed — client will fetch */
3737+ }
3838+ try {
3939+ const res = await fetchApi("/api/spheres/invitations");
4040+ if (res.ok) pageData["invitations"] = await res.json();
4141+ } catch {
4242+ /* prefetch failed — client will fetch */
4343+ }
4444+ }
4545+4646+ if (hasSphere) {
4747+ const prefetches = ssrPrefetch(url, sphereHandle);
4848+ for (const prefetch of prefetches) {
4949+ try {
5050+ const res = await fetchApi(prefetch.apiUrl);
5151+ if (res.ok) pageData[prefetch.key] = await res.json();
5252+ } catch {
5353+ /* prefetch failed — client will fetch */
5454+ }
5555+ }
5656+5757+ const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api";
5858+5959+ // For individual feature requests, also prefetch comments
6060+ const frData = pageData["feature-request"] as { featureRequest?: { id: string } } | undefined;
6161+ if (frData?.featureRequest?.id) {
6262+ try {
6363+ const res = await fetchApi(
6464+ `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`,
6565+ );
6666+ if (res.ok) pageData["feature-request-comments"] = await res.json();
6767+ } catch {
6868+ /* prefetch failed — client will fetch */
6969+ }
7070+ }
7171+7272+ // For individual kanban tasks, also prefetch comments
7373+ const taskData = pageData["kanban-task"] as { task?: { id: string } } | undefined;
7474+ if (taskData?.task?.id) {
7575+ try {
7676+ const res = await fetchApi(`${apiBase}/kanban/${taskData.task.id}/comments`);
7777+ if (res.ok) pageData["kanban-task-comments"] = await res.json();
7878+ } catch {
7979+ /* prefetch failed — client will fetch */
8080+ }
8181+ }
8282+ }
8383+8484+ return pageData;
8585+}
+29-132
packages/app/src/vite-ssr-plugin.ts
···11-import type { Plugin, ViteDevServer, ModuleNode } from "vite";
11+import type { Plugin } from "vite";
22import fs from "node:fs";
33import path from "node:path";
44-import { ssrPrefetch } from "./ssr-prefetch.ts";
44+import { collectSsrCss } from "./ssr-css.ts";
55+import { composePage } from "./ssr-compose.ts";
66+import { prefetchPageData } from "./ssr-prefetch-orchestrator.ts";
5768const API_SERVER = "http://localhost:3001";
7988-/**
99- * Walk the Vite module graph from an SSR entry and collect all CSS
1010- * module URLs (regular .css, vanilla-extract virtual CSS, etc.).
1111- * Then transform each one via the client pipeline and extract the
1212- * raw CSS from the JS wrapper so we can inline it as <style> tags.
1313- */
1414-async function collectSsrCss(server: ViteDevServer, entryUrl: string): Promise<string> {
1515- const cssUrls = new Set<string>();
1616- const visited = new Set<string>();
1717-1818- function walk(mod: ModuleNode | undefined) {
1919- if (!mod || visited.has(mod.url)) return;
2020- visited.add(mod.url);
2121- if (/\.css($|\?)/.test(mod.url)) {
2222- cssUrls.add(mod.url);
2323- }
2424- for (const dep of mod.ssrImportedModules) {
2525- walk(dep);
2626- }
2727- }
2828-2929- const resolved = await server.moduleGraph.resolveUrl(entryUrl);
3030- walk(server.moduleGraph.getModuleById(resolved[1]));
3131-3232- const styles: string[] = [];
3333- for (const url of cssUrls) {
3434- try {
3535- const result = await server.transformRequest(url);
3636- if (!result?.code) continue;
3737- // Vite wraps CSS in a JS module with: const __vite__css = "...css..."
3838- const match = result.code.match(/__vite__css\s*=\s*("[\s\S]*?")\s*[\n;]/);
3939- if (match?.[1]) {
4040- styles.push(JSON.parse(match[1]));
4141- }
4242- } catch {
4343- // skip modules that fail to transform
4444- }
4545- }
4646-4747- return styles.map((css) => `<style data-vite-dev-css>${css}</style>`).join("\n");
4848-}
4949-5010export function ssrDevPlugin({ isMultiSphere = false } = {}): Plugin {
5111 return {
5212 name: "exosphere-ssr",
···74347535 try {
7636 const cookie = req.headers.cookie ?? "";
3737+ const fetchApi = (apiPath: string) =>
3838+ fetch(`${API_SERVER}${apiPath}`, { headers: { cookie } });
3939+4040+ // In multi-sphere mode, extract handle from URL path /s/:handle/...
4141+ let sphereHandleFromUrl: string | undefined;
4242+ if (isMultiSphere) {
4343+ const handleMatch = url.match(/^\/s\/([^/]+)/);
4444+ if (handleMatch) sphereHandleFromUrl = handleMatch[1];
4545+ }
77467847 // Fetch auth and sphere data from the Hono API server
7948 let authData: { authenticated: boolean; did?: string; handle?: string } = {
8049 authenticated: false,
8150 };
8251 let sphereData: unknown = null;
8383- let sphereHandleFromUrl: string | undefined;
8452 try {
8585- // In multi-sphere mode, extract handle from URL path /s/:handle/...
8686- if (isMultiSphere) {
8787- const handleMatch = url.match(/^\/s\/([^/]+)/);
8888- if (handleMatch) sphereHandleFromUrl = handleMatch[1];
8989- }
5353+ const authRes = await fetchApi("/api/oauth/session");
5454+ authData = await authRes.json();
9055 // In multi-sphere mode without a handle, this is the dashboard — no sphere to load
9191- const sphereUrl = sphereHandleFromUrl
9292- ? `${API_SERVER}/api/spheres/${sphereHandleFromUrl}`
5656+ const spherePath = sphereHandleFromUrl
5757+ ? `/api/spheres/${sphereHandleFromUrl}`
9358 : isMultiSphere
9459 ? null
9595- : `${API_SERVER}/api/spheres/current`;
9696-9797- const authRes = await fetch(`${API_SERVER}/api/oauth/session`, {
9898- headers: { cookie },
9999- });
100100- authData = await authRes.json();
101101- if (sphereUrl) {
102102- const sphereRes = await fetch(sphereUrl, { headers: { cookie } });
6060+ : "/api/spheres/current";
6161+ if (spherePath) {
6262+ const sphereRes = await fetchApi(spherePath);
10363 sphereData = sphereRes.ok ? await sphereRes.json() : null;
10464 }
10565 } catch {
···11373 // Load the SSR entry via Vite's module graph (supports HMR)
11474 const { render } = await server.ssrLoadModule("/src/entry-server.tsx");
11575116116- // Prefetch page data from the API server
117117- const pageData: Record<string, unknown> = {};
118118-119119- // Dashboard: prefetch user's spheres and pending invitations
120120- if (isMultiSphere && !sphereHandleFromUrl && authData.authenticated) {
121121- try {
122122- const res = await fetch(`${API_SERVER}/api/spheres?member=true`, {
123123- headers: { cookie },
124124- });
125125- if (res.ok) pageData["my-spheres"] = await res.json();
126126- } catch {
127127- /* prefetch failed — client will fetch */
128128- }
129129- try {
130130- const res = await fetch(`${API_SERVER}/api/spheres/invitations`, {
131131- headers: { cookie },
132132- });
133133- if (res.ok) pageData["invitations"] = await res.json();
134134- } catch {
135135- /* prefetch failed — client will fetch */
136136- }
137137- }
138138-139139- if (sphereData) {
140140- const prefetches = ssrPrefetch(url, sphereHandleFromUrl);
141141- for (const prefetch of prefetches) {
142142- try {
143143- const res = await fetch(`${API_SERVER}${prefetch.apiUrl}`, { headers: { cookie } });
144144- if (res.ok) pageData[prefetch.key] = await res.json();
145145- } catch {
146146- /* prefetch failed — client will fetch */
147147- }
148148- }
149149-150150- // For individual feature requests, also prefetch comments
151151- const apiBase = sphereHandleFromUrl
152152- ? `${API_SERVER}/api/s/${sphereHandleFromUrl}`
153153- : `${API_SERVER}/api`;
154154- const frData = pageData["feature-request"] as
155155- | { featureRequest?: { id: string } }
156156- | undefined;
157157- if (frData?.featureRequest?.id) {
158158- try {
159159- const res = await fetch(
160160- `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`,
161161- { headers: { cookie } },
162162- );
163163- if (res.ok) pageData["feature-request-comments"] = await res.json();
164164- } catch {
165165- /* prefetch failed — client will fetch */
166166- }
167167- }
168168-169169- // For individual kanban tasks, also prefetch comments
170170- const taskData = pageData["kanban-task"] as { task?: { id: string } } | undefined;
171171- if (taskData?.task?.id) {
172172- try {
173173- const res = await fetch(`${apiBase}/kanban/${taskData.task.id}/comments`, {
174174- headers: { cookie },
175175- });
176176- if (res.ok) pageData["kanban-task-comments"] = await res.json();
177177- } catch {
178178- /* prefetch failed — client will fetch */
179179- }
180180- }
181181- }
7676+ const pageData = await prefetchPageData({
7777+ url,
7878+ sphereHandle: sphereHandleFromUrl,
7979+ isMultiSphere,
8080+ isAuthenticated: authData.authenticated,
8181+ hasSphere: Boolean(sphereData),
8282+ fetchApi,
8383+ });
1828418385 const ssrData = {
18486 auth: {
···199101 // which causes a flash of unstyled content when SSR HTML arrives first.
200102 const devCss = await collectSsrCss(server, "/src/entry-server.tsx");
201103202202- const page = template
203203- .replace("</head>", `${devCss}\n${themeScript}\n</head>`)
204204- .replace(
205205- '<div id="app"></div>',
206206- `<div id="app">${html}</div>\n<script>window.__SSR_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")}</script>`,
207207- );
104104+ const page = composePage({ template, html, data, themeScript, devCss });
208105209106 res.statusCode = 200;
210107 res.setHeader("Content-Type", "text/html");