///|
// Copied from the OpenCode TypeScript SDK defaults and option shape:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L5-L30
let server_listening_prefix = "opencode server listening"
///|
let startup_poll_interval_ms = 10
///|
/// Options for starting an OpenCode server.
///
/// When `port` is omitted, OpenCode selects an available port.
pub struct ServerOptions {
hostname : String
port : Int
timeout_ms : Int
config : Json
} derive(Debug)
///|
/// Construct server options.
pub fn ServerOptions::ServerOptions(
hostname? : String = "127.0.0.1",
port? : Int = 0,
timeout_ms? : Int = 5000,
config? : Json = Json::empty_object(),
) -> ServerOptions {
// OpenCode treats port 0 as automatic port selection:
// https://github.com/anomalyco/opencode/blob/7565e03536d19e850f9996c407f9bf5e932b5f7a/packages/opencode/src/server/server.ts#L117-L122
{ hostname, port, timeout_ms, config }
}
///|
/// Failures that can occur while starting or stopping an OpenCode server.
pub(all) suberror ServerError {
/// The server process or its log files could not be created.
StartFailed(String)
/// The server did not announce readiness before the configured timeout.
StartTimeout(timeout_ms~ : Int, output~ : String)
/// The server emitted the readiness prefix without a valid HTTP URL.
MalformedOutput(line~ : String)
/// The server exited before announcing readiness.
ServerExited(code~ : Int, output~ : String)
/// A started server could not be stopped or cleaned up.
CleanupFailed(String)
} derive(Debug)
///|
/// A running OpenCode server owned by the task group passed at creation.
pub struct Server {
url : String
process : @process.Process
log_dir : String
mut closed : Bool
}
///|
/// Return the HTTP base URL announced by the OpenCode server.
pub fn Server::url(self : Server) -> String {
self.url
}
///|
fn parse_server_url(output : String) -> String? raise ServerError {
// Copied from the OpenCode TypeScript SDK readiness parser:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L49-L71
for line_view in output.split("\n") {
let line = line_view.trim().to_owned()
if line.has_prefix(server_listening_prefix) {
match line.find(" on ") {
Some(index) => {
let url = line[index + 4:].trim().to_owned()
guard (url.has_prefix("http://") || url.has_prefix("https://")) &&
url.find(" ") is None else {
raise MalformedOutput(line~)
}
return Some(url)
}
None => raise MalformedOutput(line~)
}
}
}
None
}
///|
async fn read_log(path : String) -> String raise ServerError {
@fs.read_file(path).text() catch {
err =>
raise StartFailed(
"failed to read server output: \{@debug.to_string(err)}",
)
}
}
///|
async fn combined_output(
stdout_path : String,
stderr_path : String,
) -> String raise ServerError {
let stdout = read_log(stdout_path)
let stderr = read_log(stderr_path)
match (stdout.trim(), stderr.trim()) {
("", "") => ""
(_, "") => stdout
("", _) => stderr
_ => stdout + "\n" + stderr
}
}
///|
async fn remove_log_dir(log_dir : String) -> Unit raise ServerError {
@fs.rmdir(log_dir, recursive=true) catch {
err =>
raise CleanupFailed(
"failed to remove server logs: \{@debug.to_string(err)}",
)
}
}
///|
async fn stop_process(process : @process.Process) -> Unit raise ServerError {
// Adapted from the OpenCode TypeScript SDK process-stop behavior:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/process.ts#L3-L12
let process_result = process.try_wait() catch {
err if @async.is_cancellation_error(err) => None
err =>
raise CleanupFailed(
"failed to inspect server process: \{@debug.to_string(err)}",
)
}
if process_result is None {
process.cancel()
}
(process.wait() |> ignore) catch {
err if @async.is_cancellation_error(err) => ()
err =>
raise CleanupFailed(
"failed to wait for server process: \{@debug.to_string(err)}",
)
}
}
///|
async fn cleanup_start_failure(
process : @process.Process,
log_dir : String,
) -> Unit raise ServerError {
stop_process(process)
remove_log_dir(log_dir)
}
///|
async fn wait_for_server(
process : @process.Process,
stdout_path : String,
stderr_path : String,
timeout_ms : Int,
) -> String raise ServerError {
// Adapted from the OpenCode TypeScript SDK timeout, output, and early-exit
// handling:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L43-L91
let mut elapsed_ms = 0
for ;; {
let stdout = read_log(stdout_path)
match parse_server_url(stdout) {
Some(url) => return url
None => ()
}
match
(process.try_wait() catch {
err =>
raise StartFailed(
"failed to inspect server process: \{@debug.to_string(err)}",
)
}) {
Some(code) => {
let output = combined_output(stdout_path, stderr_path)
raise ServerExited(code~, output~)
}
None => ()
}
if elapsed_ms >= timeout_ms {
let output = combined_output(stdout_path, stderr_path)
raise StartTimeout(timeout_ms~, output~)
}
@async.sleep(startup_poll_interval_ms) catch {
err =>
raise StartFailed(
"startup wait was interrupted: \{@debug.to_string(err)}",
)
}
elapsed_ms = elapsed_ms + startup_poll_interval_ms
}
}
///|
async fn[X] create_opencode_server_with_command(
group : @async.TaskGroup[X],
options : ServerOptions,
command : String,
extra_env : Map[String, String],
) -> Server raise ServerError {
let log_dir = @fs.tmpdir(prefix="opencode-sdk-") catch {
err =>
raise StartFailed(
"failed to create server log directory: \{@debug.to_string(err)}",
)
}
let stdout_path = "\{log_dir}/stdout.log"
let stderr_path = "\{log_dir}/stderr.log"
let stdout = @process.redirect_to_file(
stdout_path,
create_mode=CreateOrTruncate,
) catch {
err => {
remove_log_dir(log_dir)
raise StartFailed(
"failed to redirect server stdout: \{@debug.to_string(err)}",
)
}
}
let stderr = @process.redirect_to_file(
stderr_path,
create_mode=CreateOrTruncate,
) catch {
err => {
remove_log_dir(log_dir)
raise StartFailed(
"failed to redirect server stderr: \{@debug.to_string(err)}",
)
}
}
let env : Map[String, String] = Map([])
for key, value in extra_env {
env[key] = value
}
// Copied from the OpenCode TypeScript SDK process environment:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L35-L40
env["OPENCODE_CONFIG_CONTENT"] = options.config.stringify()
let process = @process.spawn(
group,
command,
server_args(options),
extra_env=env,
stdout~,
stderr~,
cancel_handler=@process.hard_cancel(),
) catch {
err => {
remove_log_dir(log_dir)
raise StartFailed(
"failed to start OpenCode server: \{@debug.to_string(err)}",
)
}
}
let url = wait_for_server(
process,
stdout_path,
stderr_path,
options.timeout_ms,
) catch {
err => {
cleanup_start_failure(process, log_dir)
raise err
}
}
{ url, process, log_dir, closed: false }
}
///|
/// Start an OpenCode server in `group`.
///
/// The task group must outlive the returned server. The process is started as
/// `opencode serve`, receives `OPENCODE_CONFIG_CONTENT`, and is hard-cancelled
/// when startup fails or `close` is called.
pub async fn[X] create_opencode_server(
group : @async.TaskGroup[X],
options? : ServerOptions = ServerOptions::ServerOptions(),
command? : String = "opencode",
extra_env? : Map[String, String] = Map([]),
) -> Server raise ServerError {
// Copied from the OpenCode TypeScript SDK executable selection:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L32-L40
create_opencode_server_with_command(group, options, command, extra_env)
}
///|
/// Stop the server, wait for its process, and remove its temporary logs.
///
/// Calling `close` again after a successful close has no effect.
pub async fn Server::close(self : Server) -> Unit raise ServerError {
// Adapted from the OpenCode TypeScript SDK close and process-stop behavior:
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/server.ts#L93-L99
// https://github.com/anomalyco/opencode/blob/66495a2a22cd0a57efcc4f721e65532f0987b4e8/packages/sdk/js/src/process.ts#L3-L12
guard !self.closed else { return }
stop_process(self.process)
remove_log_dir(self.log_dir)
self.closed = true
}