@xnetjs/plugins 0.0.2
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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/index.d.ts +2047 -0
- package/dist/index.js +2640 -0
- package/dist/services/node.d.ts +583 -0
- package/dist/services/node.js +1055 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2640 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
function getPlatformCapabilities(platform) {
|
|
3
|
+
return {
|
|
4
|
+
platform,
|
|
5
|
+
features: {
|
|
6
|
+
views: true,
|
|
7
|
+
editorExtensions: platform !== "mobile",
|
|
8
|
+
slashCommands: platform !== "mobile",
|
|
9
|
+
services: platform === "electron",
|
|
10
|
+
processes: platform === "electron",
|
|
11
|
+
localAPI: platform === "electron",
|
|
12
|
+
filesystem: platform === "electron",
|
|
13
|
+
clipboard: platform !== "mobile",
|
|
14
|
+
notifications: true,
|
|
15
|
+
p2pSync: true
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function createExtensionStorage() {
|
|
20
|
+
const storage = /* @__PURE__ */ new Map();
|
|
21
|
+
return {
|
|
22
|
+
get(key) {
|
|
23
|
+
return storage.get(key);
|
|
24
|
+
},
|
|
25
|
+
set(key, value) {
|
|
26
|
+
storage.set(key, value);
|
|
27
|
+
},
|
|
28
|
+
delete(key) {
|
|
29
|
+
storage.delete(key);
|
|
30
|
+
},
|
|
31
|
+
keys() {
|
|
32
|
+
return [...storage.keys()];
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/manifest.ts
|
|
38
|
+
var PluginValidationError = class extends Error {
|
|
39
|
+
constructor(message, issues) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.issues = issues;
|
|
42
|
+
this.name = "PluginValidationError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function validateManifest(manifest) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
if (!manifest || typeof manifest !== "object") {
|
|
48
|
+
throw new PluginValidationError("Manifest must be an object", ["Invalid manifest type"]);
|
|
49
|
+
}
|
|
50
|
+
const m = manifest;
|
|
51
|
+
if (typeof m.id !== "string" || !m.id) {
|
|
52
|
+
issues.push("id is required and must be a non-empty string");
|
|
53
|
+
} else if (!/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/i.test(m.id)) {
|
|
54
|
+
issues.push("id must be in reverse-domain format (e.g., com.example.my-plugin)");
|
|
55
|
+
}
|
|
56
|
+
if (typeof m.name !== "string" || !m.name) {
|
|
57
|
+
issues.push("name is required and must be a non-empty string");
|
|
58
|
+
}
|
|
59
|
+
if (typeof m.version !== "string" || !m.version) {
|
|
60
|
+
issues.push("version is required and must be a non-empty string");
|
|
61
|
+
} else if (!/^\d+\.\d+\.\d+/.test(m.version)) {
|
|
62
|
+
issues.push("version must be a valid semver (e.g., 1.0.0)");
|
|
63
|
+
}
|
|
64
|
+
if (m.description !== void 0 && typeof m.description !== "string") {
|
|
65
|
+
issues.push("description must be a string");
|
|
66
|
+
}
|
|
67
|
+
if (m.author !== void 0 && typeof m.author !== "string") {
|
|
68
|
+
issues.push("author must be a string");
|
|
69
|
+
}
|
|
70
|
+
if (m.platforms !== void 0) {
|
|
71
|
+
if (!Array.isArray(m.platforms)) {
|
|
72
|
+
issues.push("platforms must be an array");
|
|
73
|
+
} else {
|
|
74
|
+
const validPlatforms = ["web", "electron", "mobile"];
|
|
75
|
+
for (const p of m.platforms) {
|
|
76
|
+
if (!validPlatforms.includes(p)) {
|
|
77
|
+
issues.push(`Invalid platform: ${p}. Must be one of: ${validPlatforms.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (m.activate !== void 0 && typeof m.activate !== "function") {
|
|
83
|
+
issues.push("activate must be a function");
|
|
84
|
+
}
|
|
85
|
+
if (m.deactivate !== void 0 && typeof m.deactivate !== "function") {
|
|
86
|
+
issues.push("deactivate must be a function");
|
|
87
|
+
}
|
|
88
|
+
if (issues.length > 0) {
|
|
89
|
+
throw new PluginValidationError(
|
|
90
|
+
`Plugin manifest validation failed: ${issues.join("; ")}`,
|
|
91
|
+
issues
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return manifest;
|
|
95
|
+
}
|
|
96
|
+
function defineExtension(manifest) {
|
|
97
|
+
validateManifest(manifest);
|
|
98
|
+
return manifest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/contributions.ts
|
|
102
|
+
var TypedRegistry = class {
|
|
103
|
+
items = /* @__PURE__ */ new Map();
|
|
104
|
+
listeners = /* @__PURE__ */ new Set();
|
|
105
|
+
register(item) {
|
|
106
|
+
const key = item.id ?? item.type ?? crypto.randomUUID();
|
|
107
|
+
this.items.set(key, item);
|
|
108
|
+
this.notify();
|
|
109
|
+
return {
|
|
110
|
+
dispose: () => {
|
|
111
|
+
this.items.delete(key);
|
|
112
|
+
this.notify();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
unregister(key) {
|
|
117
|
+
const deleted = this.items.delete(key);
|
|
118
|
+
if (deleted) this.notify();
|
|
119
|
+
return deleted;
|
|
120
|
+
}
|
|
121
|
+
get(key) {
|
|
122
|
+
return this.items.get(key);
|
|
123
|
+
}
|
|
124
|
+
getAll() {
|
|
125
|
+
return [...this.items.values()];
|
|
126
|
+
}
|
|
127
|
+
has(key) {
|
|
128
|
+
return this.items.has(key);
|
|
129
|
+
}
|
|
130
|
+
get size() {
|
|
131
|
+
return this.items.size;
|
|
132
|
+
}
|
|
133
|
+
onChange(listener) {
|
|
134
|
+
this.listeners.add(listener);
|
|
135
|
+
return () => this.listeners.delete(listener);
|
|
136
|
+
}
|
|
137
|
+
notify() {
|
|
138
|
+
for (const listener of this.listeners) {
|
|
139
|
+
try {
|
|
140
|
+
listener();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("[TypedRegistry] Listener error:", err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
clear() {
|
|
147
|
+
this.items.clear();
|
|
148
|
+
this.notify();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
var ContributionRegistry = class {
|
|
152
|
+
views = new TypedRegistry();
|
|
153
|
+
commands = new TypedRegistry();
|
|
154
|
+
slashCommands = new TypedRegistry();
|
|
155
|
+
sidebar = new TypedRegistry();
|
|
156
|
+
editor = new TypedRegistry();
|
|
157
|
+
propertyHandlers = new TypedRegistry();
|
|
158
|
+
blocks = new TypedRegistry();
|
|
159
|
+
settings = new TypedRegistry();
|
|
160
|
+
/**
|
|
161
|
+
* Clear all registries (for cleanup/testing)
|
|
162
|
+
*/
|
|
163
|
+
clear() {
|
|
164
|
+
this.views.clear();
|
|
165
|
+
this.commands.clear();
|
|
166
|
+
this.slashCommands.clear();
|
|
167
|
+
this.sidebar.clear();
|
|
168
|
+
this.editor.clear();
|
|
169
|
+
this.propertyHandlers.clear();
|
|
170
|
+
this.blocks.clear();
|
|
171
|
+
this.settings.clear();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/shortcuts.ts
|
|
176
|
+
function isMac() {
|
|
177
|
+
if (typeof navigator === "undefined") return false;
|
|
178
|
+
return navigator.platform?.includes("Mac") || navigator.userAgent?.includes("Mac");
|
|
179
|
+
}
|
|
180
|
+
var ShortcutManager = class {
|
|
181
|
+
shortcuts = /* @__PURE__ */ new Map();
|
|
182
|
+
enabled = true;
|
|
183
|
+
/**
|
|
184
|
+
* Register a command with a keyboard shortcut.
|
|
185
|
+
*
|
|
186
|
+
* @param command - Command with keybinding
|
|
187
|
+
* @returns Disposable to unregister the shortcut
|
|
188
|
+
*/
|
|
189
|
+
register(command) {
|
|
190
|
+
if (!command.keybinding) {
|
|
191
|
+
return { dispose: () => {
|
|
192
|
+
} };
|
|
193
|
+
}
|
|
194
|
+
const normalized = this.normalize(command.keybinding);
|
|
195
|
+
this.shortcuts.set(normalized, command);
|
|
196
|
+
return {
|
|
197
|
+
dispose: () => {
|
|
198
|
+
this.shortcuts.delete(normalized);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Unregister a command by ID.
|
|
204
|
+
*/
|
|
205
|
+
unregister(commandId) {
|
|
206
|
+
for (const [key, cmd] of this.shortcuts) {
|
|
207
|
+
if (cmd.id === commandId) {
|
|
208
|
+
return this.shortcuts.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Handle a keyboard event.
|
|
215
|
+
*
|
|
216
|
+
* @param event - Keyboard event
|
|
217
|
+
* @returns True if a shortcut was triggered
|
|
218
|
+
*/
|
|
219
|
+
handleKeyDown(event) {
|
|
220
|
+
if (!this.enabled) return false;
|
|
221
|
+
const target = event.target;
|
|
222
|
+
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") {
|
|
223
|
+
if (event.key !== "Escape") {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (target?.isContentEditable) {
|
|
228
|
+
const key2 = this.eventToString(event);
|
|
229
|
+
const command2 = this.shortcuts.get(key2);
|
|
230
|
+
if (!command2) return false;
|
|
231
|
+
}
|
|
232
|
+
const key = this.eventToString(event);
|
|
233
|
+
const command = this.shortcuts.get(key);
|
|
234
|
+
if (command) {
|
|
235
|
+
if (command.when && !command.when()) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
event.stopPropagation();
|
|
240
|
+
Promise.resolve(command.execute()).catch((err) => {
|
|
241
|
+
console.error(`[ShortcutManager] Command '${command.id}' failed:`, err);
|
|
242
|
+
});
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Enable or disable shortcut handling.
|
|
249
|
+
*/
|
|
250
|
+
setEnabled(enabled) {
|
|
251
|
+
this.enabled = enabled;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Check if shortcuts are enabled.
|
|
255
|
+
*/
|
|
256
|
+
isEnabled() {
|
|
257
|
+
return this.enabled;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get all registered shortcuts.
|
|
261
|
+
*/
|
|
262
|
+
getAll() {
|
|
263
|
+
return [...this.shortcuts.values()];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get the shortcut for a command ID.
|
|
267
|
+
*/
|
|
268
|
+
getShortcut(commandId) {
|
|
269
|
+
for (const [key, cmd] of this.shortcuts) {
|
|
270
|
+
if (cmd.id === commandId) {
|
|
271
|
+
return this.denormalize(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Format a keybinding for display (using platform-specific symbols).
|
|
278
|
+
*/
|
|
279
|
+
formatForDisplay(keybinding) {
|
|
280
|
+
const mac = isMac();
|
|
281
|
+
return keybinding.replace(/Mod/g, mac ? "\u2318" : "Ctrl").replace(/Ctrl/g, mac ? "\u2303" : "Ctrl").replace(/Alt/g, mac ? "\u2325" : "Alt").replace(/Shift/g, mac ? "\u21E7" : "Shift").replace(/Meta/g, mac ? "\u2318" : "Win").replace(/-/g, mac ? "" : "+");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Normalize a keybinding string for consistent lookup.
|
|
285
|
+
*/
|
|
286
|
+
normalize(keybinding) {
|
|
287
|
+
const mac = isMac();
|
|
288
|
+
const parts = keybinding.replace(/Mod/g, mac ? "Meta" : "Ctrl").split("-").map((p) => p.toLowerCase());
|
|
289
|
+
const modifiers = ["ctrl", "meta", "alt", "shift"];
|
|
290
|
+
const sortedModifiers = parts.filter((p) => modifiers.includes(p)).sort();
|
|
291
|
+
const key = parts.filter((p) => !modifiers.includes(p))[0] ?? "";
|
|
292
|
+
return [...sortedModifiers, key].join("-");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Convert normalized form back to display form.
|
|
296
|
+
*/
|
|
297
|
+
denormalize(normalized) {
|
|
298
|
+
const mac = isMac();
|
|
299
|
+
return normalized.split("-").map((part) => {
|
|
300
|
+
if (part === "ctrl") return "Ctrl";
|
|
301
|
+
if (part === "meta") return mac ? "Cmd" : "Ctrl";
|
|
302
|
+
if (part === "alt") return "Alt";
|
|
303
|
+
if (part === "shift") return "Shift";
|
|
304
|
+
return part.toUpperCase();
|
|
305
|
+
}).join("-");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Convert a keyboard event to a normalized string.
|
|
309
|
+
*/
|
|
310
|
+
eventToString(event) {
|
|
311
|
+
const modifiers = [];
|
|
312
|
+
if (event.ctrlKey) modifiers.push("ctrl");
|
|
313
|
+
if (event.metaKey) modifiers.push("meta");
|
|
314
|
+
if (event.altKey) modifiers.push("alt");
|
|
315
|
+
if (event.shiftKey) modifiers.push("shift");
|
|
316
|
+
let key = event.key.toLowerCase();
|
|
317
|
+
if (key === " ") key = "space";
|
|
318
|
+
if (key === "arrowup") key = "up";
|
|
319
|
+
if (key === "arrowdown") key = "down";
|
|
320
|
+
if (key === "arrowleft") key = "left";
|
|
321
|
+
if (key === "arrowright") key = "right";
|
|
322
|
+
if (["control", "meta", "alt", "shift"].includes(key)) {
|
|
323
|
+
return "";
|
|
324
|
+
}
|
|
325
|
+
return [...modifiers.sort(), key].join("-");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Clear all registered shortcuts.
|
|
329
|
+
*/
|
|
330
|
+
clear() {
|
|
331
|
+
this.shortcuts.clear();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var globalShortcutManager = null;
|
|
335
|
+
function getShortcutManager() {
|
|
336
|
+
if (!globalShortcutManager) {
|
|
337
|
+
globalShortcutManager = new ShortcutManager();
|
|
338
|
+
}
|
|
339
|
+
return globalShortcutManager;
|
|
340
|
+
}
|
|
341
|
+
function installShortcutHandler() {
|
|
342
|
+
const manager = getShortcutManager();
|
|
343
|
+
const handler = (event) => {
|
|
344
|
+
manager.handleKeyDown(event);
|
|
345
|
+
};
|
|
346
|
+
window.addEventListener("keydown", handler, true);
|
|
347
|
+
return () => {
|
|
348
|
+
window.removeEventListener("keydown", handler, true);
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/middleware.ts
|
|
353
|
+
var MiddlewareChain = class {
|
|
354
|
+
middlewares = [];
|
|
355
|
+
/**
|
|
356
|
+
* Add a middleware to the chain
|
|
357
|
+
*/
|
|
358
|
+
add(middleware) {
|
|
359
|
+
this.middlewares.push(middleware);
|
|
360
|
+
this.middlewares.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
361
|
+
return {
|
|
362
|
+
dispose: () => {
|
|
363
|
+
this.middlewares = this.middlewares.filter((m) => m.id !== middleware.id);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Remove a middleware by ID
|
|
369
|
+
*/
|
|
370
|
+
remove(id) {
|
|
371
|
+
const before = this.middlewares.length;
|
|
372
|
+
this.middlewares = this.middlewares.filter((m) => m.id !== id);
|
|
373
|
+
return this.middlewares.length < before;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Get all registered middlewares
|
|
377
|
+
*/
|
|
378
|
+
getAll() {
|
|
379
|
+
return [...this.middlewares];
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Execute beforeChange hooks in priority order
|
|
383
|
+
*/
|
|
384
|
+
async executeBefore(change, apply) {
|
|
385
|
+
const chain = this.middlewares.filter((m) => m.beforeChange);
|
|
386
|
+
const execute = async (index) => {
|
|
387
|
+
if (index >= chain.length) {
|
|
388
|
+
return apply();
|
|
389
|
+
}
|
|
390
|
+
return chain[index].beforeChange(change, () => execute(index + 1));
|
|
391
|
+
};
|
|
392
|
+
return execute(0);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Execute afterChange hooks (errors are caught and logged)
|
|
396
|
+
*/
|
|
397
|
+
executeAfter(event) {
|
|
398
|
+
for (const middleware of this.middlewares) {
|
|
399
|
+
if (middleware.afterChange) {
|
|
400
|
+
try {
|
|
401
|
+
middleware.afterChange(event);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error(`[Middleware ${middleware.id}] afterChange error:`, err);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Clear all middlewares
|
|
410
|
+
*/
|
|
411
|
+
clear() {
|
|
412
|
+
this.middlewares = [];
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Get the number of registered middlewares
|
|
416
|
+
*/
|
|
417
|
+
get size() {
|
|
418
|
+
return this.middlewares.length;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// src/context.ts
|
|
423
|
+
function createExtensionContext(options) {
|
|
424
|
+
const { pluginId, store, contributions, platform, middlewareChain } = options;
|
|
425
|
+
const disposables = [];
|
|
426
|
+
const storage = createExtensionStorage();
|
|
427
|
+
const ctx = {
|
|
428
|
+
pluginId,
|
|
429
|
+
platform,
|
|
430
|
+
store,
|
|
431
|
+
async query(schema, filter) {
|
|
432
|
+
const nodes = await store.list();
|
|
433
|
+
let filtered = schema ? nodes.filter((n) => n.schemaId === schema) : nodes;
|
|
434
|
+
if (filter?.where) {
|
|
435
|
+
filtered = filtered.filter((node) => {
|
|
436
|
+
for (const [key, value] of Object.entries(filter.where)) {
|
|
437
|
+
if (node[key] !== value) return false;
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (filter?.offset) {
|
|
443
|
+
filtered = filtered.slice(filter.offset);
|
|
444
|
+
}
|
|
445
|
+
if (filter?.limit) {
|
|
446
|
+
filtered = filtered.slice(0, filter.limit);
|
|
447
|
+
}
|
|
448
|
+
return filtered;
|
|
449
|
+
},
|
|
450
|
+
subscribe(schema, callback) {
|
|
451
|
+
const storeCallback = (event) => {
|
|
452
|
+
if (!schema || event.node?.schemaId === schema) {
|
|
453
|
+
const change = event.change;
|
|
454
|
+
callback({
|
|
455
|
+
type: change.type === "node-delete" ? "delete" : change.type === "node-change" ? "create" : "update",
|
|
456
|
+
nodeId: change.payload.nodeId,
|
|
457
|
+
node: event.node ?? void 0,
|
|
458
|
+
changes: void 0
|
|
459
|
+
// Raw change available via event.change if needed
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const unsub = store.subscribe(storeCallback);
|
|
464
|
+
const disposable = { dispose: unsub };
|
|
465
|
+
disposables.push(disposable);
|
|
466
|
+
return disposable;
|
|
467
|
+
},
|
|
468
|
+
registerSchema(_schema) {
|
|
469
|
+
const d = {
|
|
470
|
+
dispose: () => {
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
disposables.push(d);
|
|
474
|
+
return d;
|
|
475
|
+
},
|
|
476
|
+
registerView(view) {
|
|
477
|
+
const d = contributions.views.register(view);
|
|
478
|
+
disposables.push(d);
|
|
479
|
+
return d;
|
|
480
|
+
},
|
|
481
|
+
registerPropertyHandler(type, handler) {
|
|
482
|
+
const d = contributions.propertyHandlers.register({ type, handler });
|
|
483
|
+
disposables.push(d);
|
|
484
|
+
return d;
|
|
485
|
+
},
|
|
486
|
+
registerCommand(command) {
|
|
487
|
+
const d = contributions.commands.register(command);
|
|
488
|
+
disposables.push(d);
|
|
489
|
+
return d;
|
|
490
|
+
},
|
|
491
|
+
registerSidebarItem(item) {
|
|
492
|
+
const d = contributions.sidebar.register(item);
|
|
493
|
+
disposables.push(d);
|
|
494
|
+
return d;
|
|
495
|
+
},
|
|
496
|
+
registerEditorExtension(ext) {
|
|
497
|
+
const d = contributions.editor.register(ext);
|
|
498
|
+
disposables.push(d);
|
|
499
|
+
return d;
|
|
500
|
+
},
|
|
501
|
+
registerSlashCommand(cmd) {
|
|
502
|
+
const d = contributions.slashCommands.register(cmd);
|
|
503
|
+
disposables.push(d);
|
|
504
|
+
return d;
|
|
505
|
+
},
|
|
506
|
+
registerBlockType(block) {
|
|
507
|
+
const d = contributions.blocks.register(block);
|
|
508
|
+
disposables.push(d);
|
|
509
|
+
return d;
|
|
510
|
+
},
|
|
511
|
+
addMiddleware(middleware) {
|
|
512
|
+
if (!middlewareChain) {
|
|
513
|
+
console.warn(`[Plugin ${pluginId}] Middleware not available on this platform`);
|
|
514
|
+
return { dispose: () => {
|
|
515
|
+
} };
|
|
516
|
+
}
|
|
517
|
+
const namespacedMiddleware = {
|
|
518
|
+
...middleware,
|
|
519
|
+
id: `${pluginId}:${middleware.id}`
|
|
520
|
+
};
|
|
521
|
+
const d = middlewareChain.add(namespacedMiddleware);
|
|
522
|
+
disposables.push(d);
|
|
523
|
+
return d;
|
|
524
|
+
},
|
|
525
|
+
storage,
|
|
526
|
+
capabilities: getPlatformCapabilities(platform),
|
|
527
|
+
subscriptions: disposables
|
|
528
|
+
};
|
|
529
|
+
return ctx;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/schemas/plugin.ts
|
|
533
|
+
import { defineSchema, text, checkbox, date } from "@xnetjs/data";
|
|
534
|
+
var PluginSchema = defineSchema({
|
|
535
|
+
name: "Plugin",
|
|
536
|
+
namespace: "xnet://xnet.fyi/",
|
|
537
|
+
properties: {
|
|
538
|
+
/** Unique plugin identifier (reverse-domain format) */
|
|
539
|
+
pluginId: text({ required: true }),
|
|
540
|
+
/** Human-readable name */
|
|
541
|
+
name: text({ required: true }),
|
|
542
|
+
/** Semantic version */
|
|
543
|
+
version: text({ required: true }),
|
|
544
|
+
/** Plugin description */
|
|
545
|
+
description: text({}),
|
|
546
|
+
/** Author name or organization */
|
|
547
|
+
author: text({}),
|
|
548
|
+
/** Whether plugin is enabled */
|
|
549
|
+
enabled: checkbox({ default: true }),
|
|
550
|
+
/** JSON-serialized manifest */
|
|
551
|
+
manifest: text({ required: true }),
|
|
552
|
+
/** Plugin source (URL or inline bundle) */
|
|
553
|
+
source: text({}),
|
|
554
|
+
/** JSON-serialized permissions */
|
|
555
|
+
permissions: text({}),
|
|
556
|
+
/** Installation timestamp */
|
|
557
|
+
installedAt: date({})
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// src/registry.ts
|
|
562
|
+
var PluginError = class extends Error {
|
|
563
|
+
constructor(message) {
|
|
564
|
+
super(message);
|
|
565
|
+
this.name = "PluginError";
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
var PluginRegistry = class {
|
|
569
|
+
constructor(store, platform) {
|
|
570
|
+
this.store = store;
|
|
571
|
+
this.platform = platform;
|
|
572
|
+
}
|
|
573
|
+
plugins = /* @__PURE__ */ new Map();
|
|
574
|
+
contributions = new ContributionRegistry();
|
|
575
|
+
middleware = new MiddlewareChain();
|
|
576
|
+
listeners = /* @__PURE__ */ new Set();
|
|
577
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────────
|
|
578
|
+
/**
|
|
579
|
+
* Install and activate a plugin
|
|
580
|
+
*/
|
|
581
|
+
async install(manifest) {
|
|
582
|
+
try {
|
|
583
|
+
validateManifest(manifest);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
if (err instanceof PluginValidationError) {
|
|
586
|
+
throw new PluginError(`Invalid manifest: ${err.issues.join(", ")}`);
|
|
587
|
+
}
|
|
588
|
+
throw err;
|
|
589
|
+
}
|
|
590
|
+
if (manifest.platforms && !manifest.platforms.includes(this.platform)) {
|
|
591
|
+
throw new PluginError(
|
|
592
|
+
`Plugin '${manifest.id}' requires platforms: ${manifest.platforms.join(", ")} (current: ${this.platform})`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (this.plugins.has(manifest.id)) {
|
|
596
|
+
throw new PluginError(`Plugin '${manifest.id}' is already installed`);
|
|
597
|
+
}
|
|
598
|
+
await this.store.create({
|
|
599
|
+
schemaId: PluginSchema._schemaId,
|
|
600
|
+
properties: {
|
|
601
|
+
pluginId: manifest.id,
|
|
602
|
+
name: manifest.name,
|
|
603
|
+
version: manifest.version,
|
|
604
|
+
description: manifest.description ?? "",
|
|
605
|
+
author: manifest.author ?? "",
|
|
606
|
+
enabled: true,
|
|
607
|
+
manifest: JSON.stringify(manifest),
|
|
608
|
+
installedAt: Date.now()
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
this.plugins.set(manifest.id, { manifest, status: "installed" });
|
|
612
|
+
this.notify();
|
|
613
|
+
await this.activate(manifest.id);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Activate an installed plugin
|
|
617
|
+
*/
|
|
618
|
+
async activate(pluginId) {
|
|
619
|
+
const plugin = this.plugins.get(pluginId);
|
|
620
|
+
if (!plugin) {
|
|
621
|
+
throw new PluginError(`Plugin '${pluginId}' not found`);
|
|
622
|
+
}
|
|
623
|
+
if (plugin.status === "active") {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const context = createExtensionContext({
|
|
628
|
+
pluginId,
|
|
629
|
+
store: this.store,
|
|
630
|
+
contributions: this.contributions,
|
|
631
|
+
platform: this.platform,
|
|
632
|
+
middlewareChain: this.middleware
|
|
633
|
+
});
|
|
634
|
+
this.registerStaticContributions(plugin.manifest, context);
|
|
635
|
+
if (plugin.manifest.activate) {
|
|
636
|
+
await plugin.manifest.activate(context);
|
|
637
|
+
}
|
|
638
|
+
plugin.context = context;
|
|
639
|
+
plugin.status = "active";
|
|
640
|
+
plugin.error = void 0;
|
|
641
|
+
this.notify();
|
|
642
|
+
} catch (err) {
|
|
643
|
+
plugin.status = "error";
|
|
644
|
+
plugin.error = err instanceof Error ? err : new Error(String(err));
|
|
645
|
+
console.error(`Plugin '${pluginId}' activation failed:`, err);
|
|
646
|
+
this.notify();
|
|
647
|
+
throw plugin.error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Deactivate an active plugin
|
|
652
|
+
*/
|
|
653
|
+
async deactivate(pluginId) {
|
|
654
|
+
const plugin = this.plugins.get(pluginId);
|
|
655
|
+
if (!plugin || plugin.status !== "active") {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (plugin.manifest.deactivate) {
|
|
659
|
+
try {
|
|
660
|
+
await plugin.manifest.deactivate();
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.error(`Plugin '${pluginId}' deactivate error:`, err);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (plugin.context) {
|
|
666
|
+
for (const d of plugin.context.subscriptions) {
|
|
667
|
+
try {
|
|
668
|
+
d.dispose();
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.error(`Error disposing subscription for plugin '${pluginId}':`, err);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
plugin.context = void 0;
|
|
675
|
+
plugin.status = "disabled";
|
|
676
|
+
this.notify();
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Uninstall a plugin (deactivates first if active)
|
|
680
|
+
*/
|
|
681
|
+
async uninstall(pluginId) {
|
|
682
|
+
await this.deactivate(pluginId);
|
|
683
|
+
this.plugins.delete(pluginId);
|
|
684
|
+
const nodes = await this.store.list();
|
|
685
|
+
const pluginNode = nodes.find(
|
|
686
|
+
(n) => n.schemaId === PluginSchema._schemaId && n.properties.pluginId === pluginId
|
|
687
|
+
);
|
|
688
|
+
if (pluginNode) {
|
|
689
|
+
await this.store.delete(pluginNode.id);
|
|
690
|
+
}
|
|
691
|
+
this.notify();
|
|
692
|
+
}
|
|
693
|
+
// ─── Queries ───────────────────────────────────────────────────────────
|
|
694
|
+
/**
|
|
695
|
+
* Get all registered plugins
|
|
696
|
+
*/
|
|
697
|
+
getAll() {
|
|
698
|
+
return [...this.plugins.values()];
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Get a specific plugin
|
|
702
|
+
*/
|
|
703
|
+
get(pluginId) {
|
|
704
|
+
return this.plugins.get(pluginId);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Check if a plugin is installed
|
|
708
|
+
*/
|
|
709
|
+
has(pluginId) {
|
|
710
|
+
return this.plugins.has(pluginId);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Rehydrate a plugin loaded from store with a live manifest.
|
|
714
|
+
*
|
|
715
|
+
* When plugins are persisted via `install()`, the manifest is serialized
|
|
716
|
+
* with `JSON.stringify()`, which strips non-serializable values like
|
|
717
|
+
* TipTap Extension instances, React components, and functions. When
|
|
718
|
+
* `loadFromStore()` deserializes with `JSON.parse()`, these values are
|
|
719
|
+
* lost and the plugin's contributions (e.g., editor extensions) are broken.
|
|
720
|
+
*
|
|
721
|
+
* For bundled plugins, the `BundledPluginInstaller` has access to the live
|
|
722
|
+
* manifest objects. This method replaces the deserialized manifest with the
|
|
723
|
+
* live one and re-registers its static contributions so that extension
|
|
724
|
+
* objects with methods (like `renderHTML`, `addNodeView`) are properly
|
|
725
|
+
* available to the editor.
|
|
726
|
+
*/
|
|
727
|
+
async rehydrate(liveManifest) {
|
|
728
|
+
const plugin = this.plugins.get(liveManifest.id);
|
|
729
|
+
if (!plugin) return;
|
|
730
|
+
if (plugin.status === "active") {
|
|
731
|
+
await this.deactivate(liveManifest.id);
|
|
732
|
+
}
|
|
733
|
+
plugin.manifest = liveManifest;
|
|
734
|
+
plugin.status = "installed";
|
|
735
|
+
await this.activate(liveManifest.id);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Get contribution registry
|
|
739
|
+
*/
|
|
740
|
+
getContributions() {
|
|
741
|
+
return this.contributions;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Get middleware chain
|
|
745
|
+
*/
|
|
746
|
+
getMiddleware() {
|
|
747
|
+
return this.middleware;
|
|
748
|
+
}
|
|
749
|
+
// ─── Events ────────────────────────────────────────────────────────────
|
|
750
|
+
/**
|
|
751
|
+
* Subscribe to plugin changes
|
|
752
|
+
*/
|
|
753
|
+
onChange(listener) {
|
|
754
|
+
this.listeners.add(listener);
|
|
755
|
+
return {
|
|
756
|
+
dispose: () => this.listeners.delete(listener)
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
notify() {
|
|
760
|
+
for (const listener of this.listeners) {
|
|
761
|
+
try {
|
|
762
|
+
listener();
|
|
763
|
+
} catch (err) {
|
|
764
|
+
console.error("[PluginRegistry] Listener error:", err);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// ─── Static Contributions ──────────────────────────────────────────────
|
|
769
|
+
registerStaticContributions(manifest, ctx) {
|
|
770
|
+
const c = manifest.contributes;
|
|
771
|
+
if (!c) return;
|
|
772
|
+
if (c.views) {
|
|
773
|
+
for (const view of c.views) {
|
|
774
|
+
ctx.registerView(view);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (c.commands) {
|
|
778
|
+
for (const cmd of c.commands) {
|
|
779
|
+
ctx.registerCommand(cmd);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (c.slashCommands) {
|
|
783
|
+
for (const cmd of c.slashCommands) {
|
|
784
|
+
ctx.registerSlashCommand(cmd);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (c.sidebarItems) {
|
|
788
|
+
for (const item of c.sidebarItems) {
|
|
789
|
+
ctx.registerSidebarItem(item);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (c.editorExtensions) {
|
|
793
|
+
for (const ext of c.editorExtensions) {
|
|
794
|
+
ctx.registerEditorExtension(ext);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (c.propertyHandlers) {
|
|
798
|
+
for (const h of c.propertyHandlers) {
|
|
799
|
+
ctx.registerPropertyHandler(h.type, h.handler);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (c.blocks) {
|
|
803
|
+
for (const block of c.blocks) {
|
|
804
|
+
ctx.registerBlockType(block);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
// ─── Load from Store ───────────────────────────────────────────────────
|
|
809
|
+
/**
|
|
810
|
+
* Load and activate plugins from stored Nodes
|
|
811
|
+
*/
|
|
812
|
+
async loadFromStore() {
|
|
813
|
+
const nodes = await this.store.list();
|
|
814
|
+
const pluginNodes = nodes.filter((n) => n.schemaId === PluginSchema._schemaId);
|
|
815
|
+
for (const node of pluginNodes) {
|
|
816
|
+
const props = node.properties;
|
|
817
|
+
if (!props.enabled) continue;
|
|
818
|
+
try {
|
|
819
|
+
const manifest = JSON.parse(props.manifest);
|
|
820
|
+
if (this.plugins.has(manifest.id)) continue;
|
|
821
|
+
this.plugins.set(manifest.id, { manifest, status: "installed" });
|
|
822
|
+
await this.activate(manifest.id);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
console.error(`Failed to load plugin '${props.pluginId}':`, err);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// src/schemas/script.ts
|
|
831
|
+
import { defineSchema as defineSchema2, text as text2, checkbox as checkbox2, date as date2, select } from "@xnetjs/data";
|
|
832
|
+
var TRIGGER_OPTIONS = [
|
|
833
|
+
{ id: "manual", name: "Manual", color: "gray" },
|
|
834
|
+
{ id: "onChange", name: "On Change", color: "blue" },
|
|
835
|
+
{ id: "onView", name: "On View", color: "green" },
|
|
836
|
+
{ id: "scheduled", name: "Scheduled", color: "purple" }
|
|
837
|
+
];
|
|
838
|
+
var OUTPUT_OPTIONS = [
|
|
839
|
+
{ id: "value", name: "Value", color: "blue" },
|
|
840
|
+
{ id: "mutation", name: "Mutation", color: "orange" },
|
|
841
|
+
{ id: "decoration", name: "Decoration", color: "purple" },
|
|
842
|
+
{ id: "void", name: "Void", color: "gray" }
|
|
843
|
+
];
|
|
844
|
+
var ScriptSchema = defineSchema2({
|
|
845
|
+
name: "Script",
|
|
846
|
+
namespace: "xnet://xnet.dev/",
|
|
847
|
+
properties: {
|
|
848
|
+
/** Human-readable script name */
|
|
849
|
+
name: text2({ required: true }),
|
|
850
|
+
/** Description of what the script does */
|
|
851
|
+
description: text2({}),
|
|
852
|
+
/** The script code (JavaScript expression or arrow function) */
|
|
853
|
+
code: text2({ required: true }),
|
|
854
|
+
/** When the script should execute */
|
|
855
|
+
triggerType: select({
|
|
856
|
+
options: TRIGGER_OPTIONS,
|
|
857
|
+
default: "manual"
|
|
858
|
+
}),
|
|
859
|
+
/** For onChange trigger: which property to watch (empty = any) */
|
|
860
|
+
triggerProperty: text2({}),
|
|
861
|
+
/** For scheduled trigger: cron expression */
|
|
862
|
+
triggerCron: text2({}),
|
|
863
|
+
/** Schema IRI this script operates on (e.g., 'xnet://myapp/Task') */
|
|
864
|
+
inputSchema: text2({}),
|
|
865
|
+
/** What the script returns */
|
|
866
|
+
outputType: select({
|
|
867
|
+
options: OUTPUT_OPTIONS,
|
|
868
|
+
default: "value"
|
|
869
|
+
}),
|
|
870
|
+
/** Whether the script is active */
|
|
871
|
+
enabled: checkbox2({ default: true }),
|
|
872
|
+
/** Last execution error message (null if last run succeeded) */
|
|
873
|
+
lastError: text2({}),
|
|
874
|
+
/** Timestamp of last execution */
|
|
875
|
+
lastRun: date2({})
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
function isScriptNode(node) {
|
|
879
|
+
if (!node || typeof node !== "object") return false;
|
|
880
|
+
const n = node;
|
|
881
|
+
return typeof n.id === "string" && typeof n.name === "string" && typeof n.code === "string" && typeof n.triggerType === "string" && typeof n.outputType === "string" && typeof n.enabled === "boolean";
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/sandbox/context.ts
|
|
885
|
+
function createFormatHelpers() {
|
|
886
|
+
return {
|
|
887
|
+
date: (ts, options) => {
|
|
888
|
+
try {
|
|
889
|
+
return new Intl.DateTimeFormat("en-US", options).format(new Date(ts));
|
|
890
|
+
} catch {
|
|
891
|
+
return String(ts);
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
number: (val, options) => {
|
|
895
|
+
try {
|
|
896
|
+
return new Intl.NumberFormat("en-US", options).format(val);
|
|
897
|
+
} catch {
|
|
898
|
+
return String(val);
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
currency: (val, currency = "USD", locale = "en-US") => {
|
|
902
|
+
try {
|
|
903
|
+
return new Intl.NumberFormat(locale, {
|
|
904
|
+
style: "currency",
|
|
905
|
+
currency
|
|
906
|
+
}).format(val);
|
|
907
|
+
} catch {
|
|
908
|
+
return `${currency} ${val}`;
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
relative: (ts) => {
|
|
912
|
+
const diff = Date.now() - ts;
|
|
913
|
+
const absDiff = Math.abs(diff);
|
|
914
|
+
const suffix = diff >= 0 ? "ago" : "from now";
|
|
915
|
+
const seconds = Math.floor(absDiff / 1e3);
|
|
916
|
+
if (seconds < 60) return `${seconds}s ${suffix}`;
|
|
917
|
+
const minutes = Math.floor(seconds / 60);
|
|
918
|
+
if (minutes < 60) return `${minutes}m ${suffix}`;
|
|
919
|
+
const hours = Math.floor(minutes / 60);
|
|
920
|
+
if (hours < 24) return `${hours}h ${suffix}`;
|
|
921
|
+
const days = Math.floor(hours / 24);
|
|
922
|
+
if (days < 30) return `${days}d ${suffix}`;
|
|
923
|
+
const months = Math.floor(days / 30);
|
|
924
|
+
if (months < 12) return `${months}mo ${suffix}`;
|
|
925
|
+
const years = Math.floor(months / 12);
|
|
926
|
+
return `${years}y ${suffix}`;
|
|
927
|
+
},
|
|
928
|
+
bytes: (bytes) => {
|
|
929
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
930
|
+
let size = Math.abs(bytes);
|
|
931
|
+
let unitIndex = 0;
|
|
932
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
933
|
+
size /= 1024;
|
|
934
|
+
unitIndex++;
|
|
935
|
+
}
|
|
936
|
+
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function createMathHelpers() {
|
|
941
|
+
return {
|
|
942
|
+
sum: (vals) => {
|
|
943
|
+
if (!Array.isArray(vals) || vals.length === 0) return 0;
|
|
944
|
+
return vals.reduce((a, b) => a + (Number(b) || 0), 0);
|
|
945
|
+
},
|
|
946
|
+
avg: (vals) => {
|
|
947
|
+
if (!Array.isArray(vals) || vals.length === 0) return 0;
|
|
948
|
+
const sum = vals.reduce((a, b) => a + (Number(b) || 0), 0);
|
|
949
|
+
return sum / vals.length;
|
|
950
|
+
},
|
|
951
|
+
min: (vals) => {
|
|
952
|
+
if (!Array.isArray(vals) || vals.length === 0) return 0;
|
|
953
|
+
const numbers = vals.map((v) => Number(v) || 0);
|
|
954
|
+
return Math.min(...numbers);
|
|
955
|
+
},
|
|
956
|
+
max: (vals) => {
|
|
957
|
+
if (!Array.isArray(vals) || vals.length === 0) return 0;
|
|
958
|
+
const numbers = vals.map((v) => Number(v) || 0);
|
|
959
|
+
return Math.max(...numbers);
|
|
960
|
+
},
|
|
961
|
+
round: (val, decimals = 0) => {
|
|
962
|
+
const factor = Math.pow(10, decimals);
|
|
963
|
+
return Math.round(val * factor) / factor;
|
|
964
|
+
},
|
|
965
|
+
clamp: (val, min, max) => Math.min(Math.max(val, min), max),
|
|
966
|
+
abs: (val) => Math.abs(val),
|
|
967
|
+
floor: (val) => Math.floor(val),
|
|
968
|
+
ceil: (val) => Math.ceil(val)
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function createTextHelpers() {
|
|
972
|
+
return {
|
|
973
|
+
slugify: (t) => String(t || "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""),
|
|
974
|
+
truncate: (t, len) => {
|
|
975
|
+
const str = String(t || "");
|
|
976
|
+
return str.length > len ? str.slice(0, len) + "..." : str;
|
|
977
|
+
},
|
|
978
|
+
capitalize: (t) => {
|
|
979
|
+
const str = String(t || "");
|
|
980
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
981
|
+
},
|
|
982
|
+
titleCase: (t) => String(t || "").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
983
|
+
contains: (t, s) => String(t || "").toLowerCase().includes(String(s || "").toLowerCase()),
|
|
984
|
+
template: (tmpl, vars) => String(tmpl || "").replace(/\{(\w+)\}/g, (_, key) => {
|
|
985
|
+
const val = vars[key];
|
|
986
|
+
return val !== void 0 && val !== null ? String(val) : "";
|
|
987
|
+
}),
|
|
988
|
+
trim: (t) => String(t || "").trim(),
|
|
989
|
+
lower: (t) => String(t || "").toLowerCase(),
|
|
990
|
+
upper: (t) => String(t || "").toUpperCase()
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function createArrayHelpers() {
|
|
994
|
+
return {
|
|
995
|
+
first: (items) => Array.isArray(items) ? items[0] : void 0,
|
|
996
|
+
last: (items) => Array.isArray(items) ? items[items.length - 1] : void 0,
|
|
997
|
+
sortBy: (items, key, desc = false) => {
|
|
998
|
+
if (!Array.isArray(items)) return [];
|
|
999
|
+
return [...items].sort((a, b) => {
|
|
1000
|
+
const aVal = a[key];
|
|
1001
|
+
const bVal = b[key];
|
|
1002
|
+
if (aVal < bVal) return desc ? 1 : -1;
|
|
1003
|
+
if (aVal > bVal) return desc ? -1 : 1;
|
|
1004
|
+
return 0;
|
|
1005
|
+
});
|
|
1006
|
+
},
|
|
1007
|
+
groupBy: (items, key) => {
|
|
1008
|
+
if (!Array.isArray(items)) return {};
|
|
1009
|
+
const groups = {};
|
|
1010
|
+
for (const item of items) {
|
|
1011
|
+
const groupKey = String(item[key] ?? "undefined");
|
|
1012
|
+
if (!groups[groupKey]) groups[groupKey] = [];
|
|
1013
|
+
groups[groupKey].push(item);
|
|
1014
|
+
}
|
|
1015
|
+
return groups;
|
|
1016
|
+
},
|
|
1017
|
+
unique: (items) => {
|
|
1018
|
+
if (!Array.isArray(items)) return [];
|
|
1019
|
+
return [...new Set(items)];
|
|
1020
|
+
},
|
|
1021
|
+
count: (items) => Array.isArray(items) ? items.length : 0,
|
|
1022
|
+
compact: (items) => {
|
|
1023
|
+
if (!Array.isArray(items)) return [];
|
|
1024
|
+
return items.filter((item) => item != null);
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
function deepFreeze(obj) {
|
|
1029
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
1030
|
+
if (Array.isArray(obj)) {
|
|
1031
|
+
for (const item of obj) {
|
|
1032
|
+
deepFreeze(item);
|
|
1033
|
+
}
|
|
1034
|
+
return Object.freeze(obj);
|
|
1035
|
+
}
|
|
1036
|
+
for (const key of Object.keys(obj)) {
|
|
1037
|
+
const value = obj[key];
|
|
1038
|
+
if (value !== null && typeof value === "object") {
|
|
1039
|
+
deepFreeze(value);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return Object.freeze(obj);
|
|
1043
|
+
}
|
|
1044
|
+
function createScriptContext(node, queryFn) {
|
|
1045
|
+
const frozenNode = deepFreeze({ ...node });
|
|
1046
|
+
const format = Object.freeze(createFormatHelpers());
|
|
1047
|
+
const math = Object.freeze(createMathHelpers());
|
|
1048
|
+
const text3 = Object.freeze(createTextHelpers());
|
|
1049
|
+
const array = Object.freeze(createArrayHelpers());
|
|
1050
|
+
const context = {
|
|
1051
|
+
node: frozenNode,
|
|
1052
|
+
nodes: (schemaIRI) => {
|
|
1053
|
+
const results = queryFn(schemaIRI);
|
|
1054
|
+
return Object.freeze(results.map((n) => deepFreeze({ ...n })));
|
|
1055
|
+
},
|
|
1056
|
+
now: () => Date.now(),
|
|
1057
|
+
format,
|
|
1058
|
+
math,
|
|
1059
|
+
text: text3,
|
|
1060
|
+
array
|
|
1061
|
+
};
|
|
1062
|
+
return Object.freeze(context);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/sandbox/ast-validator.ts
|
|
1066
|
+
import * as acorn from "acorn";
|
|
1067
|
+
var FORBIDDEN_GLOBALS = /* @__PURE__ */ new Set([
|
|
1068
|
+
// Browser globals
|
|
1069
|
+
"window",
|
|
1070
|
+
"document",
|
|
1071
|
+
"globalThis",
|
|
1072
|
+
"self",
|
|
1073
|
+
"parent",
|
|
1074
|
+
"top",
|
|
1075
|
+
"frames",
|
|
1076
|
+
// Network access
|
|
1077
|
+
"fetch",
|
|
1078
|
+
"XMLHttpRequest",
|
|
1079
|
+
"WebSocket",
|
|
1080
|
+
"EventSource",
|
|
1081
|
+
"Request",
|
|
1082
|
+
"Response",
|
|
1083
|
+
// Storage
|
|
1084
|
+
"localStorage",
|
|
1085
|
+
"sessionStorage",
|
|
1086
|
+
"indexedDB",
|
|
1087
|
+
"caches",
|
|
1088
|
+
// Code execution (dangerous)
|
|
1089
|
+
"eval",
|
|
1090
|
+
"Function",
|
|
1091
|
+
// Node.js globals
|
|
1092
|
+
"require",
|
|
1093
|
+
"module",
|
|
1094
|
+
"exports",
|
|
1095
|
+
"process",
|
|
1096
|
+
"Buffer",
|
|
1097
|
+
"__dirname",
|
|
1098
|
+
"__filename",
|
|
1099
|
+
// Timers (could be used for timing attacks or resource exhaustion)
|
|
1100
|
+
"setTimeout",
|
|
1101
|
+
"setInterval",
|
|
1102
|
+
"setImmediate",
|
|
1103
|
+
"requestAnimationFrame",
|
|
1104
|
+
"requestIdleCallback",
|
|
1105
|
+
"clearTimeout",
|
|
1106
|
+
"clearInterval",
|
|
1107
|
+
"clearImmediate",
|
|
1108
|
+
"cancelAnimationFrame",
|
|
1109
|
+
"cancelIdleCallback",
|
|
1110
|
+
// Browser APIs
|
|
1111
|
+
"navigator",
|
|
1112
|
+
"location",
|
|
1113
|
+
"history",
|
|
1114
|
+
"screen",
|
|
1115
|
+
"alert",
|
|
1116
|
+
"confirm",
|
|
1117
|
+
"prompt",
|
|
1118
|
+
"open",
|
|
1119
|
+
"close",
|
|
1120
|
+
"print",
|
|
1121
|
+
// Web Workers
|
|
1122
|
+
"Worker",
|
|
1123
|
+
"SharedWorker",
|
|
1124
|
+
"ServiceWorker",
|
|
1125
|
+
// Other dangerous APIs
|
|
1126
|
+
"Proxy",
|
|
1127
|
+
"Reflect",
|
|
1128
|
+
"SharedArrayBuffer",
|
|
1129
|
+
"Atomics",
|
|
1130
|
+
"WebAssembly",
|
|
1131
|
+
"crypto",
|
|
1132
|
+
"performance"
|
|
1133
|
+
]);
|
|
1134
|
+
var FORBIDDEN_PROPERTIES = /* @__PURE__ */ new Set([
|
|
1135
|
+
"__proto__",
|
|
1136
|
+
"constructor",
|
|
1137
|
+
"prototype",
|
|
1138
|
+
"__defineGetter__",
|
|
1139
|
+
"__defineSetter__",
|
|
1140
|
+
"__lookupGetter__",
|
|
1141
|
+
"__lookupSetter__"
|
|
1142
|
+
]);
|
|
1143
|
+
function walkAST(node, visitors) {
|
|
1144
|
+
if (!node || typeof node !== "object") return;
|
|
1145
|
+
const nodeType = node.type;
|
|
1146
|
+
const visitor = visitors[nodeType];
|
|
1147
|
+
if (visitor) {
|
|
1148
|
+
visitor(node);
|
|
1149
|
+
}
|
|
1150
|
+
for (const key of Object.keys(node)) {
|
|
1151
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const child = node[key];
|
|
1155
|
+
if (Array.isArray(child)) {
|
|
1156
|
+
for (const item of child) {
|
|
1157
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
1158
|
+
walkAST(item, visitors);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
} else if (child && typeof child === "object" && "type" in child) {
|
|
1162
|
+
walkAST(child, visitors);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
function validateScriptAST(code) {
|
|
1167
|
+
const errors = [];
|
|
1168
|
+
const declaredVars = /* @__PURE__ */ new Set();
|
|
1169
|
+
try {
|
|
1170
|
+
const trimmed = code.trim();
|
|
1171
|
+
const wrappedCode = trimmed.startsWith("(") || trimmed.startsWith("function") ? trimmed : `(${trimmed})`;
|
|
1172
|
+
const ast = acorn.parse(wrappedCode, {
|
|
1173
|
+
ecmaVersion: 2022,
|
|
1174
|
+
sourceType: "script",
|
|
1175
|
+
// No import/export
|
|
1176
|
+
allowReturnOutsideFunction: true
|
|
1177
|
+
});
|
|
1178
|
+
walkAST(ast, {
|
|
1179
|
+
VariableDeclarator: (node) => {
|
|
1180
|
+
if (node.id?.type === "Identifier") {
|
|
1181
|
+
declaredVars.add(node.id.name);
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
FunctionDeclaration: (node) => {
|
|
1185
|
+
if (node.id?.name) {
|
|
1186
|
+
declaredVars.add(node.id.name);
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
FunctionExpression: (node) => {
|
|
1190
|
+
if (node.id?.name) {
|
|
1191
|
+
declaredVars.add(node.id.name);
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
ArrowFunctionExpression: (node) => {
|
|
1195
|
+
for (const param of node.params || []) {
|
|
1196
|
+
if (param.type === "Identifier") {
|
|
1197
|
+
declaredVars.add(param.name);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
walkAST(ast, {
|
|
1203
|
+
FunctionDeclaration: (node) => {
|
|
1204
|
+
for (const param of node.params || []) {
|
|
1205
|
+
if (param.type === "Identifier") {
|
|
1206
|
+
declaredVars.add(param.name);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
FunctionExpression: (node) => {
|
|
1211
|
+
for (const param of node.params || []) {
|
|
1212
|
+
if (param.type === "Identifier") {
|
|
1213
|
+
declaredVars.add(param.name);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
walkAST(ast, {
|
|
1219
|
+
VariableDeclarator: (node) => {
|
|
1220
|
+
if (node.id.type === "Identifier") {
|
|
1221
|
+
declaredVars.add(node.id.name);
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
FunctionDeclaration: (node) => {
|
|
1225
|
+
if (node.id?.name) {
|
|
1226
|
+
declaredVars.add(node.id.name);
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
FunctionExpression: (node) => {
|
|
1230
|
+
if (node.id?.name) {
|
|
1231
|
+
declaredVars.add(node.id.name);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
ArrowFunctionExpression: (node) => {
|
|
1235
|
+
for (const param of node.params) {
|
|
1236
|
+
if (param.type === "Identifier") {
|
|
1237
|
+
declaredVars.add(param.name);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
walkAST(ast, {
|
|
1243
|
+
FunctionDeclaration: (node) => {
|
|
1244
|
+
for (const param of node.params) {
|
|
1245
|
+
if (param.type === "Identifier") {
|
|
1246
|
+
declaredVars.add(param.name);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
FunctionExpression: (node) => {
|
|
1251
|
+
for (const param of node.params) {
|
|
1252
|
+
if (param.type === "Identifier") {
|
|
1253
|
+
declaredVars.add(param.name);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
walkAST(ast, {
|
|
1259
|
+
// Check for forbidden globals
|
|
1260
|
+
Identifier: (node) => {
|
|
1261
|
+
const name = node.name;
|
|
1262
|
+
if (declaredVars.has(name)) return;
|
|
1263
|
+
const safeBuiltins = /* @__PURE__ */ new Set([
|
|
1264
|
+
"undefined",
|
|
1265
|
+
"null",
|
|
1266
|
+
"true",
|
|
1267
|
+
"false",
|
|
1268
|
+
"NaN",
|
|
1269
|
+
"Infinity",
|
|
1270
|
+
"Array",
|
|
1271
|
+
"Object",
|
|
1272
|
+
"String",
|
|
1273
|
+
"Number",
|
|
1274
|
+
"Boolean",
|
|
1275
|
+
"Date",
|
|
1276
|
+
"Math",
|
|
1277
|
+
"JSON",
|
|
1278
|
+
"Error",
|
|
1279
|
+
"TypeError",
|
|
1280
|
+
"RangeError",
|
|
1281
|
+
"Map",
|
|
1282
|
+
"Set",
|
|
1283
|
+
"WeakMap",
|
|
1284
|
+
"WeakSet",
|
|
1285
|
+
"Symbol",
|
|
1286
|
+
"BigInt",
|
|
1287
|
+
"Promise",
|
|
1288
|
+
"parseInt",
|
|
1289
|
+
"parseFloat",
|
|
1290
|
+
"isNaN",
|
|
1291
|
+
"isFinite",
|
|
1292
|
+
"encodeURI",
|
|
1293
|
+
"encodeURIComponent",
|
|
1294
|
+
"decodeURI",
|
|
1295
|
+
"decodeURIComponent"
|
|
1296
|
+
]);
|
|
1297
|
+
if (safeBuiltins.has(name)) return;
|
|
1298
|
+
if (FORBIDDEN_GLOBALS.has(name)) {
|
|
1299
|
+
errors.push(`Forbidden global access: '${name}' at position ${node.start}`);
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
// No import statements
|
|
1303
|
+
ImportDeclaration: (node) => {
|
|
1304
|
+
errors.push(`Import statements are not allowed at position ${node.start}`);
|
|
1305
|
+
},
|
|
1306
|
+
// No export statements
|
|
1307
|
+
ExportNamedDeclaration: (node) => {
|
|
1308
|
+
errors.push(`Export statements are not allowed at position ${node.start}`);
|
|
1309
|
+
},
|
|
1310
|
+
ExportDefaultDeclaration: (node) => {
|
|
1311
|
+
errors.push(`Export statements are not allowed at position ${node.start}`);
|
|
1312
|
+
},
|
|
1313
|
+
ExportAllDeclaration: (node) => {
|
|
1314
|
+
errors.push(`Export statements are not allowed at position ${node.start}`);
|
|
1315
|
+
},
|
|
1316
|
+
// No dynamic import()
|
|
1317
|
+
ImportExpression: (node) => {
|
|
1318
|
+
errors.push(`Dynamic import() is not allowed at position ${node.start}`);
|
|
1319
|
+
},
|
|
1320
|
+
// No async/await (scripts must be synchronous for predictability)
|
|
1321
|
+
AwaitExpression: (node) => {
|
|
1322
|
+
errors.push(`Async/await is not allowed at position ${node.start}`);
|
|
1323
|
+
},
|
|
1324
|
+
// Check for async functions
|
|
1325
|
+
FunctionDeclaration: (node) => {
|
|
1326
|
+
if (node.async) {
|
|
1327
|
+
errors.push(`Async functions are not allowed at position ${node.start}`);
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
FunctionExpression: (node) => {
|
|
1331
|
+
if (node.async) {
|
|
1332
|
+
errors.push(`Async functions are not allowed at position ${node.start}`);
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
ArrowFunctionExpression: (node) => {
|
|
1336
|
+
if (node.async) {
|
|
1337
|
+
errors.push(`Async arrow functions are not allowed at position ${node.start}`);
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
// No new Function()
|
|
1341
|
+
NewExpression: (node) => {
|
|
1342
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "Function") {
|
|
1343
|
+
errors.push(`new Function() is not allowed at position ${node.start}`);
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
// Check for forbidden property access
|
|
1347
|
+
MemberExpression: (node) => {
|
|
1348
|
+
if (node.computed && node.property?.type === "Literal") {
|
|
1349
|
+
const propName = String(node.property.value);
|
|
1350
|
+
if (FORBIDDEN_PROPERTIES.has(propName)) {
|
|
1351
|
+
errors.push(`Access to '${propName}' is forbidden at position ${node.property.start}`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (!node.computed && node.property?.type === "Identifier") {
|
|
1355
|
+
if (FORBIDDEN_PROPERTIES.has(node.property.name)) {
|
|
1356
|
+
errors.push(
|
|
1357
|
+
`Access to '${node.property.name}' is forbidden at position ${node.property.start}`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
// No with statements
|
|
1363
|
+
WithStatement: (node) => {
|
|
1364
|
+
errors.push(`'with' statements are not allowed at position ${node.start}`);
|
|
1365
|
+
},
|
|
1366
|
+
// Check for eval() calls
|
|
1367
|
+
CallExpression: (node) => {
|
|
1368
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "eval") {
|
|
1369
|
+
errors.push(`eval() is not allowed at position ${node.start}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1375
|
+
errors.push(`Syntax error: ${message}`);
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
valid: errors.length === 0,
|
|
1379
|
+
errors
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
function quickSafetyCheck(code) {
|
|
1383
|
+
const lowerCode = code.toLowerCase();
|
|
1384
|
+
const dangerousPatterns = [
|
|
1385
|
+
"import ",
|
|
1386
|
+
"require(",
|
|
1387
|
+
"eval(",
|
|
1388
|
+
"__proto__",
|
|
1389
|
+
"constructor(",
|
|
1390
|
+
"function(",
|
|
1391
|
+
"fetch(",
|
|
1392
|
+
"xmlhttprequest",
|
|
1393
|
+
"websocket",
|
|
1394
|
+
"localstorage",
|
|
1395
|
+
"sessionstorage",
|
|
1396
|
+
"indexeddb",
|
|
1397
|
+
"document.",
|
|
1398
|
+
"window.",
|
|
1399
|
+
"globalthis",
|
|
1400
|
+
"process.",
|
|
1401
|
+
"child_process"
|
|
1402
|
+
];
|
|
1403
|
+
for (const pattern of dangerousPatterns) {
|
|
1404
|
+
if (lowerCode.includes(pattern)) {
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// src/sandbox/sandbox.ts
|
|
1412
|
+
var ScriptError = class extends Error {
|
|
1413
|
+
constructor(message, details) {
|
|
1414
|
+
super(message);
|
|
1415
|
+
this.details = details;
|
|
1416
|
+
this.name = "ScriptError";
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
var ScriptTimeoutError = class extends ScriptError {
|
|
1420
|
+
constructor(timeoutMs) {
|
|
1421
|
+
super("Script execution timed out", [`Exceeded ${timeoutMs}ms limit`]);
|
|
1422
|
+
this.name = "ScriptTimeoutError";
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
var ScriptValidationError = class extends ScriptError {
|
|
1426
|
+
constructor(errors) {
|
|
1427
|
+
super("Script validation failed", errors);
|
|
1428
|
+
this.name = "ScriptValidationError";
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
var ScriptSandbox = class {
|
|
1432
|
+
options;
|
|
1433
|
+
constructor(options = {}) {
|
|
1434
|
+
this.options = {
|
|
1435
|
+
timeoutMs: options.timeoutMs ?? 1e3,
|
|
1436
|
+
validateAST: options.validateAST ?? true,
|
|
1437
|
+
telemetry: options.telemetry
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Execute a script in the sandbox.
|
|
1442
|
+
*
|
|
1443
|
+
* @param code - JavaScript code (expression or arrow function)
|
|
1444
|
+
* @param context - The frozen ScriptContext
|
|
1445
|
+
* @returns The script's return value (sanitized)
|
|
1446
|
+
* @throws ScriptValidationError if AST validation fails
|
|
1447
|
+
* @throws ScriptTimeoutError if execution exceeds timeout
|
|
1448
|
+
* @throws ScriptError for other execution errors
|
|
1449
|
+
*/
|
|
1450
|
+
async execute(code, context) {
|
|
1451
|
+
const start = this.options.telemetry ? Date.now() : 0;
|
|
1452
|
+
if (this.options.validateAST) {
|
|
1453
|
+
const validation = validateScriptAST(code);
|
|
1454
|
+
if (!validation.valid) {
|
|
1455
|
+
this.options.telemetry?.reportUsage("plugins.ast_validation_failure", 1);
|
|
1456
|
+
throw new ScriptValidationError(validation.errors);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const fn = this.createIsolatedFunction(code);
|
|
1460
|
+
try {
|
|
1461
|
+
const result = await this.executeWithTimeout(fn, context);
|
|
1462
|
+
const sanitized = this.sanitizeOutput(result);
|
|
1463
|
+
this.options.telemetry?.reportPerformance("plugins.execute", Date.now() - start);
|
|
1464
|
+
this.options.telemetry?.reportUsage("plugins.execute", 1);
|
|
1465
|
+
return sanitized;
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1468
|
+
this.options.telemetry?.reportCrash(error, { codeNamespace: "plugins.ScriptSandbox.execute" });
|
|
1469
|
+
this.options.telemetry?.reportUsage("plugins.execute_failure", 1);
|
|
1470
|
+
throw err;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Execute a script synchronously (for computed columns).
|
|
1475
|
+
* Use with caution - no timeout protection in sync mode.
|
|
1476
|
+
*/
|
|
1477
|
+
executeSync(code, context) {
|
|
1478
|
+
const start = this.options.telemetry ? Date.now() : 0;
|
|
1479
|
+
if (this.options.validateAST) {
|
|
1480
|
+
const validation = validateScriptAST(code);
|
|
1481
|
+
if (!validation.valid) {
|
|
1482
|
+
this.options.telemetry?.reportUsage("plugins.ast_validation_failure", 1);
|
|
1483
|
+
throw new ScriptValidationError(validation.errors);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const fn = this.createIsolatedFunction(code);
|
|
1487
|
+
try {
|
|
1488
|
+
const result = fn(context);
|
|
1489
|
+
const sanitized = this.sanitizeOutput(result);
|
|
1490
|
+
this.options.telemetry?.reportPerformance("plugins.execute_sync", Date.now() - start);
|
|
1491
|
+
this.options.telemetry?.reportUsage("plugins.execute_sync", 1);
|
|
1492
|
+
return sanitized;
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1495
|
+
this.options.telemetry?.reportCrash(error, {
|
|
1496
|
+
codeNamespace: "plugins.ScriptSandbox.executeSync"
|
|
1497
|
+
});
|
|
1498
|
+
this.options.telemetry?.reportUsage("plugins.execute_sync_failure", 1);
|
|
1499
|
+
throw new ScriptError("Script execution error", [
|
|
1500
|
+
err instanceof Error ? err.message : String(err)
|
|
1501
|
+
]);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Create an isolated function from user code.
|
|
1506
|
+
*
|
|
1507
|
+
* The function receives context values as arguments and has dangerous
|
|
1508
|
+
* globals shadowed to undefined in its scope.
|
|
1509
|
+
*/
|
|
1510
|
+
createIsolatedFunction(code) {
|
|
1511
|
+
const wrapped = `
|
|
1512
|
+
"use strict";
|
|
1513
|
+
// Shadow dangerous globals (except 'eval' which is reserved in strict mode)
|
|
1514
|
+
var window = void 0;
|
|
1515
|
+
var document = void 0;
|
|
1516
|
+
var globalThis = void 0;
|
|
1517
|
+
var self = void 0;
|
|
1518
|
+
var parent = void 0;
|
|
1519
|
+
var top = void 0;
|
|
1520
|
+
var frames = void 0;
|
|
1521
|
+
var fetch = void 0;
|
|
1522
|
+
var XMLHttpRequest = void 0;
|
|
1523
|
+
var WebSocket = void 0;
|
|
1524
|
+
var require = void 0;
|
|
1525
|
+
var process = void 0;
|
|
1526
|
+
var Buffer = void 0;
|
|
1527
|
+
var setTimeout = void 0;
|
|
1528
|
+
var setInterval = void 0;
|
|
1529
|
+
var setImmediate = void 0;
|
|
1530
|
+
var requestAnimationFrame = void 0;
|
|
1531
|
+
var localStorage = void 0;
|
|
1532
|
+
var sessionStorage = void 0;
|
|
1533
|
+
var indexedDB = void 0;
|
|
1534
|
+
var navigator = void 0;
|
|
1535
|
+
var location = void 0;
|
|
1536
|
+
var history = void 0;
|
|
1537
|
+
var Worker = void 0;
|
|
1538
|
+
var SharedWorker = void 0;
|
|
1539
|
+
var ServiceWorker = void 0;
|
|
1540
|
+
var Proxy = void 0;
|
|
1541
|
+
var Reflect = void 0;
|
|
1542
|
+
var WebAssembly = void 0;
|
|
1543
|
+
var crypto = void 0;
|
|
1544
|
+
// Return the user's function/expression
|
|
1545
|
+
return (${code.trim()});
|
|
1546
|
+
`;
|
|
1547
|
+
const factory = new Function("node", "nodes", "now", "format", "math", "text", "array", wrapped);
|
|
1548
|
+
return (ctx) => {
|
|
1549
|
+
const userFn = factory(
|
|
1550
|
+
ctx.node,
|
|
1551
|
+
ctx.nodes,
|
|
1552
|
+
ctx.now,
|
|
1553
|
+
ctx.format,
|
|
1554
|
+
ctx.math,
|
|
1555
|
+
ctx.text,
|
|
1556
|
+
ctx.array
|
|
1557
|
+
);
|
|
1558
|
+
if (typeof userFn === "function") {
|
|
1559
|
+
return userFn(ctx.node, ctx);
|
|
1560
|
+
}
|
|
1561
|
+
return userFn;
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Execute a function with a timeout.
|
|
1566
|
+
*/
|
|
1567
|
+
executeWithTimeout(fn, ctx) {
|
|
1568
|
+
return new Promise((resolve, reject) => {
|
|
1569
|
+
const timer = setTimeout(() => {
|
|
1570
|
+
reject(new ScriptTimeoutError(this.options.timeoutMs));
|
|
1571
|
+
}, this.options.timeoutMs);
|
|
1572
|
+
try {
|
|
1573
|
+
const result = fn(ctx);
|
|
1574
|
+
clearTimeout(timer);
|
|
1575
|
+
resolve(result);
|
|
1576
|
+
} catch (err) {
|
|
1577
|
+
clearTimeout(timer);
|
|
1578
|
+
reject(
|
|
1579
|
+
new ScriptError("Script execution error", [
|
|
1580
|
+
err instanceof Error ? err.message : String(err)
|
|
1581
|
+
])
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Sanitize script output to ensure only safe values are returned.
|
|
1588
|
+
*
|
|
1589
|
+
* Allowed types: null, undefined, string, number, boolean, plain objects, arrays
|
|
1590
|
+
* Stripped: functions, symbols, circular references, dunder properties
|
|
1591
|
+
*/
|
|
1592
|
+
sanitizeOutput(result, seen = /* @__PURE__ */ new WeakSet()) {
|
|
1593
|
+
if (result === null || result === void 0) return result;
|
|
1594
|
+
if (typeof result === "string") return result;
|
|
1595
|
+
if (typeof result === "number") return Number.isFinite(result) ? result : null;
|
|
1596
|
+
if (typeof result === "boolean") return result;
|
|
1597
|
+
if (typeof result === "function") return void 0;
|
|
1598
|
+
if (typeof result === "symbol") return void 0;
|
|
1599
|
+
if (Array.isArray(result)) {
|
|
1600
|
+
if (seen.has(result)) return void 0;
|
|
1601
|
+
seen.add(result);
|
|
1602
|
+
return result.map((item) => this.sanitizeOutput(item, seen));
|
|
1603
|
+
}
|
|
1604
|
+
if (typeof result === "object") {
|
|
1605
|
+
if (seen.has(result)) return void 0;
|
|
1606
|
+
seen.add(result);
|
|
1607
|
+
const proto = Object.getPrototypeOf(result);
|
|
1608
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
1609
|
+
if (result instanceof Date) {
|
|
1610
|
+
return result.getTime();
|
|
1611
|
+
}
|
|
1612
|
+
return void 0;
|
|
1613
|
+
}
|
|
1614
|
+
const clean = {};
|
|
1615
|
+
for (const [key, value] of Object.entries(result)) {
|
|
1616
|
+
if (key.startsWith("__")) continue;
|
|
1617
|
+
if (typeof key === "symbol") continue;
|
|
1618
|
+
const sanitized = this.sanitizeOutput(value, seen);
|
|
1619
|
+
if (sanitized !== void 0) {
|
|
1620
|
+
clean[key] = sanitized;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return clean;
|
|
1624
|
+
}
|
|
1625
|
+
return void 0;
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
async function executeScript(code, context) {
|
|
1629
|
+
const sandbox = new ScriptSandbox();
|
|
1630
|
+
return sandbox.execute(code, context);
|
|
1631
|
+
}
|
|
1632
|
+
function validateScript(code) {
|
|
1633
|
+
return validateScriptAST(code);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/sandbox/runner.ts
|
|
1637
|
+
var ScriptRunner = class {
|
|
1638
|
+
sandbox;
|
|
1639
|
+
store;
|
|
1640
|
+
subscriptions = [];
|
|
1641
|
+
scriptSubscriptions = /* @__PURE__ */ new Map();
|
|
1642
|
+
started = false;
|
|
1643
|
+
telemetry;
|
|
1644
|
+
constructor(options) {
|
|
1645
|
+
this.store = options.store;
|
|
1646
|
+
this.telemetry = options.telemetry;
|
|
1647
|
+
this.sandbox = new ScriptSandbox({ ...options.sandboxOptions, telemetry: options.telemetry });
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Start the script runner.
|
|
1651
|
+
* Registers all enabled scripts and sets up triggers.
|
|
1652
|
+
*/
|
|
1653
|
+
async start() {
|
|
1654
|
+
if (this.started) return;
|
|
1655
|
+
this.started = true;
|
|
1656
|
+
const scripts = this.store.list({ schemaIRI: "xnet://xnet.dev/Script" });
|
|
1657
|
+
for (const script of scripts) {
|
|
1658
|
+
if (script.enabled) {
|
|
1659
|
+
this.registerScript(script);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
const unsubScripts = this.store.subscribe((event) => {
|
|
1663
|
+
if (event.node?.schemaIRI === "xnet://xnet.dev/Script") {
|
|
1664
|
+
this.handleScriptChange(event);
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
this.subscriptions.push(unsubScripts);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Stop the script runner.
|
|
1671
|
+
* Removes all triggers and subscriptions.
|
|
1672
|
+
*/
|
|
1673
|
+
stop() {
|
|
1674
|
+
this.started = false;
|
|
1675
|
+
for (const unsub of this.subscriptions) {
|
|
1676
|
+
unsub();
|
|
1677
|
+
}
|
|
1678
|
+
this.subscriptions = [];
|
|
1679
|
+
for (const unsub of this.scriptSubscriptions.values()) {
|
|
1680
|
+
unsub();
|
|
1681
|
+
}
|
|
1682
|
+
this.scriptSubscriptions.clear();
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Execute a script manually for testing/preview.
|
|
1686
|
+
* Does not update lastRun/lastError on the script node.
|
|
1687
|
+
*/
|
|
1688
|
+
async executeManual(code, node) {
|
|
1689
|
+
const startTime = Date.now();
|
|
1690
|
+
try {
|
|
1691
|
+
const context = createScriptContext(node, (schemaIRI) => this.store.list({ schemaIRI }));
|
|
1692
|
+
const result = await this.sandbox.execute(code, context);
|
|
1693
|
+
return {
|
|
1694
|
+
success: true,
|
|
1695
|
+
result,
|
|
1696
|
+
durationMs: Date.now() - startTime
|
|
1697
|
+
};
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
const errorMsg = err instanceof ScriptError ? err.details.join("; ") : err instanceof Error ? err.message : String(err);
|
|
1700
|
+
return {
|
|
1701
|
+
success: false,
|
|
1702
|
+
error: errorMsg,
|
|
1703
|
+
durationMs: Date.now() - startTime
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Execute a script as a computed value (synchronous).
|
|
1709
|
+
* Used for computed columns in table views.
|
|
1710
|
+
*/
|
|
1711
|
+
computeValue(code, node) {
|
|
1712
|
+
const context = createScriptContext(node, (schemaIRI) => this.store.list({ schemaIRI }));
|
|
1713
|
+
return this.sandbox.executeSync(code, context);
|
|
1714
|
+
}
|
|
1715
|
+
// ─── Private Methods ───────────────────────────────────────────────────────
|
|
1716
|
+
/**
|
|
1717
|
+
* Register a script and set up its trigger.
|
|
1718
|
+
*/
|
|
1719
|
+
registerScript(script) {
|
|
1720
|
+
this.unregisterScript(script.id);
|
|
1721
|
+
const trigger = script.triggerType;
|
|
1722
|
+
switch (trigger) {
|
|
1723
|
+
case "onChange":
|
|
1724
|
+
this.registerOnChangeTrigger(script);
|
|
1725
|
+
break;
|
|
1726
|
+
case "manual":
|
|
1727
|
+
break;
|
|
1728
|
+
case "onView":
|
|
1729
|
+
break;
|
|
1730
|
+
case "scheduled":
|
|
1731
|
+
console.warn(`Scheduled triggers not yet implemented for script: ${script.name}`);
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Unregister a script's trigger.
|
|
1737
|
+
*/
|
|
1738
|
+
unregisterScript(scriptId) {
|
|
1739
|
+
const unsub = this.scriptSubscriptions.get(scriptId);
|
|
1740
|
+
if (unsub) {
|
|
1741
|
+
unsub();
|
|
1742
|
+
this.scriptSubscriptions.delete(scriptId);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Register an onChange trigger for a script.
|
|
1747
|
+
*/
|
|
1748
|
+
registerOnChangeTrigger(script) {
|
|
1749
|
+
if (!script.inputSchema) {
|
|
1750
|
+
console.warn(`onChange script '${script.name}' has no inputSchema`);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const unsub = this.store.subscribe(async (event) => {
|
|
1754
|
+
if (event.type !== "update" && event.type !== "create") return;
|
|
1755
|
+
if (!event.node || event.node.schemaIRI !== script.inputSchema) return;
|
|
1756
|
+
if (script.triggerProperty && event.change.payload) {
|
|
1757
|
+
if (!(script.triggerProperty in event.change.payload)) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
await this.executeScript(script, event.node);
|
|
1762
|
+
});
|
|
1763
|
+
this.scriptSubscriptions.set(script.id, unsub);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Execute a script and handle results.
|
|
1767
|
+
*/
|
|
1768
|
+
async executeScript(script, targetNode) {
|
|
1769
|
+
const startTime = Date.now();
|
|
1770
|
+
try {
|
|
1771
|
+
const context = createScriptContext(targetNode, (schemaIRI) => this.store.list({ schemaIRI }));
|
|
1772
|
+
const result = await this.sandbox.execute(script.code, context);
|
|
1773
|
+
if (result && typeof result === "object") {
|
|
1774
|
+
const outputType = script.outputType;
|
|
1775
|
+
if (outputType === "mutation") {
|
|
1776
|
+
await this.store.update(targetNode.id, result);
|
|
1777
|
+
} else if (outputType === "decoration") {
|
|
1778
|
+
await this.store.update(targetNode.id, { _decoration: result });
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
this.telemetry?.reportPerformance("plugins.script_execution", Date.now() - startTime);
|
|
1782
|
+
await this.store.update(script.id, {
|
|
1783
|
+
lastRun: Date.now(),
|
|
1784
|
+
lastError: null
|
|
1785
|
+
});
|
|
1786
|
+
} catch (err) {
|
|
1787
|
+
const errorMsg = err instanceof ScriptError ? err.details.join("; ") : err instanceof Error ? err.message : String(err);
|
|
1788
|
+
this.telemetry?.reportUsage("plugins.crash_recovery", 1);
|
|
1789
|
+
await this.store.update(script.id, {
|
|
1790
|
+
lastRun: Date.now(),
|
|
1791
|
+
lastError: errorMsg
|
|
1792
|
+
});
|
|
1793
|
+
console.error(`Script '${script.name}' failed:`, errorMsg);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Handle changes to script nodes.
|
|
1798
|
+
*/
|
|
1799
|
+
handleScriptChange(event) {
|
|
1800
|
+
const script = event.node;
|
|
1801
|
+
if (!script) return;
|
|
1802
|
+
switch (event.type) {
|
|
1803
|
+
case "create":
|
|
1804
|
+
if (script.enabled) {
|
|
1805
|
+
this.registerScript(script);
|
|
1806
|
+
}
|
|
1807
|
+
break;
|
|
1808
|
+
case "update":
|
|
1809
|
+
if (script.enabled) {
|
|
1810
|
+
this.registerScript(script);
|
|
1811
|
+
} else {
|
|
1812
|
+
this.unregisterScript(script.id);
|
|
1813
|
+
}
|
|
1814
|
+
break;
|
|
1815
|
+
case "delete":
|
|
1816
|
+
this.unregisterScript(script.id);
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1822
|
+
// src/ai/prompt.ts
|
|
1823
|
+
var SYSTEM_CONTEXT = `You are generating a JavaScript script for xNet, a collaborative data platform.
|
|
1824
|
+
The script is a pure function that receives a node (data record) and a context object.
|
|
1825
|
+
It must return a value, a mutation object, or null.
|
|
1826
|
+
|
|
1827
|
+
CRITICAL CONSTRAINTS:
|
|
1828
|
+
- NO imports, require, or dynamic import
|
|
1829
|
+
- NO fetch, XMLHttpRequest, or network access
|
|
1830
|
+
- NO DOM access (window, document, globalThis)
|
|
1831
|
+
- NO async/await, setTimeout, setInterval
|
|
1832
|
+
- NO eval, Function constructor, or __proto__ access
|
|
1833
|
+
- Pure synchronous function only
|
|
1834
|
+
- Must use arrow function syntax: (node, ctx) => ...`;
|
|
1835
|
+
var API_DOCUMENTATION = `
|
|
1836
|
+
## Available API
|
|
1837
|
+
|
|
1838
|
+
The function signature is: (node, ctx) => result
|
|
1839
|
+
|
|
1840
|
+
### node (read-only)
|
|
1841
|
+
The current data record. Access properties directly: node.propertyName
|
|
1842
|
+
|
|
1843
|
+
### ctx object (ScriptContext)
|
|
1844
|
+
|
|
1845
|
+
**Query:**
|
|
1846
|
+
- ctx.nodes(schemaIRI?) - Query nodes, optionally filtered by schema. Returns frozen array.
|
|
1847
|
+
|
|
1848
|
+
**Time:**
|
|
1849
|
+
- ctx.now() - Current timestamp in milliseconds
|
|
1850
|
+
|
|
1851
|
+
**Format Helpers (ctx.format):**
|
|
1852
|
+
- ctx.format.date(timestamp, options?) - Format timestamp as date string
|
|
1853
|
+
- ctx.format.number(value, options?) - Format number with locale
|
|
1854
|
+
- ctx.format.currency(value, currency?, locale?) - Format as currency (default: USD)
|
|
1855
|
+
- ctx.format.relative(timestamp) - Relative time ("2h ago", "3d ago")
|
|
1856
|
+
- ctx.format.bytes(bytes) - Format bytes ("1.5 KB", "2.3 MB")
|
|
1857
|
+
|
|
1858
|
+
**Math Helpers (ctx.math):**
|
|
1859
|
+
- ctx.math.sum(numbers[]) - Sum of array
|
|
1860
|
+
- ctx.math.avg(numbers[]) - Average of array
|
|
1861
|
+
- ctx.math.min(numbers[]) - Minimum value
|
|
1862
|
+
- ctx.math.max(numbers[]) - Maximum value
|
|
1863
|
+
- ctx.math.round(value, decimals?) - Round to decimal places
|
|
1864
|
+
- ctx.math.clamp(value, min, max) - Clamp between min and max
|
|
1865
|
+
- ctx.math.abs(value) - Absolute value
|
|
1866
|
+
- ctx.math.floor(value) - Floor
|
|
1867
|
+
- ctx.math.ceil(value) - Ceiling
|
|
1868
|
+
|
|
1869
|
+
**Text Helpers (ctx.text):**
|
|
1870
|
+
- ctx.text.slugify(str) - Convert to URL-safe slug
|
|
1871
|
+
- ctx.text.truncate(str, maxLength) - Truncate with ellipsis
|
|
1872
|
+
- ctx.text.capitalize(str) - Capitalize first letter
|
|
1873
|
+
- ctx.text.titleCase(str) - Title Case Each Word
|
|
1874
|
+
- ctx.text.contains(str, search) - Case-insensitive contains
|
|
1875
|
+
- ctx.text.template(template, vars) - Simple {var} replacement
|
|
1876
|
+
- ctx.text.trim(str) - Trim whitespace
|
|
1877
|
+
- ctx.text.lower(str) - Lowercase
|
|
1878
|
+
- ctx.text.upper(str) - Uppercase
|
|
1879
|
+
|
|
1880
|
+
**Array Helpers (ctx.array):**
|
|
1881
|
+
- ctx.array.first(items) - Get first element
|
|
1882
|
+
- ctx.array.last(items) - Get last element
|
|
1883
|
+
- ctx.array.sortBy(items, key, desc?) - Sort by property
|
|
1884
|
+
- ctx.array.groupBy(items, key) - Group by property
|
|
1885
|
+
- ctx.array.unique(items) - Remove duplicates
|
|
1886
|
+
- ctx.array.count(items) - Count items
|
|
1887
|
+
- ctx.array.compact(items) - Remove null/undefined
|
|
1888
|
+
`;
|
|
1889
|
+
function formatSchemaProperties(properties) {
|
|
1890
|
+
if (properties.length === 0) {
|
|
1891
|
+
return "(No properties defined)";
|
|
1892
|
+
}
|
|
1893
|
+
return properties.map((prop) => {
|
|
1894
|
+
let line = `- node.${prop.name}: ${prop.type}`;
|
|
1895
|
+
if (prop.required) line += " (required)";
|
|
1896
|
+
if (prop.description) line += ` - ${prop.description}`;
|
|
1897
|
+
return line;
|
|
1898
|
+
}).join("\n");
|
|
1899
|
+
}
|
|
1900
|
+
function getOutputTypeDescription(type) {
|
|
1901
|
+
switch (type) {
|
|
1902
|
+
case "value":
|
|
1903
|
+
return `Return a computed value (string, number, boolean, or object).
|
|
1904
|
+
Used as a virtual/computed column in table views.
|
|
1905
|
+
Example: (node) => node.quantity * node.unitPrice`;
|
|
1906
|
+
case "mutation":
|
|
1907
|
+
return `Return an object with property updates to merge into the node.
|
|
1908
|
+
The returned object's properties will be applied to the node.
|
|
1909
|
+
Example: (node) => node.amount > 1000 ? { priority: 'high' } : null`;
|
|
1910
|
+
case "decoration":
|
|
1911
|
+
return `Return an object with visual decorations (not persisted to node data).
|
|
1912
|
+
Use for tags, badges, or highlights based on conditions.
|
|
1913
|
+
Example: (node) => node.dueDate < ctx.now() ? { _decoration: 'OVERDUE' } : null`;
|
|
1914
|
+
case "void":
|
|
1915
|
+
return `Return null. Script runs for side effects only (logging, etc).
|
|
1916
|
+
Example: (node) => { console.log(node.id); return null }`;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
function getTriggerTypeDescription(type) {
|
|
1920
|
+
switch (type) {
|
|
1921
|
+
case "manual":
|
|
1922
|
+
return "Run manually via UI button";
|
|
1923
|
+
case "onChange":
|
|
1924
|
+
return "Run automatically when the node is created or updated";
|
|
1925
|
+
case "onView":
|
|
1926
|
+
return "Run when computing the value for display (lazy evaluation)";
|
|
1927
|
+
case "scheduled":
|
|
1928
|
+
return "Run on a schedule (cron)";
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
function formatExamples(examples) {
|
|
1932
|
+
if (examples.length === 0) return "";
|
|
1933
|
+
const simplified = examples.slice(0, 3).map((node) => {
|
|
1934
|
+
const { id, schemaIRI, ...rest } = node;
|
|
1935
|
+
return rest;
|
|
1936
|
+
});
|
|
1937
|
+
return `
|
|
1938
|
+
## Sample Data
|
|
1939
|
+
Here are example nodes to understand the data structure:
|
|
1940
|
+
\`\`\`json
|
|
1941
|
+
${JSON.stringify(simplified, null, 2)}
|
|
1942
|
+
\`\`\``;
|
|
1943
|
+
}
|
|
1944
|
+
function buildScriptPrompt(request) {
|
|
1945
|
+
const {
|
|
1946
|
+
intent,
|
|
1947
|
+
schema,
|
|
1948
|
+
outputType,
|
|
1949
|
+
triggerType = "onChange",
|
|
1950
|
+
examples = [],
|
|
1951
|
+
constraints = []
|
|
1952
|
+
} = request;
|
|
1953
|
+
const sections = [];
|
|
1954
|
+
sections.push(SYSTEM_CONTEXT);
|
|
1955
|
+
sections.push(API_DOCUMENTATION);
|
|
1956
|
+
sections.push(`
|
|
1957
|
+
## Schema: ${schema.name}
|
|
1958
|
+
Schema IRI: ${schema.schemaIRI}
|
|
1959
|
+
|
|
1960
|
+
Available properties:
|
|
1961
|
+
${formatSchemaProperties(schema.properties)}`);
|
|
1962
|
+
sections.push(`
|
|
1963
|
+
## Output Type: ${outputType}
|
|
1964
|
+
${getOutputTypeDescription(outputType)}`);
|
|
1965
|
+
sections.push(`
|
|
1966
|
+
## Trigger: ${triggerType}
|
|
1967
|
+
${getTriggerTypeDescription(triggerType)}`);
|
|
1968
|
+
if (examples.length > 0) {
|
|
1969
|
+
sections.push(formatExamples(examples));
|
|
1970
|
+
}
|
|
1971
|
+
if (constraints.length > 0) {
|
|
1972
|
+
sections.push(`
|
|
1973
|
+
## Additional Requirements
|
|
1974
|
+
${constraints.map((c) => `- ${c}`).join("\n")}`);
|
|
1975
|
+
}
|
|
1976
|
+
sections.push(`
|
|
1977
|
+
## User Request
|
|
1978
|
+
"${intent}"
|
|
1979
|
+
|
|
1980
|
+
Generate ONLY the arrow function expression. No markdown code fences, no explanation.
|
|
1981
|
+
The function should handle edge cases (null values, missing properties) gracefully.
|
|
1982
|
+
|
|
1983
|
+
Format: (node, ctx) => { ... } or (node, ctx) => expression`);
|
|
1984
|
+
return sections.join("\n");
|
|
1985
|
+
}
|
|
1986
|
+
function buildRetryPrompt(originalPrompt, errors) {
|
|
1987
|
+
return `${originalPrompt}
|
|
1988
|
+
|
|
1989
|
+
---
|
|
1990
|
+
IMPORTANT: Your previous attempt had validation errors:
|
|
1991
|
+
${errors.map((e) => `- ${e}`).join("\n")}
|
|
1992
|
+
|
|
1993
|
+
Please fix these issues and generate a corrected version.
|
|
1994
|
+
Remember: NO forbidden globals, NO async/await, NO imports.`;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/ai/providers.ts
|
|
1998
|
+
var AIGenerationError = class extends Error {
|
|
1999
|
+
constructor(message, provider, cause) {
|
|
2000
|
+
super(message);
|
|
2001
|
+
this.provider = provider;
|
|
2002
|
+
this.cause = cause;
|
|
2003
|
+
this.name = "AIGenerationError";
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
var AnthropicProvider = class {
|
|
2007
|
+
name = "Anthropic";
|
|
2008
|
+
apiKey;
|
|
2009
|
+
baseUrl;
|
|
2010
|
+
model;
|
|
2011
|
+
maxTokens;
|
|
2012
|
+
temperature;
|
|
2013
|
+
constructor(options) {
|
|
2014
|
+
if (!options.apiKey) {
|
|
2015
|
+
throw new Error("Anthropic API key is required");
|
|
2016
|
+
}
|
|
2017
|
+
this.apiKey = options.apiKey;
|
|
2018
|
+
this.baseUrl = options.baseUrl ?? "https://api.anthropic.com";
|
|
2019
|
+
this.model = options.model ?? "claude-sonnet-4-20250514";
|
|
2020
|
+
this.maxTokens = options.maxTokens ?? 2048;
|
|
2021
|
+
this.temperature = options.temperature ?? 0.3;
|
|
2022
|
+
}
|
|
2023
|
+
async generate(prompt) {
|
|
2024
|
+
try {
|
|
2025
|
+
const response = await fetch(`${this.baseUrl}/v1/messages`, {
|
|
2026
|
+
method: "POST",
|
|
2027
|
+
headers: {
|
|
2028
|
+
"content-type": "application/json",
|
|
2029
|
+
"x-api-key": this.apiKey,
|
|
2030
|
+
"anthropic-version": "2023-06-01"
|
|
2031
|
+
},
|
|
2032
|
+
body: JSON.stringify({
|
|
2033
|
+
model: this.model,
|
|
2034
|
+
max_tokens: this.maxTokens,
|
|
2035
|
+
temperature: this.temperature,
|
|
2036
|
+
messages: [{ role: "user", content: prompt }]
|
|
2037
|
+
})
|
|
2038
|
+
});
|
|
2039
|
+
if (!response.ok) {
|
|
2040
|
+
const error = await response.text();
|
|
2041
|
+
throw new AIGenerationError(`Anthropic API error: ${response.status} ${error}`, this.name);
|
|
2042
|
+
}
|
|
2043
|
+
const data = await response.json();
|
|
2044
|
+
const textContent = data.content.find((c) => c.type === "text");
|
|
2045
|
+
if (!textContent) {
|
|
2046
|
+
throw new AIGenerationError("No text content in response", this.name);
|
|
2047
|
+
}
|
|
2048
|
+
return textContent.text;
|
|
2049
|
+
} catch (err) {
|
|
2050
|
+
if (err instanceof AIGenerationError) throw err;
|
|
2051
|
+
throw new AIGenerationError(
|
|
2052
|
+
`Anthropic generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2053
|
+
this.name,
|
|
2054
|
+
err
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
var OpenAIProvider = class {
|
|
2060
|
+
name = "OpenAI";
|
|
2061
|
+
apiKey;
|
|
2062
|
+
baseUrl;
|
|
2063
|
+
model;
|
|
2064
|
+
maxTokens;
|
|
2065
|
+
temperature;
|
|
2066
|
+
constructor(options) {
|
|
2067
|
+
if (!options.apiKey) {
|
|
2068
|
+
throw new Error("OpenAI API key is required");
|
|
2069
|
+
}
|
|
2070
|
+
this.apiKey = options.apiKey;
|
|
2071
|
+
this.baseUrl = options.baseUrl ?? "https://api.openai.com";
|
|
2072
|
+
this.model = options.model ?? "gpt-4o";
|
|
2073
|
+
this.maxTokens = options.maxTokens ?? 2048;
|
|
2074
|
+
this.temperature = options.temperature ?? 0.3;
|
|
2075
|
+
}
|
|
2076
|
+
async generate(prompt) {
|
|
2077
|
+
try {
|
|
2078
|
+
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
2079
|
+
method: "POST",
|
|
2080
|
+
headers: {
|
|
2081
|
+
"content-type": "application/json",
|
|
2082
|
+
authorization: `Bearer ${this.apiKey}`
|
|
2083
|
+
},
|
|
2084
|
+
body: JSON.stringify({
|
|
2085
|
+
model: this.model,
|
|
2086
|
+
max_tokens: this.maxTokens,
|
|
2087
|
+
temperature: this.temperature,
|
|
2088
|
+
messages: [{ role: "user", content: prompt }]
|
|
2089
|
+
})
|
|
2090
|
+
});
|
|
2091
|
+
if (!response.ok) {
|
|
2092
|
+
const error = await response.text();
|
|
2093
|
+
throw new AIGenerationError(`OpenAI API error: ${response.status} ${error}`, this.name);
|
|
2094
|
+
}
|
|
2095
|
+
const data = await response.json();
|
|
2096
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
2097
|
+
throw new AIGenerationError("No content in response", this.name);
|
|
2098
|
+
}
|
|
2099
|
+
return data.choices[0].message.content;
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
if (err instanceof AIGenerationError) throw err;
|
|
2102
|
+
throw new AIGenerationError(
|
|
2103
|
+
`OpenAI generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2104
|
+
this.name,
|
|
2105
|
+
err
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
var OllamaProvider = class {
|
|
2111
|
+
name = "Ollama";
|
|
2112
|
+
baseUrl;
|
|
2113
|
+
model;
|
|
2114
|
+
temperature;
|
|
2115
|
+
constructor(options = {}) {
|
|
2116
|
+
this.baseUrl = options.baseUrl ?? "http://localhost:11434";
|
|
2117
|
+
this.model = options.model ?? "codellama";
|
|
2118
|
+
this.temperature = options.temperature ?? 0.3;
|
|
2119
|
+
}
|
|
2120
|
+
async generate(prompt) {
|
|
2121
|
+
try {
|
|
2122
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
2123
|
+
method: "POST",
|
|
2124
|
+
headers: {
|
|
2125
|
+
"content-type": "application/json"
|
|
2126
|
+
},
|
|
2127
|
+
body: JSON.stringify({
|
|
2128
|
+
model: this.model,
|
|
2129
|
+
prompt,
|
|
2130
|
+
stream: false,
|
|
2131
|
+
options: {
|
|
2132
|
+
temperature: this.temperature
|
|
2133
|
+
}
|
|
2134
|
+
})
|
|
2135
|
+
});
|
|
2136
|
+
if (!response.ok) {
|
|
2137
|
+
const error = await response.text();
|
|
2138
|
+
throw new AIGenerationError(`Ollama API error: ${response.status} ${error}`, this.name);
|
|
2139
|
+
}
|
|
2140
|
+
const data = await response.json();
|
|
2141
|
+
if (!data.response) {
|
|
2142
|
+
throw new AIGenerationError("No response content", this.name);
|
|
2143
|
+
}
|
|
2144
|
+
return data.response;
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
if (err instanceof AIGenerationError) throw err;
|
|
2147
|
+
throw new AIGenerationError(
|
|
2148
|
+
`Ollama generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2149
|
+
this.name,
|
|
2150
|
+
err
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
function createAIProvider(config) {
|
|
2156
|
+
switch (config.type) {
|
|
2157
|
+
case "anthropic":
|
|
2158
|
+
return new AnthropicProvider(config.options);
|
|
2159
|
+
case "openai":
|
|
2160
|
+
return new OpenAIProvider(config.options);
|
|
2161
|
+
case "ollama":
|
|
2162
|
+
return new OllamaProvider(config.options);
|
|
2163
|
+
case "custom":
|
|
2164
|
+
throw new Error("Custom provider requires manual instantiation");
|
|
2165
|
+
default:
|
|
2166
|
+
throw new Error(`Unknown AI provider type: ${config.type}`);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
async function isOllamaAvailable(baseUrl = "http://localhost:11434") {
|
|
2170
|
+
try {
|
|
2171
|
+
const response = await fetch(`${baseUrl}/api/tags`, {
|
|
2172
|
+
method: "GET",
|
|
2173
|
+
signal: AbortSignal.timeout(2e3)
|
|
2174
|
+
});
|
|
2175
|
+
return response.ok;
|
|
2176
|
+
} catch {
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function listOllamaModels(baseUrl = "http://localhost:11434") {
|
|
2181
|
+
try {
|
|
2182
|
+
const response = await fetch(`${baseUrl}/api/tags`);
|
|
2183
|
+
if (!response.ok) return [];
|
|
2184
|
+
const data = await response.json();
|
|
2185
|
+
return data.models?.map((m) => m.name) ?? [];
|
|
2186
|
+
} catch {
|
|
2187
|
+
return [];
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// src/ai/generator.ts
|
|
2192
|
+
var ScriptGenerationError = class extends Error {
|
|
2193
|
+
constructor(message, validationErrors, attempts) {
|
|
2194
|
+
super(message);
|
|
2195
|
+
this.validationErrors = validationErrors;
|
|
2196
|
+
this.attempts = attempts;
|
|
2197
|
+
this.name = "ScriptGenerationError";
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
var ScriptGenerator = class {
|
|
2201
|
+
ai;
|
|
2202
|
+
maxRetries;
|
|
2203
|
+
throwOnValidationFailure;
|
|
2204
|
+
constructor(ai, options = {}) {
|
|
2205
|
+
this.ai = ai;
|
|
2206
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
2207
|
+
this.throwOnValidationFailure = options.throwOnValidationFailure ?? false;
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Generate a script from a natural language request.
|
|
2211
|
+
*
|
|
2212
|
+
* @param request - The script generation request
|
|
2213
|
+
* @returns Generated script response
|
|
2214
|
+
* @throws ScriptGenerationError if throwOnValidationFailure is true and validation fails
|
|
2215
|
+
*/
|
|
2216
|
+
async generate(request) {
|
|
2217
|
+
const prompt = buildScriptPrompt(request);
|
|
2218
|
+
let attempts = 0;
|
|
2219
|
+
let lastErrors = [];
|
|
2220
|
+
let currentPrompt = prompt;
|
|
2221
|
+
while (attempts <= this.maxRetries) {
|
|
2222
|
+
attempts++;
|
|
2223
|
+
try {
|
|
2224
|
+
const raw = await this.ai.generate(currentPrompt);
|
|
2225
|
+
const code = this.extractCode(raw);
|
|
2226
|
+
const validation = validateScriptAST(code);
|
|
2227
|
+
if (validation.valid) {
|
|
2228
|
+
return {
|
|
2229
|
+
code,
|
|
2230
|
+
explanation: this.generateExplanation(request, code),
|
|
2231
|
+
suggestedName: this.generateName(request.intent),
|
|
2232
|
+
suggestedTrigger: request.triggerType ?? this.inferTrigger(request),
|
|
2233
|
+
validated: true,
|
|
2234
|
+
attempts
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
lastErrors = validation.errors;
|
|
2238
|
+
if (attempts <= this.maxRetries) {
|
|
2239
|
+
currentPrompt = buildRetryPrompt(prompt, validation.errors);
|
|
2240
|
+
}
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
if (attempts > this.maxRetries) {
|
|
2243
|
+
throw new ScriptGenerationError(
|
|
2244
|
+
`AI generation failed after ${attempts} attempts: ${err instanceof Error ? err.message : String(err)}`,
|
|
2245
|
+
lastErrors,
|
|
2246
|
+
attempts
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
if (this.throwOnValidationFailure) {
|
|
2252
|
+
throw new ScriptGenerationError(
|
|
2253
|
+
`Script validation failed after ${attempts} attempts`,
|
|
2254
|
+
lastErrors,
|
|
2255
|
+
attempts
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
const lastRaw = await this.ai.generate(currentPrompt);
|
|
2259
|
+
const lastCode = this.extractCode(lastRaw);
|
|
2260
|
+
return {
|
|
2261
|
+
code: lastCode,
|
|
2262
|
+
explanation: this.generateExplanation(request, lastCode),
|
|
2263
|
+
suggestedName: this.generateName(request.intent),
|
|
2264
|
+
suggestedTrigger: request.triggerType ?? this.inferTrigger(request),
|
|
2265
|
+
validated: false,
|
|
2266
|
+
attempts
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Extract code from AI response (strips markdown fences, etc.)
|
|
2271
|
+
*/
|
|
2272
|
+
extractCode(raw) {
|
|
2273
|
+
let text3 = raw.trim();
|
|
2274
|
+
const fencedMatch = text3.match(/```(?:javascript|js|typescript|ts)?\s*\n?([\s\S]*?)```/);
|
|
2275
|
+
if (fencedMatch) {
|
|
2276
|
+
text3 = fencedMatch[1].trim();
|
|
2277
|
+
}
|
|
2278
|
+
if (text3.startsWith("`") && text3.endsWith("`")) {
|
|
2279
|
+
text3 = text3.slice(1, -1).trim();
|
|
2280
|
+
}
|
|
2281
|
+
if (!text3.startsWith("(") && !text3.startsWith("function")) {
|
|
2282
|
+
const arrowMatch = text3.match(/(\([^)]*\)\s*=>\s*[\s\S]+)/);
|
|
2283
|
+
if (arrowMatch) {
|
|
2284
|
+
text3 = arrowMatch[1];
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
return text3;
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Generate an explanation for the script
|
|
2291
|
+
*/
|
|
2292
|
+
generateExplanation(request, _code) {
|
|
2293
|
+
const intent = request.intent.toLowerCase();
|
|
2294
|
+
if (intent.includes("calculat") || intent.includes("comput")) {
|
|
2295
|
+
return `Calculates ${intent.replace(/calculat\w*|comput\w*/i, "").trim()}`;
|
|
2296
|
+
}
|
|
2297
|
+
if (intent.includes("tag") || intent.includes("mark") || intent.includes("label")) {
|
|
2298
|
+
return `Automatically ${intent}`;
|
|
2299
|
+
}
|
|
2300
|
+
if (intent.includes("check") || intent.includes("validat") || intent.includes("verify")) {
|
|
2301
|
+
return `Validates that ${intent.replace(/check\w*|validat\w*|verify\w*/i, "").trim()}`;
|
|
2302
|
+
}
|
|
2303
|
+
return `Script that ${intent}`;
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Generate a name from the intent
|
|
2307
|
+
*/
|
|
2308
|
+
generateName(intent) {
|
|
2309
|
+
const words = intent.replace(/[^a-zA-Z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2).slice(0, 4);
|
|
2310
|
+
if (words.length === 0) {
|
|
2311
|
+
return "customScript";
|
|
2312
|
+
}
|
|
2313
|
+
return words.map((word, index) => {
|
|
2314
|
+
const lower = word.toLowerCase();
|
|
2315
|
+
if (index === 0) return lower;
|
|
2316
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
2317
|
+
}).join("");
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Infer the best trigger type from the request
|
|
2321
|
+
*/
|
|
2322
|
+
inferTrigger(request) {
|
|
2323
|
+
const intent = request.intent.toLowerCase();
|
|
2324
|
+
const outputType = request.outputType;
|
|
2325
|
+
if (outputType === "value") {
|
|
2326
|
+
return "onView";
|
|
2327
|
+
}
|
|
2328
|
+
if (outputType === "mutation") {
|
|
2329
|
+
if (intent.includes("when") || intent.includes("if") || intent.includes("auto") || intent.includes("automatic")) {
|
|
2330
|
+
return "onChange";
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
return "manual";
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
async function generateScript(provider, request) {
|
|
2337
|
+
const generator = new ScriptGenerator(provider);
|
|
2338
|
+
return generator.generate(request);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// src/services/client.ts
|
|
2342
|
+
var SERVICE_IPC_CHANNELS = {
|
|
2343
|
+
START: "xnet:service:start",
|
|
2344
|
+
STOP: "xnet:service:stop",
|
|
2345
|
+
RESTART: "xnet:service:restart",
|
|
2346
|
+
STATUS: "xnet:service:status",
|
|
2347
|
+
LIST_ALL: "xnet:service:list-all",
|
|
2348
|
+
CALL: "xnet:service:call",
|
|
2349
|
+
STATUS_UPDATE: "xnet:service:status-update",
|
|
2350
|
+
OUTPUT: "xnet:service:output"
|
|
2351
|
+
};
|
|
2352
|
+
function getIPC() {
|
|
2353
|
+
if (typeof window === "undefined") return null;
|
|
2354
|
+
return window.xnetServices ?? null;
|
|
2355
|
+
}
|
|
2356
|
+
function createServiceClient() {
|
|
2357
|
+
const ipc = getIPC();
|
|
2358
|
+
if (!ipc) {
|
|
2359
|
+
return createMockServiceClient();
|
|
2360
|
+
}
|
|
2361
|
+
return {
|
|
2362
|
+
start: (definition) => ipc.invoke(SERVICE_IPC_CHANNELS.START, definition),
|
|
2363
|
+
stop: (serviceId) => ipc.invoke(SERVICE_IPC_CHANNELS.STOP, serviceId),
|
|
2364
|
+
restart: (serviceId) => ipc.invoke(SERVICE_IPC_CHANNELS.RESTART, serviceId),
|
|
2365
|
+
status: (serviceId) => ipc.invoke(SERVICE_IPC_CHANNELS.STATUS, serviceId),
|
|
2366
|
+
listAll: () => ipc.invoke(SERVICE_IPC_CHANNELS.LIST_ALL),
|
|
2367
|
+
call: (serviceId, method, path, body) => ipc.invoke(SERVICE_IPC_CHANNELS.CALL, serviceId, method, path, body),
|
|
2368
|
+
onStatusChange: (serviceId, callback) => {
|
|
2369
|
+
const handler = (event) => {
|
|
2370
|
+
if (event.serviceId === serviceId) {
|
|
2371
|
+
callback(event.status);
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
ipc.on(SERVICE_IPC_CHANNELS.STATUS_UPDATE, handler);
|
|
2375
|
+
return () => ipc.off(SERVICE_IPC_CHANNELS.STATUS_UPDATE, handler);
|
|
2376
|
+
},
|
|
2377
|
+
onOutput: (serviceId, callback) => {
|
|
2378
|
+
const handler = (event) => {
|
|
2379
|
+
if (event.serviceId === serviceId) {
|
|
2380
|
+
callback(event);
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
ipc.on(SERVICE_IPC_CHANNELS.OUTPUT, handler);
|
|
2384
|
+
return () => ipc.off(SERVICE_IPC_CHANNELS.OUTPUT, handler);
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
function createMockServiceClient() {
|
|
2389
|
+
const notAvailable = () => {
|
|
2390
|
+
throw new Error(
|
|
2391
|
+
"Service client is only available in Electron. Make sure you are running in the Electron renderer process."
|
|
2392
|
+
);
|
|
2393
|
+
};
|
|
2394
|
+
return {
|
|
2395
|
+
start: notAvailable,
|
|
2396
|
+
stop: notAvailable,
|
|
2397
|
+
restart: notAvailable,
|
|
2398
|
+
status: notAvailable,
|
|
2399
|
+
listAll: notAvailable,
|
|
2400
|
+
call: notAvailable,
|
|
2401
|
+
onStatusChange: () => () => {
|
|
2402
|
+
},
|
|
2403
|
+
onOutput: () => () => {
|
|
2404
|
+
}
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
function isServiceClientAvailable() {
|
|
2408
|
+
return getIPC() !== null;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// src/services/webhook-emitter.ts
|
|
2412
|
+
var WebhookEmitter = class {
|
|
2413
|
+
constructor(store) {
|
|
2414
|
+
this.store = store;
|
|
2415
|
+
}
|
|
2416
|
+
webhooks = /* @__PURE__ */ new Map();
|
|
2417
|
+
unsubscribe;
|
|
2418
|
+
deliveryHistory = [];
|
|
2419
|
+
maxHistorySize = 100;
|
|
2420
|
+
/**
|
|
2421
|
+
* Start listening for store changes.
|
|
2422
|
+
* Must be called before webhooks will be sent.
|
|
2423
|
+
*/
|
|
2424
|
+
start() {
|
|
2425
|
+
if (this.unsubscribe) return;
|
|
2426
|
+
this.unsubscribe = this.store.subscribe((event) => {
|
|
2427
|
+
this.handleEvent(event);
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Stop listening for store changes.
|
|
2432
|
+
*/
|
|
2433
|
+
stop() {
|
|
2434
|
+
if (this.unsubscribe) {
|
|
2435
|
+
this.unsubscribe();
|
|
2436
|
+
this.unsubscribe = void 0;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Register a webhook configuration.
|
|
2441
|
+
* Returns a disposable to unregister.
|
|
2442
|
+
*/
|
|
2443
|
+
register(config) {
|
|
2444
|
+
this.webhooks.set(config.id, { ...config, enabled: config.enabled ?? true });
|
|
2445
|
+
return {
|
|
2446
|
+
dispose: () => {
|
|
2447
|
+
this.webhooks.delete(config.id);
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Unregister a webhook by ID.
|
|
2453
|
+
*/
|
|
2454
|
+
unregister(id) {
|
|
2455
|
+
return this.webhooks.delete(id);
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Get all registered webhooks.
|
|
2459
|
+
*/
|
|
2460
|
+
getWebhooks() {
|
|
2461
|
+
return Array.from(this.webhooks.values());
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Get a webhook by ID.
|
|
2465
|
+
*/
|
|
2466
|
+
getWebhook(id) {
|
|
2467
|
+
return this.webhooks.get(id);
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* Update a webhook configuration.
|
|
2471
|
+
*/
|
|
2472
|
+
updateWebhook(id, updates) {
|
|
2473
|
+
const existing = this.webhooks.get(id);
|
|
2474
|
+
if (!existing) return false;
|
|
2475
|
+
this.webhooks.set(id, { ...existing, ...updates, id });
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Get recent delivery history.
|
|
2480
|
+
*/
|
|
2481
|
+
getDeliveryHistory() {
|
|
2482
|
+
return [...this.deliveryHistory];
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* Clear delivery history.
|
|
2486
|
+
*/
|
|
2487
|
+
clearDeliveryHistory() {
|
|
2488
|
+
this.deliveryHistory = [];
|
|
2489
|
+
}
|
|
2490
|
+
// ─── Event Handling ──────────────────────────────────────────────────────────
|
|
2491
|
+
handleEvent(event) {
|
|
2492
|
+
const eventType = this.getEventType(event);
|
|
2493
|
+
if (!eventType) return;
|
|
2494
|
+
const payload = {
|
|
2495
|
+
type: eventType,
|
|
2496
|
+
timestamp: Date.now(),
|
|
2497
|
+
node: event.node,
|
|
2498
|
+
schema: event.node?.schemaId
|
|
2499
|
+
};
|
|
2500
|
+
for (const webhook of this.webhooks.values()) {
|
|
2501
|
+
if (!webhook.enabled) continue;
|
|
2502
|
+
if (!webhook.events.includes(eventType)) continue;
|
|
2503
|
+
if (webhook.schema && event.node?.schemaId !== webhook.schema) continue;
|
|
2504
|
+
this.send(webhook, payload).catch((err) => {
|
|
2505
|
+
console.error(`[WebhookEmitter] Failed to send to ${webhook.url}:`, err);
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
getEventType(event) {
|
|
2510
|
+
const changeType = event.change.type;
|
|
2511
|
+
if (changeType === "node-change") {
|
|
2512
|
+
if (event.node?.deleted) return "deleted";
|
|
2513
|
+
return "updated";
|
|
2514
|
+
}
|
|
2515
|
+
return null;
|
|
2516
|
+
}
|
|
2517
|
+
// ─── Delivery ────────────────────────────────────────────────────────────────
|
|
2518
|
+
async send(webhook, payload) {
|
|
2519
|
+
const maxRetries = webhook.retries ?? 3;
|
|
2520
|
+
let lastError = null;
|
|
2521
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2522
|
+
try {
|
|
2523
|
+
const result = await this.deliverWebhook(webhook, payload);
|
|
2524
|
+
this.recordDelivery({
|
|
2525
|
+
webhookId: webhook.id,
|
|
2526
|
+
success: true,
|
|
2527
|
+
statusCode: result.status,
|
|
2528
|
+
attempts: attempt,
|
|
2529
|
+
deliveredAt: Date.now()
|
|
2530
|
+
});
|
|
2531
|
+
return;
|
|
2532
|
+
} catch (err) {
|
|
2533
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2534
|
+
if (attempt < maxRetries) {
|
|
2535
|
+
await this.delay(1e3 * Math.pow(2, attempt - 1));
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
this.recordDelivery({
|
|
2540
|
+
webhookId: webhook.id,
|
|
2541
|
+
success: false,
|
|
2542
|
+
error: lastError?.message ?? "Unknown error",
|
|
2543
|
+
attempts: maxRetries
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
async deliverWebhook(webhook, payload) {
|
|
2547
|
+
const body = JSON.stringify(payload);
|
|
2548
|
+
const headers = {
|
|
2549
|
+
"content-type": "application/json",
|
|
2550
|
+
"user-agent": "xNet-Webhook/1.0"
|
|
2551
|
+
};
|
|
2552
|
+
if (webhook.secret) {
|
|
2553
|
+
const signature = await this.sign(body, webhook.secret);
|
|
2554
|
+
headers["x-xnet-signature"] = signature;
|
|
2555
|
+
headers["x-xnet-signature-256"] = `sha256=${signature}`;
|
|
2556
|
+
}
|
|
2557
|
+
const response = await fetch(webhook.url, {
|
|
2558
|
+
method: "POST",
|
|
2559
|
+
headers,
|
|
2560
|
+
body,
|
|
2561
|
+
signal: AbortSignal.timeout(1e4)
|
|
2562
|
+
// 10 second timeout
|
|
2563
|
+
});
|
|
2564
|
+
if (!response.ok) {
|
|
2565
|
+
const text3 = await response.text().catch(() => "");
|
|
2566
|
+
throw new Error(`HTTP ${response.status}: ${text3.slice(0, 200)}`);
|
|
2567
|
+
}
|
|
2568
|
+
return { status: response.status };
|
|
2569
|
+
}
|
|
2570
|
+
async sign(body, secret) {
|
|
2571
|
+
const encoder = new TextEncoder();
|
|
2572
|
+
const key = await crypto.subtle.importKey(
|
|
2573
|
+
"raw",
|
|
2574
|
+
encoder.encode(secret),
|
|
2575
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
2576
|
+
false,
|
|
2577
|
+
["sign"]
|
|
2578
|
+
);
|
|
2579
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
2580
|
+
return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2581
|
+
}
|
|
2582
|
+
recordDelivery(result) {
|
|
2583
|
+
this.deliveryHistory.push(result);
|
|
2584
|
+
if (this.deliveryHistory.length > this.maxHistorySize) {
|
|
2585
|
+
this.deliveryHistory.shift();
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
delay(ms) {
|
|
2589
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
function createWebhookEmitter(store) {
|
|
2593
|
+
return new WebhookEmitter(store);
|
|
2594
|
+
}
|
|
2595
|
+
export {
|
|
2596
|
+
AIGenerationError,
|
|
2597
|
+
AnthropicProvider,
|
|
2598
|
+
ContributionRegistry,
|
|
2599
|
+
MiddlewareChain,
|
|
2600
|
+
OllamaProvider,
|
|
2601
|
+
OpenAIProvider,
|
|
2602
|
+
PluginError,
|
|
2603
|
+
PluginRegistry,
|
|
2604
|
+
PluginSchema,
|
|
2605
|
+
PluginValidationError,
|
|
2606
|
+
SERVICE_IPC_CHANNELS,
|
|
2607
|
+
ScriptError,
|
|
2608
|
+
ScriptGenerationError,
|
|
2609
|
+
ScriptGenerator,
|
|
2610
|
+
ScriptRunner,
|
|
2611
|
+
ScriptSandbox,
|
|
2612
|
+
ScriptSchema,
|
|
2613
|
+
ScriptTimeoutError,
|
|
2614
|
+
ScriptValidationError,
|
|
2615
|
+
ShortcutManager,
|
|
2616
|
+
TypedRegistry,
|
|
2617
|
+
WebhookEmitter,
|
|
2618
|
+
buildRetryPrompt,
|
|
2619
|
+
buildScriptPrompt,
|
|
2620
|
+
createAIProvider,
|
|
2621
|
+
createExtensionContext,
|
|
2622
|
+
createExtensionStorage,
|
|
2623
|
+
createScriptContext,
|
|
2624
|
+
createServiceClient,
|
|
2625
|
+
createWebhookEmitter,
|
|
2626
|
+
defineExtension,
|
|
2627
|
+
executeScript,
|
|
2628
|
+
generateScript,
|
|
2629
|
+
getPlatformCapabilities,
|
|
2630
|
+
getShortcutManager,
|
|
2631
|
+
installShortcutHandler,
|
|
2632
|
+
isOllamaAvailable,
|
|
2633
|
+
isScriptNode,
|
|
2634
|
+
isServiceClientAvailable,
|
|
2635
|
+
listOllamaModels,
|
|
2636
|
+
quickSafetyCheck,
|
|
2637
|
+
validateManifest,
|
|
2638
|
+
validateScript,
|
|
2639
|
+
validateScriptAST
|
|
2640
|
+
};
|