Skip to content

Implement streaming HTML rendering #293

@brainkim

Description

@brainkim

Proposal

Add an API for streaming HTML rendering, likely via a method such as HTMLRenderer.render accepting a WriteableStream as the second argument.

const result = await renderer.render(<Page />, stream);

The result would still be a string, but the stream would be written eagerly as elements fulfilled.

Motivation

Streaming SSR is important for performance and scalability, especially for large or dynamic applications. Most HTML pages will have static preambles with asset references, so it’s imperative we send that part early for a good TTFB and to allow clients to fetch assets eagerly.

API Example

import { renderer } from "@b9g/crank/html";

self.addEventListener("fetch", (event) => {
  const { readable, writable } = new TransformStream();

  // Write to the stream
  renderer.render(<App />, writable);

  // Respond with the readable side
  event.respondWith(new Response(readable));
});

Implementation details

I used to think that this could be implemented using the internal renderer methods, namely scope, which is called in a pre-order traversal of the tree, but I’m realizing that none of the renderer methods are called in a depth-first order because of async siblings. The streaming SSR algorithm can be reduced to the following problem:

// Assignment: Eager DFS Traversal of an Async Tree (Enter/Exit)

/**
 * A tree-like structure where each node is one of:
 * - A string: a leaf value.
 * - An array: representing ordered child nodes.
 * - A function: when called, returns a Promise that resolves to a subtree.
 */
type Tree = string | Tree[] | (() => Promise<Tree>);

/**
 * Walk a tree, recursively executing all functions found in the tree as soon
 * as possible.
 *
 * @param tree - The tree to walk.
 * @param onEnter - Called when visiting a string or array node (pre-order).
 * @param onExit - Called after fully traversing a string or array node (post-order).
 *
 * @returns Concatenation of all strings in DFS order:
 * - Returns `string` if the tree contains no async nodes.
 * - Returns `Promise<string>` otherwise.
 */
function walk(
  tree: Tree,
  onEnter: (value: string) => void,
  onExit: (value: string) => void
): Promise<string> | string {
  throw new Error("Not implemented");
}

/*
Requirements
------------
1. Delay nodes (functions) must execute as soon as encountered:
   - All sibling delays are started immediately in array order.
   - If a delay resolves to another delay, that nested delay is also started
     immediately upon resolution of the former.

2. Callbacks are called in a strict depth-first search (DFS) order as soon as
   all predecessors have settled and the node is available. No node should be
   passed to a callback before its predecessor, even if the predecessor
   resolves later.

3. Return synchronously if no async nodes are encountered;
   otherwise return a Promise.
*/

function delay(ms: number, value: Tree): () => Promise<Tree> {
  return () =>
    new Promise<Tree>((resolve) => setTimeout(() => resolve(value), ms));
}

// Example trees and expected behavior:

// Example 1: Simple tree with no async nodes
const tree1: Tree = ["A", ["B", "C"], "D"];
// Expected output: "ABCD"
// Expected duration: 0ms

// Example 2: Single async node
const tree2: Tree = ["A", delay(200, "B"), "C"];
// Expected output: "ABC"
// Expected duration: 200ms
// "B" is entered after 200ms
// "C" is entered after 200ms

// Example 3: Nested async
const tree3: Tree = ["A", delay(500, ["B", delay(100, "C")]), "D"];
// Expected output: "ABCD"
// Expected duration: 600ms
// ["B", delay(100, "C")] is entered after 500ms, and exits after 600ms
// "C" is entered after 600ms
// "D" is entered after 600ms

// Example 4: Multiple siblings async
const tree4: Tree = [delay(300, "A"), delay(100, "B"), "C"];
// Expected output: "ABC"
// Expected duration: 300ms
// "A" is entered after 300ms
// "B" is entered after 400ms
// "C" is entered after 500ms

// Example 5: Complex tree with multiple async nodes
const tree5: Tree = [
  "A",
  ["B", delay(100, "C"), "D"],
  delay(200, ["E", delay(50, "F")]),
  "G"
];

// Expected output: "ABCDEFG"
// Expected duration: 250ms
// "B" is entered immediately
// "C" is entered after 100ms
// "D" is entered after 100ms
// "E" is entered after 200ms
// "F" is entered after 250ms
// "G" is entered after 250ms

Could be fun for a JavaScript technical interview question.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions