Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: improve dev server

Hugo 66904f21 caee82fd

+198 -199
+14 -67
packages/app/src/server.ts
··· 2 2 import { serveStatic } from "hono/bun"; 3 3 import { getCookie } from "hono/cookie"; 4 4 import { getOAuthClient, oauthRoutes } from "@exosphere/core/auth"; 5 - import { 6 - createSphereRoutes, 7 - getCurrentSphere, 8 - getMemberSpheres, 9 - getPendingInvitations, 10 - sphereContext, 11 - } from "@exosphere/core/sphere"; 5 + import { createSphereRoutes, getCurrentSphere, sphereContext } from "@exosphere/core/sphere"; 12 6 import { isMultiSphere } from "@exosphere/core/config"; 13 7 import { startJetstream, stopCursorFlushing } from "@exosphere/indexer"; 14 8 import { modules, coreIndexer } from "@exosphere/indexer/modules"; 15 9 import { createMcpRoutes } from "@exosphere/mcp"; 16 10 import { featureRequestMcpTools } from "@exosphere/feature-requests/mcp"; 17 11 import { kanbanMcpTools } from "@exosphere/kanban/mcp"; 18 - import { ssrPrefetch } from "./ssr-prefetch.ts"; 12 + import { composePage } from "./ssr-compose.ts"; 13 + import { prefetchPageData } from "./ssr-prefetch-orchestrator.ts"; 19 14 20 15 const app = new Hono(); 21 16 ··· 157 152 sphere = null; 158 153 } 159 154 160 - // Prefetch page data by calling our own API routes internally 161 - const pageData: Record<string, unknown> = {}; 162 - 163 - // Dashboard: prefetch user's spheres and pending invitations 164 - if (isMultiSphere && !sphereHandleFromUrl && authData.did) { 165 - pageData["my-spheres"] = { spheres: getMemberSpheres(authData.did) }; 166 - pageData["invitations"] = { invitations: getPendingInvitations(authData.did) }; 167 - } 168 - 169 - if (sphere) { 170 - const prefetches = ssrPrefetch(c.req.path, sphereHandleFromUrl); 171 - const cookieHeader = c.req.header("Cookie"); 172 - const prefetchInit = cookieHeader ? { headers: { Cookie: cookieHeader } } : undefined; 173 - for (const prefetch of prefetches) { 174 - try { 175 - const res = await app.request(prefetch.apiUrl, prefetchInit); 176 - if (res.ok) pageData[prefetch.key] = await res.json(); 177 - } catch { 178 - /* prefetch failed — client will fetch */ 179 - } 180 - } 181 - 182 - // For individual feature requests, also prefetch comments 183 - const apiBase = sphereHandleFromUrl ? `/api/s/${sphereHandleFromUrl}` : "/api"; 184 - const frData = pageData["feature-request"] as 185 - | { featureRequest?: { id: string } } 186 - | undefined; 187 - if (frData?.featureRequest?.id) { 188 - try { 189 - const res = await app.request( 190 - `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 191 - prefetchInit, 192 - ); 193 - if (res.ok) pageData["feature-request-comments"] = await res.json(); 194 - } catch { 195 - /* prefetch failed — client will fetch */ 196 - } 197 - } 198 - 199 - // For individual kanban tasks, also prefetch comments 200 - const taskData = pageData["kanban-task"] as { task?: { id: string } } | undefined; 201 - if (taskData?.task?.id) { 202 - try { 203 - const res = await app.request( 204 - `${apiBase}/kanban/${taskData.task.id}/comments`, 205 - prefetchInit, 206 - ); 207 - if (res.ok) pageData["kanban-task-comments"] = await res.json(); 208 - } catch { 209 - /* prefetch failed — client will fetch */ 210 - } 211 - } 212 - } 155 + const cookieHeader = c.req.header("Cookie"); 156 + const prefetchInit = cookieHeader ? { headers: { Cookie: cookieHeader } } : undefined; 157 + const pageData = await prefetchPageData({ 158 + url: c.req.path, 159 + sphereHandle: sphereHandleFromUrl, 160 + isMultiSphere, 161 + isAuthenticated: Boolean(authData.did), 162 + hasSphere: Boolean(sphere), 163 + fetchApi: (apiPath) => app.request(apiPath, prefetchInit), 164 + }); 213 165 214 166 const ssrData = { 215 167 auth: authData, ··· 221 173 222 174 const { html, themeScript, data } = await render(c.req.path, ssrData); 223 175 224 - const page = template 225 - .replace("</head>", `${themeScript}\n</head>`) 226 - .replace( 227 - '<div id="app"></div>', 228 - `<div id="app">${html}</div>\n<script>window.__SSR_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")}</script>`, 229 - ); 176 + const page = composePage({ template, html, data, themeScript }); 230 177 231 178 const isAuthenticated = Boolean(sid && authData.authenticated); 232 179 c.header("Vary", "Cookie");
+25
packages/app/src/ssr-compose.ts
··· 1 + export interface ComposePageOptions { 2 + template: string; 3 + html: string; 4 + data: unknown; 5 + themeScript: string; 6 + /** Inlined <style> tags — dev only. In prod, CSS is linked from the manifest. */ 7 + devCss?: string; 8 + } 9 + 10 + export function composePage({ 11 + template, 12 + html, 13 + data, 14 + themeScript, 15 + devCss, 16 + }: ComposePageOptions): string { 17 + const headInjection = devCss ? `${devCss}\n${themeScript}` : themeScript; 18 + const serializedData = JSON.stringify(data).replace(/</g, "\\u003c"); 19 + return template 20 + .replace("</head>", `${headInjection}\n</head>`) 21 + .replace( 22 + '<div id="app"></div>', 23 + `<div id="app">${html}</div>\n<script>window.__SSR_DATA__=${serializedData}</script>`, 24 + ); 25 + }
+45
packages/app/src/ssr-css.ts
··· 1 + import type { ModuleNode, ViteDevServer } from "vite"; 2 + 3 + /** 4 + * Walk the Vite module graph from an SSR entry and collect all CSS 5 + * module URLs (regular .css, vanilla-extract virtual CSS, etc.). 6 + * Then transform each one via the client pipeline and extract the 7 + * raw CSS from the JS wrapper so we can inline it as <style> tags. 8 + * 9 + * Dev-only — production serves hashed stylesheets from the Vite manifest. 10 + */ 11 + export async function collectSsrCss(server: ViteDevServer, entryUrl: string): Promise<string> { 12 + const cssUrls = new Set<string>(); 13 + const visited = new Set<string>(); 14 + 15 + function walk(mod: ModuleNode | undefined) { 16 + if (!mod || visited.has(mod.url)) return; 17 + visited.add(mod.url); 18 + if (/\.css($|\?)/.test(mod.url)) { 19 + cssUrls.add(mod.url); 20 + } 21 + for (const dep of mod.ssrImportedModules) { 22 + walk(dep); 23 + } 24 + } 25 + 26 + const resolved = await server.moduleGraph.resolveUrl(entryUrl); 27 + walk(server.moduleGraph.getModuleById(resolved[1])); 28 + 29 + const styles: string[] = []; 30 + for (const url of cssUrls) { 31 + try { 32 + const result = await server.transformRequest(url); 33 + if (!result?.code) continue; 34 + // Vite wraps CSS in a JS module with: const __vite__css = "...css..." 35 + const match = result.code.match(/__vite__css\s*=\s*("[\s\S]*?")\s*[\n;]/); 36 + if (match?.[1]) { 37 + styles.push(JSON.parse(match[1])); 38 + } 39 + } catch { 40 + // skip modules that fail to transform 41 + } 42 + } 43 + 44 + return styles.map((css) => `<style data-vite-dev-css>${css}</style>`).join("\n"); 45 + }
+85
packages/app/src/ssr-prefetch-orchestrator.ts
··· 1 + import { ssrPrefetch } from "./ssr-prefetch.ts"; 2 + 3 + export interface PrefetchOptions { 4 + url: string; 5 + sphereHandle: string | undefined; 6 + isMultiSphere: boolean; 7 + isAuthenticated: boolean; 8 + hasSphere: boolean; 9 + fetchApi: (path: string) => Response | Promise<Response>; 10 + } 11 + 12 + /** 13 + * Orchestrates the full SSR prefetch tree: dashboard data (multi-sphere, no handle), 14 + * per-route module data via `ssrPrefetch`, and nested comment fetches for feature 15 + * requests and kanban tasks. Failures are swallowed — the client will refetch. 16 + * 17 + * `fetchApi` is injected: dev passes an HTTP fetcher with cookie forwarding, 18 + * prod passes `app.request` for in-process calls. 19 + */ 20 + export async function prefetchPageData({ 21 + url, 22 + sphereHandle, 23 + isMultiSphere, 24 + isAuthenticated, 25 + hasSphere, 26 + fetchApi, 27 + }: PrefetchOptions): Promise<Record<string, unknown>> { 28 + const pageData: Record<string, unknown> = {}; 29 + 30 + // Dashboard: prefetch user's spheres and pending invitations 31 + if (isMultiSphere && !sphereHandle && isAuthenticated) { 32 + try { 33 + const res = await fetchApi("/api/spheres?member=true"); 34 + if (res.ok) pageData["my-spheres"] = await res.json(); 35 + } catch { 36 + /* prefetch failed — client will fetch */ 37 + } 38 + try { 39 + const res = await fetchApi("/api/spheres/invitations"); 40 + if (res.ok) pageData["invitations"] = await res.json(); 41 + } catch { 42 + /* prefetch failed — client will fetch */ 43 + } 44 + } 45 + 46 + if (hasSphere) { 47 + const prefetches = ssrPrefetch(url, sphereHandle); 48 + for (const prefetch of prefetches) { 49 + try { 50 + const res = await fetchApi(prefetch.apiUrl); 51 + if (res.ok) pageData[prefetch.key] = await res.json(); 52 + } catch { 53 + /* prefetch failed — client will fetch */ 54 + } 55 + } 56 + 57 + const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api"; 58 + 59 + // For individual feature requests, also prefetch comments 60 + const frData = pageData["feature-request"] as { featureRequest?: { id: string } } | undefined; 61 + if (frData?.featureRequest?.id) { 62 + try { 63 + const res = await fetchApi( 64 + `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 65 + ); 66 + if (res.ok) pageData["feature-request-comments"] = await res.json(); 67 + } catch { 68 + /* prefetch failed — client will fetch */ 69 + } 70 + } 71 + 72 + // For individual kanban tasks, also prefetch comments 73 + const taskData = pageData["kanban-task"] as { task?: { id: string } } | undefined; 74 + if (taskData?.task?.id) { 75 + try { 76 + const res = await fetchApi(`${apiBase}/kanban/${taskData.task.id}/comments`); 77 + if (res.ok) pageData["kanban-task-comments"] = await res.json(); 78 + } catch { 79 + /* prefetch failed — client will fetch */ 80 + } 81 + } 82 + } 83 + 84 + return pageData; 85 + }
+29 -132
packages/app/src/vite-ssr-plugin.ts
··· 1 - import type { Plugin, ViteDevServer, ModuleNode } from "vite"; 1 + import type { Plugin } from "vite"; 2 2 import fs from "node:fs"; 3 3 import path from "node:path"; 4 - import { ssrPrefetch } from "./ssr-prefetch.ts"; 4 + import { collectSsrCss } from "./ssr-css.ts"; 5 + import { composePage } from "./ssr-compose.ts"; 6 + import { prefetchPageData } from "./ssr-prefetch-orchestrator.ts"; 5 7 6 8 const API_SERVER = "http://localhost:3001"; 7 9 8 - /** 9 - * Walk the Vite module graph from an SSR entry and collect all CSS 10 - * module URLs (regular .css, vanilla-extract virtual CSS, etc.). 11 - * Then transform each one via the client pipeline and extract the 12 - * raw CSS from the JS wrapper so we can inline it as <style> tags. 13 - */ 14 - async function collectSsrCss(server: ViteDevServer, entryUrl: string): Promise<string> { 15 - const cssUrls = new Set<string>(); 16 - const visited = new Set<string>(); 17 - 18 - function walk(mod: ModuleNode | undefined) { 19 - if (!mod || visited.has(mod.url)) return; 20 - visited.add(mod.url); 21 - if (/\.css($|\?)/.test(mod.url)) { 22 - cssUrls.add(mod.url); 23 - } 24 - for (const dep of mod.ssrImportedModules) { 25 - walk(dep); 26 - } 27 - } 28 - 29 - const resolved = await server.moduleGraph.resolveUrl(entryUrl); 30 - walk(server.moduleGraph.getModuleById(resolved[1])); 31 - 32 - const styles: string[] = []; 33 - for (const url of cssUrls) { 34 - try { 35 - const result = await server.transformRequest(url); 36 - if (!result?.code) continue; 37 - // Vite wraps CSS in a JS module with: const __vite__css = "...css..." 38 - const match = result.code.match(/__vite__css\s*=\s*("[\s\S]*?")\s*[\n;]/); 39 - if (match?.[1]) { 40 - styles.push(JSON.parse(match[1])); 41 - } 42 - } catch { 43 - // skip modules that fail to transform 44 - } 45 - } 46 - 47 - return styles.map((css) => `<style data-vite-dev-css>${css}</style>`).join("\n"); 48 - } 49 - 50 10 export function ssrDevPlugin({ isMultiSphere = false } = {}): Plugin { 51 11 return { 52 12 name: "exosphere-ssr", ··· 74 34 75 35 try { 76 36 const cookie = req.headers.cookie ?? ""; 37 + const fetchApi = (apiPath: string) => 38 + fetch(`${API_SERVER}${apiPath}`, { headers: { cookie } }); 39 + 40 + // In multi-sphere mode, extract handle from URL path /s/:handle/... 41 + let sphereHandleFromUrl: string | undefined; 42 + if (isMultiSphere) { 43 + const handleMatch = url.match(/^\/s\/([^/]+)/); 44 + if (handleMatch) sphereHandleFromUrl = handleMatch[1]; 45 + } 77 46 78 47 // Fetch auth and sphere data from the Hono API server 79 48 let authData: { authenticated: boolean; did?: string; handle?: string } = { 80 49 authenticated: false, 81 50 }; 82 51 let sphereData: unknown = null; 83 - let sphereHandleFromUrl: string | undefined; 84 52 try { 85 - // In multi-sphere mode, extract handle from URL path /s/:handle/... 86 - if (isMultiSphere) { 87 - const handleMatch = url.match(/^\/s\/([^/]+)/); 88 - if (handleMatch) sphereHandleFromUrl = handleMatch[1]; 89 - } 53 + const authRes = await fetchApi("/api/oauth/session"); 54 + authData = await authRes.json(); 90 55 // In multi-sphere mode without a handle, this is the dashboard — no sphere to load 91 - const sphereUrl = sphereHandleFromUrl 92 - ? `${API_SERVER}/api/spheres/${sphereHandleFromUrl}` 56 + const spherePath = sphereHandleFromUrl 57 + ? `/api/spheres/${sphereHandleFromUrl}` 93 58 : isMultiSphere 94 59 ? null 95 - : `${API_SERVER}/api/spheres/current`; 96 - 97 - const authRes = await fetch(`${API_SERVER}/api/oauth/session`, { 98 - headers: { cookie }, 99 - }); 100 - authData = await authRes.json(); 101 - if (sphereUrl) { 102 - const sphereRes = await fetch(sphereUrl, { headers: { cookie } }); 60 + : "/api/spheres/current"; 61 + if (spherePath) { 62 + const sphereRes = await fetchApi(spherePath); 103 63 sphereData = sphereRes.ok ? await sphereRes.json() : null; 104 64 } 105 65 } catch { ··· 113 73 // Load the SSR entry via Vite's module graph (supports HMR) 114 74 const { render } = await server.ssrLoadModule("/src/entry-server.tsx"); 115 75 116 - // Prefetch page data from the API server 117 - const pageData: Record<string, unknown> = {}; 118 - 119 - // Dashboard: prefetch user's spheres and pending invitations 120 - if (isMultiSphere && !sphereHandleFromUrl && authData.authenticated) { 121 - try { 122 - const res = await fetch(`${API_SERVER}/api/spheres?member=true`, { 123 - headers: { cookie }, 124 - }); 125 - if (res.ok) pageData["my-spheres"] = await res.json(); 126 - } catch { 127 - /* prefetch failed — client will fetch */ 128 - } 129 - try { 130 - const res = await fetch(`${API_SERVER}/api/spheres/invitations`, { 131 - headers: { cookie }, 132 - }); 133 - if (res.ok) pageData["invitations"] = await res.json(); 134 - } catch { 135 - /* prefetch failed — client will fetch */ 136 - } 137 - } 138 - 139 - if (sphereData) { 140 - const prefetches = ssrPrefetch(url, sphereHandleFromUrl); 141 - for (const prefetch of prefetches) { 142 - try { 143 - const res = await fetch(`${API_SERVER}${prefetch.apiUrl}`, { headers: { cookie } }); 144 - if (res.ok) pageData[prefetch.key] = await res.json(); 145 - } catch { 146 - /* prefetch failed — client will fetch */ 147 - } 148 - } 149 - 150 - // For individual feature requests, also prefetch comments 151 - const apiBase = sphereHandleFromUrl 152 - ? `${API_SERVER}/api/s/${sphereHandleFromUrl}` 153 - : `${API_SERVER}/api`; 154 - const frData = pageData["feature-request"] as 155 - | { featureRequest?: { id: string } } 156 - | undefined; 157 - if (frData?.featureRequest?.id) { 158 - try { 159 - const res = await fetch( 160 - `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 161 - { headers: { cookie } }, 162 - ); 163 - if (res.ok) pageData["feature-request-comments"] = await res.json(); 164 - } catch { 165 - /* prefetch failed — client will fetch */ 166 - } 167 - } 168 - 169 - // For individual kanban tasks, also prefetch comments 170 - const taskData = pageData["kanban-task"] as { task?: { id: string } } | undefined; 171 - if (taskData?.task?.id) { 172 - try { 173 - const res = await fetch(`${apiBase}/kanban/${taskData.task.id}/comments`, { 174 - headers: { cookie }, 175 - }); 176 - if (res.ok) pageData["kanban-task-comments"] = await res.json(); 177 - } catch { 178 - /* prefetch failed — client will fetch */ 179 - } 180 - } 181 - } 76 + const pageData = await prefetchPageData({ 77 + url, 78 + sphereHandle: sphereHandleFromUrl, 79 + isMultiSphere, 80 + isAuthenticated: authData.authenticated, 81 + hasSphere: Boolean(sphereData), 82 + fetchApi, 83 + }); 182 84 183 85 const ssrData = { 184 86 auth: { ··· 199 101 // which causes a flash of unstyled content when SSR HTML arrives first. 200 102 const devCss = await collectSsrCss(server, "/src/entry-server.tsx"); 201 103 202 - const page = template 203 - .replace("</head>", `${devCss}\n${themeScript}\n</head>`) 204 - .replace( 205 - '<div id="app"></div>', 206 - `<div id="app">${html}</div>\n<script>window.__SSR_DATA__=${JSON.stringify(data).replace(/</g, "\\u003c")}</script>`, 207 - ); 104 + const page = composePage({ template, html, data, themeScript, devCss }); 208 105 209 106 res.statusCode = 200; 210 107 res.setHeader("Content-Type", "text/html");