* perf(vscode): gate API readiness and coalesce duplicate startup reads Bring the VS Code bridge runtime to parity with the web startup optimizations (PR #1650), which were web/desktop-only. waitForApiUrl now hands out the OpenCode API URL only once the manager reports 'connected', instead of as soon as getApiUrl() exposes server.url. The URL is available the moment the process is spawned — before waitForReady confirms it can serve and during a workspace-switch restart (stale port) — so URL-presence alone let the bridge forward to a not-yet-ready OpenCode and surface 502s. Gating on connected status mirrors the web proxy's isOpenCodeReady hold. Also fail fast on 'error' status so a missing CLI doesn't burn the full 30s timeout. Coalesce concurrent identical GET reads (config/path/agents/agent/ project/command) at the bridge proxy so the single OpenCode process serves them once. On cold start the webview's sync bootstrap and config store fire these reads in parallel with no shared dedup; this is the extension-host analog of the runtimeFetch coalescer. Shared reads carry no AbortController so one caller's abort can't strand the others, and the entry clears as soon as it settles (never serves stale). * perf(vscode): fade the startup splash once mounted + connected, not on live fetch The webview's initial-loading overlay held until a successful live /api/config/providers AND /api/agent fetch completed. After the cache hydration work those live reads are the slowest cold-start tail — the UI underneath already paints pickers and the sidebar from cache and refreshes in the background — so gating the splash on them kept it spinning long after the app was usable. Fade the overlay as soon as the UI is mounted and OpenCode is connected. Per-widget loaders convey any remaining background refresh, matching how web/desktop (which have no such splash) already behave. Removes the now -obsolete bootstrapProvidersReady/AgentsReady/Failed tracking and recordBootstrapFetch. Connection error/disconnected splash messages are unchanged. * fix(vscode): include captured OpenCode output in spawn-timeout error When the managed OpenCode server fails to emit its 'listening' line within the start timeout, the error discarded everything the process printed to stdout/stderr — so the status report showed a bare 'Timeout waiting for server to start' with no clue whether the process hung, crashed silently, or printed a config/auth error. The exit path already includes the output; the timeout path now does too (or notes that nothing was printed). * feat(vscode): workspace-grouped session list with working folders, pinning, and archived toggle Replace the flat multi-workspace session list with the grouped project view, using each open VS Code workspace folder as a header (no per-worktree subgroups). This restores native folder and pin support, which the flat list silently dropped, and fixes the clipped left padding on session rows. - Group sessions strictly by open workspace; funnel all non-archived sessions into the workspace's group so they no longer fall into the archived bucket. - Keep the project/group/folder + buttons but make them open a draft in the correct workspace and navigate to chat; hide the project actions (...) menu, which isn't relevant in VS Code. - Force the minimal single-line row layout (the second metadata row is redundant under workspace headers) and drop the per-row tooltip. - Add a show/hide archived toggle next to the archive-all control, since the VS Code header has no display-mode menu. - Size the hover action reveal so the timestamp clears the row buttons.
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import type { BridgeContext, BridgeResponse } from './bridge';
|
|
import { waitForApiUrl } from './opencode-ready';
|
|
|
|
type BridgeMessageInput = {
|
|
id: string;
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type ApiProxyRequestPayload = {
|
|
method?: string;
|
|
path?: string;
|
|
headers?: Record<string, string>;
|
|
bodyBase64?: string;
|
|
};
|
|
|
|
type ApiSessionMessageRequestPayload = {
|
|
path?: string;
|
|
headers?: Record<string, string>;
|
|
bodyText?: string;
|
|
};
|
|
|
|
type ApiProxyAbortPayload = {
|
|
requestID?: string;
|
|
};
|
|
|
|
type ApiProxyResponsePayload = {
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
bodyBase64?: string;
|
|
bodyText?: string;
|
|
};
|
|
|
|
const shouldReturnTextBody = (headers: Headers): boolean => {
|
|
const contentType = headers.get('content-type')?.toLowerCase() || '';
|
|
return contentType.startsWith('application/json')
|
|
|| contentType.startsWith('text/')
|
|
|| contentType.includes('+json');
|
|
};
|
|
|
|
const collectProxyResponseHeaders = (headers: Headers, deps: Pick<ProxyRuntimeDeps, 'collectHeaders'>): Record<string, string> => {
|
|
const result = deps.collectHeaders(headers);
|
|
delete result['content-length'];
|
|
delete result['content-encoding'];
|
|
delete result['transfer-encoding'];
|
|
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;
|
|
sanitizeForwardHeaders: (input: Record<string, string> | undefined) => Record<string, string>;
|
|
collectHeaders: (headers: Headers) => Record<string, string>;
|
|
base64EncodeUtf8: (text: string) => string;
|
|
};
|
|
|
|
const proxyAbortControllers = new Map<string, AbortController>();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-flight read coalescing (parity with the web runtimeFetch coalescer)
|
|
//
|
|
// On cold start the webview's two data layers — the sync bootstrap and the
|
|
// config store — fire the SAME idempotent reads (config, path, agents, agent,
|
|
// project, command) concurrently through the bridge with no shared dedup. That
|
|
// saturates the single OpenCode process and delays everything queued behind it
|
|
// (e.g. createSession). Coalesce genuinely-concurrent identical GETs to those
|
|
// read endpoints so OpenCode does the work once; every caller gets its own
|
|
// response payload copy.
|
|
//
|
|
// Scope is deliberately tight: GET only, an allowlist of read paths. The shared
|
|
// fetch runs without a per-request AbortController, so one caller's
|
|
// `api:proxy:abort` cannot cancel the read for the others (these reads are fast
|
|
// and idempotent — losing abort for them is harmless). The entry is removed as
|
|
// soon as the request settles, so this only ever shares overlapping in-flight
|
|
// requests; it never serves a stale response.
|
|
// ---------------------------------------------------------------------------
|
|
const COALESCE_READ_PATH = /^\/(config|path|app\/agents|agent|project|command)(\b|\/|\?|$)/;
|
|
const READ_COALESCE = new Map<string, Promise<ApiProxyResponsePayload>>();
|
|
|
|
const performApiProxyFetch = async (
|
|
targetUrl: string,
|
|
method: string,
|
|
headers: Record<string, string>,
|
|
body: Buffer | undefined,
|
|
signal: AbortSignal | undefined,
|
|
deps: Pick<ProxyRuntimeDeps, 'collectHeaders'>,
|
|
): Promise<ApiProxyResponsePayload> => {
|
|
try {
|
|
const response = await fetch(targetUrl, { method, headers, body, signal });
|
|
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
|
|
if (shouldReturnTextBody(response.headers)) {
|
|
return { status: response.status, headers: responseHeaders, bodyText: await response.text() };
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return {
|
|
status: response.status,
|
|
headers: responseHeaders,
|
|
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: 502,
|
|
headers: { 'content-type': 'application/json' },
|
|
bodyText: JSON.stringify({
|
|
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
|
|
}),
|
|
};
|
|
}
|
|
};
|
|
|
|
export async function handleProxyBridgeMessage(
|
|
message: BridgeMessageInput,
|
|
ctx: BridgeContext | undefined,
|
|
deps: ProxyRuntimeDeps,
|
|
): Promise<BridgeResponse | null> {
|
|
const { id, type, payload } = message;
|
|
|
|
switch (type) {
|
|
case 'api:proxy:abort': {
|
|
const { requestID } = (payload || {}) as ApiProxyAbortPayload;
|
|
if (typeof requestID === 'string' && requestID.length > 0) {
|
|
proxyAbortControllers.get(requestID)?.abort();
|
|
proxyAbortControllers.delete(requestID);
|
|
}
|
|
return { id, type, success: true, data: { aborted: true } };
|
|
}
|
|
|
|
case 'api:proxy': {
|
|
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
|
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
|
const normalizedPath =
|
|
typeof requestPath === 'string' && requestPath.trim().length > 0
|
|
? requestPath.trim().startsWith('/')
|
|
? 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 };
|
|
}
|
|
|
|
const apiUrl = await waitForApiUrl(ctx?.manager);
|
|
if (!apiUrl) {
|
|
const data = deps.buildUnavailableApiResponse();
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
|
const requestHeaders: Record<string, string> = {
|
|
...deps.sanitizeForwardHeaders(headers),
|
|
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
|
};
|
|
|
|
const requestBody =
|
|
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
|
|
? Buffer.from(bodyBase64, 'base64')
|
|
: undefined;
|
|
|
|
// Coalesce concurrent identical GET reads to idempotent endpoints so the
|
|
// single OpenCode process serves them once. The shared fetch carries no
|
|
// AbortController (api:proxy:abort can't cancel these reads), so one
|
|
// caller aborting can't strand the others.
|
|
const coalesceKey =
|
|
normalizedMethod === 'GET' && COALESCE_READ_PATH.test(normalizedPath) ? `GET ${targetUrl}` : null;
|
|
if (coalesceKey) {
|
|
const existing = READ_COALESCE.get(coalesceKey);
|
|
if (existing) {
|
|
const shared = await existing;
|
|
return { id, type, success: true, data: { ...shared, headers: { ...shared.headers } } };
|
|
}
|
|
const pending = performApiProxyFetch(targetUrl, 'GET', requestHeaders, undefined, undefined, deps);
|
|
READ_COALESCE.set(coalesceKey, pending);
|
|
pending.then(
|
|
() => READ_COALESCE.delete(coalesceKey),
|
|
() => READ_COALESCE.delete(coalesceKey),
|
|
);
|
|
const data = await pending;
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
proxyAbortControllers.set(id, abortController);
|
|
|
|
try {
|
|
const data = await performApiProxyFetch(
|
|
targetUrl,
|
|
normalizedMethod,
|
|
requestHeaders,
|
|
requestBody,
|
|
abortController.signal,
|
|
deps,
|
|
);
|
|
return { id, type, success: true, data };
|
|
} finally {
|
|
proxyAbortControllers.delete(id);
|
|
}
|
|
}
|
|
|
|
case 'api:session:message': {
|
|
const apiUrl = await waitForApiUrl(ctx?.manager);
|
|
if (!apiUrl) {
|
|
const data = deps.buildUnavailableApiResponse();
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload;
|
|
const normalizedPath =
|
|
typeof requestPath === 'string' && requestPath.trim().length > 0
|
|
? requestPath.trim().startsWith('/')
|
|
? requestPath.trim()
|
|
: `/${requestPath.trim()}`
|
|
: '/';
|
|
|
|
if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) {
|
|
const body = JSON.stringify({ error: 'Invalid session message proxy path' });
|
|
const data: ApiProxyResponsePayload = {
|
|
status: 400,
|
|
headers: { 'content-type': 'application/json' },
|
|
bodyBase64: deps.base64EncodeUtf8(body),
|
|
};
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
|
const requestHeaders: Record<string, string> = {
|
|
...deps.sanitizeForwardHeaders(headers),
|
|
...ctx?.manager?.getOpenCodeAuthHeaders(),
|
|
};
|
|
const timeoutSignal = AbortSignal.timeout(45000);
|
|
const abortController = new AbortController();
|
|
proxyAbortControllers.set(id, abortController);
|
|
const onTimeout = () => abortController.abort();
|
|
timeoutSignal.addEventListener('abort', onTimeout, { once: true });
|
|
|
|
try {
|
|
const response = await fetch(targetUrl, {
|
|
method: 'POST',
|
|
headers: requestHeaders,
|
|
body: typeof bodyText === 'string' ? bodyText : '',
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
|
|
if (shouldReturnTextBody(response.headers)) {
|
|
const bodyText = await response.text();
|
|
const data: ApiProxyResponsePayload = {
|
|
status: response.status,
|
|
headers: responseHeaders,
|
|
bodyText,
|
|
};
|
|
|
|
return { id, type, success: true, data };
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const data: ApiProxyResponsePayload = {
|
|
status: response.status,
|
|
headers: responseHeaders,
|
|
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
|
};
|
|
|
|
return { id, type, success: true, data };
|
|
} catch (error) {
|
|
const isTimeout =
|
|
error instanceof Error &&
|
|
((error as Error & { name?: string }).name === 'TimeoutError' ||
|
|
(error as Error & { name?: string }).name === 'AbortError');
|
|
const body = JSON.stringify({
|
|
error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed',
|
|
});
|
|
const data: ApiProxyResponsePayload = {
|
|
status: isTimeout ? 504 : 503,
|
|
headers: { 'content-type': 'application/json' },
|
|
bodyText: body,
|
|
};
|
|
return { id, type, success: true, data };
|
|
} finally {
|
|
timeoutSignal.removeEventListener('abort', onTimeout);
|
|
proxyAbortControllers.delete(id);
|
|
}
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|