Skip to content

Commit 982b71e

Browse files
authored
disable server unless explicitly opted in (anomalyco#7529)
1 parent 75df504 commit 982b71e

6 files changed

Lines changed: 194 additions & 49 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,16 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
9797
})
9898
}
9999

100-
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) {
100+
import type { EventSource } from "./context/sdk"
101+
102+
export function tui(input: {
103+
url: string
104+
args: Args
105+
directory?: string
106+
fetch?: typeof fetch
107+
events?: EventSource
108+
onExit?: () => Promise<void>
109+
}) {
101110
// promise to prevent immediate exit
102111
return new Promise<void>(async (resolve) => {
103112
const mode = await getTerminalBackgroundColor()
@@ -117,7 +126,12 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
117126
<KVProvider>
118127
<ToastProvider>
119128
<RouteProvider>
120-
<SDKProvider url={input.url} directory={input.directory}>
129+
<SDKProvider
130+
url={input.url}
131+
directory={input.directory}
132+
fetch={input.fetch}
133+
events={input.events}
134+
>
121135
<SyncProvider>
122136
<ThemeProvider mode={mode}>
123137
<LocalProvider>

packages/opencode/src/cli/cmd/tui/context/sdk.tsx

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,66 @@ import { createSimpleContext } from "./helper"
33
import { createGlobalEmitter } from "@solid-primitives/event-bus"
44
import { batch, onCleanup, onMount } from "solid-js"
55

6+
export type EventSource = {
7+
on: (handler: (event: Event) => void) => () => void
8+
}
9+
610
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
711
name: "SDK",
8-
init: (props: { url: string; directory?: string }) => {
12+
init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
913
const abort = new AbortController()
1014
const sdk = createOpencodeClient({
1115
baseUrl: props.url,
1216
signal: abort.signal,
1317
directory: props.directory,
18+
fetch: props.fetch,
1419
})
1520

1621
const emitter = createGlobalEmitter<{
1722
[key in Event["type"]]: Extract<Event, { type: key }>
1823
}>()
1924

25+
let queue: Event[] = []
26+
let timer: Timer | undefined
27+
let last = 0
28+
29+
const flush = () => {
30+
if (queue.length === 0) return
31+
const events = queue
32+
queue = []
33+
timer = undefined
34+
last = Date.now()
35+
// Batch all event emissions so all store updates result in a single render
36+
batch(() => {
37+
for (const event of events) {
38+
emitter.emit(event.type, event)
39+
}
40+
})
41+
}
42+
43+
const handleEvent = (event: Event) => {
44+
queue.push(event)
45+
const elapsed = Date.now() - last
46+
47+
if (timer) return
48+
// If we just flushed recently (within 16ms), batch this with future events
49+
// Otherwise, process immediately to avoid latency
50+
if (elapsed < 16) {
51+
timer = setTimeout(flush, 16)
52+
return
53+
}
54+
flush()
55+
}
56+
2057
onMount(async () => {
58+
// If an event source is provided, use it instead of SSE
59+
if (props.events) {
60+
const unsub = props.events.on(handleEvent)
61+
onCleanup(unsub)
62+
return
63+
}
64+
65+
// Fall back to SSE
2166
while (true) {
2267
if (abort.signal.aborted) break
2368
const events = await sdk.event.subscribe(
@@ -26,36 +71,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
2671
signal: abort.signal,
2772
},
2873
)
29-
let queue: Event[] = []
30-
let timer: Timer | undefined
31-
let last = 0
32-
33-
const flush = () => {
34-
if (queue.length === 0) return
35-
const events = queue
36-
queue = []
37-
timer = undefined
38-
last = Date.now()
39-
// Batch all event emissions so all store updates result in a single render
40-
batch(() => {
41-
for (const event of events) {
42-
emitter.emit(event.type, event)
43-
}
44-
})
45-
}
4674

4775
for await (const event of events.stream) {
48-
queue.push(event)
49-
const elapsed = Date.now() - last
50-
51-
if (timer) continue
52-
// If we just flushed recently (within 16ms), batch this with future events
53-
// Otherwise, process immediately to avoid latency
54-
if (elapsed < 16) {
55-
timer = setTimeout(flush, 16)
56-
continue
57-
}
58-
flush()
76+
handleEvent(event)
5977
}
6078

6179
// Flush any remaining events
@@ -68,6 +86,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
6886

6987
onCleanup(() => {
7088
abort.abort()
89+
if (timer) clearTimeout(timer)
7190
})
7291

7392
return { client: sdk, event: emitter, url: props.url }

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,39 @@ import { UI } from "@/cli/ui"
77
import { iife } from "@/util/iife"
88
import { Log } from "@/util/log"
99
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
10+
import type { Event } from "@opencode-ai/sdk/v2"
11+
import type { EventSource } from "./context/sdk"
1012

1113
declare global {
1214
const OPENCODE_WORKER_PATH: string
1315
}
1416

17+
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
18+
19+
function createWorkerFetch(client: RpcClient): typeof fetch {
20+
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
21+
const request = new Request(input, init)
22+
const body = request.body ? await request.text() : undefined
23+
const result = await client.call("fetch", {
24+
url: request.url,
25+
method: request.method,
26+
headers: Object.fromEntries(request.headers.entries()),
27+
body,
28+
})
29+
return new Response(result.body, {
30+
status: result.status,
31+
headers: result.headers,
32+
})
33+
}
34+
return fn as typeof fetch
35+
}
36+
37+
function createEventSource(client: RpcClient): EventSource {
38+
return {
39+
on: (handler) => client.on<Event>("event", handler),
40+
}
41+
}
42+
1543
export const TuiThreadCommand = cmd({
1644
command: "$0 [project]",
1745
describe: "start opencode tui",
@@ -80,16 +108,45 @@ export const TuiThreadCommand = cmd({
80108
process.on("SIGUSR2", async () => {
81109
await client.call("reload", undefined)
82110
})
83-
const opts = await resolveNetworkOptions(args)
84-
const server = await client.call("server", opts)
111+
85112
const prompt = await iife(async () => {
86113
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
87114
if (!args.prompt) return piped
88115
return piped ? piped + "\n" + args.prompt : args.prompt
89116
})
90117

118+
// Check if server should be started (port or hostname explicitly set in CLI or config)
119+
const networkOpts = await resolveNetworkOptions(args)
120+
const shouldStartServer =
121+
process.argv.includes("--port") ||
122+
process.argv.includes("--hostname") ||
123+
process.argv.includes("--mdns") ||
124+
networkOpts.mdns ||
125+
networkOpts.port !== 0 ||
126+
networkOpts.hostname !== "127.0.0.1"
127+
128+
// Subscribe to events from worker
129+
await client.call("subscribe", { directory: cwd })
130+
131+
let url: string
132+
let customFetch: typeof fetch | undefined
133+
let events: EventSource | undefined
134+
135+
if (shouldStartServer) {
136+
// Start HTTP server for external access
137+
const server = await client.call("server", networkOpts)
138+
url = server.url
139+
} else {
140+
// Use direct RPC communication (no HTTP)
141+
url = "http://opencode.internal"
142+
customFetch = createWorkerFetch(client)
143+
events = createEventSource(client)
144+
}
145+
91146
const tuiPromise = tui({
92-
url: server.url,
147+
url,
148+
fetch: customFetch,
149+
events,
93150
args: {
94151
continue: args.continue,
95152
sessionID: args.session,

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Instance } from "@/project/instance"
55
import { InstanceBootstrap } from "@/project/bootstrap"
66
import { Rpc } from "@/util/rpc"
77
import { upgrade } from "@/cli/upgrade"
8-
import type { BunWebSocketData } from "hono/bun"
98
import { Config } from "@/config/config"
9+
import { Bus } from "@/bus"
10+
import { GlobalBus } from "@/bus/global"
11+
import type { BunWebSocketData } from "hono/bun"
1012

1113
await Log.init({
1214
print: process.argv.includes("--print-logs"),
@@ -29,20 +31,47 @@ process.on("uncaughtException", (e) => {
2931
})
3032
})
3133

32-
let server: Bun.Server<BunWebSocketData>
34+
// Subscribe to global events and forward them via RPC
35+
GlobalBus.on("event", (event) => {
36+
Rpc.emit("global.event", event)
37+
})
38+
39+
let server: Bun.Server<BunWebSocketData> | undefined
40+
3341
export const rpc = {
34-
async server(input: { port: number; hostname: string; mdns?: boolean }) {
35-
if (server) await server.stop(true)
36-
try {
37-
server = Server.listen(input)
38-
return {
39-
url: server.url.toString(),
40-
}
41-
} catch (e) {
42-
console.error(e)
43-
throw e
42+
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
43+
const request = new Request(input.url, {
44+
method: input.method,
45+
headers: input.headers,
46+
body: input.body,
47+
})
48+
const response = await Server.App().fetch(request)
49+
const body = await response.text()
50+
return {
51+
status: response.status,
52+
headers: Object.fromEntries(response.headers.entries()),
53+
body,
4454
}
4555
},
56+
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
57+
if (server) await server.stop(true)
58+
server = Server.listen(input)
59+
return { url: server.url.toString() }
60+
},
61+
async subscribe(input: { directory: string }) {
62+
return Instance.provide({
63+
directory: input.directory,
64+
init: InstanceBootstrap,
65+
fn: async () => {
66+
Bus.subscribeAll((event) => {
67+
Rpc.emit("event", event)
68+
})
69+
// Emit connected event
70+
Rpc.emit("event", { type: "server.connected", properties: {} })
71+
return { subscribed: true }
72+
},
73+
})
74+
},
4675
async checkUpgrade(input: { directory: string }) {
4776
await Instance.provide({
4877
directory: input.directory,
@@ -59,9 +88,7 @@ export const rpc = {
5988
async shutdown() {
6089
Log.Default.info("worker shutting down")
6190
await Instance.disposeAll()
62-
// TODO: this should be awaited, but ws connections are
63-
// causing this to hang, need to revisit this
64-
server.stop(true)
91+
if (server) server.stop(true)
6592
},
6693
}
6794

packages/opencode/src/server/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2826,6 +2826,10 @@ export namespace Server {
28262826
host: "app.opencode.ai",
28272827
},
28282828
})
2829+
response.headers.set(
2830+
"Content-Security-Policy",
2831+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
2832+
)
28292833
return response
28302834
}) as unknown as Hono,
28312835
)

packages/opencode/src/util/rpc.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ export namespace Rpc {
1313
}
1414
}
1515

16+
export function emit(event: string, data: unknown) {
17+
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
18+
}
19+
1620
export function client<T extends Definition>(target: {
1721
postMessage: (data: string) => void | null
1822
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
1923
}) {
2024
const pending = new Map<number, (result: any) => void>()
25+
const listeners = new Map<string, Set<(data: any) => void>>()
2126
let id = 0
2227
target.onmessage = async (evt) => {
2328
const parsed = JSON.parse(evt.data)
@@ -28,6 +33,14 @@ export namespace Rpc {
2833
pending.delete(parsed.id)
2934
}
3035
}
36+
if (parsed.type === "rpc.event") {
37+
const handlers = listeners.get(parsed.event)
38+
if (handlers) {
39+
for (const handler of handlers) {
40+
handler(parsed.data)
41+
}
42+
}
43+
}
3144
}
3245
return {
3346
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
@@ -37,6 +50,17 @@ export namespace Rpc {
3750
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
3851
})
3952
},
53+
on<Data>(event: string, handler: (data: Data) => void) {
54+
let handlers = listeners.get(event)
55+
if (!handlers) {
56+
handlers = new Set()
57+
listeners.set(event, handlers)
58+
}
59+
handlers.add(handler)
60+
return () => {
61+
handlers!.delete(handler)
62+
}
63+
},
4064
}
4165
}
4266
}

0 commit comments

Comments
 (0)