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/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-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
|
@@ -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
|
+
* - Engine-credential passthrough for pollers that must call the engine
|
|
30
|
+
* (needs a Service.headers helper that doesn't create an index.ts cycle).
|
|
31
|
+
* - SSE manifest push for server-module versions (browser bundle only).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { globalUserExtensionsDir, projectUserExtensionsDir, warnOnce } from "../userExtensions";
|
|
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) };
|
|
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,100 @@
|
|
|
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
|
+
|
|
47
|
+
/** Extra context for route handlers. */
|
|
48
|
+
export interface ExtRouteContext extends ExtServerContext {
|
|
49
|
+
/** Full request URL (parsed). */
|
|
50
|
+
url: URL;
|
|
51
|
+
/** Suffix segments after `/api/webui/ext/<id>/`. */
|
|
52
|
+
params: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ExtRoute {
|
|
56
|
+
/** Defaults to "GET". */
|
|
57
|
+
method?: string;
|
|
58
|
+
/** Suffix path, e.g. `"notify"` or `"hooks/session-finished"`. */
|
|
59
|
+
path: string;
|
|
60
|
+
handler: (req: Request, ctx: ExtRouteContext) => Response | Promise<Response>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ExtMiddleware {
|
|
64
|
+
/**
|
|
65
|
+
* Runs BEFORE the `/api/*` passthrough. Return a Response to
|
|
66
|
+
* short-circuit (serve directly); return a Request to replace the
|
|
67
|
+
* outgoing request (header/query transforms); return void to pass
|
|
68
|
+
* through untouched. Affects ALL clients — unlike browser hooks, which
|
|
69
|
+
* only affect their own browser.
|
|
70
|
+
*/
|
|
71
|
+
onRequest?: (req: Request, ctx: ExtServerContext) => Request | Response | void | Promise<Request | Response | void>;
|
|
72
|
+
/**
|
|
73
|
+
* Runs AFTER the upstream responded, before the browser sees it. Return
|
|
74
|
+
* a Response to replace (uniform rewriting, rate-limit headers, …).
|
|
75
|
+
*/
|
|
76
|
+
onResponse?: (
|
|
77
|
+
res: Response,
|
|
78
|
+
req: Request,
|
|
79
|
+
ctx: ExtServerContext,
|
|
80
|
+
) => Response | void | Promise<Response | void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ExtPoller {
|
|
84
|
+
/** Unique within the extension (used for hot-swap restart). */
|
|
85
|
+
id: string;
|
|
86
|
+
intervalMs: number;
|
|
87
|
+
run: (ctx: ExtServerContext) => void | Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Everything a proxy-stratum module may provide. All fields optional. */
|
|
91
|
+
export interface ServerExtensionModule {
|
|
92
|
+
routes?: ExtRoute[];
|
|
93
|
+
middleware?: ExtMiddleware;
|
|
94
|
+
/** Tap into the always-on engine event subscription (headless). */
|
|
95
|
+
onEvent?: (evt: ExtEngineEvent, ctx: ExtServerContext) => void | Promise<void>;
|
|
96
|
+
/** Always-on ticks that survive closed tabs. */
|
|
97
|
+
pollers?: ExtPoller[];
|
|
98
|
+
/** Called on hot-swap/dispose so timers/sockets don't leak. */
|
|
99
|
+
dispose?: () => void;
|
|
100
|
+
}
|