-
Notifications
You must be signed in to change notification settings - Fork 79
Description
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 250msCould be fun for a JavaScript technical interview question.