Share upstream event stream hub

This commit is contained in:
Bohdan Triapitsyn
2026-04-24 12:30:11 +03:00
parent 8e610ca8b3
commit 7030549d78
12 changed files with 1436 additions and 201 deletions
+8
View File
@@ -33,6 +33,7 @@ import { detectSayTtsCapability } from './lib/tts/capability-runtime.js';
import { createTerminalRuntime } from './lib/terminal/runtime.js';
import {
createGlobalUiEventBroadcaster,
createGlobalMessageStreamHub,
createMessageStreamWsRuntime,
} from './lib/event-stream/index.js';
import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js';
@@ -648,11 +649,17 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
});
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
parseSseDataPayload: (...args) => parseSseDataPayload(...args),
globalEventHub: globalMessageStreamHub,
onPayload: (payload) => {
maybeCacheSessionInfoFromEvent(payload);
void maybeSendPushForTrigger(payload);
@@ -1155,6 +1162,7 @@ async function main(options = {}) {
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
globalEventHub: globalMessageStreamHub,
processForwardedEventPayload,
messageStreamWsClients: uiNotificationWsClients,
terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
@@ -5,9 +5,12 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
## Entrypoints and structure
- `packages/web/server/lib/event-stream/index.js`: public entrypoint re-exporting protocol and runtime helpers.
- `packages/web/server/lib/event-stream/global-hub.js`: shared global upstream SSE hub for server-side subscribers and browser WS fan-out.
- `packages/web/server/lib/event-stream/protocol.js`: path constants, SSE envelope parsing, and WebSocket frame serialization helpers.
- `packages/web/server/lib/event-stream/runtime.js`: WebSocket server runtime, upgrade handling, SSE-to-WS bridging, and global event broadcasting.
- `packages/web/server/lib/event-stream/upstream-reader.js`: reusable upstream SSE reader with event-id tracking, stall recovery, and reconnect handling.
- `packages/web/server/lib/event-stream/runtime.js`: WebSocket server runtime, upgrade handling, shared global upstream hub, per-directory upstream reader orchestration, and global event broadcasting.
- `packages/web/server/lib/event-stream/protocol.test.js`: unit tests for protocol helpers.
- `packages/web/server/lib/event-stream/upstream-reader.test.js`: unit tests for upstream SSE reader behavior.
- `packages/web/server/lib/event-stream/runtime.test.js`: unit tests for runtime-side broadcaster behavior.
## Public exports
@@ -21,25 +24,35 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
### Runtime helpers
- `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
- `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
- `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
### Upstream reader helpers
- `DEFAULT_UPSTREAM_STALL_TIMEOUT_MS`: default idle timeout before an attached upstream SSE fetch is aborted for reconnect.
- `DEFAULT_UPSTREAM_RECONNECT_DELAY_MS`: default delay between upstream reconnect attempts.
- `createUpstreamSseReader(...)`: creates a start/stop reader for OpenCode SSE streams. The reader parses SSE blocks, tracks the latest `Last-Event-ID`, reconnects after closed or stalled upstream streams, and reports events through callbacks.
## Runtime behavior
- Browser clients connect to the WS endpoints above.
- OpenChamber still fetches OpenCode upstream event streams over SSE.
- Each WS connection proxies one upstream SSE stream.
- If an upstream SSE stream stalls after the browser WS is already ready, the runtime aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
- The web server creates one shared global message-stream hub. OpenCode watcher side effects and global WS clients subscribe to that hub, so there is one upstream `/global/event` SSE reader for both server-side processing and browser fan-out.
- The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
- Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
- If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
- Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
- Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
- The reusable upstream reader centralizes SSE fetch/parsing/reconnect behavior for the WS runtime and OpenCode watcher. Additional event consumers should move to it only with parity tests for their lifecycle and error semantics.
## Notes for contributors
- Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
- Keep runtime wiring in this module instead of `packages/web/server/index.js` unless the logic is strictly route-local glue.
- Do not change upstream OpenCode transport assumptions here; OpenCode remains SSE-based.
- If replay support is added later, add it here rather than growing `index.js`.
- Keep global replay bounded; do not turn it into an unbounded event log.
## Testing
- Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
- Run `bun test packages/web/server/lib/event-stream/upstream-reader.test.js`
- Run `bun test packages/web/server/lib/event-stream/runtime.test.js`
- Run repo validation before finalizing: `bun run type-check`, `bun run lint`, `bun run build`
@@ -0,0 +1,143 @@
import { createUpstreamSseReader } from './upstream-reader.js';
export const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 512;
export function createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
fetchImpl = fetch,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs,
replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
}) {
const eventSubscribers = new Set();
const statusSubscribers = new Set();
const replay = [];
let controller = null;
let reader = null;
let connected = false;
let everConnected = false;
let buildUrlFailed = false;
const notifyStatus = (status) => {
for (const subscriber of Array.from(statusSubscribers)) {
subscriber(status);
}
};
const normalizeEvent = ({ envelope, payload }) => {
const directory =
typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
return {
envelope,
payload,
directory,
eventId,
};
};
const start = () => {
if (reader) {
return;
}
controller = new AbortController();
reader = createUpstreamSseReader({
signal: controller.signal,
stallTimeoutMs: upstreamStallTimeoutMs,
reconnectDelayMs: upstreamReconnectDelayMs,
fetchImpl,
buildUrl: () => {
buildUrlFailed = false;
try {
return new URL(buildOpenCodeUrl('/global/event', ''));
} catch {
buildUrlFailed = true;
throw new Error('OpenCode service unavailable');
}
},
getHeaders: getOpenCodeAuthHeaders,
onConnect() {
connected = true;
const wasReady = everConnected;
everConnected = true;
notifyStatus({ type: 'connect', wasReady });
},
onDisconnect({ reason }) {
connected = false;
notifyStatus({ type: 'disconnect', reason });
},
onEvent(event) {
const normalized = normalizeEvent(event);
if (normalized.eventId) {
replay.push(normalized);
if (replay.length > replayLimit) {
replay.splice(0, replay.length - replayLimit);
}
}
for (const subscriber of Array.from(eventSubscribers)) {
subscriber(normalized);
}
},
onError(error) {
if (controller?.signal.aborted) {
return;
}
notifyStatus({
type: everConnected ? 'error' : 'initial-error',
error,
buildUrlFailed,
});
},
});
void reader.start();
};
const stop = () => {
connected = false;
reader?.stop();
if (controller && !controller.signal.aborted) {
controller.abort();
}
reader = null;
controller = null;
everConnected = false;
buildUrlFailed = false;
};
return {
start,
stop,
isConnected() {
return connected;
},
hasConnected() {
return everConnected;
},
subscribeEvent(subscriber) {
eventSubscribers.add(subscriber);
return () => {
eventSubscribers.delete(subscriber);
};
},
subscribeStatus(subscriber) {
statusSubscribers.add(subscriber);
return () => {
statusSubscribers.delete(subscriber);
};
},
replayAfter(eventId) {
if (!eventId) {
return [];
}
const index = replay.findIndex((entry) => entry.eventId === eventId);
return index === -1 ? [] : replay.slice(index + 1);
},
};
}
@@ -11,3 +11,14 @@ export {
createGlobalUiEventBroadcaster,
createMessageStreamWsRuntime,
} from './runtime.js';
export {
MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
createGlobalMessageStreamHub,
} from './global-hub.js';
export {
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
createUpstreamSseReader,
} from './upstream-reader.js';
+268 -154
View File
@@ -5,13 +5,15 @@ 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;
import { createGlobalMessageStreamHub } from './global-hub.js';
import {
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
createUpstreamSseReader,
} from './upstream-reader.js';
function shouldTriggerUpstreamHealthCheck(upstream) {
if (!upstream) {
@@ -71,14 +73,161 @@ export function createMessageStreamWsRuntime({
wsClients,
triggerHealthCheck,
heartbeatIntervalMs = MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
upstreamStallTimeoutMs = MESSAGE_STREAM_UPSTREAM_STALL_TIMEOUT_MS,
upstreamReconnectDelayMs = MESSAGE_STREAM_UPSTREAM_RECONNECT_DELAY_MS,
upstreamStallTimeoutMs = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
upstreamReconnectDelayMs = DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
fetchImpl = fetch,
globalEventHub = null,
}) {
const wsServer = new WebSocketServer({
noServer: true,
});
const ownsGlobalHub = !globalEventHub;
const globalHub = globalEventHub ?? createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
fetchImpl,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs,
});
const globalClients = new Set();
const globalClientLastEventIds = new Map();
const globalReadyClients = new Set();
const replayGlobalEvents = (socket, requestedLastEventId) => {
for (const entry of globalHub.replayAfter(requestedLastEventId)) {
const sent = sendMessageStreamWsEvent(socket, entry.payload, {
directory: entry.directory,
eventId: entry.eventId,
});
if (!sent) {
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
return;
}
}
};
const markGlobalClientReady = (socket, requestedLastEventId) => {
if (socket.readyState !== 1) {
return;
}
const sent = sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: 'global',
});
if (!sent) {
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
return;
}
globalReadyClients.add(socket);
wsClients.add(socket);
replayGlobalEvents(socket, requestedLastEventId);
};
const closeGlobalClientsWithInitialError = ({ message, closeReason = message, triggerHealthCheckFor = null }) => {
for (const socket of Array.from(globalClients)) {
sendMessageStreamWsFrame(socket, { type: 'error', message });
try {
socket.close(1011, closeReason);
} catch {
}
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
}
if (triggerHealthCheckFor === true || (triggerHealthCheckFor && shouldTriggerUpstreamHealthCheck(triggerHealthCheckFor))) {
triggerHealthCheck?.();
}
if (ownsGlobalHub) {
globalHub.stop();
}
};
const unsubscribeGlobalEvent = globalHub.subscribeEvent(({ envelope, payload, directory, eventId }) => {
for (const socket of Array.from(globalClients)) {
if (!globalReadyClients.has(socket)) {
continue;
}
const sent = sendMessageStreamWsEvent(socket, payload, {
directory,
eventId,
});
if (!sent) {
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
}
}
processForwardedEventPayload(payload, (syntheticPayload) => {
for (const socket of Array.from(globalClients)) {
if (!globalReadyClients.has(socket)) {
continue;
}
const sent = sendMessageStreamWsEvent(socket, syntheticPayload, { directory: 'global' });
if (!sent) {
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
}
}
});
});
const unsubscribeGlobalStatus = globalHub.subscribeStatus((status) => {
if (status.type === 'connect') {
for (const socket of Array.from(globalClients)) {
if (!globalReadyClients.has(socket)) {
markGlobalClientReady(socket, globalClientLastEventIds.get(socket) ?? '');
}
}
return;
}
if (status.type === 'initial-error') {
const error = status.error;
if (error?.type === 'upstream_unavailable') {
closeGlobalClientsWithInitialError({
message: `OpenCode event stream unavailable (${error.status})`,
closeReason: 'OpenCode event stream unavailable',
triggerHealthCheckFor: error.response,
});
return;
}
closeGlobalClientsWithInitialError({
message: status.buildUrlFailed ? 'OpenCode service unavailable' : 'Failed to connect to OpenCode event stream',
closeReason: status.buildUrlFailed ? 'OpenCode service unavailable' : 'Failed to connect to OpenCode event stream',
triggerHealthCheckFor: !status.buildUrlFailed,
});
return;
}
if (status.type === 'error' && status.error?.type === 'stream_error') {
console.warn('Message stream WS proxy error:', status.error.error);
}
});
const stopGlobalHubIfUnused = () => {
if (ownsGlobalHub && globalClients.size === 0) {
globalHub.stop();
}
};
wsServer.on('connection', (socket, req) => {
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
const pathname = parseRequestPathname(rawUrl);
@@ -87,23 +236,61 @@ export function createMessageStreamWsRuntime({
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
if (isGlobalStream) {
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) {
return;
}
try {
socket.ping();
} catch {
}
}, heartbeatIntervalMs);
const heartbeatInterval = setInterval(() => {
if (!globalHub.isConnected()) {
return;
}
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, { directory: 'global' });
}, heartbeatIntervalMs);
socket.on('close', () => {
clearInterval(pingInterval);
clearInterval(heartbeatInterval);
globalClients.delete(socket);
globalClientLastEventIds.delete(socket);
globalReadyClients.delete(socket);
wsClients.delete(socket);
stopGlobalHubIfUnused();
});
socket.on('error', () => {
void 0;
});
globalClients.add(socket);
globalClientLastEventIds.set(socket, requestedLastEventId);
globalHub.start();
if (globalHub.isConnected()) {
markGlobalClientReady(socket, requestedLastEventId);
}
return;
}
const controller = new AbortController();
let currentUpstreamAbortController = null;
let upstreamConnected = false;
let streamReady = false;
let lastEventId = requestedLastEventId;
let reader = null;
const cleanup = () => {
if (!controller.signal.aborted) {
controller.abort();
}
if (currentUpstreamAbortController && !currentUpstreamAbortController.signal.aborted) {
currentUpstreamAbortController.abort();
}
reader?.stop();
wsClients.delete(socket);
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) {
return;
@@ -135,25 +322,11 @@ export function createMessageStreamWsRuntime({
});
const run = async () => {
const forwardBlock = (block) => {
if (!block) {
return;
}
const envelope = parseSseEventEnvelope(block);
const payload = envelope?.payload ?? null;
if (!payload) {
return;
}
const forwardEvent = ({ envelope, payload }) => {
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,
@@ -165,149 +338,85 @@ export function createMessageStreamWsRuntime({
};
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;
let buildUrlFailed = false;
const closeWithInitialError = ({ message, closeReason = message, triggerHealthCheckFor = null }) => {
sendMessageStreamWsFrame(socket, { type: 'error', message });
socket.close(1011, closeReason);
if (triggerHealthCheckFor === true || (triggerHealthCheckFor && shouldTriggerUpstreamHealthCheck(triggerHealthCheckFor))) {
triggerHealthCheck?.();
}
reader?.stop();
cleanup();
};
if (!isGlobalStream && requestedDirectory) {
targetUrl.searchParams.set('directory', requestedDirectory);
}
reader = createUpstreamSseReader({
initialLastEventId: requestedLastEventId,
signal: controller.signal,
stallTimeoutMs: upstreamStallTimeoutMs,
reconnectDelayMs: upstreamReconnectDelayMs,
fetchImpl,
buildUrl: () => {
buildUrlFailed = false;
let targetUrl;
try {
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
} catch {
buildUrlFailed = true;
throw new Error('OpenCode service unavailable');
}
const headers = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...getOpenCodeAuthHeaders(),
};
if (lastEventId) {
headers['Last-Event-ID'] = lastEventId;
}
if (requestedDirectory) {
targetUrl.searchParams.set('directory', requestedDirectory);
}
const upstreamController = new AbortController();
currentUpstreamAbortController = upstreamController;
const abortUpstream = () => upstreamController.abort();
controller.signal.addEventListener('abort', abortUpstream, { once: true });
return targetUrl;
},
getHeaders: getOpenCodeAuthHeaders,
onConnect() {
if (!streamReady) {
sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: 'directory',
});
streamReady = true;
}
let upstream;
try {
upstream = await fetchImpl(targetUrl.toString(), {
headers,
signal: upstreamController.signal,
});
} catch {
controller.signal.removeEventListener('abort', abortUpstream);
currentUpstreamAbortController = null;
upstreamConnected = true;
},
onDisconnect() {
upstreamConnected = false;
},
onEvent: forwardEvent,
onError(error) {
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})`,
if (error?.type === 'upstream_unavailable') {
closeWithInitialError({
message: `OpenCode event stream unavailable (${error.status})`,
closeReason: 'OpenCode event stream unavailable',
triggerHealthCheckFor: error.response,
});
return;
}
closeWithInitialError({
message: buildUrlFailed ? 'OpenCode service unavailable' : 'Failed to connect to OpenCode event stream',
closeReason: buildUrlFailed ? 'OpenCode service unavailable' : 'Failed to connect to OpenCode event stream',
triggerHealthCheckFor: !buildUrlFailed,
});
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);
if (error?.type === 'stream_error') {
console.warn('Message stream WS proxy error:', error.error);
}
}
},
});
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);
}
await reader.start();
} catch (error) {
if (!controller.signal.aborted) {
console.warn('Message stream WS proxy error:', error);
@@ -367,6 +476,11 @@ export function createMessageStreamWsRuntime({
wsServer,
async close() {
server.off('upgrade', upgradeHandler);
unsubscribeGlobalEvent();
unsubscribeGlobalStatus();
if (ownsGlobalHub) {
globalHub.stop();
}
try {
for (const client of wsServer.clients) {
@@ -127,6 +127,273 @@ describe('event stream broadcaster', () => {
});
describe('message stream websocket runtime', () => {
it('shares one global upstream SSE reader across multiple websocket clients', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let fetchCalls = 0;
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
upstreamReconnectDelayMs: 0,
fetchImpl: async (_url, options) => {
fetchCalls += 1;
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
],
});
},
});
const firstSocket = new FakeSocket();
const secondSocket = new FakeSocket();
runtime.wsServer.emit('connection', firstSocket, { url: '/api/global/event/ws' });
runtime.wsServer.emit('connection', secondSocket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fetchCalls).toBe(1);
expect(firstSocket.sent).toContainEqual({ type: 'ready', scope: 'global' });
expect(secondSocket.sent).toContainEqual({ type: 'ready', scope: 'global' });
expect(firstSocket.sent).toContainEqual({
type: 'event',
payload: { type: 'server.connected', properties: {} },
eventId: 'evt-1',
directory: 'global',
});
expect(secondSocket.sent).toContainEqual({
type: 'event',
payload: { type: 'server.connected', properties: {} },
eventId: 'evt-1',
directory: 'global',
});
firstSocket.close();
secondSocket.close();
await runtime.close();
});
it('replays buffered global events after a reconnecting client Last-Event-ID', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let fetchCalls = 0;
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
upstreamReconnectDelayMs: 0,
fetchImpl: async (_url, options) => {
fetchCalls += 1;
if (fetchCalls === 1) {
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
'id: evt-2\ndata: {"type":"session.updated","properties":{"directory":"/tmp/project"}}\n\n',
],
});
}
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [],
});
},
});
const firstSocket = new FakeSocket();
runtime.wsServer.emit('connection', firstSocket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 5));
firstSocket.close();
const secondSocket = new FakeSocket();
runtime.wsServer.emit('connection', secondSocket, { url: '/api/global/event/ws?lastEventId=evt-1' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(secondSocket.sent).toContainEqual({ type: 'ready', scope: 'global' });
expect(secondSocket.sent).toContainEqual({
type: 'event',
payload: { type: 'session.updated', properties: { directory: '/tmp/project' } },
eventId: 'evt-2',
directory: '/tmp/project',
});
secondSocket.close();
await runtime.close();
});
it('keeps directory websocket streams on separate upstream readers', async () => {
const server = new EventEmitter();
const wsClients = new Set();
const fetchUrls = [];
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
upstreamReconnectDelayMs: 0,
fetchImpl: async (url, options) => {
fetchUrls.push(url);
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
],
});
},
});
const firstSocket = new FakeSocket();
const secondSocket = new FakeSocket();
runtime.wsServer.emit('connection', firstSocket, { url: '/api/event/ws?directory=/tmp/one' });
runtime.wsServer.emit('connection', secondSocket, { url: '/api/event/ws?directory=/tmp/two' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fetchUrls).toHaveLength(2);
expect(new URL(fetchUrls[0]).searchParams.get('directory')).toBe('/tmp/one');
expect(new URL(fetchUrls[1]).searchParams.get('directory')).toBe('/tmp/two');
expect(firstSocket.sent).toContainEqual({ type: 'ready', scope: 'directory' });
expect(secondSocket.sent).toContainEqual({ type: 'ready', scope: 'directory' });
firstSocket.close();
secondSocket.close();
await runtime.close();
});
it('closes the websocket and triggers health check on initial upstream unavailable response', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let triggerHealthCheckCalls = 0;
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
triggerHealthCheck: () => {
triggerHealthCheckCalls += 1;
},
upstreamReconnectDelayMs: 0,
fetchImpl: async () => ({
ok: false,
status: 503,
body: null,
}),
});
const socket = new FakeSocket();
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(socket.sent).toEqual([
{
type: 'error',
message: 'OpenCode event stream unavailable (503)',
},
]);
expect(socket.closeCalls).toEqual([
{
code: 1011,
reason: 'OpenCode event stream unavailable',
},
]);
expect(triggerHealthCheckCalls).toBe(1);
expect(wsClients.size).toBe(0);
await runtime.close();
});
it('closes the websocket without health check when OpenCode URL cannot be built', async () => {
const server = new EventEmitter();
const wsClients = new Set();
let triggerHealthCheckCalls = 0;
let fetchCalls = 0;
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl() {
throw new Error('missing OpenCode port');
},
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload() {},
wsClients,
triggerHealthCheck: () => {
triggerHealthCheckCalls += 1;
},
upstreamReconnectDelayMs: 0,
fetchImpl: async () => {
fetchCalls += 1;
throw new Error('fetch should not be called');
},
});
const socket = new FakeSocket();
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(socket.sent).toEqual([
{
type: 'error',
message: 'OpenCode service unavailable',
},
]);
expect(socket.closeCalls).toEqual([
{
code: 1011,
reason: 'OpenCode service unavailable',
},
]);
expect(fetchCalls).toBe(0);
expect(triggerHealthCheckCalls).toBe(0);
await runtime.close();
});
it('reconnects a stalled upstream SSE stream and resumes from the last event id', async () => {
const server = new EventEmitter();
const wsClients = new Set();
@@ -192,4 +459,54 @@ describe('message stream websocket runtime', () => {
socket.close();
await runtime.close();
});
it('keeps synthetic event processing on forwarded upstream events', async () => {
const server = new EventEmitter();
const wsClients = new Set();
const runtime = createMessageStreamWsRuntime({
server,
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {
throw new Error('upgrade should not be used in this test');
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
processForwardedEventPayload(payload, emitSynthetic) {
if (payload.type === 'session.updated') {
emitSynthetic({ type: 'openchamber:session-status', sessionID: 'ses_1' });
}
},
wsClients,
upstreamReconnectDelayMs: 0,
fetchImpl: async (_url, options) => createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"session.updated","properties":{"directory":"/tmp/project"}}\n\n',
],
}),
});
const socket = new FakeSocket();
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(socket.sent).toContainEqual({
type: 'event',
payload: { type: 'session.updated', properties: { directory: '/tmp/project' } },
eventId: 'evt-1',
directory: '/tmp/project',
});
expect(socket.sent).toContainEqual({
type: 'event',
payload: { type: 'openchamber:session-status', sessionID: 'ses_1' },
directory: 'global',
});
socket.close();
await runtime.close();
});
});
@@ -0,0 +1,204 @@
import { parseSseEventEnvelope } from './protocol.js';
export const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20_000;
export const DEFAULT_UPSTREAM_RECONNECT_DELAY_MS = 250;
function waitForReconnectDelay(ms, signal) {
if (signal?.aborted) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timeout = setTimeout(resolve, Math.max(0, ms));
signal?.addEventListener('abort', () => {
clearTimeout(timeout);
resolve();
}, { once: true });
});
}
function normalizeHeaders(headers) {
if (!headers || typeof headers !== 'object') {
return {};
}
return { ...headers };
}
export function createUpstreamSseReader({
buildUrl,
getHeaders = () => ({}),
fetchImpl = fetch,
parseBlock = parseSseEventEnvelope,
initialLastEventId = '',
signal,
stallTimeoutMs = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
reconnectDelayMs = DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
onEvent,
onConnect,
onDisconnect,
onError,
}) {
let running = null;
let stopped = false;
let activeController = null;
let lastEventId = typeof initialLastEventId === 'string' ? initialLastEventId : '';
const stop = () => {
stopped = true;
if (activeController && !activeController.signal.aborted) {
activeController.abort();
}
};
signal?.addEventListener('abort', stop, { once: true });
const start = () => {
if (running) {
return running;
}
stopped = false;
running = (async () => {
while (!stopped && !signal?.aborted) {
const controller = new AbortController();
activeController = controller;
const abortActive = () => controller.abort();
signal?.addEventListener('abort', abortActive, { once: true });
let abortReason = null;
let stallTimer = null;
const clearStallTimer = () => {
if (stallTimer) {
clearTimeout(stallTimer);
stallTimer = null;
}
};
const resetStallTimer = () => {
clearStallTimer();
if (stallTimeoutMs <= 0) {
return;
}
stallTimer = setTimeout(() => {
abortReason = 'upstream_stalled';
controller.abort();
}, stallTimeoutMs);
};
try {
const url = buildUrl();
const headers = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...normalizeHeaders(getHeaders()),
};
if (lastEventId) {
headers['Last-Event-ID'] = lastEventId;
}
const response = await fetchImpl(url.toString(), {
headers,
signal: controller.signal,
});
if (!response?.ok || !response.body) {
onError?.({
type: 'upstream_unavailable',
status: response?.status ?? 0,
response,
});
await waitForReconnectDelay(reconnectDelayMs, signal);
continue;
}
onConnect?.({ response, lastEventId });
const decoder = new TextDecoder();
const reader = response.body.getReader();
let buffer = '';
resetStallTimer();
while (!stopped && !signal?.aborted) {
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 && !stopped && !signal?.aborted) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
const envelope = parseBlock(block);
if (envelope?.payload) {
if (typeof envelope.eventId === 'string' && envelope.eventId.length > 0) {
lastEventId = envelope.eventId;
}
onEvent?.({
block,
envelope,
payload: envelope.payload,
eventId: envelope.eventId,
directory: envelope.directory,
});
}
separatorIndex = buffer.indexOf('\n\n');
}
}
if (!stopped && !signal?.aborted && buffer.trim().length > 0) {
const block = buffer.trim();
const envelope = parseBlock(block);
if (envelope?.payload) {
if (typeof envelope.eventId === 'string' && envelope.eventId.length > 0) {
lastEventId = envelope.eventId;
}
onEvent?.({
block,
envelope,
payload: envelope.payload,
eventId: envelope.eventId,
directory: envelope.directory,
});
}
}
} catch (error) {
if (!stopped && !signal?.aborted && abortReason !== 'upstream_stalled') {
onError?.({
type: 'stream_error',
error,
});
}
} finally {
clearStallTimer();
signal?.removeEventListener('abort', abortActive);
if (activeController === controller) {
activeController = null;
}
onDisconnect?.({ reason: abortReason ?? (stopped || signal?.aborted ? 'stopped' : 'closed') });
}
if (!stopped && !signal?.aborted) {
await waitForReconnectDelay(reconnectDelayMs, signal);
}
}
})().finally(() => {
running = null;
});
return running;
};
return {
start,
stop,
getLastEventId() {
return lastEventId;
},
};
}
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { createUpstreamSseReader } from './upstream-reader.js';
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
const encoder = new TextEncoder();
let index = 0;
return {
ok: true,
status: 200,
body: {
getReader() {
return {
async read() {
if (index < blocks.length) {
return { value: encoder.encode(blocks[index++]), done: false };
}
if (!holdOpen) {
return { value: undefined, done: true };
}
return new Promise((_resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
});
},
};
},
},
};
}
describe('createUpstreamSseReader', () => {
it('emits parsed events and tracks the latest event id', async () => {
const events = [];
let reader;
reader = createUpstreamSseReader({
buildUrl: () => 'http://127.0.0.1:4096/global/event',
reconnectDelayMs: 0,
fetchImpl: async (_url, options) => createSseResponse({
signal: options.signal,
blocks: [
'id: evt-1\r\ndata: {"type":"server.connected","properties":{"directory":"/tmp/project"}}\r\n\r\n',
],
}),
onEvent(event) {
events.push(event);
reader.stop();
},
});
await reader.start();
expect(events).toHaveLength(1);
expect(events[0].eventId).toBe('evt-1');
expect(events[0].directory).toBe('/tmp/project');
expect(events[0].payload).toEqual({
type: 'server.connected',
properties: {
directory: '/tmp/project',
},
});
expect(reader.getLastEventId()).toBe('evt-1');
});
it('reconnects a stalled stream with Last-Event-ID', async () => {
const fetchLastEventIds = [];
const events = [];
let attempt = 0;
let reader;
reader = createUpstreamSseReader({
buildUrl: () => 'http://127.0.0.1:4096/global/event',
stallTimeoutMs: 10,
reconnectDelayMs: 0,
fetchImpl: async (_url, options) => {
fetchLastEventIds.push(options.headers['Last-Event-ID'] ?? null);
attempt += 1;
if (attempt === 1) {
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
],
});
}
return createSseResponse({
signal: options.signal,
blocks: [
'id: evt-2\ndata: {"type":"session.updated","properties":{}}\n\n',
],
});
},
onEvent(event) {
events.push(event.eventId);
if (event.eventId === 'evt-2') {
reader.stop();
}
},
});
await reader.start();
expect(events).toEqual(['evt-1', 'evt-2']);
expect(fetchLastEventIds.slice(0, 2)).toEqual([null, 'evt-1']);
expect(reader.getLastEventId()).toBe('evt-2');
});
it('reports unavailable upstream responses and continues reconnecting until stopped', async () => {
const errors = [];
let attempt = 0;
let reader;
reader = createUpstreamSseReader({
buildUrl: () => 'http://127.0.0.1:4096/global/event',
reconnectDelayMs: 0,
fetchImpl: async (_url, options) => {
attempt += 1;
if (attempt === 1) {
return { ok: false, status: 503, body: null };
}
return createSseResponse({
signal: options.signal,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
],
});
},
onError(error) {
errors.push(error);
},
onEvent() {
reader.stop();
},
});
await reader.start();
expect(errors).toEqual([
expect.objectContaining({
type: 'upstream_unavailable',
status: 503,
}),
]);
expect(attempt).toBe(2);
});
});
@@ -335,10 +335,16 @@ This module provides OpenCode server integration utilities for the web server ru
- OpenCode readiness gate for proxied `/api` requests
## Public exports (watcher.js)
- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime.
- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime backed by the shared upstream SSE reader.
- Returned API:
- `start()`
- `stop()`
- Behavior:
- Waits for OpenCode readiness before attaching the watcher.
- In production wiring, subscribes to the shared global message-stream hub instead of opening its own `/global/event` connection.
- Can still create its own `/global/event` reader when no shared hub is provided, which keeps module tests and isolated reuse simple.
- Reuses event-stream parsing, `Last-Event-ID`, stall timeout, and reconnect behavior.
- Forwards unwrapped global event payloads into notification/session side effects.
## Storage and configuration
- Provider auth: `~/.local/share/opencode/auth.json`.
@@ -20,6 +20,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
globalEventHub,
processForwardedEventPayload,
messageStreamWsClients,
triggerHealthCheck,
@@ -75,6 +76,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
globalEventHub,
processForwardedEventPayload,
wsClients: messageStreamWsClients,
triggerHealthCheck,
+61 -42
View File
@@ -1,4 +1,4 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { createUpstreamSseReader } from '../event-stream/upstream-reader.js';
export const createOpenCodeWatcherRuntime = (deps) => {
const {
@@ -6,9 +6,16 @@ export const createOpenCodeWatcherRuntime = (deps) => {
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
onPayload,
fetchImpl = fetch,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs = 1000,
globalEventHub = null,
} = deps;
let abortController = null;
let reader = null;
let unsubscribeEvent = null;
let unsubscribeStatus = null;
const unwrapGlobalEventPayload = (eventData) => {
if (!eventData || typeof eventData !== 'object') {
@@ -32,50 +39,56 @@ export const createOpenCodeWatcherRuntime = (deps) => {
abortController = new AbortController();
const signal = abortController.signal;
let attempt = 0;
const run = async () => {
while (!signal.aborted) {
attempt += 1;
try {
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const client = createOpencodeClient({
baseUrl,
headers: getOpenCodeAuthHeaders(),
});
const result = await client.global.event({
signal,
sseMaxRetryAttempts: 0,
onSseEvent: (event) => {
const payload = unwrapGlobalEventPayload(event.data);
if (!payload || typeof payload !== 'object') {
return;
}
onPayload(payload);
},
});
console.log('[PushWatcher] connected');
for await (const _ of result.stream) {
void _;
if (signal.aborted) {
break;
}
}
} catch (error) {
if (signal.aborted) {
return;
}
console.warn('[PushWatcher] disconnected', error?.message ?? error);
if (globalEventHub) {
unsubscribeEvent = globalEventHub.subscribeEvent((event) => {
const payload = unwrapGlobalEventPayload(event.payload);
if (!payload || typeof payload !== 'object') {
return;
}
onPayload(payload);
});
unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
if (signal.aborted) {
return;
}
if (status.type === 'connect') {
console.log('[PushWatcher] connected');
return;
}
if (status.type === 'error' || status.type === 'initial-error') {
console.warn('[PushWatcher] disconnected', status.error?.error?.message ?? status.error?.message ?? status.error);
}
});
globalEventHub.start();
return;
}
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
};
reader = createUpstreamSseReader({
signal,
buildUrl: () => buildOpenCodeUrl('/global/event', ''),
getHeaders: getOpenCodeAuthHeaders,
fetchImpl,
stallTimeoutMs: upstreamStallTimeoutMs,
reconnectDelayMs: upstreamReconnectDelayMs,
onConnect() {
console.log('[PushWatcher] connected');
},
onEvent(event) {
const payload = unwrapGlobalEventPayload(event.payload);
if (!payload || typeof payload !== 'object') {
return;
}
onPayload(payload);
},
onError(error) {
if (signal.aborted) {
return;
}
console.warn('[PushWatcher] disconnected', error?.error?.message ?? error?.message ?? error);
},
});
void run();
void reader.start();
};
const stop = () => {
@@ -84,8 +97,14 @@ export const createOpenCodeWatcherRuntime = (deps) => {
}
try {
abortController.abort();
reader?.stop();
unsubscribeEvent?.();
unsubscribeStatus?.();
} catch {
}
reader = null;
unsubscribeEvent = null;
unsubscribeStatus = null;
abortController = null;
};
@@ -0,0 +1,239 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
import { createOpenCodeWatcherRuntime } from './watcher.js';
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
const encoder = new TextEncoder();
let index = 0;
return {
ok: true,
status: 200,
body: {
getReader() {
return {
async read() {
if (index < blocks.length) {
return { value: encoder.encode(blocks[index++]), done: false };
}
if (!holdOpen) {
return { value: undefined, done: true };
}
return new Promise((_resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
});
},
};
},
},
};
}
describe('createOpenCodeWatcherRuntime', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('waits for OpenCode readiness and forwards unwrapped global SSE payloads', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
const payloads = [];
const fetchCalls = [];
const watcher = createOpenCodeWatcherRuntime({
waitForOpenCodePort: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
onPayload(payload) {
payloads.push(payload);
watcher.stop();
},
fetchImpl: async (url, options) => {
fetchCalls.push({ url, headers: options.headers });
return createSseResponse({
signal: options.signal,
blocks: [
'id: evt-1\ndata: {"directory":"/tmp/project","payload":{"type":"session.updated","properties":{"sessionID":"ses_1"}}}\n\n',
],
});
},
});
await watcher.start();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fetchCalls).toEqual([
{
url: 'http://127.0.0.1:4096/global/event',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
Authorization: 'Bearer test-token',
},
},
]);
expect(payloads).toEqual([
{
type: 'session.updated',
properties: {
sessionID: 'ses_1',
},
},
]);
});
it('resumes watcher reconnects with Last-Event-ID after a stalled upstream stream', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
const fetchLastEventIds = [];
const payloads = [];
let attempt = 0;
const watcher = createOpenCodeWatcherRuntime({
waitForOpenCodePort: async () => {},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
onPayload(payload) {
payloads.push(payload.type);
if (payload.type === 'session.updated') {
watcher.stop();
}
},
fetchImpl: async (_url, options) => {
fetchLastEventIds.push(options.headers['Last-Event-ID'] ?? null);
attempt += 1;
if (attempt === 1) {
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
],
});
}
return createSseResponse({
signal: options.signal,
blocks: [
'id: evt-2\ndata: {"type":"session.updated","properties":{}}\n\n',
],
});
},
upstreamStallTimeoutMs: 10,
upstreamReconnectDelayMs: 0,
});
await watcher.start();
await new Promise((resolve) => setTimeout(resolve, 30));
expect(payloads).toEqual(['server.connected', 'session.updated']);
expect(fetchLastEventIds.slice(0, 2)).toEqual([null, 'evt-1']);
});
it('subscribes to a shared global event hub instead of opening its own upstream stream', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
const payloads = [];
let hubFetchCalls = 0;
let watcherFetchCalls = 0;
const globalEventHub = createGlobalMessageStreamHub({
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
upstreamReconnectDelayMs: 0,
fetchImpl: async (_url, options) => {
hubFetchCalls += 1;
return createSseResponse({
signal: options.signal,
holdOpen: true,
blocks: [
'id: evt-1\ndata: {"payload":{"type":"session.updated","properties":{"sessionID":"ses_1"}}}\n\n',
],
});
},
});
const watcher = createOpenCodeWatcherRuntime({
waitForOpenCodePort: async () => {},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
globalEventHub,
onPayload(payload) {
payloads.push(payload);
watcher.stop();
},
fetchImpl: async () => {
watcherFetchCalls += 1;
throw new Error('watcher fetch should not be called');
},
});
await watcher.start();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(hubFetchCalls).toBe(1);
expect(watcherFetchCalls).toBe(0);
expect(payloads).toEqual([
{
type: 'session.updated',
properties: {
sessionID: 'ses_1',
},
},
]);
});
it('does not stop a shared global event hub when the watcher stops', async () => {
const events = new Set();
const statuses = new Set();
let startCalls = 0;
let stopCalls = 0;
const globalEventHub = {
start() {
startCalls += 1;
},
stop() {
stopCalls += 1;
},
subscribeEvent(subscriber) {
events.add(subscriber);
return () => {
events.delete(subscriber);
};
},
subscribeStatus(subscriber) {
statuses.add(subscriber);
return () => {
statuses.delete(subscriber);
};
},
};
const watcher = createOpenCodeWatcherRuntime({
waitForOpenCodePort: async () => {},
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
getOpenCodeAuthHeaders: () => ({}),
globalEventHub,
onPayload() {},
});
await watcher.start();
watcher.stop();
expect(startCalls).toBe(1);
expect(stopCalls).toBe(0);
expect(events.size).toBe(0);
expect(statuses.size).toBe(0);
});
});