Compare commits

...

13 Commits

Author SHA1 Message Date
Dax Raad 08e4d4fe0c serve: early-return for socket, skip network options resolve 2026-05-16 02:28:34 -04:00
Dax Raad 342d02aefe serve: inline listener url creation 2026-05-16 01:06:38 -04:00
Dax Raad 12c19d627d serve: expose socket listener url 2026-05-16 01:01:53 -04:00
Dax Raad 41f0f7d407 serve: drop socket listener url 2026-05-16 01:01:03 -04:00
Dax Raad 91d148d95f serve: avoid fake socket listener port 2026-05-16 01:00:02 -04:00
Dax Raad 3caba56bd9 serve: fold mdns domain into option 2026-05-16 00:51:18 -04:00
Dax Raad cb45de6fd2 serve: inline node listen options 2026-05-16 00:49:27 -04:00
Dax Raad d1f44d4cd2 serve: require explicit tcp listener type 2026-05-16 00:49:19 -04:00
Dax Raad 25400f48df serve: keep resolved listen options together 2026-05-16 00:48:00 -04:00
Dax Raad a2008478f9 serve: type listener transports explicitly 2026-05-16 00:43:56 -04:00
Dax Raad 971fe35e81 serve: keep socket listener option-driven 2026-05-16 00:41:40 -04:00
Dax Raad e3cbfa1c05 serve: simplify socket listener setup 2026-05-16 00:40:19 -04:00
Dax Raad 2719140b56 serve: add socket listener mode 2026-05-16 00:23:09 -04:00
8 changed files with 142 additions and 38 deletions
+27 -3
View File
@@ -6,15 +6,26 @@ import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = effectCmd({ export const ServeCommand = effectCmd({
command: "serve", 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", 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, instance: false,
handler: Effect.fn("Cli.serve")(function* (args) { handler: Effect.fn("Cli.serve")(function* (args) {
if (!Flag.OPENCODE_SERVER_PASSWORD) { if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") 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 opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts)) const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
@@ -22,3 +33,16 @@ export const ServeCommand = effectCmd({
yield* Effect.never 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"}`
}
+1 -1
View File
@@ -72,7 +72,7 @@ export const rpc = {
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) 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() } return { url: server.url.toString() }
}, },
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
+1 -1
View File
@@ -67,7 +67,7 @@ export const WebCommand = effectCmd({
UI.println( UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`, `${opts.mdns === true ? "opencode.local" : opts.mdns.domain}:${server.port}`,
) )
} }
+2 -2
View File
@@ -47,7 +47,7 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) 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 port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
const hostname = hostnameExplicitlySet const hostname = hostnameExplicitlySet
? args.hostname ? 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 argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors] 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 }
} }
+65 -24
View File
@@ -19,33 +19,54 @@ globalThis.AI_SDK_LOG_WARNINGS = false
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
export type Listener = { export type TcpListener = {
type: "tcp"
hostname: string hostname: string
port: number port: number
url: URL url: URL
stop: (close?: boolean) => Promise<void> stop: (close?: boolean) => Promise<void>
} }
export type SocketListener = {
type: "socket"
socket: string
url: URL
stop: (close?: boolean) => Promise<void>
}
export type Listener = TcpListener | SocketListener
type ServerApp = { type ServerApp = {
fetch(request: Request): Response | Promise<Response> fetch(request: Request): Response | Promise<Response>
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response> request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
} }
type ListenOptions = CorsOptions & { export type TcpListenOptions = CorsOptions & {
type: "tcp"
port: number port: number
hostname: string hostname: string
mdns?: boolean mdns?: true | { domain: string }
mdnsDomain?: string
} }
export type SocketListenOptions = CorsOptions & {
type: "socket"
socket: string
}
export type ListenOptions = TcpListenOptions | SocketListenOptions
type ListenerState = { type ListenerState = {
scope: Scope.Scope scope: Scope.Scope
server: Context.Service.Shape<typeof HttpServer.HttpServer> server: Context.Service.Shape<typeof HttpServer.HttpServer>
http: ListenerServer http: ListenerServer
websockets: WebSocketTracker.Interface websockets: WebSocketTracker.Interface
} }
type EffectListener = Omit<Listener, "stop"> & { type EffectTcpListener = Omit<TcpListener, "stop"> & {
stop: (close?: boolean) => Effect.Effect<void> stop: (close?: boolean) => Effect.Effect<void>
} }
type EffectSocketListener = Omit<SocketListener, "stop"> & {
stop: (close?: boolean) => Effect.Effect<void>
}
type EffectListener = EffectTcpListener | EffectSocketListener
interface ListenerServer { interface ListenerServer {
readonly closeAll: Effect.Effect<void> readonly closeAll: Effect.Effect<void>
@@ -72,26 +93,50 @@ export async function openapi() {
export let url: URL export let url: URL
export function listen(opts: TcpListenOptions): Promise<TcpListener>
export function listen(opts: SocketListenOptions): Promise<SocketListener>
export async function listen(opts: ListenOptions): Promise<Listener> { export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts)) 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 { return {
type: "tcp" as const,
hostname: listener.hostname, hostname: listener.hostname,
port: listener.port, port: listener.port,
url: listener.url, url: listener.url,
stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), stop,
} }
} }
const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")( const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(
function* (opts: ListenOptions) { function* (opts: ListenOptions) {
const state = yield* startWithPortFallback(opts) 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 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 url = listenerUrl
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
return { return {
type: "tcp" as const,
hostname: opts.hostname, hostname: opts.hostname,
port: address.port, port: address.port,
url: listenerUrl, url: listenerUrl,
@@ -100,14 +145,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
}, },
) )
function listenerLayer(opts: ListenOptions, port: number) { function listenerLayer(opts: ListenOptions) {
return HttpRouter.serve(HttpApiApp.createRoutes(opts), { return HttpRouter.serve(HttpApiApp.createRoutes(opts), {
middleware: disposeMiddleware, middleware: disposeMiddleware,
disableLogger: true, disableLogger: true,
disableListenLog: true, disableListenLog: true,
}).pipe( }).pipe(
Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), Layer.provideMerge(serverLayer(opts)),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)` // Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default // reads reflect the current `process.env`. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the // `ConfigProvider` snapshots `process.env` on first read and caches the
@@ -118,15 +163,15 @@ function listenerLayer(opts: ListenOptions, port: number) {
} }
function startWithPortFallback(opts: ListenOptions) { function startWithPortFallback(opts: ListenOptions) {
if (opts.port !== 0) return startListener(opts, opts.port) if (opts.type === "socket" || opts.port !== 0) return startListener(opts)
// Match the legacy listener port-resolution behavior: explicit `0` prefers // Match the legacy listener port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port. // 4096 first, then any free port.
return startListener(opts, 4096).pipe(Effect.catch(() => 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() 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.provide(HttpApiApp.context),
Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
Effect.map( Effect.map(
@@ -148,20 +193,13 @@ function tcpAddress(state: ListenerState) {
}) })
} }
function makeURL(hostname: string, port: number) { function setupMdns(opts: TcpListenOptions, port: number, scope: Scope.Scope) {
const result = new URL("http://localhost")
result.hostname = hostname
result.port = String(port)
return result
}
function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
return Effect.gen(function* () { return Effect.gen(function* () {
const publish = const publish =
opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
if (publish) { if (publish) {
const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) 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) yield* Scope.addFinalizer(scope, unpublish)
return unpublish return unpublish
} }
@@ -188,7 +226,7 @@ function forceClose(state: ListenerState) {
return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) 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 server = createServer()
const serverRef = { closeStarted: false, forceStop: false } const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server) const close = server.close.bind(server)
@@ -203,7 +241,10 @@ function serverLayer(opts: { port: number; hostname: string }) {
}) as typeof server.close }) as typeof server.close
return Layer.mergeAll( 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)( Layer.succeed(ListenerServerService)(
ListenerServerService.of({ ListenerServerService.of({
closeAll: Effect.sync(() => { closeAll: Effect.sync(() => {
@@ -85,7 +85,9 @@ describe("HttpApi CORS", () => {
it.live("uses custom CORS origins passed to the server", () => it.live("uses custom CORS origins passed to the server", () =>
Effect.gen(function* () { Effect.gen(function* () {
const listener = yield* Effect.acquireRelease( 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)), (listener) => Effect.promise(() => listener.stop(true)),
) )
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import http from "node:http"
import net from "node:net" import net from "node:net"
import path from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
@@ -35,7 +37,7 @@ async function startListener() {
Flag.OPENCODE_SERVER_USERNAME = auth.username Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password process.env.OPENCODE_SERVER_PASSWORD = auth.password
process.env.OPENCODE_SERVER_USERNAME = auth.username 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() { async function startNoAuthListener() {
@@ -43,7 +45,7 @@ async function startNoAuthListener() {
Flag.OPENCODE_SERVER_USERNAME = auth.username Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username 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() { function authorization() {
@@ -134,7 +136,7 @@ async function expectSocketRejected(url: URL, init?: { headers?: Record<string,
) )
} }
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) { function stop(listener: { stop(close?: boolean): Promise<void> }, label: string) {
return withTimeout(listener.stop(true), 10_000, label) return withTimeout(listener.stop(true), 10_000, label)
} }
@@ -285,6 +287,26 @@ describe("HttpApi Server.listen", () => {
).rejects.toThrow() ).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 () => { test("default in-process handler does not emit Effect HTTP response logs", async () => {
let output = "" let output = ""
// oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr. // 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)) 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()
})
}
@@ -45,7 +45,7 @@ describe("HttpApi Server.listen mDNS", () => {
test("skips publish for loopback hostnames", async () => { test("skips publish for loopback hostnames", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" 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 { try {
expect(events.filter((e) => e.kind === "publish")).toEqual([]) expect(events.filter((e) => e.kind === "publish")).toEqual([])
} finally { } finally {
@@ -57,7 +57,7 @@ describe("HttpApi Server.listen mDNS", () => {
test("publishes for non-loopback hostnames and unpublishes on stop", async () => { test("publishes for non-loopback hostnames and unpublishes on stop", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" 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 { try {
const published = events.filter((e) => e.kind === "publish") const published = events.filter((e) => e.kind === "publish")
expect(published.length).toBe(1) 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 () => { test("scope finalizer unpublishes even if stop() is not called for force-close", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" 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) expect(events.filter((e) => e.kind === "publish").length).toBe(1)
// Plain (graceful) stop without close=true should still unpublish. // Plain (graceful) stop without close=true should still unpublish.
await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener") await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener")