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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user