From c87f6d12fab76a97ec5bbdd885d0fd6ec86f90e8 Mon Sep 17 00:00:00 2001 From: Prashanth Pai <411294+prashanthpai@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:14:18 +0530 Subject: [PATCH] feat: support unix domain socket Signed-off-by: Prashanth Pai <411294+prashanthpai@users.noreply.github.com> --- packages/opencode/src/cli/cmd/serve.ts | 6 ++- packages/opencode/src/cli/cmd/tui/app.tsx | 12 +++++ packages/opencode/src/cli/cmd/tui/attach.ts | 15 +++++- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 7 ++- .../opencode/src/cli/cmd/tui/parse-url.ts | 28 +++++++++++ packages/opencode/src/cli/network.ts | 8 ++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/server.ts | 36 +++++++++----- packages/sdk/js/src/client.ts | 49 +++++++++++++++++-- packages/sdk/js/src/v2/client.ts | 47 ++++++++++++++++-- 10 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/parse-url.ts diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index bee2c8f711f..15182e08972 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -13,7 +13,11 @@ export const ServeCommand = cmd({ } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + if (opts.unix) { + console.log(`opencode server listening on unix socket: ${opts.unix}`) + } else { + console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + } await new Promise(() => {}) await server.stop() }, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf..97929ea4dc1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -103,6 +103,7 @@ export function tui(input: { url: string args: Args directory?: string + unix?: string fetch?: typeof fetch events?: EventSource onExit?: () => Promise @@ -129,6 +130,7 @@ export function tui(input: { @@ -491,7 +493,17 @@ function App() { { title: "Open WebUI", value: "webui.open", + hidden: sdk.url.startsWith("unix://"), onSelect: () => { + if (sdk.url.startsWith("unix://")) { + toast.show({ + variant: "warning", + message: "Cannot open WebUI for Unix socket connections", + duration: 3000, + }) + dialog.clear() + return + } open(sdk.url).catch(() => {}) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 3f9285f631c..7c524b54690 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -1,5 +1,6 @@ import { cmd } from "../cmd" import { tui } from "./app" +import { parseAttachUrl } from "./parse-url" export const AttachCommand = cmd({ command: "attach ", @@ -8,7 +9,7 @@ export const AttachCommand = cmd({ yargs .positional("url", { type: "string", - describe: "http://localhost:4096", + describe: "Server URL: http://localhost:4096 or unix:///path/to/socket", demandOption: true, }) .option("dir", { @@ -22,10 +23,20 @@ export const AttachCommand = cmd({ }), handler: async (args) => { if (args.dir) process.chdir(args.dir) + + let parsed + try { + parsed = parseAttachUrl(args.url) + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`) + process.exit(1) + } + await tui({ - url: args.url, + url: parsed.baseUrl, args: { sessionID: args.session }, directory: args.dir ? process.cwd() : undefined, + unix: parsed.unix, }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3339e7b00d2..776e4893973 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -9,12 +9,13 @@ export type EventSource = { export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => { + init: (props: { url: string; directory?: string; unix?: string; fetch?: typeof fetch; events?: EventSource }) => { const abort = new AbortController() const sdk = createOpencodeClient({ baseUrl: props.url, signal: abort.signal, directory: props.directory, + unix: props.unix, fetch: props.fetch, }) @@ -89,6 +90,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (timer) clearTimeout(timer) }) - return { client: sdk, event: emitter, url: props.url } + const displayUrl = props.unix ? `unix://${props.unix}` : props.url + + return { client: sdk, event: emitter, url: displayUrl } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/parse-url.ts b/packages/opencode/src/cli/cmd/tui/parse-url.ts new file mode 100644 index 00000000000..6f84cc44a61 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/parse-url.ts @@ -0,0 +1,28 @@ +export interface ParsedAttachUrl { + baseUrl: string + unix?: string +} + +export function parseAttachUrl(url: string): ParsedAttachUrl { + if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("unix://")) { + throw new Error("URL must start with http://, https://, or unix://") + } + + if (url.startsWith("unix://")) { + const socketPath = url.substring(7) // Remove "unix://" prefix + if (!socketPath || socketPath.trim().length === 0) { + throw new Error("Unix socket path cannot be empty") + } + + if (!socketPath.startsWith("/")) { + throw new Error("Unix socket path must be absolute (start with /)") + } + + return { + baseUrl: "http://localhost:4096", // Placeholder for SDK URL building + unix: socketPath, + } + } + + return { baseUrl: url } +} diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..7030ac8e729 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -23,6 +23,10 @@ const options = { describe: "additional domains to allow for CORS", default: [] as string[], }, + unix: { + type: "string" as const, + describe: "unix socket path to bind to (overrides port/hostname)", + }, } export type NetworkOptions = InferredOptionTypes @@ -37,7 +41,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") const corsExplicitlySet = process.argv.includes("--cors") + const unixExplicitlySet = process.argv.includes("--unix") + const unix = unixExplicitlySet ? args.unix : (config?.server?.unix ?? args.unix) const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet @@ -49,5 +55,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, cors } + return { hostname, port, mdns, cors, unix } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b2142e29b94..1acf96601be 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -812,6 +812,7 @@ export namespace Config { hostname: z.string().optional().describe("Hostname to listen on"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), + unix: z.string().optional().describe("Unix socket path to bind to (overrides port/hostname)"), }) .strict() .meta({ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 15b7f829b9c..d639ae4af3b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -529,24 +529,38 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + export function listen(opts: { port?: number; hostname?: string; mdns?: boolean; cors?: string[]; unix?: string }) { _corsWhitelist = opts.cors ?? [] - const args = { - hostname: opts.hostname, + const baseArgs: any = { idleTimeout: 0, fetch: App().fetch, websocket: websocket, - } as const - const tryServe = (port: number) => { - try { - return Bun.serve({ ...args, port }) - } catch { - return undefined + } + + let server: ReturnType + + if (opts.unix) { + // Unix socket mode + server = Bun.serve({ ...baseArgs, unix: opts.unix }) + } else { + // TCP socket mode + const args = { + ...baseArgs, + hostname: opts.hostname ?? "127.0.0.1", + } as const + const tryServe = (port: number) => { + try { + return Bun.serve({ ...args, port }) + } catch { + return undefined + } } + const port = opts.port ?? 0 + const result = port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(port) + if (!result) throw new Error(`Failed to start server on port ${port}`) + server = result } - const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) _url = server.url diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 806ad26e55a..9f55d682fa6 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -5,12 +5,53 @@ import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" export { type Config as OpencodeClientConfig, OpencodeClient } -export function createOpencodeClient(config?: Config & { directory?: string }) { +export function createOpencodeClient(config?: Config & { directory?: string; unix?: string }) { if (!config?.fetch) { + // Handle Unix domain socket if specified + if (config?.unix) { + const unixPath = config.unix + const customFetch: any = (req: any) => { + return fetch( + req.url, + { + method: req.method, + headers: req.headers, + body: req.body, + unix: unixPath, + timeout: false, + } as any, + ) + } + config = { + ...config, + fetch: customFetch, + } + } else { + const customFetch: any = (req: any) => { + // @ts-ignore + req.timeout = false + return fetch(req) + } + config = { + ...config, + fetch: customFetch, + } + } + } else if (config?.unix) { + // Handle Unix domain socket when fetch is already provided + const unixPath = config.unix + const originalFetch: any = config.fetch const customFetch: any = (req: any) => { - // @ts-ignore - req.timeout = false - return fetch(req) + return originalFetch( + req.url, + { + method: req.method, + headers: req.headers, + body: req.body, + unix: unixPath, + timeout: false, + } as any, + ) } config = { ...config, diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 8685be52d6a..e10802ec425 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -5,12 +5,51 @@ import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" export { type Config as OpencodeClientConfig, OpencodeClient } -export function createOpencodeClient(config?: Config & { directory?: string }) { +export function createOpencodeClient(config?: Config & { directory?: string; unix?: string }) { if (!config?.fetch) { + if (config?.unix) { + const unixPath = config.unix + const customFetch: any = (req: any) => { + return fetch( + req.url, + { + method: req.method, + headers: req.headers, + body: req.body, + unix: unixPath, + timeout: false, + } as any, + ) + } + config = { + ...config, + fetch: customFetch, + } + } else { + const customFetch: any = (req: any) => { + // @ts-ignore + req.timeout = false + return fetch(req) + } + config = { + ...config, + fetch: customFetch, + } + } + } else if (config?.unix) { + const unixPath = config.unix + const originalFetch: any = config.fetch const customFetch: any = (req: any) => { - // @ts-ignore - req.timeout = false - return fetch(req) + return originalFetch( + req.url, + { + method: req.method, + headers: req.headers, + body: req.body, + unix: unixPath, + timeout: false, + } as any, + ) } config = { ...config,