feat(vscode): refactor OpenCode configuration management and API proxying

This commit is contained in:
Bohdan Triapitsyn
2025-12-24 22:50:46 +02:00
parent 2d45abdc32
commit 359cfd45b1
10 changed files with 1457 additions and 148 deletions
+52 -6
View File
@@ -59,6 +59,14 @@ window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
});
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
return sendBridgeMessageWithOptions<T>(type, payload);
}
export function sendBridgeMessageWithOptions<T = unknown>(
type: string,
payload?: unknown,
options?: { timeoutMs?: number }
): Promise<T> {
return new Promise((resolve, reject) => {
const id = `req_${++requestIdCounter}_${Date.now()}`;
const request: BridgeRequest = { id, type, payload };
@@ -68,17 +76,55 @@ export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown):
reject,
});
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, 30000);
const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, timeoutMs);
}
getVSCodeAPI().postMessage(request);
});
}
export type ProxiedApiResponse = {
status: number;
headers: Record<string, string>;
bodyBase64: string;
};
export async function proxyApiRequest(options: {
method: string;
path: string;
headers?: Record<string, string>;
bodyBase64?: string;
}): Promise<ProxiedApiResponse> {
// Do not impose a bridge-level timeout. Let the original fetch's AbortSignal
// (or OpenCode server response timing) control the lifecycle.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
}
export type ProxiedSseStartResponse = {
status: number;
headers: Record<string, string>;
streamId: string | null;
error?: string;
};
export async function startSseProxy(options: {
path: string;
headers?: Record<string, string>;
}): Promise<ProxiedSseStartResponse> {
return sendBridgeMessage<ProxiedSseStartResponse>('api:sse:start', options);
}
export async function stopSseProxy(options: { streamId: string }): Promise<{ stopped: boolean }> {
return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options);
}
type CommandHandler = (payload: unknown) => void;
const commandHandlers = new Map<string, CommandHandler>();
+293 -91
View File
@@ -1,5 +1,5 @@
import { createVSCodeAPIs } from './api';
import { onThemeChange, sendBridgeMessage } from './api/bridge';
import { onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import type { RuntimeAPIs } from '../../ui/src/lib/api/types';
import {
buildVSCodeThemeFromPalette,
@@ -14,7 +14,7 @@ declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
__VSCODE_CONFIG__?: {
apiUrl: string;
apiUrl?: string;
workspaceFolder: string;
theme: string;
connectionStatus: string;
@@ -48,19 +48,124 @@ const handleConnectionMessage = (event: MessageEvent) => {
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
// Hide loading screen when connected
if (payload === 'connected') {
const loadingEl = document.getElementById('initial-loading');
if (loadingEl) {
loadingEl.classList.add('fade-out');
setTimeout(() => loadingEl.remove(), 300);
}
}
}
};
window.addEventListener('message', handleConnectionMessage);
window.addEventListener('openchamber:connection-status', () => {
maybeHideLoadingOverlay();
});
const fadeOutLoadingScreen = () => {
const loadingEl = document.getElementById('initial-loading');
if (!loadingEl) return;
loadingEl.classList.add('fade-out');
setTimeout(() => {
try {
loadingEl.remove();
} catch {
// ignore
}
}, 300);
};
const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => {
const statusEl = document.getElementById('loading-status');
if (!statusEl) return;
statusEl.textContent = text;
if (variant === 'error') {
statusEl.classList.add('error-text');
} else {
statusEl.classList.remove('error-text');
}
};
const waitForUiMount = (timeoutMs = 8000): Promise<boolean> => {
if (typeof document === 'undefined') return Promise.resolve(false);
const root = document.getElementById('root');
if (!root) return Promise.resolve(false);
const hasContent = () => root.childNodes.length > 0;
if (hasContent()) return Promise.resolve(true);
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (hasContent()) {
observer.disconnect();
clearTimeout(timeout);
resolve(true);
}
});
observer.observe(root, { childList: true, subtree: true });
const timeout = setTimeout(() => {
observer.disconnect();
resolve(false);
}, timeoutMs);
});
};
let uiMounted = false;
let bootstrapProvidersReady = false;
let bootstrapAgentsReady = false;
let bootstrapFailed = false;
const recordBootstrapFetch = (pathname: string, ok: boolean) => {
if (!pathname.startsWith('/api/')) return;
if (pathname.startsWith('/api/config/providers')) {
if (ok) bootstrapProvidersReady = true;
else bootstrapFailed = true;
return;
}
if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) {
if (ok) bootstrapAgentsReady = true;
else bootstrapFailed = true;
}
};
const maybeHideLoadingOverlay = () => {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting';
if (!uiMounted) {
return;
}
if (connectionStatus === 'connected') {
if (bootstrapFailed) {
setLoadingStatusText('OpenCode connected, but initial data load failed.', 'error');
fadeOutLoadingScreen();
return;
}
if (bootstrapProvidersReady && bootstrapAgentsReady) {
fadeOutLoadingScreen();
return;
}
const providersText = bootstrapProvidersReady ? '✓ Providers' : '… Providers';
const agentsText = bootstrapAgentsReady ? '✓ Agents' : '… Agents';
setLoadingStatusText(`Loading data (${providersText}, ${agentsText})…`);
return;
}
if (connectionStatus === 'error') {
const error = window.__OPENCHAMBER_CONNECTION__?.error;
setLoadingStatusText(error || 'Connection error', 'error');
fadeOutLoadingScreen();
return;
}
if (connectionStatus === 'disconnected') {
setLoadingStatusText('Disconnected', 'error');
fadeOutLoadingScreen();
return;
}
setLoadingStatusText('Starting OpenCode API…');
};
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
if (typeof document === 'undefined' || !theme) return;
@@ -133,9 +238,6 @@ if (workspaceFolder) {
} catch (error) {
console.warn('Failed to persist workspace folder', error);
}
sendBridgeMessage('api:opencode/directory', { path: workspaceFolder }).catch((error) => {
console.warn('Failed to set OpenCode working directory from VS Code workspace', error);
});
}
const normalizeUrl = (input: string | URL) => {
@@ -146,42 +248,67 @@ const normalizeUrl = (input: string | URL) => {
}
};
// API URL may be empty during initial load while port is being detected
// The extension will broadcast the URL once it's known
let apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || '';
// Promise that resolves when API URL is available
let apiUrlResolver: ((url: string) => void) | null = null;
const apiUrlPromise = apiBaseUrl
? Promise.resolve(apiBaseUrl)
: new Promise<string>((resolve) => { apiUrlResolver = resolve; });
// Listen for API URL updates from extension
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data;
if (msg?.type === 'apiUrlUpdate' && typeof msg.url === 'string') {
const newUrl = msg.url.replace(/\/+$/, '');
apiBaseUrl = newUrl;
console.log('[OpenChamber] API URL updated:', apiBaseUrl);
// Resolve the promise if waiting
if (apiUrlResolver) {
apiUrlResolver(newUrl);
apiUrlResolver = null;
}
}
});
// Helper to wait for API URL with timeout
const waitForApiUrl = async (timeoutMs = 15000): Promise<string> => {
if (apiBaseUrl) return apiBaseUrl;
const timeout = new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for API URL')), timeoutMs)
);
return Promise.race([apiUrlPromise, timeout]);
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
if (!headers) return {};
const normalized = headers instanceof Headers ? headers : new Headers(headers);
const result: Record<string, string> = {};
normalized.forEach((value, key) => {
result[key] = value;
});
return result;
};
const decodeBase64 = (value: string): Uint8Array => {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
};
const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
if (method === 'GET' || method === 'HEAD') return undefined;
if (input instanceof Request) {
const cloned = input.clone();
const buffer = await cloned.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
const body = init?.body;
if (!body) return undefined;
if (typeof body === 'string') {
return encodeBase64(new TextEncoder().encode(body));
}
if (body instanceof URLSearchParams) {
return encodeBase64(new TextEncoder().encode(body.toString()));
}
if (body instanceof Blob) {
const buffer = await body.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
console.warn('[OpenChamber] Unsupported request body type for proxy request:', body);
return undefined;
};
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
@@ -200,33 +327,6 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
});
}
if (pathname.startsWith('/api/openchamber/models-metadata')) {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
try {
const response = await fetch('https://models.dev/api.json', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`models.dev responded with ${response.status}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.warn('[OpenChamber] Failed to fetch models metadata, returning empty set:', error);
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} finally {
if (timeout) clearTimeout(timeout);
}
}
if (pathname.startsWith('/api/fs/list')) {
const targetPath = url.searchParams.get('path') || '';
const data = await sendBridgeMessage('api:fs:list', { path: targetPath });
@@ -259,6 +359,34 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname.startsWith('/api/config/agents/')) {
const encodedName = pathname.slice('/api/config/agents/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/commands/')) {
const encodedName = pathname.slice('/api/config/commands/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
if (pathname.startsWith('/api/config/settings')) {
if ((init?.method || 'GET').toUpperCase() === 'GET') {
const settings = await sendBridgeMessage('api:config/settings:get');
@@ -327,27 +455,89 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
const localResponse = await handleLocalApiRequest(targetUrl, init);
if (localResponse) {
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
maybeHideLoadingOverlay();
return localResponse;
}
// Wait for API URL to be available before making requests
const baseUrl = await waitForApiUrl();
const rewritten = new URL(targetUrl.href);
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
const fetchTarget = `${baseUrl}${rewritten.pathname}${rewritten.search}`;
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
if (input instanceof Request) {
const cloned = input.clone();
const requestInit: RequestInit = {
method: method,
headers: cloned.headers,
body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(),
};
return originalFetch(fetchTarget, requestInit);
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
const headersFromInit = headersToRecord(init?.headers);
const headers = { ...headersFromRequest, ...headersFromInit };
if (isSseApiPath(targetUrl.pathname)) {
const start = await startSseProxy({ path: suffixPath, headers });
if (!start.streamId) {
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
}
const streamId = start.streamId;
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const onMessage = (event: MessageEvent) => {
const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string };
if (!msg || msg.streamId !== streamId) return;
if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') {
controller.enqueue(encoder.encode(msg.chunk));
return;
}
if (msg.type === 'api:sse:end') {
unsubscribe?.();
unsubscribe = null;
if (typeof msg.error === 'string' && msg.error.length > 0) {
controller.error(new Error(msg.error));
} else {
controller.close();
}
void stopSseProxy({ streamId }).catch(() => {});
}
};
window.addEventListener('message', onMessage);
unsubscribe = () => window.removeEventListener('message', onMessage);
if (signal) {
const onAbort = () => {
unsubscribe?.();
unsubscribe = null;
try {
controller.error(new DOMException('Aborted', 'AbortError'));
} catch {
controller.close();
}
void stopSseProxy({ streamId }).catch(() => {});
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
},
cancel() {
unsubscribe?.();
unsubscribe = null;
void stopSseProxy({ streamId }).catch(() => {});
},
});
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
}
return originalFetch(fetchTarget, init);
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
}
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
@@ -362,4 +552,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
return originalFetch(input as RequestInfo, init);
};
import('../../ui/src/main');
import('../../ui/src/main')
.then(async () => {
await waitForUiMount();
uiMounted = true;
maybeHideLoadingOverlay();
})
.catch((error) => {
console.error('[OpenChamber] Failed to bootstrap UI:', error);
// If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root.
uiMounted = true;
fadeOutLoadingScreen();
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
import type { Session, Message, Part } from '@opencode-ai/sdk';
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
const getApiUrl = () => '/api';
const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';