summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-02-18 23:03:24 +0800
committerGitHub <[email protected]>2026-02-18 23:03:24 +0800
commit1bb8574179bbf7c49a34ad0e5df522a752af08c2 (patch)
treed10e2ec54cc027dc8ffd76bb40031300f9eaeef8 /packages/app/src/context
parent2611c35acc3dc64582e15ad1efca36c60a2883a8 (diff)
downloadopencode-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.tsx48
-rw-r--r--packages/app/src/context/global-sync.tsx70
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts32
-rw-r--r--packages/app/src/context/sdk.tsx13
-rw-r--r--packages/app/src/context/server.tsx186
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,