disable server unless explicitly opted in (#7529)

This commit is contained in:
Dax
2026-01-09 16:33:46 -05:00
committed by GitHub
parent 75df5040ea
commit 982b71e861
6 changed files with 194 additions and 49 deletions

View File

@@ -97,7 +97,16 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}) })
} }
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) { import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
directory?: string
fetch?: typeof fetch
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit // promise to prevent immediate exit
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor() const mode = await getTerminalBackgroundColor()
@@ -117,7 +126,12 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider> <RouteProvider>
<SDKProvider url={input.url} directory={input.directory}> <SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
events={input.events}
>
<SyncProvider> <SyncProvider>
<ThemeProvider mode={mode}> <ThemeProvider mode={mode}>
<LocalProvider> <LocalProvider>

View File

@@ -3,21 +3,66 @@ import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { url: string; directory?: string }) => { init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
const abort = new AbortController() const abort = new AbortController()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: props.url, baseUrl: props.url,
signal: abort.signal, signal: abort.signal,
directory: props.directory, directory: props.directory,
fetch: props.fetch,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
}>() }>()
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
const handleEvent = (event: Event) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
}
flush()
}
onMount(async () => { onMount(async () => {
// If an event source is provided, use it instead of SSE
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
return
}
// Fall back to SSE
while (true) { while (true) {
if (abort.signal.aborted) break if (abort.signal.aborted) break
const events = await sdk.event.subscribe( const events = await sdk.event.subscribe(
@@ -26,36 +71,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
signal: abort.signal, signal: abort.signal,
}, },
) )
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
for await (const event of events.stream) { for await (const event of events.stream) {
queue.push(event) handleEvent(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
}
flush()
} }
// Flush any remaining events // Flush any remaining events
@@ -68,6 +86,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()
if (timer) clearTimeout(timer)
}) })
return { client: sdk, event: emitter, url: props.url } return { client: sdk, event: emitter, url: props.url }

View File

@@ -7,11 +7,39 @@ import { UI } from "@/cli/ui"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
} }
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
}
}
export const TuiThreadCommand = cmd({ export const TuiThreadCommand = cmd({
command: "$0 [project]", command: "$0 [project]",
describe: "start opencode tui", describe: "start opencode tui",
@@ -80,16 +108,45 @@ export const TuiThreadCommand = cmd({
process.on("SIGUSR2", async () => { process.on("SIGUSR2", async () => {
await client.call("reload", undefined) await client.call("reload", undefined)
}) })
const opts = await resolveNetworkOptions(args)
const server = await client.call("server", opts)
const prompt = await iife(async () => { const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt return piped ? piped + "\n" + args.prompt : args.prompt
}) })
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
const shouldStartServer =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
networkOpts.mdns ||
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
// Subscribe to events from worker
await client.call("subscribe", { directory: cwd })
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
if (shouldStartServer) {
// Start HTTP server for external access
const server = await client.call("server", networkOpts)
url = server.url
} else {
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client)
}
const tuiPromise = tui({ const tuiPromise = tui({
url: server.url, url,
fetch: customFetch,
events,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,

View File

@@ -5,8 +5,10 @@ import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import type { BunWebSocketData } from "hono/bun"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import type { BunWebSocketData } from "hono/bun"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -29,20 +31,47 @@ process.on("uncaughtException", (e) => {
}) })
}) })
let server: Bun.Server<BunWebSocketData> // Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
export const rpc = { export const rpc = {
async server(input: { port: number; hostname: string; mdns?: boolean }) { async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
if (server) await server.stop(true) const request = new Request(input.url, {
try { method: input.method,
server = Server.listen(input) headers: input.headers,
return { body: input.body,
url: server.url.toString(), })
} const response = await Server.App().fetch(request)
} catch (e) { const body = await response.text()
console.error(e) return {
throw e status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
} }
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = Server.listen(input)
return { url: server.url.toString() }
},
async subscribe(input: { directory: string }) {
return Instance.provide({
directory: input.directory,
init: InstanceBootstrap,
fn: async () => {
Bus.subscribeAll((event) => {
Rpc.emit("event", event)
})
// Emit connected event
Rpc.emit("event", { type: "server.connected", properties: {} })
return { subscribed: true }
},
})
},
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
await Instance.provide({ await Instance.provide({
directory: input.directory, directory: input.directory,
@@ -59,9 +88,7 @@ export const rpc = {
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
await Instance.disposeAll() await Instance.disposeAll()
// TODO: this should be awaited, but ws connections are if (server) server.stop(true)
// causing this to hang, need to revisit this
server.stop(true)
}, },
} }

View File

@@ -2826,6 +2826,10 @@ export namespace Server {
host: "app.opencode.ai", host: "app.opencode.ai",
}, },
}) })
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
)
return response return response
}) as unknown as Hono, }) as unknown as Hono,
) )

View File

@@ -13,11 +13,16 @@ export namespace Rpc {
} }
} }
export function emit(event: string, data: unknown) {
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
}
export function client<T extends Definition>(target: { export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) { }) {
const pending = new Map<number, (result: any) => void>() const pending = new Map<number, (result: any) => void>()
const listeners = new Map<string, Set<(data: any) => void>>()
let id = 0 let id = 0
target.onmessage = async (evt) => { target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data) const parsed = JSON.parse(evt.data)
@@ -28,6 +33,14 @@ export namespace Rpc {
pending.delete(parsed.id) pending.delete(parsed.id)
} }
} }
if (parsed.type === "rpc.event") {
const handlers = listeners.get(parsed.event)
if (handlers) {
for (const handler of handlers) {
handler(parsed.data)
}
}
}
} }
return { return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> { call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
@@ -37,6 +50,17 @@ export namespace Rpc {
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
}) })
}, },
on<Data>(event: string, handler: (data: Data) => void) {
let handlers = listeners.get(event)
if (!handlers) {
handlers = new Set()
listeners.set(event, handlers)
}
handlers.add(handler)
return () => {
handlers!.delete(handler)
}
},
} }
} }
} }