feat(web): add WebSocket transport for message event streaming with SSE fallback (#764)
* feat: add websocket message stream transport * fix: avoid false missing session directories in sidebar * fix: re-probe project root session directories * refactor: use button group for message stream transport * fix: resolve chat input hook dependency warning --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
fd8972a7d9
commit
bee9d19f3a
@@ -30,6 +30,10 @@ import { prepareNotificationLastMessage } from './lib/notifications/index.js';
|
||||
import { registerTtsRoutes } from './lib/tts/routes.js';
|
||||
import { detectSayTtsCapability } from './lib/tts/capability-runtime.js';
|
||||
import { createTerminalRuntime } from './lib/terminal/runtime.js';
|
||||
import {
|
||||
createGlobalUiEventBroadcaster,
|
||||
createMessageStreamWsRuntime,
|
||||
} from './lib/event-stream/index.js';
|
||||
import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js';
|
||||
import { createOpenCodeLifecycleRuntime } from './lib/opencode/lifecycle.js';
|
||||
import { createOpenCodeEnvRuntime } from './lib/opencode/env-runtime.js';
|
||||
@@ -76,6 +80,7 @@ const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
|
||||
const uiNotificationClients = new Set();
|
||||
const uiNotificationWsClients = new Set();
|
||||
const uiOpenChamberEventClients = new Set();
|
||||
const HEALTH_CHECK_INTERVAL = 15000;
|
||||
const SHUTDOWN_TIMEOUT = 10000;
|
||||
@@ -306,15 +311,22 @@ const notificationEmitterRuntime = createNotificationEmitterRuntime({
|
||||
getDesktopNotifyEnabled: () => ENV_DESKTOP_NOTIFY,
|
||||
desktopNotifyPrefix: DESKTOP_NOTIFY_PREFIX,
|
||||
getUiNotificationClients: () => uiNotificationClients,
|
||||
getBroadcastGlobalUiEvent: () => broadcastGlobalUiEvent,
|
||||
});
|
||||
|
||||
const writeSseEvent = (...args) => notificationEmitterRuntime.writeSseEvent(...args);
|
||||
const emitDesktopNotification = (...args) => notificationEmitterRuntime.emitDesktopNotification(...args);
|
||||
const broadcastGlobalUiEvent = createGlobalUiEventBroadcaster({
|
||||
sseClients: uiNotificationClients,
|
||||
wsClients: uiNotificationWsClients,
|
||||
writeSseEvent,
|
||||
});
|
||||
const broadcastUiNotification = (...args) => notificationEmitterRuntime.broadcastUiNotification(...args);
|
||||
|
||||
const sessionRuntime = createSessionRuntime({
|
||||
writeSseEvent,
|
||||
getNotificationClients: () => uiNotificationClients,
|
||||
broadcastEvent: broadcastGlobalUiEvent,
|
||||
});
|
||||
|
||||
const projectConfigRuntime = createProjectConfigRuntime({
|
||||
@@ -359,6 +371,7 @@ const tunnelAuthController = createTunnelAuth();
|
||||
let runtimeManagedRemoteTunnelToken = '';
|
||||
let runtimeManagedRemoteTunnelHostname = '';
|
||||
let terminalRuntime = null;
|
||||
let messageStreamRuntime = null;
|
||||
const userProvidedOpenCodePassword = hmrStateRuntime.getUserProvidedOpenCodePassword(hmrState);
|
||||
const initialOpenCodeAuthState = hmrStateRuntime.resolveOpenCodeAuthFromState({
|
||||
hmrState,
|
||||
@@ -597,6 +610,50 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
||||
},
|
||||
});
|
||||
|
||||
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
|
||||
if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
maybeCacheSessionInfoFromEvent(payload);
|
||||
|
||||
if (payload.type !== 'session.status') {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
|
||||
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
|
||||
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
|
||||
const status = typeof info.type === 'string' ? info.type.trim() : '';
|
||||
|
||||
if (!sessionId || !status) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitSyntheticEvent({
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
attempt: typeof info.attempt === 'number' ? info.attempt : undefined,
|
||||
message: typeof info.message === 'string' ? info.message : undefined,
|
||||
next: typeof info.next === 'number' ? info.next : undefined,
|
||||
},
|
||||
needsAttention: false,
|
||||
},
|
||||
});
|
||||
|
||||
emitSyntheticEvent({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase: status === 'busy' || status === 'retry' ? 'busy' : 'idle',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const serverUtilsRuntime = createServerUtilsRuntime({
|
||||
fs,
|
||||
@@ -704,6 +761,7 @@ const tunnelWiringRuntime = createTunnelWiringRuntime({
|
||||
});
|
||||
const startupPipelineRuntime = createStartupPipelineRuntime({
|
||||
createTerminalRuntime,
|
||||
createMessageStreamWsRuntime,
|
||||
createServerStartupRuntime,
|
||||
});
|
||||
|
||||
@@ -842,6 +900,10 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
|
||||
setTerminalRuntime: (value) => {
|
||||
terminalRuntime = value;
|
||||
},
|
||||
getMessageStreamRuntime: () => messageStreamRuntime,
|
||||
setMessageStreamRuntime: (value) => {
|
||||
messageStreamRuntime = value;
|
||||
},
|
||||
shouldSkipOpenCodeStop: () => ENV_SKIP_OPENCODE_START || isExternalOpenCode,
|
||||
getOpenCodePort: () => openCodePort,
|
||||
getOpenCodeProcess: () => openCodeProcess,
|
||||
@@ -1028,6 +1090,10 @@ async function main(options = {}) {
|
||||
isExecutable,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
processForwardedEventPayload,
|
||||
messageStreamWsClients: uiNotificationWsClients,
|
||||
terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
|
||||
terminalRebindWindowMs: TERMINAL_INPUT_WS_REBIND_WINDOW_MS,
|
||||
terminalMaxRebindsPerWindow: TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW,
|
||||
@@ -1058,6 +1124,7 @@ async function main(options = {}) {
|
||||
attachSignals,
|
||||
});
|
||||
terminalRuntime = startupPipelineResult.terminalRuntime;
|
||||
messageStreamRuntime = startupPipelineResult.messageStreamRuntime;
|
||||
|
||||
try {
|
||||
await scheduledTasksRuntime.start();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Event Stream Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module contains the OpenChamber message-stream WebSocket protocol and runtime bridge. It keeps the browser-facing WebSocket transport separate from the upstream OpenCode SSE transport.
|
||||
|
||||
## 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/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/protocol.test.js`: unit tests for protocol helpers.
|
||||
- `packages/web/server/lib/event-stream/runtime.test.js`: unit tests for runtime-side broadcaster behavior.
|
||||
|
||||
## Public exports
|
||||
|
||||
### Protocol helpers
|
||||
- `MESSAGE_STREAM_GLOBAL_WS_PATH`: `/api/global/event/ws`
|
||||
- `MESSAGE_STREAM_DIRECTORY_WS_PATH`: `/api/event/ws`
|
||||
- `MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS`: heartbeat interval for browser-facing WS connections.
|
||||
- `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
|
||||
- `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
|
||||
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
|
||||
|
||||
### Runtime helpers
|
||||
- `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.
|
||||
|
||||
## Runtime behavior
|
||||
- Browser clients connect to the WS endpoints above.
|
||||
- OpenChamber still fetches OpenCode upstream event streams over SSE.
|
||||
- Each WS connection proxies one upstream SSE stream.
|
||||
- Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path.
|
||||
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
|
||||
|
||||
## Notes for contributors
|
||||
- Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
|
||||
- Keep runtime wiring in this module instead of `packages/web/server/index.js` unless the logic is strictly route-local glue.
|
||||
- Do not change upstream OpenCode transport assumptions here; OpenCode remains SSE-based.
|
||||
- If replay support is added later, add it here rather than growing `index.js`.
|
||||
|
||||
## Testing
|
||||
- Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
|
||||
- Run `bun test packages/web/server/lib/event-stream/runtime.test.js`
|
||||
- Run repo validation before finalizing: `bun run type-check`, `bun run lint`, `bun run build`
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
||||
parseSseEventEnvelope,
|
||||
sendMessageStreamWsFrame,
|
||||
sendMessageStreamWsEvent,
|
||||
} from './protocol.js';
|
||||
|
||||
export {
|
||||
createGlobalUiEventBroadcaster,
|
||||
createMessageStreamWsRuntime,
|
||||
} from './runtime.js';
|
||||
@@ -0,0 +1,82 @@
|
||||
export const MESSAGE_STREAM_GLOBAL_WS_PATH = '/api/global/event/ws';
|
||||
export const MESSAGE_STREAM_DIRECTORY_WS_PATH = '/api/event/ws';
|
||||
export const MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
|
||||
|
||||
export function parseSseEventEnvelope(block) {
|
||||
if (!block || typeof block !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventId = block
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('id:'))
|
||||
?.slice(3)
|
||||
.trim() || null;
|
||||
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof parsed.payload === 'object' &&
|
||||
parsed.payload !== null
|
||||
) {
|
||||
return {
|
||||
eventId,
|
||||
directory: typeof parsed.directory === 'string' && parsed.directory.length > 0 ? parsed.directory : null,
|
||||
payload: parsed.payload,
|
||||
};
|
||||
}
|
||||
|
||||
const directory =
|
||||
typeof parsed?.directory === 'string' && parsed.directory.length > 0
|
||||
? parsed.directory
|
||||
: typeof parsed?.properties?.directory === 'string' && parsed.properties.directory.length > 0
|
||||
? parsed.properties.directory
|
||||
: null;
|
||||
|
||||
return {
|
||||
eventId,
|
||||
directory,
|
||||
payload: parsed,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMessageStreamWsFrame(socket, payload) {
|
||||
if (!socket || socket.readyState !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.send(JSON.stringify(payload));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMessageStreamWsEvent(socket, payload, options = {}) {
|
||||
return sendMessageStreamWsFrame(socket, {
|
||||
type: 'event',
|
||||
payload,
|
||||
...(typeof options.eventId === 'string' && options.eventId.length > 0 ? { eventId: options.eventId } : {}),
|
||||
...(typeof options.directory === 'string' && options.directory.length > 0 ? { directory: options.directory } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||
parseSseEventEnvelope,
|
||||
sendMessageStreamWsEvent,
|
||||
sendMessageStreamWsFrame,
|
||||
} from './protocol.js';
|
||||
|
||||
describe('event stream protocol helpers', () => {
|
||||
it('exports stable websocket paths', () => {
|
||||
expect(MESSAGE_STREAM_GLOBAL_WS_PATH).toBe('/api/global/event/ws');
|
||||
expect(MESSAGE_STREAM_DIRECTORY_WS_PATH).toBe('/api/event/ws');
|
||||
});
|
||||
|
||||
it('parses wrapped SSE payloads with event id and directory', () => {
|
||||
const envelope = parseSseEventEnvelope(
|
||||
'id: evt-1\n' +
|
||||
'event: message\n' +
|
||||
'data: {"directory":"/tmp/project","payload":{"type":"session.updated"}}\n'
|
||||
);
|
||||
|
||||
expect(envelope).toEqual({
|
||||
eventId: 'evt-1',
|
||||
directory: '/tmp/project',
|
||||
payload: { type: 'session.updated' },
|
||||
});
|
||||
});
|
||||
|
||||
it('derives directory from payload properties when not wrapped', () => {
|
||||
const envelope = parseSseEventEnvelope(
|
||||
'data: {"type":"openchamber:notification","properties":{"directory":"/tmp/project"}}\n'
|
||||
);
|
||||
|
||||
expect(envelope).toEqual({
|
||||
eventId: null,
|
||||
directory: '/tmp/project',
|
||||
payload: {
|
||||
type: 'openchamber:notification',
|
||||
properties: { directory: '/tmp/project' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for malformed SSE blocks', () => {
|
||||
expect(parseSseEventEnvelope('event: message\n')).toBeNull();
|
||||
expect(parseSseEventEnvelope('data: {oops}\n')).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes generic websocket frames', () => {
|
||||
let rawPayload = null;
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
send(payload) {
|
||||
rawPayload = payload;
|
||||
},
|
||||
};
|
||||
|
||||
const sent = sendMessageStreamWsFrame(socket, { type: 'ready' });
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(rawPayload).toBe('{"type":"ready"}');
|
||||
});
|
||||
|
||||
it('serializes event frames with routing metadata', () => {
|
||||
let rawPayload = null;
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
send(payload) {
|
||||
rawPayload = payload;
|
||||
},
|
||||
};
|
||||
|
||||
const sent = sendMessageStreamWsEvent(
|
||||
socket,
|
||||
{ type: 'openchamber:heartbeat', timestamp: 1 },
|
||||
{ eventId: 'evt-2', directory: '/tmp/project' }
|
||||
);
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(JSON.parse(rawPayload)).toEqual({
|
||||
type: 'event',
|
||||
payload: { type: 'openchamber:heartbeat', timestamp: 1 },
|
||||
eventId: 'evt-2',
|
||||
directory: '/tmp/project',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { parseRequestPathname } from '../terminal/index.js';
|
||||
import {
|
||||
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
||||
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
||||
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
||||
parseSseEventEnvelope,
|
||||
sendMessageStreamWsEvent,
|
||||
sendMessageStreamWsFrame,
|
||||
} from './protocol.js';
|
||||
|
||||
export function createGlobalUiEventBroadcaster({
|
||||
sseClients,
|
||||
wsClients,
|
||||
writeSseEvent,
|
||||
}) {
|
||||
return (payload, options = {}) => {
|
||||
const hasSseClients = sseClients.size > 0;
|
||||
const hasWsClients = wsClients.size > 0;
|
||||
if (!hasSseClients && !hasWsClients) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSseClients) {
|
||||
for (const res of sseClients) {
|
||||
try {
|
||||
writeSseEvent(res, payload);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWsClients) {
|
||||
for (const socket of Array.from(wsClients)) {
|
||||
const sent = sendMessageStreamWsEvent(socket, payload, {
|
||||
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
|
||||
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
|
||||
});
|
||||
if (!sent) {
|
||||
wsClients.delete(socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createMessageStreamWsRuntime({
|
||||
server,
|
||||
uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
processForwardedEventPayload,
|
||||
wsClients,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const wsServer = new WebSocketServer({
|
||||
noServer: true,
|
||||
});
|
||||
|
||||
wsServer.on('connection', (socket, req) => {
|
||||
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
|
||||
const pathname = parseRequestPathname(rawUrl);
|
||||
const requestUrl = new URL(rawUrl, 'http://127.0.0.1');
|
||||
const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
|
||||
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
|
||||
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
|
||||
|
||||
const controller = new AbortController();
|
||||
const cleanup = () => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
wsClients.delete(socket);
|
||||
};
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.ping();
|
||||
} catch {
|
||||
}
|
||||
}, MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, { directory: 'global' });
|
||||
}, MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
clearInterval(heartbeatInterval);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
void 0;
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
let targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(buildOpenCodeUrl(isGlobalStream ? '/global/event' : '/event', ''));
|
||||
} catch {
|
||||
sendMessageStreamWsFrame(socket, { type: 'error', message: 'OpenCode service unavailable' });
|
||||
socket.close(1011, 'OpenCode service unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGlobalStream && requestedDirectory) {
|
||||
targetUrl.searchParams.set('directory', requestedDirectory);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
};
|
||||
|
||||
if (requestedLastEventId) {
|
||||
headers['Last-Event-ID'] = requestedLastEventId;
|
||||
}
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetchImpl(targetUrl.toString(), {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Failed to connect to OpenCode event stream' });
|
||||
socket.close(1011, 'Failed to connect to OpenCode event stream');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
sendMessageStreamWsFrame(socket, {
|
||||
type: 'error',
|
||||
message: `OpenCode event stream unavailable (${upstream.status})`,
|
||||
});
|
||||
socket.close(1011, 'OpenCode event stream unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessageStreamWsFrame(socket, {
|
||||
type: 'ready',
|
||||
scope: isGlobalStream ? 'global' : 'directory',
|
||||
});
|
||||
|
||||
if (isGlobalStream) {
|
||||
wsClients.add(socket);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = upstream.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
const forwardBlock = (block) => {
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope = parseSseEventEnvelope(block);
|
||||
const payload = envelope?.payload ?? null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = isGlobalStream
|
||||
? (typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global')
|
||||
: (requestedDirectory || envelope?.directory || 'global');
|
||||
|
||||
sendMessageStreamWsEvent(socket, payload, {
|
||||
directory,
|
||||
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
|
||||
});
|
||||
|
||||
processForwardedEventPayload(payload, (syntheticPayload) => {
|
||||
sendMessageStreamWsEvent(socket, syntheticPayload, { directory: 'global' });
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.warn('Message stream WS proxy error:', error);
|
||||
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Message stream proxy error' });
|
||||
socket.close(1011, 'Message stream proxy error');
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
try {
|
||||
if (socket.readyState === 1 || socket.readyState === 0) {
|
||||
socket.close();
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
});
|
||||
|
||||
const upgradeHandler = (req, socket, head) => {
|
||||
const pathname = parseRequestPathname(req.url);
|
||||
if (pathname !== MESSAGE_STREAM_GLOBAL_WS_PATH && pathname !== MESSAGE_STREAM_DIRECTORY_WS_PATH) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
if (uiAuthController?.enabled) {
|
||||
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
|
||||
if (!sessionToken) {
|
||||
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const originAllowed = await isRequestOriginAllowed(req);
|
||||
if (!originAllowed) {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
wsServer.emit('connection', ws, req);
|
||||
});
|
||||
} catch {
|
||||
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
|
||||
}
|
||||
};
|
||||
|
||||
void handleUpgrade();
|
||||
};
|
||||
|
||||
server.on('upgrade', upgradeHandler);
|
||||
|
||||
return {
|
||||
wsServer,
|
||||
async close() {
|
||||
server.off('upgrade', upgradeHandler);
|
||||
|
||||
try {
|
||||
for (const client of wsServer.clients) {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
wsServer.close(() => resolve());
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
wsClients.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createGlobalUiEventBroadcaster } from './runtime.js';
|
||||
|
||||
describe('event stream broadcaster', () => {
|
||||
it('fans out synthetic events to SSE and WS clients', () => {
|
||||
const sseEvents = [];
|
||||
const wsPayloads = [];
|
||||
const sseClient = { id: 'sse-1' };
|
||||
const wsClient = {
|
||||
readyState: 1,
|
||||
send(payload) {
|
||||
wsPayloads.push(JSON.parse(payload));
|
||||
},
|
||||
};
|
||||
|
||||
const broadcast = createGlobalUiEventBroadcaster({
|
||||
sseClients: new Set([sseClient]),
|
||||
wsClients: new Set([wsClient]),
|
||||
writeSseEvent(res, payload) {
|
||||
sseEvents.push({ res, payload });
|
||||
},
|
||||
});
|
||||
|
||||
broadcast({ type: 'openchamber:session-status' }, { eventId: 'evt-1', directory: '/tmp/project' });
|
||||
|
||||
expect(sseEvents).toEqual([
|
||||
{
|
||||
res: sseClient,
|
||||
payload: { type: 'openchamber:session-status' },
|
||||
},
|
||||
]);
|
||||
expect(wsPayloads).toEqual([
|
||||
{
|
||||
type: 'event',
|
||||
payload: { type: 'openchamber:session-status' },
|
||||
eventId: 'evt-1',
|
||||
directory: '/tmp/project',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes websocket clients that fail to receive a payload', () => {
|
||||
const wsClients = new Set([
|
||||
{
|
||||
readyState: 1,
|
||||
send() {
|
||||
throw new Error('socket write failed');
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const broadcast = createGlobalUiEventBroadcaster({
|
||||
sseClients: new Set(),
|
||||
wsClients,
|
||||
writeSseEvent() {
|
||||
throw new Error('should not be called');
|
||||
},
|
||||
});
|
||||
|
||||
broadcast({ type: 'openchamber:notification' });
|
||||
|
||||
expect(wsClients.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
|
||||
getDesktopNotifyEnabled,
|
||||
desktopNotifyPrefix,
|
||||
getUiNotificationClients,
|
||||
getBroadcastGlobalUiEvent,
|
||||
} = dependencies;
|
||||
|
||||
const writeSseEvent = (res, payload) => {
|
||||
@@ -34,6 +35,25 @@ export const createNotificationEmitterRuntime = (dependencies) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const syntheticPayload = {
|
||||
type: 'openchamber:notification',
|
||||
properties: {
|
||||
...payload,
|
||||
// Tell the UI whether the sidecar stdout notification channel is active.
|
||||
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
|
||||
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
|
||||
desktopStdoutActive: desktopNotifyEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
const broadcastGlobalUiEvent = typeof getBroadcastGlobalUiEvent === 'function'
|
||||
? getBroadcastGlobalUiEvent()
|
||||
: null;
|
||||
if (broadcastGlobalUiEvent) {
|
||||
broadcastGlobalUiEvent(syntheticPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
const clients = getUiNotificationClients();
|
||||
if (clients.size === 0) {
|
||||
return;
|
||||
@@ -41,16 +61,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
|
||||
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:notification',
|
||||
properties: {
|
||||
...payload,
|
||||
// Tell the UI whether the sidecar stdout notification channel is active.
|
||||
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
|
||||
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
|
||||
desktopStdoutActive: desktopNotifyEnabled,
|
||||
},
|
||||
});
|
||||
writeSseEvent(res, syntheticPayload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
|
||||
|
||||
## Public exports (session-runtime.js)
|
||||
- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status.
|
||||
- `createSessionRuntime({ writeSseEvent, getNotificationClients, broadcastEvent? })`: creates runtime-owned state machine and APIs for session status.
|
||||
- Returned API:
|
||||
- `processOpenCodeSsePayload(payload)`
|
||||
- `getSessionActivitySnapshot()`
|
||||
|
||||
@@ -42,7 +42,7 @@ const deriveSessionActivityTransitions = (payload) => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => {
|
||||
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
|
||||
const sessionActivityPhases = new Map();
|
||||
const sessionActivityCooldowns = new Map();
|
||||
const sessionStates = new Map();
|
||||
@@ -93,6 +93,16 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
|
||||
sessionActivityCooldowns.set(sessionId, timer);
|
||||
}
|
||||
|
||||
if (typeof broadcastEvent === 'function') {
|
||||
broadcastEvent({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -132,21 +142,27 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
|
||||
const attentionState = sessionAttentionStates.get(sessionId);
|
||||
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
|
||||
const clients = getNotificationClients();
|
||||
if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) {
|
||||
if (!existing || existing.status !== status || attentionChanged) {
|
||||
const state = sessionStates.get(sessionId);
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: state.lastUpdateAt,
|
||||
metadata: state.metadata,
|
||||
needsAttention: attentionState?.needsAttention ?? false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
const syntheticPayload = {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: state.lastUpdateAt,
|
||||
metadata: state.metadata,
|
||||
needsAttention: attentionState?.needsAttention ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof broadcastEvent === 'function') {
|
||||
broadcastEvent(syntheticPayload);
|
||||
} else if (clients.size > 0) {
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, syntheticPayload);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,20 +199,27 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
|
||||
|
||||
if (wasNeedsAttention) {
|
||||
state.needsAttention = false;
|
||||
const clients = getNotificationClients();
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
needsAttention: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
const syntheticPayload = {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
needsAttention: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof broadcastEvent === 'function') {
|
||||
broadcastEvent(syntheticPayload);
|
||||
} else {
|
||||
const clients = getNotificationClients();
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, syntheticPayload);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createSessionRuntime } from './session-runtime.js';
|
||||
|
||||
describe('session runtime', () => {
|
||||
const runtimes = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const runtime of runtimes) {
|
||||
runtime.dispose();
|
||||
}
|
||||
runtimes.length = 0;
|
||||
});
|
||||
|
||||
it('broadcasts attention clears through the shared broadcaster', () => {
|
||||
const events = [];
|
||||
const runtime = createSessionRuntime({
|
||||
writeSseEvent() {
|
||||
throw new Error('SSE fallback should not be used when broadcastEvent is provided');
|
||||
},
|
||||
getNotificationClients: () => new Set(),
|
||||
broadcastEvent: (payload) => {
|
||||
events.push(payload);
|
||||
},
|
||||
});
|
||||
runtimes.push(runtime);
|
||||
|
||||
runtime.processOpenCodeSsePayload({
|
||||
type: 'session.status',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
info: {
|
||||
type: 'busy',
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.markUserMessageSent('session-1');
|
||||
runtime.processOpenCodeSsePayload({
|
||||
type: 'session.status',
|
||||
properties: {
|
||||
sessionID: 'session-1',
|
||||
info: {
|
||||
type: 'idle',
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.markSessionViewed('session-1', 'client-1');
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'openchamber:session-status',
|
||||
properties: expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
status: 'idle',
|
||||
needsAttention: true,
|
||||
}),
|
||||
});
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId: 'session-1',
|
||||
status: 'idle',
|
||||
timestamp: expect.any(Number),
|
||||
metadata: {},
|
||||
needsAttention: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -325,6 +325,12 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
result.chatRenderMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.messageStreamTransport === 'string') {
|
||||
const mode = candidate.messageStreamTransport.trim();
|
||||
if (mode === 'auto' || mode === 'ws' || mode === 'sse') {
|
||||
result.messageStreamTransport = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.activityRenderMode === 'string') {
|
||||
const mode = candidate.activityRenderMode.trim();
|
||||
if (mode === 'collapsed' || mode === 'summary') {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createSettingsHelpers } from './settings-helpers.js';
|
||||
|
||||
const createTestHelpers = () => createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
normalizeDirectoryPath: (value) => value,
|
||||
normalizeTunnelBootstrapTtlMs: (value) => value,
|
||||
normalizeTunnelSessionTtlMs: (value) => value,
|
||||
normalizeTunnelProvider: (value) => value,
|
||||
normalizeTunnelMode: (value) => value,
|
||||
normalizeOptionalPath: (value) => value,
|
||||
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresets: () => undefined,
|
||||
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
||||
sanitizeTypographySizesPartial: () => undefined,
|
||||
normalizeStringArray: (input) => input,
|
||||
sanitizeModelRefs: () => undefined,
|
||||
sanitizeSkillCatalogs: () => undefined,
|
||||
sanitizeProjects: () => undefined,
|
||||
});
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('accepts messageStreamTransport as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'ws' })).toEqual({
|
||||
messageStreamTransport: 'ws',
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'sse' })).toEqual({
|
||||
messageStreamTransport: 'sse',
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'auto' })).toEqual({
|
||||
messageStreamTransport: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid messageStreamTransport values', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
clearHealthCheckInterval,
|
||||
getTerminalRuntime,
|
||||
setTerminalRuntime,
|
||||
getMessageStreamRuntime,
|
||||
setMessageStreamRuntime,
|
||||
shouldSkipOpenCodeStop,
|
||||
getOpenCodePort,
|
||||
getOpenCodeProcess,
|
||||
@@ -54,6 +56,16 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
}
|
||||
}
|
||||
|
||||
const messageStreamRuntime = getMessageStreamRuntime();
|
||||
if (messageStreamRuntime) {
|
||||
try {
|
||||
await messageStreamRuntime.close();
|
||||
} catch {
|
||||
} finally {
|
||||
setMessageStreamRuntime(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkipOpenCodeStop()) {
|
||||
const portToKill = getOpenCodePort();
|
||||
const openCodeProcess = getOpenCodeProcess();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const createStartupPipelineRuntime = (dependencies) => {
|
||||
const {
|
||||
createTerminalRuntime,
|
||||
createMessageStreamWsRuntime,
|
||||
createServerStartupRuntime,
|
||||
} = dependencies;
|
||||
|
||||
@@ -17,6 +18,10 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
||||
isExecutable,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
processForwardedEventPayload,
|
||||
messageStreamWsClients,
|
||||
terminalHeartbeatIntervalMs,
|
||||
terminalRebindWindowMs,
|
||||
terminalMaxRebindsPerWindow,
|
||||
@@ -62,6 +67,17 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
||||
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow,
|
||||
});
|
||||
|
||||
const messageStreamRuntime = createMessageStreamWsRuntime({
|
||||
server,
|
||||
uiAuthController,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
processForwardedEventPayload,
|
||||
wsClients: messageStreamWsClients,
|
||||
});
|
||||
|
||||
setupProxy(app);
|
||||
scheduleOpenCodeApiDetection();
|
||||
void bootstrapOpenCodeAtStartup();
|
||||
@@ -98,6 +114,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
||||
|
||||
return {
|
||||
terminalRuntime,
|
||||
messageStreamRuntime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user