diff --git a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts
index 663935ed..44e0c3d7 100644
--- a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts
+++ b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts
@@ -11,7 +11,12 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { ArrivedMessage, SwarmMessage } from "./messages.js";
import { messagesUrl, newest, numbered } from "./messages.js";
-export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
+import {
+ connectionAfterError,
+ type ConnectionState,
+} from "../../ui/stream/connection.js";
+
+export type { ConnectionState };
/** The most messages the page holds; older ones drop off. */
export const MAX_MESSAGES = 2000;
@@ -19,12 +24,16 @@ export const MAX_MESSAGES = 2000;
export function useMessageStream(sinceMinutes: number): {
messages: ArrivedMessage[];
connection: ConnectionState;
+ /** Opens the stream again, for one the browser gave up on. */
+ reconnect: () => void;
} {
const [messages, setMessages] = useState
([]);
const [connection, setConnection] = useState("connecting");
// The next id to hand out; it never goes back, so an id is not reused after
// a reconnect either.
const nextId = useRef(0);
+ // Bumped by `reconnect`, which reruns the effect below.
+ const [attempt, setAttempt] = useState(0);
useEffect(() => {
setMessages([]);
@@ -61,16 +70,12 @@ export function useMessageStream(sinceMinutes: number): {
pending.push(m);
if (frame === null) frame = requestAnimationFrame(flush);
};
- es.onerror = () => {
- setConnection(
- es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
- );
- };
+ es.onerror = () => setConnection(connectionAfterError(es.readyState));
return () => {
drop();
es.close();
};
- }, [sinceMinutes]);
+ }, [sinceMinutes, attempt]);
- return { messages, connection };
+ return { messages, connection, reconnect: () => setAttempt((n) => n + 1) };
}
diff --git a/frontend/packages/swarm-ui/src/ui/stream/connection.ts b/frontend/packages/swarm-ui/src/ui/stream/connection.ts
new file mode 100644
index 00000000..8a439b15
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/stream/connection.ts
@@ -0,0 +1,14 @@
+// The state of an `EventSource` as the swarm UI's stream hooks report it.
+
+export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
+
+/** `EventSource.CONNECTING`: the browser is retrying by itself. */
+const CONNECTING = 0;
+
+/** The state after an `error` event. While the browser is still retrying
+ * (`readyState` 0), the stream is "reconnecting". Any other state means the
+ * browser gave up, which it does when the server answers with a non-200 status
+ * or a wrong content type, so nothing reopens the stream unless the page does. */
+export function connectionAfterError(readyState: number): ConnectionState {
+ return readyState === CONNECTING ? "reconnecting" : "closed";
+}
diff --git a/frontend/packages/swarm-ui/test/stream-connection.test.mjs b/frontend/packages/swarm-ui/test/stream-connection.test.mjs
new file mode 100644
index 00000000..c8f34fd6
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/stream-connection.test.mjs
@@ -0,0 +1,15 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { connectionAfterError } from "../src/ui/stream/connection.ts";
+
+test("connectionAfterError: the browser still retrying is reconnecting", () => {
+ assert.equal(connectionAfterError(0), "reconnecting");
+});
+
+test("connectionAfterError: a given-up stream is closed", () => {
+ assert.equal(connectionAfterError(2), "closed");
+});
+
+test("connectionAfterError: an open readyState on error is also closed", () => {
+ assert.equal(connectionAfterError(1), "closed");
+});