openpencil/apps/web/server/opencode/v2/server.ts

129 lines
3.1 KiB
TypeScript
Raw Normal View History

import { spawn } from 'node:child_process';
import { type Config } from './gen/types.gen.js';
export type ServerOptions = {
hostname?: string;
port?: number;
signal?: AbortSignal;
timeout?: number;
config?: Config;
/** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */
binaryPath?: string;
};
export type TuiOptions = {
project?: string;
model?: string;
session?: string;
agent?: string;
signal?: AbortSignal;
config?: Config;
};
export async function createOpencodeServer(options?: ServerOptions) {
options = Object.assign(
{
hostname: '127.0.0.1',
port: 4096,
timeout: 5000,
},
options ?? {},
);
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`];
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`);
const cmd = options.binaryPath ?? 'opencode';
const proc = spawn(cmd, args, {
signal: options.signal,
shell: process.platform === 'win32',
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
});
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`));
}, options.timeout);
let output = '';
proc.stdout?.on('data', (chunk) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
throw new Error(`Failed to parse server url from output: ${line}`);
}
clearTimeout(id);
resolve(match[1]!);
return;
}
}
});
proc.stderr?.on('data', (chunk) => {
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
let msg = `Server exited with code ${code}`;
if (output.trim()) {
msg += `\nServer output: ${output}`;
}
reject(new Error(msg));
});
proc.on('error', (error) => {
clearTimeout(id);
reject(error);
});
if (options.signal) {
options.signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new Error('Aborted'));
});
}
});
return {
url,
close() {
proc.kill();
},
};
}
export function createOpencodeTui(options?: TuiOptions) {
const args = [];
if (options?.project) {
args.push(`--project=${options.project}`);
}
if (options?.model) {
args.push(`--model=${options.model}`);
}
if (options?.session) {
args.push(`--session=${options.session}`);
}
if (options?.agent) {
args.push(`--agent=${options.agent}`);
}
const proc = spawn(`opencode`, args, {
signal: options?.signal,
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
},
});
return {
close() {
proc.kill();
},
};
}