Module

x/fresh/src/server/render.ts

The next-gen web framework.
Extremely Popular
Go to Latest
File
import { renderToString } from "preact-render-to-string";import { ComponentChildren, ComponentType, h, options } from "preact";import { AppModule, ErrorPage, Island, Plugin, PluginRenderFunctionResult, PluginRenderResult, PluginRenderStyleTag, RenderFunction, Route, UnknownPage,} from "./types.ts";import { HEAD_CONTEXT } from "../runtime/head.ts";import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts";import { ContentSecurityPolicy } from "../runtime/csp.ts";import { bundleAssetUrl } from "./constants.ts";import { assetHashingHook } from "../runtime/utils.ts";import { htmlEscapeJsonString } from "./htmlescape.ts";
export interface RenderOptions<Data> { route: Route<Data> | UnknownPage | ErrorPage; islands: Island[]; plugins: Plugin[]; app: AppModule; imports: string[]; preloads: string[]; url: URL; params: Record<string, string | string[]>; renderFn: RenderFunction; data?: Data; error?: unknown; lang?: string;}
export type InnerRenderFunction = () => string;
export class RenderContext { #id: string; #state: Map<string, unknown> = new Map(); #styles: string[] = []; #url: URL; #route: string; #lang: string;
constructor(id: string, url: URL, route: string, lang: string) { this.#id = id; this.#url = url; this.#route = route; this.#lang = lang; }
/** A unique ID for this logical JIT render. */ get id(): string { return this.#id; }
/** * State that is persisted between multiple renders with the same render * context. This is useful because one logical JIT render could have multiple * preact render passes due to suspense. */ get state(): Map<string, unknown> { return this.#state; }
/** * All of the CSS style rules that should be inlined into the document. * Adding to this list across multiple renders is supported (even across * suspense!). The CSS rules will always be inserted on the client in the * order specified here. */ get styles(): string[] { return this.#styles; }
/** The URL of the page being rendered. */ get url(): URL { return this.#url; }
/** The route matcher (e.g. /blog/:id) that the request matched for this page * to be rendered. */ get route(): string { return this.#route; }
/** The language of the page being rendered. Defaults to "en". */ get lang(): string { return this.#lang; } set lang(lang: string) { this.#lang = lang; }}
function defaultCsp() { return { directives: { defaultSrc: [NONE], styleSrc: [UNSAFE_INLINE] }, reportOnly: false, };}
/** * This function renders out a page. Rendering is synchronous and non streaming. * Suspense boundaries are not supported. */export async function render<Data>( opts: RenderOptions<Data>,): Promise<[string, ContentSecurityPolicy | undefined]> { const props: Record<string, unknown> = { params: opts.params, url: opts.url, route: opts.route.pattern, data: opts.data, }; if (opts.error) { props.error = opts.error; }
const csp: ContentSecurityPolicy | undefined = opts.route.csp ? defaultCsp() : undefined; const headComponents: ComponentChildren[] = [];
const vnode = h(CSP_CONTEXT.Provider, { value: csp, children: h(HEAD_CONTEXT.Provider, { value: headComponents, children: h(opts.app.default, { Component() { return h(opts.route.component! as ComponentType<unknown>, props); }, }), }), });
const ctx = new RenderContext( crypto.randomUUID(), opts.url, opts.route.pattern, opts.lang ?? "en", );
if (csp) { // Clear the csp const newCsp = defaultCsp(); csp.directives = newCsp.directives; csp.reportOnly = newCsp.reportOnly; } // Clear the head components headComponents.splice(0, headComponents.length);
// Setup the interesting VNode types ISLANDS.splice(0, ISLANDS.length, ...opts.islands);
// Clear the encountered vnodes ENCOUNTERED_ISLANDS.clear();
// Clear the island props ISLAND_PROPS = [];
let bodyHtml: string | null = null;
function realRender(): string { bodyHtml = renderToString(vnode); return bodyHtml; }
const plugins = opts.plugins.filter((p) => p.render !== null); const renderResults: [Plugin, PluginRenderResult][] = [];
function render(): PluginRenderFunctionResult { const plugin = plugins.shift(); if (plugin) { const res = plugin.render!({ render }); if (res === undefined) { throw new Error( `${plugin?.name}'s render hook did not return a PluginRenderResult object.`, ); } renderResults.push([plugin, res]); } else { realRender(); } if (bodyHtml === null) { throw new Error( `The 'render' function was not called by ${plugin?.name}'s render hook.`, ); } return { htmlText: bodyHtml, requiresHydration: ENCOUNTERED_ISLANDS.size > 0, }; }
await opts.renderFn(ctx, () => render().htmlText);
if (bodyHtml === null) { throw new Error("The `render` function was not called by the renderer."); }
bodyHtml = bodyHtml as string;
const imports = opts.imports.map((url) => { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } return [url, randomNonce] as const; });
const state: [islands: unknown[], plugins: unknown[]] = [ISLAND_PROPS, []]; const styleTags: PluginRenderStyleTag[] = [];
let script = `const STATE_COMPONENT = document.getElementById("__FRSH_STATE");const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");`;
for (const [plugin, res] of renderResults) { for (const hydrate of res.scripts ?? []) { const i = state[1].push(hydrate.state) - 1; const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } const url = bundleAssetUrl( `/plugin-${plugin.name}-${hydrate.entrypoint}.js`, ); imports.push([url, randomNonce] as const);
script += `import p${i} from "${url}";p${i}(STATE[1][${i}]);`; } styleTags.splice(styleTags.length, 0, ...res.styles ?? []); }
if (ENCOUNTERED_ISLANDS.size > 0) { // Load the main.js script { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } const url = bundleAssetUrl("/main.js"); imports.push([url, randomNonce] as const); }
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
// Prepare the inline script that loads and revives the islands let islandRegistry = ""; for (const island of ENCOUNTERED_ISLANDS) { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } const url = bundleAssetUrl(`/island-${island.id}.js`); imports.push([url, randomNonce] as const); script += `import ${island.name} from "${url}";`; islandRegistry += `${island.id}:${island.name},`; } script += `revive({${islandRegistry}}, STATE[0]);`; }
if (state[0].length > 0 || state[1].length > 0) { // Append state to the body bodyHtml += `<script id="__FRSH_STATE" type="application/json">${ htmlEscapeJsonString(JSON.stringify(state)) }</script>`;
// Append the inline script to the body const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } bodyHtml += `<script type="module" nonce="${randomNonce}">${script}</script>`; }
if (ctx.styles.length > 0) { const node = h("style", { id: "__FRSH_STYLE", dangerouslySetInnerHTML: { __html: ctx.styles.join("\n") }, }); headComponents.splice(0, 0, node); }
for (const style of styleTags) { const node = h("style", { id: style.id, dangerouslySetInnerHTML: { __html: style.cssText }, media: style.media, }); headComponents.splice(0, 0, node); }
const html = template({ bodyHtml, headComponents, imports, preloads: opts.preloads, lang: ctx.lang, });
return [html, csp];}
export interface TemplateOptions { bodyHtml: string; headComponents: ComponentChildren[]; imports: (readonly [string, string])[]; preloads: string[]; lang: string;}
export function template(opts: TemplateOptions): string { const page = h( "html", { lang: opts.lang }, h( "head", null, h("meta", { charSet: "UTF-8" }), h("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0", }), opts.preloads.map((src) => h("link", { rel: "modulepreload", href: src }) ), opts.imports.map(([src, nonce]) => h("script", { src: src, nonce: nonce, type: "module" }) ), opts.headComponents, ), h("body", { dangerouslySetInnerHTML: { __html: opts.bodyHtml } }), ); return "<!DOCTYPE html>" + renderToString(page);}
// Set up a preact option hook to track when vnode with custom functions are// created.const ISLANDS: Island[] = [];const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);let ISLAND_PROPS: unknown[] = [];const originalHook = options.vnode;let ignoreNext = false;options.vnode = (vnode) => { assetHashingHook(vnode); const originalType = vnode.type as ComponentType<unknown>; if (typeof vnode.type === "function") { const island = ISLANDS.find((island) => island.component === originalType); if (island) { if (ignoreNext) { ignoreNext = false; return; } ENCOUNTERED_ISLANDS.add(island); vnode.type = (props) => { ignoreNext = true; const child = h(originalType, props); ISLAND_PROPS.push(props); return h( `!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`, null, child, ); }; } } if (originalHook) originalHook(vnode);};