Skip to content

Commit affc29f

Browse files
Zexiclaude
authored andcommitted
feat: show server hostname in web UI title
Rewrites the served HTML <title> to "${hostname} - OpenCode" so the browser tab identifies which machine the sidecar is running on. Falls back to the request Host header's domain part when os.hostname() is empty. Applies to embedded, dev-dir, and proxy serving paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f59ad08 commit affc29f

2 files changed

Lines changed: 42 additions & 8 deletions

File tree

packages/opencode/src/server/shared/ui.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,36 @@ import { Flag } from "@opencode-ai/core/flag/flag"
33
import { Effect, Stream } from "effect"
44
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
55
import { createHash } from "node:crypto"
6+
import os from "node:os"
67
import path from "node:path"
78
import { fileURLToPath } from "node:url"
89
import { ProxyUtil } from "../proxy-util"
910

11+
const SERVER_HOSTNAME = (() => {
12+
try {
13+
return os.hostname().replace(/\.local$/i, "")
14+
} catch {
15+
return ""
16+
}
17+
})()
18+
19+
function serverDisplayName(request: HttpServerRequest.HttpServerRequest) {
20+
if (SERVER_HOSTNAME) return SERVER_HOSTNAME
21+
const host = request.headers["host"] ?? ""
22+
return host.split(":")[0] ?? ""
23+
}
24+
25+
function escapeHtml(value: string) {
26+
return value.replace(/[&<>"']/g, (c) =>
27+
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
28+
)
29+
}
30+
31+
function rewriteTitle(html: string, name: string) {
32+
if (!name) return html
33+
return html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, `<title>${escapeHtml(name)} - OpenCode</title>`)
34+
}
35+
1036
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
1137
? Promise.resolve(null)
1238
: // @ts-expect-error - generated file at build time
@@ -70,11 +96,13 @@ function notFound() {
7096
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
7197
}
7298

73-
function embeddedUIResponse(file: string, body: Uint8Array) {
99+
function embeddedUIResponse(file: string, body: Uint8Array, name: string) {
74100
const mime = AppFileSystem.mimeType(file)
75101
const headers = new Headers({ "content-type": mime })
76102
if (mime.startsWith("text/html")) {
77-
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
103+
const rewritten = rewriteTitle(new TextDecoder().decode(body), name)
104+
headers.set("content-security-policy", cspForHtml(rewritten))
105+
return HttpServerResponse.raw(new TextEncoder().encode(rewritten), { headers })
78106
}
79107
return HttpServerResponse.raw(body, { headers })
80108
}
@@ -83,14 +111,15 @@ export function serveEmbeddedUIEffect(
83111
requestPath: string,
84112
fs: AppFileSystem.Interface,
85113
embeddedWebUI: Record<string, string>,
114+
name: string,
86115
) {
87116
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
88117
if (!file) return Effect.succeed(notFound())
89118

90119
const resolved = embeddedUIFile(file)
91120

92121
return fs.readFile(resolved).pipe(
93-
Effect.map((body) => embeddedUIResponse(resolved, body)),
122+
Effect.map((body) => embeddedUIResponse(resolved, body, name)),
94123
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
95124
)
96125
}
@@ -99,13 +128,14 @@ function serveLocalDirEffect(
99128
requestPath: string,
100129
fs: AppFileSystem.Interface,
101130
dir: string,
131+
name: string,
102132
) {
103133
const filePath = path.join(dir, requestPath === "/" ? "index.html" : requestPath)
104134
return fs.readFile(filePath).pipe(
105-
Effect.map((body) => embeddedUIResponse(filePath, body)),
135+
Effect.map((body) => embeddedUIResponse(filePath, body, name)),
106136
Effect.catchReason("PlatformError", "NotFound", () =>
107137
fs.readFile(path.join(dir, "index.html")).pipe(
108-
Effect.map((body) => embeddedUIResponse(path.join(dir, "index.html"), body)),
138+
Effect.map((body) => embeddedUIResponse(path.join(dir, "index.html"), body, name)),
109139
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
110140
),
111141
),
@@ -119,11 +149,13 @@ export function serveUIEffect(
119149
return Effect.gen(function* () {
120150
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
121151
const requestPath = new URL(request.url, "http://localhost").pathname
152+
const name = serverDisplayName(request)
122153

123-
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(requestPath, services.fs, embeddedWebUI)
154+
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(requestPath, services.fs, embeddedWebUI, name)
124155

125156
// Dev mode: serve from local build directory if configured
126-
if (Flag.OPENCODE_DEV_UI_DIR) return yield* serveLocalDirEffect(requestPath, services.fs, Flag.OPENCODE_DEV_UI_DIR)
157+
if (Flag.OPENCODE_DEV_UI_DIR)
158+
return yield* serveLocalDirEffect(requestPath, services.fs, Flag.OPENCODE_DEV_UI_DIR, name)
127159

128160
const response = yield* services.client.execute(
129161
HttpClientRequest.make(request.method)(upstreamURL(requestPath), {
@@ -134,7 +166,7 @@ export function serveUIEffect(
134166
const headers = proxyResponseHeaders(response.headers)
135167

136168
if (response.headers["content-type"]?.includes("text/html")) {
137-
const body = yield* response.text
169+
const body = rewriteTitle(yield* response.text, name)
138170
headers.set("Content-Security-Policy", cspForHtml(body))
139171
return HttpServerResponse.text(body, { status: response.status, headers })
140172
}

packages/opencode/test/server/httpapi-ui.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ describe("HttpApi UI fallback", () => {
317317
},
318318
},
319319
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
320+
"",
320321
).pipe(Effect.map(HttpServerResponse.toWeb))
321322

322323
expect(response.status).toBe(200)
@@ -346,6 +347,7 @@ describe("HttpApi UI fallback", () => {
346347
},
347348
},
348349
{ "index.html": "/$bunfs/root/index.html" },
350+
"",
349351
).pipe(Effect.map(HttpServerResponse.toWeb))
350352

351353
const csp = response.headers.get("content-security-policy") ?? ""

0 commit comments

Comments
 (0)