Files
openchamber/packages/web/server/lib/event-stream/runtime.js
T
Bohdan Triapitsyn f24e6de21b fix: improve event stream reconnect reliability
Recover stalled event streams without dropping the session
Wait briefly for reconnection before showing connection lost errors
Persist Electron server logs for easier disconnect debugging
2026-04-22 21:03:02 +03:00

389 lines
12 KiB
JavaScript

import { WebSocketServer } from 'ws';
import { parseRequestPathname } from '../terminal/index.js';
import {
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_GLOBAL_WS_PATH,
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
parseSseEventEnvelope,
sendMessageStreamWsEvent,
sendMessageStreamWsFrame,
} from './protocol.js';
const MESSAGE_STREAM_UPSTREAM_STALL_TIMEOUT_MS = 20_000;
const MESSAGE_STREAM_UPSTREAM_RECONNECT_DELAY_MS = 250;
function shouldTriggerUpstreamHealthCheck(upstream) {
if (!upstream) {
return true;
}
if (!upstream.body) {
return upstream.ok || upstream.status >= 500;
}
return upstream.status >= 500;
}
export function createGlobalUiEventBroadcaster({
sseClients,
wsClients,
writeSseEvent,
}) {
return (payload, options = {}) => {
const hasSseClients = sseClients.size > 0;
const hasWsClients = wsClients.size > 0;
if (!hasSseClients && !hasWsClients) {
return;
}
if (hasSseClients) {
for (const res of sseClients) {
try {
writeSseEvent(res, payload);
} catch {
}
}
}
if (hasWsClients) {
for (const socket of Array.from(wsClients)) {
const sent = sendMessageStreamWsEvent(socket, payload, {
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
});
if (!sent) {
wsClients.delete(socket);
}
}
}
};
}
export function createMessageStreamWsRuntime({
server,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients,
triggerHealthCheck,
heartbeatIntervalMs = MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
upstreamStallTimeoutMs = MESSAGE_STREAM_UPSTREAM_STALL_TIMEOUT_MS,
upstreamReconnectDelayMs = MESSAGE_STREAM_UPSTREAM_RECONNECT_DELAY_MS,
fetchImpl = fetch,
}) {
const wsServer = new WebSocketServer({
noServer: true,
});
wsServer.on('connection', (socket, req) => {
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
const pathname = parseRequestPathname(rawUrl);
const requestUrl = new URL(rawUrl, 'http://127.0.0.1');
const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
const controller = new AbortController();
let currentUpstreamAbortController = null;
let upstreamConnected = false;
let streamReady = false;
let lastEventId = requestedLastEventId;
const cleanup = () => {
if (!controller.signal.aborted) {
controller.abort();
}
if (currentUpstreamAbortController && !currentUpstreamAbortController.signal.aborted) {
currentUpstreamAbortController.abort();
}
wsClients.delete(socket);
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) {
return;
}
try {
socket.ping();
} catch {
}
}, heartbeatIntervalMs);
const heartbeatInterval = setInterval(() => {
if (!upstreamConnected) {
return;
}
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, { directory: 'global' });
}, heartbeatIntervalMs);
socket.on('close', () => {
clearInterval(pingInterval);
clearInterval(heartbeatInterval);
upstreamConnected = false;
cleanup();
});
socket.on('error', () => {
void 0;
});
const run = async () => {
const forwardBlock = (block) => {
if (!block) {
return;
}
const envelope = parseSseEventEnvelope(block);
const payload = envelope?.payload ?? null;
if (!payload) {
return;
}
const directory = isGlobalStream
? (typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global')
: (requestedDirectory || envelope?.directory || 'global');
if (typeof envelope?.eventId === 'string' && envelope.eventId.length > 0) {
lastEventId = envelope.eventId;
}
sendMessageStreamWsEvent(socket, payload, {
directory,
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
});
processForwardedEventPayload(payload, (syntheticPayload) => {
sendMessageStreamWsEvent(socket, syntheticPayload, { directory: 'global' });
});
};
try {
while (!controller.signal.aborted) {
let targetUrl;
try {
targetUrl = new URL(buildOpenCodeUrl(isGlobalStream ? '/global/event' : '/event', ''));
} catch {
sendMessageStreamWsFrame(socket, { type: 'error', message: 'OpenCode service unavailable' });
socket.close(1011, 'OpenCode service unavailable');
return;
}
if (!isGlobalStream && requestedDirectory) {
targetUrl.searchParams.set('directory', requestedDirectory);
}
const headers = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...getOpenCodeAuthHeaders(),
};
if (lastEventId) {
headers['Last-Event-ID'] = lastEventId;
}
const upstreamController = new AbortController();
currentUpstreamAbortController = upstreamController;
const abortUpstream = () => upstreamController.abort();
controller.signal.addEventListener('abort', abortUpstream, { once: true });
let upstream;
try {
upstream = await fetchImpl(targetUrl.toString(), {
headers,
signal: upstreamController.signal,
});
} catch {
controller.signal.removeEventListener('abort', abortUpstream);
currentUpstreamAbortController = null;
upstreamConnected = false;
if (controller.signal.aborted) {
return;
}
if (!streamReady) {
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Failed to connect to OpenCode event stream' });
socket.close(1011, 'Failed to connect to OpenCode event stream');
triggerHealthCheck?.();
return;
}
await wait(upstreamReconnectDelayMs);
continue;
}
if (!upstream.ok || !upstream.body) {
controller.signal.removeEventListener('abort', abortUpstream);
currentUpstreamAbortController = null;
upstreamConnected = false;
if (!streamReady) {
sendMessageStreamWsFrame(socket, {
type: 'error',
message: `OpenCode event stream unavailable (${upstream.status})`,
});
socket.close(1011, 'OpenCode event stream unavailable');
if (shouldTriggerUpstreamHealthCheck(upstream)) {
triggerHealthCheck?.();
}
return;
}
await wait(upstreamReconnectDelayMs);
continue;
}
if (!streamReady) {
sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: isGlobalStream ? 'global' : 'directory',
});
streamReady = true;
if (isGlobalStream) {
wsClients.add(socket);
}
}
upstreamConnected = true;
const decoder = new TextDecoder();
const reader = upstream.body.getReader();
let buffer = '';
let upstreamAbortReason = null;
let stallTimer = null;
const resetStallTimer = () => {
if (stallTimer) {
clearTimeout(stallTimer);
}
stallTimer = setTimeout(() => {
upstreamAbortReason = 'upstream_stalled';
upstreamConnected = false;
upstreamController.abort();
}, upstreamStallTimeoutMs);
};
resetStallTimer();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
resetStallTimer();
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
forwardBlock(block);
separatorIndex = buffer.indexOf('\n\n');
}
}
if (buffer.trim().length > 0) {
forwardBlock(buffer.trim());
}
} catch (error) {
if (!controller.signal.aborted && upstreamAbortReason !== 'upstream_stalled') {
console.warn('Message stream WS proxy error:', error);
}
} finally {
if (stallTimer) {
clearTimeout(stallTimer);
}
upstreamConnected = false;
currentUpstreamAbortController = null;
controller.signal.removeEventListener('abort', abortUpstream);
}
if (controller.signal.aborted) {
return;
}
await wait(upstreamReconnectDelayMs);
}
} catch (error) {
if (!controller.signal.aborted) {
console.warn('Message stream WS proxy error:', error);
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Message stream proxy error' });
socket.close(1011, 'Message stream proxy error');
}
} finally {
cleanup();
try {
if (socket.readyState === 1 || socket.readyState === 0) {
socket.close();
}
} catch {
}
}
};
void run();
});
const upgradeHandler = (req, socket, head) => {
const pathname = parseRequestPathname(req.url);
if (pathname !== MESSAGE_STREAM_GLOBAL_WS_PATH && pathname !== MESSAGE_STREAM_DIRECTORY_WS_PATH) {
return;
}
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
if (!sessionToken) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
}
wsServer.handleUpgrade(req, socket, head, (ws) => {
wsServer.emit('connection', ws, req);
});
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
};
void handleUpgrade();
};
server.on('upgrade', upgradeHandler);
return {
wsServer,
async close() {
server.off('upgrade', upgradeHandler);
try {
for (const client of wsServer.clients) {
try {
client.terminate();
} catch {
}
}
await new Promise((resolve) => {
wsServer.close(() => resolve());
});
} catch {
} finally {
wsClients.clear();
}
},
};
}