import type { ComponentHandle, FrameHandle, Key, RemixNode } from './component.ts' import type { ElementType, ElementProps, RemixElement } from './jsx.ts' import { Fragment, createComponent, createFrameHandle, Frame } from './component.ts' import { isEntry, type EntryComponent } from './client-entries.ts' import { normalizeSvgAttribute } from './svg-attributes.ts' interface VNode { type: ElementType props: ElementProps key?: Key _handle?: ComponentHandle _parent?: VNode } export function createVNode(type: ElementType, props: ElementProps, key?: Key): VNode { return { type, props, key } } /** * Options for server-side rendering to a byte stream. */ export interface RenderToStreamOptions { /** Source URL to associate with the current frame render. */ frameSrc?: string | URL /** Source URL for the top-level frame in nested frame renders. */ topFrameSrc?: string | URL /** Error hook invoked when rendering work throws. */ onError?: (error: unknown) => void /** Callback used to resolve nested frame content during streaming SSR. */ resolveFrame?: ( src: string, target?: string, context?: ResolveFrameContext, ) => Promise> | string | ReadableStream /** * Callback used to resolve runtime module metadata for client entry modules during SSR. */ resolveClientEntry?: ( entryId: string, component: EntryComponent, ) => Promise | ResolvedClientEntry } /** * Context passed to `resolveFrame` during server rendering. */ export interface ResolveFrameContext { /** Source URL for the frame currently being resolved. */ currentFrameSrc: string /** Source URL for the top-level frame in the current render. */ topFrameSrc: string } interface HydrationData { moduleUrl: string exportName: string props: Record } interface UnresolvedHydrationData { entryId: string component: EntryComponent props: Record } interface ResolvedClientEntry { href: string exportName: string } interface FrameData { status: 'pending' | 'resolved' name?: string src: string } interface RenderContext { insideSvg: boolean onError: (error: unknown) => void parentVNode?: VNode styleCache: Map resolveFrame: ( src: string, target?: string, context?: ResolveFrameContext, ) => Promise> | string | ReadableStream pendingFrames: Array<{ frameId: string; promise: Promise }> hydrationData: Map unresolvedHydrationData: Map frameData: Map blockingFrameTails: ReadableStream[] serverIdScope: string serverIdCounter: number } interface ResolvedFrameHtml { html: string tail?: ReadableStream } interface SsrFrameState { frame: FrameHandle topFrame: FrameHandle } type Segment = | { kind: 'static'; html: string } | { kind: 'composite'; parts: Segment[] } | { kind: 'frame' frameId: string content: Segment | null pending?: Promise } const SELF_CLOSING_TAGS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]) const NUMERIC_CSS_PROPS = new Set([ 'z-index', 'opacity', 'flex-grow', 'flex-shrink', 'flex-order', 'grid-area', 'grid-row', 'grid-column', 'font-weight', 'line-height', 'order', 'orphans', 'widows', 'zoom', 'columns', 'column-count', ]) const FRAMEWORK_PROPS = new Set(['children', 'innerHTML', 'on', 'key', 'mix']) const ssrSignal = Object.freeze({ get aborted() { return false }, get reason() { return undefined }, get onabort() { return null }, set onabort(_: AbortSignal['onabort']) {}, addEventListener( _type: string, _listener: EventListenerOrEventListenerObject | null, _options?: AddEventListenerOptions | boolean, ) {}, removeEventListener( _type: string, _listener: EventListenerOrEventListenerObject | null, _options?: EventListenerOptions | boolean, ) {}, dispatchEvent(_event: Event) { return true }, throwIfAborted() {}, }) as AbortSignal /** * Renders a node tree to a streaming HTML response body. * * @param node Node tree to render. * @param options Stream rendering options. * @returns A readable byte stream of HTML. */ export function renderToStream( node: RemixNode, options?: RenderToStreamOptions, ): ReadableStream { let encoder = new TextEncoder() let onError = options?.onError ?? ((error) => console.error(error)) let currentFrameSrc = normalizeFrameSrc(options?.frameSrc ?? options?.topFrameSrc) let topFrameSrc = normalizeFrameSrc(options?.topFrameSrc ?? currentFrameSrc) let rootFrameState = createSsrFrameState(currentFrameSrc, topFrameSrc) let context: RenderContext = { insideSvg: false, onError, resolveFrame: options?.resolveFrame ?? defaultResolveFrame, styleCache: new Map(), pendingFrames: [], hydrationData: new Map(), unresolvedHydrationData: new Map(), frameData: new Map(), blockingFrameTails: [], serverIdScope: crypto.randomUUID().slice(0, 8), serverIdCounter: 0, } return new ReadableStream({ async start(controller) { try { let root = buildSegment(node, context, rootFrameState) await resolveBlocking(root) await resolveClientEntries(context, options?.resolveClientEntry) validateClientEntriesForHydration(context) let html = serializeSegment(root) let finalHtml = finalizeHtml(html, context) let bytes = encoder.encode(finalHtml) controller.enqueue(bytes) // If we have any tails from blocking frame streams, stream them now. // These contain nested non-blocking frame templates (or other follow-up chunks) // that must come after the initial document chunk. let tailPromise = context.blockingFrameTails.length > 0 ? streamByteStreams(context.blockingFrameTails, controller, context.onError) : Promise.resolve() // If we have pending non-blocking frames, stream them as they resolve let pendingPromise = context.pendingFrames.length > 0 ? streamPendingFrames(context, controller, encoder) : Promise.resolve() await Promise.all([tailPromise, pendingPromise]) controller.close() } catch (error) { onError(error) controller.error(error) } }, }) } function defaultResolveFrame(): never { throw new Error('No resolveFrame provided') } function normalizeFrameSrc(value?: string | URL): string { return value == null ? '' : String(value) } function createSsrFrameState(frameSrc: string, topFrameSrc = frameSrc): SsrFrameState { let topFrame = createFrameHandle({ src: topFrameSrc }) let frame = frameSrc === topFrameSrc ? topFrame : createFrameHandle({ src: frameSrc }) return { frame, topFrame } } function getResolveFrameContext(frameState: SsrFrameState): ResolveFrameContext { return { currentFrameSrc: frameState.frame.src, topFrameSrc: frameState.topFrame.src, } } function randomId(prefix: string): string { return prefix + crypto.randomUUID().slice(0, 8) } function createServerComponentId(context: RenderContext): string { context.serverIdCounter++ return `s${context.serverIdScope}-${context.serverIdCounter}` } async function splitFirstChunk( stream: ReadableStream, ): Promise<{ first: Uint8Array; tail: ReadableStream }> { let reader = stream.getReader() let { value, done } = await reader.read() if (done || !value) { reader.releaseLock() return { first: new Uint8Array(), tail: new ReadableStream({ start(controller) { controller.close() }, }), } } let released = false function release() { if (released) return released = true try { reader.releaseLock() } catch { // ignore } } let tail = new ReadableStream({ async pull(controller) { let next = await reader.read() if (next.done) { controller.close() release() return } controller.enqueue(next.value) }, cancel(reason) { release() return reader.cancel(reason) }, }) return { first: value, tail } } async function resolveFrameHtml( input: string | ReadableStream, ): Promise { if (typeof input === 'string') return { html: input } let decoder = new TextDecoder() let { first, tail } = await splitFirstChunk(input) return { html: decoder.decode(first), tail } } function isRemixElement(node: unknown): node is RemixElement { return typeof node === 'object' && node !== null && '$rmx' in node } function staticSeg(html: string): Segment { return { kind: 'static', html } } function compositeSeg(parts: Segment[]): Segment { return { kind: 'composite', parts } } function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFrameState): Segment { if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') { return staticSeg(escapeTextContent(String(node))) } if (node === null || node === undefined || typeof node === 'boolean') { return staticSeg('') } if (Array.isArray(node)) { return compositeSeg(node.map((child) => buildSegment(child, context, frameState))) } if (isRemixElement(node)) { let type = node.type let props = node.props if (type === Fragment) { let children = props.children return children != null ? buildSegment(children, context, frameState) : staticSeg('') } if (typeof type === 'string') { let tag = type if (tag === 'html') { return buildElementSegment(tag, props, context, frameState) } if (tag === 'head') { return buildHeadElementSegment(tag, props, context, frameState) } return buildElementSegment(tag, props, context, frameState) } if (typeof type === 'function') { if (type === Frame) { return buildFrameSegment(props, context, frameState) } if (isEntry(type)) { return buildEntrySegment(type, props, context, frameState) } return buildComponentSegment( type, props, context, createServerComponentId(context), frameState, ) } } return staticSeg('') } function buildFrameSegment(props: any, context: RenderContext, frameState: SsrFrameState): Segment { let frameId = randomId('f') // Store frame data in context for aggregation context.frameData.set(frameId, { status: props.fallback ? 'pending' : 'resolved', name: props.name, src: props.src, }) let seg: Segment = { kind: 'frame', frameId, content: null, } let resolveFrameContext = getResolveFrameContext(frameState) let nonBlocking = !!props.fallback if (nonBlocking) { seg.content = buildSegment(props.fallback, context, frameState) let framePromise = Promise.resolve( context.resolveFrame(props.src, props.name, resolveFrameContext), ).then(async (resolved) => resolveFrameHtml(resolved)) context.pendingFrames.push({ frameId, promise: framePromise }) } else { seg.pending = Promise.resolve( context.resolveFrame(props.src, props.name, resolveFrameContext), ).then(async (resolved) => { let { html, tail } = await resolveFrameHtml(resolved) seg.content = staticSeg(html) if (tail) { context.blockingFrameTails.push(tail) } }) } return seg } function buildElementSegment( tag: string, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let mixedProps = resolveSsrMixedProps(tag, props, context, frameState) let processedProps = processStyleProps(mixedProps) // Determine namespace context for the current element and its children let currentIsSvg = context.insideSvg || tag === 'svg' let attrs = renderAttributes(processedProps, currentIsSvg) if (SELF_CLOSING_TAGS.has(tag)) { return staticSeg(`<${tag}${attrs} />`) } if (props.innerHTML) { return staticSeg(`<${tag}${attrs}>${props.innerHTML}`) } let open = staticSeg(`<${tag}${attrs}>`) // Adjust svg context for children: foreignObject switches back to HTML let previousInsideSvg = context.insideSvg context.insideSvg = tag === 'foreignObject' ? false : currentIsSvg let children = props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('') context.insideSvg = previousInsideSvg let close = staticSeg(``) return compositeSeg([open, children, close]) } function buildHeadElementSegment( tag: string, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let processedProps = processStyleProps(props) let attrs = renderAttributes(processedProps, false) let open = staticSeg(`<${tag}${attrs}>`) let children = props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('') let close = staticSeg(``) return compositeSeg([open, children, close]) } function renderAttributes(props: any, isSvg: boolean): string { let attrs = '' for (let key in props) { if (FRAMEWORK_PROPS.has(key)) continue let value = props[key] if (value === undefined || value === null || value === false) continue let attrName = transformAttributeName(key, isSvg) if (value === true) { attrs += ` ${attrName}` } else { attrs += ` ${attrName}="${escapeHtml(String(value))}"` } } return attrs } function resolveSsrMixedProps( hostType: string, initialProps: ElementProps, context: RenderContext, frameState: SsrFrameState, ): ElementProps { let descriptors = resolveSsrMixDescriptors(initialProps) if (descriptors.length === 0) return initialProps let composedProps = withoutSsrMix(initialProps) let mixinProps = withoutSsrMixinTreeProps(composedProps) let maxDescriptors = 1024 for (let index = 0; index < descriptors.length && index < maxDescriptors; index++) { let descriptor = descriptors[index] let runner = resolveSsrMixinRunner(hostType, descriptor, context, frameState) if (!runner) continue let result: unknown try { result = runner(...descriptor.args, mixinProps) } catch (error) { console.error(error) continue } if (!result) continue if (isSsrMixinElement(result)) continue let returnedDescriptors = resolveReturnedSsrMixDescriptors(result) if (returnedDescriptors) { for (let returned of returnedDescriptors) descriptors.push(returned) continue } if (!isRemixElement(result)) { console.error(new Error('mixins must return a remix element')) continue } let remixResult = result let resultType = typeof remixResult.type === 'string' ? remixResult.type : isSsrMixinElement(remixResult.type) ? remixResult.type.__rmxMixinElementType : null if (resultType !== hostType) { console.error(new Error('mixins must return an element with the same host type')) continue } if (remixResult.type !== resultType) { remixResult = { ...remixResult, type: resultType } } let nextProps = sanitizeReturnedSsrMixinProps(remixResult.props as ElementProps) let nestedDescriptors = resolveSsrMixDescriptors(nextProps) for (let nested of nestedDescriptors) descriptors.push(nested) composedProps = { ...composedProps, ...withoutSsrMix(nextProps) } mixinProps = withoutSsrMixinTreeProps(composedProps) } let nextMix = initialProps.mix return { ...composedProps, ...(nextMix === undefined ? {} : { mix: nextMix }), } } function resolveSsrMixinRunner( hostType: string, descriptor: { type?: unknown; args?: unknown[] }, context: RenderContext, frameState: SsrFrameState, ): ((...args: unknown[]) => unknown) | null { if (typeof descriptor.type !== 'function') return null try { let handle = createSsrMixinHandle(hostType, descriptor, context, frameState) let runner = descriptor.type(handle, hostType) if (typeof runner !== 'function') return null return runner } catch (error) { console.error(error) return null } } function createSsrMixinHandle( hostType: string, _descriptor: { type?: unknown }, context: RenderContext, frameState: SsrFrameState, ) { let element = ((_: { update(): Promise }, __: unknown) => (props: ElementProps) => ({ $rmx: true as const, type: hostType, key: null, props, })) as (( handle: { update(): Promise }, setup: unknown, ) => (props: ElementProps) => RemixElement) & { __rmxMixinElementType: string } element.__rmxMixinElementType = hostType return { id: 'ssr-mixin', context: { get(providerType: ElementType | symbol) { if (typeof providerType !== 'function') { return undefined } let current = context.parentVNode while (current) { if (current.type === providerType) { let providerHandle = current._handle if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, }, frame: createFrameHandle({ src: frameState.frame.src, $runtime: { styleCache: context.styleCache, }, }), element, signal: ssrSignal, update: () => { throw new Error('handle.update() is not available during SSR.') }, queueTask: () => {}, on: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true, } } function resolveSsrMixDescriptors(props: ElementProps): Array<{ type: any; args: unknown[] }> { let mix = props.mix if (!mix) return [] if (Array.isArray(mix)) { if (mix.length === 0) return [] return mix.filter(Boolean) as Array<{ type: any; args: unknown[] }> } return [mix] as Array<{ type: any; args: unknown[] }> } function withoutSsrMix(props: ElementProps): ElementProps { if (!('mix' in props)) return props let output = { ...props } delete output.mix return output } function withoutSsrMixinTreeProps(props: ElementProps): ElementProps { if (!('children' in props) && !('innerHTML' in props)) return props let output = { ...props } delete output.children delete output.innerHTML return output } function sanitizeReturnedSsrMixinProps(props: ElementProps): ElementProps { if (!('children' in props) && !('innerHTML' in props)) return props console.error(new Error('mixins must not return children or innerHTML')) return withoutSsrMixinTreeProps(props) } function resolveReturnedSsrMixDescriptors( value: unknown, ): Array<{ type: Function; args: unknown[] }> | null { let descriptors: Array<{ type: Function; args: unknown[] }> = [] if (!collectReturnedSsrMixDescriptors(value, descriptors)) { return null } return descriptors } function collectReturnedSsrMixDescriptors( value: unknown, output: Array<{ type: Function; args: unknown[] }>, ): boolean { if (!value) { return true } if (Array.isArray(value)) { for (let item of value) { if (!collectReturnedSsrMixDescriptors(item, output)) { return false } } return true } if (!isSsrMixinDescriptor(value)) { return false } output.push(value) return true } function isSsrMixinElement( value: unknown, ): value is ((...args: unknown[]) => unknown) & { __rmxMixinElementType: string } { if (typeof value !== 'function') return false return '__rmxMixinElementType' in value } function isSsrMixinDescriptor(value: unknown): value is { type: Function; args: unknown[] } { if (!value || typeof value !== 'object' || isRemixElement(value)) { return false } let descriptor = value as { type?: unknown; args?: unknown } return typeof descriptor.type === 'function' && Array.isArray(descriptor.args) } function buildComponentSegment( type: Function, props: any, context: RenderContext, componentId: string, frameState: SsrFrameState, ): Segment { let vnode = createVNode(type, props) if (context.parentVNode) { vnode._parent = context.parentVNode } let handle = createComponent({ id: componentId, type: type, frame: frameState.frame, signal: ssrSignal, getContext(providerType) { let current = vnode._parent while (current) { if (current.type === providerType) { let providerHandle = current._handle // TODO: need better vnode types to avoid defensive checks if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, getFrameByName() { return undefined }, getTopFrame() { return frameState.topFrame }, }) vnode._handle = handle let [renderedNode] = handle.render(props) let childContext = { ...context, parentVNode: vnode } return buildSegment(renderedNode, childContext, frameState) } function createHydrationPropsReplacer(context: RenderContext, frameState: SsrFrameState) { function unwrapNode(node: RemixNode): unknown { if (node === null || node === undefined || typeof node === 'boolean') return node if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') { return node } if (Array.isArray(node)) { return node.map((child) => unwrapNode(child)) } if (isRemixElement(node)) { return unwrapElement(node) } return node } function unwrapElement(element: RemixElement): unknown { let type = element.type let props = element.props // Preserve Frame semantics through serialized props by emitting // a dedicated descriptor that can be revived on the client. if (type === Frame) { return { $rmxFrame: true, props: transformProps(props), key: element.key, } } // If it's a DOM tag, return a serializable shape with transformed props if (typeof type === 'string') { return { $rmx: true, type, props: transformProps(props) } } // Component function: render synchronously, then unwrap its result if (typeof type === 'function') { let vnode = createVNode(type, props) if (context.parentVNode) { vnode._parent = context.parentVNode } let handle = createComponent({ id: 'SERIALIZED', type: type, frame: frameState.frame, signal: ssrSignal, getContext(providerType) { let current = vnode._parent while (current) { if (current.type === providerType) { let providerHandle = current._handle if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, getFrameByName() { return undefined }, getTopFrame() { return frameState.topFrame }, }) vnode._handle = handle let [renderedNode] = handle.render(props) return unwrapNode(renderedNode) } return null } function transformProps(input: ElementProps): Record { let out: Record = {} for (let key in input) { let value = input[key] if (key === 'children') { out[key] = unwrapNode(value) } else { if (isRemixElement(value)) { out[key] = unwrapNode(value) } else if (Array.isArray(value)) { out[key] = value.map((v) => unwrapNode(v)) } else { out[key] = value } } } return out } return function replacer(_key: string, value: unknown) { if (isRemixElement(value)) { return unwrapElement(value) } if (Array.isArray(value)) { return value.map((v) => unwrapNode(v)) } return value } } function buildEntrySegment( type: EntryComponent, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let instanceId = randomId('h') let rendered = buildComponentSegment(type, props, context, instanceId, frameState) // Store hydration data in context for aggregation let replacer = createHydrationPropsReplacer(context, frameState) context.unresolvedHydrationData.set(instanceId, { entryId: type.$entryId, component: type, props: JSON.parse(JSON.stringify(props, replacer)), }) let start = staticSeg(``) let end = staticSeg('') return compositeSeg([start, rendered, end]) } function resolveDefaultClientEntry( entryId: string, component: EntryComponent, ): ResolvedClientEntry { let fallbackExportName = component.name || '' let hashIndex = entryId.lastIndexOf('#') if (hashIndex === -1 && fallbackExportName) { return { exportName: fallbackExportName, href: entryId, } } if (hashIndex !== -1) { let exportName = entryId.slice(hashIndex + 1) || fallbackExportName if (exportName) { return { exportName, href: entryId.slice(0, hashIndex), } } } throw new Error( `clientEntry() requires either an export name in the entry ID (e.g., "/js/module.js#ComponentName"), a named component function, or a resolveClientEntry hook that resolves one. Received "${entryId}".`, ) } async function resolveClientEntries( context: RenderContext, resolveClientEntry?: ( entryId: string, component: EntryComponent, ) => Promise | ResolvedClientEntry, ): Promise { if (context.unresolvedHydrationData.size === 0) return let resolvedEntries = new Map() for (let [hydrationId, unresolvedHydrationData] of context.unresolvedHydrationData) { let { entryId, component, props } = unresolvedHydrationData let resolvedEntry = resolvedEntries.get(entryId) if (!resolvedEntry) { resolvedEntry = resolveClientEntry ? await Promise.resolve(resolveClientEntry(entryId, component)) : resolveDefaultClientEntry(entryId, component) validateResolvedClientEntry(entryId, resolvedEntry) resolvedEntries.set(entryId, resolvedEntry) } context.hydrationData.set(hydrationId, { exportName: resolvedEntry.exportName, moduleUrl: resolvedEntry.href, props, }) } context.unresolvedHydrationData.clear() } function validateResolvedClientEntry( entryId: string, resolvedEntry: ResolvedClientEntry, ): asserts resolvedEntry is ResolvedClientEntry { if (!resolvedEntry || typeof resolvedEntry !== 'object') { throw new Error( `resolveClientEntry must return an object with href and exportName. Received "${entryId}".`, ) } if (!resolvedEntry.href) { throw new Error(`resolveClientEntry must return a non-empty href. Received "${entryId}".`) } if (!resolvedEntry.exportName) { throw new Error(`resolveClientEntry must return a non-empty exportName. Received "${entryId}".`) } } function validateClientEntriesForHydration(context: RenderContext): void { if (context.unresolvedHydrationData.size > 0) { let [hydrationId, unresolvedHydrationData] = context.unresolvedHydrationData.entries().next() .value as [string, UnresolvedHydrationData] throw new Error( `Client entry was not resolved for hydration. Received "${unresolvedHydrationData.entryId}" (${hydrationId}).`, ) } } // Resolve all blocking frame content once async function resolveBlocking(segment: Segment): Promise { if (segment.kind === 'frame') { if (segment.pending) { await segment.pending segment.pending = undefined } if (segment.content) await resolveBlocking(segment.content) return } if (segment.kind === 'composite') { for (let part of segment.parts) { await resolveBlocking(part) } } } // Serialize the segment tree to HTML function serializeSegment(seg: Segment): string { if (seg.kind === 'static') return seg.html if (seg.kind === 'composite') return seg.parts.map(serializeSegment).join('') // frame let inner = seg.content ? serializeSegment(seg.content) : '' let start = `` let end = `` return start + inner + end } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function escapeTextContent(str: string): string { return str.replace(/&/g, '&').replace(//g, '>') } function escapeTemplateContent(html: string): string { return html.replace(/<\/template/gi, '<\\/template') } function transformAttributeName(name: string, isSvg: boolean): string { // aria-/data- pass through if (name.startsWith('aria-') || name.startsWith('data-')) return name // HTML mappings if (name === 'className') return 'class' if (!isSvg) { if (name === 'htmlFor') return 'for' if (name === 'tabIndex') return 'tabindex' if (name === 'acceptCharset') return 'accept-charset' if (name === 'httpEquiv') return 'http-equiv' return name.toLowerCase() } return normalizeSvgAttribute(name).attr } function finalizeHtml(html: string, context: RenderContext): string { let hasHtmlRoot = html.trimStart().toLowerCase().startsWith('${css}` if (hasHtmlRoot) { // For HTML root, inject into existing head or create one let headCloseIndex = html.indexOf('') if (headCloseIndex !== -1) { // Inject before existing html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex) } else { // No existing head, inject after let htmlOpenMatch = html.match(/]*>/) if (htmlOpenMatch) { let insertIndex = htmlOpenMatch.index! + htmlOpenMatch[0].length html = html.slice(0, insertIndex) + `${headContent}` + html.slice(insertIndex) } } } else { // No HTML root, prepend head html = `${headContent}${html}` } } // Append aggregated hydration/frame data script at the end let rmxData = buildRmxDataScript(context) if (rmxData) { if (hasHtmlRoot) { // Insert before if present, otherwise before let bodyCloseIndex = html.indexOf('') if (bodyCloseIndex !== -1) { html = html.slice(0, bodyCloseIndex) + rmxData + html.slice(bodyCloseIndex) } else { let htmlCloseIndex = html.indexOf('') if (htmlCloseIndex !== -1) { html = html.slice(0, htmlCloseIndex) + rmxData + html.slice(htmlCloseIndex) } else { html += rmxData } } } else { html += rmxData } } return html } function processStyleProps(props: any): any { let processedProps = { ...props } let classAttr = typeof props.class === 'string' ? props.class : '' let className = typeof props.className === 'string' ? props.className : '' let mergedClassName = [classAttr, className].filter(Boolean).join(' ') if (mergedClassName) { processedProps.className = mergedClassName delete processedProps.class } if (typeof props.style === 'object') { processedProps.style = serializeStyleObject(props.style) } return processedProps } function collectAllStyles(context: RenderContext): string { if (context.styleCache.size === 0) return '' let allCss = '' for (let { css } of context.styleCache.values()) { allCss += css + '\n' } return `@layer rmx { ${allCss.trim()} }` } function buildRmxDataScript(context: RenderContext): string { if (context.hydrationData.size === 0 && context.frameData.size === 0) { return '' } let data: { h?: Record f?: Record } = {} if (context.hydrationData.size > 0) { data.h = Object.fromEntries(context.hydrationData) } if (context.frameData.size > 0) { data.f = Object.fromEntries(context.frameData) } let serializedData = escapeScriptJson(JSON.stringify(data)) return `` } function escapeScriptJson(json: string): string { // Avoid prematurely closing the script tag when serialized data contains "". return json.replace(/): string { let parts: string[] = [] for (let [key, value] of Object.entries(style)) { if (value == null) continue if (typeof value === 'boolean') continue if (typeof value === 'number' && !Number.isFinite(value)) continue // Convert camelCase to kebab-case let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) // Add px to numeric values where appropriate let shouldAppendPx = typeof value === 'number' && value !== 0 && !NUMERIC_CSS_PROPS.has(cssKey) && !cssKey.startsWith('--') let cssValue = shouldAppendPx ? `${value}px` : Array.isArray(value) ? value.join(', ') : String(value) parts.push(`${cssKey}: ${cssValue};`) } return parts.join(' ') } // Frame styles work end-to-end when frame handlers use their own `renderToStream`: // the handler's `finalizeHtml` emits `