Compare commits

...

1 Commits

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
+1
View File
@@ -255,6 +255,7 @@ declare global {
interface Window {
__OPENCODE__?: {
deepLinks?: string[]
servers?: unknown
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
+10 -1
View File
@@ -8,6 +8,7 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import { parseStartupServers } from "@/utils/startup-servers"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -112,6 +113,14 @@ const getDefaultUrl = () => {
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 params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
@@ -171,7 +180,7 @@ if (root instanceof HTMLElement) {
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
servers={[...getStartupServers(), server]}
disableHealthCheck
/>
</AppBaseProviders>
+1
View File
@@ -2,6 +2,7 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_OPENCODE_REMOTE_SERVERS?: string
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
@@ -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" },
},
])
})
})
+62
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)
}
+1
View File
@@ -5,6 +5,7 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
deepLinks?: string[]
servers?: unknown
}
}
}
+19 -5
View File
@@ -55,10 +55,24 @@ function notFound() {
function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
}
return HttpServerResponse.raw(body, { headers })
if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers })
const html = injectRemoteServersMeta(new TextDecoder().decode(body))
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(
@@ -94,7 +108,7 @@ export function serveUIEffect(
const headers = proxyResponseHeaders(response.headers)
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))
return HttpServerResponse.text(body, { status: response.status, headers })
}
@@ -17,7 +17,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
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"
const testStateLayer = Layer.effectDiscard(
@@ -27,6 +27,7 @@ const testStateLayer = Layer.effectDiscard(
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
envWebRemoteServers: process.env.OPENCODE_WEB_REMOTE_SERVERS,
}
yield* Effect.addFinalizer(() =>
@@ -35,6 +36,7 @@ const testStateLayer = Layer.effectDiscard(
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
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", () => {
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", () =>
Effect.gen(function* () {
let proxiedUrl: string | undefined