import { createProxyMiddleware } from 'http-proxy-middleware'; import { applyForwardProxyResponseHeaders, collectForwardProxyHeaders, shouldForwardProxyResponseHeader, } from '../../proxy-headers.js'; 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 forwardSseRequest = async (req, res) => { const abortController = new AbortController(); const closeUpstream = () => abortController.abort(); let upstream = null; let reader = null; 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(); } reader = upstream.body.getReader(); while (!abortController.signal.aborted) { const { done, value } = await reader.read(); if (done) { break; } if (value && value.length > 0) { res.write(value); } } 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 { req.off('close', closeUpstream); try { if (reader) { await reader.cancel(); reader.releaseLock(); } else if (upstream?.body && !upstream.body.locked) { await upstream.body.cancel(); } } catch { } } }; // 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(); } }); } app.get('/api/global/event', forwardSseRequest); app.get('/api/event', forwardSseRequest); // Generic proxy for non-SSE OpenCode API routes. const apiProxy = createProxyMiddleware({ target: `http://127.0.0.1:${runtime.openCodePort || 3902}`, changeOrigin: true, pathRewrite: { '^/api': '' }, // Dynamic target — port can change after restart router: () => { const rt = getRuntime(); return `http://127.0.0.1:${rt.openCodePort || 3902}`; }, on: { proxyReq: (proxyReq) => { // 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'); }, 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' }); } }, }, }); app.use('/api', apiProxy); };