Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Luke Parker
07cd7c0f93 feat(app): add configured web servers 2026-06-29 23:20:22 +00:00
8 changed files with 157 additions and 7 deletions

View file

@ -255,6 +255,7 @@ declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
deepLinks?: string[] deepLinks?: string[]
servers?: unknown
} }
api?: { api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void> setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>

View file

@ -8,6 +8,7 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server" import { authFromToken } from "@/utils/server"
import { parseStartupServers } from "@/utils/startup-servers"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server" import { ServerConnection } from "./context/server"
@ -112,6 +113,14 @@ const getDefaultUrl = () => {
return getCurrentUrl() return getCurrentUrl()
} }
const remoteServersMeta = () => document.querySelector<HTMLMetaElement>('meta[name="opencode-remote-servers"]')?.content
const getStartupServers = () => [
...parseStartupServers(import.meta.env.VITE_OPENCODE_REMOTE_SERVERS),
...parseStartupServers(remoteServersMeta()),
...parseStartupServers(window.__OPENCODE__?.servers),
]
const clearAuthToken = () => { const clearAuthToken = () => {
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return if (!params.has("auth_token")) return
@ -171,7 +180,7 @@ if (root instanceof HTMLElement) {
<AppInterface <AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())} defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)} canonicalLocalServer={ServerConnection.key(server)}
servers={[server]} servers={[...getStartupServers(), server]}
disableHealthCheck disableHealthCheck
/> />
</AppBaseProviders> </AppBaseProviders>

View file

@ -2,6 +2,7 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_OPENCODE_REMOTE_SERVERS?: string
readonly VITE_SENTRY_DSN?: string readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string readonly VITE_SENTRY_ENVIRONMENT?: string

View file

@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { parseStartupServers } from "./startup-servers"
describe("parseStartupServers", () => {
test("parses comma separated startup servers", () => {
expect(parseStartupServers("Dax=http://romulus.example.test:4096, apollo.example.test:4096")).toEqual([
{
type: "http",
displayName: "Dax",
http: { url: "http://romulus.example.test:4096" },
},
{
type: "http",
http: { url: "http://apollo.example.test:4096" },
},
])
})
test("parses JSON startup servers with credentials", () => {
expect(
parseStartupServers(
JSON.stringify([
{
displayName: "Kit box",
url: "kit.example.test:4096/",
username: "opencode",
password: "secret",
},
]),
),
).toEqual([
{
type: "http",
displayName: "Kit box",
http: { url: "http://kit.example.test:4096", username: "opencode", password: "secret" },
},
])
})
test("parses JSON name to URL maps", () => {
expect(parseStartupServers('{"Dax":"http://romulus.example.test:4096"}')).toEqual([
{
type: "http",
displayName: "Dax",
http: { url: "http://romulus.example.test:4096" },
},
])
})
})

View file

@ -0,0 +1,62 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
export type StartupServerConfig =
| string
| {
url?: unknown
name?: unknown
displayName?: unknown
username?: unknown
password?: unknown
}
export function parseStartupServers(input: unknown): ServerConnection.Http[] {
const parsed = typeof input === "string" ? parseServerString(input) : input
if (Array.isArray(parsed)) return parsed.flatMap((value) => serverFromConfig(value))
if (!isRecord(parsed)) return []
return Object.entries(parsed).flatMap(([name, value]) =>
typeof value === "string" ? serverFromConfig({ name, url: value }) : serverFromConfig(value),
)
}
function parseServerString(input: string): unknown {
const trimmed = input.trim()
if (!trimmed) return []
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
return JSON.parse(trimmed)
} catch {
return []
}
}
return trimmed.split(",").map((item) => {
const value = item.trim()
const index = value.indexOf("=")
if (index <= 0) return value
return { name: value.slice(0, index).trim(), url: value.slice(index + 1).trim() }
})
}
function serverFromConfig(input: unknown): ServerConnection.Http[] {
const url = normalizeServerUrl(typeof input === "string" ? input : (isRecord(input) && stringValue(input.url)) || "")
if (!url) return []
const conn: ServerConnection.Http = { type: "http", http: { url } }
if (isRecord(input)) {
const displayName = stringValue(input.displayName) ?? stringValue(input.name)
if (displayName) conn.displayName = displayName
const username = stringValue(input.username)
if (username) conn.http.username = username
const password = stringValue(input.password)
if (password) conn.http.password = password
}
return [conn]
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -5,6 +5,7 @@ declare global {
api: ElectronAPI api: ElectronAPI
__OPENCODE__?: { __OPENCODE__?: {
deepLinks?: string[] deepLinks?: string[]
servers?: unknown
} }
} }
} }

