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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Proxy-stratum loader + mount points (spec §8 scaffolding).
3
+ *
4
+ * Discovery: every extension dir root is scanned for `<name>/server.ts`
5
+ * (plus `manifest.json` for `{ id, disabled }`). Roots, in precedence
6
+ * order: user dir → project dir → shipped `webui-extensions/` (when it
7
+ * exists). Same id at higher precedence wins; `disabled: true` pauses.
8
+ * Presence = installed, delete/move = uninstalled — same gating rule as
9
+ * every other stratum.
10
+ *
11
+ * Hot reload: stat-poll (2s) + ESM re-import with `?v=<mtime>` cache-bust.
12
+ * No proxy restart for code edits (dev's existing `--watch` restart stays;
13
+ * SSE self-reconnects). The proven suggestion in the spec is CJS require
14
+ * with cache deletion; the equivalent used here is query-busted dynamic
15
+ * import — same property (fresh module per mtime), no restart.
16
+ *
17
+ * Mount points provided (core stays thin and non-forkable):
18
+ * - `dispatchExtRequest(req, url)` — routes auto-mounted at
19
+ * `/api/webui/ext/<id>/…`, called BEFORE the generic /api proxy.
20
+ * - `runExtRequestMiddleware` / `applyExtResponseMiddleware` — wrap the
21
+ * `/api/*` passthrough chain (uniform transforms, all clients).
22
+ * - `dispatchExtEvent(evt)` — tap into the recorder's always-on engine
23
+ * event subscription; called from `recordEvent()` in index.ts.
24
+ * - `startExtModules()` — boot hook: discover + start pollers/schedules
25
+ * (always-on ticks that survive closed tabs).
26
+ *
27
+ * GAPS (follow-ups, not in this scaffolding):
28
+ * - `server/` directory form (only bare `server.ts` is discovered).
29
+ * - SSE manifest push for server-module versions (browser bundle only).
30
+ * (Engine credentials shipped as `ctx.engine` — see `engine.ts`.)
31
+ */
32
+
33
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { globalUserExtensionsDir, projectUserExtensionsDir, warnOnce } from "../userExtensions";
37
+ import { engine } from "./engine";
38
+ import { kvFor } from "./kv";
39
+ import type { ExtEngineEvent, ExtServerContext, ServerExtensionModule } from "./types";
40
+
41
+ const POLL_MS = 2_000;
42
+
43
+ type LoadedExt = {
44
+ id: string;
45
+ dir: string;
46
+ entry: string;
47
+ mtimeMs: number;
48
+ module: ServerExtensionModule;
49
+ timers: ReturnType<typeof setInterval>[];
50
+ };
51
+
52
+ const loaded = new Map<string, LoadedExt>();
53
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
54
+
55
+ function shippedDir(): string | null {
56
+ const root = fileURLToPath(new URL("../../", import.meta.url));
57
+ const dir = join(root, "webui-extensions");
58
+ return existsSync(dir) ? dir : null;
59
+ }
60
+
61
+ function readManifest(dir: string): { id?: string; disabled?: boolean } {
62
+ try {
63
+ const raw = readFileSync(join(dir, "manifest.json"), "utf8");
64
+ return JSON.parse(raw) as { id?: string; disabled?: boolean };
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ /** Structural validation — extensions need no import of core types. */
71
+ function isServerExtensionModule(obj: unknown): obj is ServerExtensionModule {
72
+ if (typeof obj !== "object" || obj === null) return false;
73
+ const m = obj as Record<string, unknown>;
74
+ for (const key of ["routes", "pollers"]) {
75
+ if (m[key] !== undefined && !Array.isArray(m[key])) return false;
76
+ }
77
+ const mw = m["middleware"];
78
+ if (mw !== undefined && (typeof mw !== "object" || mw === null)) return false;
79
+ if (m["onEvent"] !== undefined && typeof m["onEvent"] !== "function") return false;
80
+ if (m["dispose"] !== undefined && typeof m["dispose"] !== "function") return false;
81
+ return true;
82
+ }
83
+
84
+ function ctxFor(id: string): ExtServerContext {
85
+ return { extID: id, kv: kvFor(id), engine };
86
+ }
87
+
88
+ async function loadEntry(id: string, dir: string, entry: string): Promise<void> {
89
+ const mtimeMs = statSync(entry).mtimeMs;
90
+ // Bare-path + ?v= busts Bun's module cache; file:// URLs do NOT (verified:
91
+ // file://…?v= serves stale in a long-lived process while /path?v= is fresh).
92
+ const url = `${entry}?v=${mtimeMs}`;
93
+ const imported = (await import(url)) as { default?: unknown };
94
+ const mod = imported.default;
95
+ if (!isServerExtensionModule(mod)) {
96
+ warnOnce(`ext-shape:${id}`, `server extension "${id}" has no valid default export — skipped`);
97
+ return;
98
+ }
99
+ // Swap path: stop the old module's pollers BEFORE installing the new one.
100
+ unloadEntry(id);
101
+ const timers: ReturnType<typeof setInterval>[] = [];
102
+ for (const poller of mod.pollers ?? []) {
103
+ const ctx = ctxFor(id);
104
+ const timer = setInterval(() => {
105
+ try {
106
+ const r = poller.run(ctx);
107
+ if (r instanceof Promise) r.catch((err) => console.error(`[webui] ext "${id}" poller "${poller.id}" failed:`, err));
108
+ } catch (err) {
109
+ console.error(`[webui] ext "${id}" poller "${poller.id}" failed:`, err);
110
+ }
111
+ }, poller.intervalMs);
112
+ timers.push(timer);
113
+ }
114
+ loaded.set(id, { id, dir, entry, mtimeMs, module: mod, timers });
115
+ console.log(`[webui] server extension loaded: ${id}`);
116
+ }
117
+
118
+ function unloadEntry(id: string): void {
119
+ const prev = loaded.get(id);
120
+ if (!prev) return;
121
+ for (const t of prev.timers) clearInterval(t);
122
+ try {
123
+ prev.module.dispose?.();
124
+ } catch (err) {
125
+ console.error(`[webui] ext "${id}" dispose failed:`, err);
126
+ }
127
+ loaded.delete(id);
128
+ }
129
+
130
+ /** One discovery pass: load new/changed, unload removed/disabled. */
131
+ async function discoverOnce(): Promise<void> {
132
+ const roots = [globalUserExtensionsDir(), projectUserExtensionsDir(), shippedDir()];
133
+ const seen = new Map<string, { dir: string; entry: string }>();
134
+ for (const root of roots) {
135
+ if (!root) continue;
136
+ let names: string[];
137
+ try {
138
+ names = readdirSync(root, { withFileTypes: true })
139
+ .filter((d) => d.isDirectory())
140
+ .map((d) => d.name);
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const name of names) {
145
+ if (seen.has(name)) continue; // higher-precedence root wins
146
+ const entry = join(root, name, "server.ts");
147
+ try {
148
+ if (!existsSync(entry)) continue;
149
+ } catch {
150
+ continue;
151
+ }
152
+ seen.set(name, { dir: join(root, name), entry });
153
+ }
154
+ }
155
+
156
+ // Removed from disk (or newly disabled) → unload.
157
+ for (const id of [...loaded.keys()]) {
158
+ const found = seen.get(id);
159
+ if (!found) {
160
+ unloadEntry(id);
161
+ continue;
162
+ }
163
+ if (readManifest(found.dir).disabled === true) unloadEntry(id);
164
+ }
165
+
166
+ // New or mtime-moved → (re)load. Disabled = paused, never loaded.
167
+ for (const [name, { dir, entry }] of seen) {
168
+ let manifestId = name;
169
+ try {
170
+ if (readManifest(dir).disabled === true) continue;
171
+ const mid = readManifest(dir).id;
172
+ if (mid) manifestId = mid;
173
+ } catch {
174
+ /* unreadable manifest — load by folder name */
175
+ }
176
+ const prev = loaded.get(manifestId);
177
+ let mtimeMs = 0;
178
+ try {
179
+ mtimeMs = statSync(entry).mtimeMs;
180
+ } catch {
181
+ continue;
182
+ }
183
+ if (!prev || prev.mtimeMs !== mtimeMs || prev.entry !== entry) {
184
+ try {
185
+ await loadEntry(manifestId, dir, entry);
186
+ } catch (err) {
187
+ console.error(`[webui] server extension "${manifestId}" failed to load:`, err);
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Boot hook (idempotent): initial discovery + stat-poll. Call once. */
194
+ export function startExtModules(): void {
195
+ if (pollTimer) return;
196
+ void discoverOnce().catch((err) => console.error("[webui] ext discovery failed:", err));
197
+ pollTimer = setInterval(() => {
198
+ void discoverOnce().catch((err) => console.error("[webui] ext re-discovery failed:", err));
199
+ }, POLL_MS);
200
+ }
201
+
202
+ /**
203
+ * Route dispatch for `/api/webui/ext/<id>/…`. Returns a Response when an
204
+ * extension route handled it, `null` to fall through (unknown id/route →
205
+ * the caller 404s; never falls through to the engine).
206
+ */
207
+ export async function dispatchExtRequest(req: Request, url: URL): Promise<Response | null> {
208
+ const rest = url.pathname.slice("/api/webui/ext/".length);
209
+ const slash = rest.indexOf("/");
210
+ const id = slash < 0 ? rest : rest.slice(0, slash);
211
+ const suffix = slash < 0 ? "" : rest.slice(slash + 1);
212
+ const ext = loaded.get(decodeURIComponent(id));
213
+ if (!ext) return null;
214
+ const method = req.method.toUpperCase();
215
+ for (const route of ext.module.routes ?? []) {
216
+ const routeMethod = (route.method ?? "GET").toUpperCase();
217
+ if (routeMethod !== method) continue;
218
+ const want = route.path.replace(/^\/+|\/+$/g, "");
219
+ if (decodeURIComponent(suffix).replace(/^\/+|\/+$/g, "") !== want) continue;
220
+ try {
221
+ return await route.handler(req, {
222
+ ...ctxFor(ext.id),
223
+ url,
224
+ params: suffix ? suffix.split("/") : [],
225
+ });
226
+ } catch (err) {
227
+ console.error(`[webui] ext "${ext.id}" route "${route.path}" failed:`, err);
228
+ return Response.json({ error: `extension route failed: ${ext.id}/${route.path}` }, { status: 500 });
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Request middleware: first non-void result wins. A returned Response
236
+ * short-circuits the `/api/*` passthrough; a returned Request replaces it.
237
+ */
238
+ export async function runExtRequestMiddleware(req: Request): Promise<Request | Response | null> {
239
+ for (const ext of loaded.values()) {
240
+ const fn = ext.module.middleware?.onRequest;
241
+ if (!fn) continue;
242
+ try {
243
+ const out = await fn(req, ctxFor(ext.id));
244
+ if (out instanceof Request || out instanceof Response) return out;
245
+ } catch (err) {
246
+ console.error(`[webui] ext "${ext.id}" onRequest failed:`, err);
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** Response middleware: each may replace the upstream response in order. */
253
+ export async function applyExtResponseMiddleware(res: Response, req: Request): Promise<Response> {
254
+ let current = res;
255
+ for (const ext of loaded.values()) {
256
+ const fn = ext.module.middleware?.onResponse;
257
+ if (!fn) continue;
258
+ try {
259
+ const out = await fn(current, req, ctxFor(ext.id));
260
+ if (out instanceof Response) current = out;
261
+ } catch (err) {
262
+ console.error(`[webui] ext "${ext.id}" onResponse failed:`, err);
263
+ }
264
+ }
265
+ return current;
266
+ }
267
+
268
+ /** Event tap: headless reaction to the recorder's always-on subscription. */
269
+ export async function dispatchExtEvent(evt: ExtEngineEvent): Promise<void> {
270
+ for (const ext of loaded.values()) {
271
+ const fn = ext.module.onEvent;
272
+ if (!fn) continue;
273
+ try {
274
+ await fn(evt, ctxFor(ext.id));
275
+ } catch (err) {
276
+ console.error(`[webui] ext "${ext.id}" onEvent failed:`, err);
277
+ }
278
+ }
279
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Proxy stratum types (spec §8).
3
+ *
4
+ * A `server.ts` (or `server/` dir with an index) in an extension folder may
5
+ * provide any of: `routes`, `middleware`, `onEvent`, `pollers`. The module
6
+ * default-exports the object — NO import needed on the extension side; the
7
+ * loader validates the shape structurally (`isServerExtensionModule`) so
8
+ * extensions never depend on a resolvable core path.
9
+ *
10
+ * ```ts
11
+ * // my-extension/server.ts
12
+ * export default {
13
+ * routes: [
14
+ * {
15
+ * method: "POST", path: "notify",
16
+ * handler: async (req, ctx) => {
17
+ * await ctx.kv.set("lastPing", Date.now());
18
+ * return Response.json({ ok: true });
19
+ * },
20
+ * },
21
+ * ],
22
+ * onEvent: async (evt) => { / headless reaction, all tabs closed / },
23
+ * pollers: [
24
+ * { id: "keepalive", intervalMs: 60_000, run: async () => {} },
25
+ * ],
26
+ * };
27
+ * ```
28
+ */
29
+
30
+ import type { ExtKV } from "./kv";
31
+
32
+ /** Engine event tapped from the recorder's always-on subscription. */
33
+ export interface ExtEngineEvent {
34
+ id: string;
35
+ type: string;
36
+ data: unknown;
37
+ }
38
+
39
+ /** Per-request context for routes, middleware, and pollers. */
40
+ export interface ExtServerContext {
41
+ /** This extension's id (folder name / manifest id). */
42
+ extID: string;
43
+ /** Namespaced persistent KV (JSON-file backed, see kv.ts). */
44
+ kv: ExtKV;
45
+ /**
46
+ * Engine fetch helper (see `engine.ts`). Replaces hand-parsing
47
+ * `service.json`: `await ctx.engine.fetch("/api/session/active")`.
48
+ * Honors `WEBUI_ENGINE_URL` / `WEBUI_ENGINE_PASSWORD` (explicit env
49
+ * wins); auth headers always win over caller-supplied ones.
50
+ */
51
+ engine: ExtEngine;
52
+ }
53
+
54
+ /**
55
+ * Engine credential helper (implemented in `engine.ts`, node builtins only
56
+ * — no core import needed on the extension side; the context is structural).
57
+ */
58
+ export interface ExtEngine {
59
+ /** Effective engine base URL (`WEBUI_ENGINE_URL` wins, else `service.json`). `null` when undiscoverable. */
60
+ baseUrl(): string | null;
61
+ /** Auth headers for the engine (Basic `opencode:<password>`), or `{}` when unauthenticated. */
62
+ headers(): Record<string, string>;
63
+ /**
64
+ * Fetch against the engine (`/api/...` paths; leading slash optional).
65
+ * Throws with a remedy when undiscoverable; HTTP errors are returned, not
66
+ * thrown. Aborts after 15s unless the caller passes its own signal.
67
+ */
68
+ fetch(path: string, init?: RequestInit): Promise<Response>;
69
+ }
70
+
71
+ /** Extra context for route handlers. */
72
+ export interface ExtRouteContext extends ExtServerContext {
73
+ /** Full request URL (parsed). */
74
+ url: URL;
75
+ /** Suffix segments after `/api/webui/ext/<id>/`. */
76
+ params: string[];
77
+ }
78
+
79
+ export interface ExtRoute {
80
+ /** Defaults to "GET". */
81
+ method?: string;
82
+ /** Suffix path, e.g. `"notify"` or `"hooks/session-finished"`. */
83
+ path: string;
84
+ handler: (req: Request, ctx: ExtRouteContext) => Response | Promise<Response>;
85
+ }
86
+
87
+ export interface ExtMiddleware {
88
+ /**
89
+ * Runs BEFORE the `/api/*` passthrough. Return a Response to
90
+ * short-circuit (serve directly); return a Request to replace the
91
+ * outgoing request (header/query transforms); return void to pass
92
+ * through untouched. Affects ALL clients — unlike browser hooks, which
93
+ * only affect their own browser.
94
+ */
95
+ onRequest?: (req: Request, ctx: ExtServerContext) => Request | Response | void | Promise<Request | Response | void>;
96
+ /**
97
+ * Runs AFTER the upstream responded, before the browser sees it. Return
98
+ * a Response to replace (uniform rewriting, rate-limit headers, …).
99
+ */
100
+ onResponse?: (
101
+ res: Response,
102
+ req: Request,
103
+ ctx: ExtServerContext,
104
+ ) => Response | void | Promise<Response | void>;
105
+ }
106
+
107
+ export interface ExtPoller {
108
+ /** Unique within the extension (used for hot-swap restart). */
109
+ id: string;
110
+ intervalMs: number;
111
+ run: (ctx: ExtServerContext) => void | Promise<void>;
112
+ }
113
+
114
+ /** Everything a proxy-stratum module may provide. All fields optional. */
115
+ export interface ServerExtensionModule {
116
+ routes?: ExtRoute[];
117
+ middleware?: ExtMiddleware;
118
+ /** Tap into the always-on engine event subscription (headless). */
119
+ onEvent?: (evt: ExtEngineEvent, ctx: ExtServerContext) => void | Promise<void>;
120
+ /** Always-on ticks that survive closed tabs. */
121
+ pollers?: ExtPoller[];
122
+ /** Called on hot-swap/dispose so timers/sockets don't leak. */
123
+ dispose?: () => void;
124
+ }