133 lines
5.0 KiB
Plaintext
133 lines
5.0 KiB
Plaintext
import { HtmlEscapedCallbackPhase, raw, resolveCallback } from "../utils/html.js";
|
|
import { DOM_RENDERER, DOM_STASH } from "./constants.js";
|
|
import { captureRenderContext, createContext, useContext } from "./context.js";
|
|
import { JSXNode, isUntrustedObject, renderChildren, renderUntrustedObject } from "./base.js";
|
|
import { buildDataStack } from "./dom/render.js";
|
|
import { Suspense as Suspense$1 } from "./dom/components.js";
|
|
import { childrenToString } from "./components.js";
|
|
//#region src/jsx/streaming.ts
|
|
/**
|
|
* @module
|
|
* This module enables JSX to supports streaming Response.
|
|
*/
|
|
/**
|
|
* Used to specify nonce for scripts generated by `Suspense` and `ErrorBoundary`.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <StreamingContext.Provider value={{ scriptNonce: 'test-nonce' }}>
|
|
* <Suspense fallback={<p>Loading...</p>}>
|
|
* <Content />
|
|
* </Suspense>
|
|
* </StreamingContext.Provider>
|
|
* ```
|
|
*/
|
|
const StreamingContext = createContext(null);
|
|
let suspenseCounter = 0;
|
|
const serializeBoundaryChild = (child) => typeof child === "string" || Array.isArray(child) ? renderChildren([child]) : child == null || typeof child === "boolean" ? "" : isUntrustedObject(child) ? renderUntrustedObject(child) : child.toString();
|
|
/**
|
|
* @experimental
|
|
* `Suspense` is an experimental feature.
|
|
* The API might be changed.
|
|
*/
|
|
const Suspense = async ({ children, fallback }) => {
|
|
if (!Array.isArray(children)) children = [children];
|
|
const nonce = useContext(StreamingContext)?.scriptNonce;
|
|
let resArray = [];
|
|
const stackNode = { [DOM_STASH]: [0, []] };
|
|
const popNodeStack = () => {
|
|
buildDataStack.pop();
|
|
};
|
|
try {
|
|
stackNode[DOM_STASH][0] = 0;
|
|
buildDataStack.push([[], stackNode]);
|
|
resArray = children.map(serializeBoundaryChild);
|
|
} catch (e) {
|
|
if (e instanceof Promise) {
|
|
const resume = captureRenderContext();
|
|
resArray = [e.then(() => resume(() => {
|
|
stackNode[DOM_STASH][0] = 0;
|
|
buildDataStack.push([[], stackNode]);
|
|
return childrenToString(children).finally(popNodeStack);
|
|
}))];
|
|
} else throw e;
|
|
} finally {
|
|
popNodeStack();
|
|
}
|
|
if (resArray.some((res) => res instanceof Promise)) {
|
|
const index = suspenseCounter++;
|
|
const fallbackStr = await (typeof fallback === "string" || Array.isArray(fallback) ? renderChildren([fallback]) : isUntrustedObject(fallback) ? renderUntrustedObject(fallback) : fallback.toString());
|
|
return raw(`<template id="H:${index}"></template>${fallbackStr}<!--/$-->`, [...fallbackStr.callbacks || [], ({ phase, buffer, context }) => {
|
|
if (phase === HtmlEscapedCallbackPhase.BeforeStream) return;
|
|
return Promise.all(resArray).then(async (htmlArray) => {
|
|
htmlArray = htmlArray.flat();
|
|
const content = htmlArray.join("");
|
|
if (buffer) buffer[0] = buffer[0].replace(new RegExp(`<template id="H:${index}"></template>.*?<!--/\\$-->`, "s"), () => content);
|
|
let html = buffer ? "" : `<template data-hono-target="H:${index}">${content}</template><script${nonce ? ` nonce="${nonce}"` : ""}>
|
|
((d,c,n) => {
|
|
c=d.currentScript.previousSibling
|
|
d=d.getElementById('H:${index}')
|
|
if(!d)return
|
|
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
d.replaceWith(c.content)
|
|
})(document)
|
|
<\/script>`;
|
|
const callbacks = htmlArray.map((html) => html.callbacks || []).flat();
|
|
if (!callbacks.length) return html;
|
|
if (phase === HtmlEscapedCallbackPhase.Stream) html = await resolveCallback(html, HtmlEscapedCallbackPhase.BeforeStream, true, context);
|
|
return raw(html, callbacks);
|
|
});
|
|
}]);
|
|
} else return raw(resArray.join(""));
|
|
};
|
|
Suspense[DOM_RENDERER] = Suspense$1;
|
|
const textEncoder = new TextEncoder();
|
|
/**
|
|
* @experimental
|
|
* `renderToReadableStream()` is an experimental feature.
|
|
* The API might be changed.
|
|
*/
|
|
const renderToReadableStream = (content, onError = console.trace) => {
|
|
let cancelled = false;
|
|
return new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
if (content instanceof JSXNode) content = content.toString();
|
|
const context = typeof content === "object" ? content : {};
|
|
const resolved = await resolveCallback(content, HtmlEscapedCallbackPhase.BeforeStream, true, context);
|
|
if (!cancelled) controller.enqueue(textEncoder.encode(resolved));
|
|
let resolvedCount = 0;
|
|
const callbacks = [];
|
|
const then = (promise) => {
|
|
callbacks.push(promise.catch((err) => {
|
|
console.log(err);
|
|
onError(err);
|
|
return "";
|
|
}).then(async (res) => {
|
|
res = await resolveCallback(res, HtmlEscapedCallbackPhase.BeforeStream, true, context);
|
|
res.callbacks?.map((c) => c({
|
|
phase: HtmlEscapedCallbackPhase.Stream,
|
|
context
|
|
})).filter(Boolean).forEach(then);
|
|
resolvedCount++;
|
|
if (!cancelled) controller.enqueue(textEncoder.encode(res));
|
|
}));
|
|
};
|
|
resolved.callbacks?.map((c) => c({
|
|
phase: HtmlEscapedCallbackPhase.Stream,
|
|
context
|
|
})).filter(Boolean).forEach(then);
|
|
while (resolvedCount !== callbacks.length) await Promise.all(callbacks);
|
|
} catch (e) {
|
|
onError(e);
|
|
}
|
|
if (!cancelled) controller.close();
|
|
},
|
|
cancel() {
|
|
cancelled = true;
|
|
}
|
|
});
|
|
};
|
|
//#endregion
|
|
export { StreamingContext, Suspense, renderToReadableStream };
|