UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

552 lines (494 loc) 16.6 kB
import { $inject, Alepha } from "alepha"; import { $logger } from "alepha/logger"; import { AlephaContext } from "alepha/react"; import type { SimpleHead } from "alepha/react/head"; import { createElement, type ReactNode } from "react"; import { renderToString } from "react-dom/server"; import ErrorViewer from "../components/ErrorViewer.tsx"; import { Redirection } from "../errors/Redirection.ts"; import type { ReactRouterState } from "./ReactPageProvider.ts"; /** * Handles HTML streaming for SSR. * * Uses hardcoded HTML structure - all customization via $head primitive. * Pre-encodes static parts as Uint8Array for zero-copy streaming. */ export class ReactServerTemplateProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); /** * Shared TextEncoder - reused across all requests. */ protected readonly encoder = new TextEncoder(); /** * Pre-encoded static HTML parts for zero-copy streaming. */ protected readonly SLOTS = { DOCTYPE: this.encoder.encode("<!DOCTYPE html>\n"), HTML_OPEN: this.encoder.encode("<html"), HTML_CLOSE: this.encoder.encode(">\n"), HEAD_OPEN: this.encoder.encode("<head>"), HEAD_CLOSE: this.encoder.encode("</head>\n"), BODY_OPEN: this.encoder.encode("<body"), BODY_CLOSE: this.encoder.encode(">\n"), ROOT_OPEN: this.encoder.encode('<div id="root">'), ROOT_CLOSE: this.encoder.encode("</div>\n"), BODY_HTML_CLOSE: this.encoder.encode("</body>\n</html>"), HYDRATION_PREFIX: this.encoder.encode( '<script id="__ssr" type="application/json">', ), HYDRATION_SUFFIX: this.encoder.encode("</script>"), } as const; /** * Early head content (charset, viewport, entry assets). * Set once during configuration, reused for all requests. */ protected earlyHeadContent = ""; /** * Root element ID for React mounting. */ public readonly rootId = "root"; /** * Regex for extracting root div content from HTML. */ public readonly rootDivRegex = new RegExp( `<div[^>]*\\s+id=["']${this.rootId}["'][^>]*>([\\s\\S]*?)<\\/div>`, "i", ); /** * Extract content inside the root div from HTML. */ public extractRootContent(html: string): string | undefined { return html.match(this.rootDivRegex)?.[1]; } /** * Set early head content (charset, viewport, entry assets). * Called once during server configuration. */ public setEarlyHeadContent( entryAssets: string, globalHead?: SimpleHead, ): void { const charset = globalHead?.charset ?? "UTF-8"; const viewport = globalHead?.viewport ?? "width=device-width, initial-scale=1"; this.earlyHeadContent = `<meta charset="${this.escapeHtml(charset)}">\n` + `<meta name="viewport" content="${this.escapeHtml(viewport)}">\n` + entryAssets; } /** * Render attributes record to HTML string. */ public renderAttributes(attrs?: Record<string, string>): string { if (!attrs) return ""; const entries = Object.entries(attrs); if (entries.length === 0) return ""; return entries .map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`) .join(""); } /** * Render head content (title, meta, link, script tags). */ public renderHeadContent(head?: SimpleHead): string { if (!head) return ""; let content = ""; if (head.title) { content += `<title>${this.escapeHtml(head.title)}</title>\n`; } if (head.meta) { for (const meta of head.meta) { if (meta.property) { content += `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`; } else if (meta.name) { content += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`; } } } if (head.link) { for (const link of head.link) { content += `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`; if (link.type) content += ` type="${this.escapeHtml(link.type)}"`; if (link.as) content += ` as="${this.escapeHtml(link.as)}"`; if (link.crossorigin != null) content += ' crossorigin=""'; if (link.media) content += ` media="${this.escapeHtml(link.media)}"`; if (link.sizes) content += ` sizes="${this.escapeHtml(link.sizes)}"`; if (link.hreflang) content += ` hreflang="${this.escapeHtml(link.hreflang)}"`; content += ">\n"; } } if (head.script) { for (const script of head.script) { if (typeof script === "string") { content += `<script>${script}</script>\n`; } else { const { content: scriptContent, ...rest } = script; const attrs = Object.entries(rest) .filter(([, v]) => v !== false && v !== undefined) .map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`, ) .join(" "); content += scriptContent ? `<script ${attrs}>${scriptContent}</script>\n` : `<script ${attrs}></script>\n`; } } } return content; } /** * Escape HTML special characters. */ public escapeHtml(str: string): string { return str .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } /** * Safely serialize data to JSON for embedding in HTML. */ public safeJsonSerialize(data: unknown): string { return JSON.stringify(data) .replace(/</g, "\\u003c") .replace(/>/g, "\\u003e") .replace(/&/g, "\\u0026"); } /** * Build hydration data from router state. */ public buildHydrationData(state: ReactRouterState): HydrationData { const layers = state.layers.map((layer) => ({ part: layer.part, name: layer.name, config: layer.config, props: layer.props, error: layer.error ? { ...layer.error, name: layer.error.name, message: layer.error.message, stack: !this.alepha.isProduction() ? layer.error.stack : undefined, } : undefined, })); return { "alepha.react.router.layers": layers, ...this.alepha.store.exportAtoms("current"), }; } // --------------------------------------------------------------------------- // Core streaming methods // --------------------------------------------------------------------------- /** * Pipe React stream to controller with backpressure handling. * Returns true if stream completed successfully, false if error occurred. */ protected async pipeReactStream( controller: ReadableStreamDefaultController<Uint8Array>, reactStream: ReadableStream<Uint8Array>, state: ReactRouterState, ): Promise<boolean> { const reader = reactStream.getReader(); try { while (true) { // Backpressure: wait if buffer is full if (controller.desiredSize !== null && controller.desiredSize <= 0) { await new Promise<void>((resolve) => queueMicrotask(resolve)); } const { done, value } = await reader.read(); if (done) break; controller.enqueue(value); } return true; } catch (error) { this.log.error("React stream error", error); controller.enqueue( this.encoder.encode( this.renderErrorToString( error instanceof Error ? error : new Error(String(error)), state, ), ), ); return false; } finally { reader.releaseLock(); } } /** * Stream complete HTML document (head already closed). * Used by both createHtmlStream and late phase of createEarlyHtmlStream. */ protected async streamBodyAndClose( controller: ReadableStreamDefaultController<Uint8Array>, reactStream: ReadableStream<Uint8Array>, state: ReactRouterState, hydration: boolean, ): Promise<void> { const { encoder, SLOTS: slots } = this; // Body open controller.enqueue(slots.BODY_OPEN); controller.enqueue( encoder.encode(this.renderAttributes(state.head?.bodyAttributes)), ); controller.enqueue(slots.BODY_CLOSE); // Root + React content controller.enqueue(slots.ROOT_OPEN); await this.pipeReactStream(controller, reactStream, state); controller.enqueue(slots.ROOT_CLOSE); // Hydration if (hydration) { controller.enqueue(slots.HYDRATION_PREFIX); controller.enqueue( encoder.encode(this.safeJsonSerialize(this.buildHydrationData(state))), ); controller.enqueue(slots.HYDRATION_SUFFIX); } controller.enqueue(slots.BODY_HTML_CLOSE); } // --------------------------------------------------------------------------- // Public streaming APIs // --------------------------------------------------------------------------- /** * Create HTML stream with early head optimization. * * Flow: * 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE) * 2. Run async work (page loaders) * 3. Send rest of head, body, React content, hydration */ public createEarlyHtmlStream( globalHead: SimpleHead, asyncWork: () => Promise< | { state: ReactRouterState; reactStream: ReadableStream<Uint8Array> } | { redirect: string } | null >, options: { hydration?: boolean; state?: ReactRouterState; onError?: (error: unknown) => void; } = {}, ): ReadableStream<Uint8Array> { const { hydration = true, onError } = options; const { encoder, SLOTS: slots } = this; let headClosed = false; let bodyStarted = false; let routerState: ReactRouterState | undefined = options.state; return new ReadableStream<Uint8Array>({ start: async (controller) => { try { // === EARLY PHASE (before async work) === controller.enqueue(slots.DOCTYPE); controller.enqueue(slots.HTML_OPEN); controller.enqueue( encoder.encode(this.renderAttributes(globalHead?.htmlAttributes)), ); controller.enqueue(slots.HTML_CLOSE); controller.enqueue(slots.HEAD_OPEN); if (this.earlyHeadContent) { controller.enqueue(encoder.encode(this.earlyHeadContent)); } // === ASYNC WORK === const result = await asyncWork(); // Handle redirect if (!result || "redirect" in result) { if (result && "redirect" in result) { this.log.debug("Loader redirect, using meta refresh", { redirect: result.redirect, }); controller.enqueue( encoder.encode( `<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`, ), ); } controller.enqueue(slots.HEAD_CLOSE); controller.enqueue(encoder.encode("<body></body></html>")); controller.close(); return; } const { state, reactStream } = result; routerState = state; // === LATE PHASE (after async work) === controller.enqueue( encoder.encode(this.renderHeadContent(state.head)), ); controller.enqueue(slots.HEAD_CLOSE); headClosed = true; bodyStarted = true; await this.streamBodyAndClose( controller, reactStream, state, hydration, ); controller.close(); } catch (error) { onError?.(error); try { this.injectErrorHtml(controller, error, routerState, { headClosed, bodyStarted, }); controller.close(); } catch { controller.error(error); } } }, }); } /** * Create HTML stream (non-early version, for testing/prerender). */ public createHtmlStream( reactStream: ReadableStream<Uint8Array>, state: ReactRouterState, options: { hydration?: boolean; onError?: (error: unknown) => void } = {}, ): ReadableStream<Uint8Array> { const { hydration = true, onError } = options; const { encoder, SLOTS: slots } = this; return new ReadableStream<Uint8Array>({ start: async (controller) => { try { // Head controller.enqueue(slots.DOCTYPE); controller.enqueue(slots.HTML_OPEN); controller.enqueue( encoder.encode(this.renderAttributes(state.head?.htmlAttributes)), ); controller.enqueue(slots.HTML_CLOSE); controller.enqueue(slots.HEAD_OPEN); if (this.earlyHeadContent) { controller.enqueue(encoder.encode(this.earlyHeadContent)); } controller.enqueue( encoder.encode(this.renderHeadContent(state.head)), ); controller.enqueue(slots.HEAD_CLOSE); // Body (shared logic) await this.streamBodyAndClose( controller, reactStream, state, hydration, ); controller.close(); } catch (error) { onError?.(error); controller.error(error); } }, }); } // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- /** * Inject error HTML when streaming fails. */ protected injectErrorHtml( controller: ReadableStreamDefaultController<Uint8Array>, error: unknown, routerState: ReactRouterState | undefined, streamState: { headClosed: boolean; bodyStarted: boolean }, ): void { const { encoder, SLOTS: slots } = this; if (!streamState.headClosed) { controller.enqueue( encoder.encode(this.renderHeadContent(routerState?.head)), ); controller.enqueue(slots.HEAD_CLOSE); } if (!streamState.bodyStarted) { controller.enqueue(slots.BODY_OPEN); controller.enqueue( encoder.encode( this.renderAttributes(routerState?.head?.bodyAttributes), ), ); controller.enqueue(slots.BODY_CLOSE); controller.enqueue(slots.ROOT_OPEN); } controller.enqueue( encoder.encode( this.renderErrorToString( error instanceof Error ? error : new Error(String(error)), routerState, ), ), ); controller.enqueue(slots.ROOT_CLOSE); if (routerState) { controller.enqueue(slots.HYDRATION_PREFIX); controller.enqueue( encoder.encode( this.safeJsonSerialize(this.buildHydrationData(routerState)), ), ); controller.enqueue(slots.HYDRATION_SUFFIX); } controller.enqueue(slots.BODY_HTML_CLOSE); } /** * Render error to HTML string. */ protected renderErrorToString( error: Error, routerState: ReactRouterState | undefined, ): string { this.log.error("SSR rendering error", error); let errorElement: ReactNode; if (routerState?.onError) { try { const result = routerState.onError(error, routerState); if (result instanceof Redirection) { this.log.warn("Error handler returned Redirection but headers sent", { redirect: result.redirect, }); } else if (result != null) { errorElement = result; } } catch (handlerError) { this.log.error("Error handler threw", handlerError); } } if (!errorElement) { errorElement = createElement(ErrorViewer, { error, alepha: this.alepha, }); } const wrappedElement = createElement( AlephaContext.Provider, { value: this.alepha }, errorElement, ); try { return renderToString(wrappedElement); } catch (renderError) { this.log.error("Failed to render error component", renderError); return error.message; } } } /** * Hydration state serialized to a JSON script tag with id="__ssr" */ export interface HydrationData { "alepha.react.router.layers": Array<{ part?: string; name?: string; config?: Record<string, any>; props?: Record<string, any>; error?: { name: string; message: string; stack?: string }; }>; [key: string]: unknown; }