Current section

Files

Jump to
opencode_sdk lib opencode server.ex
Raw

lib/opencode/server.ex

defmodule OpenCode.Server do
@moduledoc """
OpenCode server process management.
Mirrors the JS SDK's `createOpencodeServer()` behavior.
"""
defstruct [:port, :url]
@type t :: %__MODULE__{
port: port() | nil,
url: String.t() | nil
}
@spec create(keyword()) :: {:ok, t()} | {:error, term()}
def create(opts \\ []) do
host = Keyword.get(opts, :hostname, "127.0.0.1")
num = Keyword.get(opts, :port, 4096)
time = Keyword.get(opts, :timeout, 5000)
config = Keyword.get(opts, :config, %{})
args = build_args(host, num, config)
env = build_env(config)
port =
Port.open({:spawn_executable, find_executable("opencode")}, [
:binary,
:exit_status,
:use_stdio,
:stderr_to_stdout,
args: args,
env: env
])
case wait_for_ready(port, time, "", host, num) do
{:ok, url} ->
{:ok, %__MODULE__{port: port, url: url}}
{:error, reason} ->
if Port.info(port) != nil, do: Port.close(port)
{:error, reason}
end
end
@spec close(t()) :: :ok
def close(%__MODULE__{port: port}) when is_port(port) do
Port.close(port)
:ok
end
def close(_), do: :ok
defp build_args(host, num, config) do
args = ["serve", "--hostname=#{host}", "--port=#{num}"]
level =
case config do
%{logLevel: val} -> val
%{"logLevel" => val} -> val
_ -> nil
end
case level do
nil -> args
val -> args ++ ["--log-level=#{val}"]
end
end
defp build_env(config) do
base =
System.get_env()
|> Enum.map(fn {key, val} -> {String.to_charlist(key), String.to_charlist(val)} end)
|> Enum.reject(fn {key, _} -> key == ~c"OPENCODE_CONFIG_CONTENT" end)
[{~c"OPENCODE_CONFIG_CONTENT", String.to_charlist(Jason.encode!(config))} | base]
end
defp find_executable(name) do
case System.find_executable(name) do
nil -> String.to_charlist(name)
path -> String.to_charlist(path)
end
end
defp wait_for_ready(port, time, buf, host, num) do
deadline = System.monotonic_time(:millisecond) + time
base_url = "http://#{host}:#{num}"
do_wait(port, deadline, buf, base_url)
end
defp do_wait(port, deadline, buf, base_url) do
left = deadline - System.monotonic_time(:millisecond)
case left <= 0 do
true ->
case health_ok?(base_url) do
true -> {:ok, base_url}
false -> {:error, {:timeout, "Server did not start within timeout"}}
end
false ->
receive do
{^port, {:data, data}} ->
next = buf <> data
case parse_url(next) do
{:ok, url} -> {:ok, url}
:not_found -> do_wait(port, deadline, next, base_url)
end
{^port, {:exit_status, status}} ->
{:error, {:exit_status, status, buf}}
after
min(left, 100) ->
do_wait(port, deadline, buf, base_url)
end
end
end
defp parse_url(out) do
out
|> String.split("\n")
|> Enum.find_value(:not_found, fn line ->
case String.contains?(line, "opencode server listening") do
true ->
case Regex.run(~r/on\s+(https?:\/\/[^\s]+)/, line) do
[_, url] -> {:ok, url}
_ -> :not_found
end
false ->
:not_found
end
end)
end
defp health_ok?(url) do
case Req.get(url <> "/global/health",
connect_options: [timeout: 500],
receive_timeout: 500
) do
{:ok, %{status: status}} when status in 200..299 -> true
_ -> false
end
end
end