claw-dashboard 1.9.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5285 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -6
- 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 +1934 -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 +1056 -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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Manifest Validator
|
|
3
|
+
* Validates plugin.json files against the JSON Schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { resolve, dirname, join } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
// Load the schema
|
|
14
|
+
const SCHEMA_PATH = resolve(__dirname, '../schemas/plugin-manifest.json');
|
|
15
|
+
let schema = null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync(SCHEMA_PATH)) {
|
|
19
|
+
const schemaContent = readFileSync(SCHEMA_PATH, 'utf8');
|
|
20
|
+
schema = JSON.parse(schemaContent);
|
|
21
|
+
}
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.warn(`Failed to load plugin schema: ${err.message}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validation result type
|
|
28
|
+
* @typedef {Object} ValidationResult
|
|
29
|
+
* @property {boolean} valid - Whether the manifest is valid
|
|
30
|
+
* @property {string[]} errors - Array of error messages
|
|
31
|
+
* @property {string[]} warnings - Array of warning messages
|
|
32
|
+
* @property {string[]} info - Array of info messages
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a value against a schema property
|
|
37
|
+
* @private
|
|
38
|
+
* @param {*} value - The value to validate
|
|
39
|
+
* @param {Object} propSchema - The property schema
|
|
40
|
+
* @param {string} path - The current path in the object
|
|
41
|
+
* @returns {string[]} Array of error messages
|
|
42
|
+
*/
|
|
43
|
+
function validateProperty(value, propSchema, path) {
|
|
44
|
+
const errors = [];
|
|
45
|
+
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
// Check if required (handled at object level)
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Type validation
|
|
52
|
+
if (propSchema.type) {
|
|
53
|
+
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
54
|
+
if (actualType !== propSchema.type) {
|
|
55
|
+
// Special case for numbers (can be strings that parse as numbers)
|
|
56
|
+
if (propSchema.type === 'number' && !isNaN(Number(value))) {
|
|
57
|
+
// OK
|
|
58
|
+
} else {
|
|
59
|
+
errors.push(`'${path}' must be of type ${propSchema.type}, got ${actualType}`);
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// String validations
|
|
66
|
+
if (propSchema.type === 'string' && typeof value === 'string') {
|
|
67
|
+
if (propSchema.minLength !== undefined && value.length < propSchema.minLength) {
|
|
68
|
+
errors.push(`'${path}' must be at least ${propSchema.minLength} characters`);
|
|
69
|
+
}
|
|
70
|
+
if (propSchema.maxLength !== undefined && value.length > propSchema.maxLength) {
|
|
71
|
+
errors.push(`'${path}' must be at most ${propSchema.maxLength} characters`);
|
|
72
|
+
}
|
|
73
|
+
if (propSchema.pattern) {
|
|
74
|
+
const regex = new RegExp(propSchema.pattern);
|
|
75
|
+
if (!regex.test(value)) {
|
|
76
|
+
errors.push(`'${path}' does not match required pattern: ${propSchema.pattern}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (propSchema.enum && !propSchema.enum.includes(value)) {
|
|
80
|
+
errors.push(`'${path}' must be one of: ${propSchema.enum.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Number validations
|
|
85
|
+
if (propSchema.type === 'number' && typeof value === 'number') {
|
|
86
|
+
if (propSchema.minimum !== undefined && value < propSchema.minimum) {
|
|
87
|
+
errors.push(`'${path}' must be at least ${propSchema.minimum}`);
|
|
88
|
+
}
|
|
89
|
+
if (propSchema.maximum !== undefined && value > propSchema.maximum) {
|
|
90
|
+
errors.push(`'${path}' must be at most ${propSchema.maximum}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Array validations
|
|
95
|
+
if (propSchema.type === 'array' && Array.isArray(value)) {
|
|
96
|
+
if (propSchema.items) {
|
|
97
|
+
value.forEach((item, index) => {
|
|
98
|
+
const itemErrors = validateProperty(item, propSchema.items, `${path}[${index}]`);
|
|
99
|
+
errors.push(...itemErrors);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (propSchema.uniqueItems) {
|
|
103
|
+
const uniqueSet = new Set(value.map(JSON.stringify));
|
|
104
|
+
if (uniqueSet.size !== value.length) {
|
|
105
|
+
errors.push(`'${path}' must have unique items`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Object validations
|
|
111
|
+
if (propSchema.type === 'object' && typeof value === 'object' && !Array.isArray(value)) {
|
|
112
|
+
if (propSchema.properties) {
|
|
113
|
+
for (const [key, subSchema] of Object.entries(propSchema.properties)) {
|
|
114
|
+
const subErrors = validateProperty(value[key], subSchema, `${path}.${key}`);
|
|
115
|
+
errors.push(...subErrors);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate a plugin manifest against the schema
|
|
125
|
+
* @param {Object} manifest - The parsed plugin.json object
|
|
126
|
+
* @param {Object} options - Validation options
|
|
127
|
+
* @param {boolean} options.strict - Whether to fail on unknown properties
|
|
128
|
+
* @returns {ValidationResult} Validation result
|
|
129
|
+
*/
|
|
130
|
+
export function validateManifest(manifest, options = {}) {
|
|
131
|
+
const { strict = false } = options;
|
|
132
|
+
const errors = [];
|
|
133
|
+
const warnings = [];
|
|
134
|
+
const info = [];
|
|
135
|
+
|
|
136
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
137
|
+
return {
|
|
138
|
+
valid: false,
|
|
139
|
+
errors: ['Manifest must be a valid JSON object'],
|
|
140
|
+
warnings,
|
|
141
|
+
info
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!schema) {
|
|
146
|
+
return {
|
|
147
|
+
valid: true,
|
|
148
|
+
errors: [],
|
|
149
|
+
warnings: ['Schema not loaded, skipping validation'],
|
|
150
|
+
info: ['Manifest structure was not validated']
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check required fields
|
|
155
|
+
if (schema.required) {
|
|
156
|
+
for (const field of schema.required) {
|
|
157
|
+
if (!(field in manifest)) {
|
|
158
|
+
errors.push(`Missing required field: '${field}'`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Validate properties
|
|
164
|
+
if (schema.properties) {
|
|
165
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
166
|
+
if (key in manifest) {
|
|
167
|
+
const propErrors = validateProperty(manifest[key], propSchema, key);
|
|
168
|
+
errors.push(...propErrors);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check for unknown properties in strict mode
|
|
174
|
+
if (strict && schema.additionalProperties === false) {
|
|
175
|
+
const knownProps = Object.keys(schema.properties || {});
|
|
176
|
+
for (const key of Object.keys(manifest)) {
|
|
177
|
+
if (!knownProps.includes(key)) {
|
|
178
|
+
errors.push(`Unknown property: '${key}'`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add warnings for deprecated fields
|
|
184
|
+
if (schema.properties) {
|
|
185
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
186
|
+
if (propSchema.deprecated && key in manifest) {
|
|
187
|
+
warnings.push(`Field '${key}' is deprecated: ${propSchema.description}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Add info messages for optional fields with defaults
|
|
193
|
+
if (schema.properties) {
|
|
194
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
195
|
+
if (!(key in manifest) && 'default' in propSchema) {
|
|
196
|
+
info.push(`Using default value for '${key}': ${JSON.stringify(propSchema.default)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
valid: errors.length === 0,
|
|
203
|
+
errors,
|
|
204
|
+
warnings,
|
|
205
|
+
info
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a plugin manifest from a file path
|
|
211
|
+
* @param {string} filePath - Path to the plugin.json file
|
|
212
|
+
* @param {Object} options - Validation options
|
|
213
|
+
* @returns {ValidationResult} Validation result
|
|
214
|
+
*/
|
|
215
|
+
export function validateManifestFile(filePath, options = {}) {
|
|
216
|
+
const resolvedPath = resolve(filePath);
|
|
217
|
+
|
|
218
|
+
if (!existsSync(resolvedPath)) {
|
|
219
|
+
return {
|
|
220
|
+
valid: false,
|
|
221
|
+
errors: [`File not found: ${resolvedPath}`],
|
|
222
|
+
warnings: [],
|
|
223
|
+
info: []
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let manifest;
|
|
228
|
+
try {
|
|
229
|
+
const content = readFileSync(resolvedPath, 'utf8');
|
|
230
|
+
manifest = JSON.parse(content);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return {
|
|
233
|
+
valid: false,
|
|
234
|
+
errors: [`Failed to parse JSON: ${err.message}`],
|
|
235
|
+
warnings: [],
|
|
236
|
+
info: []
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return validateManifest(manifest, options);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Format validation results for display
|
|
245
|
+
* @param {ValidationResult} result - The validation result
|
|
246
|
+
* @param {string} pluginName - Optional plugin name for context
|
|
247
|
+
* @returns {string} Formatted output
|
|
248
|
+
*/
|
|
249
|
+
export function formatValidationResult(result, pluginName = '') {
|
|
250
|
+
const lines = [];
|
|
251
|
+
const name = pluginName ? ` ${pluginName} ` : '';
|
|
252
|
+
|
|
253
|
+
if (result.valid) {
|
|
254
|
+
lines.push(`✓ Plugin${name}is valid`);
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(`✗ Plugin${name}validation failed`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (result.errors.length > 0) {
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push('Errors:');
|
|
262
|
+
result.errors.forEach(err => lines.push(` • ${err}`));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (result.warnings.length > 0) {
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push('Warnings:');
|
|
268
|
+
result.warnings.forEach(warn => lines.push(` • ${warn}`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (result.info.length > 0) {
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push('Info:');
|
|
274
|
+
result.info.forEach(i => lines.push(` • ${i}`));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return lines.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export default {
|
|
281
|
+
validateManifest,
|
|
282
|
+
validateManifestFile,
|
|
283
|
+
formatValidationResult
|
|
284
|
+
};
|
package/src/retry.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry utility for OpenClaw API calls
|
|
3
|
+
* Implements exponential backoff with configurable options
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import logger from './logger.js';
|
|
7
|
+
import config from './config.js';
|
|
8
|
+
|
|
9
|
+
// Default retry configuration
|
|
10
|
+
const DEFAULT_OPTIONS = config.DEFAULT_RETRY_OPTIONS;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sleep for specified milliseconds
|
|
14
|
+
* @param {number} ms - Milliseconds to sleep
|
|
15
|
+
* @returns {Promise} Resolves after delay
|
|
16
|
+
*/
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculate delay with exponential backoff
|
|
23
|
+
* @param {number} attempt - Current attempt number (0-indexed)
|
|
24
|
+
* @param {object} options - Retry options
|
|
25
|
+
* @returns {number} Delay in milliseconds
|
|
26
|
+
*/
|
|
27
|
+
function calculateDelay(attempt, options) {
|
|
28
|
+
const delay = Math.min(
|
|
29
|
+
options.initialDelay * Math.pow(options.backoffMultiplier, attempt),
|
|
30
|
+
options.maxDelay
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Add jitter (±10%) to prevent thundering herd
|
|
34
|
+
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
|
|
35
|
+
return Math.floor(delay + jitter);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if error is retryable
|
|
40
|
+
* @param {Error} error - Error object
|
|
41
|
+
* @param {object} options - Retry options
|
|
42
|
+
* @returns {boolean} Whether the error is retryable
|
|
43
|
+
*/
|
|
44
|
+
function isRetryableError(error, options) {
|
|
45
|
+
// Check for retryable error codes
|
|
46
|
+
if (error.code && options.retryableErrors.includes(error.code)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check for retryable HTTP status codes (if response exists)
|
|
51
|
+
if (error.status && options.retryableStatuses.includes(error.status)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check error message for common retryable patterns
|
|
56
|
+
const message = error.message || '';
|
|
57
|
+
const retryablePatterns = [
|
|
58
|
+
/connection refused/i,
|
|
59
|
+
/timeout/i,
|
|
60
|
+
/network/i,
|
|
61
|
+
/econnreset/i,
|
|
62
|
+
/eai_again/i,
|
|
63
|
+
/temporary failure/i,
|
|
64
|
+
/service unavailable/i,
|
|
65
|
+
/internal server error/i,
|
|
66
|
+
/bad gateway/i,
|
|
67
|
+
/gateway timeout/i,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
return retryablePatterns.some(pattern => pattern.test(message));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrap a function with retry logic
|
|
75
|
+
* @param {Function} fn - Function to wrap (must return a Promise)
|
|
76
|
+
* @param {object} options - Retry options
|
|
77
|
+
* @returns {Function} Wrapped function
|
|
78
|
+
*/
|
|
79
|
+
function withRetry(fn, options = {}) {
|
|
80
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
81
|
+
|
|
82
|
+
return async function retryWrapper(...args) {
|
|
83
|
+
let lastError;
|
|
84
|
+
|
|
85
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
// Execute the function
|
|
88
|
+
const result = await fn(...args);
|
|
89
|
+
|
|
90
|
+
// Log successful retry if this wasn't the first attempt
|
|
91
|
+
if (attempt > 0) {
|
|
92
|
+
logger.info(`[RETRY] Operation succeeded after ${attempt} retries`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastError = error;
|
|
99
|
+
|
|
100
|
+
// Check if we should retry
|
|
101
|
+
const isLastAttempt = attempt >= opts.maxRetries;
|
|
102
|
+
const shouldRetry = !isLastAttempt && isRetryableError(error, opts);
|
|
103
|
+
|
|
104
|
+
if (!shouldRetry) {
|
|
105
|
+
// Log the final failure
|
|
106
|
+
logger.error(`[RETRY] Operation failed after ${attempt} attempts: ${error.message}`);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Calculate delay and wait
|
|
111
|
+
const delay = calculateDelay(attempt, opts);
|
|
112
|
+
logger.warn(`[RETRY] Attempt ${attempt + 1}/${opts.maxRetries + 1} failed: ${error.message}. Retrying in ${Math.round(delay)}ms...`);
|
|
113
|
+
await sleep(delay);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// This should never be reached, but just in case
|
|
118
|
+
throw lastError;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Retry a function until it succeeds or timeout
|
|
124
|
+
* @param {Function} fn - Function to retry
|
|
125
|
+
* @param {number} timeout - Maximum time to keep retrying (ms)
|
|
126
|
+
* @param {number} interval - Time between retries (ms)
|
|
127
|
+
* @returns {Promise} Result of function
|
|
128
|
+
*/
|
|
129
|
+
async function retryUntil(fn, timeout = config.RETRY.TIMEOUT, interval = config.RETRY.INTERVAL) {
|
|
130
|
+
const startTime = Date.now();
|
|
131
|
+
|
|
132
|
+
while (true) {
|
|
133
|
+
try {
|
|
134
|
+
return await fn();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (Date.now() - startTime >= timeout) {
|
|
137
|
+
throw new Error(`Retry timeout after ${timeout}ms: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
logger.warn(`[RETRY] Waiting for condition: ${error.message}`);
|
|
140
|
+
await sleep(interval);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a retryable HTTP request wrapper
|
|
147
|
+
* @param {Function} fetchFn - Fetch function (fetch, axios, etc.)
|
|
148
|
+
* @param {object} options - Retry options
|
|
149
|
+
* @returns {Function} Wrapped fetch function
|
|
150
|
+
*/
|
|
151
|
+
function createRetryableFetch(fetchFn, options = {}) {
|
|
152
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
153
|
+
|
|
154
|
+
return async function retryableFetch(url, fetchOptions = {}) {
|
|
155
|
+
let lastError;
|
|
156
|
+
|
|
157
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetchFn(url, fetchOptions);
|
|
160
|
+
|
|
161
|
+
// Check if response status is retryable
|
|
162
|
+
if (opts.retryableStatuses.includes(response.status)) {
|
|
163
|
+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
164
|
+
error.status = response.status;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Return response for non-retryable statuses (caller handles 2xx, 4xx)
|
|
169
|
+
return response;
|
|
170
|
+
|
|
171
|
+
} catch (error) {
|
|
172
|
+
lastError = error;
|
|
173
|
+
|
|
174
|
+
// Check if we should retry
|
|
175
|
+
const isLastAttempt = attempt >= opts.maxRetries;
|
|
176
|
+
const shouldRetry = !isLastAttempt && isRetryableError(error, opts);
|
|
177
|
+
|
|
178
|
+
if (!shouldRetry) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Calculate delay and wait
|
|
183
|
+
const delay = calculateDelay(attempt, opts);
|
|
184
|
+
logger.warn(`[RETRY] HTTP request attempt ${attempt + 1}/${opts.maxRetries + 1} failed: ${error.message}. Retrying in ${Math.round(delay)}ms...`);
|
|
185
|
+
await sleep(delay);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw lastError;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Batch retry multiple independent operations
|
|
195
|
+
* @param {Array<Function>} fns - Array of functions to execute
|
|
196
|
+
* @param {object} options - Retry options
|
|
197
|
+
* @returns {Promise<Array>} Array of results
|
|
198
|
+
*/
|
|
199
|
+
async function retryBatch(fns, options = {}) {
|
|
200
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
201
|
+
|
|
202
|
+
// Wrap each function with retry
|
|
203
|
+
const wrappedFns = fns.map(fn => withRetry(fn, opts));
|
|
204
|
+
|
|
205
|
+
// Execute all in parallel
|
|
206
|
+
return Promise.allSettled(wrappedFns.map(fn => fn()));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default {
|
|
210
|
+
DEFAULT_OPTIONS,
|
|
211
|
+
withRetry,
|
|
212
|
+
retryUntil,
|
|
213
|
+
createRetryableFetch,
|
|
214
|
+
retryBatch,
|
|
215
|
+
sleep,
|
|
216
|
+
calculateDelay,
|
|
217
|
+
isRetryableError
|
|
218
|
+
};
|