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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,15 @@ const collectProxyResponseHeaders = (headers: Headers, deps: Pick<ProxyRuntimeDe
|
||||
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 = {
|
||||
tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise<ApiProxyResponsePayload | null>;
|
||||
buildUnavailableApiResponse: () => ApiProxyResponsePayload;
|
||||
@@ -65,9 +74,18 @@ export async function handleProxyBridgeMessage(
|
||||
typeof requestPath === 'string' && requestPath.trim().length > 0
|
||||
? requestPath.trim().startsWith('/')
|
||||
? 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);
|
||||
if (localFsResponse) {
|
||||
return { id, type, success: true, data: localFsResponse };
|
||||
@@ -86,14 +104,6 @@ export async function handleProxyBridgeMessage(
|
||||
...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 {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: normalizedMethod,
|
||||
|
||||
@@ -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
@@ -1,14 +1,6 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { waitForApiUrl } from './opencode-ready';
|
||||
|
||||
type StreamEvent<TData = unknown> = {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
};
|
||||
|
||||
type OpenSseProxyOptions = {
|
||||
manager: OpenCodeManager;
|
||||
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 serializeSseEventBlock = (event: StreamEvent<unknown>): string => {
|
||||
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 normalizeSsePath = (path: string): { pathname: '/event' | '/global/event'; searchParams: URLSearchParams; directory: string | null } => {
|
||||
const parsed = new URL(path, 'https://openchamber.invalid');
|
||||
const pathname = parsed.pathname === '/global/event' ? '/global/event' : '/event';
|
||||
const directory = parsed.searchParams.get('directory');
|
||||
return {
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(parsed.searchParams),
|
||||
directory: typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null,
|
||||
};
|
||||
};
|
||||
@@ -79,41 +57,105 @@ const resolveDefaultDirectory = (manager: OpenCodeManager): string => {
|
||||
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);
|
||||
if (!baseUrl) {
|
||||
throw new Error('OpenCode API URL not available');
|
||||
}
|
||||
|
||||
return createOpencodeClient({
|
||||
baseUrl,
|
||||
headers: {
|
||||
...(headers || {}),
|
||||
...manager.getOpenCodeAuthHeaders(),
|
||||
},
|
||||
const { pathname, searchParams, directory } = normalizeSsePath(path);
|
||||
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
|
||||
const targetUrl = createSseUrl(baseUrl, pathname, searchParams, resolvedDirectory);
|
||||
|
||||
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 = (
|
||||
signal: AbortSignal,
|
||||
onChunk: (chunk: string) => void,
|
||||
wrapDirectory?: string,
|
||||
) => ({
|
||||
signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
onSseEvent: (event: StreamEvent<unknown>) => {
|
||||
const nextEvent = wrapDirectory
|
||||
? {
|
||||
...event,
|
||||
data: {
|
||||
directory: wrapDirectory,
|
||||
payload: event.data,
|
||||
},
|
||||
const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
|
||||
if (!response.body) {
|
||||
throw new Error('OpenCode SSE response missing body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
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 ({
|
||||
manager,
|
||||
@@ -122,45 +164,22 @@ export const openSseProxy = async ({
|
||||
signal,
|
||||
onChunk,
|
||||
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
||||
const client = await createAuthedClient(manager, headers);
|
||||
const { pathname, directory } = normalizeSsePath(path);
|
||||
const resolvedDirectory = directory || resolveDefaultDirectory(manager);
|
||||
|
||||
// Reconnect logic with exponential backoff
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const connect = async (): Promise<{ stream: AsyncIterable<unknown> }> => {
|
||||
const connect = async (): Promise<Response> => {
|
||||
try {
|
||||
const { pathname } = normalizeSsePath(path);
|
||||
console.log(`[SSE] Connecting to ${pathname} (attempt ${reconnectAttempts + 1}/${MAX_RECONNECTS + 1})`);
|
||||
|
||||
if (pathname === '/global/event') {
|
||||
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),
|
||||
);
|
||||
const result = await fetchSseResponse(manager, path, headers, signal);
|
||||
reconnectAttempts = 0;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError' || signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Implement reconnect logic
|
||||
if (!signal.aborted && reconnectAttempts < MAX_RECONNECTS) {
|
||||
reconnectAttempts++;
|
||||
@@ -184,16 +203,12 @@ export const openSseProxy = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const result = await connect();
|
||||
const response = await connect();
|
||||
|
||||
const run = (async () => {
|
||||
let activeResponse = response;
|
||||
try {
|
||||
for await (const _ of result.stream) {
|
||||
void _;
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
||||
} catch (error: unknown) {
|
||||
const cause = (error as { cause?: { code?: string } } | null)?.cause;
|
||||
|
||||
@@ -212,11 +227,8 @@ export const openSseProxy = async ({
|
||||
|
||||
// Attempt to reconnect
|
||||
try {
|
||||
const newResult = await connect();
|
||||
for await (const _ of newResult.stream) {
|
||||
void _;
|
||||
if (signal.aborted) break;
|
||||
}
|
||||
activeResponse = await connect();
|
||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
||||
return; // Successfully reconnected
|
||||
} catch (reconnectError) {
|
||||
console.error('[SSE] Reconnect failed', reconnectError);
|
||||
@@ -231,7 +243,7 @@ export const openSseProxy = async ({
|
||||
})();
|
||||
|
||||
return {
|
||||
headers: { ...SSE_RESPONSE_HEADERS },
|
||||
headers: createSseResponseHeaders(response),
|
||||
run,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user