diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 76f6276af5da..b4c7c0a83363 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -6,15 +6,26 @@ import { Flag } from "@opencode-ai/core/flag/flag" export const ServeCommand = effectCmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + withNetworkOptions(yargs) + .option("socket", { + type: "string", + describe: "Unix socket path or Windows named pipe name/path to listen on", + }), describe: "starts a headless opencode server", - // Server loads instances per-request via x-opencode-directory header — no - // need for an ambient project InstanceContext at startup. instance: false, handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } + if (args.socket) { + const server = yield* Effect.promise(() => + Server.listen({ type: "socket", socket: resolveSocketPath(args.socket) }), + ) + console.log(`opencode server listening on socket ${server.socket}`) + yield* Effect.never + } + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) @@ -22,3 +33,16 @@ export const ServeCommand = effectCmd({ yield* Effect.never }), }) + +function resolveSocketPath(input: string) { + if (process.platform !== "win32") return input + const lower = input.toLowerCase() + if (lower.startsWith("\\\\.\\pipe\\") || lower.startsWith("\\\\?\\pipe\\")) return input + const name = input + .replace(/^[a-zA-Z]:/, (drive) => drive.slice(0, 1)) + .replace(/[\\/:]+/g, "-") + .replace(/^-+|-+$/g, "") + return `\\\\.\\pipe\\${name || "opencode"}` +} + + diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 31ead18cf84f..b778c8f7cdf8 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -72,7 +72,7 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = await Server.listen(input) + server = await Server.listen({ type: "tcp", ...input, mdns: input.mdns ? true : undefined }) return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 384290c6ac39..13666cffc890 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -67,7 +67,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, + `${opts.mdns === true ? "opencode.local" : opts.mdns.domain}:${server.port}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 41f8184ef5d7..4441acf7ff8d 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -47,7 +47,7 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) - const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : config?.server?.mdnsDomain const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet ? args.hostname @@ -58,5 +58,5 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, mdnsDomain, cors } + return { type: "tcp" as const, hostname, port, mdns: mdns ? (mdnsDomain ? { domain: mdnsDomain } : true) : undefined, cors } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9a448b78d626..3aabdb54d639 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -19,33 +19,54 @@ globalThis.AI_SDK_LOG_WARNINGS = false const log = Log.create({ service: "server" }) -export type Listener = { +export type TcpListener = { + type: "tcp" hostname: string port: number url: URL stop: (close?: boolean) => Promise } +export type SocketListener = { + type: "socket" + socket: string + url: URL + stop: (close?: boolean) => Promise +} + +export type Listener = TcpListener | SocketListener + type ServerApp = { fetch(request: Request): Response | Promise request(input: string | URL | Request, init?: RequestInit): Response | Promise } -type ListenOptions = CorsOptions & { +export type TcpListenOptions = CorsOptions & { + type: "tcp" port: number hostname: string - mdns?: boolean - mdnsDomain?: string + mdns?: true | { domain: string } } + +export type SocketListenOptions = CorsOptions & { + type: "socket" + socket: string +} + +export type ListenOptions = TcpListenOptions | SocketListenOptions type ListenerState = { scope: Scope.Scope server: Context.Service.Shape http: ListenerServer websockets: WebSocketTracker.Interface } -type EffectListener = Omit & { +type EffectTcpListener = Omit & { stop: (close?: boolean) => Effect.Effect } +type EffectSocketListener = Omit & { + stop: (close?: boolean) => Effect.Effect +} +type EffectListener = EffectTcpListener | EffectSocketListener interface ListenerServer { readonly closeAll: Effect.Effect @@ -72,26 +93,50 @@ export async function openapi() { export let url: URL +export function listen(opts: TcpListenOptions): Promise +export function listen(opts: SocketListenOptions): Promise export async function listen(opts: ListenOptions): Promise { const listener = await Effect.runPromise(listenEffect(opts)) + const stop = (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined) + if (listener.type === "socket") { + return { + type: "socket" as const, + socket: listener.socket, + url: listener.url, + stop, + } + } return { + type: "tcp" as const, hostname: listener.hostname, port: listener.port, url: listener.url, - stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), + stop, } } const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) + if (opts.type === "socket") { + return { + type: "socket" as const, + socket: opts.socket, + url: new URL(`socket:${encodeURIComponent(opts.socket)}`), + stop: yield* makeStop(state, Effect.void), + } + } + const address = yield* tcpAddress(state) - const listenerUrl = makeURL(opts.hostname, address.port) + const listenerUrl = new URL("http://localhost") + listenerUrl.hostname = opts.hostname + listenerUrl.port = String(address.port) url = listenerUrl const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) return { + type: "tcp" as const, hostname: opts.hostname, port: address.port, url: listenerUrl, @@ -100,14 +145,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect startListener(opts, 0))) + return startListener({ ...opts, port: 4096 }).pipe(Effect.catch(() => startListener(opts))) } -function startListener(opts: ListenOptions, port: number) { +function startListener(opts: ListenOptions) { const scope = Scope.makeUnsafe() - return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe( + return Layer.buildWithMemoMap(listenerLayer(opts), Layer.makeMemoMapUnsafe(), scope).pipe( Effect.provide(HttpApiApp.context), Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), Effect.map( @@ -148,20 +193,13 @@ function tcpAddress(state: ListenerState) { }) } -function makeURL(hostname: string, port: number) { - const result = new URL("http://localhost") - result.hostname = hostname - result.port = String(port) - return result -} - -function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) { +function setupMdns(opts: TcpListenOptions, port: number, scope: Scope.Scope) { return Effect.gen(function* () { const publish = opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (publish) { const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) - yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) + yield* Effect.sync(() => MDNS.publish(port, opts.mdns === true ? undefined : opts.mdns.domain)) yield* Scope.addFinalizer(scope, unpublish) return unpublish } @@ -188,7 +226,7 @@ function forceClose(state: ListenerState) { return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) } -function serverLayer(opts: { port: number; hostname: string }) { +function serverLayer(opts: ListenOptions) { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) @@ -203,7 +241,10 @@ function serverLayer(opts: { port: number; hostname: string }) { }) as typeof server.close return Layer.mergeAll( - NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + NodeHttpServer.layer(() => server, { + ...(opts.type === "socket" ? { path: opts.socket } : { port: opts.port, host: opts.hostname }), + gracefulShutdownTimeout: "1 second", + }), Layer.succeed(ListenerServerService)( ListenerServerService.of({ closeAll: Effect.sync(() => { diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 4e9680c7ce4b..266adb153abc 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -85,7 +85,9 @@ describe("HttpApi CORS", () => { it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease( - Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), + Effect.promise(() => + Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }), + ), (listener) => Effect.promise(() => listener.stop(true)), ) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index f155521384af..f413ea216c4a 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" +import http from "node:http" import net from "node:net" +import path from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" @@ -35,7 +37,7 @@ async function startListener() { Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password process.env.OPENCODE_SERVER_USERNAME = auth.username - return Server.listen({ hostname: "127.0.0.1", port: 0 }) + return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 }) } async function startNoAuthListener() { @@ -43,7 +45,7 @@ async function startNoAuthListener() { Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD process.env.OPENCODE_SERVER_USERNAME = auth.username - return Server.listen({ hostname: "127.0.0.1", port: 0 }) + return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 }) } function authorization() { @@ -134,7 +136,7 @@ async function expectSocketRejected(url: URL, init?: { headers?: Record>, label: string) { +function stop(listener: { stop(close?: boolean): Promise }, label: string) { return withTimeout(listener.stop(true), 10_000, label) } @@ -285,6 +287,26 @@ describe("HttpApi Server.listen", () => { ).rejects.toThrow() }) + test("listens on a socket path", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const socket = + process.platform === "win32" + ? `\\\\.\\pipe\\opencode-test-${process.pid}-${Date.now()}` + : path.join(tmp.path, "opencode.sock") + const listener = await Server.listen({ type: "socket", socket }) + try { + expect(listener.type).toBe("socket") + expect(listener.socket).toBe(socket) + expect(listener.url.href).toBe(`socket:${encodeURIComponent(socket)}`) + + const response = await requestSocketRoot(socket) + expect(response.statusCode).toBe(200) + expect(response.body).toContain("OpenCode") + } finally { + await stop(listener, "timed out cleaning up socket listener") + } + }) + test("default in-process handler does not emit Effect HTTP response logs", async () => { let output = "" // oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr. @@ -433,3 +455,18 @@ function occupyPort(port: number) { server.listen(port, "127.0.0.1", () => resolve(server)) }) } + +function requestSocketRoot(socket: string) { + return new Promise<{ statusCode?: number; body: string }>((resolve, reject) => { + const request = http.request( + { socketPath: socket, path: "/", method: "GET" }, + (response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk: Buffer) => chunks.push(chunk)) + response.on("end", () => resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString() })) + }, + ) + request.on("error", reject) + request.end() + }) +} diff --git a/packages/opencode/test/server/httpapi-mdns.test.ts b/packages/opencode/test/server/httpapi-mdns.test.ts index 6b88a1f10bf3..52e24fc8ae1c 100644 --- a/packages/opencode/test/server/httpapi-mdns.test.ts +++ b/packages/opencode/test/server/httpapi-mdns.test.ts @@ -45,7 +45,7 @@ describe("HttpApi Server.listen mDNS", () => { test("skips publish for loopback hostnames", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, mdns: true }) try { expect(events.filter((e) => e.kind === "publish")).toEqual([]) } finally { @@ -57,7 +57,7 @@ describe("HttpApi Server.listen mDNS", () => { test("publishes for non-loopback hostnames and unpublishes on stop", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true }) try { const published = events.filter((e) => e.kind === "publish") expect(published.length).toBe(1) @@ -73,7 +73,7 @@ describe("HttpApi Server.listen mDNS", () => { test("scope finalizer unpublishes even if stop() is not called for force-close", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true }) expect(events.filter((e) => e.kind === "publish").length).toBe(1) // Plain (graceful) stop without close=true should still unpublish. await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener")