194 lines
7.9 KiB
TypeScript
194 lines
7.9 KiB
TypeScript
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
|
import { spawn } from "node:child_process"
|
|
import { homedir } from "node:os"
|
|
import { join } from "node:path"
|
|
|
|
// Find, start, and stop the local opencode background service.
|
|
//
|
|
// The service daemon advertises itself through a registration file in the
|
|
// user's state directory: url, pid, version, and the private password, with
|
|
// 0600 permissions. That file is the complete discovery contract — reading it
|
|
// is all a client needs to connect. The daemon's own configuration (port,
|
|
// persisted password) is CLI-owned and never read here.
|
|
|
|
export type Transport = {
|
|
readonly url: string
|
|
readonly headers?: RequestInit["headers"]
|
|
}
|
|
|
|
export type Options = {
|
|
// Absolute path to the service registration file. Defaults to
|
|
// opencode/service.json in the XDG state directory.
|
|
readonly file?: string
|
|
// When set, discovery only returns a server reporting this exact version,
|
|
// and start() replaces a healthy server whose version differs.
|
|
readonly version?: string
|
|
// Argv used to spawn the service. Defaults to ["opencode", "serve",
|
|
// "--service"] resolved from PATH.
|
|
readonly command?: ReadonlyArray<string>
|
|
}
|
|
|
|
// Read-only lookup: registration file plus health check and version gate.
|
|
// Never spawns; escalation to start() is the caller's policy.
|
|
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
|
return (yield* discoverLocal(options))?.transport
|
|
})
|
|
|
|
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
|
const info = yield* read(options.file)
|
|
if (info === undefined) return undefined
|
|
if (options.version !== undefined && info.version !== options.version) return undefined
|
|
return yield* probe(info, options.version)
|
|
})
|
|
|
|
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
|
// version-mismatched one, and otherwise spawns the service command detached.
|
|
export const start = Effect.fn("service.start")(function* (options: Options = {}) {
|
|
const compatible = yield* discover(options)
|
|
if (compatible !== undefined) return compatible
|
|
const mismatched = yield* find(options)
|
|
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
|
|
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
|
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
|
const child = yield* Effect.try({
|
|
try: () => {
|
|
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
|
child.unref()
|
|
return child
|
|
},
|
|
catch: (cause) => new Error("Failed to start server", { cause }),
|
|
})
|
|
|
|
return yield* discoverLocal(options).pipe(
|
|
Effect.flatMap((found) =>
|
|
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
|
|
),
|
|
Effect.retry(poll),
|
|
Effect.tap((found) =>
|
|
found.info.pid === child.pid
|
|
? Effect.void
|
|
: Effect.sync(() => {
|
|
child.kill("SIGTERM")
|
|
}),
|
|
),
|
|
Effect.map((found) => found.transport),
|
|
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
|
Effect.mapError(() => new Error("Failed to start server")),
|
|
)
|
|
})
|
|
|
|
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const existing = yield* find(options)
|
|
if (existing !== undefined) yield* kill(existing.info, options)
|
|
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
|
})
|
|
|
|
function fallback() {
|
|
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
|
return join(state, "opencode", "service.json")
|
|
}
|
|
|
|
function auth(password: string): RequestInit["headers"] {
|
|
return { authorization: "Basic " + btoa("opencode:" + password) }
|
|
}
|
|
|
|
export const Info = Schema.Struct({
|
|
id: Schema.optional(Schema.String),
|
|
version: Schema.optional(Schema.String),
|
|
url: Schema.String,
|
|
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
password: Schema.optional(Schema.String),
|
|
})
|
|
export type Info = typeof Info.Type
|
|
|
|
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
|
const decodeHealth = Schema.decodeUnknownOption(
|
|
Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }),
|
|
)
|
|
const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) }))
|
|
|
|
// A missing or corrupt file means no valid info; callers treat both
|
|
// the same (the registering server self-evicts, clients rediscover).
|
|
const read = Effect.fnUntraced(function* (file?: string) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const text = yield* fs.readFileString(file ?? fallback()).pipe(Effect.option)
|
|
if (Option.isNone(text)) return undefined
|
|
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
|
})
|
|
|
|
type LocalService = {
|
|
readonly info: Info
|
|
readonly transport: Transport
|
|
}
|
|
|
|
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
|
const headers = info.password === undefined ? undefined : auth(info.password)
|
|
const response = yield* Effect.tryPromise(() =>
|
|
fetch(new URL("/api/health", info.url), {
|
|
headers,
|
|
signal: AbortSignal.timeout(2_000),
|
|
}),
|
|
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
|
if (response === undefined || !response.ok) return undefined
|
|
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
|
const health = decodeHealth(body)
|
|
if (Option.isSome(health)) {
|
|
if (health.value.pid !== info.pid) return undefined
|
|
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
|
if (version !== undefined && health.value.version !== version) return undefined
|
|
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
|
}
|
|
if (
|
|
!allowLegacy ||
|
|
Option.isNone(decodeLegacyHealth(body)) ||
|
|
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
|
)
|
|
return undefined
|
|
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
|
})
|
|
|
|
// Health-checked lookup without the version gate: lifecycle operations must be
|
|
// able to see (and replace or stop) a server from a different version.
|
|
const find = Effect.fnUntraced(function* (options: Options) {
|
|
const info = yield* read(options.file)
|
|
if (info === undefined) return undefined
|
|
return yield* probe(info, undefined, true)
|
|
})
|
|
|
|
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
|
|
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))
|
|
|
|
const signal = (pid: number, name: NodeJS.Signals) =>
|
|
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
|
|
|
|
const stopped = Effect.fnUntraced(function* (pid: number) {
|
|
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
|
Effect.orElseSucceed(() => false),
|
|
)
|
|
if (!running) return true
|
|
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
|
})
|
|
|
|
function same(left: Info, right: Info) {
|
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
|
}
|
|
|
|
const kill = Effect.fnUntraced(function* (info: Info, options: Options) {
|
|
// A stale registration may point at a PID that has since been reused by
|
|
// another process. Only signal the PID after authenticating the server.
|
|
const current = yield* find(options)
|
|
if (current === undefined || !same(current.info, info)) return
|
|
|
|
yield* signal(info.pid, "SIGTERM")
|
|
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
|
if (Option.isSome(done)) return
|
|
|
|
const latest = yield* find(options)
|
|
if (latest === undefined || !same(latest.info, info)) return
|
|
yield* signal(info.pid, "SIGKILL")
|
|
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
|
})
|
|
|
|
export * as Service from "./service.js"
|