Non-streaming ErrorBoundary in streaming context
What is the feature you are proposing?
Currently, <ErrorBoundary> will always try to stream content when the current connection is streaming, even without <Suspense>. I'd like to be able to disable this behavior.
I came across this issue when trying to use HTMX's SSE functionality. In short, SSE streams aren't like normal HTML streams, and this breaks the ErrorBoundary. Here's an example (run on Deno 1.46.1 on Windows 11):
#!/usr/bin/env -S deno run --allow-net
/** @jsxImportSource jsr:@hono/[email protected]/jsx */
/** @jsx react-jsx */
import { Hono } from "jsr:@hono/[email protected]";
import { html } from "jsr:@hono/[email protected]/html";
import { ErrorBoundary, FC, PropsWithChildren } from "jsr:@hono/[email protected]/jsx";
import { streamSSE } from "jsr:@hono/[email protected]/streaming";
const app = new Hono();
const AsyncContent: FC<PropsWithChildren> = async ({ children }) => {
await new Promise((resolve) => setTimeout(resolve, 100));
return <div>{children}</div>;
};
app.get("/", (ctx) => {
return ctx.html(html`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test Document</title>
</head>
<body>
<script>
const eventSource = new EventSource("/sse");
eventSource.onmessage = (event) => {
const wrapper = document.createElement("div");
wrapper.innerHTML = event.data;
document.body.appendChild(wrapper);
}
</script>
</body>
</html>
`);
});
app.get("/sse", (ctx) => {
return streamSSE(ctx, async (stream) => {
// Note: Streaming HTML through SSE isn't documented. But I found that using await html`${content}` works (well, except for this issue)
await stream.writeSSE({
data: await html`${<AsyncContent>This is some streamed-in content!</AsyncContent>}`,
});
await stream.writeSSE({
data: await html`${(
<ErrorBoundary>
<AsyncContent>
This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
</AsyncContent>
</ErrorBoundary>
)}`,
});
while (!stream.aborted) {
await stream.sleep(1000);
}
});
});
Deno.serve(app.fetch);
Perhaps this doesn't need to be explicitly controlled, but instead the streaming detection could be smarter - the regular HTML streaming stuff can't really work out-of-the-box with SSE, so it shouldn't attempt it. (I'd additionally like to see some documentation on SSEing HTML, but that's not really in the scope of this issue.)
Hi @Mabi19
If you want to return results asynchronously while using Suspense together, you can write the following.
@@ -5,7 +5,8 @@
import { Hono } from "jsr:@hono/[email protected]";
import { html } from "jsr:@hono/[email protected]/html";
-import { ErrorBoundary, FC, PropsWithChildren } from "jsr:@hono/[email protected]/jsx";
+import { ErrorBoundary, Suspense, FC, PropsWithChildren } from "jsr:@hono/[email protected]/jsx";
+import { renderToReadableStream } from "jsr:@hono/[email protected]/jsx/dom/server";
import { streamSSE } from "jsr:@hono/[email protected]/streaming";
const app = new Hono();
@@ -30,6 +31,13 @@
eventSource.onmessage = (event) => {
const wrapper = document.createElement("div");
wrapper.innerHTML = event.data;
+ const scripts = wrapper.getElementsByTagName('script');
+ for (let i = 0; i < scripts.length; i++) {
+ const script = scripts[i];
+ const newScript = document.createElement('script');
+ newScript.text = script.text;
+ script.parentNode.replaceChild(newScript, script);
+ }
document.body.appendChild(wrapper);
}
</script>
@@ -40,20 +48,31 @@
app.get("/sse", (ctx) => {
return streamSSE(ctx, async (stream) => {
+ const write = async (data: string) => {
+ const reader = (await renderToReadableStream(data)).getReader()
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ stream.writeSSE({
+ data: new TextDecoder().decode(value)
+ });
+ }
+ };
+
// Note: Streaming HTML through SSE isn't documented. But I found that using await html`${content}` works (well, except for this issue)
- await stream.writeSSE({
- data: await html`${<AsyncContent>This is some streamed-in content!</AsyncContent>}`,
- });
- await stream.writeSSE({
- data: await html`${(
- <ErrorBoundary>
- <AsyncContent>
- This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
- ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
- </AsyncContent>
- </ErrorBoundary>
- )}`,
- });
+ await write(<AsyncContent>This is some streamed-in content!</AsyncContent>);
+ await write(
+ <ErrorBoundary>
+ <Suspense fallback={<div>Loading...</div>}>
+ <AsyncContent>
+ This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
+ ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
+ </AsyncContent>
+ </Suspense>
+ </ErrorBoundary>
+ );
while (!stream.aborted) {
await stream.sleep(1000);
Alternatively, if you want to wait for AsyncContent resolution on the server side before doing stream.writeSSE, you can write
@@ -5,6 +5,7 @@
import { Hono } from "jsr:@hono/[email protected]";
import { html } from "jsr:@hono/[email protected]/html";
+import { HtmlEscapedCallbackPhase, resolveCallback } from "jsr:@hono/[email protected]/utils/html";
import { ErrorBoundary, FC, PropsWithChildren } from "jsr:@hono/[email protected]/jsx";
import { streamSSE } from "jsr:@hono/[email protected]/streaming";
@@ -45,14 +46,14 @@
data: await html`${<AsyncContent>This is some streamed-in content!</AsyncContent>}`,
});
await stream.writeSSE({
- data: await html`${(
- <ErrorBoundary>
+ data: await resolveCallback(await html`${(
+ <ErrorBoundary>
<AsyncContent>
This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
</AsyncContent>
</ErrorBoundary>
- )}`,
+ )}`, HtmlEscapedCallbackPhase.Stream, false, {}),
});
while (!stream.aborted) {
I've updated my code to use resolveCallback, and it seems to work. I still think that achieving this result should be easier, especially with <Suspense> - to me this solution reaches a little too deep into the internals of Hono for something so common when using hypermedia. If not, this should at least be documented in my opinion.
I could probably contribute this to the documentation if you want.
Hi @Mabi19
I could probably contribute this to the documentation if you want.
Please! Thank you.
Hi @Mabi19 Thanks for your comment.
I agree that solving this problem without the internal low-level API would be nice. There is no high-level API for this problem because we were not sure if there is such a use case.
I am still not convinced that there will be users who will use this in the future, but if there seems to be a use case, the following function could be added.
diff --git a/src/utils/html.ts b/src/utils/html.ts
index d3557263..f27093fc 100644
--- a/src/utils/html.ts
+++ b/src/utils/html.ts
@@ -139,6 +139,15 @@ export const resolveCallbackSync = (str: string | HtmlEscapedString): string =>
return buffer[0]
}
+export const streamToString = async (
+ str: string | HtmlEscapedString | Promise<string | HtmlEscapedString>
+): Promise<string> => {
+ if (str instanceof Promise) {
+ str = await str
+ }
+ return resolveCallback(str, HtmlEscapedCallbackPhase.Stream, false, {})
+}
+
export const resolveCallback = async (
str: string | HtmlEscapedString,
phase: (typeof HtmlEscapedCallbackPhase)[keyof typeof HtmlEscapedCallbackPhase],
This should allow you to write the following.
await stream.writeSSE({
data: await streamToString(<AsyncContent>This is some streamed-in content!</AsyncContent>),
});
await stream.writeSSE({
data: await streamToString(
<ErrorBoundary>
<AsyncContent>
This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
</AsyncContent>
</ErrorBoundary>
),
});
@yusukebe Do you think we should add such a function?
@usualoma The main use case I can think of is hypermedia-based web apps, like when using the HTMX library. That approach is built entirely on sending HTML fragments, and doing that over SSE or WebSockets is quite common. In particular, another hypermedia framework called Datastar sends everything through SSE, though I'm not particularly familiar with it.
Hi @Mabi19 @usualoma
@yusukebe Do you think we should add such a function?
I think it's fine.
However, I'm wondering if it's good or not to wait for an asynchronous component once, turn it into a string, and then return it in SSE.
@Mabi19
Sorry, the output result is still the same, but what you should have done in your app was the same as the following line, so here we prefer to use HtmlEscapedCallbackPhase.Stringify instead of HtmlEscapedCallbackPhase.Stream is correct.
https://github.com/honojs/hono/blob/18f937d3be56ac6fac2fbd3e069399dc39ef53fe/src/context.ts#L852
good or not to wait for an asynchronous component once
Yes, the subtlety of the use case is that if we are “wait in await and return the streaming content as a resolved string”, then it is not clear that ErrorBoundary is necessary. It might be better to use try/catch or perhaps even promise.catch().
It would certainly be convenient to be able to catch both synchronous and asynchronous events by putting even an ErrorBoundary in place, though.
Another idea for implementation
It might be a good idea to allow the same value as c.html(res) to be passed to data in stream.writeSSE. This would have no learning cost and would not affect existing APIs. And I think it would be consistent with the API.
diff --git a/src/helper/streaming/sse.ts b/src/helper/streaming/sse.ts
index 1ed96e13..bdf57b1c 100644
--- a/src/helper/streaming/sse.ts
+++ b/src/helper/streaming/sse.ts
@@ -1,8 +1,9 @@
import type { Context } from '../../context'
import { StreamingApi } from '../../utils/stream'
+import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html'
export interface SSEMessage {
- data: string
+ data: string | Promise<string>
event?: string
id?: string
retry?: number
@@ -14,7 +15,17 @@ export class SSEStreamingApi extends StreamingApi {
}
async writeSSE(message: SSEMessage) {
- const data = message.data
+ let data = message.data
+ if (typeof data === 'object') {
+ if (!(data instanceof Promise)) {
+ data = (data as string).toString() // HtmlEscapedString object to string
+ }
+ if ((data as string | Promise<string>) instanceof Promise) {
+ data = await resolveCallback(await data, HtmlEscapedCallbackPhase.Stringify, false, {})
+ }
+ }
+
+ const dataLines = (data as string)
.split('\n')
.map((line) => {
return `data: ${line}`
@@ -24,7 +35,7 @@ export class SSEStreamingApi extends StreamingApi {
const sseData =
[
message.event && `event: ${message.event}`,
- data,
+ dataLines,
message.id && `id: ${message.id}`,
message.retry && `retry: ${message.retry}`,
]
With this change, we can write the following in application.
await stream.writeSSE({
data: <AsyncContent>This is some streamed-in content!</AsyncContent>,
});
await stream.writeSSE({
data:
<ErrorBoundary fallback={<div>Error!</div>}>
<AsyncContent>
This content is wrapped in an ErrorBoundary! It will never be visible in the browser because the
ErrorBoundary sends a placeholder instead as it thinks this is a regular HTML stream.
</AsyncContent>
</ErrorBoundary>
});