diff options
| author | Brendan Allan <[email protected]> | 2026-02-18 23:03:24 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-18 23:03:24 +0800 |
| commit | 1bb8574179bbf7c49a34ad0e5df522a752af08c2 (patch) | |
| tree | d10e2ec54cc027dc8ffd76bb40031300f9eaeef8 /packages/app/src/context | |
| parent | 2611c35acc3dc64582e15ad1efca36c60a2883a8 (diff) | |
| download | opencode-1bb8574179bbf7c49a34ad0e5df522a752af08c2.tar.gz opencode-1bb8574179bbf7c49a34ad0e5df522a752af08c2.zip | |
app: refactor server management backend (#13813)
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/global-sdk.tsx | 48 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 70 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/bootstrap.ts | 32 | ||||
| -rw-r--r-- | packages/app/src/context/sdk.tsx | 13 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 186 |
5 files changed, 200 insertions, 149 deletions
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 82e39ed69..8c0035d55 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -1,8 +1,9 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" import z from "zod" +import { createSdkForServer } from "@/utils/server" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const platform = usePlatform() const abort = new AbortController() - const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword - - const auth = (() => { - if (!password) return - if (!server.isLocal()) return - return { - Authorization: `Basic ${btoa(`opencode:${password}`)}`, - } - })() - const eventFetch = (() => { - if (!platform.fetch) return + if (!platform.fetch || !server.current) return try { - const url = new URL(server.url) + const url = new URL(server.current.http.url) const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" if (url.protocol === "http:" && !loopback) return platform.fetch } catch { @@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })() - const eventSdk = createOpencodeClient({ - baseUrl: server.url, + const currentServer = server.current + if (!currentServer) throw new Error("No server available") + + const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, - headers: eventFetch ? undefined : auth, + server: currentServer.http, }) const emitter = createGlobalEmitter<{ [key: string]: Event @@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", { - url: server.url, + url: currentServer.http.url, fetch: eventFetch ? "platform" : "webview", error, }) @@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (!aborted(error) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { - url: server.url, + url: currentServer.http.url, fetch: eventFetch ? "platform" : "webview", error, }) @@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo flush() }) - const sdk = createOpencodeClient({ - baseUrl: server.url, + const sdk = createSdkForServer({ + server: server.current.http, fetch: platform.fetch, throwOnError: true, }) - return { url: server.url, client: sdk, event: emitter } + return { + url: currentServer.http.url, + client: sdk, + event: emitter, + createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) { + const s = server.current + if (!s) throw new Error("Server not available") + return createSdkForServer({ + server: s.http, + fetch: platform.fetch, + ...opts, + }) + }, + } }, }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 9733b72af..4f3664d8e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1,41 +1,41 @@ -import { - type Config, - type Path, - type Project, - type ProviderAuthResponse, - type ProviderListResponse, - type Todo, - createOpencodeClient, +import type { + Config, + OpencodeClient, + Path, + Project, + ProviderAuthResponse, + ProviderListResponse, + Todo, } from "@opencode-ai/sdk/v2/client" -import { createStore, produce, reconcile } from "solid-js/store" -import { useGlobalSDK } from "./global-sdk" -import type { InitError } from "../pages/error" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" import { createContext, createEffect, - untrack, getOwner, - useContext, + Match, onCleanup, onMount, type ParentProps, Switch, - Match, + untrack, + useContext, } from "solid-js" -import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { usePlatform } from "./platform" +import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" -import { createRefreshQueue } from "./global-sync/queue" +import type { InitError } from "../pages/error" +import { useGlobalSDK } from "./global-sdk" +import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { trimSessions } from "./global-sync/session-trim" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" -import { sanitizeProject } from "./global-sync/utils" +import { createRefreshQueue } from "./global-sync/queue" +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" +import { sanitizeProject } from "./global-sync/utils" +import { usePlatform } from "./platform" type GlobalStore = { ready: boolean @@ -77,7 +77,7 @@ function createGlobalSync() { loadSessionsFallback: 0, } - const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>() + const sdkCache = new Map<string, OpencodeClient>() const booting = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>() const sessionMeta = new Map<string, { limit: number }>() @@ -151,9 +151,7 @@ function createGlobalSync() { const sdkFor = (directory: string) => { const cached = sdkCache.get(directory) if (cached) return cached - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, + const sdk = globalSDK.createClient({ directory, throwOnError: true, }) @@ -193,7 +191,10 @@ function createGlobalSync() { const [store, setStore] = children.child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) if (meta && meta.limit >= store.limit) { - const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) + const next = trimSessions(store.session, { + limit: store.limit, + permission: store.permission, + }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } @@ -218,10 +219,17 @@ function createGlobalSync() { .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = store.limit const childSessions = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) + const sessions = trimSessions([...nonArchived, ...childSessions], { + limit, + permission: store.permission, + }) setStore( "sessionTotal", - estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), ) setStore("session", reconcile(sessions, { key: "id" })) sessionMeta.set(directory, { limit }) @@ -331,7 +339,9 @@ function createGlobalSync() { await bootstrapGlobal({ globalSDK: globalSDK.client, connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), + connectErrorDescription: language.t("error.globalSync.connectFailed", { + url: globalSDK.url, + }), requestFailedTitle: language.t("common.requestFailed"), setGlobalStore, }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 40a30cba8..6e7714828 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,21 +1,21 @@ -import { - type Config, - type Path, - type PermissionRequest, - type Project, - type ProviderAuthResponse, - type ProviderListResponse, - type QuestionRequest, - type Todo, - createOpencodeClient, +import type { + Config, + OpencodeClient, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + ProviderListResponse, + QuestionRequest, + Todo, } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" +import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import { retry } from "@opencode-ai/util/retry" -import { getFilename } from "@opencode-ai/util/path" -import { showToast } from "@opencode-ai/ui/toast" -import { cmp, normalizeProviderList } from "./utils" import type { State, VcsCache } from "./types" +import { cmp, normalizeProviderList } from "./utils" type GlobalStore = { ready: boolean @@ -31,7 +31,7 @@ type GlobalStore = { } export async function bootstrapGlobal(input: { - globalSDK: ReturnType<typeof createOpencodeClient> + globalSDK: OpencodeClient connectErrorTitle: string connectErrorDescription: string requestFailedTitle: string @@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) export async function bootstrapDirectory(input: { directory: string - sdk: ReturnType<typeof createOpencodeClient> + sdk: OpencodeClient store: Store<State> setStore: SetStoreFunction<State> vcsCache: VcsCache diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index 555933619..bc97ea13a 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,9 +1,8 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { useGlobalSDK } from "./global-sdk" -import { usePlatform } from "./platform" type SDKEventMap = { [key in Event["type"]]: Extract<Event, { type: key }> @@ -12,14 +11,11 @@ type SDKEventMap = { export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { directory: Accessor<string> }) => { - const platform = usePlatform() const globalSDK = useGlobalSDK() const directory = createMemo(props.directory) const client = createMemo(() => - createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, + globalSDK.createClient({ directory: directory(), throwOnError: true, }), @@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get url() { return globalSDK.url }, + createClient(opts: Parameters<typeof globalSDK.createClient>[0]) { + return globalSDK.createClient(opts) + }, } }, }) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5d3d0cf3a..182f7507f 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" @@ -15,9 +15,10 @@ export function normalizeServerUrl(input: string) { return withProtocol.replace(/\/+$/, "") } -export function serverDisplayName(url: string) { - if (!url) return "" - return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") +export function serverDisplayName(conn?: ServerConnection.Any) { + if (!conn) return "" + if (conn.displayName) return conn.displayName + return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") } function projectsKey(url: string) { @@ -27,80 +28,104 @@ function projectsKey(url: string) { return url } +export namespace ServerConnection { + type Base = { displayName?: string } + + export type HttpBase = { + url: string + username?: string + password?: string + } + + // Regular web connections + export type Http = { + type: "http" + http: HttpBase + } & Base + + export type Sidecar = { + type: "sidecar" + http: HttpBase + } & ( + | // Regular desktop server + { variant: "base" } + // WSL server (windows only) + | { + variant: "wsl" + distro: string + } + ) & + Base + + // Remote server desktop can SSH into + export type Ssh = { + type: "ssh" + host: string + // SSH client exposes an HTTP server for the app to use as a proxy + http: HttpBase + } & Base + + export type Any = + | Http + // All these are desktop-only + | (Sidecar | Ssh) + + export const key = (conn: Any): Key => { + switch (conn.type) { + case "http": + return Key.make(conn.http.url) + case "sidecar": { + if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) + return Key.make("sidecar") + } + case "ssh": + return Key.make(`ssh:${conn.host}`) + } + } + + export type Key = string & { _brand: "Key" } + export const Key = { make: (v: string) => v as Key } +} + export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", - init: (props: { defaultUrl: string; isSidecar?: boolean }) => { + init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { const platform = usePlatform() const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), createStore({ list: [] as string[], - currentSidecarUrl: "", projects: {} as Record<string, StoredProject[]>, lastProject: {} as Record<string, string>, }), ) + const allServers = createMemo( + (): Array<ServerConnection.Any> => [ + ...(props.servers ?? []), + ...store.list.map((value) => ({ + type: "http" as const, + http: typeof value === "string" ? { url: value } : value, + })), + ], + ) + const [state, setState] = createStore({ - active: "", + active: props.defaultServer, healthy: undefined as boolean | undefined, }) const healthy = () => state.healthy - const defaultUrl = () => normalizeServerUrl(props.defaultUrl) - - function reconcileStartup() { - const fallback = defaultUrl() - if (!fallback) return - - const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl) - const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list - if (!props.isSidecar) { - batch(() => { - setStore("list", list) - if (store.currentSidecarUrl) setStore("currentSidecarUrl", "") - setState("active", fallback) - }) - return - } - - const nextList = list.includes(fallback) ? list : [...list, fallback] - batch(() => { - setStore("list", nextList) - setStore("currentSidecarUrl", fallback) - setState("active", fallback) - }) - } - - function updateServerList(url: string, remove = false) { - if (remove) { - const list = store.list.filter((x) => x !== url) - const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active - batch(() => { - setStore("list", list) - setState("active", next) - }) - return - } - - batch(() => { - if (!store.list.includes(url)) { - setStore("list", store.list.length, url) - } - setState("active", url) - }) - } - - function startHealthPolling(url: string) { + function startHealthPolling(conn: ServerConnection.Any) { let alive = true let busy = false const run = () => { if (busy) return busy = true - void check(url) + void check(conn) .then((next) => { if (!alive) return setState("healthy", next) @@ -118,59 +143,70 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } } - function setActive(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - setState("active", url) + function setActive(input: ServerConnection.Key) { + if (state.active !== input) setState("active", input) } function add(input: string) { const url = normalizeServerUrl(input) if (!url) return - updateServerList(url) + return batch(() => { + const http: ServerConnection.HttpBase = { url } + if (!store.list.includes(url)) { + setStore("list", store.list.length, url) + } + const conn: ServerConnection.Http = { type: "http", http } + setState("active", ServerConnection.key(conn)) + return conn + }) } - function remove(input: string) { - const url = normalizeServerUrl(input) - if (!url) return - updateServerList(url, true) + function remove(key: ServerConnection.Key) { + const list = store.list.filter((x) => x !== key) + batch(() => { + setStore("list", list) + if (state.active === key) { + const next = list[0] + setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer) + } + }) } - createEffect(() => { - if (!ready()) return - if (state.active) return - reconcileStartup() - }) - const isReady = createMemo(() => ready() && !!state.active) const fetcher = platform.fetch ?? globalThis.fetch - const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) createEffect(() => { - const url = state.active - if (!url) return + const current_ = current() + if (!current_) return setState("healthy", undefined) - onCleanup(startHealthPolling(url)) + onCleanup(startHealthPolling(current_)) }) const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") + const current: Accessor<ServerConnection.Any | undefined> = createMemo( + () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], + ) return { ready: isReady, healthy, isLocal, - get url() { + get key() { return state.active }, get name() { - return serverDisplayName(state.active) + return serverDisplayName(current()) }, get list() { - return store.list + return allServers() + }, + get current() { + return current() }, setActive, add, |
