import { defineEventHandler } from "h3"; /** * Shared SSR catch-all handler for React Router framework mode. * * Templates wire this up via: * * // server/routes/[...page].get.ts * import { createH3SSRHandler } from "@agent-native/core/server/ssr-handler"; * export default createH3SSRHandler( * () => import("virtual:react-router/server-build"), * ); * * The `getBuild` callback MUST live in the template's own source so Vite's * @react-router/dev plugin can resolve the `virtual:` module. Pulling the * import into core (e.g. via a re-export) puts it in node_modules where * Vite's SSR externalizer leaves it untouched and Node's ESM loader rejects * the unknown scheme — silently 302'ing every request to "/". */ import { createRequestHandler } from "react-router"; import { isMcpPublicPath } from "../mcp/route-paths.js"; import { DEFAULT_SPECULATION_RULES_PATH, resolveSsrCacheHeaders, } from "../shared/cache-control.js"; import { AGENT_NATIVE_SOCIAL_IMAGE_ALT, AGENT_NATIVE_SOCIAL_IMAGE_HEIGHT, AGENT_NATIVE_SOCIAL_IMAGE_PATH, AGENT_NATIVE_SOCIAL_IMAGE_TYPE, AGENT_NATIVE_SOCIAL_IMAGE_WIDTH, withAgentNativeSocialImageCacheBuster, } from "../shared/social-meta.js"; import { getAppBasePathFromViteEnv, stripAppBasePath as canonicalStripAppBasePath, } from "./app-base-path.js"; import { captureError } from "./capture-error.js"; import { runWithRequestContext } from "./request-context.js"; import { getRealtimeClientConfigScript, getSentryClientConfigScript, } from "./sentry-config.js"; export { DEFAULT_SSR_CACHE_HEADERS, DEFAULT_SPECULATION_RULES_HEADER, DEFAULT_SSR_CACHE_CONTROL, DISABLED_SSR_CACHE_HEADERS, isSsrCacheEnabled, resolveSsrCacheHeaders, SSR_CACHE_ENV_VAR, } from "../shared/cache-control.js"; function getAppBasePath(): string { return getAppBasePathFromViteEnv(); } function stripAppBasePath(pathname: string): string { return canonicalStripAppBasePath(pathname, getAppBasePath()); } function stripBasePath(pathname: string, basePath: string): string { if (!basePath) return pathname; if (pathname === basePath) return "/"; if (pathname.startsWith(`${basePath}/`)) { return pathname.slice(basePath.length) || "/"; } return pathname; } function requestWithPathname( request: Request, pathname: string, basePath: string, ): Request { const url = new URL(request.url); let changed = false; if (basePath && pathname === "/__manifest") { const paths = url.searchParams.get("paths"); if (paths) { const strippedPaths = paths .split(",") .map((path) => stripBasePath(path, basePath)) .join(","); if (strippedPaths !== paths) { url.searchParams.set("paths", strippedPaths); changed = true; } } } if (url.pathname !== pathname) { url.pathname = pathname; changed = true; } if (!changed) return request; const init: RequestInit & { duplex?: "half" } = { method: request.method, headers: request.headers, signal: request.signal, }; if (request.body && !["GET", "HEAD"].includes(request.method.toUpperCase())) { init.body = request.body; init.duplex = "half"; } return new Request(url, init); } function requestForAnonymousSsr(request: Request): Request { const headers = new Headers(request.headers); headers.delete("cookie"); headers.delete("authorization"); const init: RequestInit & { duplex?: "half" } = { method: request.method, headers, signal: request.signal, }; if (request.body && !["GET", "HEAD"].includes(request.method.toUpperCase())) { init.body = request.body; init.duplex = "half"; } return new Request(request.url, init); } function prefixMountedPath(path: string, basePath: string): string { if (!basePath || !path.startsWith("/") || path.startsWith("//")) return path; if (path === basePath || path.startsWith(`${basePath}/`)) return path; return `${basePath}${path}`; } function prefixMountedHtml(html: string, basePath: string): string { if (!basePath) return html; const prefixedHtml = html .replace( /\b(href|src|action|formaction|poster)=(["'])(\/(?!\/)[^"']*)\2/g, (_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefixMountedPath(path, basePath)}${quote}`, ) .replace(/url\((["']?)(\/(?!\/)[^)'" ]+)\1\)/g, (_match, quote, path) => { const q = quote || ""; return `url(${q}${prefixMountedPath(path, basePath)}${q})`; }); // React Router serializes the server-side basename into its hydration // context. The request above is deliberately rendered mount-relative, so // that value is normally "/" even though the browser URL is mounted at a // workspace prefix such as "/analytics". If the client hydrates that // context unchanged, the mounted pathname no longer matches the route tree: // index redirects can stall and child pages can fall through to the 404. // Keep the serialized router state consistent with the URLs we just // prefixed. Template entry clients also set this defensively for older // responses, but the initial hydration state must be correct at the source. return prefixedHtml.replace( /(window\.__reactRouterContext\s*=\s*\{\s*"basename"\s*:\s*)"(?:\\.|[^"\\])*"/, `$1${JSON.stringify(basePath)}`, ); } function injectHeadScript(html: string, script: string | null): string { if (!script) return html; const headCloseIdx = html.indexOf(""); if (headCloseIdx === -1) return html; return html.slice(0, headCloseIdx) + script + html.slice(headCloseIdx); } const OG_IMAGE_META_RE = /]*\bproperty=(["'])og:image\1)[^>]*>/i; const TWITTER_CARD_META_RE = /]*\bname=(["'])twitter:card\1)[^>]*>/i; const TWITTER_IMAGE_META_RE = /]*\bname=(["'])twitter:image\1)[^>]*>/i; function defaultSocialImageUrl(requestUrl: string, basePath: string): string { return withAgentNativeSocialImageCacheBuster( new URL( prefixMountedPath(AGENT_NATIVE_SOCIAL_IMAGE_PATH, basePath), requestUrl, ).toString(), ); } function injectDefaultSocialImageMeta(html: string, imageUrl: string): string { const headCloseIdx = html.indexOf(""); if (headCloseIdx === -1) return html; const hasAnySocialImage = OG_IMAGE_META_RE.test(html) || TWITTER_IMAGE_META_RE.test(html); const tags: string[] = []; if (!hasAnySocialImage) { tags.push(``); tags.push(``); tags.push( ``, ); tags.push( ``, ); tags.push( ``, ); tags.push( ``, ); } if (!TWITTER_CARD_META_RE.test(html)) { tags.push(``); } if (!hasAnySocialImage) { tags.push(``); tags.push( ``, ); } if (tags.length === 0) return html; return html.slice(0, headCloseIdx) + tags.join("") + html.slice(headCloseIdx); } function isSsrHtmlOrDataResponse( headers: Headers, status: number, pathname: string, ): boolean { if (status < 200 || status >= 400) return false; const contentType = headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("text/html")) return true; return pathname.endsWith(".data") && contentType.includes("text/x-script"); } /** * Apply the SSR cache policy to the response headers. * * ┌──────────────────────────────────────────────────────────────────────────┐ * │ SSR IS A PUBLIC, HARD-CDN-CACHED SHELL — SERVED IDENTICALLY TO EVERYONE. │ * │ │ * │ Every SSR HTML / React Router `.data` response gets the same public │ * │ stale-while-revalidate policy for ALL visitors, authenticated or not, so │ * │ the edge serves one shared copy and never stampedes origin. │ * │ │ * │ DO NOT reintroduce per-user / cookie-based cache variation here (no │ * │ `private`, no `no-store`, no `Vary: Cookie`, no "authenticated → don't │ * │ cache" branch). That makes pages uncacheable for every logged-in visitor, │ * │ which is slow and expensive — exactly the regression this guardrail │ * │ prevents. The reason it is SAFE to hard-cache is that the SSR response is │ * │ impersonal: `createH3SSRHandler` renders without reading the request's │ * │ session/cookies, so there is no per-user data baked into the HTML. ALL │ * │ per-user state (who's logged in, private records, access checks) is │ * │ resolved CLIENT-SIDE after load. Keep it that way: if you need the SSR │ * │ output to differ per user, the fix is to move that work client-side, not │ * │ to disable caching here. │ * │ │ * │ HOW LONG the shell is cached is deployment-wide and configurable through │ * │ AGENT_NATIVE_SSR_CACHE (see `resolveSsrCacheHeaders`), for hosts that do │ * │ not purge their CDN on deploy. What remains forbidden is PER-REQUEST / │ * │ PER-USER variation — no `private`, no `Vary: Cookie`, no per-route escape │ * │ hatch — because that is what poisons a shared CDN cache key. A value fixed │ * │ for the whole deployment cannot. │ * └──────────────────────────────────────────────────────────────────────────┘ */ function applyDefaultSsrCacheHeader( headers: Headers, status: number, pathname: string, ) { if (!isSsrHtmlOrDataResponse(headers, status, pathname)) return; // A public shell must never set a viewer cookie or vary by credentials. // Preserve harmless content-negotiation dimensions such as Accept-Encoding. headers.delete("set-cookie"); const vary = headers.get("vary"); if (vary) { const publicVary = vary .split(",") .map((value) => value.trim()) .filter((value) => { const normalized = value.toLowerCase(); return ( normalized && normalized !== "*" && normalized !== "cookie" && normalized !== "authorization" ); }); if (publicVary.length > 0) headers.set("vary", publicVary.join(", ")); else headers.delete("vary"); } // Netlify Functions/proxies are not cached by default. Set all three cache // headers: Cache-Control for browsers, CDN-Cache-Control for generic CDNs, // and Netlify-CDN-Cache-Control (with durable) so Netlify's shared cache // actually serves SSR HTML/.data from the edge instead of forwarding every // request to origin — for every visitor, authenticated or not. for (const [name, value] of Object.entries(resolveSsrCacheHeaders())) { headers.set(name, value); } } function applyDefaultSpeculationRulesHeader( headers: Headers, status: number, basePath: string, ) { if (status < 200 || status >= 400) return; if (headers.has("speculation-rules")) return; const contentType = headers.get("content-type")?.toLowerCase() ?? ""; if (!contentType.includes("text/html")) return; // Cloudflare Speed Brain injects its own Speculation-Rules header when the // origin omits one. Those browser prefetches carry `Sec-Purpose: prefetch`, // and Cloudflare refuses cache-ineligible dynamic pages with a 503 before // the request can reach Netlify/origin. We publish an explicit no-op ruleset // by default so Cloudflare does not inject its edge prefetch rules. Preserve // an app-provided Speculation-Rules header above if a template deliberately // owns this behavior. const rulesPath = prefixMountedPath(DEFAULT_SPECULATION_RULES_PATH, basePath); headers.set("speculation-rules", `"${rulesPath}"`); } /** * Strip document-level CSP from app HTML responses. * * Hosted templates inject framework bootstrap scripts, analytics, Sentry config, * and app-owned inline scripts whose exact bytes vary by build/template. Any * shared CSP header, even Report-Only, can block or noisily report Google Tag * Manager and those bootstraps. Extension iframes and webviews keep their own * route-specific sandboxes; normal app documents deliberately do not emit CSP. */ function removeDocumentCsp(headers: Headers): void { headers.delete("content-security-policy"); headers.delete("content-security-policy-report-only"); } function isFrameworkOrAssetPath(pathname: string): boolean { return ( isMcpPublicPath(pathname) || pathname.startsWith("/.well-known/") || pathname.startsWith("/_agent_native/") || pathname.startsWith("/_agent-native/") || pathname.startsWith("/api/") || pathname.startsWith("/@vite/") || pathname.startsWith("/@id/") || pathname.startsWith("/@fs/") || pathname === "/@react-refresh" || pathname === "/__vite_ping" || pathname === "/__open-in-editor" || pathname === "/favicon.ico" || pathname === "/favicon.png" || (/\.\w+$/.test(pathname) && !pathname.endsWith(".data")) ); } async function rewriteMountedResponse( response: Response, basePath: string, pathname: string, requestUrl: string, ): Promise { const clientConfigScript = [getSentryClientConfigScript(), getRealtimeClientConfigScript()] .filter(Boolean) .join("") || null; const headers = new Headers(response.headers); applyDefaultSsrCacheHeader(headers, response.status, pathname); applyDefaultSpeculationRulesHeader(headers, response.status, basePath); const location = headers.get("location"); if (location?.startsWith("/") && !location.startsWith("//")) { headers.set("location", prefixMountedPath(location, basePath)); } const contentType = headers.get("content-type") ?? ""; if (!contentType.toLowerCase().includes("text/html")) { return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } removeDocumentCsp(headers); if (!response.body) { return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } const html = await response.text(); headers.delete("content-length"); return new Response( injectHeadScript( injectDefaultSocialImageMeta( prefixMountedHtml(html, basePath), defaultSocialImageUrl(requestUrl, basePath), ), clientConfigScript, ), { status: response.status, statusText: response.statusText, headers, }, ); } /** * Create an h3 catch-all that hands page routes to React Router and * returns 404 for framework / asset paths that React Router doesn't own. */ export function createH3SSRHandler(getBuild: () => Promise | unknown) { const handler = createRequestHandler(getBuild as any); return defineEventHandler(async (event) => { const basePath = getAppBasePath(); const p = stripAppBasePath(event.url.pathname); if (isFrameworkOrAssetPath(p)) { return new Response(null, { status: 404 }); } try { const request = requestForAnonymousSsr( requestWithPathname(event.req as Request, p, basePath), ); // SSR renders an IMPERSONAL public shell — we deliberately do NOT read the // request's session/cookies here, and pin an explicitly anonymous request // context. That keeps the SSR HTML/.data identical for every visitor so it // can be hard-cached at the CDN for everyone (see applyDefaultSsrCacheHeader). // // Consequence: SSR loaders that call `getRequestUserEmail()` / `accessFilter()` // always see the unauthenticated branch and render public content only. Any // per-user view (private records, share-grant access, who's logged in) MUST // be resolved CLIENT-SIDE after load, never baked into SSR. Do not re-pin the // session here to "fix" a per-user page — that silently makes the page // uncacheable and/or leaks one user's data into another's cached copy. const ctx = { userEmail: undefined, orgId: undefined }; if (request.method === "HEAD") { const getRequest = new Request(request.url, { method: "GET", headers: request.headers, signal: request.signal, }); const response = await runWithRequestContext(ctx, () => handler(getRequest), ); return await rewriteMountedResponse( new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers, }), basePath, p, request.url, ); } return await rewriteMountedResponse( await runWithRequestContext(ctx, () => handler(request)), basePath, p, request.url, ); } catch (err) { // Log the full stack server-side, but never leak it to the client. // Stack traces expose file paths, library versions, and code structure // that aid reconnaissance attacks. In dev we surface the message text // so devtools shows something useful; in prod we return a bare 500. console.error("[ssr-handler] SSR error:", err); captureError(err, { route: p, method: event.req.method, userAgent: event.req.headers.get("user-agent") ?? undefined, tags: { renderMode: "anonymous-public", surface: "ssr" }, }); const isProd = process.env.NODE_ENV === "production"; const body = isProd ? "Internal Server Error" : `Internal Server Error: ${(err as Error)?.message ?? err}`; return new Response(body, { status: 500, headers: { "content-type": "text/plain" }, }); } }); }