How Pi-Web Handles Server-Sent Events (SSE) Reconnection on Page Refresh
Pi-Web instantiates a fresh EventSource on every page load, waits for a server-side "connected" handshake, and implements a manual retry loop for fatal errors while relying on the browser's native auto-reconnect for transient failures.
When a user refreshes the browser in the agegr/pi-web repository, the React chat interface must re-establish its Server-Sent Events (SSE) channel to continue receiving real-time agent updates. The useAgentSession hook manages this SSE reconnection lifecycle by creating a new stream, validating the server handshake, and recursively retrying after terminal connection failures.
Creating a Fresh EventSource on Mount
After a page refresh, the component remounts and immediately invokes connectEvents to build a new SSE connection. Unlike WebSocket reuse strategies, Pi-Web explicitly closes any stale references and constructs a pristine EventSource pointing to the session-specific endpoint.
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
See hooks/useAgentSession.ts lines 599–600.
The hook stores this instance in eventSourceRef to track the current stream and prevent duplicate connections during React's strict mode or rapid state transitions.
The Server-Side Connection Handshake
Before the client considers the stream active, it awaits a JSON message with type: "connected" sent by the server-side handler in app/api/agent/[id]/events/route.ts. This handshake confirms that the AgentSession managed by lib/rpc-manager.ts is ready to push updates.
es.onmessage = (e) => {
const event = JSON.parse(e.data);
if (event.type === "connected") settle("connected");
handleAgentEventRef.current?.(event);
};
See hooks/useAgentSession.ts lines 614–616.
The settle function resolves the internal promise with status "connected", unblocking any UI actions that depend on a live event stream.
Handling Fatal vs. Transient Errors
Pi-Web distinguishes between recoverable network blips and terminal failures. When es.readyState === EventSource.CLOSED—typically caused by HTTP 404/500 errors or content-type mismatches—the hook triggers a manual reconnection loop.
if (es.readyState === EventSource.CLOSED) {
settle("closed");
if (eventSourceRef.current === es && agentRunningRef.current) {
eventSourceRef.current = null;
setTimeout(() => {
if (agentRunningRef.current) void connectEvents(sid);
}, 1_000);
}
}
See hooks/useAgentSession.ts lines 622–630.
For transient errors while the state remains CONNECTING, the code intentionally does nothing. The browser's native EventSource auto-reconnect logic handles these brief interruptions automatically.
Guaranteed Connection with ensureEventsConnected
Higher-level chat operations depend on ensureEventsConnected, which wraps connectEvents and enforces a strict timeout. If the server fails to send the handshake within 5 seconds (configurable via EVENT_STREAM_CONNECT_TIMEOUT_MS), the promise rejects with an EventStreamConnectionError.
const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);
See hooks/useAgentSession.ts lines 334–336.
This guard ensures that message sending, session forking, or tool execution only occur after the SSE channel is fully validated, preventing race conditions on slow networks.
Complete Implementation Reference
Below is the consolidated reconnection logic from hooks/useAgentSession.ts showing the full lifecycle from instantiation to error recovery:
const connectEvents = useCallback((sid: string): Promise<EventStreamConnectionResult> => {
// Clean up any existing stream before creating a new one
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
eventSourceRef.current = es;
return new Promise((resolve) => {
const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);
let settled = false;
const settle = (status: EventStreamConnectionStatus) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve({ status, source: es });
};
es.onmessage = (e) => {
const ev = JSON.parse(e.data);
if (ev.type === "connected") settle("connected");
handleAgentEventRef.current?.(ev);
};
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
// Fatal error: initiate manual reconnect
settle("closed");
if (eventSourceRef.current === es && agentRunningRef.current) {
eventSourceRef.current = null;
setTimeout(() => {
if (agentRunningRef.current) void connectEvents(sid);
}, 1_000);
}
}
// Transient errors: allow browser auto-reconnect
};
});
}, []);
// Triggered on mount or when sessionId changes
useEffect(() => {
if (!sessionId) return;
ensureEventsConnected(sessionId).catch(console.error);
}, [sessionId, ensureEventsConnected]);
See hooks/useAgentSession.ts lines 641–647 for the ensureEventsConnected implementation.
Key Files and Their Roles
| File | Role |
|---|---|
hooks/useAgentSession.ts |
Client-side hook that creates the EventSource, handles auto-reconnection, and exposes the SSE stream to the UI. |
app/api/agent/[id]/events/route.ts |
Server-side endpoint that streams JSON events (connected, assistant, toolResult, etc.) to the client. |
lib/rpc-manager.ts |
Manages the lifecycle of AgentSession objects; used by the events route to push updates into the stream. |
lib/normalize.ts |
Normalizes incoming tool-call payloads; referenced by the SSE handler to keep message shapes consistent. |
Summary
- Fresh instantiation: Every page refresh creates a new
EventSourceviaconnectEvents, discarding any stale browser-side state. - Handshake validation: The client waits for a
{type:"connected"}message before considering the stream ready, preventing premature UI actions. - Dual error handling: Fatal errors (
CLOSEDstate) trigger a 1-second delayed manual retry usingsetTimeout, while transient errors rely on native browser auto-reconnect. - Timeout protection: A 5-second configurable timeout prevents indefinite hanging when the server is unreachable.
- Session-aware cleanup: Reconnection only occurs if
agentRunningRef.currentremains true, ensuring retries stop when the agent session ends.
Frequently Asked Questions
Does Pi-Web reuse the same EventSource after a page refresh?
No. According to the agegr/pi-web source code, every page refresh triggers a new component mount, which explicitly closes any existing EventSource and creates a fresh instance. The previous reference is cleared from eventSourceRef to prevent memory leaks and connection duplication.
What triggers the manual reconnection logic?
The manual retry loop activates only when es.readyState === EventSource.CLOSED, indicating a fatal error such as an HTTP 404, 500, or content-type mismatch. The code checks eventSourceRef.current === es to ensure it only retries the most recent connection attempt, then schedules connectEvents(sid) to run after 1,000 milliseconds if the agent is still running.
How long does Pi-Web wait for the SSE connection to establish?
The promise returned by connectEvents settles with status "timeout" if the server fails to send the handshake within 5 seconds (controlled by EVENT_STREAM_CONNECT_TIMEOUT_MS). This allows callers to fall back to polling or display connection error states rather than waiting indefinitely.
How does the server indicate a successful SSE connection?
The server-side endpoint at app/api/agent/[id]/events/route.ts emits a JSON payload with {"type":"connected"} immediately upon accepting the stream. The client resolves its connection promise only after parsing this message, guaranteeing that the AgentSession in lib/rpc-manager.ts is actively streaming before the UI enables chat interactions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →