opencode-webui 1.0.8 → 2.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/README.md +20 -7
- package/dist/assets/index-BHminhzR.js +128 -0
- package/dist/assets/index-d4KcyqrZ.css +1 -0
- package/dist/assets/report-9wOQi_Kx.js +2 -0
- package/dist/index.html +16 -2
- package/package.json +2 -3
- package/server/ext/kv.ts +82 -0
- package/server/ext/registry.ts +279 -0
- package/server/ext/types.ts +100 -0
- package/server/index.ts +364 -58
- package/server/userExtensions.ts +175 -48
- package/skills/webui/SKILL.md +113 -96
- package/webui-extensions/README.md +356 -0
- package/dist/assets/index-D_1wPDvx.css +0 -1
- package/dist/assets/index-v2k5q9l4.js +0 -122
- package/dist/assets/report-CHS9-Gsw.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 } 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,19 @@ 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";
|
|
48
57
|
|
|
49
58
|
// `sandbox` argv — one command, every runtime: `bun run sandbox` (repo, the
|
|
50
59
|
// script adds Vite), `bunx opencode-webui sandbox`, `./opencode-webui sandbox`
|
|
@@ -154,6 +163,9 @@ function recordEvent(evt: RecordedEvent) {
|
|
|
154
163
|
recorderSeenIds.add(evt.id);
|
|
155
164
|
if (recorderSeenIds.size > RECORDER_SEEN_MAX) recorderSeenIds.clear();
|
|
156
165
|
}
|
|
166
|
+
// Proxy-stratum event tap (spec §8): headless extensions observe the same
|
|
167
|
+
// deduped stream the replay buffer keeps. Fire-and-forget, never blocks.
|
|
168
|
+
void dispatchExtEvent(evt).catch((err) => console.error("[webui] ext onEvent failed:", err));
|
|
157
169
|
let buf = replayBuffers.get(sessionID);
|
|
158
170
|
if (!buf) {
|
|
159
171
|
// Hard cap on tracked sessions; drop the least recently active.
|
|
@@ -240,12 +252,21 @@ async function startEventRecorder() {
|
|
|
240
252
|
// code. The engine lists loaded plugins at GET /plugin ({ location,
|
|
241
253
|
// data: PluginInfo[] }). For every LOCAL-source plugin we probe two UI entry
|
|
242
254
|
// 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
|
-
//
|
|
255
|
+
// `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into an
|
|
256
|
+
// ESM script and serve:
|
|
257
|
+
//
|
|
258
|
+
// GET /api/webui/extensions -> { data: [{ id, url?, domUrl?, source }] }
|
|
259
|
+
// GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache (browser stratum)
|
|
260
|
+
// GET /api/webui/extensions/:id/dom.js -> text/javascript, no-cache (DOM stratum, spec §7)
|
|
246
261
|
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
262
|
+
// `react` (+ `react/jsx-runtime`, `react/jsx-dev-runtime`) are EXTERNAL in
|
|
263
|
+
// every bundle — never inlined. The page's index.html carries an import map
|
|
264
|
+
// pointing those bare specifiers at /api/webui/vendor/*.js, which re-export
|
|
265
|
+
// the app's own React via the window.__opencodeUI bridge (installed at boot,
|
|
266
|
+
// re-ensured by the runtime loader before every import). One React instance
|
|
267
|
+
// for app and extensions alike: inlining a second copy breaks hooks
|
|
268
|
+
// (invalid-hook-call on the first useState). The vendor shims are the only
|
|
269
|
+
// copy extensions ever see.
|
|
249
270
|
//
|
|
250
271
|
// Both routes ride the same session auth as every other /api call (the
|
|
251
272
|
// upstream plugin list is fetched with Service.headers) and register BEFORE
|
|
@@ -324,21 +345,22 @@ async function discoverUIEntries(): Promise<UIEntry[]> {
|
|
|
324
345
|
}
|
|
325
346
|
|
|
326
347
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
348
|
+
* Folder extensions (user > project > shipped, same-id swap) PLUS engine
|
|
349
|
+
* plugin UI halves. Folder ids win collisions: a folder may shadow a
|
|
350
|
+
* plugin's UI — presence on disk is the deliberate override.
|
|
329
351
|
*/
|
|
330
352
|
async function discoverAllUIEntries(): Promise<UIEntry[]> {
|
|
331
353
|
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:${
|
|
354
|
+
const folderEntries = discoverUserUIEntries();
|
|
355
|
+
if (folderEntries.length === 0) return pluginEntries;
|
|
356
|
+
const ids = new Set(folderEntries.map((e) => e.id));
|
|
357
|
+
const merged = [...folderEntries];
|
|
358
|
+
for (const plugin of pluginEntries) {
|
|
359
|
+
if (ids.has(plugin.id)) {
|
|
360
|
+
warnOnce(`collide:${plugin.id}`, `plugin extension "${plugin.id}" skipped — a folder already owns that id`);
|
|
339
361
|
continue;
|
|
340
362
|
}
|
|
341
|
-
merged.push(
|
|
363
|
+
merged.push(plugin);
|
|
342
364
|
}
|
|
343
365
|
return merged;
|
|
344
366
|
}
|
|
@@ -353,23 +375,12 @@ async function bundleUIEntry(entry: string): Promise<string> {
|
|
|
353
375
|
target: "browser",
|
|
354
376
|
format: "esm",
|
|
355
377
|
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
|
-
],
|
|
378
|
+
// React is EXTERNAL — never inlined. Extension bundles import the bare
|
|
379
|
+
// specifiers and the page's import map (index.html) resolves them to
|
|
380
|
+
// /api/webui/vendor/*.js, which re-export the app's own React instance.
|
|
381
|
+
// Resolving react to a FILE path here (the old react-from-app plugin)
|
|
382
|
+
// inlined a private second copy -> invalid-hook-call on first useState.
|
|
383
|
+
external: ["react", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
373
384
|
});
|
|
374
385
|
const artifact =
|
|
375
386
|
built.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js")) ??
|
|
@@ -380,6 +391,222 @@ async function bundleUIEntry(entry: string): Promise<string> {
|
|
|
380
391
|
return js;
|
|
381
392
|
}
|
|
382
393
|
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Shared-React vendor shims (import-map targets for external bundles).
|
|
396
|
+
//
|
|
397
|
+
// Extension bundles import the bare specifiers "react",
|
|
398
|
+
// "react/jsx-runtime" and "react/jsx-dev-runtime" (see `external` above).
|
|
399
|
+
// The page's import map (index.html) resolves those to these routes, which
|
|
400
|
+
// re-export the APP's React instance via the window.__opencodeUI bridge —
|
|
401
|
+
// installed at boot (main.tsx) and re-ensured by the runtime loader before
|
|
402
|
+
// every bundle import, so the bridge always exists when a shim executes.
|
|
403
|
+
// No React code ships in these shims and none is inlined into bundles:
|
|
404
|
+
// there is exactly one React instance in the page.
|
|
405
|
+
//
|
|
406
|
+
// The named-export list is derived from the running app's react copy so it
|
|
407
|
+
// stays correct across React upgrades without hand-maintained lists.
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
const REACT_NAMED_EXPORTS: string[] = (() => {
|
|
411
|
+
try {
|
|
412
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
413
|
+
const ns = require("react") as Record<string, unknown>;
|
|
414
|
+
return Object.keys(ns).filter(
|
|
415
|
+
(k) => k !== "default" && k !== "__esModule" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k),
|
|
416
|
+
);
|
|
417
|
+
} catch {
|
|
418
|
+
// Fallback: hooks + primitives an extension could plausibly import.
|
|
419
|
+
return [
|
|
420
|
+
"Children", "Component", "Fragment", "Profiler", "PureComponent", "StrictMode", "Suspense",
|
|
421
|
+
"cache", "cloneElement", "createContext", "createElement", "createRef", "forwardRef",
|
|
422
|
+
"isValidElement", "lazy", "memo", "startTransition", "use", "useCallback", "useContext",
|
|
423
|
+
"useDebugValue", "useDeferredValue", "useEffect", "useId", "useImperativeHandle",
|
|
424
|
+
"useInsertionEffect", "useLayoutEffect", "useMemo", "useReducer", "useRef", "useState",
|
|
425
|
+
"useSyncExternalStore", "useTransition", "version",
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
})();
|
|
429
|
+
|
|
430
|
+
const REACT_VENDOR_JS = `// Shared-React shim: re-exports the app's React (window.__opencodeUI.react).
|
|
431
|
+
// Served by the proxy at /api/webui/vendor/react.js (import-map target).
|
|
432
|
+
const R = globalThis.__opencodeUI?.react;
|
|
433
|
+
if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
|
|
434
|
+
export default R;
|
|
435
|
+
export const { ${REACT_NAMED_EXPORTS.join(", ")} } = R;
|
|
436
|
+
`;
|
|
437
|
+
|
|
438
|
+
// The automatic JSX transform calls jsx()/jsxs() to build elements. These
|
|
439
|
+
// delegate to createElement on the SAME shared instance, so elements and
|
|
440
|
+
// hooks always agree on the dispatcher. __source/__self (dev) are stripped.
|
|
441
|
+
const JSX_RUNTIME_BODY = `const R = globalThis.__opencodeUI?.react;
|
|
442
|
+
if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
|
|
443
|
+
export const Fragment = R.Fragment;
|
|
444
|
+
function _el(type, props, key) {
|
|
445
|
+
const p = { ...(props || {}) };
|
|
446
|
+
const children = p.children;
|
|
447
|
+
delete p.children;
|
|
448
|
+
delete p.__source;
|
|
449
|
+
delete p.__self;
|
|
450
|
+
if (key !== undefined) p.key = key;
|
|
451
|
+
if (children === undefined) return R.createElement(type, p);
|
|
452
|
+
return Array.isArray(children) ? R.createElement(type, p, ...children) : R.createElement(type, p, children);
|
|
453
|
+
}
|
|
454
|
+
export function jsx(type, props, key) { return _el(type, props, key); }
|
|
455
|
+
export function jsxs(type, props, key) { return _el(type, props, key); }
|
|
456
|
+
`;
|
|
457
|
+
|
|
458
|
+
const REACT_JSX_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-runtime (import-map target).
|
|
459
|
+
${JSX_RUNTIME_BODY}`;
|
|
460
|
+
|
|
461
|
+
const REACT_JSX_DEV_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-dev-runtime (import-map target).
|
|
462
|
+
${JSX_RUNTIME_BODY}export function jsxDEV(type, props, key) { return _el(type, props, key); }
|
|
463
|
+
`;
|
|
464
|
+
|
|
465
|
+
function vendorShimFor(path: string): string | null {
|
|
466
|
+
if (path === "/api/webui/vendor/react.js") return REACT_VENDOR_JS;
|
|
467
|
+
if (path === "/api/webui/vendor/react-jsx-runtime.js") return REACT_JSX_RUNTIME_VENDOR_JS;
|
|
468
|
+
if (path === "/api/webui/vendor/react-jsx-dev-runtime.js") return REACT_JSX_DEV_RUNTIME_VENDOR_JS;
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
// Extension manifest push (spec §6: replaces the 8s browser poll).
|
|
474
|
+
//
|
|
475
|
+
// The proxy watches all three folder sources, rebuilds changed bundles
|
|
476
|
+
// (bundleCache is mtime-keyed, so the next bundle.js fetch rebuilds), bumps
|
|
477
|
+
// the manifest version, and pushes it over SSE; the page re-imports bundles
|
|
478
|
+
// whose ?v= moved and same-id-swaps them in the registry — sub-second, no
|
|
479
|
+
// refresh. Delete/move = uninstall (the id vanishes from the manifest and
|
|
480
|
+
// the page unregisters it); manifest `disabled: true` = paused.
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
type ManifestItem =
|
|
484
|
+
| { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string }
|
|
485
|
+
| { id: string; source: string; origin?: UIEntry["origin"]; disabled: true };
|
|
486
|
+
|
|
487
|
+
async function buildExtensionManifest(): Promise<ManifestItem[]> {
|
|
488
|
+
// discoverAllUIEntries never throws; upstream failures collapse to [].
|
|
489
|
+
const entries = await discoverAllUIEntries();
|
|
490
|
+
return entries.map((e) => {
|
|
491
|
+
if (e.disabled || (!e.entry && !e.domEntry)) {
|
|
492
|
+
return { id: e.id, source: e.source ?? e.entry, origin: e.origin, disabled: true as const };
|
|
493
|
+
}
|
|
494
|
+
const item: { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string } = {
|
|
495
|
+
id: e.id,
|
|
496
|
+
source: e.source ?? e.entry,
|
|
497
|
+
origin: e.origin,
|
|
498
|
+
};
|
|
499
|
+
// Shipped browser stratum loads via the in-repo Vite glob
|
|
500
|
+
// (webui-extensions/index.ts, Vite HMR) — never via a bundle URL, or
|
|
501
|
+
// module side effects run twice and `extension.loaded` fires twice
|
|
502
|
+
// (Bug 2). Omit `url` for shipped origin; a user/project copy shadowing
|
|
503
|
+
// the same id wins discovery with origin user/project, keeps its `url`,
|
|
504
|
+
// and same-id-swaps over the glob copy. DOM stratum (`domUrl`) still
|
|
505
|
+
// serves for shipped: the glob never loads `dom.ts`, so there is no
|
|
506
|
+
// double-load there and omitting it would break shipped DOM extensions.
|
|
507
|
+
if (e.entry && e.origin !== "shipped") {
|
|
508
|
+
item.url = `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`;
|
|
509
|
+
}
|
|
510
|
+
// DOM stratum (spec §7): its own `?v=` — a `dom.ts` edit changes the
|
|
511
|
+
// manifest JSON, which is what fires the SSE push (no second channel).
|
|
512
|
+
if (e.domEntry && e.domMtimeMs !== undefined) {
|
|
513
|
+
item.domUrl = `/api/webui/extensions/${encodeURIComponent(e.id)}/dom.js?v=${e.domMtimeMs}`;
|
|
514
|
+
}
|
|
515
|
+
return item;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let extManifestVersion = 0;
|
|
520
|
+
let lastManifestJSON = "";
|
|
521
|
+
const extManifestListeners = new Set<(msg: string) => void>();
|
|
522
|
+
|
|
523
|
+
function broadcastExtensionManifest() {
|
|
524
|
+
const msg = JSON.stringify({ type: "webui.extensions", version: extManifestVersion });
|
|
525
|
+
for (const send of extManifestListeners) send(msg);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Re-scan and push when the manifest actually changed (edits change ?v=
|
|
530
|
+
* mtimes, adds/removes/disabled-flips change the id set). Returns true on
|
|
531
|
+
* change. Watcher-triggered scans invalidate the TTL caches first so the
|
|
532
|
+
* push is immediate, not up-to-5s late.
|
|
533
|
+
*/
|
|
534
|
+
async function checkExtensionManifest(immediate: boolean): Promise<boolean> {
|
|
535
|
+
if (immediate) {
|
|
536
|
+
invalidateExtensionCache();
|
|
537
|
+
uiEntryCache = null;
|
|
538
|
+
}
|
|
539
|
+
const manifest = await buildExtensionManifest();
|
|
540
|
+
const json = JSON.stringify(manifest);
|
|
541
|
+
if (json === lastManifestJSON) return false;
|
|
542
|
+
lastManifestJSON = json;
|
|
543
|
+
extManifestVersion++;
|
|
544
|
+
dbg("extensions manifest v" + extManifestVersion + ":", manifest.length, "entr(ies)");
|
|
545
|
+
broadcastExtensionManifest();
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const watchedExtRoots = new Set<string>();
|
|
550
|
+
let extRescanTimer: ReturnType<typeof setTimeout> | null = null;
|
|
551
|
+
|
|
552
|
+
function scheduleExtRescan() {
|
|
553
|
+
if (extRescanTimer) return;
|
|
554
|
+
extRescanTimer = setTimeout(() => {
|
|
555
|
+
extRescanTimer = null;
|
|
556
|
+
void checkExtensionManifest(true);
|
|
557
|
+
}, 300); // coalesce save-bursts (edit + manifest.json write land together)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function ensureExtWatchers() {
|
|
561
|
+
const attach = (path: string) => {
|
|
562
|
+
if (watchedExtRoots.has(path)) return;
|
|
563
|
+
try {
|
|
564
|
+
const watcher = watch(path, { persistent: false }, () => scheduleExtRescan());
|
|
565
|
+
watcher.on("error", () => {
|
|
566
|
+
// Path deleted (or never existed) — drop it; a later rescan
|
|
567
|
+
// re-attaches when it reappears.
|
|
568
|
+
try {
|
|
569
|
+
watcher.close();
|
|
570
|
+
} catch {
|
|
571
|
+
/* already closed */
|
|
572
|
+
}
|
|
573
|
+
watchedExtRoots.delete(path);
|
|
574
|
+
});
|
|
575
|
+
watchedExtRoots.add(path);
|
|
576
|
+
} catch {
|
|
577
|
+
/* absent path — retry on the next rescan */
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
for (const root of extensionSourceRoots()) attach(root);
|
|
581
|
+
// Each extension folder too: a content-only edit (index.tsx bytes,
|
|
582
|
+
// manifest.json `disabled` flip) fires no event on the PARENT root watch
|
|
583
|
+
// (inotify reports only direct-child create/delete/rename there) — without
|
|
584
|
+
// this, edits would wait for the 5s backstop instead of pushing sub-second.
|
|
585
|
+
// discoverUserUIEntries() is TTL-cached, so this sweep is cheap.
|
|
586
|
+
const PREFIX = "webui-extensions:";
|
|
587
|
+
for (const e of discoverUserUIEntries()) {
|
|
588
|
+
const dir = e.entry
|
|
589
|
+
? dirname(e.entry)
|
|
590
|
+
: e.source?.startsWith(PREFIX)
|
|
591
|
+
? e.source.slice(PREFIX.length)
|
|
592
|
+
: null;
|
|
593
|
+
if (dir) attach(dir);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
let extWatcherRunning = false;
|
|
598
|
+
/** fs.watch for immediacy + a 5s re-scan for engine-plugin drift and roots. */
|
|
599
|
+
function startExtensionWatcher() {
|
|
600
|
+
if (extWatcherRunning) return;
|
|
601
|
+
extWatcherRunning = true;
|
|
602
|
+
ensureExtWatchers();
|
|
603
|
+
void checkExtensionManifest(true); // seed lastManifestJSON + version 1
|
|
604
|
+
setInterval(() => {
|
|
605
|
+
ensureExtWatchers(); // attach to roots that appeared since boot
|
|
606
|
+
void checkExtensionManifest(false);
|
|
607
|
+
}, 5_000).unref?.();
|
|
608
|
+
}
|
|
609
|
+
|
|
383
610
|
// ---------------------------------------------------------------------------
|
|
384
611
|
// Boot: CLI flag → auth policy → skill sync → serve → banner.
|
|
385
612
|
// ---------------------------------------------------------------------------
|
|
@@ -496,31 +723,79 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
496
723
|
return Response.json({ data: events });
|
|
497
724
|
}
|
|
498
725
|
|
|
499
|
-
//
|
|
726
|
+
// Extension manifest — folder entries (user > project > shipped) plus
|
|
727
|
+
// plugin UI halves. Disabled entries ship WITHOUT a url so the page can
|
|
728
|
+
// show them paused without importing anything. Shipped-origin browser
|
|
729
|
+
// entries likewise ship without `url` (Bug 2: the in-repo Vite glob owns
|
|
730
|
+
// them — a bundle URL here would double-load); shadowing user/project
|
|
731
|
+
// copies keep their `url` and same-id-swap. `origin` lets the page tell
|
|
732
|
+
// them apart; shipped `domUrl` still serves (the glob never loads dom).
|
|
500
733
|
if (method === "GET" && path === "/api/webui/extensions") {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
734
|
+
const data = await buildExtensionManifest();
|
|
735
|
+
dbg("extensions list:", data.length, "ui entr(ies)");
|
|
736
|
+
return Response.json({ data, version: extManifestVersion });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Manifest push channel (spec §6): one event per manifest change plus a
|
|
740
|
+
// hello on subscribe. The page re-fetches the manifest on each event and
|
|
741
|
+
// re-imports only bundles whose ?v= moved. Heartbeat comments keep the
|
|
742
|
+
// stream alive through idle infrastructure.
|
|
743
|
+
if (method === "GET" && path === "/api/webui/extensions/events") {
|
|
744
|
+
const encoder = new TextEncoder();
|
|
745
|
+
let send: ((msg: string) => void) | null = null;
|
|
746
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
747
|
+
const stream = new ReadableStream({
|
|
748
|
+
start(controller) {
|
|
749
|
+
send = (msg: string) => {
|
|
750
|
+
try {
|
|
751
|
+
controller.enqueue(encoder.encode(`data: ${msg}\n\n`));
|
|
752
|
+
} catch {
|
|
753
|
+
/* client gone — cancel() cleans up */
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
extManifestListeners.add(send);
|
|
757
|
+
send(JSON.stringify({ type: "webui.extensions", version: extManifestVersion }));
|
|
758
|
+
heartbeat = setInterval(() => {
|
|
759
|
+
try {
|
|
760
|
+
controller.enqueue(encoder.encode(`: ping\n\n`));
|
|
761
|
+
} catch {
|
|
762
|
+
/* client gone */
|
|
763
|
+
}
|
|
764
|
+
}, 15_000);
|
|
765
|
+
},
|
|
766
|
+
cancel() {
|
|
767
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
768
|
+
if (send) extManifestListeners.delete(send);
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
return new Response(stream, {
|
|
772
|
+
headers: {
|
|
773
|
+
"content-type": "text/event-stream",
|
|
774
|
+
"cache-control": "no-cache",
|
|
775
|
+
connection: "keep-alive",
|
|
776
|
+
},
|
|
510
777
|
});
|
|
511
778
|
}
|
|
512
779
|
|
|
513
|
-
|
|
514
|
-
|
|
780
|
+
// Browser-stratum bundle (`bundle.js`) and DOM-stratum bundle
|
|
781
|
+
// (`dom.js`, spec §7) — same mtime-keyed Bun.build pipeline, same
|
|
782
|
+
// no-cache serving. Disabled entries 404 — the page must never import a
|
|
783
|
+
// paused extension.
|
|
784
|
+
if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/(bundle|dom)\.js$/.test(path)) {
|
|
785
|
+
const segs = path.split("/");
|
|
786
|
+
const id = decodeURIComponent(segs[4] ?? "");
|
|
787
|
+
const wantDom = (segs[5] ?? "").startsWith("dom");
|
|
515
788
|
try {
|
|
516
789
|
// Resolve through the CURRENT discovery result so removed/expired
|
|
517
|
-
// plugins 404 instead of serving a stale bundle.
|
|
790
|
+
// plugins 404 instead of serving a stale bundle. Disabled entries
|
|
791
|
+
// 404 too — the page must never import a paused extension.
|
|
518
792
|
const found = (await discoverAllUIEntries()).find((e) => e.id === id);
|
|
519
|
-
|
|
793
|
+
const entry = wantDom ? found?.domEntry : found?.entry;
|
|
794
|
+
if (!found || found.disabled || !entry || !existsSync(entry)) {
|
|
520
795
|
return Response.json({ error: `unknown extension: ${id}` }, { status: 404 });
|
|
521
796
|
}
|
|
522
|
-
const js = await bundleUIEntry(
|
|
523
|
-
dbg("extensions bundle:", id, `${js.length}b`);
|
|
797
|
+
const js = await bundleUIEntry(entry); // throws -> 500 below
|
|
798
|
+
dbg("extensions bundle:", id, wantDom ? "(dom)" : "", `${js.length}b`);
|
|
524
799
|
return new Response(js, {
|
|
525
800
|
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
526
801
|
});
|
|
@@ -530,6 +805,24 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
530
805
|
}
|
|
531
806
|
}
|
|
532
807
|
|
|
808
|
+
if (path.startsWith("/api/webui/ext/")) {
|
|
809
|
+
const extRes = await dispatchExtRequest(req, url);
|
|
810
|
+
if (extRes) return extRes;
|
|
811
|
+
return Response.json({ error: "unknown extension route" }, { status: 404 });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Shared-React vendor shims (import-map targets — see bundleUIEntry).
|
|
815
|
+
// Same session auth as every other /api route (gate above applies);
|
|
816
|
+
// same-origin dynamic imports carry the session cookie. Content is
|
|
817
|
+
// derived from the running app's react copy, so no-cache (tiny files).
|
|
818
|
+
if (method === "GET" && path.startsWith("/api/webui/vendor/")) {
|
|
819
|
+
const js = vendorShimFor(path);
|
|
820
|
+
if (js === null) return Response.json({ error: "unknown vendor module" }, { status: 404 });
|
|
821
|
+
return new Response(js, {
|
|
822
|
+
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
533
826
|
if (path.startsWith("/api")) {
|
|
534
827
|
const isUpgrade = (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
|
|
535
828
|
if (isUpgrade) {
|
|
@@ -548,14 +841,21 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
548
841
|
}
|
|
549
842
|
const t0 = Date.now();
|
|
550
843
|
try {
|
|
844
|
+
// Proxy-stratum request middleware (spec §8): a returned Response
|
|
845
|
+
// short-circuits the passthrough; a returned Request replaces it.
|
|
846
|
+
let activeReq = req;
|
|
847
|
+
const extRewrite = await runExtRequestMiddleware(req);
|
|
848
|
+
if (extRewrite instanceof Response) return extRewrite;
|
|
849
|
+
if (extRewrite instanceof Request) activeReq = extRewrite;
|
|
850
|
+
const upMethod = activeReq.method;
|
|
551
851
|
const ep = await serviceEndpoint();
|
|
552
852
|
const headers = Service.headers(ep);
|
|
553
853
|
const upstream: Response = await fetch(`${ep.url}${path}${url.search}`, {
|
|
554
|
-
method,
|
|
854
|
+
method: upMethod,
|
|
555
855
|
// Abort the upstream request when the browser client goes away,
|
|
556
856
|
// otherwise streamed responses (SSE) leak one connection per
|
|
557
857
|
// client reconnect until the pool wedges and requests hang.
|
|
558
|
-
signal:
|
|
858
|
+
signal: activeReq.signal,
|
|
559
859
|
headers: {
|
|
560
860
|
...headers,
|
|
561
861
|
// Forward only benign client headers. Service.headers must WIN —
|
|
@@ -563,7 +863,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
563
863
|
// engine credential (e.g. its own `authorization`). Also drop
|
|
564
864
|
// spoofable/transport headers the engine should never see.
|
|
565
865
|
...Object.fromEntries(
|
|
566
|
-
[...
|
|
866
|
+
[...activeReq.headers.entries()].filter(([k]) => {
|
|
567
867
|
const name = k.toLowerCase();
|
|
568
868
|
if (FORBIDDEN_CLIENT_HEADERS.has(name)) return false;
|
|
569
869
|
return (
|
|
@@ -578,7 +878,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
578
878
|
// hops gain nothing from compression — never request it.
|
|
579
879
|
"accept-encoding": "identity",
|
|
580
880
|
},
|
|
581
|
-
body: ["GET", "HEAD"].includes(
|
|
881
|
+
body: ["GET", "HEAD"].includes(upMethod) ? undefined : activeReq.body,
|
|
582
882
|
redirect: "manual",
|
|
583
883
|
});
|
|
584
884
|
|
|
@@ -588,10 +888,14 @@ const server: Server<Record<string, unknown>> = Bun.serve({
|
|
|
588
888
|
responseHeaders.delete("content-encoding");
|
|
589
889
|
}
|
|
590
890
|
dbg("proxy:", method, path, "->", upstream.status, `${Date.now() - t0}ms`);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
891
|
+
// Proxy-stratum response middleware (spec §8): uniform rewriting.
|
|
892
|
+
return await applyExtResponseMiddleware(
|
|
893
|
+
new Response(upstream.body, {
|
|
894
|
+
status: upstream.status,
|
|
895
|
+
headers: responseHeaders,
|
|
896
|
+
}),
|
|
897
|
+
req,
|
|
898
|
+
);
|
|
595
899
|
} catch (err) {
|
|
596
900
|
const message = err instanceof Error ? err.message : String(err);
|
|
597
901
|
console.error("[webui] proxy error:", message);
|
|
@@ -696,10 +1000,12 @@ console.log(
|
|
|
696
1000
|
? `[webui] sandbox — loopback only, NO password; extensions (scratch): ${globalUserExtensionsDir()}`
|
|
697
1001
|
: `[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
|
|
698
1002
|
`[webui] same sessions as your opencode TUI — it's the same engine`,
|
|
699
|
-
`[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/
|
|
1003
|
+
`[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/ (index.tsx + manifest.json)`,
|
|
700
1004
|
SKILL.ok
|
|
701
1005
|
? `[webui] agent skill installed at ${SKILL.target} (auto-synced each boot)`
|
|
702
1006
|
: `[webui] agent skill NOT synced: ${SKILL.reason}`,
|
|
703
1007
|
].join("\n"),
|
|
704
1008
|
);
|
|
705
1009
|
void startEventRecorder();
|
|
1010
|
+
startExtensionWatcher();
|
|
1011
|
+
void startExtModules();
|