32 KiB
32 KiB
OpenCode Server Integration Guide
Overview
This guide details how to integrate OpenCode's serve command with the chat server to enable a persistent single-process architecture, reducing resource consumption when multiple sessions are running concurrently.
Current Architecture vs. Proposed
Current (Per-Message Spawning)
┌─────────────┐ spawn ┌─────────────┐
│ Chat Server │ ──────────────▶│ OpenCode │ (Process 1)
│ │ │ --session A │
│ │ └─────────────┘
│ │ spawn ┌─────────────┐
│ │ ──────────────▶│ OpenCode │ (Process 2)
│ │ │ --session B │
└─────────────┘ └─────────────┘
Memory: 2 processes × ~300MB = ~600MB
Startup: ~1-2s latency per message
Proposed (Persistent Server)
┌─────────────┐ HTTP/WS ┌─────────────┐
│ Chat Server │ ◀─────────────▶│ OpenCode │ (Single Process)
│ │ │ serve │
│ │ │ :4096 │
│ │ │ │
│ Session A ─┼── POST /session/A/message
│ Session B ─┼── POST /session/B/message
└─────────────┘ └─────────────┘
Memory: 1 process × ~400MB = ~400MB
Startup: ~10-50ms latency per message
OpenCode Server API Reference
Server Startup
opencode serve --port 4096 --hostname 127.0.0.1
Options:
--port: Port number (default: 4096, auto-selects if 4096 is busy)--hostname: Bind address (default: 127.0.0.1)--mdns: Enable mDNS discovery (optional)--cors: CORS whitelist origins (optional)
Environment Variables:
OPENCODE_SERVER_PASSWORD: Basic auth password (optional but recommended)OPENCODE_SERVER_USERNAME: Basic auth username (default: "opencode")
Key Endpoints
Session Management
| Method | Endpoint | Description |
|---|---|---|
| GET | /session |
List all sessions |
| POST | /session |
Create new session |
| GET | /session/:sessionID |
Get session details |
| DELETE | /session/:sessionID |
Delete session |
| PATCH | /session/:sessionID |
Update session (title, etc.) |
| POST | /session/:sessionID/abort |
Abort running task |
Messaging
| Method | Endpoint | Description |
|---|---|---|
| GET | /session/:sessionID/message |
Get all messages |
| POST | /session/:sessionID/message |
Send prompt (streaming response) |
| POST | /session/:sessionID/prompt_async |
Send prompt (non-blocking) |
| POST | /session/:sessionID/command |
Send command |
| POST | /session/:sessionID/shell |
Execute shell command |
Events
| Method | Endpoint | Description |
|---|---|---|
| GET | /event |
SSE stream of all events |
| GET | /session/status |
Get all session statuses |
Project Context
All endpoints accept either:
- Query parameter:
?directory=/path/to/project - Header:
x-opencode-directory: /path/to/project
Request/Response Schemas
Create Session
// POST /session
// Request body (optional)
{
title?: string,
agent?: string, // Agent ID (e.g., "general", "code-review")
model?: {
providerID: string,
modelID: string
}
}
// Response
{
id: string,
title: string,
directory: string,
time: {
created: number,
updated: number
},
// ... other fields
}
Send Message
// POST /session/:sessionID/message
// Request body
{
prompt: {
parts: Array<{ type: "text", text: string } | { type: "image", url: string }>
},
agent?: string,
model?: {
providerID: string,
modelID: string
}
}
// Response (streaming)
{
info: {
id: string,
sessionID: string,
role: "assistant",
// ...
},
parts: Array<MessagePart>
}
Event Stream (SSE)
// GET /event
// Events are JSON objects separated by newlines
// Event types include:
{
type: "server.connected",
properties: {}
}
{
type: "message.part",
properties: {
sessionID: string,
messageID: string,
part: { ... }
}
}
{
type: "message.complete",
properties: {
sessionID: string,
messageID: string
}
}
{
type: "session.status",
properties: {
sessionID: string,
status: "idle" | "busy" | "error"
}
}
{
type: "server.heartbeat",
properties: {}
}
Implementation Plan
Phase 1: OpencodeProcessManager Enhancement
File: server.js (lines 1117-1380)
1.1 Add Connection State
class OpencodeProcessManager {
constructor() {
this.process = null;
this.isReady = false;
this.baseUrl = 'http://127.0.0.1:4096';
this.pendingRequests = new Map();
this.eventSource = null;
this.eventHandlers = new Map(); // sessionId -> callback[]
this.lastActivity = Date.now();
this.sessionWorkspaces = new Map(); // sessionId -> { directory, userId, appId }
this.authHeader = null;
this.connectionAttempts = 0;
this.maxConnectionAttempts = 3;
this.healthCheckInterval = null;
}
}
1.2 Server Startup Method
async start() {
if (this.process && this.isReady) {
log('OpenCode server already running');
return;
}
log('Starting OpenCode server...');
const cliCommand = resolveCliCommand('opencode');
const port = Number(process.env.OPENCODE_SERVER_PORT || 4096);
const hostname = process.env.OPENCODE_SERVER_HOST || '127.0.0.1';
// Set auth if password is configured
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (password) {
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
}
try {
// Verify CLI exists
fsSync.accessSync(cliCommand, fsSync.constants.X_OK);
} catch (err) {
throw new Error(`OpenCode CLI not found: ${cliCommand}`);
}
// Start the serve process
const args = ['serve', '--port', String(port), '--hostname', hostname];
this.process = spawn(cliCommand, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: password || '',
OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME || 'opencode'
}
});
// Handle process events
this.process.stdout.on('data', (data) => {
const text = data.toString();
log('OpenCode server stdout', { text: text.slice(0, 200) });
// Detect ready signal
if (text.includes('listening on http://')) {
this.isReady = true;
this.connectionAttempts = 0;
log('OpenCode server ready', { port, hostname });
// Start event subscription
this.subscribeToEvents();
// Start health check
this.startHealthCheck();
}
});
this.process.stderr.on('data', (data) => {
log('OpenCode server stderr', { text: data.toString().slice(0, 200) });
});
this.process.on('error', (err) => {
log('OpenCode server error', { error: String(err) });
this.isReady = false;
this.handleServerError(err);
});
this.process.on('exit', (code, signal) => {
log('OpenCode server exited', { code, signal });
this.isReady = false;
this.process = null;
this.stopHealthCheck();
this.closeEventSource();
// Auto-restart if not shutting down
if (!isShuttingDown && this.connectionAttempts < this.maxConnectionAttempts) {
this.connectionAttempts++;
log('Auto-restarting OpenCode server', { attempt: this.connectionAttempts });
setTimeout(() => this.start().catch(e =>
log('Failed to restart OpenCode server', { error: String(e) })
), 2000 * this.connectionAttempts);
}
});
// Wait for ready signal
await this.waitForReady(30000);
this.baseUrl = `http://${hostname}:${port}`;
}
1.3 Health Check Implementation
startHealthCheck() {
if (this.healthCheckInterval) return;
this.healthCheckInterval = setInterval(async () => {
if (!this.isReady || !this.process) return;
try {
const response = await fetch(`${this.baseUrl}/session/status`, {
method: 'GET',
headers: this.getHeaders()
});
if (!response.ok) {
log('Health check failed', { status: response.status });
if (response.status >= 500) {
this.isReady = false;
}
}
} catch (err) {
log('Health check error', { error: String(err) });
this.isReady = false;
}
}, 30000); // Every 30 seconds
}
stopHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
1.4 Event Subscription (SSE)
subscribeToEvents() {
if (this.eventSource) {
this.closeEventSource();
}
// Use native HTTP client for SSE (Node.js doesn't have EventSource by default)
const url = new URL('/event', this.baseUrl);
const headers = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
};
if (this.authHeader) {
headers['Authorization'] = this.authHeader;
}
const req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
headers
}, (res) => {
let buffer = '';
res.on('data', (chunk) => {
buffer += chunk.toString();
// Process complete SSE messages
const lines = buffer.split('\n\n');
buffer = lines.pop() || ''; // Keep incomplete message in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
this.handleServerEvent(event);
} catch (e) {
log('Failed to parse SSE event', { line: line.slice(0, 100) });
}
}
}
});
res.on('end', () => {
log('SSE connection closed');
this.eventSource = null;
// Reconnect if server is still running
if (this.isReady && !isShuttingDown) {
setTimeout(() => this.subscribeToEvents(), 1000);
}
});
});
req.on('error', (err) => {
log('SSE connection error', { error: String(err) });
this.eventSource = null;
if (this.isReady && !isShuttingDown) {
setTimeout(() => this.subscribeToEvents(), 2000);
}
});
req.end();
this.eventSource = req;
}
closeEventSource() {
if (this.eventSource) {
this.eventSource.destroy();
this.eventSource = null;
}
}
handleServerEvent(event) {
// Route events to appropriate handlers
const { type, properties } = event;
switch (type) {
case 'server.connected':
log('SSE connected to OpenCode server');
break;
case 'server.heartbeat':
// No action needed, just keeps connection alive
break;
case 'message.part':
this.handleMessagePart(properties);
break;
case 'message.complete':
this.handleMessageComplete(properties);
break;
case 'session.status':
this.handleSessionStatus(properties);
break;
case 'tool.call':
this.handleToolCall(properties);
break;
case 'tool.result':
this.handleToolResult(properties);
break;
case 'instance.disposed':
log('OpenCode instance disposed');
break;
default:
log('Unhandled server event', { type, properties });
}
}
1.5 HTTP Request Helper
getHeaders(directory = null) {
const headers = {
'Content-Type': 'application/json'
};
if (this.authHeader) {
headers['Authorization'] = this.authHeader;
}
if (directory) {
headers['x-opencode-directory'] = directory;
}
return headers;
}
async request(method, path, body = null, directory = null) {
if (!this.isReady) {
throw new Error('OpenCode server not ready');
}
const url = new URL(path, this.baseUrl);
if (directory) {
url.searchParams.set('directory', directory);
}
const options = {
method,
headers: this.getHeaders(directory)
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url.toString(), options);
if (!response.ok) {
const errorText = await response.text();
const error = new Error(`OpenCode API error: ${response.status} ${errorText}`);
error.status = response.status;
throw error;
}
// Handle streaming responses
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
}
return response;
}
1.6 Session Management
async createSession(workspaceDir, options = {}) {
const response = await this.request('POST', '/session', {
title: options.title || `Session ${Date.now()}`,
agent: options.agent,
model: options.model
}, workspaceDir);
const sessionId = response.id;
// Track session
this.sessionWorkspaces.set(sessionId, {
directory: workspaceDir,
userId: options.userId,
appId: options.appId,
createdAt: Date.now()
});
return response;
}
async getSession(sessionId, directory) {
return this.request('GET', `/session/${sessionId}`, null, directory);
}
async listSessions(directory) {
return this.request('GET', '/session', null, directory);
}
async deleteSession(sessionId, directory) {
const result = await this.request('DELETE', `/session/${sessionId}`, null, directory);
this.sessionWorkspaces.delete(sessionId);
return result;
}
1.7 Message Sending
async sendMessage(sessionId, workspaceDir, message, options = {}) {
const body = {
prompt: {
parts: [{ type: 'text', text: message.content }]
}
};
// Add images if present
if (message.attachments?.length) {
for (const attachment of message.attachments) {
if (attachment.type?.startsWith('image/')) {
body.prompt.parts.push({
type: 'image',
url: attachment.url // Must be a data URL or accessible URL
});
}
}
}
// Add agent/model if specified
if (options.agent) body.agent = options.agent;
if (options.model) body.model = options.model;
return this.request('POST', `/session/${sessionId}/message`, body, workspaceDir);
}
async sendMessageAsync(sessionId, workspaceDir, message, options = {}) {
// Non-blocking version - returns immediately, events come via SSE
const body = {
prompt: {
parts: [{ type: 'text', text: message.content }]
}
};
if (options.agent) body.agent = options.agent;
if (options.model) body.model = options.model;
await this.request('POST', `/session/${sessionId}/prompt_async`, body, workspaceDir);
}
async abortSession(sessionId, directory) {
return this.request('POST', `/session/${sessionId}/abort`, null, directory);
}
1.8 Event Handlers for Streaming
handleMessagePart(properties) {
const { sessionID, messageID, part } = properties;
// Find the pending request for this session
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'part',
part
});
}
// Also notify session-specific handlers
const handlers = this.eventHandlers.get(sessionID);
if (handlers) {
for (const handler of handlers) {
handler({ type: 'part', messageID, part });
}
}
}
handleMessageComplete(properties) {
const { sessionID, messageID } = properties;
// Resolve the pending request
const pending = this.pendingRequests.get(messageID);
if (pending) {
pending.resolve({ sessionID, messageID });
this.pendingRequests.delete(messageID);
}
// Notify session handlers
const handlers = this.eventHandlers.get(sessionID);
if (handlers) {
for (const handler of handlers) {
handler({ type: 'complete', messageID });
}
}
}
handleSessionStatus(properties) {
const { sessionID, status } = properties;
const workspace = this.sessionWorkspaces.get(sessionID);
if (workspace) {
workspace.status = status;
workspace.lastStatusUpdate = Date.now();
}
log('Session status update', { sessionID, status });
}
handleToolCall(properties) {
const { sessionID, messageID, tool, input } = properties;
// Forward to stream callback for real-time display
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'tool_call',
tool,
input
});
}
}
handleToolResult(properties) {
const { sessionID, messageID, tool, output } = properties;
const pending = this.pendingRequests.get(messageID);
if (pending?.streamCallback) {
pending.streamCallback({
type: 'tool_result',
tool,
output
});
}
}
// Register/unregister event handlers for a session
registerEventHandler(sessionId, handler) {
if (!this.eventHandlers.has(sessionId)) {
this.eventHandlers.set(sessionId, new Set());
}
this.eventHandlers.get(sessionId).add(handler);
return () => {
this.eventHandlers.get(sessionId)?.delete(handler);
if (this.eventHandlers.get(sessionId)?.size === 0) {
this.eventHandlers.delete(sessionId);
}
};
}
1.9 Cleanup and Shutdown
async stop() {
log('Stopping OpenCode server...');
this.isReady = false;
this.stopHealthCheck();
this.closeEventSource();
// Reject all pending requests
for (const [messageId, pending] of this.pendingRequests) {
pending.reject(new Error('OpenCode server shutting down'));
}
this.pendingRequests.clear();
this.eventHandlers.clear();
this.sessionWorkspaces.clear();
if (this.process) {
// Graceful shutdown
this.process.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
log('Force killing OpenCode server');
this.process.kill('SIGKILL');
}
resolve();
}, 5000);
if (this.process) {
this.process.once('exit', () => {
clearTimeout(timeout);
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
this.process = null;
}
log('OpenCode server stopped');
}
getStats() {
return {
isRunning: !!this.process,
isReady: this.isReady,
baseUrl: this.baseUrl,
activeSessions: this.sessionWorkspaces.size,
pendingRequests: this.pendingRequests.size,
eventHandlers: this.eventHandlers.size,
lastActivity: this.lastActivity,
idleTime: Date.now() - this.lastActivity
};
}
Phase 2: Integration with Chat Server
2.1 Modify sendToOpencode Function
File: server.js (around line 9519)
async function sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId }) {
const messageKey = message?.id || 'unknown';
// Check if server mode is available
if (opencodeManager.isReady) {
log('Using OpenCode server mode', { sessionId: session.id, messageId: messageKey });
try {
// Ensure session exists in OpenCode
let ocSessionId = opencodeSessionId || session.opencodeSessionId;
if (!ocSessionId) {
// Create new session in OpenCode server
const newSession = await opencodeManager.createSession(session.workspaceDir, {
title: session.title || `Chat ${session.id}`,
userId: session.userId,
appId: session.appId
});
ocSessionId = newSession.id;
session.opencodeSessionId = ocSessionId;
session.initialOpencodeSessionId = ocSessionId;
await persistState();
log('Created new OpenCode session', { ocSessionId });
}
// Register stream callback
let streamUnregister = null;
if (streamCallback) {
streamUnregister = opencodeManager.registerEventHandler(ocSessionId, (event) => {
streamCallback(event);
});
}
// Track this request
const requestId = messageKey;
opencodeManager.pendingRequests.set(requestId, {
resolve: () => {},
reject: () => {},
streamCallback,
started: Date.now()
});
// Send message
const response = await opencodeManager.sendMessageAsync(ocSessionId, session.workspaceDir, {
content,
attachments: message?.attachments
}, {
model: model ? { providerID: 'openrouter', modelID: model } : undefined
});
// Messages will come via SSE, handled by event handlers
// Return a promise that resolves when complete
return new Promise((resolve, reject) => {
const pending = opencodeManager.pendingRequests.get(requestId);
if (pending) {
pending.resolve = (result) => {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
resolve(result);
};
pending.reject = (error) => {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
reject(error);
};
}
// Timeout safety
setTimeout(() => {
if (opencodeManager.pendingRequests.has(requestId)) {
if (streamUnregister) streamUnregister();
opencodeManager.pendingRequests.delete(requestId);
reject(new Error('OpenCode request timed out'));
}
}, 600000); // 10 minutes
});
} catch (error) {
log('OpenCode server mode failed, falling back to spawn', { error: String(error) });
// Fall through to spawn-based execution
}
}
// Fallback: Original spawn-based implementation
return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId });
}
2.2 Initialize Server on Startup
File: server.js (in the startup section, around where server starts)
async function initializeOpencodeServer() {
const useServerMode = process.env.OPENCODE_SERVER_MODE !== 'false';
if (useServerMode) {
try {
log('Initializing OpenCode server mode...');
await opencodeManager.start();
log('OpenCode server mode initialized successfully');
} catch (error) {
log('Failed to initialize OpenCode server mode, using spawn fallback', {
error: String(error)
});
}
} else {
log('OpenCode server mode disabled, using spawn mode');
}
}
// Call during server startup
async function main() {
// ... existing initialization ...
await initializeOpencodeServer();
// ... start HTTP server ...
}
2.3 Cleanup on Shutdown
File: server.js (in gracefulShutdown function)
async function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
log(`Received ${signal}, starting graceful shutdown...`);
// Stop periodic tasks
stopAutoSave();
stopMemoryCleanup();
// Stop OpenCode server
log('Stopping OpenCode server...');
await opencodeManager.stop();
// ... rest of existing shutdown logic ...
}
Phase 3: Configuration Options
Environment Variables
# Enable/disable server mode (default: enabled)
OPENCODE_SERVER_MODE=true
# Server port (default: 4096)
OPENCODE_SERVER_PORT=4096
# Server hostname (default: 127.0.0.1)
OPENCODE_SERVER_HOST=127.0.0.1
# Basic auth password (recommended for production)
OPENCODE_SERVER_PASSWORD=your-secure-password
# Basic auth username (default: opencode)
OPENCODE_SERVER_USERNAME=opencode
# Auto-restart on failure (default: true)
OPENCODE_SERVER_AUTO_RESTART=true
# Maximum connection attempts before fallback (default: 3)
OPENCODE_SERVER_MAX_RETRIES=3
Feature Flag Integration
// In configuration section
const OPENCODE_SERVER_MODE = process.env.OPENCODE_SERVER_MODE !== 'false';
const OPENCODE_SERVER_PORT = Number(process.env.OPENCODE_SERVER_PORT || 4096);
const OPENCODE_SERVER_HOST = process.env.OPENCODE_SERVER_HOST || '127.0.0.1';
const OPENCODE_SERVER_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || '';
const OPENCODE_SERVER_USERNAME = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
const OPENCODE_SERVER_AUTO_RESTART = process.env.OPENCODE_SERVER_AUTO_RESTART !== 'false';
const OPENCODE_SERVER_MAX_RETRIES = Number(process.env.OPENCODE_SERVER_MAX_RETRIES || 3);
Phase 4: Error Handling and Fallback
4.1 Automatic Fallback Strategy
class OpencodeProcessManager {
// ... existing code ...
async executeWithFallback(sessionId, workspaceDir, operation, fallbackFn) {
// Try server mode first
if (this.isReady) {
try {
return await operation();
} catch (error) {
// Check if error is recoverable
if (this.isRecoverableError(error)) {
log('Recoverable error in server mode, retrying', { error: String(error) });
await this.restart();
return await operation();
}
// Fall back to spawn mode
log('Server mode failed, falling back to spawn', { error: String(error) });
return await fallbackFn();
}
}
// Server not ready, use spawn mode
return await fallbackFn();
}
isRecoverableError(error) {
// Connection errors, timeouts, and 5xx errors are recoverable
if (error.code === 'ECONNREFUSED') return true;
if (error.code === 'ECONNRESET') return true;
if (error.code === 'ETIMEDOUT') return true;
if (error.status >= 500 && error.status < 600) return true;
return false;
}
async restart() {
log('Restarting OpenCode server...');
await this.stop();
await new Promise(resolve => setTimeout(resolve, 1000));
await this.start();
}
}
4.2 Graceful Degradation
// In sendToOpencodeWithFallback
async function sendToOpencodeWithFallback({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan }) {
const useServerMode = opencodeManager.isReady;
if (useServerMode) {
try {
return await sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId });
} catch (error) {
log('Server mode failed after retries, using spawn fallback', {
error: String(error),
sessionId: session.id
});
}
}
// Original spawn-based implementation
return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan });
}
Phase 5: Monitoring and Observability
5.1 Health Endpoint
// Add to HTTP server routes
app.get('/api/opencode/status', async (req, res) => {
const stats = opencodeManager.getStats();
res.json({
serverMode: {
enabled: OPENCODE_SERVER_MODE,
...stats
},
fallbackMode: !opencodeManager.isReady
});
});
app.post('/api/opencode/restart', async (req, res) => {
try {
await opencodeManager.restart();
res.json({ success: true, stats: opencodeManager.getStats() });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
5.2 Metrics Logging
class OpencodeProcessManager {
// ... existing code ...
logMetrics() {
const stats = this.getStats();
const memUsage = process.memoryUsage();
log('OpenCode server metrics', {
...stats,
memory: {
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB'
}
});
}
}
// Log metrics periodically
setInterval(() => {
if (opencodeManager.isReady) {
opencodeManager.logMetrics();
}
}, 60000); // Every minute
Testing Strategy
Unit Tests
// test/opencode-server.test.js
describe('OpencodeProcessManager', () => {
let manager;
beforeEach(() => {
manager = new OpencodeProcessManager();
});
afterEach(async () => {
await manager.stop();
});
test('should start server successfully', async () => {
await manager.start();
expect(manager.isReady).toBe(true);
expect(manager.process).toBeDefined();
});
test('should handle server crash and restart', async () => {
await manager.start();
manager.process.kill('SIGKILL');
await new Promise(r => setTimeout(r, 5000));
expect(manager.isReady).toBe(true);
});
test('should create and track sessions', async () => {
await manager.start();
const session = await manager.createSession('/tmp/test');
expect(session.id).toBeDefined();
expect(manager.sessionWorkspaces.has(session.id)).toBe(true);
});
test('should handle concurrent requests', async () => {
await manager.start();
const session = await manager.createSession('/tmp/test');
const requests = Array(10).fill(null).map(() =>
manager.sendMessage(session.id, '/tmp/test', { content: 'test' })
);
const results = await Promise.all(requests);
expect(results).toHaveLength(10);
});
});
Integration Tests
describe('OpenCode Server Integration', () => {
test('should fallback to spawn mode on server failure', async () => {
// Force server failure
await opencodeManager.stop();
opencodeManager.isReady = false;
// Send message - should use spawn fallback
const result = await sendToOpencodeWithFallback({
session: { id: 'test', workspaceDir: '/tmp' },
model: 'test-model',
content: 'Hello'
});
expect(result).toBeDefined();
});
test('should handle SSE event streaming', async () => {
await opencodeManager.start();
const events = [];
const unregister = opencodeManager.registerEventHandler('test-session', (event) => {
events.push(event);
});
// Trigger an event
opencodeManager.handleServerEvent({
type: 'message.part',
properties: { sessionID: 'test-session', part: { text: 'test' } }
});
expect(events).toHaveLength(1);
unregister();
});
});
Migration Path
Step 1: Add Feature Flag (Low Risk)
- Add environment variable
OPENCODE_SERVER_MODE=false(default disabled) - Add OpencodeProcessManager class enhancements
- Test in development
Step 2: Enable for Testing (Medium Risk)
- Set
OPENCODE_SERVER_MODE=truein staging - Monitor metrics and error rates
- Compare resource usage with spawn mode
Step 3: Gradual Rollout (Low Risk)
- Enable for subset of users/sessions
- Use canary deployment pattern
- Monitor for issues
Step 4: Full Rollout (Production)
- Enable for all traffic
- Keep fallback mode available
- Remove spawn mode code in future version (optional)
Performance Comparison
Expected Improvements
| Metric | Spawn Mode | Server Mode | Improvement |
|---|---|---|---|
| Memory per session | ~300MB | Shared ~400MB | 33% reduction (2 sessions) |
| Startup latency | 1-2s | 10-50ms | 95% reduction |
| CPU overhead | High (process spawn) | Low (HTTP calls) | Significant |
| Concurrent sessions | Limited by memory | Higher capacity | Better scalability |
Benchmarking Script
#!/bin/bash
# benchmark-opencode.sh
echo "Benchmarking OpenCode modes..."
# Spawn mode
OPENCODE_SERVER_MODE=false node server.js &
PID1=$!
sleep 5
ab -n 100 -c 10 http://localhost:4000/api/test
kill $PID1
# Server mode
OPENCODE_SERVER_MODE=true node server.js &
PID2=$!
sleep 5
ab -n 100 -c 10 http://localhost:4000/api/test
kill $PID2
Troubleshooting
Common Issues
- Port already in use: Change
OPENCODE_SERVER_PORTor stop conflicting service - Connection refused: Check if OpenCode CLI is properly installed
- Auth failures: Verify
OPENCODE_SERVER_PASSWORDmatches - Memory issues: Monitor with
/api/opencode/statusendpoint
Debug Mode
# Enable verbose logging
DEBUG=opencode:* OPENCODE_SERVER_MODE=true node server.js
# Check server status
curl http://localhost:4000/api/opencode/status
# Manual restart
curl -X POST http://localhost:4000/api/opencode/restart
Future Enhancements
- Connection pooling: Reuse HTTP connections for better performance
- Request batching: Combine multiple operations into single API calls
- Multi-server support: Load balance across multiple OpenCode instances
- Circuit breaker: Automatic mode switching based on error rates
- Prometheus metrics: Export metrics for monitoring systems