backpack-viewer 0.6.0 → 0.7.1
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/bin/serve.js +159 -396
- package/dist/app/assets/index-D-H7agBH.js +12 -0
- package/dist/app/assets/index-DE73ngo-.css +1 -0
- package/dist/app/assets/index-Lvl7EMM_.js +6 -0
- package/dist/app/index.html +2 -2
- package/dist/bridge.d.ts +22 -0
- package/dist/bridge.js +41 -0
- package/dist/config.js +10 -0
- package/dist/copy-prompt.d.ts +17 -0
- package/dist/copy-prompt.js +81 -0
- package/dist/default-config.json +4 -0
- package/dist/dom-utils.d.ts +46 -0
- package/dist/dom-utils.js +57 -0
- package/dist/empty-state.js +63 -31
- package/dist/extensions/api.d.ts +15 -0
- package/dist/extensions/api.js +185 -0
- package/dist/extensions/chat/backpack-extension.json +23 -0
- package/dist/extensions/chat/src/index.js +32 -0
- package/dist/extensions/chat/src/panel.js +306 -0
- package/dist/extensions/chat/src/providers/anthropic.js +158 -0
- package/dist/extensions/chat/src/providers/types.js +15 -0
- package/dist/extensions/chat/src/tools.js +281 -0
- package/dist/extensions/chat/style.css +147 -0
- package/dist/extensions/event-bus.d.ts +12 -0
- package/dist/extensions/event-bus.js +30 -0
- package/dist/extensions/loader.d.ts +32 -0
- package/dist/extensions/loader.js +71 -0
- package/dist/extensions/manifest.d.ts +54 -0
- package/dist/extensions/manifest.js +116 -0
- package/dist/extensions/panel-mount.d.ts +26 -0
- package/dist/extensions/panel-mount.js +377 -0
- package/dist/extensions/share/backpack-extension.json +20 -0
- package/dist/extensions/share/src/index.js +357 -0
- package/dist/extensions/share/style.css +151 -0
- package/dist/extensions/taskbar.d.ts +29 -0
- package/dist/extensions/taskbar.js +64 -0
- package/dist/extensions/types.d.ts +182 -0
- package/dist/extensions/types.js +8 -0
- package/dist/info-panel.d.ts +2 -1
- package/dist/info-panel.js +78 -87
- package/dist/main.js +189 -29
- package/dist/search.js +1 -1
- package/dist/server-api-routes.d.ts +56 -0
- package/dist/server-api-routes.js +460 -0
- package/dist/server-extensions.d.ts +126 -0
- package/dist/server-extensions.js +272 -0
- package/dist/server-viewer-state.d.ts +18 -0
- package/dist/server-viewer-state.js +33 -0
- package/dist/sidebar.js +19 -7
- package/dist/style.css +356 -74
- package/dist/tools-pane.js +31 -14
- package/package.json +4 -3
- package/dist/app/assets/index-B3z5bBGl.css +0 -1
- package/dist/app/assets/index-BROJmzot.js +0 -35
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { listBackpacks, getActiveBackpack, setActiveBackpack, registerBackpack, unregisterBackpack, } from "backpack-ontology";
|
|
2
|
+
// --- Small HTTP helpers (used only inside this module) ---
|
|
3
|
+
function readBody(req) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
let body = "";
|
|
6
|
+
req.on("data", (chunk) => {
|
|
7
|
+
body += chunk.toString();
|
|
8
|
+
});
|
|
9
|
+
req.on("end", () => resolve(body));
|
|
10
|
+
req.on("error", reject);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function sendJson(res, status, value) {
|
|
14
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
15
|
+
res.end(JSON.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
function sendErr(res, status, message) {
|
|
18
|
+
sendJson(res, status, { error: message });
|
|
19
|
+
}
|
|
20
|
+
function urlPath(req) {
|
|
21
|
+
return (req.url ?? "/").replace(/\?.*$/, "");
|
|
22
|
+
}
|
|
23
|
+
// --- Main dispatcher ---
|
|
24
|
+
/**
|
|
25
|
+
* Try to match and handle an API request. Returns true if the request
|
|
26
|
+
* was handled (response written), false if no route matched and the
|
|
27
|
+
* caller should fall through to its own handlers (e.g., static files).
|
|
28
|
+
*
|
|
29
|
+
* Errors that escape route handlers result in a 500 with a JSON body.
|
|
30
|
+
*/
|
|
31
|
+
export async function handleApiRequest(req, res, ctx) {
|
|
32
|
+
const method = req.method ?? "GET";
|
|
33
|
+
const url = urlPath(req);
|
|
34
|
+
try {
|
|
35
|
+
// --- /api/config ---
|
|
36
|
+
if (url === "/api/config" && method === "GET") {
|
|
37
|
+
sendJson(res, 200, ctx.viewerConfig);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
// --- /api/version-check ---
|
|
41
|
+
if (url === "/api/version-check" && method === "GET") {
|
|
42
|
+
const result = await ctx.versionCheck();
|
|
43
|
+
sendJson(res, 200, result);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// --- /api/remotes (read-only) ---
|
|
47
|
+
if (url === "/api/remotes" && method === "GET") {
|
|
48
|
+
const remotes = await ctx.remoteRegistry.list();
|
|
49
|
+
const summaries = await Promise.all(remotes.map(async (r) => {
|
|
50
|
+
let nodeCount = 0;
|
|
51
|
+
let edgeCount = 0;
|
|
52
|
+
try {
|
|
53
|
+
const data = await ctx.remoteRegistry.loadCached(r.name);
|
|
54
|
+
nodeCount = data.nodes.length;
|
|
55
|
+
edgeCount = data.edges.length;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* keep counts at 0 */
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
name: r.name,
|
|
62
|
+
url: r.url,
|
|
63
|
+
source: r.source,
|
|
64
|
+
addedAt: r.addedAt,
|
|
65
|
+
lastFetched: r.lastFetched,
|
|
66
|
+
pinned: r.pinned,
|
|
67
|
+
sizeBytes: r.sizeBytes,
|
|
68
|
+
nodeCount,
|
|
69
|
+
edgeCount,
|
|
70
|
+
};
|
|
71
|
+
}));
|
|
72
|
+
sendJson(res, 200, summaries);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const remoteItem = url.match(/^\/api\/remotes\/(.+)$/);
|
|
76
|
+
if (remoteItem && method === "GET") {
|
|
77
|
+
const name = decodeURIComponent(remoteItem[1]);
|
|
78
|
+
try {
|
|
79
|
+
const data = await ctx.remoteRegistry.loadCached(name);
|
|
80
|
+
sendJson(res, 200, data);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
sendErr(res, 404, err.message);
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// --- /api/graphs/<name>/branches/* ---
|
|
88
|
+
const branchSwitch = url.match(/^\/api\/graphs\/(.+)\/branches\/switch$/);
|
|
89
|
+
if (branchSwitch && method === "POST") {
|
|
90
|
+
const graphName = decodeURIComponent(branchSwitch[1]);
|
|
91
|
+
const body = await readBody(req);
|
|
92
|
+
try {
|
|
93
|
+
const { name: branchName } = JSON.parse(body);
|
|
94
|
+
await ctx.storage.current.switchBranch(graphName, branchName);
|
|
95
|
+
sendJson(res, 200, { ok: true });
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
sendErr(res, 400, err.message);
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
const deleteBranch = url.match(/^\/api\/graphs\/(.+)\/branches\/(.+)$/);
|
|
103
|
+
if (deleteBranch && method === "DELETE") {
|
|
104
|
+
const graphName = decodeURIComponent(deleteBranch[1]);
|
|
105
|
+
const branchName = decodeURIComponent(deleteBranch[2]);
|
|
106
|
+
try {
|
|
107
|
+
await ctx.storage.current.deleteBranch(graphName, branchName);
|
|
108
|
+
sendJson(res, 200, { ok: true });
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
sendErr(res, 400, err.message);
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const branches = url.match(/^\/api\/graphs\/(.+)\/branches$/);
|
|
116
|
+
if (branches && method === "GET") {
|
|
117
|
+
const graphName = decodeURIComponent(branches[1]);
|
|
118
|
+
try {
|
|
119
|
+
const list = await ctx.storage.current.listBranches(graphName);
|
|
120
|
+
sendJson(res, 200, list);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
sendErr(res, 500, err.message);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (branches && method === "POST") {
|
|
128
|
+
const graphName = decodeURIComponent(branches[1]);
|
|
129
|
+
const body = await readBody(req);
|
|
130
|
+
try {
|
|
131
|
+
const { name: branchName, from } = JSON.parse(body);
|
|
132
|
+
await ctx.storage.current.createBranch(graphName, branchName, from);
|
|
133
|
+
sendJson(res, 200, { ok: true });
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
sendErr(res, 400, err.message);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// --- /api/graphs/<name>/snapshots ---
|
|
141
|
+
const snapshots = url.match(/^\/api\/graphs\/(.+)\/snapshots$/);
|
|
142
|
+
if (snapshots && method === "GET") {
|
|
143
|
+
const graphName = decodeURIComponent(snapshots[1]);
|
|
144
|
+
try {
|
|
145
|
+
const list = await ctx.storage.current.listSnapshots(graphName);
|
|
146
|
+
sendJson(res, 200, list);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
sendErr(res, 500, err.message);
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
if (snapshots && method === "POST") {
|
|
154
|
+
const graphName = decodeURIComponent(snapshots[1]);
|
|
155
|
+
const body = await readBody(req);
|
|
156
|
+
try {
|
|
157
|
+
const { label } = JSON.parse(body);
|
|
158
|
+
await ctx.storage.current.createSnapshot(graphName, label);
|
|
159
|
+
sendJson(res, 200, { ok: true });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
sendErr(res, 400, err.message);
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// --- /api/graphs/<name>/rollback ---
|
|
167
|
+
const rollback = url.match(/^\/api\/graphs\/(.+)\/rollback$/);
|
|
168
|
+
if (rollback && method === "POST") {
|
|
169
|
+
const graphName = decodeURIComponent(rollback[1]);
|
|
170
|
+
const body = await readBody(req);
|
|
171
|
+
try {
|
|
172
|
+
const { version } = JSON.parse(body);
|
|
173
|
+
await ctx.storage.current.rollback(graphName, version);
|
|
174
|
+
sendJson(res, 200, { ok: true });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
sendErr(res, 400, err.message);
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
// --- /api/graphs/<name>/diff/<version> ---
|
|
182
|
+
const diff = url.match(/^\/api\/graphs\/(.+)\/diff\/(\d+)$/);
|
|
183
|
+
if (diff && method === "GET") {
|
|
184
|
+
const graphName = decodeURIComponent(diff[1]);
|
|
185
|
+
const version = parseInt(diff[2], 10);
|
|
186
|
+
try {
|
|
187
|
+
const current = await ctx.storage.current.loadOntology(graphName);
|
|
188
|
+
const snapshot = await ctx.storage.current.loadSnapshot(graphName, version);
|
|
189
|
+
const currentNodeIds = new Set(current.nodes.map((n) => n.id));
|
|
190
|
+
const snapshotNodeIds = new Set(snapshot.nodes.map((n) => n.id));
|
|
191
|
+
const currentEdgeIds = new Set(current.edges.map((e) => e.id));
|
|
192
|
+
const snapshotEdgeIds = new Set(snapshot.edges.map((e) => e.id));
|
|
193
|
+
sendJson(res, 200, {
|
|
194
|
+
nodesAdded: current.nodes.filter((n) => !snapshotNodeIds.has(n.id)).length,
|
|
195
|
+
nodesRemoved: snapshot.nodes.filter((n) => !currentNodeIds.has(n.id)).length,
|
|
196
|
+
edgesAdded: current.edges.filter((e) => !snapshotEdgeIds.has(e.id)).length,
|
|
197
|
+
edgesRemoved: snapshot.edges.filter((e) => !currentEdgeIds.has(e.id)).length,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
sendErr(res, 500, err.message);
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
// --- /api/graphs/<name>/snippets/<id> ---
|
|
206
|
+
const snippetItem = url.match(/^\/api\/graphs\/(.+)\/snippets\/(.+)$/);
|
|
207
|
+
if (snippetItem && method === "GET") {
|
|
208
|
+
const graphName = decodeURIComponent(snippetItem[1]);
|
|
209
|
+
const snippetId = decodeURIComponent(snippetItem[2]);
|
|
210
|
+
try {
|
|
211
|
+
const snippet = await ctx.storage.current.loadSnippet(graphName, snippetId);
|
|
212
|
+
sendJson(res, 200, snippet);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
sendErr(res, 404, "Snippet not found");
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (snippetItem && method === "DELETE") {
|
|
220
|
+
const graphName = decodeURIComponent(snippetItem[1]);
|
|
221
|
+
const snippetId = decodeURIComponent(snippetItem[2]);
|
|
222
|
+
try {
|
|
223
|
+
await ctx.storage.current.deleteSnippet(graphName, snippetId);
|
|
224
|
+
sendJson(res, 200, { ok: true });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
sendErr(res, 400, err.message);
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const snippets = url.match(/^\/api\/graphs\/(.+)\/snippets$/);
|
|
232
|
+
if (snippets && method === "GET") {
|
|
233
|
+
const graphName = decodeURIComponent(snippets[1]);
|
|
234
|
+
try {
|
|
235
|
+
const list = await ctx.storage.current.listSnippets(graphName);
|
|
236
|
+
sendJson(res, 200, list);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
sendJson(res, 200, []);
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (snippets && method === "POST") {
|
|
244
|
+
const graphName = decodeURIComponent(snippets[1]);
|
|
245
|
+
const body = await readBody(req);
|
|
246
|
+
try {
|
|
247
|
+
const { label, description, nodeIds, edgeIds } = JSON.parse(body);
|
|
248
|
+
const id = await ctx.storage.current.saveSnippet(graphName, {
|
|
249
|
+
label,
|
|
250
|
+
description,
|
|
251
|
+
nodeIds,
|
|
252
|
+
edgeIds: edgeIds ?? [],
|
|
253
|
+
});
|
|
254
|
+
sendJson(res, 200, { ok: true, id });
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
sendErr(res, 400, err.message);
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
// --- /api/backpacks (meta: list, active, register, switch, unregister) ---
|
|
262
|
+
if (url === "/api/backpacks" && method === "GET") {
|
|
263
|
+
try {
|
|
264
|
+
const list = await listBackpacks();
|
|
265
|
+
const active = await getActiveBackpack();
|
|
266
|
+
sendJson(res, 200, list.map((b) => ({ ...b, active: b.name === active.name })));
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
sendErr(res, 500, err.message);
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
if (url === "/api/backpacks/active" && method === "GET") {
|
|
274
|
+
try {
|
|
275
|
+
const active = await getActiveBackpack();
|
|
276
|
+
sendJson(res, 200, active);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
sendErr(res, 500, err.message);
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (url === "/api/backpacks/switch" && method === "POST") {
|
|
284
|
+
const body = await readBody(req);
|
|
285
|
+
try {
|
|
286
|
+
const { name } = JSON.parse(body);
|
|
287
|
+
await setActiveBackpack(name);
|
|
288
|
+
const swapped = await ctx.makeBackend();
|
|
289
|
+
ctx.storage.current = swapped.backend;
|
|
290
|
+
ctx.storage.activeEntry = swapped.entry;
|
|
291
|
+
ctx.onActiveBackpackChange?.();
|
|
292
|
+
sendJson(res, 200, { ok: true, active: ctx.storage.activeEntry });
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
sendErr(res, 400, err.message);
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (url === "/api/backpacks" && method === "POST") {
|
|
300
|
+
const body = await readBody(req);
|
|
301
|
+
try {
|
|
302
|
+
const { name, path: p, activate } = JSON.parse(body);
|
|
303
|
+
// registerBackpack only takes a path; the name is derived by
|
|
304
|
+
// backpack-ontology from the directory. Pre-existing call sites
|
|
305
|
+
// pass `name` as a hint but it's not used by the function.
|
|
306
|
+
void name;
|
|
307
|
+
const entry = await registerBackpack(p);
|
|
308
|
+
if (activate) {
|
|
309
|
+
await setActiveBackpack(name);
|
|
310
|
+
const swapped = await ctx.makeBackend();
|
|
311
|
+
ctx.storage.current = swapped.backend;
|
|
312
|
+
ctx.storage.activeEntry = swapped.entry;
|
|
313
|
+
ctx.onActiveBackpackChange?.();
|
|
314
|
+
}
|
|
315
|
+
sendJson(res, 200, { ok: true, entry });
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
sendErr(res, 400, err.message);
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
const backpackDelete = url.match(/^\/api\/backpacks\/(.+)$/);
|
|
323
|
+
if (backpackDelete && method === "DELETE") {
|
|
324
|
+
const name = decodeURIComponent(backpackDelete[1]);
|
|
325
|
+
try {
|
|
326
|
+
await unregisterBackpack(name);
|
|
327
|
+
if (ctx.storage.activeEntry && ctx.storage.activeEntry.name === name) {
|
|
328
|
+
const swapped = await ctx.makeBackend();
|
|
329
|
+
ctx.storage.current = swapped.backend;
|
|
330
|
+
ctx.storage.activeEntry = swapped.entry;
|
|
331
|
+
ctx.onActiveBackpackChange?.();
|
|
332
|
+
}
|
|
333
|
+
sendJson(res, 200, { ok: true });
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
sendErr(res, 400, err.message);
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
// --- /api/locks ---
|
|
341
|
+
if (url === "/api/locks" && method === "GET") {
|
|
342
|
+
try {
|
|
343
|
+
const summaries = await ctx.storage.current.listOntologies();
|
|
344
|
+
const result = {};
|
|
345
|
+
const storage = ctx.storage.current;
|
|
346
|
+
if (typeof storage.readLock === "function") {
|
|
347
|
+
await Promise.all(summaries.map(async (s) => {
|
|
348
|
+
try {
|
|
349
|
+
result[s.name] = await storage.readLock(s.name);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
result[s.name] = null;
|
|
353
|
+
}
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
sendJson(res, 200, result);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
sendJson(res, 200, {});
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
const lock = url.match(/^\/api\/graphs\/(.+)\/lock$/);
|
|
364
|
+
if (lock && method === "GET") {
|
|
365
|
+
const graphName = decodeURIComponent(lock[1]);
|
|
366
|
+
try {
|
|
367
|
+
const storage = ctx.storage.current;
|
|
368
|
+
const lockInfo = typeof storage.readLock === "function"
|
|
369
|
+
? await storage.readLock(graphName)
|
|
370
|
+
: null;
|
|
371
|
+
sendJson(res, 200, lockInfo);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
sendJson(res, 200, null);
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
// --- /api/ontologies/* ---
|
|
379
|
+
if (url === "/api/ontologies" && method === "GET") {
|
|
380
|
+
try {
|
|
381
|
+
const summaries = await ctx.storage.current.listOntologies();
|
|
382
|
+
sendJson(res, 200, summaries);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
sendJson(res, 200, []);
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
// /api/ontologies/<name>/rename — must match before /api/ontologies/<name>
|
|
390
|
+
const rename = url.match(/^\/api\/ontologies\/(.+)\/rename$/);
|
|
391
|
+
if (rename && method === "POST") {
|
|
392
|
+
const oldName = decodeURIComponent(rename[1]);
|
|
393
|
+
const body = await readBody(req);
|
|
394
|
+
try {
|
|
395
|
+
const { name: newName } = JSON.parse(body);
|
|
396
|
+
await ctx.storage.current.renameOntology(oldName, newName);
|
|
397
|
+
sendJson(res, 200, { ok: true, name: newName });
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
sendErr(res, 500, err.message);
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (url.startsWith("/api/ontologies/")) {
|
|
405
|
+
const name = decodeURIComponent(url.replace("/api/ontologies/", ""));
|
|
406
|
+
if (!name)
|
|
407
|
+
return false;
|
|
408
|
+
if (method === "PUT") {
|
|
409
|
+
const body = await readBody(req);
|
|
410
|
+
try {
|
|
411
|
+
const data = JSON.parse(body);
|
|
412
|
+
await ctx.storage.current.saveOntology(name, data);
|
|
413
|
+
sendJson(res, 200, { ok: true });
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
sendErr(res, 500, err.message);
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
if (method === "GET") {
|
|
421
|
+
try {
|
|
422
|
+
const data = await ctx.storage.current.loadOntology(name);
|
|
423
|
+
sendJson(res, 200, data);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
sendErr(res, 404, "Ontology not found");
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// --- /oauth/callback (for Share extension OAuth popup) ---
|
|
432
|
+
if (url.startsWith("/oauth/callback") && method === "GET") {
|
|
433
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
434
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
435
|
+
var params = new URLSearchParams(window.location.search);
|
|
436
|
+
var code = params.get("code");
|
|
437
|
+
var state = params.get("state");
|
|
438
|
+
if (window.opener && code) {
|
|
439
|
+
window.opener.postMessage({
|
|
440
|
+
type: "backpack-oauth-callback",
|
|
441
|
+
code: code,
|
|
442
|
+
returnedState: state
|
|
443
|
+
}, "*");
|
|
444
|
+
}
|
|
445
|
+
window.close();
|
|
446
|
+
</script></body></html>`);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
if (!res.headersSent) {
|
|
453
|
+
sendErr(res, 500, err.message);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
res.end();
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { configDir, dataDir } from "backpack-ontology";
|
|
2
|
+
import { type ExtensionManifest } from "./extensions/manifest.js";
|
|
3
|
+
/**
|
|
4
|
+
* Server-side extension infrastructure shared by `bin/serve.js` (prod)
|
|
5
|
+
* and `vite.config.ts` (dev). Both entry files import these helpers and
|
|
6
|
+
* keep only thin HTTP wiring.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Resolve enabled extensions from config + first-party in-tree dirs
|
|
10
|
+
* - Parse and validate each manifest
|
|
11
|
+
* - Provide handlers for the generic extension endpoints:
|
|
12
|
+
* GET /api/extensions
|
|
13
|
+
* POST /api/extensions/<name>/fetch
|
|
14
|
+
* GET /api/extensions/<name>/settings
|
|
15
|
+
* PUT /api/extensions/<name>/settings/<key>
|
|
16
|
+
* DELETE /api/extensions/<name>/settings/<key>
|
|
17
|
+
*
|
|
18
|
+
* Static file serving (`/extensions/<name>/<file>`) is handled by each
|
|
19
|
+
* entry file because the static-file plumbing differs between Vite and
|
|
20
|
+
* the raw http server.
|
|
21
|
+
*/
|
|
22
|
+
export interface LoadedExtension {
|
|
23
|
+
manifest: ExtensionManifest;
|
|
24
|
+
/** Absolute path to the extension's directory on disk. */
|
|
25
|
+
rootDir: string;
|
|
26
|
+
/** Whether this extension shipped with the viewer (in-tree). */
|
|
27
|
+
firstParty: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ExtensionConfigEntry {
|
|
30
|
+
/** Extension name (must match the manifest and the directory). */
|
|
31
|
+
name: string;
|
|
32
|
+
/**
|
|
33
|
+
* Source. Either:
|
|
34
|
+
* - "first-party" — load from <viewer-dist>/extensions/<name> or
|
|
35
|
+
* <viewer-src>/extensions/<name> in dev
|
|
36
|
+
* - { path: "/abs/path" } — load from a user-specified absolute path
|
|
37
|
+
*/
|
|
38
|
+
source: "first-party" | {
|
|
39
|
+
path: string;
|
|
40
|
+
};
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve enabled extensions. Reads:
|
|
45
|
+
* 1. The list of first-party extensions bundled with the viewer
|
|
46
|
+
* (default-enabled unless the user disabled them)
|
|
47
|
+
* 2. The list of external extensions from the user's viewer config
|
|
48
|
+
*
|
|
49
|
+
* For each one: locates the manifest, validates it, and returns the
|
|
50
|
+
* loaded extension. Errors are logged but don't crash the server —
|
|
51
|
+
* a malformed extension is skipped, others keep working.
|
|
52
|
+
*
|
|
53
|
+
* @param firstPartyDir absolute path to the directory containing
|
|
54
|
+
* bundled first-party extensions (typically
|
|
55
|
+
* `<dist>/extensions/` in prod or
|
|
56
|
+
* `<repo>/extensions/` in dev)
|
|
57
|
+
* @param userExtensions list from viewer config; same shape regardless of source
|
|
58
|
+
* @param disabledFirstParty names of first-party extensions the user disabled
|
|
59
|
+
*/
|
|
60
|
+
export declare function loadExtensions(firstPartyDir: string, userExtensions: {
|
|
61
|
+
name: string;
|
|
62
|
+
path: string;
|
|
63
|
+
}[], disabledFirstParty: Set<string>): LoadedExtension[];
|
|
64
|
+
/**
|
|
65
|
+
* Per-extension settings file path. Each extension gets its own file
|
|
66
|
+
* under `~/.config/backpack/extensions/<name>/settings.json`. The file
|
|
67
|
+
* is created on first write.
|
|
68
|
+
*/
|
|
69
|
+
export declare function extensionSettingsPath(extName: string): string;
|
|
70
|
+
export declare function readExtensionSettings(extName: string): Promise<Record<string, unknown>>;
|
|
71
|
+
export declare function writeExtensionSetting(extName: string, key: string, value: unknown): Promise<void>;
|
|
72
|
+
export declare function deleteExtensionSetting(extName: string, key: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Result of an extension fetch proxy call. Either an error JSON to send
|
|
75
|
+
* back to the client, or an upstream body for the caller to pipe through.
|
|
76
|
+
*/
|
|
77
|
+
export interface ExtensionFetchResult {
|
|
78
|
+
status: number;
|
|
79
|
+
errorJson?: string;
|
|
80
|
+
upstreamHeaders?: Headers;
|
|
81
|
+
upstreamBody?: ReadableStream<Uint8Array>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Forward an extension fetch request to its declared upstream.
|
|
85
|
+
*
|
|
86
|
+
* Validates that the requested URL's origin is in the extension's
|
|
87
|
+
* manifest network allowlist. Injects headers (env-var or literal) per
|
|
88
|
+
* the manifest's `injectHeaders` config. Returns either a structured
|
|
89
|
+
* error or a ReadableStream the caller pipes back to the browser.
|
|
90
|
+
*
|
|
91
|
+
* The browser-side `viewer.fetch(url, init)` POSTs to
|
|
92
|
+
* `/api/extensions/<name>/fetch` with a JSON body matching
|
|
93
|
+
* ProxyRequestPayload. The caller (serve.js / vite plugin) handles the
|
|
94
|
+
* raw HTTP wiring; this function handles validation, secret injection,
|
|
95
|
+
* and the upstream call.
|
|
96
|
+
*/
|
|
97
|
+
export declare function proxyExtensionFetch(ext: LoadedExtension, rawJsonBody: string): Promise<ExtensionFetchResult>;
|
|
98
|
+
/**
|
|
99
|
+
* Look up a loaded extension by name. Used by the per-extension
|
|
100
|
+
* endpoints to resolve which extension a request is targeting.
|
|
101
|
+
*/
|
|
102
|
+
export declare function findExtension(extensions: LoadedExtension[], name: string): LoadedExtension | null;
|
|
103
|
+
/**
|
|
104
|
+
* Public manifest serialization — what `GET /api/extensions` returns.
|
|
105
|
+
* Strips no fields currently but exists as the boundary in case we want
|
|
106
|
+
* to filter (e.g., redact env-var names) in the future.
|
|
107
|
+
*/
|
|
108
|
+
export declare function publicExtensionInfo(ext: LoadedExtension): {
|
|
109
|
+
name: string;
|
|
110
|
+
version: string;
|
|
111
|
+
viewerApi: string;
|
|
112
|
+
displayName: string | undefined;
|
|
113
|
+
description: string | undefined;
|
|
114
|
+
entry: string;
|
|
115
|
+
stylesheet: string | undefined;
|
|
116
|
+
permissions: import("./extensions/manifest.js").ManifestPermissions | undefined;
|
|
117
|
+
firstParty: boolean;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the on-disk file path for a request like
|
|
121
|
+
* `/extensions/<name>/<sub-path>`. Validates the sub-path stays within
|
|
122
|
+
* the extension's root dir (no path traversal). Returns null if the
|
|
123
|
+
* extension is unknown or the path escapes.
|
|
124
|
+
*/
|
|
125
|
+
export declare function resolveExtensionFile(extensions: LoadedExtension[], extName: string, subPath: string): string | null;
|
|
126
|
+
export { configDir, dataDir };
|