opencode-webui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/assets/Inter.ttf +0 -0
- package/dist/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/dist/assets/index-C5HRLW8j.js +122 -0
- package/dist/assets/index-DUtdz9a2.css +1 -0
- package/dist/assets/opencode.svg +7 -0
- package/dist/assets/report-R1enHhQU.js +2 -0
- package/dist/assets/runtime-status-CWjwBTFm.js +1 -0
- package/dist/index.html +14 -0
- package/package.json +72 -0
- package/server/auth.ts +524 -0
- package/server/index.ts +626 -0
- package/server/skillSync.ts +49 -0
- package/server/userExtensions.ts +88 -0
- package/skills/webui/SKILL.md +122 -0
- package/ui-extensions/README.md +271 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* opencode-webui proxy server.
|
|
4
|
+
*
|
|
5
|
+
* The browser never talks to the opencode service directly. This Bun server
|
|
6
|
+
* discovers the background service (Service.ensure), attaches the auth
|
|
7
|
+
* headers, and proxies /api/* with streaming. In production it also serves
|
|
8
|
+
* the built frontend from dist/.
|
|
9
|
+
*
|
|
10
|
+
* Dev flow: vite (5173) --/api--> this server (4097) --> opencode service
|
|
11
|
+
* Prod flow: this server (4097) serves dist/ + proxies /api
|
|
12
|
+
*
|
|
13
|
+
* Access control (server/auth.ts): every route except the login round-trip
|
|
14
|
+
* requires a session cookie. WEBUI_PASSWORD sets the password; unset means a
|
|
15
|
+
* strong passphrase is generated and printed once — but only on a loopback
|
|
16
|
+
* bind, because a wildcard bind without a password refuses to start. The
|
|
17
|
+
* browser never holds service credentials, and neither the password nor
|
|
18
|
+
* session tokens are ever logged.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Service } from "@opencode-ai/client/service";
|
|
22
|
+
import type { Server } from "bun";
|
|
23
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { appendFile } from "node:fs/promises";
|
|
25
|
+
import { basename, dirname, join } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import {
|
|
28
|
+
guardRequest,
|
|
29
|
+
handleLogin,
|
|
30
|
+
isAuthed,
|
|
31
|
+
isLoopbackHostname,
|
|
32
|
+
loadSecret,
|
|
33
|
+
loginPageResponse,
|
|
34
|
+
logoutResponse,
|
|
35
|
+
peerIP,
|
|
36
|
+
resolveAuthPolicy,
|
|
37
|
+
unauthorizedResponse,
|
|
38
|
+
} from "./auth";
|
|
39
|
+
import { syncSkill } from "./skillSync";
|
|
40
|
+
import {
|
|
41
|
+
discoverUserUIEntries,
|
|
42
|
+
globalUserExtensionsDir,
|
|
43
|
+
warnOnce,
|
|
44
|
+
type UIEntry,
|
|
45
|
+
} from "./userExtensions";
|
|
46
|
+
|
|
47
|
+
const PROXY_PORT = Number(process.env.WEBUI_PROXY_PORT ?? 4097);
|
|
48
|
+
const HOST = process.env.WEBUI_HOST ?? "127.0.0.1";
|
|
49
|
+
// Bun binds 0.0.0.0 by default; keep the safe loopback default and only pass
|
|
50
|
+
// through what the operator actually asked for ("localhost" binds 127.0.0.1).
|
|
51
|
+
const BIND_HOST = HOST === "localhost" ? "127.0.0.1" : HOST;
|
|
52
|
+
// fileURLToPath, not .pathname — .pathname yields "/C:/..." on Windows and
|
|
53
|
+
// breaks every join; inside a --compile binary this stays a virtual /$bunfs
|
|
54
|
+
// path that scripts/embed-shim.ts maps onto the embedded assets.
|
|
55
|
+
const DIST_DIR = fileURLToPath(new URL("../dist/", import.meta.url));
|
|
56
|
+
const APP_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
57
|
+
const DEBUG_LOG = process.env.WEBUI_DEBUG_LOG ?? "/tmp/webui-debug.log";
|
|
58
|
+
const DEBUG = Bun.env.WEBUI_DEBUG === "1";
|
|
59
|
+
const REPORT_REPO = process.env.WEBUI_REPORT_REPO ?? "AbdelftahZowail/opencode-webui";
|
|
60
|
+
|
|
61
|
+
function dbg(...args: unknown[]) {
|
|
62
|
+
if (!DEBUG) return;
|
|
63
|
+
console.log("[webui]", ...args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeDebug(lines: unknown[]) {
|
|
67
|
+
const text = lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n");
|
|
68
|
+
try {
|
|
69
|
+
await appendFile(DEBUG_LOG, text + "\n", "utf8");
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error("[webui] debug log write failed:", err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let endpoint: Awaited<ReturnType<typeof Service.ensure>> | null = null;
|
|
76
|
+
|
|
77
|
+
async function serviceEndpoint() {
|
|
78
|
+
if (!endpoint) {
|
|
79
|
+
endpoint = await Service.ensure();
|
|
80
|
+
console.log(`[webui] connected to opencode service at ${endpoint.url}`);
|
|
81
|
+
}
|
|
82
|
+
return endpoint;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Live-event recorder (catch-up for late-joining browsers).
|
|
87
|
+
//
|
|
88
|
+
// The engine serves NO mid-stream text over REST (verified: part skeletons
|
|
89
|
+
// appear with text:0 until each part ends) and a FRESH /api/event
|
|
90
|
+
// subscription receives only future events — so a browser that attaches,
|
|
91
|
+
// reloads or reconnects mid-run loses everything since the run started (the
|
|
92
|
+
// "stream starts 5-15s late" bug). The TUI never detaches; the browser does.
|
|
93
|
+
//
|
|
94
|
+
// This proxy is the always-on background: it holds ONE service-side event
|
|
95
|
+
// subscription of its own and keeps a bounded per-session ring buffer of
|
|
96
|
+
// recent session-scoped events. Browsers fetch
|
|
97
|
+
// GET /api/webui/replay?sessionID=X[&since=<eventID>]
|
|
98
|
+
// on session join and on (re)connect and feed the events through the exact
|
|
99
|
+
// same reducer path — id-based dedupe (seenEventIDs) and overlap-safe delta
|
|
100
|
+
// appends (appendStreamDelta) make replaying already-seen events harmless.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const RECORDER_MAX_EVENTS = 400; // per session
|
|
104
|
+
const RECORDER_MAX_BYTES = 512 * 1024; // per session
|
|
105
|
+
const RECORDER_SESSION_TTL_MS = 10 * 60_000; // idle sessions drop after this
|
|
106
|
+
const RECORDER_MAX_SESSIONS = 60;
|
|
107
|
+
const RECORDER_SEEN_MAX = 20_000; // engine-replay dedupe window
|
|
108
|
+
|
|
109
|
+
// `bytes` is the event's JSON size, computed ONCE at record time — the ring
|
|
110
|
+
// caps used to re-stringify on every push AND every drop. (Served replay
|
|
111
|
+
// payloads carry the extra field; the client picks only known fields.)
|
|
112
|
+
type RecordedEvent = { id: string; created: number; type: string; data: unknown; bytes: number };
|
|
113
|
+
const replayBuffers = new Map<string, { events: RecordedEvent[]; bytes: number; lastAt: number }>();
|
|
114
|
+
const recorderSeenIds = new Set<string>();
|
|
115
|
+
|
|
116
|
+
function recordEvent(evt: RecordedEvent) {
|
|
117
|
+
const sessionID = (evt.data as { sessionID?: string } | undefined)?.sessionID;
|
|
118
|
+
if (!sessionID) return;
|
|
119
|
+
// Engine replays overlap on reconnect — dedupe by event id (bounded).
|
|
120
|
+
if (evt.id) {
|
|
121
|
+
if (recorderSeenIds.has(evt.id)) return;
|
|
122
|
+
recorderSeenIds.add(evt.id);
|
|
123
|
+
if (recorderSeenIds.size > RECORDER_SEEN_MAX) recorderSeenIds.clear();
|
|
124
|
+
}
|
|
125
|
+
let buf = replayBuffers.get(sessionID);
|
|
126
|
+
if (!buf) {
|
|
127
|
+
// Hard cap on tracked sessions; drop the least recently active.
|
|
128
|
+
if (replayBuffers.size >= RECORDER_MAX_SESSIONS) {
|
|
129
|
+
let oldestKey: string | null = null;
|
|
130
|
+
let oldestAt = Infinity;
|
|
131
|
+
for (const [k, v] of replayBuffers) {
|
|
132
|
+
if (v.lastAt < oldestAt) {
|
|
133
|
+
oldestAt = v.lastAt;
|
|
134
|
+
oldestKey = k;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (oldestKey) replayBuffers.delete(oldestKey);
|
|
138
|
+
}
|
|
139
|
+
buf = { events: [], bytes: 0, lastAt: Date.now() };
|
|
140
|
+
replayBuffers.set(sessionID, buf);
|
|
141
|
+
}
|
|
142
|
+
const entry: RecordedEvent = { ...evt, bytes: JSON.stringify(evt).length };
|
|
143
|
+
buf.events.push(entry);
|
|
144
|
+
buf.bytes += entry.bytes;
|
|
145
|
+
buf.lastAt = Date.now();
|
|
146
|
+
// Ring caps: newest wins.
|
|
147
|
+
while (buf.events.length > RECORDER_MAX_EVENTS || buf.bytes > RECORDER_MAX_BYTES) {
|
|
148
|
+
const dropped = buf.events.shift();
|
|
149
|
+
if (!dropped) break;
|
|
150
|
+
buf.bytes -= dropped.bytes;
|
|
151
|
+
}
|
|
152
|
+
// TTL prune (cheap: on insert, only when the map is large).
|
|
153
|
+
if (replayBuffers.size > 8) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
for (const [k, v] of replayBuffers) {
|
|
156
|
+
if (now - v.lastAt > RECORDER_SESSION_TTL_MS) replayBuffers.delete(k);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let recorderRunning = false;
|
|
162
|
+
async function startEventRecorder() {
|
|
163
|
+
if (recorderRunning) return;
|
|
164
|
+
recorderRunning = true;
|
|
165
|
+
void (async () => {
|
|
166
|
+
for (;;) {
|
|
167
|
+
try {
|
|
168
|
+
const ep = await serviceEndpoint();
|
|
169
|
+
const res = await fetch(`${ep.url}/api/event`, { headers: Service.headers(ep) });
|
|
170
|
+
if (!res.ok || !res.body) throw new Error(`recorder: ${res.status}`);
|
|
171
|
+
console.log("[webui] event recorder connected");
|
|
172
|
+
const reader = res.body.getReader();
|
|
173
|
+
const decoder = new TextDecoder();
|
|
174
|
+
let buffer = "";
|
|
175
|
+
for (;;) {
|
|
176
|
+
const { done, value } = await reader.read();
|
|
177
|
+
if (done) break;
|
|
178
|
+
buffer += decoder.decode(value, { stream: true });
|
|
179
|
+
const lines = buffer.split("\n");
|
|
180
|
+
buffer = lines.pop() ?? "";
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(trimmed.slice("data:".length).trim()) as RecordedEvent;
|
|
186
|
+
if (typeof parsed.type === "string" && parsed.type.startsWith("session.")) {
|
|
187
|
+
recordEvent(parsed);
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* malformed line — skip */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.warn("[webui] event recorder dropped, reconnecting:", err instanceof Error ? err.message : err);
|
|
196
|
+
// The service may have restarted with a NEW url — re-discover.
|
|
197
|
+
endpoint = null;
|
|
198
|
+
}
|
|
199
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
200
|
+
}
|
|
201
|
+
})();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Plugin web-UI extensions.
|
|
206
|
+
//
|
|
207
|
+
// opencode v2 plugins may ship an optional BROWSER half next to their server
|
|
208
|
+
// code. The engine lists loaded plugins at GET /plugin ({ location,
|
|
209
|
+
// data: PluginInfo[] }). For every LOCAL-source plugin we probe two UI entry
|
|
210
|
+
// candidates next to it — `<dir>/ui/main.tsx`, then
|
|
211
|
+
// `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into a
|
|
212
|
+
// self-contained ESM script (it carries its own React and talks to this app
|
|
213
|
+
// only through the window.__opencodeUI bridge), and serve:
|
|
214
|
+
//
|
|
215
|
+
// GET /api/webui/extensions -> { data: [{ id, url, source }] }
|
|
216
|
+
// GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache
|
|
217
|
+
//
|
|
218
|
+
// Both routes ride the same session auth as every other /api call (the
|
|
219
|
+
// upstream plugin list is fetched with Service.headers) and register BEFORE
|
|
220
|
+
// the generic /api passthrough below. `source` in the listing is the bundled
|
|
221
|
+
// UI entry path; `url`'s ?v= is that file's mtime so edits bust caches.
|
|
222
|
+
// v1 limitation: package/builtin/sdk sources have nothing on disk to bundle
|
|
223
|
+
// and are ignored.
|
|
224
|
+
//
|
|
225
|
+
// USER extension dirs (server/userExtensions.ts) merge into the same
|
|
226
|
+
// manifest below — same pipeline, `source: "user:<path>"`.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
type PluginSource =
|
|
230
|
+
| { type: "local"; path: string }
|
|
231
|
+
| { type: "package"; package?: string }
|
|
232
|
+
| { type: "builtin" }
|
|
233
|
+
| { type: "sdk" };
|
|
234
|
+
|
|
235
|
+
type PluginInfo = {
|
|
236
|
+
id?: string; // absent on status:"failed" entries
|
|
237
|
+
source: PluginSource;
|
|
238
|
+
status: "active" | "failed";
|
|
239
|
+
error?: string;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const EXTENSION_LIST_TTL_MS = 5_000;
|
|
243
|
+
let uiEntryCache: { at: number; entries: UIEntry[] } | null = null;
|
|
244
|
+
|
|
245
|
+
// entry path -> bundle, rebuilt only when the entry's mtime moves
|
|
246
|
+
const bundleCache = new Map<string, { mtimeMs: number; js: string }>();
|
|
247
|
+
|
|
248
|
+
function uiEntryCandidates(pluginPath: string): string[] {
|
|
249
|
+
const dir = dirname(pluginPath);
|
|
250
|
+
const base = basename(pluginPath).replace(/\.[^.]+$/, "");
|
|
251
|
+
return [join(dir, "ui", "main.tsx"), join(dir, `${base}.ui.tsx`)];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Active local plugins' UI entries. Cached 5s; any upstream failure -> []. */
|
|
255
|
+
async function discoverUIEntries(): Promise<UIEntry[]> {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
if (uiEntryCache && now - uiEntryCache.at < EXTENSION_LIST_TTL_MS) {
|
|
258
|
+
return uiEntryCache.entries;
|
|
259
|
+
}
|
|
260
|
+
const entries: UIEntry[] = [];
|
|
261
|
+
try {
|
|
262
|
+
const ep = await serviceEndpoint();
|
|
263
|
+
const upstream = await fetch(`${ep.url}/api/plugin`, { headers: Service.headers(ep) });
|
|
264
|
+
if (!upstream.ok) throw new Error(`GET ${ep.url}/api/plugin -> ${upstream.status}`);
|
|
265
|
+
const body = (await upstream.json()) as { data?: PluginInfo[] };
|
|
266
|
+
for (const plugin of body.data ?? []) {
|
|
267
|
+
// v1: disk sources only. Failed engine-halves still get their UI half
|
|
268
|
+
// served: a plugin can fail to LOAD server-side (missing deps, bad
|
|
269
|
+
// schema) while its UI is perfectly fine — failed entries carry no id,
|
|
270
|
+
// so one is derived from the file basename.
|
|
271
|
+
if (plugin.source.type !== "local") continue;
|
|
272
|
+
const srcPath = plugin.source.path;
|
|
273
|
+
try {
|
|
274
|
+
const fallbackId = basename(srcPath).replace(/\.[^.]+$/, "");
|
|
275
|
+
const entry = uiEntryCandidates(srcPath).find((c) => existsSync(c));
|
|
276
|
+
if (!entry) continue;
|
|
277
|
+
entries.push({
|
|
278
|
+
id: plugin.id ?? fallbackId,
|
|
279
|
+
entry,
|
|
280
|
+
mtimeMs: statSync(entry).mtimeMs,
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.error(`[webui] plugin "${plugin.id ?? srcPath}" skipped during ui discovery:`, err);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// Never throw to callers — an unreachable engine just means no extensions.
|
|
288
|
+
console.error("[webui] plugin ui discovery failed:", err instanceof Error ? err.message : err);
|
|
289
|
+
}
|
|
290
|
+
uiEntryCache = { at: now, entries };
|
|
291
|
+
return entries;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Plugin UI entries + user-dir entries, plugin ids winning collisions (a
|
|
296
|
+
* user folder may never shadow a plugin's UI — that's warned about once).
|
|
297
|
+
*/
|
|
298
|
+
async function discoverAllUIEntries(): Promise<UIEntry[]> {
|
|
299
|
+
const pluginEntries = await discoverUIEntries();
|
|
300
|
+
const userEntries = discoverUserUIEntries();
|
|
301
|
+
if (userEntries.length === 0) return pluginEntries;
|
|
302
|
+
const ids = new Set(pluginEntries.map((e) => e.id));
|
|
303
|
+
const merged = [...pluginEntries];
|
|
304
|
+
for (const user of userEntries) {
|
|
305
|
+
if (ids.has(user.id)) {
|
|
306
|
+
warnOnce(`collide:${user.id}`, `user extension "${user.id}" skipped — a plugin already owns that id`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
merged.push(user);
|
|
310
|
+
}
|
|
311
|
+
return merged;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Bundled JS for a UI entry, cached by mtime so an edit costs one rebuild. */
|
|
315
|
+
async function bundleUIEntry(entry: string): Promise<string> {
|
|
316
|
+
const mtimeMs = statSync(entry).mtimeMs;
|
|
317
|
+
const cached = bundleCache.get(entry);
|
|
318
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.js;
|
|
319
|
+
const built = await Bun.build({
|
|
320
|
+
entrypoints: [entry],
|
|
321
|
+
target: "browser",
|
|
322
|
+
format: "esm",
|
|
323
|
+
minify: false,
|
|
324
|
+
// Plugin dirs have no node_modules — resolve React from THIS app so every
|
|
325
|
+
// runtime bundle shares one React copy. JSX dev/runtime variants included.
|
|
326
|
+
plugins: [
|
|
327
|
+
{
|
|
328
|
+
name: "react-from-app",
|
|
329
|
+
setup(build) {
|
|
330
|
+
build.onResolve({ filter: /^react(\/jsx-runtime|\/jsx-dev-runtime)?$/ }, (args) => ({
|
|
331
|
+
path: join(
|
|
332
|
+
APP_ROOT,
|
|
333
|
+
"node_modules",
|
|
334
|
+
"react",
|
|
335
|
+
args.path === "react" ? "index.js" : `${args.path.slice("react/".length)}.js`,
|
|
336
|
+
),
|
|
337
|
+
}));
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
});
|
|
342
|
+
const artifact =
|
|
343
|
+
built.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js")) ??
|
|
344
|
+
built.outputs.find((o) => o.path.endsWith(".js"));
|
|
345
|
+
if (!artifact) throw new Error(`bun.build produced no js artifact for ${entry}`);
|
|
346
|
+
const js = await artifact.text();
|
|
347
|
+
bundleCache.set(entry, { mtimeMs, js });
|
|
348
|
+
return js;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Boot: CLI flag → auth policy → skill sync → serve → banner.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** `--install-skill`: copy the skill and exit without starting the server. */
|
|
356
|
+
if (process.argv.includes("--install-skill")) {
|
|
357
|
+
const result = await syncSkill();
|
|
358
|
+
if (result.ok) console.log(`[webui] skill installed at ${result.target}`);
|
|
359
|
+
else console.error(`[webui] skill install failed: ${result.reason}`);
|
|
360
|
+
process.exit(result.ok ? 0 : 1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Exits with a clear message when a wildcard bind has no WEBUI_PASSWORD.
|
|
364
|
+
const AUTH = resolveAuthPolicy(HOST);
|
|
365
|
+
const SECRET = loadSecret();
|
|
366
|
+
const SKILL = await syncSkill(); // best-effort — never blocks the banner below it
|
|
367
|
+
|
|
368
|
+
function readVersion(): string {
|
|
369
|
+
try {
|
|
370
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
371
|
+
version?: string;
|
|
372
|
+
};
|
|
373
|
+
return pkg.version ?? "0.0.0";
|
|
374
|
+
} catch {
|
|
375
|
+
return "0.0.0";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const PKG_VERSION = readVersion();
|
|
379
|
+
|
|
380
|
+
const server: Server<Record<string, unknown>> = Bun.serve({
|
|
381
|
+
port: PROXY_PORT,
|
|
382
|
+
hostname: BIND_HOST,
|
|
383
|
+
// Bun's default idleTimeout (10s) kills any socket silent for 10s. The
|
|
384
|
+
// engine heartbeats /api/event every 15s, so an idle session's connection
|
|
385
|
+
// is guaranteed to die before the next heartbeat — that WAS the "SSE
|
|
386
|
+
// wedge" (instrumented 2026-08-31: bun c#N killed exactly 10s after the
|
|
387
|
+
// last byte, vite never told, browser fuse fired at 20s). It also broke
|
|
388
|
+
// the session.wait long-polls (silent for minutes). Liveness here is
|
|
389
|
+
// owned by engine heartbeats + browser fuse + req.signal aborts — not a
|
|
390
|
+
// socket timer — so disable it.
|
|
391
|
+
idleTimeout: 0,
|
|
392
|
+
async fetch(req, bunServer) {
|
|
393
|
+
const url = new URL(req.url);
|
|
394
|
+
const method = req.method;
|
|
395
|
+
const path = url.pathname;
|
|
396
|
+
|
|
397
|
+
// DNS-rebinding + cross-origin guard — before ANY route, login included.
|
|
398
|
+
const guarded = guardRequest(req, HOST);
|
|
399
|
+
if (guarded) return guarded;
|
|
400
|
+
|
|
401
|
+
// The unauthenticated surface: login page, login POST, logout.
|
|
402
|
+
if (method === "GET" && path === "/login") return loginPageResponse(url);
|
|
403
|
+
if (method === "POST" && path === "/api/auth/login") {
|
|
404
|
+
return handleLogin(req, peerIP(req, bunServer), SECRET, AUTH.digest);
|
|
405
|
+
}
|
|
406
|
+
if (method === "GET" && path === "/api/auth/logout") return logoutResponse();
|
|
407
|
+
|
|
408
|
+
// Everything below — /api/* (JSON 401), pages, dist/ static, SSE, and
|
|
409
|
+
// WebSocket upgrades — requires a valid session cookie.
|
|
410
|
+
if (!isAuthed(req, SECRET)) return unauthorizedResponse(url);
|
|
411
|
+
|
|
412
|
+
if (method === "GET" && path === "/api/webui/status") {
|
|
413
|
+
try {
|
|
414
|
+
const ep = await serviceEndpoint();
|
|
415
|
+
return Response.json({ ok: true, service: ep.url });
|
|
416
|
+
} catch (err) {
|
|
417
|
+
return Response.json(
|
|
418
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
419
|
+
{ status: 503 },
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Proxy metadata: app version + where to report issues.
|
|
425
|
+
if (method === "GET" && path === "/api/webui/config") {
|
|
426
|
+
return Response.json({ version: PKG_VERSION, reportRepo: REPORT_REPO });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (method === "POST" && path === "/api/debug") {
|
|
430
|
+
// Frontend log sink — append to the debug file, never forwarded.
|
|
431
|
+
try {
|
|
432
|
+
const body = (await req.json()) as unknown;
|
|
433
|
+
const lines = Array.isArray(body) ? body : [body];
|
|
434
|
+
void writeDebug(lines);
|
|
435
|
+
dbg("debug log:", lines.length, "line(s) ->", DEBUG_LOG);
|
|
436
|
+
return new Response("ok");
|
|
437
|
+
} catch (err) {
|
|
438
|
+
return Response.json({ error: String(err) }, { status: 400 });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Live-event replay — proxy-local (the engine has no such route), serves
|
|
443
|
+
// the recorder's ring buffer so a late-joining browser can catch up.
|
|
444
|
+
if (method === "GET" && path === "/api/webui/replay") {
|
|
445
|
+
const sessionID = url.searchParams.get("sessionID");
|
|
446
|
+
if (!sessionID) return Response.json({ error: "sessionID required" }, { status: 400 });
|
|
447
|
+
const since = url.searchParams.get("since") ?? "";
|
|
448
|
+
const buf = replayBuffers.get(sessionID);
|
|
449
|
+
let events = buf?.events ?? [];
|
|
450
|
+
if (since) {
|
|
451
|
+
const idx = events.findIndex((e) => e.id === since);
|
|
452
|
+
if (idx >= 0) events = events.slice(idx + 1);
|
|
453
|
+
}
|
|
454
|
+
dbg("replay:", sessionID, `${events.length} event(s)`);
|
|
455
|
+
return Response.json({ data: events });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Plugin + user web-UI extensions — must match before the generic /api proxy.
|
|
459
|
+
if (method === "GET" && path === "/api/webui/extensions") {
|
|
460
|
+
// discoverAllUIEntries never throws; upstream failures collapse to { data: [] }.
|
|
461
|
+
const entries = await discoverAllUIEntries();
|
|
462
|
+
dbg("extensions list:", entries.length, "ui entr(ies)");
|
|
463
|
+
return Response.json({
|
|
464
|
+
data: entries.map((e) => ({
|
|
465
|
+
id: e.id,
|
|
466
|
+
source: e.source ?? e.entry,
|
|
467
|
+
url: `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`,
|
|
468
|
+
})),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/bundle\.js$/.test(path)) {
|
|
473
|
+
const id = decodeURIComponent(path.split("/")[4] ?? "");
|
|
474
|
+
try {
|
|
475
|
+
// Resolve through the CURRENT discovery result so removed/expired
|
|
476
|
+
// plugins 404 instead of serving a stale bundle.
|
|
477
|
+
const found = (await discoverAllUIEntries()).find((e) => e.id === id);
|
|
478
|
+
if (!found || !existsSync(found.entry)) {
|
|
479
|
+
return Response.json({ error: `unknown extension: ${id}` }, { status: 404 });
|
|
480
|
+
}
|
|
481
|
+
const js = await bundleUIEntry(found.entry); // throws -> 500 below
|
|
482
|
+
dbg("extensions bundle:", id, `${js.length}b`);
|
|
483
|
+
return new Response(js, {
|
|
484
|
+
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
485
|
+
});
|
|
486
|
+
} catch (err) {
|
|
487
|
+
console.error(`[webui] extension bundle "${id}" failed:`, err);
|
|
488
|
+
return Response.json({ error: `bundle failed for ${id}` }, { status: 500 });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (path.startsWith("/api")) {
|
|
493
|
+
const isUpgrade = (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
|
|
494
|
+
if (isUpgrade) {
|
|
495
|
+
try {
|
|
496
|
+
const ep = await serviceEndpoint();
|
|
497
|
+
const headers = Service.headers(ep);
|
|
498
|
+
const upgraded = server.upgrade(req, {
|
|
499
|
+
data: { url: `${ep.url}${path}${url.search}`, headers },
|
|
500
|
+
});
|
|
501
|
+
dbg("ws upgrade:", path);
|
|
502
|
+
if (upgraded) return undefined;
|
|
503
|
+
} catch (err) {
|
|
504
|
+
console.error("[webui] ws upgrade error:", err);
|
|
505
|
+
return Response.json({ error: String(err) }, { status: 502 });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const t0 = Date.now();
|
|
509
|
+
try {
|
|
510
|
+
const ep = await serviceEndpoint();
|
|
511
|
+
const headers = Service.headers(ep);
|
|
512
|
+
const upstream: Response = await fetch(`${ep.url}${path}${url.search}`, {
|
|
513
|
+
method,
|
|
514
|
+
// Abort the upstream request when the browser client goes away,
|
|
515
|
+
// otherwise streamed responses (SSE) leak one connection per
|
|
516
|
+
// client reconnect until the pool wedges and requests hang.
|
|
517
|
+
signal: req.signal,
|
|
518
|
+
headers: {
|
|
519
|
+
...headers,
|
|
520
|
+
...Object.fromEntries(
|
|
521
|
+
[...req.headers.entries()].filter(
|
|
522
|
+
([k]) =>
|
|
523
|
+
!["host", "connection", "upgrade", "accept-encoding"].includes(
|
|
524
|
+
k.toLowerCase(),
|
|
525
|
+
),
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
// Force identity from the engine: it brotli/gzip-compresses at
|
|
529
|
+
// least the experimental session-log endpoint with a stream the
|
|
530
|
+
// browser fails to decode (BrotliDecompressionError), and
|
|
531
|
+
// compressed SSE would buffer idle heartbeats anyway. Loopback
|
|
532
|
+
// hops gain nothing from compression — never request it.
|
|
533
|
+
"accept-encoding": "identity",
|
|
534
|
+
},
|
|
535
|
+
body: ["GET", "HEAD"].includes(method) ? undefined : req.body,
|
|
536
|
+
redirect: "manual",
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const responseHeaders = new Headers(upstream.headers);
|
|
540
|
+
const contentType = upstream.headers.get("content-type") ?? "";
|
|
541
|
+
if (!contentType.includes("text/event-stream")) {
|
|
542
|
+
responseHeaders.delete("content-encoding");
|
|
543
|
+
}
|
|
544
|
+
dbg("proxy:", method, path, "->", upstream.status, `${Date.now() - t0}ms`);
|
|
545
|
+
return new Response(upstream.body, {
|
|
546
|
+
status: upstream.status,
|
|
547
|
+
headers: responseHeaders,
|
|
548
|
+
});
|
|
549
|
+
} catch (err) {
|
|
550
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
551
|
+
console.error("[webui] proxy error:", message);
|
|
552
|
+
return Response.json({ error: message }, { status: 502 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (Bun.env.NODE_ENV === "production") {
|
|
557
|
+
if (method === "GET" || method === "HEAD") {
|
|
558
|
+
let filePath = decodeURIComponent(path);
|
|
559
|
+
if (filePath === "/") filePath = "/index.html";
|
|
560
|
+
const file = Bun.file(DIST_DIR + filePath.slice(1));
|
|
561
|
+
if (await file.exists()) return new Response(file);
|
|
562
|
+
const index = Bun.file(DIST_DIR + "index.html");
|
|
563
|
+
if (await index.exists()) return new Response(index);
|
|
564
|
+
}
|
|
565
|
+
return new Response("not found", { status: 404 });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return new Response("webui dev server: use vite (port 5173)", {
|
|
569
|
+
status: 200,
|
|
570
|
+
headers: { "content-type": "text/plain" },
|
|
571
|
+
});
|
|
572
|
+
},
|
|
573
|
+
websocket: {
|
|
574
|
+
open(ws) {
|
|
575
|
+
const data = ws.data as unknown as { url: string; headers: Record<string, string>; upstream?: WebSocket; pending?: unknown[] };
|
|
576
|
+
try {
|
|
577
|
+
const upstream = new WebSocket(data.url, { headers: data.headers } as unknown as string[]);
|
|
578
|
+
data.upstream = upstream;
|
|
579
|
+
data.pending = [];
|
|
580
|
+
upstream.onopen = () => {
|
|
581
|
+
const pending = data.pending ?? [];
|
|
582
|
+
data.pending = [];
|
|
583
|
+
for (const msg of pending) upstream.send(msg as Parameters<WebSocket["send"]>[0]);
|
|
584
|
+
};
|
|
585
|
+
upstream.onmessage = (e) => {
|
|
586
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(e.data as string | ArrayBuffer);
|
|
587
|
+
};
|
|
588
|
+
upstream.onclose = () => {
|
|
589
|
+
if (ws.readyState === WebSocket.OPEN) ws.close();
|
|
590
|
+
};
|
|
591
|
+
upstream.onerror = () => {
|
|
592
|
+
if (ws.readyState === WebSocket.OPEN) ws.close();
|
|
593
|
+
};
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error("[webui] ws upstream error:", err);
|
|
596
|
+
ws.close();
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
message(ws, msg) {
|
|
600
|
+
const data = ws.data as unknown as { upstream?: WebSocket; pending?: unknown[] };
|
|
601
|
+
const upstream = data.upstream;
|
|
602
|
+
if (upstream?.readyState === WebSocket.OPEN) upstream.send(msg);
|
|
603
|
+
else if (upstream && upstream.readyState === WebSocket.CONNECTING) data.pending!.push(msg);
|
|
604
|
+
},
|
|
605
|
+
close(ws) {
|
|
606
|
+
const data = ws.data as unknown as { upstream?: WebSocket };
|
|
607
|
+
data.upstream?.close();
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// First-boot banner — the entire onboarding. The generated password is
|
|
613
|
+
// printed exactly once and never logged anywhere else.
|
|
614
|
+
const displayHost = isLoopbackHostname(HOST === "localhost" ? "localhost" : HOST) ? "localhost" : HOST;
|
|
615
|
+
console.log(
|
|
616
|
+
[
|
|
617
|
+
`[webui] ready → http://${displayHost}:${server.port}`,
|
|
618
|
+
`[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
|
|
619
|
+
`[webui] same sessions as your opencode TUI — it's the same engine`,
|
|
620
|
+
`[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/main.tsx`,
|
|
621
|
+
SKILL.ok
|
|
622
|
+
? `[webui] agent skill installed at ${SKILL.target} (auto-synced each boot)`
|
|
623
|
+
: `[webui] agent skill NOT synced: ${SKILL.reason}`,
|
|
624
|
+
].join("\n"),
|
|
625
|
+
);
|
|
626
|
+
void startEventRecorder();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
/**
|
|
3
|
+
* Skill auto-sync: the repo's skills/webui/SKILL.md is the agent-side manual
|
|
4
|
+
* for driving this webui. It is copied into the user's opencode skill dir on
|
|
5
|
+
* every boot (overwrite = sync, so repo edits propagate) and can be installed
|
|
6
|
+
* standalone via `--install-skill`. Failures are reported, never fatal — a
|
|
7
|
+
* missing/readonly skill must not take the proxy down with it.
|
|
8
|
+
*
|
|
9
|
+
* The source is read through Bun.file, not node:fs: on a real checkout that is
|
|
10
|
+
* an ordinary disk path, but inside a `bun build --compile` binary SOURCE is a
|
|
11
|
+
* virtual "/$bunfs/..." path that node:fs cannot see — scripts/embed-shim.ts
|
|
12
|
+
* maps it onto the copy embedded in the executable (scripts/embed-dist.ts
|
|
13
|
+
* embeds skills/webui/SKILL.md alongside the dist/ assets).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Lives next to the server source so it works from any cwd. */
|
|
21
|
+
const SOURCE = fileURLToPath(new URL("../skills/webui/SKILL.md", import.meta.url));
|
|
22
|
+
|
|
23
|
+
export function skillTargetPath(): string {
|
|
24
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
25
|
+
return join(base, "opencode", "skills", "webui", "SKILL.md");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SkillSyncResult = { ok: boolean; target: string; reason?: string };
|
|
29
|
+
|
|
30
|
+
export async function syncSkill(): Promise<SkillSyncResult> {
|
|
31
|
+
const target = skillTargetPath();
|
|
32
|
+
try {
|
|
33
|
+
let text: string;
|
|
34
|
+
try {
|
|
35
|
+
text = await Bun.file(SOURCE).text();
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false, target, reason: `source missing: ${SOURCE}` };
|
|
38
|
+
}
|
|
39
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
40
|
+
writeFileSync(target, text);
|
|
41
|
+
return { ok: true, target };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
target,
|
|
46
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|