468 lines
17 KiB
TypeScript
468 lines
17 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 PartialPrerenderResult {
|
|
shell: string;
|
|
regions: Array<{ id: string; html: string }>;
|
|
}
|
|
|
|
/** Extract compiler-emitted dynamic regions into a cacheable static shell. */
|
|
export function partialPrerender(html: string, startIndex = 0): PartialPrerenderResult {
|
|
const regions: PartialPrerenderResult["regions"] = [];
|
|
const shell = html.replace(
|
|
/<wrn-dynamic-region\b[^>]*>([\s\S]*?)<\/wrn-dynamic-region>/gi,
|
|
(_whole, content: string) => {
|
|
const id = `wrn-region-${startIndex + regions.length}`;
|
|
regions.push({ id, html: content });
|
|
return `<template data-wrn-dynamic-placeholder="${id}"></template>`;
|
|
},
|
|
);
|
|
return { shell, regions };
|
|
}
|
|
|
|
/** Stream the static shell first, followed by inert region templates for client insertion. */
|
|
export function streamPartialDocument(
|
|
result: PartialPrerenderResult,
|
|
nonce?: string,
|
|
): ReadableStream<Uint8Array> {
|
|
const encoder = new TextEncoder();
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(result.shell));
|
|
for (const region of result.regions) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`<template data-wrn-dynamic-content="${region.id}">${region.html}</template><script${nonce ? ` nonce="${escapeHtml(nonce)}"` : ""}>(function(){var p=document.querySelector('template[data-wrn-dynamic-placeholder="${region.id}"]');var c=document.querySelector('template[data-wrn-dynamic-content="${region.id}"]');if(p&&c){p.replaceWith(c.content);c.remove()}})()</script>`,
|
|
),
|
|
);
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
/** CSP nonce applied to framework-promoted `.wrn` style blocks. */
|
|
styleNonce?: 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 sourceDocument = opts.documentTemplate?.trim();
|
|
const extracted = extractWrnexusStyles(sourceDocument ?? opts.body, opts.styleNonce);
|
|
const renderedBody = sourceDocument ? opts.body : extracted.html;
|
|
const renderedDocument = sourceDocument ? extracted.html : undefined;
|
|
|
|
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 globalHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
|
|
const localStyles = extracted.styles ? `\n ${extracted.styles}` : "";
|
|
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
|
|
|
|
const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
|
|
? (opts.htmlAttrs ?? "")
|
|
: `${opts.htmlAttrs ?? ""} lang="en"`;
|
|
|
|
if (renderedDocument) {
|
|
let document = renderedDocument;
|
|
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}${globalHead}`;
|
|
document = document.replace(/<head([^>]*)>/i, `<head$1>${headContent}`);
|
|
document = appendBeforeClosingTag(document, "head", localStyles);
|
|
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}${globalHead}${localStyles}
|
|
</head>
|
|
<body>
|
|
<div id="app">${renderedBody}</div>${scriptTags}${extraBody}
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
interface ExtractedStyle {
|
|
id: string;
|
|
owner: string;
|
|
kind: "layout" | "page" | "component";
|
|
css: string;
|
|
order: number;
|
|
}
|
|
|
|
interface ExtractedWrnexusStyles {
|
|
html: string;
|
|
styles: string;
|
|
}
|
|
|
|
const WRNEXUS_STYLE_RE = /<style\b([^>]*)>([\s\S]*?)<\/style\s*>/gi;
|
|
|
|
function appendBeforeClosingTag(document: string, tag: string, html: string): string {
|
|
if (!html) return document;
|
|
const closing = new RegExp(`</${tag}\\s*>`, "i");
|
|
return closing.test(document)
|
|
? document.replace(closing, `${html}\n </${tag}>`)
|
|
: `${document}${html}`;
|
|
}
|
|
|
|
function readHtmlAttribute(attributes: string, name: string): string | undefined {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const match = attributes.match(
|
|
new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"),
|
|
);
|
|
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
}
|
|
|
|
function stableStyleHash(value: string): string {
|
|
let hash = 0x811c9dc5;
|
|
for (let index = 0; index < value.length; index++) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
function normalizeStyleKind(value: string | undefined): ExtractedStyle["kind"] {
|
|
return value === "layout" || value === "page" ? value : "component";
|
|
}
|
|
|
|
function styleKindOrder(kind: ExtractedStyle["kind"]): number {
|
|
return kind === "layout" ? 0 : kind === "page" ? 1 : 2;
|
|
}
|
|
|
|
function escapeStyleAttribute(value: string): string {
|
|
return escapeHtml(value);
|
|
}
|
|
|
|
/**
|
|
* Promote compiler-emitted `.wrn` style blocks out of rendered markup and into
|
|
* the document head. They are emitted after global stylesheets, deduplicated by
|
|
* stable id, ordered layout -> page -> component, and nonce-tagged for CSP.
|
|
*/
|
|
export function extractWrnexusStyles(html: string, nonce?: string): ExtractedWrnexusStyles {
|
|
const found: ExtractedStyle[] = [];
|
|
let sourceOrder = 0;
|
|
|
|
const cleaned = html.replace(
|
|
WRNEXUS_STYLE_RE,
|
|
(whole: string, attributes: string, css: string) => {
|
|
const legacyOwner = readHtmlAttribute(attributes, "data-wrnexus-style");
|
|
const explicitId = readHtmlAttribute(attributes, "data-wrnexus-style-id");
|
|
if (!legacyOwner && !explicitId) return whole;
|
|
|
|
const owner =
|
|
readHtmlAttribute(attributes, "data-wrnexus-style-owner") ?? legacyOwner ?? "anonymous";
|
|
const kind = normalizeStyleKind(readHtmlAttribute(attributes, "data-wrnexus-style-kind"));
|
|
const id = explicitId ?? `wrn-${kind}-${stableStyleHash(`${kind}:${owner}`)}`;
|
|
|
|
found.push({ id, owner, kind, css: css.trim(), order: sourceOrder++ });
|
|
return "";
|
|
},
|
|
);
|
|
|
|
const byId = new Map<string, ExtractedStyle>();
|
|
for (const style of found) {
|
|
const current = byId.get(style.id);
|
|
if (!current) {
|
|
byId.set(style.id, style);
|
|
continue;
|
|
}
|
|
// Repeated mounts of one component carry identical CSS. Keep the latest
|
|
// body only when it differs while preserving the original cascade position.
|
|
if (current.css !== style.css) byId.set(style.id, { ...style, order: current.order });
|
|
}
|
|
|
|
const nonceAttribute = nonce ? ` nonce="${escapeStyleAttribute(nonce)}"` : "";
|
|
const styles = [...byId.values()]
|
|
.sort((left, right) => {
|
|
const kind = styleKindOrder(left.kind) - styleKindOrder(right.kind);
|
|
return kind || left.order - right.order;
|
|
})
|
|
.map(
|
|
(style) =>
|
|
`<style data-wrnexus-style-id="${escapeStyleAttribute(style.id)}" data-wrnexus-style-owner="${escapeStyleAttribute(style.owner)}" data-wrnexus-style-kind="${style.kind}"${nonceAttribute}>\n${style.css.replace(/<\/style/gi, "<\\/style")}\n</style>`,
|
|
)
|
|
.join("\n ");
|
|
|
|
return { html: cleaned, styles };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
export * from "./rpc.ts";
|
|
export * from "./store-context.ts";
|