alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
552 lines (494 loc) • 16.6 kB
text/typescript
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* 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;
}