the asset system is reworked to support "dynamic" entries, where each entry is a separate file on disk containing the latest generation's headers+raw+gzip+zstd. when calling view.regenerate, it will look for pages that had "export const regenerate" during generation, and render those pages using the view system, but then store the results as assets instead of sending as a response. pages configured as regenerable are also bundled as views, using the non-aliasing key "page:${page.id}". this cannot alias because file paths may not contain a colon.
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
// This file implements out-of-order HTML streaming, mimicking the React
|
|
// Suspense API. To use, place Suspense around an expensive async component
|
|
// and render the page with 'renderStreaming'.
|
|
//
|
|
// Implementation of this article:
|
|
// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
|
|
//
|
|
// I would link to an article from Next.js or React, but their examples
|
|
// are too verbose and not informative to what they actually do.
|
|
const userData = render.userData<null | State>(() => null);
|
|
|
|
interface SuspenseProps {
|
|
children: render.Node;
|
|
fallback?: render.Node;
|
|
}
|
|
|
|
interface State {
|
|
nested: boolean;
|
|
nextId: number;
|
|
completed: number;
|
|
pushChunk(name: string, node: render.ResolvedNode): void;
|
|
}
|
|
|
|
export function Suspense({ children, fallback }: SuspenseProps): render.Node {
|
|
const state = userData.get();
|
|
if (!state) return children;
|
|
if (state.nested) throw new Error("<Suspense> cannot be nested");
|
|
const parent = UNWRAP(render.current);
|
|
const r = render.initRender(true, { [userData.key]: { nested: true } });
|
|
const resolved = render.resolveNode(r, children);
|
|
if (r.async == 0) return render.raw(resolved);
|
|
const name = "suspended_" + ++state.nextId;
|
|
state.nested = true;
|
|
const ip: [render.ResolvedNode] = [
|
|
render.resolvedElement(
|
|
"slot",
|
|
{ name },
|
|
fallback ? render.resolveNode(parent, fallback) : "",
|
|
),
|
|
];
|
|
state.nested = false;
|
|
r.asyncDone = () => {
|
|
const rejections = r.rejections;
|
|
if (rejections && rejections.length > 0) throw new Error("TODO");
|
|
state.pushChunk?.(name, ip[0] = resolved);
|
|
};
|
|
return render.raw(ip);
|
|
}
|
|
|
|
// TODO: add a User-Agent parameter, which is used to determine if a
|
|
// fallback path must be used.
|
|
// - Before ~2024 needs to use a JS implementation.
|
|
// - IE should probably bail out entirely.
|
|
export async function* renderStreaming<
|
|
T extends render.Addons = Record<never, unknown>,
|
|
>(node: render.Node, addon: T = {} as T) {
|
|
const {
|
|
text: begin,
|
|
addon: { [userData.key]: state, ...addonOutput },
|
|
} = await render.async(node, {
|
|
...addon,
|
|
[userData.key]: {
|
|
nested: false,
|
|
nextId: 0,
|
|
completed: 0,
|
|
pushChunk: () => {},
|
|
} satisfies State as State,
|
|
});
|
|
if (state.nextId === 0) {
|
|
yield begin;
|
|
return addonOutput as unknown as T;
|
|
}
|
|
let resolve: (() => void) | null = null;
|
|
let chunks: string[] = [];
|
|
state.pushChunk = (slot, node) => {
|
|
while (node.length === 1 && Array.isArray(node)) node = node[0];
|
|
if (node[0] === render.kElement) {
|
|
(node as render.ResolvedElement)[2].slot = slot;
|
|
} else {
|
|
node = [
|
|
render.kElement,
|
|
"clover-suspense",
|
|
{
|
|
style: "display:contents",
|
|
slot,
|
|
},
|
|
node,
|
|
];
|
|
}
|
|
chunks.push(render.stringifyNode(node));
|
|
resolve?.();
|
|
};
|
|
yield `<template shadowrootmode=open>${begin}</template>`;
|
|
do {
|
|
await new Promise<void>((done) => (resolve = done));
|
|
yield* chunks;
|
|
chunks = [];
|
|
} while (state.nextId < state.completed);
|
|
return addonOutput as unknown as T;
|
|
}
|
|
|
|
import * as render from "#engine/render";
|