Fix duplicated health probe URL in diagnostics Share session list proxy handling across platforms Avoid repeated hanging session requests on Windows
696 lines
22 KiB
JavaScript
696 lines
22 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 fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => {
|
|
const headers = req
|
|
? {
|
|
...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
|
|
);
|
|
};
|
|
|
|
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();
|
|
|
|
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': '' },
|
|
// 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);
|
|
};
|