2026-03-31 18:47:00 +03:00
|
|
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
|
|
|
|
2026-04-01 17:01:35 +03:00
|
|
|
import {
|
|
|
|
|
applyForwardProxyResponseHeaders,
|
|
|
|
|
collectForwardProxyHeaders,
|
|
|
|
|
shouldForwardProxyResponseHeader,
|
|
|
|
|
} from '../../proxy-headers.js';
|
2026-04-01 15:19:20 +02:00
|
|
|
|
2026-04-23 23:40:02 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 20:16:44 +09:00
|
|
|
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');
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-04-01 17:01:35 +03:00
|
|
|
const isAbortError = (error) => error?.name === 'AbortError';
|
2026-04-07 01:17:12 +03:00
|
|
|
const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902';
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
2026-04-01 17:01:35 +03:00
|
|
|
|
|
|
|
|
const forwardSseRequest = async (req, res) => {
|
|
|
|
|
const abortController = new AbortController();
|
|
|
|
|
const closeUpstream = () => abortController.abort();
|
|
|
|
|
let upstream = null;
|
|
|
|
|
let reader = null;
|
2026-04-27 20:16:44 +09:00
|
|
|
let heartbeatTimer = null;
|
|
|
|
|
let writeQueue = Promise.resolve(true);
|
|
|
|
|
const sseBoundary = createSseBoundaryTracker();
|
2026-04-01 17:01:35 +03:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 01:17:12 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 20:16:44 +09:00
|
|
|
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();
|
|
|
|
|
|
2026-04-01 17:01:35 +03:00
|
|
|
reader = upstream.body.getReader();
|
|
|
|
|
while (!abortController.signal.aborted) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (value && value.length > 0) {
|
2026-04-27 20:16:44 +09:00
|
|
|
sseBoundary.observe(value);
|
|
|
|
|
const canContinue = await enqueueSseWrite(value);
|
2026-04-23 23:40:02 +03:00
|
|
|
if (!canContinue) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-01 17:01:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-27 20:16:44 +09:00
|
|
|
if (heartbeatTimer) {
|
|
|
|
|
clearTimeout(heartbeatTimer);
|
|
|
|
|
heartbeatTimer = null;
|
|
|
|
|
}
|
2026-04-01 17:01:35 +03:00
|
|
|
req.off('close', closeUpstream);
|
|
|
|
|
try {
|
|
|
|
|
if (reader) {
|
|
|
|
|
await reader.cancel();
|
|
|
|
|
reader.releaseLock();
|
|
|
|
|
} else if (upstream?.body && !upstream.body.locked) {
|
|
|
|
|
await upstream.body.cancel();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Ensure API prefix is detected before proxying
|
|
|
|
|
app.use('/api', (_req, _res, next) => {
|
|
|
|
|
ensureOpenCodeApiPrefix();
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Readiness gate — return 503 while OpenCode is starting/restarting
|
|
|
|
|
app.use('/api', (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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runtimeState = getRuntime();
|
|
|
|
|
const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
|
|
|
|
|
const stillWaiting =
|
|
|
|
|
(!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
|
|
|
|
runtimeState.isRestartingOpenCode ||
|
|
|
|
|
!runtimeState.openCodePort;
|
|
|
|
|
|
|
|
|
|
if (stillWaiting) {
|
|
|
|
|
return res.status(503).json({
|
|
|
|
|
error: 'OpenCode is restarting',
|
|
|
|
|
restarting: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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(merged);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 17:01:35 +03:00
|
|
|
app.get('/api/global/event', forwardSseRequest);
|
|
|
|
|
app.get('/api/event', forwardSseRequest);
|
|
|
|
|
|
|
|
|
|
// Generic proxy for non-SSE OpenCode API routes.
|
2026-03-31 18:47:00 +03:00
|
|
|
const apiProxy = createProxyMiddleware({
|
2026-04-07 01:17:12 +03:00
|
|
|
target: resolveProxyTarget(),
|
2026-03-31 18:47:00 +03:00
|
|
|
changeOrigin: true,
|
|
|
|
|
pathRewrite: { '^/api': '' },
|
|
|
|
|
// Dynamic target — port can change after restart
|
2026-04-07 01:17:12 +03:00
|
|
|
router: () => resolveProxyTarget(),
|
2026-03-31 18:47:00 +03:00
|
|
|
on: {
|
|
|
|
|
proxyReq: (proxyReq) => {
|
|
|
|
|
// Inject OpenCode auth headers
|
|
|
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|
|
|
|
if (authHeaders.Authorization) {
|
|
|
|
|
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
|
|
|
|
}
|
2026-04-01 14:24:46 +08:00
|
|
|
|
|
|
|
|
// Defensive: request identity encoding from upstream OpenCode.
|
|
|
|
|
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
|
|
|
|
proxyReq.setHeader('accept-encoding', 'identity');
|
2026-03-31 18:47:00 +03:00
|
|
|
},
|
2026-04-01 15:19:20 +02:00
|
|
|
proxyRes: (proxyRes) => {
|
|
|
|
|
for (const key of Object.keys(proxyRes.headers || {})) {
|
|
|
|
|
if (!shouldForwardProxyResponseHeader(key)) {
|
|
|
|
|
delete proxyRes.headers[key];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-31 18:47:00 +03:00
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.use('/api', apiProxy);
|
|
|
|
|
};
|