Files
WRNexusJS/packages/ssr/src/index.ts
T
2026-07-27 12:42:18 +05:30

311 lines
11 KiB
TypeScript

/**
* @wrnexus/ssr — server-side rendering.
*
* Pages return an HTML string for the body; this module wraps that body in a
* full document with a `<head>` 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<string, string | boolean>;
}
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 `<script type="module">` tags to load (e.g. per-island chunks or
* the reactive runtime). Only the scripts a page actually needs are passed.
*/
scripts?: RenderScript[];
/** Optional default document title used when meta.title is absent. */
defaultTitle?: string;
/** Raw HTML injected at the end of `<head>` (trusted, framework-controlled). */
extraHead?: string;
/** Raw HTML injected at the end of `<body>` (trusted, framework-controlled). */
extraBody?: string;
/** Attributes for the `<html>` element, e.g. ` data-theme="dark"` (trusted). */
htmlAttrs?: string;
/**
* Optional application-authored full document shell. It must contain
* `<html>`, `<head>`, and `<body>`. Framework metadata, assets, and scripts
* are merged into it instead of wrapping the rendered body again.
*/
documentTemplate?: string;
}
/**
* Render a complete HTML document.
*
* Metadata is HTML-escaped so a malicious title/description can never break
* out of its element or attribute.
*/
export function renderDocument(opts: RenderOptions): string {
const seo = mergeSeo(opts.seo, opts.meta, opts.defaultTitle, opts.url);
const title = escapeHtml(seo.title);
const scripts = normalizeScripts(opts.scripts ?? []);
const seoTags = renderSeoTags(seo);
const preloadTags = scripts
.filter((script) => (script.type ?? "module") === "module")
.map((script) => `\n <link rel="modulepreload" href="${escapeHtml(script.src)}" />`)
.join("");
const scriptTags = scripts.map((script) => `\n ${renderScriptTag(script)}`).join("");
const extraHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
? (opts.htmlAttrs ?? "")
: `${opts.htmlAttrs ?? ""} lang="en"`;
if (opts.documentTemplate) {
let document = opts.documentTemplate.trim();
if (!/^<!doctype\s+html>/i.test(document)) document = `<!doctype html>\n${document}`;
document = document.replace(/<html([^>]*)>/i, (_match, existing: string) => {
const language = /(?:^|\s)lang\s*=/.test(`${existing}${opts.htmlAttrs ?? ""}`)
? ""
: ' lang="en"';
return `<html${existing}${opts.htmlAttrs ?? ""}${language}>`;
});
const headContent = `
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>${title}</title>${seoTags}${preloadTags}${extraHead}`;
document = document.replace(/<head([^>]*)>/i, `<head$1>${headContent}`);
document = document.replace(/<\/body>/i, `${scriptTags}${extraBody}\n </body>`);
return `${document}\n`;
}
return `<!doctype html>
<html${htmlAttrs}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>${title}</title>${seoTags}${preloadTags}${extraHead}
</head>
<body>
<div id="app">${opts.body}</div>${scriptTags}${extraBody}
</body>
</html>
`;
}
function normalizeScripts(scripts: readonly RenderScript[]): ScriptAsset[] {
const bySrc = new Map<string, ScriptAsset>();
for (const script of scripts) {
const normalized: ScriptAsset = typeof script === "string" ? { src: script } : script;
if (!normalized.src || bySrc.has(normalized.src)) continue;
bySrc.set(normalized.src, normalized);
}
return [...bySrc.values()];
}
function renderScriptTag(script: ScriptAsset): string {
const type = script.type ?? "module";
const attrs: string[] = [];
if (type === "module") attrs.push('type="module"');
if (script.async) attrs.push("async");
if (script.defer || (type === "classic" && !script.async)) attrs.push("defer");
attrs.push(`src="${escapeHtml(script.src)}"`);
if (script.integrity) attrs.push(`integrity="${escapeHtml(script.integrity)}"`);
if (script.crossOrigin) attrs.push(`crossorigin="${escapeHtml(script.crossOrigin)}"`);
if (script.nonce) attrs.push(`nonce="${escapeHtml(script.nonce)}"`);
const reserved = new Set(["src", "type", "async", "defer", "integrity", "crossorigin", "nonce"]);
for (const [name, value] of Object.entries(script.attributes ?? {})) {
if (
reserved.has(name.toLowerCase()) ||
!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(name) ||
value === false
) {
continue;
}
attrs.push(value === true ? name : `${name}="${escapeHtml(value)}"`);
}
return `<script ${attrs.join(" ")}></script>`;
}
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(`<link rel="canonical" href="${escapeHtml(seo.canonical)}" />`);
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 `<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
}
function property(name: string, content: string): string {
return `<meta property="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
}
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<RenderOptions, "body"> {
body: string | Promise<string> | AsyncIterable<string>;
}
function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
return (
typeof value === "object" &&
value !== null &&
Symbol.asyncIterator in value &&
typeof (value as AsyncIterable<string>)[Symbol.asyncIterator] === "function"
);
}
async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable<string> {
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<Uint8Array> {
const marker = "<!--__WRNEXUS_STREAM_BODY__-->";
const document = renderDocument({ ...opts, body: marker });
const [prefix, suffix] = document.split(marker);
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
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 });
}