Keep draining standalone server stdout after reading its readiness message so later server output does not write into a closed pipe. Install the Node CLI broken-pipe handler before loading the main entrypoint. Ignore only EPIPE while preserving all unrelated stdout errors. This prevents Windows Node builds from failing when the TUI prints its exit epilogue after renderer teardown.
63 lines
2.6 KiB
TypeScript
63 lines
2.6 KiB
TypeScript
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { Deferred, Effect, Schema, Stream } from "effect"
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
import { randomBytes } from "node:crypto"
|
|
import { selfCommand } from "../util/process"
|
|
|
|
const Ready = Schema.Struct({ url: Schema.String })
|
|
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
|
|
|
|
type Options = {
|
|
readonly command?: ReadonlyArray<string>
|
|
}
|
|
|
|
function command(password: string, options: Options) {
|
|
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
|
|
if (!executable) throw new Error("Failed to resolve standalone server command")
|
|
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
|
|
cwd: process.cwd(),
|
|
// Explicit entry wins over anything inherited, so a user-exported
|
|
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
|
|
env: { OPENCODE_PASSWORD: password },
|
|
extendEnv: true,
|
|
// The server treats EOF on this pipe as the end of its ownership lease.
|
|
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
|
stdin: "pipe",
|
|
stderr: "ignore",
|
|
killSignal: "SIGTERM",
|
|
forceKillAfter: "3 seconds",
|
|
})
|
|
}
|
|
|
|
const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
|
function* (options: Options) {
|
|
const password = randomBytes(32).toString("base64url")
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
const proc = yield* spawner.spawn(command(password, options))
|
|
const readyLine = yield* Deferred.make<string, Error>()
|
|
// Keep draining stdout after readiness so later server writes cannot hit EPIPE.
|
|
yield* proc.stdout.pipe(
|
|
Stream.decodeText(),
|
|
Stream.splitLines,
|
|
Stream.runForEach((line) => Deferred.succeed(readyLine, line)),
|
|
Effect.ensuring(Deferred.fail(readyLine, new Error("Standalone server exited before reporting readiness"))),
|
|
Effect.forkScoped,
|
|
)
|
|
const output = yield* Deferred.await(readyLine)
|
|
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
|
return {
|
|
url: ready.url,
|
|
auth: { type: "basic" as const, username: "opencode", password },
|
|
pid: proc.pid,
|
|
} satisfies Endpoint & { readonly pid: number }
|
|
},
|
|
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
|
)
|
|
|
|
export function start(options: Options = {}) {
|
|
return makeEndpoint(options)
|
|
}
|
|
|
|
export * as Standalone from "./standalone"
|