opencode-webui 1.0.9 → 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/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 a
244
- // self-contained ESM script (it carries its own React and talks to this app
245
- // only through the window.__opencodeUI bridge), and serve:
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
- // GET /api/webui/extensions -> { data: [{ id, url, source }] }
248
- // GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache
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
- * Plugin UI entries + user-dir entries, plugin ids winning collisions (a
328
- * user folder may never shadow a plugin's UI that's warned about once).
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 userEntries = discoverUserUIEntries();
333
- if (userEntries.length === 0) return pluginEntries;
334
- const ids = new Set(pluginEntries.map((e) => e.id));
335
- const merged = [...pluginEntries];
336
- for (const user of userEntries) {
337
- if (ids.has(user.id)) {
338
- warnOnce(`collide:${user.id}`, `user extension "${user.id}" skipped — a plugin already owns that id`);
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(user);
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
- // Plugin dirs have no node_modules resolve React from THIS app so every
357
- // runtime bundle shares one React copy. JSX dev/runtime variants included.
358
- plugins: [
359
- {
360
- name: "react-from-app",
361
- setup(build) {
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 EXTERNALnever 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
- // Plugin + user web-UI extensions must match before the generic /api proxy.
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
- // discoverAllUIEntries never throws; upstream failures collapse to { data: [] }.
502
- const entries = await discoverAllUIEntries();
503
- dbg("extensions list:", entries.length, "ui entr(ies)");
504
- return Response.json({
505
- data: entries.map((e) => ({
506
- id: e.id,
507
- source: e.source ?? e.entry,
508
- url: `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`,
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
- if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/bundle\.js$/.test(path)) {
514
- const id = decodeURIComponent(path.split("/")[4] ?? "");
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
- if (!found || !existsSync(found.entry)) {
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(found.entry); // throws -> 500 below
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: req.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
- [...req.headers.entries()].filter(([k]) => {
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(method) ? undefined : req.body,
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
- return new Response(upstream.body, {
592
- status: upstream.status,
593
- headers: responseHeaders,
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>/main.tsx`,
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();