opencode-webui 1.0.9 → 2.1.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/README.md +21 -8
- package/dist/assets/brother-agent-C1lqxeTh.js +1 -0
- package/dist/assets/groq-voice-m-OaABoC.js +1 -0
- package/dist/assets/index-Bs1cKqOa.js +129 -0
- package/dist/assets/index-D0Y4JI3u.css +1 -0
- package/dist/assets/jsx-runtime-B-hcVAMW.js +1 -0
- package/dist/assets/report-CczF_91u.js +2 -0
- package/dist/assets/rich-render-C-7ieXZz.js +1 -0
- package/dist/index.html +17 -2
- package/package.json +3 -3
- package/server/ext/engine.ts +161 -0
- package/server/ext/kv.ts +81 -0
- package/server/ext/registry.ts +279 -0
- package/server/ext/types.ts +124 -0
- package/server/index.ts +433 -58
- package/server/userExtensions.ts +175 -48
- package/skills/webui/SKILL.md +118 -97
- package/webui-extensions/README.md +462 -0
- package/dist/assets/index-B7R1vNdF.css +0 -1
- package/dist/assets/index-BV-oA2S9.js +0 -128
- package/dist/assets/report-nsiIAZ9q.js +0 -2
- package/ui-extensions/README.md +0 -285
package/server/index.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { Service } from "@opencode-ai/client/service";
|
|
22
22
|
import type { Server } from "bun";
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, statSync, watch, appendFileSync } from "node:fs";
|
|
24
24
|
import { appendFile } from "node:fs/promises";
|
|
25
25
|
import { basename, dirname, join, resolve } from "node:path";
|
|
26
26
|
import { homedir } from "node:os";
|
|
@@ -41,10 +41,20 @@ import {
|
|
|
41
41
|
import { syncSkill } from "./skillSync";
|
|
42
42
|
import {
|
|
43
43
|
discoverUserUIEntries,
|
|
44
|
+
extensionSourceRoots,
|
|
44
45
|
globalUserExtensionsDir,
|
|
46
|
+
invalidateExtensionCache,
|
|
45
47
|
warnOnce,
|
|
46
48
|
type UIEntry,
|
|
47
49
|
} from "./userExtensions";
|
|
50
|
+
import {
|
|
51
|
+
applyExtResponseMiddleware,
|
|
52
|
+
dispatchExtEvent,
|
|
53
|
+
dispatchExtRequest,
|
|
54
|
+
runExtRequestMiddleware,
|
|
55
|
+
startExtModules,
|
|
56
|
+
} from "./ext/registry";
|
|
57
|
+
import { resolveEngineOverride } from "./ext/engine";
|
|
48
58
|
|
|
49
59
|
// `sandbox` argv — one command, every runtime: `bun run sandbox` (repo, the
|
|
50
60
|
// script adds Vite), `bunx opencode-webui sandbox`, `./opencode-webui sandbox`
|
|
@@ -107,6 +117,18 @@ async function writeDebug(lines: unknown[]) {
|
|
|
107
117
|
let endpoint: Awaited<ReturnType<typeof Service.ensure>> | null = null;
|
|
108
118
|
|
|
109
119
|
async function serviceEndpoint() {
|
|
120
|
+
// Explicit env wins: WEBUI_ENGINE_URL aims the proxy at a chosen engine.
|
|
121
|
+
// An override URL also SKIPS Service.ensure() — no spawn from a stale
|
|
122
|
+
// service.json pid (the rogue-serve incident), no version-kill of the
|
|
123
|
+
// chosen engine. Same resolution as ctx.engine (see server/ext/engine.ts).
|
|
124
|
+
const override = resolveEngineOverride();
|
|
125
|
+
if (override) {
|
|
126
|
+
if (!endpoint || endpoint.url !== override.url) {
|
|
127
|
+
endpoint = override;
|
|
128
|
+
console.log(`[webui] connected to opencode service at ${override.url} (WEBUI_ENGINE_URL)`);
|
|
129
|
+
}
|
|
130
|
+
return endpoint;
|
|
131
|
+
}
|
|
110
132
|
if (!endpoint) {
|
|
111
133
|
endpoint = await Service.ensure();
|
|
112
134
|
console.log(`[webui] connected to opencode service at ${endpoint.url}`);
|
|
@@ -114,6 +136,40 @@ async function serviceEndpoint() {
|
|
|
114
136
|
return endpoint;
|
|
115
137
|
}
|
|
116
138
|
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Proxy crash-reason persistence (G-T8).
|
|
141
|
+
//
|
|
142
|
+
// One sandbox death left no cause. Fatal reasons are appended to CRASH_LOG
|
|
143
|
+
// (never thrown from there — the crash path must not crash) and the last
|
|
144
|
+
// entry is surfaced on the next boot, so an agent can see why the proxy died
|
|
145
|
+
// without having watched it die. Semantics are unchanged: uncaught exceptions
|
|
146
|
+
// still exit(1) (the Node default), rejections keep the runtime's behavior —
|
|
147
|
+
// only observability is added.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const CRASH_LOG =
|
|
151
|
+
process.env.WEBUI_CRASH_LOG ??
|
|
152
|
+
join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-webui", "proxy-crash.log");
|
|
153
|
+
|
|
154
|
+
function persistCrashReason(kind: "uncaughtException" | "unhandledRejection", reason: unknown): void {
|
|
155
|
+
const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
156
|
+
try {
|
|
157
|
+
mkdirSync(dirname(CRASH_LOG), { recursive: true, mode: 0o700 });
|
|
158
|
+
appendFileSync(CRASH_LOG, `${new Date().toISOString()} ${kind}: ${detail}\n`, "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
/* crash path — never throw */
|
|
161
|
+
}
|
|
162
|
+
console.error(`[webui] ${kind} (recorded in ${CRASH_LOG}):`, detail.split("\n")[0]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
process.on("uncaughtException", (err) => {
|
|
166
|
+
persistCrashReason("uncaughtException", err);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
|
169
|
+
process.on("unhandledRejection", (reason) => {
|
|
170
|
+
persistCrashReason("unhandledRejection", reason);
|
|
171
|
+
});
|
|
172
|
+
|
|
117
173
|
// ---------------------------------------------------------------------------
|
|
118
174
|
// Live-event recorder (catch-up for late-joining browsers).
|
|
119
175
|
//
|
|
@@ -154,6 +210,9 @@ function recordEvent(evt: RecordedEvent) {
|
|
|
154
210
|
recorderSeenIds.add(evt.id);
|
|
155
211
|
if (recorderSeenIds.size > RECORDER_SEEN_MAX) recorderSeenIds.clear();
|
|
156
212
|
}
|
|
213
|
+
// Proxy-stratum event tap (spec §8): headless extensions observe the same
|
|
214
|
+
// deduped stream the replay buffer keeps. Fire-and-forget, never blocks.
|
|
215
|
+
void dispatchExtEvent(evt).catch((err) => console.error("[webui] ext onEvent failed:", err));
|
|
157
216
|
let buf = replayBuffers.get(sessionID);
|
|
158
217
|
if (!buf) {
|
|
159
218
|
// Hard cap on tracked sessions; drop the least recently active.
|
|
@@ -240,12 +299,21 @@ async function startEventRecorder() {
|
|
|
240
299
|
// code. The engine lists loaded plugins at GET /plugin ({ location,
|
|
241
300
|
// data: PluginInfo[] }). For every LOCAL-source plugin we probe two UI entry
|
|
242
301
|
// candidates next to it — `<dir>/ui/main.tsx`, then
|
|
243
|
-
// `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into
|
|
244
|
-
//
|
|
245
|
-
//
|
|
302
|
+
// `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into an
|
|
303
|
+
// ESM script and serve:
|
|
304
|
+
//
|
|
305
|
+
// GET /api/webui/extensions -> { data: [{ id, url?, domUrl?, source }] }
|
|
306
|
+
// GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache (browser stratum)
|
|
307
|
+
// GET /api/webui/extensions/:id/dom.js -> text/javascript, no-cache (DOM stratum, spec §7)
|
|
246
308
|
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
309
|
+
// `react` (+ `react/jsx-runtime`, `react/jsx-dev-runtime`) are EXTERNAL in
|
|
310
|
+
// every bundle — never inlined. The page's index.html carries an import map
|
|
311
|
+
// pointing those bare specifiers at /api/webui/vendor/*.js, which re-export
|
|
312
|
+
// the app's own React via the window.__opencodeUI bridge (installed at boot,
|
|
313
|
+
// re-ensured by the runtime loader before every import). One React instance
|
|
314
|
+
// for app and extensions alike: inlining a second copy breaks hooks
|
|
315
|
+
// (invalid-hook-call on the first useState). The vendor shims are the only
|
|
316
|
+
// copy extensions ever see.
|
|
249
317
|
//
|
|
250
318
|
// Both routes ride the same session auth as every other /api call (the
|
|
251
319
|
// upstream plugin list is fetched with Service.headers) and register BEFORE
|
|
@@ -324,21 +392,22 @@ async function discoverUIEntries(): Promise<UIEntry[]> {
|
|
|
324
392
|
}
|
|
325
393
|
|
|
326
394
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
395
|
+
* Folder extensions (user > project > shipped, same-id swap) PLUS engine
|
|
396
|
+
* plugin UI halves. Folder ids win collisions: a folder may shadow a
|
|
397
|
+
* plugin's UI — presence on disk is the deliberate override.
|
|
329
398
|
*/
|
|
330
399
|
async function discoverAllUIEntries(): Promise<UIEntry[]> {
|
|
331
400
|
const pluginEntries = await discoverUIEntries();
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
const ids = new Set(
|
|
335
|
-
const merged = [...
|
|
336
|
-
for (const
|
|
337
|
-
if (ids.has(
|
|
338
|
-
warnOnce(`collide:${
|
|
401
|
+
const folderEntries = discoverUserUIEntries();
|
|
402
|
+
if (folderEntries.length === 0) return pluginEntries;
|
|
403
|
+
const ids = new Set(folderEntries.map((e) => e.id));
|
|
404
|
+
const merged = [...folderEntries];
|
|
405
|
+
for (const plugin of pluginEntries) {
|
|
406
|
+
if (ids.has(plugin.id)) {
|
|
407
|
+
warnOnce(`collide:${plugin.id}`, `plugin extension "${plugin.id}" skipped — a folder already owns that id`);
|
|
339
408
|
continue;
|
|
340
409
|
}
|
|
341
|
-
merged.push(
|
|
410
|
+
merged.push(plugin);
|
|
342
411
|
}
|
|
343
412
|
return merged;
|
|
344
413
|
}
|
|
@@ -353,33 +422,244 @@ async function bundleUIEntry(entry: string): Promise<string> {
|
|
|
353
422
|
target: "browser",
|
|
354
423
|
format: "esm",
|
|
355
424
|
minify: false,
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
build.onResolve({ filter: /^react(\/jsx-runtime|\/jsx-dev-runtime)?$/ }, (args) => ({
|
|
363
|
-
path: join(
|
|
364
|
-
APP_ROOT,
|
|
365
|
-
"node_modules",
|
|
366
|
-
"react",
|
|
367
|
-
args.path === "react" ? "index.js" : `${args.path.slice("react/".length)}.js`,
|
|
368
|
-
),
|
|
369
|
-
}));
|
|
370
|
-
},
|
|
371
|
-
},
|
|
372
|
-
],
|
|
425
|
+
// React is EXTERNAL — never inlined. Extension bundles import the bare
|
|
426
|
+
// specifiers and the page's import map (index.html) resolves them to
|
|
427
|
+
// /api/webui/vendor/*.js, which re-export the app's own React instance.
|
|
428
|
+
// Resolving react to a FILE path here (the old react-from-app plugin)
|
|
429
|
+
// inlined a private second copy -> invalid-hook-call on first useState.
|
|
430
|
+
external: ["react", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
373
431
|
});
|
|
374
432
|
const artifact =
|
|
375
433
|
built.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js")) ??
|
|
376
434
|
built.outputs.find((o) => o.path.endsWith(".js"));
|
|
435
|
+
for (const log of built.logs) {
|
|
436
|
+
// Build warnings/errors are the ONLY server-side signal for a broken
|
|
437
|
+
// extension — a failing bundle must never be silent (the page just sees
|
|
438
|
+
// a missing entry). Bun.build failures throw below; warnings print here.
|
|
439
|
+
console.warn(`[webui] extension bundle build (${entry}): ${log.message}`);
|
|
440
|
+
}
|
|
377
441
|
if (!artifact) throw new Error(`bun.build produced no js artifact for ${entry}`);
|
|
378
442
|
const js = await artifact.text();
|
|
379
443
|
bundleCache.set(entry, { mtimeMs, js });
|
|
380
444
|
return js;
|
|
381
445
|
}
|
|
382
446
|
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Shared-React vendor shims (import-map targets for external bundles).
|
|
449
|
+
//
|
|
450
|
+
// Extension bundles import the bare specifiers "react",
|
|
451
|
+
// "react/jsx-runtime" and "react/jsx-dev-runtime" (see `external` above).
|
|
452
|
+
// The page's import map (index.html) resolves those to these routes, which
|
|
453
|
+
// re-export the APP's React instance via the window.__opencodeUI bridge —
|
|
454
|
+
// installed at boot (main.tsx) and re-ensured by the runtime loader before
|
|
455
|
+
// every bundle import, so the bridge always exists when a shim executes.
|
|
456
|
+
// No React code ships in these shims and none is inlined into bundles:
|
|
457
|
+
// there is exactly one React instance in the page.
|
|
458
|
+
//
|
|
459
|
+
// The named-export list is derived from the running app's react copy so it
|
|
460
|
+
// stays correct across React upgrades without hand-maintained lists.
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
const REACT_NAMED_EXPORTS: string[] = (() => {
|
|
464
|
+
try {
|
|
465
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
466
|
+
const ns = require("react") as Record<string, unknown>;
|
|
467
|
+
return Object.keys(ns).filter(
|
|
468
|
+
(k) => k !== "default" && k !== "__esModule" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k),
|
|
469
|
+
);
|
|
470
|
+
} catch {
|
|
471
|
+
// Fallback: hooks + primitives an extension could plausibly import.
|
|
472
|
+
return [
|
|
473
|
+
"Children", "Component", "Fragment", "Profiler", "PureComponent", "StrictMode", "Suspense",
|
|
474
|
+
"cache", "cloneElement", "createContext", "createElement", "createRef", "forwardRef",
|
|
475
|
+
"isValidElement", "lazy", "memo", "startTransition", "use", "useCallback", "useContext",
|
|
476
|
+
"useDebugValue", "useDeferredValue", "useEffect", "useId", "useImperativeHandle",
|
|
477
|
+
"useInsertionEffect", "useLayoutEffect", "useMemo", "useReducer", "useRef", "useState",
|
|
478
|
+
"useSyncExternalStore", "useTransition", "version",
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
})();
|
|
482
|
+
|
|
483
|
+
const REACT_VENDOR_JS = `// Shared-React shim: re-exports the app's React (window.__opencodeUI.react).
|
|
484
|
+
// Served by the proxy at /api/webui/vendor/react.js (import-map target).
|
|
485
|
+
const R = globalThis.__opencodeUI?.react;
|
|
486
|
+
if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
|
|
487
|
+
export default R;
|
|
488
|
+
export const { ${REACT_NAMED_EXPORTS.join(", ")} } = R;
|
|
489
|
+
`;
|
|
490
|
+
|
|
491
|
+
// The automatic JSX transform calls jsx()/jsxs() to build elements. These
|
|
492
|
+
// delegate to createElement on the SAME shared instance, so elements and
|
|
493
|
+
// hooks always agree on the dispatcher. __source/__self (dev) are stripped.
|
|
494
|
+
const JSX_RUNTIME_BODY = `const R = globalThis.__opencodeUI?.react;
|
|
495
|
+
if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
|
|
496
|
+
export const Fragment = R.Fragment;
|
|
497
|
+
function _el(type, props, key) {
|
|
498
|
+
const p = { ...(props || {}) };
|
|
499
|
+
const children = p.children;
|
|
500
|
+
delete p.children;
|
|
501
|
+
delete p.__source;
|
|
502
|
+
delete p.__self;
|
|
503
|
+
if (key !== undefined) p.key = key;
|
|
504
|
+
if (children === undefined) return R.createElement(type, p);
|
|
505
|
+
return Array.isArray(children) ? R.createElement(type, p, ...children) : R.createElement(type, p, children);
|
|
506
|
+
}
|
|
507
|
+
export function jsx(type, props, key) { return _el(type, props, key); }
|
|
508
|
+
export function jsxs(type, props, key) { return _el(type, props, key); }
|
|
509
|
+
`;
|
|
510
|
+
|
|
511
|
+
const REACT_JSX_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-runtime (import-map target).
|
|
512
|
+
${JSX_RUNTIME_BODY}`;
|
|
513
|
+
|
|
514
|
+
const REACT_JSX_DEV_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-dev-runtime (import-map target).
|
|
515
|
+
${JSX_RUNTIME_BODY}export function jsxDEV(type, props, key) { return _el(type, props, key); }
|
|
516
|
+
`;
|
|
517
|
+
|
|
518
|
+
function vendorShimFor(path: string): string | null {
|
|
519
|
+
if (path === "/api/webui/vendor/react.js") return REACT_VENDOR_JS;
|
|
520
|
+
if (path === "/api/webui/vendor/react-jsx-runtime.js") return REACT_JSX_RUNTIME_VENDOR_JS;
|
|
521
|
+
if (path === "/api/webui/vendor/react-jsx-dev-runtime.js") return REACT_JSX_DEV_RUNTIME_VENDOR_JS;
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Extension manifest push (spec §6: replaces the 8s browser poll).
|
|
527
|
+
//
|
|
528
|
+
// The proxy watches all three folder sources, rebuilds changed bundles
|
|
529
|
+
// (bundleCache is mtime-keyed, so the next bundle.js fetch rebuilds), bumps
|
|
530
|
+
// the manifest version, and pushes it over SSE; the page re-imports bundles
|
|
531
|
+
// whose ?v= moved and same-id-swaps them in the registry — sub-second, no
|
|
532
|
+
// refresh. Delete/move = uninstall (the id vanishes from the manifest and
|
|
533
|
+
// the page unregisters it); manifest `disabled: true` = paused.
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
type ManifestItem =
|
|
537
|
+
| { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string }
|
|
538
|
+
| { id: string; source: string; origin?: UIEntry["origin"]; disabled: true };
|
|
539
|
+
|
|
540
|
+
async function buildExtensionManifest(): Promise<ManifestItem[]> {
|
|
541
|
+
// discoverAllUIEntries never throws; upstream failures collapse to [].
|
|
542
|
+
const entries = await discoverAllUIEntries();
|
|
543
|
+
return entries.map((e) => {
|
|
544
|
+
if (e.disabled || (!e.entry && !e.domEntry)) {
|
|
545
|
+
return { id: e.id, source: e.source ?? e.entry, origin: e.origin, disabled: true as const };
|
|
546
|
+
}
|
|
547
|
+
const item: { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string } = {
|
|
548
|
+
id: e.id,
|
|
549
|
+
source: e.source ?? e.entry,
|
|
550
|
+
origin: e.origin,
|
|
551
|
+
};
|
|
552
|
+
// Shipped browser stratum loads via the in-repo Vite glob
|
|
553
|
+
// (webui-extensions/index.ts, Vite HMR) — never via a bundle URL, or
|
|
554
|
+
// module side effects run twice and `extension.loaded` fires twice
|
|
555
|
+
// (Bug 2). Omit `url` for shipped origin; a user/project copy shadowing
|
|
556
|
+
// the same id wins discovery with origin user/project, keeps its `url`,
|
|
557
|
+
// and same-id-swaps over the glob copy. DOM stratum (`domUrl`) still
|
|
558
|
+
// serves for shipped: the glob never loads `dom.ts`, so there is no
|
|
559
|
+
// double-load there and omitting it would break shipped DOM extensions.
|
|
560
|
+
if (e.entry && e.origin !== "shipped") {
|
|
561
|
+
item.url = `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`;
|
|
562
|
+
}
|
|
563
|
+
// DOM stratum (spec §7): its own `?v=` — a `dom.ts` edit changes the
|
|
564
|
+
// manifest JSON, which is what fires the SSE push (no second channel).
|
|
565
|
+
if (e.domEntry && e.domMtimeMs !== undefined) {
|
|
566
|
+
item.domUrl = `/api/webui/extensions/${encodeURIComponent(e.id)}/dom.js?v=${e.domMtimeMs}`;
|
|
567
|
+
}
|
|
568
|
+
return item;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let extManifestVersion = 0;
|
|
573
|
+
let lastManifestJSON = "";
|
|
574
|
+
const extManifestListeners = new Set<(msg: string) => void>();
|
|
575
|
+
|
|
576
|
+
function broadcastExtensionManifest() {
|
|
577
|
+
const msg = JSON.stringify({ type: "webui.extensions", version: extManifestVersion });
|
|
578
|
+
for (const send of extManifestListeners) send(msg);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Re-scan and push when the manifest actually changed (edits change ?v=
|
|
583
|
+
* mtimes, adds/removes/disabled-flips change the id set). Returns true on
|
|
584
|
+
* change. Watcher-triggered scans invalidate the TTL caches first so the
|
|
585
|
+
* push is immediate, not up-to-5s late.
|
|
586
|
+
*/
|
|
587
|
+
async function checkExtensionManifest(immediate: boolean): Promise<boolean> {
|
|
588
|
+
if (immediate) {
|
|
589
|
+
invalidateExtensionCache();
|
|
590
|
+
uiEntryCache = null;
|
|
591
|
+
}
|
|
592
|
+
const manifest = await buildExtensionManifest();
|
|
593
|
+
const json = JSON.stringify(manifest);
|
|
594
|
+
if (json === lastManifestJSON) return false;
|
|
595
|
+
lastManifestJSON = json;
|
|
596
|
+
extManifestVersion++;
|
|
597
|
+
dbg("extensions manifest v" + extManifestVersion + ":", manifest.length, "entr(ies)");
|
|
598
|
+
broadcastExtensionManifest();
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const watchedExtRoots = new Set<string>();
|
|
603
|
+
let extRescanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
604
|
+
|
|
605
|
+
function scheduleExtRescan() {
|
|
606
|
+
if (extRescanTimer) return;
|
|
607
|
+
extRescanTimer = setTimeout(() => {
|
|
608
|
+
extRescanTimer = null;
|
|
609
|
+
void checkExtensionManifest(true);
|
|
610
|
+
}, 300); // coalesce save-bursts (edit + manifest.json write land together)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function ensureExtWatchers() {
|
|
614
|
+
const attach = (path: string) => {
|
|
615
|
+
if (watchedExtRoots.has(path)) return;
|
|
616
|
+
try {
|
|
617
|
+
const watcher = watch(path, { persistent: false }, () => scheduleExtRescan());
|
|
618
|
+
watcher.on("error", () => {
|
|
619
|
+
// Path deleted (or never existed) — drop it; a later rescan
|
|
620
|
+
// re-attaches when it reappears.
|
|
621
|
+
try {
|
|
622
|
+
watcher.close();
|
|
623
|
+
} catch {
|
|
624
|
+
/* already closed */
|
|
625
|
+
}
|
|
626
|
+
watchedExtRoots.delete(path);
|
|
627
|
+
});
|
|
628
|
+
watchedExtRoots.add(path);
|
|
629
|
+
} catch {
|
|
630
|
+
/* absent path — retry on the next rescan */
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
for (const root of extensionSourceRoots()) attach(root);
|
|
634
|
+
// Each extension folder too: a content-only edit (index.tsx bytes,
|
|
635
|
+
// manifest.json `disabled` flip) fires no event on the PARENT root watch
|
|
636
|
+
// (inotify reports only direct-child create/delete/rename there) — without
|
|
637
|
+
// this, edits would wait for the 5s backstop instead of pushing sub-second.
|
|
638
|
+
// discoverUserUIEntries() is TTL-cached, so this sweep is cheap.
|
|
639
|
+
const PREFIX = "webui-extensions:";
|
|
640
|
+
for (const e of discoverUserUIEntries()) {
|
|
641
|
+
const dir = e.entry
|
|
642
|
+
? dirname(e.entry)
|
|
643
|
+
: e.source?.startsWith(PREFIX)
|
|
644
|
+
? e.source.slice(PREFIX.length)
|
|
645
|
+
: null;
|
|
646
|
+
if (dir) attach(dir);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let extWatcherRunning = false;
|
|
651
|
+
/** fs.watch for immediacy + a 5s re-scan for engine-plugin drift and roots. */
|
|
652
|
+
function startExtensionWatcher() {
|
|
653
|
+
if (extWatcherRunning) return;
|
|
654
|
+
extWatcherRunning = true;
|
|
655
|
+
ensureExtWatchers();
|
|
656
|
+
void checkExtensionManifest(true); // seed lastManifestJSON + version 1
|
|
657
|
+
setInterval(() => {
|
|
658
|
+
ensureExtWatchers(); // attach to roots that appeared since boot
|
|
659
|
+
void checkExtensionManifest(false);
|
|
660
|
+
}, 5_000).unref?.();
|
|
661
|
+
}
|
|
662
|
+
|
|
383
663
|
// ---------------------------------------------------------------------------
|
|
384
664
|
// Boot: CLI flag → auth policy → skill sync → serve → banner.
|
|
385
665
|
// ---------------------------------------------------------------------------
|
|
@@ -496,31 +776,79 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
496
776
|
return Response.json({ data: events });
|
|
497
777
|
}
|
|
498
778
|
|
|
499
|
-
//
|
|
779
|
+
// Extension manifest — folder entries (user > project > shipped) plus
|
|
780
|
+
// plugin UI halves. Disabled entries ship WITHOUT a url so the page can
|
|
781
|
+
// show them paused without importing anything. Shipped-origin browser
|
|
782
|
+
// entries likewise ship without `url` (Bug 2: the in-repo Vite glob owns
|
|
783
|
+
// them — a bundle URL here would double-load); shadowing user/project
|
|
784
|
+
// copies keep their `url` and same-id-swap. `origin` lets the page tell
|
|
785
|
+
// them apart; shipped `domUrl` still serves (the glob never loads dom).
|
|
500
786
|
if (method === "GET" && path === "/api/webui/extensions") {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
787
|
+
const data = await buildExtensionManifest();
|
|
788
|
+
dbg("extensions list:", data.length, "ui entr(ies)");
|
|
789
|
+
return Response.json({ data, version: extManifestVersion });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Manifest push channel (spec §6): one event per manifest change plus a
|
|
793
|
+
// hello on subscribe. The page re-fetches the manifest on each event and
|
|
794
|
+
// re-imports only bundles whose ?v= moved. Heartbeat comments keep the
|
|
795
|
+
// stream alive through idle infrastructure.
|
|
796
|
+
if (method === "GET" && path === "/api/webui/extensions/events") {
|
|
797
|
+
const encoder = new TextEncoder();
|
|
798
|
+
let send: ((msg: string) => void) | null = null;
|
|
799
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
800
|
+
const stream = new ReadableStream({
|
|
801
|
+
start(controller) {
|
|
802
|
+
send = (msg: string) => {
|
|
803
|
+
try {
|
|
804
|
+
controller.enqueue(encoder.encode(`data: ${msg}\n\n`));
|
|
805
|
+
} catch {
|
|
806
|
+
/* client gone — cancel() cleans up */
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
extManifestListeners.add(send);
|
|
810
|
+
send(JSON.stringify({ type: "webui.extensions", version: extManifestVersion }));
|
|
811
|
+
heartbeat = setInterval(() => {
|
|
812
|
+
try {
|
|
813
|
+
controller.enqueue(encoder.encode(`: ping\n\n`));
|
|
814
|
+
} catch {
|
|
815
|
+
/* client gone */
|
|
816
|
+
}
|
|
817
|
+
}, 15_000);
|
|
818
|
+
},
|
|
819
|
+
cancel() {
|
|
820
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
821
|
+
if (send) extManifestListeners.delete(send);
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
return new Response(stream, {
|
|
825
|
+
headers: {
|
|
826
|
+
"content-type": "text/event-stream",
|
|
827
|
+
"cache-control": "no-cache",
|
|
828
|
+
connection: "keep-alive",
|
|
829
|
+
},
|
|
510
830
|
});
|
|
511
831
|
}
|
|
512
832
|
|
|
513
|
-
|
|
514
|
-
|
|
833
|
+
// Browser-stratum bundle (`bundle.js`) and DOM-stratum bundle
|
|
834
|
+
// (`dom.js`, spec §7) — same mtime-keyed Bun.build pipeline, same
|
|
835
|
+
// no-cache serving. Disabled entries 404 — the page must never import a
|
|
836
|
+
// paused extension.
|
|
837
|
+
if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/(bundle|dom)\.js$/.test(path)) {
|
|
838
|
+
const segs = path.split("/");
|
|
839
|
+
const id = decodeURIComponent(segs[4] ?? "");
|
|
840
|
+
const wantDom = (segs[5] ?? "").startsWith("dom");
|
|
515
841
|
try {
|
|
516
842
|
// Resolve through the CURRENT discovery result so removed/expired
|
|
517
|
-
// plugins 404 instead of serving a stale bundle.
|
|
843
|
+
// plugins 404 instead of serving a stale bundle. Disabled entries
|
|
844
|
+
// 404 too — the page must never import a paused extension.
|
|
518
845
|
const found = (await discoverAllUIEntries()).find((e) => e.id === id);
|
|
519
|
-
|
|
846
|
+
const entry = wantDom ? found?.domEntry : found?.entry;
|
|
847
|
+
if (!found || found.disabled || !entry || !existsSync(entry)) {
|
|
520
848
|
return Response.json({ error: `unknown extension: ${id}` }, { status: 404 });
|
|
521
849
|
}
|
|
522
|
-
const js = await bundleUIEntry(
|
|
523
|
-
dbg("extensions bundle:", id, `${js.length}b`);
|
|
850
|
+
const js = await bundleUIEntry(entry); // throws -> 500 below
|
|
851
|
+
dbg("extensions bundle:", id, wantDom ? "(dom)" : "", `${js.length}b`);
|
|
524
852
|
return new Response(js, {
|
|
525
853
|
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
526
854
|
});
|
|
@@ -530,6 +858,24 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
530
858
|
}
|
|
531
859
|
}
|
|
532
860
|
|
|
861
|
+
if (path.startsWith("/api/webui/ext/")) {
|
|
862
|
+
const extRes = await dispatchExtRequest(req, url);
|
|
863
|
+
if (extRes) return extRes;
|
|
864
|
+
return Response.json({ error: "unknown extension route" }, { status: 404 });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Shared-React vendor shims (import-map targets — see bundleUIEntry).
|
|
868
|
+
// Same session auth as every other /api route (gate above applies);
|
|
869
|
+
// same-origin dynamic imports carry the session cookie. Content is
|
|
870
|
+
// derived from the running app's react copy, so no-cache (tiny files).
|
|
871
|
+
if (method === "GET" && path.startsWith("/api/webui/vendor/")) {
|
|
872
|
+
const js = vendorShimFor(path);
|
|
873
|
+
if (js === null) return Response.json({ error: "unknown vendor module" }, { status: 404 });
|
|
874
|
+
return new Response(js, {
|
|
875
|
+
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
533
879
|
if (path.startsWith("/api")) {
|
|
534
880
|
const isUpgrade = (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
|
|
535
881
|
if (isUpgrade) {
|
|
@@ -548,14 +894,21 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
548
894
|
}
|
|
549
895
|
const t0 = Date.now();
|
|
550
896
|
try {
|
|
897
|
+
// Proxy-stratum request middleware (spec §8): a returned Response
|
|
898
|
+
// short-circuits the passthrough; a returned Request replaces it.
|
|
899
|
+
let activeReq = req;
|
|
900
|
+
const extRewrite = await runExtRequestMiddleware(req);
|
|
901
|
+
if (extRewrite instanceof Response) return extRewrite;
|
|
902
|
+
if (extRewrite instanceof Request) activeReq = extRewrite;
|
|
903
|
+
const upMethod = activeReq.method;
|
|
551
904
|
const ep = await serviceEndpoint();
|
|
552
905
|
const headers = Service.headers(ep);
|
|
553
906
|
const upstream: Response = await fetch(`${ep.url}${path}${url.search}`, {
|
|
554
|
-
method,
|
|
907
|
+
method: upMethod,
|
|
555
908
|
// Abort the upstream request when the browser client goes away,
|
|
556
909
|
// otherwise streamed responses (SSE) leak one connection per
|
|
557
910
|
// client reconnect until the pool wedges and requests hang.
|
|
558
|
-
signal:
|
|
911
|
+
signal: activeReq.signal,
|
|
559
912
|
headers: {
|
|
560
913
|
...headers,
|
|
561
914
|
// Forward only benign client headers. Service.headers must WIN —
|
|
@@ -563,7 +916,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
563
916
|
// engine credential (e.g. its own `authorization`). Also drop
|
|
564
917
|
// spoofable/transport headers the engine should never see.
|
|
565
918
|
...Object.fromEntries(
|
|
566
|
-
[...
|
|
919
|
+
[...activeReq.headers.entries()].filter(([k]) => {
|
|
567
920
|
const name = k.toLowerCase();
|
|
568
921
|
if (FORBIDDEN_CLIENT_HEADERS.has(name)) return false;
|
|
569
922
|
return (
|
|
@@ -578,7 +931,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
578
931
|
// hops gain nothing from compression — never request it.
|
|
579
932
|
"accept-encoding": "identity",
|
|
580
933
|
},
|
|
581
|
-
body: ["GET", "HEAD"].includes(
|
|
934
|
+
body: ["GET", "HEAD"].includes(upMethod) ? undefined : activeReq.body,
|
|
582
935
|
redirect: "manual",
|
|
583
936
|
});
|
|
584
937
|
|
|
@@ -588,10 +941,14 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
588
941
|
responseHeaders.delete("content-encoding");
|
|
589
942
|
}
|
|
590
943
|
dbg("proxy:", method, path, "->", upstream.status, `${Date.now() - t0}ms`);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
944
|
+
// Proxy-stratum response middleware (spec §8): uniform rewriting.
|
|
945
|
+
return await applyExtResponseMiddleware(
|
|
946
|
+
new Response(upstream.body, {
|
|
947
|
+
status: upstream.status,
|
|
948
|
+
headers: responseHeaders,
|
|
949
|
+
}),
|
|
950
|
+
req,
|
|
951
|
+
);
|
|
595
952
|
} catch (err) {
|
|
596
953
|
const message = err instanceof Error ? err.message : String(err);
|
|
597
954
|
console.error("[webui] proxy error:", message);
|
|
@@ -696,10 +1053,28 @@ console.log(
|
|
|
696
1053
|
? `[webui] sandbox — loopback only, NO password; extensions (scratch): ${globalUserExtensionsDir()}`
|
|
697
1054
|
: `[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
|
|
698
1055
|
`[webui] same sessions as your opencode TUI — it's the same engine`,
|
|
699
|
-
`[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/
|
|
1056
|
+
`[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/ (index.tsx + manifest.json)`,
|
|
700
1057
|
SKILL.ok
|
|
701
1058
|
? `[webui] agent skill installed at ${SKILL.target} (auto-synced each boot)`
|
|
702
1059
|
: `[webui] agent skill NOT synced: ${SKILL.reason}`,
|
|
703
1060
|
].join("\n"),
|
|
704
1061
|
);
|
|
705
1062
|
void startEventRecorder();
|
|
1063
|
+
startExtensionWatcher();
|
|
1064
|
+
void startExtModules();
|
|
1065
|
+
|
|
1066
|
+
// Crash-log boot note: if a previous proxy died fatally, its reason is the
|
|
1067
|
+
// last line of CRASH_LOG — surface it so the next boot (or an agent reading
|
|
1068
|
+
// the log) sees why without having watched it die.
|
|
1069
|
+
try {
|
|
1070
|
+
if (existsSync(CRASH_LOG)) {
|
|
1071
|
+
const lines = readFileSync(CRASH_LOG, "utf8").trim().split("\n").filter((l) => l.length > 0);
|
|
1072
|
+
// Entries are multi-line (stacks) — the "last" entry is the last line
|
|
1073
|
+
// starting a new timestamped record, not the log's physical last line.
|
|
1074
|
+
const heads = lines.filter((l) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(l));
|
|
1075
|
+
const last = heads[heads.length - 1] ?? lines[lines.length - 1];
|
|
1076
|
+
if (last) console.log(`[webui] previous proxy crash (${heads.length} entr(ies) in ${CRASH_LOG}) — last: ${last.slice(0, 300)}`);
|
|
1077
|
+
}
|
|
1078
|
+
} catch {
|
|
1079
|
+
/* observability only — never block boot */
|
|
1080
|
+
}
|