Share upstream event stream hub
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user