/** * @wrnexus/ssr — server-side rendering. * * Pages return an HTML string for the body; this module wraps that body in a * full document with a `` built from page metadata. It is intentionally * isolated from any client runtime: nothing here touches the DOM or ships to * the browser, which keeps "server-only code" genuinely server-only. */ import { escapeHtml, type PageMeta, type SeoConfig } from "@wrnexus/core"; export interface ScriptAsset { src: string; /** Module scripts are the default for backward compatibility. */ type?: "module" | "classic"; async?: boolean; defer?: boolean; integrity?: string; crossOrigin?: "anonymous" | "use-credentials"; nonce?: string; attributes?: Record; } export type RenderScript = string | ScriptAsset; export interface RenderOptions { /** Page metadata for the document head. */ meta: PageMeta; /** Global SEO defaults from `wrnexus.config.ts`. */ seo?: SeoConfig; /** Current request URL, used to resolve canonical/Open Graph URLs. */ url?: URL; /** Rendered HTML for the body (placed inside `#app`). */ body: string; /** * URLs of ``; } interface ResolvedSeo { title: string; description?: string; canonical?: string; robots?: string; keywords?: string; image?: string; siteName?: string; type: string; locale?: string; twitterCard: string; twitterSite?: string; themeColor?: string; } function mergeSeo( globalSeo: SeoConfig | undefined, pageSeo: PageMeta, defaultTitle: string | undefined, url: URL | undefined, ): ResolvedSeo { const pageTitle = pageSeo.title; const baseTitle = pageTitle ?? globalSeo?.title ?? defaultTitle ?? "WrNexus"; const titleTemplate = pageTitle ? globalSeo?.titleTemplate : undefined; const title = titleTemplate?.includes("%s") ? titleTemplate.replace("%s", baseTitle) : baseTitle; const canonical = resolveSeoUrl( pageSeo.canonical ?? globalSeo?.canonical ?? (globalSeo?.canonicalBase && url ? url.pathname : undefined), pageSeo.canonicalBase ?? globalSeo?.canonicalBase, url, ); const image = resolveSeoUrl( pageSeo.image ?? globalSeo?.image, pageSeo.canonicalBase ?? globalSeo?.canonicalBase, url, ); const keywords = pageSeo.keywords ?? globalSeo?.keywords; return { title, description: pageSeo.description ?? globalSeo?.description, canonical, robots: pageSeo.robots ?? globalSeo?.robots, keywords: Array.isArray(keywords) ? keywords.join(", ") : keywords, image, siteName: pageSeo.siteName ?? globalSeo?.siteName, type: pageSeo.type ?? globalSeo?.type ?? "website", locale: pageSeo.locale ?? globalSeo?.locale, twitterCard: pageSeo.twitterCard ?? globalSeo?.twitterCard ?? "summary", twitterSite: pageSeo.twitterSite ?? globalSeo?.twitterSite, themeColor: pageSeo.themeColor ?? globalSeo?.themeColor, }; } function renderSeoTags(seo: ResolvedSeo): string { const tags: string[] = []; if (seo.description) tags.push(meta("description", seo.description)); if (seo.robots) tags.push(meta("robots", seo.robots)); if (seo.keywords) tags.push(meta("keywords", seo.keywords)); if (seo.themeColor) tags.push(meta("theme-color", seo.themeColor)); if (seo.canonical) tags.push(``); tags.push(property("og:title", seo.title)); if (seo.description) tags.push(property("og:description", seo.description)); tags.push(property("og:type", seo.type)); if (seo.canonical) tags.push(property("og:url", seo.canonical)); if (seo.siteName) tags.push(property("og:site_name", seo.siteName)); if (seo.locale) tags.push(property("og:locale", seo.locale)); if (seo.image) tags.push(property("og:image", seo.image)); tags.push(meta("twitter:card", seo.twitterCard)); tags.push(meta("twitter:title", seo.title)); if (seo.description) tags.push(meta("twitter:description", seo.description)); if (seo.image) tags.push(meta("twitter:image", seo.image)); if (seo.twitterSite) tags.push(meta("twitter:site", seo.twitterSite)); return tags.length ? `\n ${tags.join("\n ")}` : ""; } function meta(name: string, content: string): string { return ``; } function property(name: string, content: string): string { return ``; } function resolveSeoUrl( value: string | undefined, base: string | undefined, currentUrl: URL | undefined, ): string | undefined { if (!value) return undefined; try { const origin = base ?? (currentUrl ? currentUrl.origin : undefined); return origin ? new URL(value, origin).toString() : value; } catch { return value; } } export interface StreamRenderOptions extends Omit { body: string | Promise | AsyncIterable; } function isAsyncIterable(value: unknown): value is AsyncIterable { return ( typeof value === "object" && value !== null && Symbol.asyncIterator in value && typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" ); } async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable { if (typeof body === "string") { yield body; return; } if (isAsyncIterable(body)) { yield* body; return; } yield await body; } /** * Stream a complete document while preserving the exact head/body contract of * `renderDocument`. Async iterables can flush a shell, primary content, and * slower fragments without buffering the entire route. */ export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream { const marker = ""; const document = renderDocument({ ...opts, body: marker }); const [prefix, suffix] = document.split(marker); const encoder = new TextEncoder(); return new ReadableStream({ async start(controller) { try { controller.enqueue(encoder.encode(prefix ?? "")); for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk)); controller.enqueue(encoder.encode(suffix ?? "")); controller.close(); } catch (error) { controller.error(error); } }, }); } export function streamDocumentResponse( opts: StreamRenderOptions, init: ResponseInit = {}, ): Response { const headers = new Headers(init.headers); if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8"); return new Response(renderDocumentStream(opts), { ...init, headers }); }