View file

@ -55,10 +55,24 @@ function notFound() {
function embeddedUIResponse(file: string, body: Uint8Array) { function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = FSUtil.mimeType(file) const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime }) const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) { if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers })
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
} const html = injectRemoteServersMeta(new TextDecoder().decode(body))
return HttpServerResponse.raw(body, { headers }) headers.set("content-security-policy", cspForHtml(html))
return HttpServerResponse.raw(new TextEncoder().encode(html), { headers })
}
export function injectRemoteServersMeta(body: string, remoteServers = process.env.OPENCODE_WEB_REMOTE_SERVERS) {
const value = remoteServers?.trim()
if (!value) return body
if (body.includes('name="opencode-remote-servers"')) return body
const meta = `<meta name="opencode-remote-servers" content="${escapeHtmlAttribute(value)}">`
if (/<head\b[^>]*>/i.test(body)) return body.replace(/<head\b([^>]*)>/i, `<head$1>${meta}`)
return `${meta}${body}`
}
function escapeHtmlAttribute(value: string) {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
} }
export function serveEmbeddedUIEffect( export function serveEmbeddedUIEffect(
@ -94,7 +108,7 @@ export function serveUIEffect(
const headers = proxyResponseHeaders(response.headers) const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) { if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text const body = injectRemoteServersMeta(yield* response.text)
headers.set("Content-Security-Policy", cspForHtml(body)) headers.set("Content-Security-Policy", cspForHtml(body))
return HttpServerResponse.text(body, { status: response.status, headers }) return HttpServerResponse.text(body, { status: response.status, headers })
} }

View file

@ -17,7 +17,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { ServerAuth } from "../../src/server/auth" import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { injectRemoteServersMeta, serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard( const testStateLayer = Layer.effectDiscard(
@ -27,6 +27,7 @@ const testStateLayer = Layer.effectDiscard(
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD, envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME, envUsername: process.env.OPENCODE_SERVER_USERNAME,
envWebRemoteServers: process.env.OPENCODE_WEB_REMOTE_SERVERS,
} }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
@ -35,6 +36,7 @@ const testStateLayer = Layer.effectDiscard(
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
restoreEnv("OPENCODE_WEB_REMOTE_SERVERS", original.envWebRemoteServers)
}), }),
) )
}), }),
@ -184,6 +186,17 @@ function responseText(response: Response) {
} }
describe("HttpApi UI fallback", () => { describe("HttpApi UI fallback", () => {
it.live("injects configured remote servers into web UI html", () =>
Effect.gen(function* () {
const config = '[{"name":"Dax","url":"http://romulus.example.test:4096"}]'
const html = injectRemoteServersMeta("<html><head><title>opencode</title></head></html>", config)
expect(html).toContain('<meta name="opencode-remote-servers"')
expect(html).toContain("Dax")
expect(html).toContain("http://romulus.example.test:4096")
}),
)
it.live("serves the web UI through the HTTP API app", () => it.live("serves the web UI through the HTTP API app", () =>
Effect.gen(function* () { Effect.gen(function* () {
let proxiedUrl: string | undefined let proxiedUrl: string | undefined