Share upstream event stream hub
This commit is contained in:
@@ -33,6 +33,7 @@ import { detectSayTtsCapability } from './lib/tts/capability-runtime.js';
|
|||||||
import { createTerminalRuntime } from './lib/terminal/runtime.js';
|
import { createTerminalRuntime } from './lib/terminal/runtime.js';
|
||||||
import {
|
import {
|
||||||
createGlobalUiEventBroadcaster,
|
createGlobalUiEventBroadcaster,
|
||||||
|
createGlobalMessageStreamHub,
|
||||||
createMessageStreamWsRuntime,
|
createMessageStreamWsRuntime,
|
||||||
} from './lib/event-stream/index.js';
|
} from './lib/event-stream/index.js';
|
||||||
import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js';
|
import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js';
|
||||||
@@ -648,11 +649,17 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
|
|||||||
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
||||||
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
|
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
|
||||||
|
|
||||||
|
const globalMessageStreamHub = createGlobalMessageStreamHub({
|
||||||
|
buildOpenCodeUrl,
|
||||||
|
getOpenCodeAuthHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
||||||
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
parseSseDataPayload: (...args) => parseSseDataPayload(...args),
|
parseSseDataPayload: (...args) => parseSseDataPayload(...args),
|
||||||
|
globalEventHub: globalMessageStreamHub,
|
||||||
onPayload: (payload) => {
|
onPayload: (payload) => {
|
||||||
maybeCacheSessionInfoFromEvent(payload);
|
maybeCacheSessionInfoFromEvent(payload);
|
||||||
void maybeSendPushForTrigger(payload);
|
void maybeSendPushForTrigger(payload);
|
||||||
@@ -1155,6 +1162,7 @@ async function main(options = {}) {
|
|||||||
rejectWebSocketUpgrade,
|
rejectWebSocketUpgrade,
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
globalEventHub: globalMessageStreamHub,
|
||||||
processForwardedEventPayload,
|
processForwardedEventPayload,
|
||||||
messageStreamWsClients: uiNotificationWsClients,
|
messageStreamWsClients: uiNotificationWsClients,
|
||||||
terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
|
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
|
## 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/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/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/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.
|
- `packages/web/server/lib/event-stream/runtime.test.js`: unit tests for runtime-side broadcaster behavior.
|
||||||
|
|
||||||
## Public exports
|
## 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`.
|
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
|
||||||
|
|
||||||
### Runtime helpers
|
### 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.
|
- `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.
|
- `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
|
## Runtime behavior
|
||||||
- Browser clients connect to the WS endpoints above.
|
- Browser clients connect to the WS endpoints above.
|
||||||
- OpenChamber still fetches OpenCode upstream event streams over SSE.
|
- OpenChamber still fetches OpenCode upstream event streams over SSE.
|
||||||
- Each WS connection proxies one upstream SSE stream.
|
- 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.
|
||||||
- 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 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.
|
- 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 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.
|
- 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
|
## Notes for contributors
|
||||||
- Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
|
- 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.
|
- 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.
|
- 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
|
## Testing
|
||||||
- Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
|
- 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 `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`
|
- 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,
|
createGlobalUiEventBroadcaster,
|
||||||
createMessageStreamWsRuntime,
|
createMessageStreamWsRuntime,
|
||||||
} from './runtime.js';
|
} 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';
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import {
|
|||||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||||
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
||||||
parseSseEventEnvelope,
|
|
||||||
sendMessageStreamWsEvent,
|
sendMessageStreamWsEvent,
|
||||||
sendMessageStreamWsFrame,
|
sendMessageStreamWsFrame,
|
||||||
} from './protocol.js';
|
} from './protocol.js';
|
||||||
|
import { createGlobalMessageStreamHub } from './global-hub.js';
|
||||||
const MESSAGE_STREAM_UPSTREAM_STALL_TIMEOUT_MS = 20_000;
|
import {
|
||||||
const MESSAGE_STREAM_UPSTREAM_RECONNECT_DELAY_MS = 250;
|
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
|
||||||
|
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
||||||
|
createUpstreamSseReader,
|
||||||
|
} from './upstream-reader.js';
|
||||||
|
|
||||||
function shouldTriggerUpstreamHealthCheck(upstream) {
|
function shouldTriggerUpstreamHealthCheck(upstream) {
|
||||||
if (!upstream) {
|
if (!upstream) {
|
||||||
@@ -71,14 +73,161 @@ export function createMessageStreamWsRuntime({
|
|||||||
wsClients,
|
wsClients,
|
||||||
triggerHealthCheck,
|
triggerHealthCheck,
|
||||||
heartbeatIntervalMs = MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
heartbeatIntervalMs = MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
||||||
upstreamStallTimeoutMs = MESSAGE_STREAM_UPSTREAM_STALL_TIMEOUT_MS,
|
upstreamStallTimeoutMs = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
||||||
upstreamReconnectDelayMs = MESSAGE_STREAM_UPSTREAM_RECONNECT_DELAY_MS,
|
upstreamReconnectDelayMs = DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
|
globalEventHub = null,
|
||||||
}) {
|
}) {
|
||||||
const wsServer = new WebSocketServer({
|
const wsServer = new WebSocketServer({
|
||||||
noServer: true,
|
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) => {
|
wsServer.on('connection', (socket, req) => {
|
||||||
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
|
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
|
||||||
const pathname = parseRequestPathname(rawUrl);
|
const pathname = parseRequestPathname(rawUrl);
|
||||||
@@ -87,23 +236,61 @@ export function createMessageStreamWsRuntime({
|
|||||||
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
|
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
|
||||||
const requestedDirectory = requestUrl.searchParams.get('directory')?.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();
|
const controller = new AbortController();
|
||||||
let currentUpstreamAbortController = null;
|
|
||||||
let upstreamConnected = false;
|
let upstreamConnected = false;
|
||||||
let streamReady = false;
|
let streamReady = false;
|
||||||
let lastEventId = requestedLastEventId;
|
let reader = null;
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}
|
}
|
||||||
if (currentUpstreamAbortController && !currentUpstreamAbortController.signal.aborted) {
|
reader?.stop();
|
||||||
currentUpstreamAbortController.abort();
|
|
||||||
}
|
|
||||||
wsClients.delete(socket);
|
wsClients.delete(socket);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
if (socket.readyState !== 1) {
|
if (socket.readyState !== 1) {
|
||||||
return;
|
return;
|
||||||
@@ -135,25 +322,11 @@ export function createMessageStreamWsRuntime({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const forwardBlock = (block) => {
|
const forwardEvent = ({ envelope, payload }) => {
|
||||||
if (!block) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envelope = parseSseEventEnvelope(block);
|
|
||||||
const payload = envelope?.payload ?? null;
|
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = isGlobalStream
|
const directory = isGlobalStream
|
||||||
? (typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global')
|
? (typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global')
|
||||||
: (requestedDirectory || envelope?.directory || 'global');
|
: (requestedDirectory || envelope?.directory || 'global');
|
||||||
|
|
||||||
if (typeof envelope?.eventId === 'string' && envelope.eventId.length > 0) {
|
|
||||||
lastEventId = envelope.eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessageStreamWsEvent(socket, payload, {
|
sendMessageStreamWsEvent(socket, payload, {
|
||||||
directory,
|
directory,
|
||||||
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
|
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
|
||||||
@@ -165,149 +338,85 @@ export function createMessageStreamWsRuntime({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!controller.signal.aborted) {
|
let buildUrlFailed = false;
|
||||||
let targetUrl;
|
const closeWithInitialError = ({ message, closeReason = message, triggerHealthCheckFor = null }) => {
|
||||||
try {
|
sendMessageStreamWsFrame(socket, { type: 'error', message });
|
||||||
targetUrl = new URL(buildOpenCodeUrl(isGlobalStream ? '/global/event' : '/event', ''));
|
socket.close(1011, closeReason);
|
||||||
} catch {
|
if (triggerHealthCheckFor === true || (triggerHealthCheckFor && shouldTriggerUpstreamHealthCheck(triggerHealthCheckFor))) {
|
||||||
sendMessageStreamWsFrame(socket, { type: 'error', message: 'OpenCode service unavailable' });
|
triggerHealthCheck?.();
|
||||||
socket.close(1011, 'OpenCode service unavailable');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
reader?.stop();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
if (!isGlobalStream && requestedDirectory) {
|
reader = createUpstreamSseReader({
|
||||||
targetUrl.searchParams.set('directory', requestedDirectory);
|
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 = {
|
if (requestedDirectory) {
|
||||||
Accept: 'text/event-stream',
|
targetUrl.searchParams.set('directory', requestedDirectory);
|
||||||
'Cache-Control': 'no-cache',
|
}
|
||||||
Connection: 'keep-alive',
|
|
||||||
...getOpenCodeAuthHeaders(),
|
|
||||||
};
|
|
||||||
if (lastEventId) {
|
|
||||||
headers['Last-Event-ID'] = lastEventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstreamController = new AbortController();
|
return targetUrl;
|
||||||
currentUpstreamAbortController = upstreamController;
|
},
|
||||||
const abortUpstream = () => upstreamController.abort();
|
getHeaders: getOpenCodeAuthHeaders,
|
||||||
controller.signal.addEventListener('abort', abortUpstream, { once: true });
|
onConnect() {
|
||||||
|
if (!streamReady) {
|
||||||
|
sendMessageStreamWsFrame(socket, {
|
||||||
|
type: 'ready',
|
||||||
|
scope: 'directory',
|
||||||
|
});
|
||||||
|
streamReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
let upstream;
|
upstreamConnected = true;
|
||||||
try {
|
},
|
||||||
upstream = await fetchImpl(targetUrl.toString(), {
|
onDisconnect() {
|
||||||
headers,
|
|
||||||
signal: upstreamController.signal,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
controller.signal.removeEventListener('abort', abortUpstream);
|
|
||||||
currentUpstreamAbortController = null;
|
|
||||||
upstreamConnected = false;
|
upstreamConnected = false;
|
||||||
|
},
|
||||||
|
onEvent: forwardEvent,
|
||||||
|
onError(error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
return;
|
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) {
|
if (!streamReady) {
|
||||||
sendMessageStreamWsFrame(socket, {
|
if (error?.type === 'upstream_unavailable') {
|
||||||
type: 'error',
|
closeWithInitialError({
|
||||||
message: `OpenCode event stream unavailable (${upstream.status})`,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await wait(upstreamReconnectDelayMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!streamReady) {
|
if (error?.type === 'stream_error') {
|
||||||
sendMessageStreamWsFrame(socket, {
|
console.warn('Message stream WS proxy error:', error.error);
|
||||||
type: 'ready',
|
|
||||||
scope: isGlobalStream ? 'global' : 'directory',
|
|
||||||
});
|
|
||||||
streamReady = true;
|
|
||||||
|
|
||||||
if (isGlobalStream) {
|
|
||||||
wsClients.add(socket);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
upstreamConnected = true;
|
await reader.start();
|
||||||
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) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.warn('Message stream WS proxy error:', error);
|
console.warn('Message stream WS proxy error:', error);
|
||||||
@@ -367,6 +476,11 @@ export function createMessageStreamWsRuntime({
|
|||||||
wsServer,
|
wsServer,
|
||||||
async close() {
|
async close() {
|
||||||
server.off('upgrade', upgradeHandler);
|
server.off('upgrade', upgradeHandler);
|
||||||
|
unsubscribeGlobalEvent();
|
||||||
|
unsubscribeGlobalStatus();
|
||||||
|
if (ownsGlobalHub) {
|
||||||
|
globalHub.stop();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const client of wsServer.clients) {
|
for (const client of wsServer.clients) {
|
||||||
|
|||||||
@@ -127,6 +127,273 @@ describe('event stream broadcaster', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('message stream websocket runtime', () => {
|
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 () => {
|
it('reconnects a stalled upstream SSE stream and resumes from the last event id', async () => {
|
||||||
const server = new EventEmitter();
|
const server = new EventEmitter();
|
||||||
const wsClients = new Set();
|
const wsClients = new Set();
|
||||||
@@ -192,4 +459,54 @@ describe('message stream websocket runtime', () => {
|
|||||||
socket.close();
|
socket.close();
|
||||||
await runtime.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
|
- OpenCode readiness gate for proxied `/api` requests
|
||||||
|
|
||||||
## Public exports (watcher.js)
|
## 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:
|
- Returned API:
|
||||||
- `start()`
|
- `start()`
|
||||||
- `stop()`
|
- `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
|
## Storage and configuration
|
||||||
- Provider auth: `~/.local/share/opencode/auth.json`.
|
- Provider auth: `~/.local/share/opencode/auth.json`.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
|||||||
rejectWebSocketUpgrade,
|
rejectWebSocketUpgrade,
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
globalEventHub,
|
||||||
processForwardedEventPayload,
|
processForwardedEventPayload,
|
||||||
messageStreamWsClients,
|
messageStreamWsClients,
|
||||||
triggerHealthCheck,
|
triggerHealthCheck,
|
||||||
@@ -75,6 +76,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
|||||||
rejectWebSocketUpgrade,
|
rejectWebSocketUpgrade,
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
|
globalEventHub,
|
||||||
processForwardedEventPayload,
|
processForwardedEventPayload,
|
||||||
wsClients: messageStreamWsClients,
|
wsClients: messageStreamWsClients,
|
||||||
triggerHealthCheck,
|
triggerHealthCheck,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
import { createUpstreamSseReader } from '../event-stream/upstream-reader.js';
|
||||||
|
|
||||||
export const createOpenCodeWatcherRuntime = (deps) => {
|
export const createOpenCodeWatcherRuntime = (deps) => {
|
||||||
const {
|
const {
|
||||||
@@ -6,9 +6,16 @@ export const createOpenCodeWatcherRuntime = (deps) => {
|
|||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
onPayload,
|
onPayload,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
upstreamStallTimeoutMs,
|
||||||
|
upstreamReconnectDelayMs = 1000,
|
||||||
|
globalEventHub = null,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
let abortController = null;
|
let abortController = null;
|
||||||
|
let reader = null;
|
||||||
|
let unsubscribeEvent = null;
|
||||||
|
let unsubscribeStatus = null;
|
||||||
|
|
||||||
const unwrapGlobalEventPayload = (eventData) => {
|
const unwrapGlobalEventPayload = (eventData) => {
|
||||||
if (!eventData || typeof eventData !== 'object') {
|
if (!eventData || typeof eventData !== 'object') {
|
||||||
@@ -32,50 +39,56 @@ export const createOpenCodeWatcherRuntime = (deps) => {
|
|||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
let attempt = 0;
|
if (globalEventHub) {
|
||||||
const run = async () => {
|
unsubscribeEvent = globalEventHub.subscribeEvent((event) => {
|
||||||
while (!signal.aborted) {
|
const payload = unwrapGlobalEventPayload(event.payload);
|
||||||
attempt += 1;
|
if (!payload || typeof payload !== 'object') {
|
||||||
try {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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);
|
reader = createUpstreamSseReader({
|
||||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
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 = () => {
|
const stop = () => {
|
||||||
@@ -84,8 +97,14 @@ export const createOpenCodeWatcherRuntime = (deps) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
reader?.stop();
|
||||||
|
unsubscribeEvent?.();
|
||||||
|
unsubscribeStatus?.();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
reader = null;
|
||||||
|
unsubscribeEvent = null;
|
||||||
|
unsubscribeStatus = null;
|
||||||
abortController = 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