* perf: optimize session loading and startup * fix(chat): stabilize history prepend virtualization * perf: unblock first session open from startup network contention Opening the first session after app start waited seconds for its message fetch. Three independent contributors, each measured via CDP network capture and Chromium net-log against the packaged desktop app: - The active-session watchdog fired an uncapped per-directory status poll and child-session discovery burst at startup, and other subsystems (git checks, global session pages, command/skill discovery) fanned out alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin. Add a shared background-network gate (concurrency 3) and route the watchdog, poll-shaped git reads (also priority: low), global session pages, command/skill loads, and the background update check through it. - The packaged renderer is cross-origin to the loopback backend, so every API call needs a CORS preflight; a few slow OpenCode-proxied requests held the whole pool while preflights and interactive traffic queued behind them. Lift Chromium's per-host connection cap for loopback via ignore-connections-limit in the Electron shell. - OpenCode initializes each directory lazily on its first request, so the first click paid that cost interactively. Warm the last-used directory and the three most recently opened projects right after OpenCode readiness, sequentially and best-effort, overlapping UI startup. Validation: new background-network tests, lifecycle warmup test, focused store/sync tests, UI type-check and lint, dead-code report, node --check plus electron type-check/lint, and CDP first-open measurements on the packaged app (message fetch socket queue 5.4s -> 0.03s). * fix(ui): keep interactive git reads out of background queue --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
826 lines
27 KiB
JavaScript
826 lines
27 KiB
JavaScript
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
|
|
import {
|
|
applyForwardProxyResponseHeaders,
|
|
collectForwardProxyHeaders,
|
|
shouldForwardProxyResponseHeader,
|
|
} from '../../proxy-headers.js';
|
|
import { createRealpathCache } from '../path-realpath-cache.js';
|
|
import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js';
|
|
import { recordStartupPerformance } from './startup-performance.js';
|
|
|
|
const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
|
|
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
|
|
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
|
|
|
|
return async (requestUrl) => {
|
|
if (typeof requestUrl !== 'string' || !requestUrl.includes('directory=')) {
|
|
return requestUrl;
|
|
}
|
|
|
|
const url = new URL(requestUrl, 'http://localhost');
|
|
const directory = url.searchParams.get('directory');
|
|
if (!directory) {
|
|
return requestUrl;
|
|
}
|
|
|
|
const canonicalDirectory = await realpathCache.resolve(directory);
|
|
if (!canonicalDirectory || canonicalDirectory === directory) {
|
|
return requestUrl;
|
|
}
|
|
|
|
url.searchParams.set('directory', canonicalDirectory);
|
|
return `${url.pathname}${url.search}`;
|
|
};
|
|
};
|
|
|
|
export const normalizeForwardedDirectoryHeaders = (headers) => {
|
|
const rawDirectory = headers?.['x-opencode-directory'];
|
|
if (typeof rawDirectory !== 'string') {
|
|
return headers;
|
|
}
|
|
|
|
if (headers['x-opencode-directory-encoding'] !== 'uri') {
|
|
return headers;
|
|
}
|
|
|
|
try {
|
|
headers['x-opencode-directory'] = decodeURIComponent(rawDirectory);
|
|
} catch {
|
|
// Leave malformed values untouched; upstream will reject invalid paths.
|
|
}
|
|
delete headers['x-opencode-directory-encoding'];
|
|
return headers;
|
|
};
|
|
|
|
const waitForSseDrain = (res, signal) => new Promise((resolve) => {
|
|
if (signal?.aborted || res.writableEnded || res.destroyed) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const cleanup = () => {
|
|
res.off?.('drain', onDone);
|
|
res.off?.('close', onDone);
|
|
res.off?.('error', onDone);
|
|
signal?.removeEventListener?.('abort', onDone);
|
|
};
|
|
const onDone = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
res.once?.('drain', onDone);
|
|
res.once?.('close', onDone);
|
|
res.once?.('error', onDone);
|
|
signal?.addEventListener?.('abort', onDone, { once: true });
|
|
});
|
|
|
|
export const writeSseChunkWithBackpressure = async (res, value, signal) => {
|
|
if (!value || value.length === 0 || signal?.aborted || res.writableEnded || res.destroyed) {
|
|
return false;
|
|
}
|
|
|
|
const flushed = res.write(value);
|
|
if (flushed !== false) {
|
|
return true;
|
|
}
|
|
|
|
await waitForSseDrain(res, signal);
|
|
return !signal?.aborted && !res.writableEnded && !res.destroyed;
|
|
};
|
|
|
|
export const createSseBoundaryTracker = () => {
|
|
const decoder = new TextDecoder();
|
|
let tail = '';
|
|
|
|
const normalize = (value) => value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
return {
|
|
observe(value) {
|
|
const text = typeof value === 'string'
|
|
? value
|
|
: decoder.decode(value, { stream: true });
|
|
if (text.length > 0) {
|
|
tail = `${tail}${normalize(text)}`;
|
|
if (tail.length > 4096) {
|
|
tail = tail.slice(-4096);
|
|
}
|
|
}
|
|
return this.isAtBoundary();
|
|
},
|
|
isAtBoundary() {
|
|
return tail.length === 0 || tail.endsWith('\n\n');
|
|
},
|
|
};
|
|
};
|
|
|
|
const SESSION_LIST_ALLOWED_FIELDS = [
|
|
'id',
|
|
'slug',
|
|
'projectID',
|
|
'workspaceID',
|
|
'directory',
|
|
'path',
|
|
'parentID',
|
|
'title',
|
|
'agent',
|
|
'model',
|
|
'version',
|
|
'time',
|
|
'cost',
|
|
'tokens',
|
|
'share',
|
|
'metadata',
|
|
'project',
|
|
];
|
|
|
|
const sanitizeSessionListItem = (session) => {
|
|
if (!session || typeof session !== 'object' || Array.isArray(session)) {
|
|
return session;
|
|
}
|
|
|
|
const sanitized = {};
|
|
for (const key of SESSION_LIST_ALLOWED_FIELDS) {
|
|
if (key in session) {
|
|
sanitized[key] = session[key];
|
|
}
|
|
}
|
|
|
|
const summary = session.summary;
|
|
if (summary && typeof summary === 'object' && !Array.isArray(summary)) {
|
|
const summaryWithoutDiffs = { ...summary };
|
|
delete summaryWithoutDiffs.diffs;
|
|
sanitized.summary = summaryWithoutDiffs;
|
|
}
|
|
|
|
const revert = session.revert;
|
|
if (revert && typeof revert === 'object' && !Array.isArray(revert)) {
|
|
const revertMarker = {};
|
|
if (typeof revert.messageID === 'string') {
|
|
revertMarker.messageID = revert.messageID;
|
|
}
|
|
if (typeof revert.partID === 'string') {
|
|
revertMarker.partID = revert.partID;
|
|
}
|
|
if (Object.keys(revertMarker).length > 0) {
|
|
sanitized.revert = revertMarker;
|
|
}
|
|
}
|
|
|
|
return sanitized;
|
|
};
|
|
|
|
const sanitizeSessionListPayload = (payload) => {
|
|
if (!Array.isArray(payload)) {
|
|
return payload;
|
|
}
|
|
return payload.map((session) => sanitizeSessionListItem(session));
|
|
};
|
|
|
|
export const registerOpenCodeProxy = (app, deps) => {
|
|
const {
|
|
fs,
|
|
os,
|
|
path,
|
|
OPEN_CODE_READY_GRACE_MS,
|
|
LONG_REQUEST_TIMEOUT_MS,
|
|
getRuntime,
|
|
getOpenCodeAuthHeaders,
|
|
buildOpenCodeUrl,
|
|
ensureOpenCodeApiPrefix,
|
|
SSE_HEARTBEAT_INTERVAL_MS = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS,
|
|
SSE_UPSTREAM_STALL_TIMEOUT_MS = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
|
getSseUpstreamStallTimeoutMs = () => SSE_UPSTREAM_STALL_TIMEOUT_MS,
|
|
} = deps;
|
|
|
|
if (app.get('opencodeProxyConfigured')) {
|
|
return;
|
|
}
|
|
|
|
const runtime = getRuntime();
|
|
if (runtime.openCodePort) {
|
|
console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`);
|
|
} else {
|
|
console.log('Setting up OpenCode API gate (OpenCode not started yet)');
|
|
}
|
|
app.set('opencodeProxyConfigured', true);
|
|
|
|
const isAbortError = (error) => error?.name === 'AbortError';
|
|
const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902';
|
|
const canonicalizeDirectoryQuery = createDirectoryQueryCanonicalizer({
|
|
realpath: fs?.promises?.realpath?.bind(fs.promises),
|
|
});
|
|
|
|
const hasParsedBodyValue = (body) => {
|
|
if (body === undefined || body === null) return false;
|
|
if (Buffer.isBuffer(body)) return body.length > 0;
|
|
if (typeof body === 'string') return body.length > 0;
|
|
if (Array.isArray(body)) return body.length > 0;
|
|
if (typeof body === 'object') return Object.keys(body).length > 0;
|
|
return true;
|
|
};
|
|
|
|
const getContentType = (proxyReq, req) => {
|
|
const value = proxyReq.getHeader?.('content-type') ?? req.headers?.['content-type'] ?? '';
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
};
|
|
|
|
const serializeUrlEncodedBody = (body) => {
|
|
if (!body || typeof body !== 'object' || Buffer.isBuffer(body)) {
|
|
return String(body ?? '');
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(body)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
if (entry !== undefined && entry !== null) params.append(key, String(entry));
|
|
}
|
|
continue;
|
|
}
|
|
params.append(key, String(value));
|
|
}
|
|
return params.toString();
|
|
};
|
|
|
|
const serializeParsedBody = (req, proxyReq) => {
|
|
if (req.method === 'GET' || req.method === 'HEAD') return null;
|
|
if (req.body === undefined || req.body === null) return null;
|
|
const originalContentLength = Number.parseInt(req.headers?.['content-length'] || '0', 10) || 0;
|
|
if (!hasParsedBodyValue(req.body) && originalContentLength <= 0) return null;
|
|
|
|
const contentType = getContentType(proxyReq, req).toLowerCase();
|
|
if (Buffer.isBuffer(req.body)) return req.body;
|
|
if (contentType.includes('application/json')) return Buffer.from(JSON.stringify(req.body));
|
|
if (contentType.includes('application/x-www-form-urlencoded')) return Buffer.from(serializeUrlEncodedBody(req.body));
|
|
if (typeof req.body === 'string') return Buffer.from(req.body);
|
|
return null;
|
|
};
|
|
|
|
const replayParsedBody = (proxyReq, req) => {
|
|
const body = serializeParsedBody(req, proxyReq);
|
|
if (!body) return;
|
|
proxyReq.setHeader('content-length', String(body.length));
|
|
proxyReq.write(body);
|
|
};
|
|
|
|
const normalizeProxyTarget = (candidate) => {
|
|
if (typeof candidate !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = candidate.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed.replace(/\/+$/, '');
|
|
};
|
|
|
|
// Keep generic proxy requests on the same upstream base URL that health checks
|
|
// and direct fetch helpers use. This avoids split-brain state where /health
|
|
// succeeds against an external host but /api/* still proxies to 127.0.0.1.
|
|
const resolveProxyTarget = () => {
|
|
try {
|
|
const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
const runtimeState = getRuntime();
|
|
const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl);
|
|
if (externalBase) {
|
|
return externalBase;
|
|
}
|
|
|
|
return FALLBACK_PROXY_TARGET;
|
|
};
|
|
|
|
const normalizeProxyTimeout = (value) => {
|
|
return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000;
|
|
};
|
|
|
|
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
|
|
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
|
|
|
|
const isProxyTimeoutError = (error) => {
|
|
const code = typeof error?.code === 'string' ? error.code : '';
|
|
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
|
|
return code === 'ETIMEDOUT'
|
|
|| code === 'ESOCKETTIMEDOUT'
|
|
|| message.includes('timeout')
|
|
|| message.includes('timed out');
|
|
};
|
|
|
|
const sendProxyErrorResponse = (res, statusCode) => {
|
|
if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') {
|
|
return false;
|
|
}
|
|
res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' });
|
|
return true;
|
|
};
|
|
|
|
const applyProxyResponseDeadline = (req, res, next) => {
|
|
const timeout = setTimeout(() => {
|
|
req[PROXY_TIMEOUT_MARKER] = true;
|
|
if (sendProxyErrorResponse(res, 504)) {
|
|
res.once('finish', () => req.destroy?.());
|
|
}
|
|
}, PROXY_REQUEST_TIMEOUT_MS);
|
|
timeout.unref?.();
|
|
|
|
const clear = () => clearTimeout(timeout);
|
|
res.once('finish', clear);
|
|
res.once('close', clear);
|
|
next();
|
|
};
|
|
|
|
const forwardSseRequest = async (req, res) => {
|
|
const abortController = new AbortController();
|
|
const closeUpstream = () => abortController.abort();
|
|
let upstream = null;
|
|
let reader = null;
|
|
let heartbeatTimer = null;
|
|
let upstreamStallTimer = null;
|
|
let didUpstreamStall = false;
|
|
let writeQueue = Promise.resolve(true);
|
|
const sseBoundary = createSseBoundaryTracker();
|
|
|
|
req.on('close', closeUpstream);
|
|
|
|
try {
|
|
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
|
|
? req.originalUrl
|
|
: (typeof req.url === 'string' ? req.url : '');
|
|
const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
|
|
const headers = normalizeForwardedDirectoryHeaders(
|
|
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
|
|
);
|
|
headers.accept ??= 'text/event-stream';
|
|
headers['cache-control'] ??= 'no-cache';
|
|
|
|
upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
|
|
method: 'GET',
|
|
headers,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
res.status(upstream.status);
|
|
applyForwardProxyResponseHeaders(upstream.headers, res);
|
|
|
|
const contentType = upstream.headers.get('content-type') || 'text/event-stream';
|
|
const isEventStream = contentType.toLowerCase().includes('text/event-stream');
|
|
|
|
if (!upstream.body) {
|
|
res.end(await upstream.text().catch(() => ''));
|
|
return;
|
|
}
|
|
|
|
if (!isEventStream) {
|
|
res.end(await upstream.text());
|
|
return;
|
|
}
|
|
|
|
res.setHeader('Content-Type', contentType);
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
if (typeof res.flushHeaders === 'function') {
|
|
res.flushHeaders();
|
|
}
|
|
|
|
// Disable TCP Nagle's algorithm so small SSE chunks are sent immediately
|
|
// instead of being buffered up to ~200ms by the TCP stack.
|
|
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
|
res.socket.setNoDelay(true);
|
|
}
|
|
|
|
const scheduleHeartbeat = () => {
|
|
heartbeatTimer = setTimeout(async () => {
|
|
if (abortController.signal.aborted || res.writableEnded || res.destroyed) {
|
|
return;
|
|
}
|
|
if (!sseBoundary.isAtBoundary()) {
|
|
scheduleHeartbeat();
|
|
return;
|
|
}
|
|
const canContinue = await enqueueSseWrite(':heartbeat\n\n');
|
|
if (canContinue) {
|
|
scheduleHeartbeat();
|
|
}
|
|
}, SSE_HEARTBEAT_INTERVAL_MS);
|
|
};
|
|
|
|
const clearUpstreamStallTimer = () => {
|
|
clearTimeout(upstreamStallTimer);
|
|
upstreamStallTimer = null;
|
|
};
|
|
|
|
const resetUpstreamStallTimer = () => {
|
|
clearUpstreamStallTimer();
|
|
upstreamStallTimer = setTimeout(() => {
|
|
didUpstreamStall = true;
|
|
abortController.abort();
|
|
}, getSseUpstreamStallTimeoutMs());
|
|
upstreamStallTimer.unref?.();
|
|
};
|
|
|
|
const enqueueSseWrite = (value) => {
|
|
writeQueue = writeQueue
|
|
.catch(() => false)
|
|
.then((canContinue) => {
|
|
if (!canContinue) {
|
|
return false;
|
|
}
|
|
return writeSseChunkWithBackpressure(res, value, abortController.signal);
|
|
});
|
|
return writeQueue;
|
|
};
|
|
|
|
scheduleHeartbeat();
|
|
resetUpstreamStallTimer();
|
|
|
|
reader = upstream.body.getReader();
|
|
while (!abortController.signal.aborted) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
if (value && value.length > 0) {
|
|
resetUpstreamStallTimer();
|
|
sseBoundary.observe(value);
|
|
const canContinue = await enqueueSseWrite(value);
|
|
if (!canContinue) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
res.end();
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
if (didUpstreamStall && !res.writableEnded && !res.destroyed) {
|
|
await writeQueue.catch(() => false);
|
|
res.end();
|
|
}
|
|
return;
|
|
}
|
|
console.error('[proxy] OpenCode SSE proxy error:', error?.message ?? error);
|
|
if (!res.headersSent) {
|
|
res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
} else {
|
|
res.end();
|
|
}
|
|
} finally {
|
|
if (heartbeatTimer) {
|
|
clearTimeout(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
if (upstreamStallTimer) {
|
|
clearTimeout(upstreamStallTimer);
|
|
upstreamStallTimer = null;
|
|
}
|
|
req.off('close', closeUpstream);
|
|
try {
|
|
if (reader) {
|
|
await reader.cancel();
|
|
reader.releaseLock();
|
|
} else if (upstream?.body && !upstream.body.locked) {
|
|
await upstream.body.cancel();
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
|
|
const headers = req
|
|
? {
|
|
...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())),
|
|
accept: 'application/json',
|
|
'accept-encoding': 'identity',
|
|
}
|
|
: {
|
|
Accept: 'application/json',
|
|
...getOpenCodeAuthHeaders(),
|
|
'accept-encoding': 'identity',
|
|
};
|
|
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
|
|
method: 'GET',
|
|
headers,
|
|
...(typeof timeoutMs === 'number' ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
});
|
|
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
|
|
const bodyText = await upstream.text();
|
|
const isJson = contentType.toLowerCase().includes('application/json');
|
|
|
|
if (!isJson) {
|
|
return { upstream, contentType, bodyText, payload: null, isJson: false };
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(bodyText);
|
|
return { upstream, contentType, bodyText, payload, isJson: true, parseError: null };
|
|
} catch (parseError) {
|
|
return { upstream, contentType, bodyText, payload: null, isJson: true, parseError };
|
|
}
|
|
};
|
|
|
|
const getRequestUpstreamPath = async (req) => {
|
|
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
|
|
? req.originalUrl
|
|
: (typeof req.url === 'string' ? req.url : '');
|
|
const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
|
|
return canonicalizeDirectoryQuery(upstreamPathRaw);
|
|
};
|
|
|
|
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
|
|
try {
|
|
const upstreamPath = await getRequestUpstreamPath(req);
|
|
const result = await fetchSessionListPayload(upstreamPath, { req });
|
|
|
|
res.status(result.upstream.status);
|
|
applyForwardProxyResponseHeaders(result.upstream.headers, res);
|
|
|
|
if (!result.isJson) {
|
|
res.setHeader('content-type', result.contentType);
|
|
res.end(result.bodyText);
|
|
return;
|
|
}
|
|
|
|
if (result.parseError || !Array.isArray(result.payload)) {
|
|
res.setHeader('content-type', result.contentType);
|
|
res.end(result.bodyText);
|
|
return;
|
|
}
|
|
|
|
res.setHeader('content-type', result.contentType);
|
|
res.json(sanitizeSessionListPayload(result.payload));
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
return;
|
|
}
|
|
console.error(`[proxy] OpenCode ${logLabel} proxy error:`, error?.message ?? error);
|
|
if (!res.headersSent) {
|
|
res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
return;
|
|
}
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Ensure API prefix is detected before proxying
|
|
app.use('/api', (_req, _res, next) => {
|
|
ensureOpenCodeApiPrefix();
|
|
next();
|
|
});
|
|
|
|
// Readiness gate — while OpenCode is starting/restarting, HOLD the request and
|
|
// poll readiness instead of returning 503 immediately. A bare 503 pushes the
|
|
// client into an exponential-backoff retry loop (500ms → 1s → …) that wastes
|
|
// seconds of cold-start time and can fail bootstrap outright. Holding the
|
|
// request until OpenCode is ready (typically well under a second) lets the
|
|
// first call simply succeed. We still 503 if readiness doesn't arrive within a
|
|
// bounded window so genuinely-down servers fail fast.
|
|
const READINESS_HOLD_POLL_MS = 75;
|
|
const READINESS_HOLD_MAX_MS = 6000;
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const isStillWaiting = (runtimeState) => {
|
|
const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
|
|
return (
|
|
(!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
|
runtimeState.isRestartingOpenCode ||
|
|
!runtimeState.openCodePort
|
|
);
|
|
};
|
|
const classifyReadinessRoute = (requestPath) => {
|
|
if (/^\/session\/[^/]+\/message(?:\/|$)/.test(requestPath)) return 'session-messages';
|
|
if (requestPath === '/session' || requestPath.startsWith('/session/')) return 'session';
|
|
if (requestPath === '/event' || requestPath === '/global/event') return 'events';
|
|
return 'other';
|
|
};
|
|
|
|
app.use('/api', async (req, res, next) => {
|
|
if (
|
|
req.path.startsWith('/themes/custom') ||
|
|
req.path.startsWith('/push') ||
|
|
req.path.startsWith('/config/agents') ||
|
|
req.path.startsWith('/config/opencode-resolution') ||
|
|
req.path.startsWith('/config/settings') ||
|
|
req.path.startsWith('/config/skills') ||
|
|
req.path === '/config/reload' ||
|
|
req.path === '/health'
|
|
) {
|
|
return next();
|
|
}
|
|
|
|
if (!isStillWaiting(getRuntime())) {
|
|
return next();
|
|
}
|
|
|
|
const holdStartedAt = performance.now();
|
|
const routeClass = classifyReadinessRoute(req.path);
|
|
const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS);
|
|
while (Date.now() < deadline) {
|
|
// Client gave up (closed/aborted) — stop holding.
|
|
if (res.writableEnded || req.aborted) {
|
|
recordStartupPerformance('proxy.readiness-hold', {
|
|
durationMs: performance.now() - holdStartedAt,
|
|
outcome: 'aborted',
|
|
routeClass,
|
|
});
|
|
return;
|
|
}
|
|
await sleep(READINESS_HOLD_POLL_MS);
|
|
if (!isStillWaiting(getRuntime())) {
|
|
recordStartupPerformance('proxy.readiness-hold', {
|
|
durationMs: performance.now() - holdStartedAt,
|
|
outcome: 'ready',
|
|
routeClass,
|
|
});
|
|
return next();
|
|
}
|
|
}
|
|
|
|
recordStartupPerformance('proxy.readiness-hold', {
|
|
durationMs: performance.now() - holdStartedAt,
|
|
outcome: 'timeout',
|
|
routeClass,
|
|
});
|
|
if (!res.headersSent) {
|
|
res.status(503).json({
|
|
error: 'OpenCode is restarting',
|
|
restarting: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Windows: session merge for cross-directory session listing
|
|
if (process.platform === 'win32') {
|
|
app.get('/api/session', async (req, res, next) => {
|
|
const rawUrl = req.originalUrl || req.url || '';
|
|
if (rawUrl.includes('directory=')) return next();
|
|
|
|
const fetchWindowsSessionList = async (sessionPath) => {
|
|
const result = await fetchSessionListPayload(sessionPath, { req, timeoutMs: 10000 });
|
|
if (!result.upstream.ok || !Array.isArray(result.payload)) return null;
|
|
return sanitizeSessionListPayload(result.payload);
|
|
};
|
|
|
|
try {
|
|
const globalSessions = await fetchWindowsSessionList('/session').catch((error) => {
|
|
console.log(`[SessionMerge] Global session list failed: ${error.message}`);
|
|
return null;
|
|
});
|
|
|
|
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
|
let projectDirs = [];
|
|
try {
|
|
const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
|
|
const settings = JSON.parse(settingsRaw);
|
|
projectDirs = (settings.projects || [])
|
|
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
|
|
.filter(Boolean);
|
|
} catch {
|
|
}
|
|
|
|
const seen = new Set(
|
|
(globalSessions || [])
|
|
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
|
|
.filter((id) => typeof id === 'string')
|
|
);
|
|
const extraSessions = [];
|
|
let successfulProjectReads = 0;
|
|
for (const dir of projectDirs) {
|
|
const candidates = Array.from(new Set([
|
|
dir,
|
|
dir.replace(/\\/g, '/'),
|
|
dir.replace(/\//g, '\\'),
|
|
]));
|
|
for (const candidateDir of candidates) {
|
|
const encoded = encodeURIComponent(candidateDir);
|
|
try {
|
|
const dirSessions = await fetchWindowsSessionList(`/session?directory=${encoded}`);
|
|
if (dirSessions) {
|
|
successfulProjectReads += 1;
|
|
}
|
|
for (const session of dirSessions || []) {
|
|
const id = session && typeof session.id === 'string' ? session.id : null;
|
|
if (id && !seen.has(id)) {
|
|
seen.add(id);
|
|
extraSessions.push(session);
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!globalSessions && successfulProjectReads === 0) {
|
|
return res.status(504).json({ error: 'OpenCode session list timed out' });
|
|
}
|
|
|
|
const merged = [...(globalSessions || []), ...extraSessions];
|
|
merged.sort((a, b) => {
|
|
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
|
|
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
|
|
return bTime - aTime;
|
|
});
|
|
console.log(`[SessionMerge] ${globalSessions?.length || 0} global + ${extraSessions.length} extra = ${merged.length} total`);
|
|
return res.json(sanitizeSessionListPayload(merged));
|
|
} catch (error) {
|
|
console.log(`[SessionMerge] Error: ${error.message}`);
|
|
return res.status(500).json({ error: error.message || 'Failed to merge Windows sessions' });
|
|
}
|
|
});
|
|
}
|
|
|
|
app.get('/api/session', (req, res, next) => {
|
|
return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
|
|
});
|
|
|
|
app.get('/api/global/event', forwardSseRequest);
|
|
app.get('/api/event', forwardSseRequest);
|
|
|
|
app.get('/api/experimental/session', (req, res, next) => {
|
|
return forwardSanitizedSessionListRequest(req, res, next, 'experimental.session');
|
|
});
|
|
|
|
// Generic proxy for non-SSE OpenCode API routes.
|
|
const apiProxy = createProxyMiddleware({
|
|
target: resolveProxyTarget(),
|
|
changeOrigin: true,
|
|
pathRewrite: { '^/api': '' },
|
|
timeout: PROXY_REQUEST_TIMEOUT_MS,
|
|
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
|
|
// Dynamic target — port can change after restart
|
|
router: () => resolveProxyTarget(),
|
|
on: {
|
|
proxyReq: (proxyReq, req) => {
|
|
// Inject OpenCode auth headers
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|
if (authHeaders.Authorization) {
|
|
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
|
}
|
|
|
|
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
|
|
const rawDirectory = req.headers['x-opencode-directory'];
|
|
if (typeof rawDirectory === 'string') {
|
|
try {
|
|
proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory));
|
|
} catch {
|
|
proxyReq.setHeader('x-opencode-directory', rawDirectory);
|
|
}
|
|
}
|
|
proxyReq.removeHeader?.('x-opencode-directory-encoding');
|
|
}
|
|
|
|
// Defensive: request identity encoding from upstream OpenCode.
|
|
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
|
proxyReq.setHeader('accept-encoding', 'identity');
|
|
|
|
replayParsedBody(proxyReq, req);
|
|
},
|
|
proxyRes: (proxyRes) => {
|
|
for (const key of Object.keys(proxyRes.headers || {})) {
|
|
if (!shouldForwardProxyResponseHeader(key)) {
|
|
delete proxyRes.headers[key];
|
|
}
|
|
}
|
|
},
|
|
error: (err, req, res) => {
|
|
console.error('[proxy] OpenCode proxy error:', err.message);
|
|
if (req?.[PROXY_TIMEOUT_MARKER]) {
|
|
return;
|
|
}
|
|
const statusCode = isProxyTimeoutError(err) ? 504 : 503;
|
|
sendProxyErrorResponse(res, statusCode);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Best-effort fallback for stale clients still sending symlink paths.
|
|
// Settings and project selection normalize at source; this cached async path
|
|
// avoids blocking the proxy hot path on every directory-scoped request.
|
|
app.use('/api', async (req, _res, next) => {
|
|
try {
|
|
const rewrittenUrl = await canonicalizeDirectoryQuery(req.url);
|
|
if (rewrittenUrl !== req.url) {
|
|
req.url = rewrittenUrl;
|
|
}
|
|
} catch {
|
|
// Pass through as-is if URL parsing or realpath resolution fails.
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use('/api', applyProxyResponseDeadline);
|
|
app.use('/api', apiProxy);
|
|
};
|