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
@@ -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);
});
});