Files
opencode/packages/desktop-electron/src/main/server.ts
T
LukeParkerDev 12fa782137 refactor: treat WSL servers as additive alongside windows local
Local Server is always Windows-native now; WSL lives as a separate list
of one-or-more distro-bound sidecars spawned alongside it. Manage Servers
shows an Add WSL button on Windows, each WSL server appears as its own
row with remove + retry, and the wizard runs scoped to a new distro.
2026-04-19 13:19:09 +10:00

235 lines
6.4 KiB
TypeScript

import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export async function allocatePort() {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
const ready = async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
}
await ready()
})()
return { listener, health: { wait } }
}
export type WslSidecar = {
listener: { stop: () => void }
url: string
username: string | null
password: string
}
export async function spawnWslSidecar(
distro: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
const script = [
"set -euo pipefail",
"export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
"export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
})
child.stdin.end(script)
let settled = false
const recentOutput: string[] = []
const emit = (line: WslCommandLine) => {
if (settled || !line.text.trim()) return
recentOutput.push(`[${line.stream}] ${line.text}`)
if (recentOutput.length > 12) recentOutput.shift()
opts.onLine?.(line)
}
forwardLines(child.stdout, "stdout", emit)
forwardLines(child.stderr, "stderr", emit)
const exit = new Promise<never>((_, reject) => {
child.once("error", reject)
child.once("exit", (code, signal) => {
reject(new Error(startupFailure(code, signal, recentOutput)))
})
})
const url = `http://127.0.0.1:${port}`
const healthPromise = (async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
})()
const timeoutMs = opts.healthTimeoutMs ?? 30_000
const timeout = new Promise<never>((_, reject) => {
const id = setTimeout(
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
timeoutMs,
)
void healthPromise.finally(() => clearTimeout(id))
})
try {
await Promise.race([healthPromise, exit, timeout])
} catch (error) {
child.kill()
throw error
} finally {
settled = true
}
return {
listener: {
stop() {
child.kill()
},
},
url,
username,
password,
}
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: app.getPath("userData"),
}
Object.assign(process.env, env)
}
function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],
onLine: (line: WslCommandLine) => void,
) {
let pending = ""
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
pending += chunk
const lines = pending.split(/\r?\n/g)
pending = lines.pop() ?? ""
for (const line of lines) onLine({ stream: source, text: line })
})
stream.on("end", () => {
if (pending) onLine({ stream: source, text: pending })
})
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {
healthUrl = new URL("/global/health", url)
} catch {
return false
}
const headers = new Headers()
if (password) {
const auth = Buffer.from(`opencode:${password}`).toString("base64")
headers.set("authorization", `Basic ${auth}`)
}
try {
const res = await fetch(healthUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}