Files
openchamber/packages/web/server/lib/opencode/proxy.js
T
Bohdan Triapitsyn e372c8d8cb perf: instant startup via cache hydration + decoupled readiness (#1650)
* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)

Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.

* perf(startup): cache directory session list for instant sidebar

Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.

* perf(startup): hold API requests through OpenCode warmup instead of 503

The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.

* perf(startup): surface cached providers/agents in pickers (optimistic readiness)

The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.

* perf(startup): don't abort directory bootstrap on transient phase-1 failure

A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.

* perf(startup): don't bootstrap directories from archived sidebar rows

Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.

* perf(startup): stop empty-session bootstrap retry storm on web/desktop

The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.

* perf(startup): scope provider/agent config to project (worktrees inherit)

Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).

- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
  /loadAgents/activateDirectory now key by it, so a worktree reuses one shared
  project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
  the snapshot key always match (picker stays consistent); the OpenCode working
  directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
  refresh skips re-fetching config that was just loaded (initializeApp then
  activateDirectory for the same project), and to avoid churn on rapid project
  switches. Config-change invalidation clears the snapshot, which bypasses the
  guard, so freshness never masks a needed refresh.

* fix(sidebar): default archived sessions to hidden to avoid startup flash

useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).

* perf(startup): persist worktree->project mapping to kill cold double-load

The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.

* perf(startup): persist worktree map for instant sidebar + first-launch keying

Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.

* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch

On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.

* perf(startup): cache git branches so the draft branch selector paints instantly

The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
2026-06-15 03:16:34 +03:00

666 lines
21 KiB
JavaScript

import { createProxyMiddleware } from 'http-proxy-middleware';
import {
applyForwardProxyResponseHeaders,
collectForwardProxyHeaders,
shouldForwardProxyResponseHeader,
} from '../../proxy-headers.js';
import { createRealpathCache } from '../path-realpath-cache.js';
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 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',
];
export 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;
};
export 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,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
} = 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;
}
if (runtimeState.openCodePort) {
return `http://localhost:${runtimeState.openCodePort}`;
}
return FALLBACK_PROXY_TARGET;
};
const forwardSseRequest = async (req, res) => {
const abortController = new AbortController();
const closeUpstream = () => abortController.abort();
let upstream = null;
let reader = null;
let heartbeatTimer = null;
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 = 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 SSE_HEARTBEAT_INTERVAL_MS = 20_000;
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 enqueueSseWrite = (value) => {
writeQueue = writeQueue
.catch(() => false)
.then((canContinue) => {
if (!canContinue) {
return false;
}
return writeSseChunkWithBackpressure(res, value, abortController.signal);
});
return writeQueue;
};
scheduleHeartbeat();
reader = upstream.body.getReader();
while (!abortController.signal.aborted) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value && value.length > 0) {
sseBoundary.observe(value);
const canContinue = await enqueueSseWrite(value);
if (!canContinue) {
break;
}
}
}
res.end();
} catch (error) {
if (isAbortError(error)) {
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;
}
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 forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
try {
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;
const upstreamPath = await canonicalizeDirectoryQuery(upstreamPathRaw);
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
method: 'GET',
headers: {
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
accept: 'application/json',
'accept-encoding': 'identity',
},
});
res.status(upstream.status);
applyForwardProxyResponseHeaders(upstream.headers, res);
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
const bodyText = await upstream.text();
if (!contentType.toLowerCase().includes('application/json')) {
res.setHeader('content-type', contentType);
res.end(bodyText);
return;
}
let payload;
try {
payload = JSON.parse(bodyText);
} catch {
res.setHeader('content-type', contentType);
res.end(bodyText);
return;
}
res.setHeader('content-type', contentType);
res.json(sanitizeSessionListPayload(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
);
};
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 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) return;
await sleep(READINESS_HOLD_POLL_MS);
if (!isStillWaiting(getRuntime())) {
return next();
}
}
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();
try {
const authHeaders = getOpenCodeAuthHeaders();
const fetchOpts = {
method: 'GET',
headers: { Accept: 'application/json', ...authHeaders },
signal: AbortSignal.timeout(10000),
};
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
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 = [];
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 dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
if (dirRes.ok) {
const dirPayload = await dirRes.json().catch(() => []);
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
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 {
}
}
}
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} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(sanitizeSessionListPayload(merged));
} catch (error) {
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
next();
}
});
}
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': '' },
// 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);
}
// 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 (res && !res.headersSent && typeof res.status === 'function') {
res.status(503).json({ error: 'OpenCode service unavailable' });
}
},
},
});
// 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', apiProxy);
};