claw-dashboard 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5236 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,1437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget Loader Module
|
|
3
|
+
* Provides lazy loading for widget modules to improve startup performance
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync } from 'fs';
|
|
7
|
+
import { join, resolve, extname, basename } from 'path';
|
|
8
|
+
import { pathToFileURL } from 'url';
|
|
9
|
+
import logger from '../logger.js';
|
|
10
|
+
import config from '../config.js';
|
|
11
|
+
import { sanitizeWidgetConfig, validateWidgetConfig, validatePluginPath, validatePluginName } from '../security.js';
|
|
12
|
+
import { processWidgetConfig } from './config-processor.js';
|
|
13
|
+
import { validateManifest } from '../plugin-manifest-validator.js';
|
|
14
|
+
import {
|
|
15
|
+
resolveDependencies,
|
|
16
|
+
validateWidgetDependencies,
|
|
17
|
+
buildDependencyGraph,
|
|
18
|
+
getAllDependencies,
|
|
19
|
+
getAllDependents,
|
|
20
|
+
} from './dependency-resolver.js';
|
|
21
|
+
import { PluginError, PluginErrorAnalyzer, PLUGIN_ERROR_CODES } from '../plugin-errors.js';
|
|
22
|
+
import { ConfigWatcher } from '../config-watcher.js';
|
|
23
|
+
import { EventEmitter } from 'events';
|
|
24
|
+
|
|
25
|
+
const { PATHS, WIDGETS } = config;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract default values from config schema definition
|
|
29
|
+
* Config schemas define fields with { type, default, min, max, options } etc.
|
|
30
|
+
* This function extracts just the default values for widget consumption.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} configSchema - Config schema from manifest
|
|
33
|
+
* @returns {Object} Config with default values extracted
|
|
34
|
+
*/
|
|
35
|
+
function extractDefaultsFromSchema(configSchema) {
|
|
36
|
+
if (!configSchema || typeof configSchema !== 'object') {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const [key, value] of Object.entries(configSchema)) {
|
|
42
|
+
// If value is a schema definition (has 'type' property), extract default
|
|
43
|
+
if (value && typeof value === 'object' && value.type !== undefined) {
|
|
44
|
+
result[key] = value.default !== undefined ? value.default : null;
|
|
45
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
46
|
+
// Nested object - recursively process
|
|
47
|
+
result[key] = extractDefaultsFromSchema(value);
|
|
48
|
+
} else {
|
|
49
|
+
// Direct value (legacy format)
|
|
50
|
+
result[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Widget Loader class for lazy loading widget modules
|
|
58
|
+
* Extends EventEmitter to support hot-reload events
|
|
59
|
+
*/
|
|
60
|
+
export class WidgetLoader extends EventEmitter {
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
super();
|
|
63
|
+
this.widgetsDir = options.widgetsDir || PATHS.WIDGETS_DIR;
|
|
64
|
+
this.pluginsDir = options.pluginsDir || PATHS.PLUGINS_DIR;
|
|
65
|
+
this.loadedWidgets = new Map();
|
|
66
|
+
this.widgetRegistry = new Map();
|
|
67
|
+
this.loadPromises = new Map();
|
|
68
|
+
this.hooks = {
|
|
69
|
+
beforeLoad: [],
|
|
70
|
+
afterLoad: [],
|
|
71
|
+
beforeUnload: [],
|
|
72
|
+
};
|
|
73
|
+
this.configWatcher = null;
|
|
74
|
+
this._reloadStats = {
|
|
75
|
+
reloads: 0,
|
|
76
|
+
errors: 0,
|
|
77
|
+
lastReload: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Register a widget without loading it
|
|
83
|
+
* @param {string} id - Unique widget identifier
|
|
84
|
+
* @param {Object} metadata - Widget metadata
|
|
85
|
+
* @param {Function} loader - Async function that returns the widget module
|
|
86
|
+
*/
|
|
87
|
+
register(id, metadata, loader) {
|
|
88
|
+
if (this.widgetRegistry.has(id)) {
|
|
89
|
+
logger.warn(`Widget '${id}' is already registered, overwriting`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.widgetRegistry.set(id, {
|
|
93
|
+
id,
|
|
94
|
+
metadata: {
|
|
95
|
+
name: metadata.name || id,
|
|
96
|
+
description: metadata.description || '',
|
|
97
|
+
version: metadata.version || '1.0.0',
|
|
98
|
+
author: metadata.author || '',
|
|
99
|
+
category: metadata.category || 'system',
|
|
100
|
+
priority: metadata.priority || 100,
|
|
101
|
+
lazyLoad: metadata.lazyLoad !== false, // default true
|
|
102
|
+
dependencies: metadata.dependencies || [],
|
|
103
|
+
permissions: metadata.permissions || [],
|
|
104
|
+
...metadata,
|
|
105
|
+
},
|
|
106
|
+
loader,
|
|
107
|
+
loaded: false,
|
|
108
|
+
instance: null,
|
|
109
|
+
error: null,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
logger.debug(`Widget '${id}' registered`);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Unregister a widget
|
|
118
|
+
* @param {string} id - Widget identifier
|
|
119
|
+
*/
|
|
120
|
+
async unregister(id) {
|
|
121
|
+
const widget = this.widgetRegistry.get(id);
|
|
122
|
+
if (!widget) {
|
|
123
|
+
logger.warn(`Widget '${id}' not found in registry`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Run beforeUnload hooks
|
|
128
|
+
await this.runHooks('beforeUnload', widget);
|
|
129
|
+
|
|
130
|
+
// Unload if loaded
|
|
131
|
+
if (widget.loaded && widget.instance?.destroy) {
|
|
132
|
+
try {
|
|
133
|
+
await widget.instance.destroy();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger.error(`Error destroying widget '${id}': ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.loadedWidgets.delete(id);
|
|
140
|
+
this.widgetRegistry.delete(id);
|
|
141
|
+
this.loadPromises.delete(id);
|
|
142
|
+
|
|
143
|
+
logger.debug(`Widget '${id}' unregistered`);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Load a widget by ID (lazy loading)
|
|
149
|
+
* @param {string} id - Widget identifier
|
|
150
|
+
* @returns {Promise<Object>} Loaded widget instance
|
|
151
|
+
*/
|
|
152
|
+
async load(id) {
|
|
153
|
+
// Return cached promise if loading is in progress
|
|
154
|
+
if (this.loadPromises.has(id)) {
|
|
155
|
+
return this.loadPromises.get(id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const widget = this.widgetRegistry.get(id);
|
|
159
|
+
if (!widget) {
|
|
160
|
+
throw new Error(`Widget '${id}' not registered`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Return cached instance if already loaded
|
|
164
|
+
if (widget.loaded && widget.instance) {
|
|
165
|
+
return widget.instance;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create load promise
|
|
169
|
+
const loadPromise = this._doLoad(widget);
|
|
170
|
+
this.loadPromises.set(id, loadPromise);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const instance = await loadPromise;
|
|
174
|
+
return instance;
|
|
175
|
+
} finally {
|
|
176
|
+
this.loadPromises.delete(id);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Convenience method to register and load a widget in one call
|
|
182
|
+
* @param {string} id - Unique widget identifier
|
|
183
|
+
* @param {Object} metadata - Widget metadata
|
|
184
|
+
* @param {Function} loader - Async function that returns the widget module
|
|
185
|
+
* @returns {Promise<Object>} Loaded widget instance
|
|
186
|
+
*/
|
|
187
|
+
async loadAndRegister(id, metadata, loader) {
|
|
188
|
+
this.register(id, metadata, loader);
|
|
189
|
+
return this.load(id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Internal method to perform the actual loading
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
async _doLoad(widget) {
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// Run beforeLoad hooks
|
|
201
|
+
await this.runHooks('beforeLoad', widget);
|
|
202
|
+
|
|
203
|
+
// Check dependencies
|
|
204
|
+
await this._resolveDependencies(widget);
|
|
205
|
+
|
|
206
|
+
// Load the widget module
|
|
207
|
+
const instance = await widget.loader();
|
|
208
|
+
|
|
209
|
+
if (!instance || typeof instance !== 'object') {
|
|
210
|
+
throw new Error('Widget loader did not return a valid object');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate required methods
|
|
214
|
+
this._validateWidget(instance, widget.id);
|
|
215
|
+
|
|
216
|
+
widget.instance = instance;
|
|
217
|
+
widget.loaded = true;
|
|
218
|
+
widget.loadTime = Date.now() - startTime;
|
|
219
|
+
widget.error = null;
|
|
220
|
+
|
|
221
|
+
this.loadedWidgets.set(widget.id, instance);
|
|
222
|
+
|
|
223
|
+
// Run afterLoad hooks
|
|
224
|
+
await this.runHooks('afterLoad', widget);
|
|
225
|
+
|
|
226
|
+
logger.debug(`Widget '${widget.id}' loaded in ${widget.loadTime}ms`);
|
|
227
|
+
return instance;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
widget.error = err;
|
|
230
|
+
widget.loaded = false;
|
|
231
|
+
|
|
232
|
+
// Enhance error message if not already a PluginError
|
|
233
|
+
if (!(err instanceof PluginError)) {
|
|
234
|
+
const enhanced = PluginErrorAnalyzer.analyze(err, widget.id, { phase: 'widget' });
|
|
235
|
+
logger.error(`Failed to load widget '${widget.id}': ${enhanced.getFormattedMessage()}`);
|
|
236
|
+
} else {
|
|
237
|
+
logger.error(`Failed to load widget '${widget.id}': ${err.getFormattedMessage()}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve widget dependencies
|
|
246
|
+
* @private
|
|
247
|
+
*/
|
|
248
|
+
async _resolveDependencies(widget) {
|
|
249
|
+
const deps = widget.metadata.dependencies || [];
|
|
250
|
+
|
|
251
|
+
for (const depId of deps) {
|
|
252
|
+
if (!this.widgetRegistry.has(depId)) {
|
|
253
|
+
const pluginError = new PluginError(
|
|
254
|
+
PLUGIN_ERROR_CODES.DEPENDENCY_MISSING,
|
|
255
|
+
`Dependency "${depId}" not found for widget "${widget.id}"`,
|
|
256
|
+
{
|
|
257
|
+
pluginId: widget.id,
|
|
258
|
+
dependencyId: depId,
|
|
259
|
+
availableDependencies: Array.from(this.widgetRegistry.keys()),
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
throw pluginError;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const depWidget = this.widgetRegistry.get(depId);
|
|
266
|
+
if (!depWidget.loaded) {
|
|
267
|
+
await this.load(depId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Validate widget has required methods
|
|
274
|
+
* @private
|
|
275
|
+
*/
|
|
276
|
+
_validateWidget(instance, id) {
|
|
277
|
+
const required = ['render', 'getData'];
|
|
278
|
+
const missing = required.filter(method => typeof instance[method] !== 'function');
|
|
279
|
+
|
|
280
|
+
if (missing.length > 0) {
|
|
281
|
+
const pluginError = new PluginError(
|
|
282
|
+
PLUGIN_ERROR_CODES.WIDGET_MISSING_METHODS,
|
|
283
|
+
`Widget "${id}" is missing required methods: ${missing.join(', ')}`,
|
|
284
|
+
{
|
|
285
|
+
pluginId: id,
|
|
286
|
+
missingMethods: missing,
|
|
287
|
+
hasRender: typeof instance.render === 'function',
|
|
288
|
+
hasGetData: typeof instance.getData === 'function',
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
throw pluginError;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Unload a widget
|
|
297
|
+
* @param {string} id - Widget identifier
|
|
298
|
+
*/
|
|
299
|
+
async unload(id) {
|
|
300
|
+
const widget = this.widgetRegistry.get(id);
|
|
301
|
+
if (!widget || !widget.loaded) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await this.runHooks('beforeUnload', widget);
|
|
306
|
+
|
|
307
|
+
if (widget.instance?.destroy) {
|
|
308
|
+
try {
|
|
309
|
+
await widget.instance.destroy();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
logger.error(`Error destroying widget '${id}': ${err.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
widget.instance = null;
|
|
316
|
+
widget.loaded = false;
|
|
317
|
+
this.loadedWidgets.delete(id);
|
|
318
|
+
|
|
319
|
+
logger.debug(`Widget '${id}' unloaded`);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Load multiple widgets in parallel
|
|
325
|
+
* @param {string[]} ids - Array of widget IDs
|
|
326
|
+
* @returns {Promise<Map>} Map of id to loaded instance
|
|
327
|
+
*/
|
|
328
|
+
async loadMany(ids) {
|
|
329
|
+
const results = await Promise.allSettled(
|
|
330
|
+
ids.map(id => this.load(id).then(instance => ({ id, instance })))
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const loaded = new Map();
|
|
334
|
+
const errors = [];
|
|
335
|
+
|
|
336
|
+
for (const result of results) {
|
|
337
|
+
if (result.status === 'fulfilled') {
|
|
338
|
+
loaded.set(result.value.id, result.value.instance);
|
|
339
|
+
} else {
|
|
340
|
+
errors.push(result.reason);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (errors.length > 0) {
|
|
345
|
+
logger.warn(`Failed to load ${errors.length} widget(s): ${errors.map(e => e.message).join(', ')}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return loaded;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Preload widgets that are likely to be needed
|
|
353
|
+
* @param {string[]} priorityIds - Widget IDs to preload
|
|
354
|
+
*/
|
|
355
|
+
async preload(priorityIds) {
|
|
356
|
+
const preloadList = priorityIds.filter(id => {
|
|
357
|
+
const widget = this.widgetRegistry.get(id);
|
|
358
|
+
return widget && widget.metadata.lazyLoad && !widget.loaded;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (preloadList.length === 0) return;
|
|
362
|
+
|
|
363
|
+
logger.debug(`Preloading ${preloadList.length} widget(s)`);
|
|
364
|
+
|
|
365
|
+
// Load in order of priority
|
|
366
|
+
const sorted = preloadList
|
|
367
|
+
.map(id => ({ id, priority: this.widgetRegistry.get(id).metadata.priority }))
|
|
368
|
+
.sort((a, b) => a.priority - b.priority);
|
|
369
|
+
|
|
370
|
+
// Load high priority widgets first
|
|
371
|
+
for (const { id } of sorted.filter(w => w.priority < 50)) {
|
|
372
|
+
try {
|
|
373
|
+
await this.load(id);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
// Non-critical, continue
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Load remaining in background
|
|
380
|
+
const remaining = sorted.filter(w => w.priority >= 50).map(w => w.id);
|
|
381
|
+
if (remaining.length > 0) {
|
|
382
|
+
this.loadMany(remaining).catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get widget metadata without loading
|
|
388
|
+
* @param {string} id - Widget identifier
|
|
389
|
+
*/
|
|
390
|
+
getMetadata(id) {
|
|
391
|
+
const widget = this.widgetRegistry.get(id);
|
|
392
|
+
return widget ? { ...widget.metadata } : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Get all registered widget metadata
|
|
397
|
+
*/
|
|
398
|
+
getAllMetadata() {
|
|
399
|
+
const metadata = [];
|
|
400
|
+
for (const [id, widget] of this.widgetRegistry) {
|
|
401
|
+
metadata.push({
|
|
402
|
+
id,
|
|
403
|
+
...widget.metadata,
|
|
404
|
+
loaded: widget.loaded,
|
|
405
|
+
hasError: !!widget.error,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return metadata.sort((a, b) => a.priority - b.priority);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get loaded widget instance
|
|
413
|
+
* @param {string} id - Widget identifier
|
|
414
|
+
*/
|
|
415
|
+
get(id) {
|
|
416
|
+
const widget = this.widgetRegistry.get(id);
|
|
417
|
+
return widget?.loaded ? widget.instance : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Check if widget is loaded
|
|
422
|
+
* @param {string} id - Widget identifier
|
|
423
|
+
*/
|
|
424
|
+
isLoaded(id) {
|
|
425
|
+
const widget = this.widgetRegistry.get(id);
|
|
426
|
+
return widget?.loaded || false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Add a hook
|
|
431
|
+
* @param {string} type - Hook type: 'beforeLoad', 'afterLoad', 'beforeUnload'
|
|
432
|
+
* @param {Function} handler - Hook handler
|
|
433
|
+
*/
|
|
434
|
+
addHook(type, handler) {
|
|
435
|
+
if (!this.hooks[type]) {
|
|
436
|
+
throw new Error(`Unknown hook type: ${type}`);
|
|
437
|
+
}
|
|
438
|
+
this.hooks[type].push(handler);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run hooks for a type
|
|
443
|
+
* @private
|
|
444
|
+
*/
|
|
445
|
+
async runHooks(type, widget) {
|
|
446
|
+
for (const handler of this.hooks[type]) {
|
|
447
|
+
try {
|
|
448
|
+
await handler(widget);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
logger.error(`Hook error (${type}): ${err.message}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Discover widgets from plugins directory
|
|
457
|
+
*/
|
|
458
|
+
async discoverPlugins() {
|
|
459
|
+
// Validate pluginsDir exists and is safe
|
|
460
|
+
const pluginsDirValidation = validatePluginPath(this.pluginsDir, {
|
|
461
|
+
allowAbsolute: true,
|
|
462
|
+
mustExist: true,
|
|
463
|
+
expectedType: 'directory',
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
if (!pluginsDirValidation.valid) {
|
|
467
|
+
logger.warn(`Plugins directory validation failed: ${pluginsDirValidation.error}`);
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const validatedPluginsDir = pluginsDirValidation.path;
|
|
472
|
+
|
|
473
|
+
if (!existsSync(validatedPluginsDir)) {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const discovered = [];
|
|
478
|
+
const entries = readdirSync(validatedPluginsDir, { withFileTypes: true });
|
|
479
|
+
|
|
480
|
+
for (const entry of entries) {
|
|
481
|
+
if (!entry.isDirectory()) continue;
|
|
482
|
+
|
|
483
|
+
// Validate plugin directory name
|
|
484
|
+
const nameValidation = validatePluginName(entry.name);
|
|
485
|
+
if (!nameValidation.valid) {
|
|
486
|
+
logger.warn(`Skipping plugin directory with invalid name '${entry.name}': ${nameValidation.error}`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const pluginPath = join(validatedPluginsDir, entry.name);
|
|
491
|
+
|
|
492
|
+
// Validate the plugin path is within allowed directory
|
|
493
|
+
const pathValidation = validatePluginPath(entry.name, {
|
|
494
|
+
allowedDirs: [validatedPluginsDir],
|
|
495
|
+
allowAbsolute: false,
|
|
496
|
+
mustExist: true,
|
|
497
|
+
expectedType: 'directory',
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (!pathValidation.valid) {
|
|
501
|
+
logger.warn(`Skipping plugin with unsafe path '${entry.name}': ${pathValidation.error}`);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const manifestPath = join(pluginPath, 'plugin.json');
|
|
506
|
+
const indexPath = join(pluginPath, 'index.js');
|
|
507
|
+
|
|
508
|
+
// Validate manifest and index paths are within plugin directory
|
|
509
|
+
const manifestValidation = validatePluginPath('plugin.json', {
|
|
510
|
+
allowedDirs: [pluginPath],
|
|
511
|
+
allowAbsolute: false,
|
|
512
|
+
mustExist: true,
|
|
513
|
+
expectedType: 'file',
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (!manifestValidation.valid) {
|
|
517
|
+
logger.warn(`Plugin '${entry.name}' has invalid manifest path: ${manifestValidation.error}`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const indexValidation = validatePluginPath('index.js', {
|
|
522
|
+
allowedDirs: [pluginPath],
|
|
523
|
+
allowAbsolute: false,
|
|
524
|
+
mustExist: true,
|
|
525
|
+
expectedType: 'file',
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (!indexValidation.valid) {
|
|
529
|
+
logger.warn(`Plugin '${entry.name}' has invalid entry point: ${indexValidation.error}`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!existsSync(manifestPath) || !existsSync(indexPath)) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const manifest = JSON.parse(await import('fs').then(m => m.readFileSync(manifestPath, 'utf8')));
|
|
539
|
+
|
|
540
|
+
if (manifest.type !== 'widget') continue;
|
|
541
|
+
|
|
542
|
+
// Validate manifest against schema
|
|
543
|
+
const validation = validateManifest(manifest);
|
|
544
|
+
if (!validation.valid) {
|
|
545
|
+
const pluginError = PluginErrorAnalyzer.analyze(
|
|
546
|
+
new Error(validation.errors.join(', ')),
|
|
547
|
+
manifest.id || entry.name,
|
|
548
|
+
{ phase: 'manifest', manifest }
|
|
549
|
+
);
|
|
550
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
discovered.push({
|
|
555
|
+
id: manifest.id || entry.name,
|
|
556
|
+
manifest,
|
|
557
|
+
path: pluginPath,
|
|
558
|
+
entryPoint: indexPath,
|
|
559
|
+
});
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if (err instanceof PluginError) {
|
|
562
|
+
logger.warn(err.getFormattedMessage());
|
|
563
|
+
} else {
|
|
564
|
+
const pluginError = PluginErrorAnalyzer.analyze(err, entry.name, { phase: 'manifest' });
|
|
565
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return discovered;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Load and register a plugin
|
|
575
|
+
* @param {string} pluginPath - Path to plugin directory
|
|
576
|
+
* @param {Object} options - Load options
|
|
577
|
+
* @param {boolean} options.sanitize - Whether to sanitize config (default: true)
|
|
578
|
+
* @param {boolean} options.fallbackOnError - Fall back to defaults on error (default: true)
|
|
579
|
+
*/
|
|
580
|
+
async loadPlugin(pluginPath, options = {}) {
|
|
581
|
+
const {
|
|
582
|
+
sanitize = true,
|
|
583
|
+
fallbackOnError = true,
|
|
584
|
+
eager = true, // Default to eager loading (load immediately after register)
|
|
585
|
+
} = options;
|
|
586
|
+
|
|
587
|
+
// Validate plugin path before processing
|
|
588
|
+
const pathValidation = validatePluginPath(pluginPath, {
|
|
589
|
+
allowedDirs: [this.pluginsDir],
|
|
590
|
+
allowAbsolute: true,
|
|
591
|
+
mustExist: true,
|
|
592
|
+
expectedType: 'directory',
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (!pathValidation.valid) {
|
|
596
|
+
throw new Error(`Invalid plugin path: ${pathValidation.error}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Use the validated, resolved path
|
|
600
|
+
const validatedPluginPath = pathValidation.path;
|
|
601
|
+
const manifestPath = join(validatedPluginPath, 'plugin.json');
|
|
602
|
+
const indexPath = join(validatedPluginPath, 'index.js');
|
|
603
|
+
|
|
604
|
+
// Validate manifest and index paths
|
|
605
|
+
const manifestValidation = validatePluginPath(manifestPath, {
|
|
606
|
+
allowedDirs: [validatedPluginPath],
|
|
607
|
+
allowAbsolute: true,
|
|
608
|
+
mustExist: true,
|
|
609
|
+
expectedType: 'file',
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (!manifestValidation.valid) {
|
|
613
|
+
const error = new Error(`Invalid manifest path: ${manifestValidation.error}`);
|
|
614
|
+
if (fallbackOnError) {
|
|
615
|
+
logger.warn(`Failed to load plugin at ${validatedPluginPath}: ${error.message}`);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const indexValidation = validatePluginPath(indexPath, {
|
|
622
|
+
allowedDirs: [validatedPluginPath],
|
|
623
|
+
allowAbsolute: true,
|
|
624
|
+
mustExist: true,
|
|
625
|
+
expectedType: 'file',
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
if (!indexValidation.valid) {
|
|
629
|
+
const error = new Error(`Invalid entry point path: ${indexValidation.error}`);
|
|
630
|
+
if (fallbackOnError) {
|
|
631
|
+
logger.warn(`Failed to load plugin at ${validatedPluginPath}: ${error.message}`);
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!existsSync(manifestPath)) {
|
|
638
|
+
const pluginError = new PluginError(
|
|
639
|
+
PLUGIN_ERROR_CODES.MANIFEST_NOT_FOUND,
|
|
640
|
+
`Plugin manifest not found at ${validatedPluginPath}`,
|
|
641
|
+
{ pluginId: basename(validatedPluginPath) }
|
|
642
|
+
);
|
|
643
|
+
throw pluginError;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let manifest;
|
|
647
|
+
try {
|
|
648
|
+
const manifestContent = await import('fs').then(m => m.readFileSync(manifestPath, 'utf8'));
|
|
649
|
+
manifest = JSON.parse(manifestContent);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
const pluginError = PluginErrorAnalyzer.analyze(err, basename(validatedPluginPath), {
|
|
652
|
+
phase: 'manifest',
|
|
653
|
+
path: validatedPluginPath,
|
|
654
|
+
});
|
|
655
|
+
if (fallbackOnError) {
|
|
656
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
throw pluginError;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Validate manifest against schema
|
|
663
|
+
const validation = validateManifest(manifest);
|
|
664
|
+
if (!validation.valid) {
|
|
665
|
+
const pluginError = PluginErrorAnalyzer.analyze(
|
|
666
|
+
new Error(`Validation failed: ${validation.errors.join(', ')}`),
|
|
667
|
+
manifest.id || basename(validatedPluginPath),
|
|
668
|
+
{ phase: 'manifest', manifest }
|
|
669
|
+
);
|
|
670
|
+
if (fallbackOnError) {
|
|
671
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
throw pluginError;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Validate manifest has required fields
|
|
678
|
+
if (!manifest.id && !manifest.name) {
|
|
679
|
+
manifest.id = basename(validatedPluginPath);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const id = manifest.id || basename(validatedPluginPath);
|
|
683
|
+
|
|
684
|
+
// Store plugin path in metadata for hot-reload support
|
|
685
|
+
manifest._pluginPath = validatedPluginPath;
|
|
686
|
+
manifest._manifestPath = manifestPath;
|
|
687
|
+
manifest._indexPath = indexPath;
|
|
688
|
+
|
|
689
|
+
// Process and sanitize plugin config
|
|
690
|
+
// Extract defaults from schema definition if config has schema format
|
|
691
|
+
let processedConfig = extractDefaultsFromSchema(manifest.config);
|
|
692
|
+
|
|
693
|
+
// Then apply env interpolation
|
|
694
|
+
if (manifest.config) {
|
|
695
|
+
const processingResult = processWidgetConfig(processedConfig, {
|
|
696
|
+
interpolateEnv: true,
|
|
697
|
+
validateVersion: false,
|
|
698
|
+
supportLegacy: true,
|
|
699
|
+
throwOnError: false,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
if (processingResult.success) {
|
|
703
|
+
processedConfig = processingResult.config;
|
|
704
|
+
if (processingResult.warnings) {
|
|
705
|
+
processingResult.warnings.forEach(warning => {
|
|
706
|
+
logger.debug(`[${id}] ${warning}`);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Apply security sanitization
|
|
712
|
+
if (sanitize) {
|
|
713
|
+
try {
|
|
714
|
+
processedConfig = sanitizeWidgetConfig(processedConfig);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
logger.warn(`Failed to sanitize config for plugin '${id}': ${err.message}, using processed config`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Create loader function with error handling
|
|
722
|
+
const loader = async () => {
|
|
723
|
+
try {
|
|
724
|
+
const module = await import(pathToFileURL(indexPath).href);
|
|
725
|
+
|
|
726
|
+
// Support both default export and named exports
|
|
727
|
+
const WidgetClass = module.default || module.Widget || module;
|
|
728
|
+
|
|
729
|
+
if (typeof WidgetClass === 'function') {
|
|
730
|
+
return new WidgetClass(processedConfig);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Handle invalid export
|
|
734
|
+
const pluginError = new PluginError(
|
|
735
|
+
PLUGIN_ERROR_CODES.ENTRY_INVALID_EXPORT,
|
|
736
|
+
`Plugin "${id}" does not export a valid widget class`,
|
|
737
|
+
{
|
|
738
|
+
pluginId: id,
|
|
739
|
+
exportType: typeof WidgetClass,
|
|
740
|
+
hasDefault: !!module.default,
|
|
741
|
+
hasNamed: !!module.Widget,
|
|
742
|
+
}
|
|
743
|
+
);
|
|
744
|
+
throw pluginError;
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (err instanceof PluginError) {
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
const pluginError = PluginErrorAnalyzer.analyze(err, id, {
|
|
750
|
+
phase: 'entry',
|
|
751
|
+
path: indexPath,
|
|
752
|
+
});
|
|
753
|
+
throw pluginError;
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
this.register(id, manifest, loader);
|
|
758
|
+
|
|
759
|
+
// Auto-load based on options:
|
|
760
|
+
// - If eager: false, don't load (lazy mode - caller must call load() later)
|
|
761
|
+
// - If manifest.lazyLoad: true, don't load (plugin declares itself as lazy)
|
|
762
|
+
// - Otherwise, load immediately
|
|
763
|
+
const shouldLoad = eager !== false && manifest.lazyLoad !== true;
|
|
764
|
+
|
|
765
|
+
if (shouldLoad) {
|
|
766
|
+
try {
|
|
767
|
+
await this.load(id);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
if (fallbackOnError) {
|
|
770
|
+
logger.warn(`Failed to auto-load plugin '${id}': ${err.message}`);
|
|
771
|
+
// Note: We still return the ID for backward compatibility
|
|
772
|
+
// The widget is registered, even if loading failed
|
|
773
|
+
// Callers can check isLoaded() to verify loading succeeded
|
|
774
|
+
} else {
|
|
775
|
+
throw err;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return id;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Load all discovered plugins with error handling and fallback
|
|
785
|
+
* Uses dependency resolution to ensure correct load order
|
|
786
|
+
* @param {Object} options - Load options
|
|
787
|
+
* @param {boolean} [options.resolveDependencies=true] - Whether to resolve and load in dependency order
|
|
788
|
+
* @param {boolean} [options.allowPartial=false] - Allow partial loading when dependencies are missing
|
|
789
|
+
* @returns {Object} Results with successful and failed plugin IDs
|
|
790
|
+
*/
|
|
791
|
+
async loadAllPluginsWithFallback(options = {}) {
|
|
792
|
+
const {
|
|
793
|
+
sanitize = true,
|
|
794
|
+
fallbackOnError = true,
|
|
795
|
+
continueOnError = true,
|
|
796
|
+
resolveDependencies: shouldResolveDeps = true,
|
|
797
|
+
allowPartial = false,
|
|
798
|
+
} = options;
|
|
799
|
+
|
|
800
|
+
const discovered = await this.discoverPlugins();
|
|
801
|
+
const results = {
|
|
802
|
+
successful: [],
|
|
803
|
+
failed: [],
|
|
804
|
+
skipped: [],
|
|
805
|
+
dependencyErrors: [],
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// First pass: register all discovered plugins without loading
|
|
809
|
+
for (const plugin of discovered) {
|
|
810
|
+
try {
|
|
811
|
+
// Register only - don't load yet so we can resolve dependencies
|
|
812
|
+
const id = await this.registerPlugin(plugin.path, { sanitize, fallbackOnError });
|
|
813
|
+
if (id) {
|
|
814
|
+
// Track as successfully registered (will be loaded in second pass)
|
|
815
|
+
if (!results.successful.includes(id)) {
|
|
816
|
+
results.successful.push(id);
|
|
817
|
+
}
|
|
818
|
+
} else {
|
|
819
|
+
results.skipped.push(plugin.id);
|
|
820
|
+
// Remove from successful if it was added
|
|
821
|
+
const idx = results.successful.indexOf(plugin.id);
|
|
822
|
+
if (idx > -1) results.successful.splice(idx, 1);
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
results.failed.push({ id: plugin.id, error: err.message });
|
|
826
|
+
// Remove from successful if it was added
|
|
827
|
+
const idx = results.successful.indexOf(plugin.id);
|
|
828
|
+
if (idx > -1) results.successful.splice(idx, 1);
|
|
829
|
+
logger.warn(`Plugin '${plugin.id}' failed to register: ${err.message}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Second pass: resolve dependencies and load in order
|
|
834
|
+
if (shouldResolveDeps && this.widgetRegistry.size > 0) {
|
|
835
|
+
const resolution = resolveDependencies(this.widgetRegistry, {
|
|
836
|
+
allowPartial,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
if (!resolution.success) {
|
|
840
|
+
results.dependencyErrors.push({
|
|
841
|
+
error: resolution.error,
|
|
842
|
+
circularPath: resolution.circularPath,
|
|
843
|
+
missingDeps: resolution.missingDeps,
|
|
844
|
+
constraintViolations: resolution.constraintViolations,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// Create enhanced error for dependency issues
|
|
848
|
+
// missingDeps is an Object mapping widget ID to missing dependency IDs array
|
|
849
|
+
const missingDepIds = resolution.missingDeps
|
|
850
|
+
? Object.entries(resolution.missingDeps)
|
|
851
|
+
.map(([id, deps]) => `${id}(${deps.join(', ')})`)
|
|
852
|
+
.join('; ')
|
|
853
|
+
: 'unknown';
|
|
854
|
+
const depError = new PluginError(
|
|
855
|
+
resolution.circularPath ? PLUGIN_ERROR_CODES.DEPENDENCY_CIRCULAR : PLUGIN_ERROR_CODES.DEPENDENCY_MISSING,
|
|
856
|
+
resolution.error,
|
|
857
|
+
{
|
|
858
|
+
pluginId: missingDepIds,
|
|
859
|
+
circularPath: resolution.circularPath,
|
|
860
|
+
missingDeps: resolution.missingDeps,
|
|
861
|
+
}
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
if (!continueOnError) {
|
|
865
|
+
logger.error(depError.getFormattedMessage());
|
|
866
|
+
return results;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
logger.warn(depError.getFormattedMessage());
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Load in dependency order
|
|
873
|
+
for (const id of resolution.order) {
|
|
874
|
+
const widget = this.widgetRegistry.get(id);
|
|
875
|
+
if (!widget || widget.loaded) continue;
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
await this.load(id);
|
|
879
|
+
results.successful.push(id);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
results.failed.push({ id, error: err.message });
|
|
882
|
+
logger.warn(`Widget '${id}' failed to load: ${err.message}`);
|
|
883
|
+
|
|
884
|
+
if (!continueOnError && !fallbackOnError) {
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
// Fallback: load without dependency resolution (original behavior)
|
|
891
|
+
for (const plugin of discovered) {
|
|
892
|
+
// Skip already registered ones
|
|
893
|
+
if (this.widgetRegistry.has(plugin.id)) continue;
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const id = await this.loadPlugin(plugin.path, { sanitize, fallbackOnError });
|
|
897
|
+
if (id) {
|
|
898
|
+
results.successful.push(id);
|
|
899
|
+
} else {
|
|
900
|
+
results.skipped.push(plugin.id);
|
|
901
|
+
}
|
|
902
|
+
} catch (err) {
|
|
903
|
+
results.failed.push({ id: plugin.id, error: err.message });
|
|
904
|
+
logger.warn(`Plugin '${plugin.id}' failed to load: ${err.message}`);
|
|
905
|
+
|
|
906
|
+
if (!continueOnError && !fallbackOnError) {
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
logger.debug(`Plugin loading complete: ${results.successful.length} loaded, ${results.failed.length} failed, ${results.skipped.length} skipped`);
|
|
914
|
+
return results;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Register a plugin without loading it (for dependency resolution)
|
|
919
|
+
* @param {string} pluginPath - Path to plugin directory
|
|
920
|
+
* @param {Object} options - Registration options
|
|
921
|
+
* @returns {string|null} Plugin ID or null if skipped
|
|
922
|
+
*/
|
|
923
|
+
async registerPlugin(pluginPath, options = {}) {
|
|
924
|
+
const { sanitize = true, fallbackOnError = true } = options;
|
|
925
|
+
|
|
926
|
+
// Validate plugin path
|
|
927
|
+
const pathValidation = validatePluginPath(pluginPath, {
|
|
928
|
+
allowedDirs: [this.pluginsDir],
|
|
929
|
+
allowAbsolute: true,
|
|
930
|
+
mustExist: true,
|
|
931
|
+
expectedType: 'directory',
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
if (!pathValidation.valid) {
|
|
935
|
+
throw new Error(`Invalid plugin path: ${pathValidation.error}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const validatedPluginPath = pathValidation.path;
|
|
939
|
+
const manifestPath = join(validatedPluginPath, 'plugin.json');
|
|
940
|
+
const indexPath = join(validatedPluginPath, 'index.js');
|
|
941
|
+
|
|
942
|
+
if (!existsSync(manifestPath)) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let manifest;
|
|
947
|
+
try {
|
|
948
|
+
const manifestContent = await import('fs').then(m => m.readFileSync(manifestPath, 'utf8'));
|
|
949
|
+
manifest = JSON.parse(manifestContent);
|
|
950
|
+
} catch (err) {
|
|
951
|
+
const pluginError = PluginErrorAnalyzer.analyze(err, basename(validatedPluginPath), {
|
|
952
|
+
phase: 'manifest',
|
|
953
|
+
path: validatedPluginPath,
|
|
954
|
+
});
|
|
955
|
+
if (fallbackOnError) {
|
|
956
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
throw pluginError;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Validate manifest against schema
|
|
963
|
+
const validation = validateManifest(manifest);
|
|
964
|
+
if (!validation.valid) {
|
|
965
|
+
const pluginError = PluginErrorAnalyzer.analyze(
|
|
966
|
+
new Error(`Validation failed: ${validation.errors.join(', ')}`),
|
|
967
|
+
manifest.id || basename(validatedPluginPath),
|
|
968
|
+
{ phase: 'manifest', manifest }
|
|
969
|
+
);
|
|
970
|
+
if (fallbackOnError) {
|
|
971
|
+
logger.warn(pluginError.getFormattedMessage());
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
throw pluginError;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const id = manifest.id || basename(validatedPluginPath);
|
|
978
|
+
|
|
979
|
+
// Store plugin path in metadata for hot-reload support
|
|
980
|
+
manifest._pluginPath = validatedPluginPath;
|
|
981
|
+
manifest._manifestPath = manifestPath;
|
|
982
|
+
manifest._indexPath = indexPath;
|
|
983
|
+
|
|
984
|
+
// Process and sanitize plugin config
|
|
985
|
+
let processedConfig = {};
|
|
986
|
+
if (manifest.config) {
|
|
987
|
+
const processingResult = processWidgetConfig(manifest.config, {
|
|
988
|
+
interpolateEnv: true,
|
|
989
|
+
validateVersion: true,
|
|
990
|
+
supportLegacy: true,
|
|
991
|
+
throwOnError: false,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
if (processingResult.success) {
|
|
995
|
+
processedConfig = processingResult.config;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (sanitize) {
|
|
999
|
+
try {
|
|
1000
|
+
processedConfig = sanitizeWidgetConfig(processedConfig);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
logger.warn(`Failed to sanitize config for plugin '${id}': ${err.message}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Create loader function
|
|
1008
|
+
const loader = async () => {
|
|
1009
|
+
try {
|
|
1010
|
+
const module = await import(pathToFileURL(indexPath).href);
|
|
1011
|
+
const WidgetClass = module.default || module.Widget || module;
|
|
1012
|
+
|
|
1013
|
+
if (typeof WidgetClass === 'function') {
|
|
1014
|
+
return new WidgetClass(processedConfig);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return WidgetClass;
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
logger.error(`Failed to load plugin '${id}': ${err.message}`);
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
this.register(id, manifest, loader);
|
|
1025
|
+
return id;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Load widgets in dependency order
|
|
1030
|
+
* @param {string[]} ids - Widget IDs to load (loads all registered if empty)
|
|
1031
|
+
* @param {Object} options - Load options
|
|
1032
|
+
* @returns {Promise<Object>} Loading results
|
|
1033
|
+
*/
|
|
1034
|
+
async loadInDependencyOrder(ids = null, options = {}) {
|
|
1035
|
+
const { allowPartial = false, continueOnError = true } = options;
|
|
1036
|
+
|
|
1037
|
+
const targetIds = ids || Array.from(this.widgetRegistry.keys());
|
|
1038
|
+
|
|
1039
|
+
const resolution = resolveDependencies(this.widgetRegistry, {
|
|
1040
|
+
targetIds,
|
|
1041
|
+
allowPartial,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const results = {
|
|
1045
|
+
successful: [],
|
|
1046
|
+
failed: [],
|
|
1047
|
+
skipped: [],
|
|
1048
|
+
resolution,
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
if (!resolution.success) {
|
|
1052
|
+
logger.error(`Dependency resolution failed: ${resolution.error}`);
|
|
1053
|
+
return results;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
for (const id of resolution.order) {
|
|
1057
|
+
const widget = this.widgetRegistry.get(id);
|
|
1058
|
+
if (!widget || widget.loaded) continue;
|
|
1059
|
+
|
|
1060
|
+
try {
|
|
1061
|
+
await this.load(id);
|
|
1062
|
+
results.successful.push(id);
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
results.failed.push({ id, error: err.message });
|
|
1065
|
+
if (!continueOnError) break;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return results;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Get dependency information for a widget
|
|
1074
|
+
* @param {string} id - Widget ID
|
|
1075
|
+
* @returns {Object|null} Dependency information
|
|
1076
|
+
*/
|
|
1077
|
+
getDependencyInfo(id) {
|
|
1078
|
+
const widget = this.widgetRegistry.get(id);
|
|
1079
|
+
if (!widget) return null;
|
|
1080
|
+
|
|
1081
|
+
const validation = validateWidgetDependencies(this.widgetRegistry, id);
|
|
1082
|
+
const graph = buildDependencyGraph(this.widgetRegistry);
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
id,
|
|
1086
|
+
dependencies: widget.metadata.dependencies || [],
|
|
1087
|
+
allDependencies: getAllDependencies(graph, id),
|
|
1088
|
+
dependents: getAllDependents(graph, id),
|
|
1089
|
+
validation,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Get the full dependency graph
|
|
1095
|
+
* @returns {Object} Dependency graph representation
|
|
1096
|
+
*/
|
|
1097
|
+
getDependencyGraph() {
|
|
1098
|
+
const graph = buildDependencyGraph(this.widgetRegistry);
|
|
1099
|
+
const result = {};
|
|
1100
|
+
|
|
1101
|
+
for (const [id, node] of graph) {
|
|
1102
|
+
result[id] = {
|
|
1103
|
+
id,
|
|
1104
|
+
dependencies: node.dependencies.map(d => ({
|
|
1105
|
+
id: d.id,
|
|
1106
|
+
optional: d.optional,
|
|
1107
|
+
version: d.version,
|
|
1108
|
+
})),
|
|
1109
|
+
dependents: Array.from(node.dependents),
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Validate dependencies for one or all widgets
|
|
1118
|
+
* @param {string} [id] - Specific widget ID (validates all if omitted)
|
|
1119
|
+
* @returns {Object} Validation results
|
|
1120
|
+
*/
|
|
1121
|
+
validateDependencies(id = null) {
|
|
1122
|
+
if (id) {
|
|
1123
|
+
return {
|
|
1124
|
+
[id]: validateWidgetDependencies(this.widgetRegistry, id),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const results = {};
|
|
1129
|
+
for (const [widgetId] of this.widgetRegistry) {
|
|
1130
|
+
results[widgetId] = validateWidgetDependencies(this.widgetRegistry, widgetId);
|
|
1131
|
+
}
|
|
1132
|
+
return results;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Get loading statistics
|
|
1137
|
+
*/
|
|
1138
|
+
getStats() {
|
|
1139
|
+
const all = Array.from(this.widgetRegistry.values());
|
|
1140
|
+
return {
|
|
1141
|
+
total: all.length,
|
|
1142
|
+
loaded: all.filter(w => w.loaded).length,
|
|
1143
|
+
failed: all.filter(w => w.error).length,
|
|
1144
|
+
loading: this.loadPromises.size,
|
|
1145
|
+
averageLoadTime: all.filter(w => w.loadTime).reduce((sum, w) => sum + w.loadTime, 0) / all.filter(w => w.loadTime).length || 0,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Clear all widgets
|
|
1151
|
+
*/
|
|
1152
|
+
async clear() {
|
|
1153
|
+
const ids = Array.from(this.loadedWidgets.keys());
|
|
1154
|
+
await Promise.all(ids.map(id => this.unload(id)));
|
|
1155
|
+
this.widgetRegistry.clear();
|
|
1156
|
+
this.loadedWidgets.clear();
|
|
1157
|
+
this.loadPromises.clear();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Enable hot-reload for widget configurations
|
|
1162
|
+
* Watches plugin.json files and reloads widgets when changed
|
|
1163
|
+
* @param {Object} options - Hot-reload options
|
|
1164
|
+
* @param {number} options.debounceMs - Debounce interval for changes (default: 500)
|
|
1165
|
+
* @param {boolean} options.usePolling - Use polling instead of native events (default: false)
|
|
1166
|
+
* @param {boolean} options.reloadWidgets - Automatically reload widgets when config changes (default: true)
|
|
1167
|
+
* @returns {ConfigWatcher|null} The config watcher instance or null if disabled
|
|
1168
|
+
*/
|
|
1169
|
+
enableConfigHotReload(options = {}) {
|
|
1170
|
+
const {
|
|
1171
|
+
debounceMs = 500,
|
|
1172
|
+
usePolling = false,
|
|
1173
|
+
reloadWidgets = true,
|
|
1174
|
+
} = options;
|
|
1175
|
+
|
|
1176
|
+
// Don't enable if already watching
|
|
1177
|
+
if (this.configWatcher) {
|
|
1178
|
+
logger.debug('Config hot-reload already enabled');
|
|
1179
|
+
return this.configWatcher;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Create watcher
|
|
1183
|
+
this.configWatcher = new ConfigWatcher({
|
|
1184
|
+
debounceMs,
|
|
1185
|
+
usePolling,
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// Track reload stats
|
|
1189
|
+
this._reloadStats = {
|
|
1190
|
+
reloads: 0,
|
|
1191
|
+
errors: 0,
|
|
1192
|
+
lastReload: null,
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// Listen for reload events
|
|
1196
|
+
this.configWatcher.on('reload', async ({ filePath, timestamp }) => {
|
|
1197
|
+
try {
|
|
1198
|
+
// Find which widget this config belongs to
|
|
1199
|
+
const widgetId = this._findWidgetIdByConfigPath(filePath);
|
|
1200
|
+
if (!widgetId) {
|
|
1201
|
+
logger.debug(`Config reload: Could not find widget for ${filePath}`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
logger.info(`Config hot-reload triggered for widget: ${widgetId}`);
|
|
1206
|
+
|
|
1207
|
+
// Read and process new config
|
|
1208
|
+
const reloadResult = await this._reloadWidgetConfig(widgetId, filePath);
|
|
1209
|
+
|
|
1210
|
+
if (reloadResult.success) {
|
|
1211
|
+
this._reloadStats.reloads++;
|
|
1212
|
+
this._reloadStats.lastReload = { widgetId, timestamp };
|
|
1213
|
+
logger.info(`Config hot-reload successful for ${widgetId}`);
|
|
1214
|
+
|
|
1215
|
+
// Emit event for external listeners
|
|
1216
|
+
this.emit?.('configReloaded', { widgetId, timestamp, config: reloadResult.config });
|
|
1217
|
+
} else {
|
|
1218
|
+
this._reloadStats.errors++;
|
|
1219
|
+
logger.error(`Config hot-reload failed for ${widgetId}: ${reloadResult.error}`);
|
|
1220
|
+
|
|
1221
|
+
// Emit error event
|
|
1222
|
+
this.emit?.('configReloadError', { widgetId, error: reloadResult.error, timestamp });
|
|
1223
|
+
}
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
this._reloadStats.errors++;
|
|
1226
|
+
logger.error(`Config hot-reload error: ${err.message}`);
|
|
1227
|
+
this.emit?.('configReloadError', { filePath, error: err.message, timestamp });
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Handle watcher errors
|
|
1232
|
+
this.configWatcher.on('error', ({ filePath, error }) => {
|
|
1233
|
+
this._reloadStats.errors++;
|
|
1234
|
+
logger.error(`Config watcher error for ${filePath}: ${error.message}`);
|
|
1235
|
+
this.emit?.('configWatcherError', { filePath, error: error.message });
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// Start watching all registered widgets' plugin.json files
|
|
1239
|
+
this._startWatchingWidgetConfigs();
|
|
1240
|
+
|
|
1241
|
+
logger.info('Widget config hot-reload enabled');
|
|
1242
|
+
return this.configWatcher;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Disable config hot-reload
|
|
1247
|
+
*/
|
|
1248
|
+
disableConfigHotReload() {
|
|
1249
|
+
if (this.configWatcher) {
|
|
1250
|
+
this.configWatcher.unwatchAll();
|
|
1251
|
+
this.configWatcher = null;
|
|
1252
|
+
logger.info('Widget config hot-reload disabled');
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Check if hot-reload is enabled
|
|
1258
|
+
* @returns {boolean}
|
|
1259
|
+
*/
|
|
1260
|
+
isConfigHotReloadEnabled() {
|
|
1261
|
+
return !!this.configWatcher;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Get hot-reload statistics
|
|
1266
|
+
* @returns {Object} Stats object
|
|
1267
|
+
*/
|
|
1268
|
+
getHotReloadStats() {
|
|
1269
|
+
return {
|
|
1270
|
+
enabled: this.isConfigHotReloadEnabled(),
|
|
1271
|
+
...this._reloadStats,
|
|
1272
|
+
watchedFiles: this.configWatcher?.getWatchedFiles().length || 0,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Find widget ID by its config file path
|
|
1278
|
+
* @private
|
|
1279
|
+
* @param {string} configPath - Path to config file
|
|
1280
|
+
* @returns {string|null} Widget ID or null
|
|
1281
|
+
*/
|
|
1282
|
+
_findWidgetIdByConfigPath(configPath) {
|
|
1283
|
+
for (const [id, widget] of this.widgetRegistry) {
|
|
1284
|
+
// Check if the widget has a plugin path that matches
|
|
1285
|
+
if (widget.metadata?._pluginPath) {
|
|
1286
|
+
const expectedPath = join(widget.metadata._pluginPath, 'plugin.json');
|
|
1287
|
+
if (configPath === expectedPath || configPath.endsWith(expectedPath)) {
|
|
1288
|
+
return id;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Reload widget configuration from file
|
|
1297
|
+
* @private
|
|
1298
|
+
* @param {string} widgetId - Widget ID
|
|
1299
|
+
* @param {string} filePath - Path to plugin.json
|
|
1300
|
+
* @returns {Object} Reload result { success: boolean, config?: Object, error?: string }
|
|
1301
|
+
*/
|
|
1302
|
+
async _reloadWidgetConfig(widgetId, filePath) {
|
|
1303
|
+
const widget = this.widgetRegistry.get(widgetId);
|
|
1304
|
+
if (!widget) {
|
|
1305
|
+
return { success: false, error: 'Widget not found in registry' };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
// Read new manifest
|
|
1310
|
+
const fs = await import('fs');
|
|
1311
|
+
const manifestContent = fs.readFileSync(filePath, 'utf8');
|
|
1312
|
+
const manifest = JSON.parse(manifestContent);
|
|
1313
|
+
|
|
1314
|
+
// Validate manifest
|
|
1315
|
+
const validation = validateManifest(manifest);
|
|
1316
|
+
if (!validation.valid) {
|
|
1317
|
+
return { success: false, error: `Manifest validation failed: ${validation.errors.join(', ')}` };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Process new config
|
|
1321
|
+
let newConfig = {};
|
|
1322
|
+
if (manifest.config) {
|
|
1323
|
+
const processingResult = processWidgetConfig(manifest.config, {
|
|
1324
|
+
interpolateEnv: true,
|
|
1325
|
+
validateVersion: true,
|
|
1326
|
+
supportLegacy: true,
|
|
1327
|
+
throwOnError: false,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
if (!processingResult.success) {
|
|
1331
|
+
return { success: false, error: `Config processing failed: ${processingResult.error}` };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
newConfig = processingResult.config;
|
|
1335
|
+
|
|
1336
|
+
// Sanitize the new config
|
|
1337
|
+
try {
|
|
1338
|
+
newConfig = sanitizeWidgetConfig(newConfig);
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
return { success: false, error: `Config sanitization failed: ${err.message}` };
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Update widget metadata
|
|
1345
|
+
widget.metadata = {
|
|
1346
|
+
...widget.metadata,
|
|
1347
|
+
...manifest,
|
|
1348
|
+
config: newConfig,
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
// Update widget instance config if loaded
|
|
1352
|
+
if (widget.loaded && widget.instance) {
|
|
1353
|
+
// Update instance config
|
|
1354
|
+
if (widget.instance.config) {
|
|
1355
|
+
widget.instance.config = newConfig;
|
|
1356
|
+
} else {
|
|
1357
|
+
widget.instance.config = newConfig;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Call onConfigChange if widget supports it
|
|
1361
|
+
if (typeof widget.instance.onConfigChange === 'function') {
|
|
1362
|
+
try {
|
|
1363
|
+
await widget.instance.onConfigChange(newConfig, widget.instance.config);
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
logger.warn(`Widget ${widgetId} onConfigChange failed: ${err.message}`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return { success: true, config: newConfig };
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
return { success: false, error: err.message };
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Start watching all widget config files
|
|
1378
|
+
* @private
|
|
1379
|
+
*/
|
|
1380
|
+
_startWatchingWidgetConfigs() {
|
|
1381
|
+
if (!this.configWatcher) return;
|
|
1382
|
+
|
|
1383
|
+
for (const [id, widget] of this.widgetRegistry) {
|
|
1384
|
+
if (widget.metadata?._pluginPath) {
|
|
1385
|
+
const configPath = join(widget.metadata._pluginPath, 'plugin.json');
|
|
1386
|
+
this.configWatcher.watchFile(configPath);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Watch a specific widget's config file
|
|
1393
|
+
* @param {string} widgetId - Widget ID to watch
|
|
1394
|
+
* @returns {boolean} True if watching started
|
|
1395
|
+
*/
|
|
1396
|
+
watchWidgetConfig(widgetId) {
|
|
1397
|
+
if (!this.configWatcher) {
|
|
1398
|
+
logger.warn('Config hot-reload not enabled, call enableConfigHotReload() first');
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const widget = this.widgetRegistry.get(widgetId);
|
|
1403
|
+
if (!widget?.metadata?._pluginPath) {
|
|
1404
|
+
logger.warn(`Widget ${widgetId} does not have a plugin path to watch`);
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const configPath = join(widget.metadata._pluginPath, 'plugin.json');
|
|
1409
|
+
return this.configWatcher.watchFile(configPath);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Stop watching a specific widget's config file
|
|
1414
|
+
* @param {string} widgetId - Widget ID to unwatch
|
|
1415
|
+
*/
|
|
1416
|
+
unwatchWidgetConfig(widgetId) {
|
|
1417
|
+
if (!this.configWatcher) return;
|
|
1418
|
+
|
|
1419
|
+
const widget = this.widgetRegistry.get(widgetId);
|
|
1420
|
+
if (!widget?.metadata?._pluginPath) return;
|
|
1421
|
+
|
|
1422
|
+
const configPath = join(widget.metadata._pluginPath, 'plugin.json');
|
|
1423
|
+
this.configWatcher.unwatchFile(configPath);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Singleton instance
|
|
1428
|
+
let defaultLoader = null;
|
|
1429
|
+
|
|
1430
|
+
export function getWidgetLoader(options) {
|
|
1431
|
+
if (!defaultLoader) {
|
|
1432
|
+
defaultLoader = new WidgetLoader(options);
|
|
1433
|
+
}
|
|
1434
|
+
return defaultLoader;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export default WidgetLoader;
|