fix: restore live streaming in VS Code

Forward SSE chunks without reserializing events
Prevent generic proxy from buffering SSE endpoints
Add regression tests for VS Code stream proxy
This commit is contained in:
Bohdan Triapitsyn
2026-05-26 11:42:20 +03:00
parent c61f16e0dd
commit db2464d94d
4 changed files with 241 additions and 102 deletions
@@ -0,0 +1,32 @@
import { describe, expect, it, mock } from 'bun:test';
const { handleProxyBridgeMessage } = await import('./bridge-proxy-runtime');
const createDeps = () => ({
tryHandleLocalFsProxy: mock(() => Promise.resolve(null)),
buildUnavailableApiResponse: mock(() => ({ status: 503, headers: {}, bodyText: '' })),
sanitizeForwardHeaders: mock((headers) => headers || {}),
collectHeaders: mock(() => ({})),
base64EncodeUtf8: mock((text) => Buffer.from(text, 'utf8').toString('base64')),
});
describe('bridge proxy runtime', () => {
it('does not buffer SSE endpoints through the generic API proxy', async () => {
const deps = createDeps();
const response = await handleProxyBridgeMessage(
{ id: '1', type: 'api:proxy', payload: { method: 'GET', path: '/global/event?lastEventId=evt-1' } },
undefined,
deps,
);
expect(response?.success).toBe(true);
expect(response?.data).toMatchObject({
status: 400,
headers: { 'content-type': 'application/json' },
bodyText: JSON.stringify({ error: 'SSE requests must use api:sse:start' }),
});
expect(deps.tryHandleLocalFsProxy).not.toHaveBeenCalled();
expect(deps.buildUnavailableApiResponse).not.toHaveBeenCalled();
});
});
+19 -9
View File
@@ -42,6 +42,15 @@ const collectProxyResponseHeaders = (headers: Headers, deps: Pick<ProxyRuntimeDe
return result; return result;
}; };
const isSseProxyPath = (requestPath: string): boolean => {
try {
const parsed = new URL(requestPath, 'https://openchamber.invalid');
return parsed.pathname === '/event' || parsed.pathname === '/global/event';
} catch {
return requestPath === '/event' || requestPath === '/global/event';
}
};
type ProxyRuntimeDeps = { type ProxyRuntimeDeps = {
tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise<ApiProxyResponsePayload | null>; tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise<ApiProxyResponsePayload | null>;
buildUnavailableApiResponse: () => ApiProxyResponsePayload; buildUnavailableApiResponse: () => ApiProxyResponsePayload;
@@ -65,9 +74,18 @@ export async function handleProxyBridgeMessage(
typeof requestPath === 'string' && requestPath.trim().length > 0 typeof requestPath === 'string' && requestPath.trim().length > 0
? requestPath.trim().startsWith('/') ? requestPath.trim().startsWith('/')
? requestPath.trim() ? requestPath.trim()
: `/${requestPath.trim()}` : `/${requestPath.trim()}`
: '/'; : '/';
if (isSseProxyPath(normalizedPath)) {
const data: ApiProxyResponsePayload = {
status: 400,
headers: { 'content-type': 'application/json' },
bodyText: JSON.stringify({ error: 'SSE requests must use api:sse:start' }),
};
return { id, type, success: true, data };
}
const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath); const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath);
if (localFsResponse) { if (localFsResponse) {
return { id, type, success: true, data: localFsResponse }; return { id, type, success: true, data: localFsResponse };
@@ -86,14 +104,6 @@ export async function handleProxyBridgeMessage(
...ctx?.manager?.getOpenCodeAuthHeaders(), ...ctx?.manager?.getOpenCodeAuthHeaders(),
}; };
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
if (!requestHeaders.Accept) {
requestHeaders.Accept = 'text/event-stream';
}
requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache';
requestHeaders.Connection = requestHeaders.Connection || 'keep-alive';
}
try { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: normalizedMethod, method: normalizedMethod,
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
const originalFetch = globalThis.fetch;
const { openSseProxy } = await import('./sseProxy');
const createManager = () => ({
getApiUrl: () => 'http://127.0.0.1:4096/',
getWorkingDirectory: () => '/repo',
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
onStatusChange: () => ({ dispose() {} }),
});
const createSseResponse = (chunks) => {
const encoder = new TextEncoder();
return new Response(new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
};
describe('VS Code SSE proxy', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('forwards upstream SSE chunks without reserializing event data', async () => {
const upstreamChunks = [
'id: evt-1\n',
'data: {"type":"message.part.delta","properties":{"delta":"hi"}}\n\n',
];
let fetchInput;
let fetchInit;
globalThis.fetch = mock((input, init) => {
fetchInput = input;
fetchInit = init;
return Promise.resolve(createSseResponse(upstreamChunks));
});
const received = [];
const controller = new AbortController();
const proxy = await openSseProxy({
manager: createManager(),
path: '/global/event',
headers: { 'Last-Event-ID': 'evt-0' },
signal: controller.signal,
onChunk: (chunk) => received.push(chunk),
});
await proxy.run;
expect(fetchInput).toBe('http://127.0.0.1:4096/global/event');
expect(fetchInit.headers.Authorization).toBe('Bearer test-token');
expect(fetchInit.headers['Last-Event-ID']).toBe('evt-0');
expect(proxy.headers['content-type']).toContain('text/event-stream');
expect(received.join('')).toBe(upstreamChunks.join(''));
});
it('adds the active directory for directory-scoped event streams', async () => {
let fetchInput;
globalThis.fetch = mock((input) => {
fetchInput = input;
return Promise.resolve(createSseResponse(['data: {"type":"server.connected"}\n\n']));
});
const proxy = await openSseProxy({
manager: createManager(),
path: '/event?foo=bar',
signal: new AbortController().signal,
onChunk: () => {},
});
await proxy.run;
const url = new URL(fetchInput);
expect(url.pathname).toBe('/event');
expect(url.searchParams.get('foo')).toBe('bar');
expect(url.searchParams.get('directory')).toBe('/repo');
});
});
+105 -93
View File
@@ -1,14 +1,6 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import type { OpenCodeManager } from './opencode'; import type { OpenCodeManager } from './opencode';
import { waitForApiUrl } from './opencode-ready'; import { waitForApiUrl } from './opencode-ready';
type StreamEvent<TData = unknown> = {
data: TData;
event?: string;
id?: string;
retry?: number;
};
type OpenSseProxyOptions = { type OpenSseProxyOptions = {
manager: OpenCodeManager; manager: OpenCodeManager;
path: string; path: string;
@@ -50,27 +42,13 @@ const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) =
const getAbortReason = (signal: AbortSignal) => signal.reason ?? new DOMException('Aborted', 'AbortError'); const getAbortReason = (signal: AbortSignal) => signal.reason ?? new DOMException('Aborted', 'AbortError');
const serializeSseEventBlock = (event: StreamEvent<unknown>): string => { const normalizeSsePath = (path: string): { pathname: '/event' | '/global/event'; searchParams: URLSearchParams; directory: string | null } => {
const lines: string[] = [];
if (typeof event.id === 'string' && event.id.length > 0) {
lines.push(`id: ${event.id}`);
}
if (typeof event.event === 'string' && event.event.length > 0) {
lines.push(`event: ${event.event}`);
}
if (typeof event.retry === 'number' && Number.isFinite(event.retry)) {
lines.push(`retry: ${event.retry}`);
}
lines.push(`data: ${JSON.stringify(event.data)}`);
return lines.join('\n');
};
const normalizeSsePath = (path: string): { pathname: '/event' | '/global/event'; directory: string | null } => {
const parsed = new URL(path, 'https://openchamber.invalid'); const parsed = new URL(path, 'https://openchamber.invalid');
const pathname = parsed.pathname === '/global/event' ? '/global/event' : '/event'; const pathname = parsed.pathname === '/global/event' ? '/global/event' : '/event';
const directory = parsed.searchParams.get('directory'); const directory = parsed.searchParams.get('directory');
return { return {
pathname, pathname,
searchParams: new URLSearchParams(parsed.searchParams),
directory: typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null, directory: typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null,
}; };
}; };
@@ -79,41 +57,105 @@ const resolveDefaultDirectory = (manager: OpenCodeManager): string => {
return manager.getWorkingDirectory() || 'global'; return manager.getWorkingDirectory() || 'global';
}; };
const createAuthedClient = async (manager: OpenCodeManager, headers?: Record<string, string>) => { const createSseUrl = (baseUrl: string, pathname: '/event' | '/global/event', searchParams: URLSearchParams, directory: string): URL => {
const base = `${baseUrl.replace(/\/+$/, '')}/`;
const url = new URL(pathname.replace(/^\/+/, ''), base);
for (const [key, value] of searchParams) {
url.searchParams.append(key, value);
}
if (pathname === '/event' && !url.searchParams.has('directory')) {
url.searchParams.set('directory', directory);
}
return url;
};
const createSseHeaders = (manager: OpenCodeManager, headers?: Record<string, string>): Record<string, string> => ({
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...(headers || {}),
...manager.getOpenCodeAuthHeaders(),
});
const createSseResponseHeaders = (response: Response): Record<string, string> => ({
'content-type': response.headers.get('content-type') || SSE_RESPONSE_HEADERS['content-type'],
'cache-control': response.headers.get('cache-control') || SSE_RESPONSE_HEADERS['cache-control'],
});
const fetchSseResponse = async (
manager: OpenCodeManager,
path: string,
headers: Record<string, string> | undefined,
signal: AbortSignal,
): Promise<Response> => {
const baseUrl = await waitForApiUrl(manager); const baseUrl = await waitForApiUrl(manager);
if (!baseUrl) { if (!baseUrl) {
throw new Error('OpenCode API URL not available'); throw new Error('OpenCode API URL not available');
} }
return createOpencodeClient({ const { pathname, searchParams, directory } = normalizeSsePath(path);
baseUrl, const resolvedDirectory = directory || resolveDefaultDirectory(manager);
headers: { const targetUrl = createSseUrl(baseUrl, pathname, searchParams, resolvedDirectory);
...(headers || {}),
...manager.getOpenCodeAuthHeaders(), const response = await fetch(targetUrl.toString(), {
}, method: 'GET',
headers: createSseHeaders(manager, headers),
signal,
}); });
if (!response.ok) {
await response.body?.cancel().catch(() => {});
const error = new Error(`OpenCode SSE request failed (${response.status})`);
(error as Error & { status?: number }).status = response.status;
throw error;
}
if (!response.body) {
throw new Error('OpenCode SSE response missing body');
}
return response;
}; };
const getSseOptions = ( const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
signal: AbortSignal, if (!response.body) {
onChunk: (chunk: string) => void, throw new Error('OpenCode SSE response missing body');
wrapDirectory?: string, }
) => ({
signal, const reader = response.body.getReader();
sseMaxRetryAttempts: 0, const decoder = new TextDecoder();
onSseEvent: (event: StreamEvent<unknown>) => {
const nextEvent = wrapDirectory try {
? { while (!signal.aborted) {
...event, const { done, value } = await reader.read();
data: { if (done) {
directory: wrapDirectory, break;
payload: event.data, }
}, if (value && value.length > 0) {
const chunk = decoder.decode(value, { stream: true });
if (chunk.length > 0) {
onChunk(chunk);
} }
: event; }
onChunk(`${serializeSseEventBlock(nextEvent)}\n\n`); }
},
}); const remaining = decoder.decode();
if (!signal.aborted && remaining.length > 0) {
onChunk(remaining);
}
} finally {
try {
await reader.cancel();
} catch {
// ignore cancel failures during stream shutdown
}
try {
reader.releaseLock();
} catch {
// ignore release failures after reader shutdown
}
}
};
export const openSseProxy = async ({ export const openSseProxy = async ({
manager, manager,
@@ -122,45 +164,22 @@ export const openSseProxy = async ({
signal, signal,
onChunk, onChunk,
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => { }: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
const client = await createAuthedClient(manager, headers);
const { pathname, directory } = normalizeSsePath(path);
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
// Reconnect logic with exponential backoff // Reconnect logic with exponential backoff
let reconnectAttempts = 0; let reconnectAttempts = 0;
const connect = async (): Promise<{ stream: AsyncIterable<unknown> }> => { const connect = async (): Promise<Response> => {
try { try {
const { pathname } = normalizeSsePath(path);
console.log(`[SSE] Connecting to ${pathname} (attempt ${reconnectAttempts + 1}/${MAX_RECONNECTS + 1})`); console.log(`[SSE] Connecting to ${pathname} (attempt ${reconnectAttempts + 1}/${MAX_RECONNECTS + 1})`);
if (pathname === '/global/event') { const result = await fetchSseResponse(manager, path, headers, signal);
try {
const result = await client.global.event(getSseOptions(signal, onChunk));
// Reset reconnect counter on successful connection
reconnectAttempts = 0;
return result;
} catch (error) {
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
throw error;
}
// Fallback to directory event on error
console.warn('[SSE] Global event failed, falling back to directory event', error);
const result = client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk, resolvedDirectory),
);
reconnectAttempts = 0;
return result;
}
}
const result = client.event.subscribe(
{ directory: resolvedDirectory },
getSseOptions(signal, onChunk),
);
reconnectAttempts = 0; reconnectAttempts = 0;
return result; return result;
} catch (error) { } catch (error) {
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
throw error;
}
// Implement reconnect logic // Implement reconnect logic
if (!signal.aborted && reconnectAttempts < MAX_RECONNECTS) { if (!signal.aborted && reconnectAttempts < MAX_RECONNECTS) {
reconnectAttempts++; reconnectAttempts++;
@@ -184,16 +203,12 @@ export const openSseProxy = async ({
} }
}; };
const result = await connect(); const response = await connect();
const run = (async () => { const run = (async () => {
let activeResponse = response;
try { try {
for await (const _ of result.stream) { await pipeSseResponse(activeResponse, signal, onChunk);
void _;
if (signal.aborted) {
break;
}
}
} catch (error: unknown) { } catch (error: unknown) {
const cause = (error as { cause?: { code?: string } } | null)?.cause; const cause = (error as { cause?: { code?: string } } | null)?.cause;
@@ -212,11 +227,8 @@ export const openSseProxy = async ({
// Attempt to reconnect // Attempt to reconnect
try { try {
const newResult = await connect(); activeResponse = await connect();
for await (const _ of newResult.stream) { await pipeSseResponse(activeResponse, signal, onChunk);
void _;
if (signal.aborted) break;
}
return; // Successfully reconnected return; // Successfully reconnected
} catch (reconnectError) { } catch (reconnectError) {
console.error('[SSE] Reconnect failed', reconnectError); console.error('[SSE] Reconnect failed', reconnectError);
@@ -231,7 +243,7 @@ export const openSseProxy = async ({
})(); })();
return { return {
headers: { ...SSE_RESPONSE_HEADERS }, headers: createSseResponseHeaders(response),
run, run,
}; };
}; };