claude-autopm 1.31.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -5
- package/autopm/.claude/mcp/test-server.md +10 -0
- package/bin/autopm-poc.js +176 -44
- package/bin/autopm.js +97 -179
- package/lib/ai-providers/AbstractAIProvider.js +524 -0
- package/lib/ai-providers/ClaudeProvider.js +359 -48
- package/lib/ai-providers/TemplateProvider.js +432 -0
- package/lib/cli/commands/agent.js +206 -0
- package/lib/cli/commands/config.js +488 -0
- package/lib/cli/commands/prd.js +345 -0
- package/lib/cli/commands/task.js +206 -0
- package/lib/config/ConfigManager.js +531 -0
- package/lib/errors/AIProviderError.js +164 -0
- package/lib/services/AgentService.js +557 -0
- package/lib/services/EpicService.js +609 -0
- package/lib/services/PRDService.js +928 -103
- package/lib/services/TaskService.js +760 -0
- package/lib/services/interfaces.js +753 -0
- package/lib/utils/CircuitBreaker.js +165 -0
- package/lib/utils/Encryption.js +201 -0
- package/lib/utils/RateLimiter.js +241 -0
- package/lib/utils/ServiceFactory.js +165 -0
- package/package.json +6 -5
- package/scripts/config/get.js +108 -0
- package/scripts/config/init.js +100 -0
- package/scripts/config/list-providers.js +93 -0
- package/scripts/config/set-api-key.js +107 -0
- package/scripts/config/set-provider.js +201 -0
- package/scripts/config/set.js +139 -0
- package/scripts/config/show.js +181 -0
- package/autopm/.claude/.env +0 -158
- package/autopm/.claude/settings.local.json +0 -9
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker Implementation
|
|
3
|
+
*
|
|
4
|
+
* Implements the circuit breaker pattern to prevent cascading failures
|
|
5
|
+
* and allow systems to recover from transient faults.
|
|
6
|
+
*
|
|
7
|
+
* States:
|
|
8
|
+
* - CLOSED: Normal operation, requests pass through
|
|
9
|
+
* - OPEN: Too many failures, reject requests immediately
|
|
10
|
+
* - HALF_OPEN: Testing recovery, allow limited requests
|
|
11
|
+
*
|
|
12
|
+
* State Transitions:
|
|
13
|
+
* CLOSED → OPEN: After failureThreshold consecutive failures
|
|
14
|
+
* OPEN → HALF_OPEN: After timeout expires
|
|
15
|
+
* HALF_OPEN → CLOSED: After successThreshold consecutive successes
|
|
16
|
+
* HALF_OPEN → OPEN: On any failure
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const breaker = new CircuitBreaker({
|
|
20
|
+
* failureThreshold: 5, // Open after 5 consecutive failures
|
|
21
|
+
* successThreshold: 2, // Close after 2 consecutive successes
|
|
22
|
+
* timeout: 60000, // Wait 60s before trying again
|
|
23
|
+
* halfOpenTimeout: 30000 // (unused, for future extensions)
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* try {
|
|
27
|
+
* const result = await breaker.execute(async () => {
|
|
28
|
+
* return await apiCall();
|
|
29
|
+
* });
|
|
30
|
+
* } catch (error) {
|
|
31
|
+
* if (error.message === 'Circuit breaker is OPEN') {
|
|
32
|
+
* // Circuit is open, service unavailable
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Circuit breaker states
|
|
39
|
+
* @enum {string}
|
|
40
|
+
*/
|
|
41
|
+
const States = {
|
|
42
|
+
CLOSED: 'CLOSED', // Normal operation
|
|
43
|
+
OPEN: 'OPEN', // Failing, reject immediately
|
|
44
|
+
HALF_OPEN: 'HALF_OPEN' // Testing if recovered
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Circuit Breaker class
|
|
49
|
+
*/
|
|
50
|
+
class CircuitBreaker {
|
|
51
|
+
/**
|
|
52
|
+
* Create a circuit breaker
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} [options={}] - Configuration options
|
|
55
|
+
* @param {number} [options.failureThreshold=5] - Number of consecutive failures before opening
|
|
56
|
+
* @param {number} [options.successThreshold=2] - Number of consecutive successes to close from half-open
|
|
57
|
+
* @param {number} [options.timeout=60000] - Time in ms to wait before transitioning from OPEN to HALF_OPEN
|
|
58
|
+
* @param {number} [options.halfOpenTimeout=30000] - Reserved for future use
|
|
59
|
+
*/
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
// Configuration with defaults
|
|
62
|
+
this.failureThreshold = options.failureThreshold || 5;
|
|
63
|
+
this.successThreshold = options.successThreshold || 2;
|
|
64
|
+
this.timeout = options.timeout !== undefined ? options.timeout : 60000;
|
|
65
|
+
this.halfOpenTimeout = options.halfOpenTimeout || 30000;
|
|
66
|
+
|
|
67
|
+
// State tracking
|
|
68
|
+
this.state = States.CLOSED;
|
|
69
|
+
this.failureCount = 0;
|
|
70
|
+
this.successCount = 0;
|
|
71
|
+
this.nextAttempt = Date.now(); // Time when we can retry after OPEN
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Execute a function with circuit breaker protection
|
|
76
|
+
*
|
|
77
|
+
* @param {Function} fn - Async function to execute
|
|
78
|
+
* @returns {Promise<*>} Result of the function
|
|
79
|
+
* @throws {Error} Circuit breaker error or function error
|
|
80
|
+
*/
|
|
81
|
+
async execute(fn) {
|
|
82
|
+
// Check if circuit is OPEN
|
|
83
|
+
if (this.state === States.OPEN) {
|
|
84
|
+
if (Date.now() < this.nextAttempt) {
|
|
85
|
+
// Still within timeout period, reject immediately
|
|
86
|
+
throw new Error('Circuit breaker is OPEN');
|
|
87
|
+
}
|
|
88
|
+
// Timeout expired, transition to HALF_OPEN
|
|
89
|
+
this.state = States.HALF_OPEN;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Execute the function
|
|
93
|
+
try {
|
|
94
|
+
const result = await fn();
|
|
95
|
+
this._onSuccess();
|
|
96
|
+
return result;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this._onFailure();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Handle successful execution
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
_onSuccess() {
|
|
108
|
+
// Reset failure count on any success
|
|
109
|
+
this.failureCount = 0;
|
|
110
|
+
|
|
111
|
+
if (this.state === States.HALF_OPEN) {
|
|
112
|
+
// In HALF_OPEN state, count successes
|
|
113
|
+
this.successCount++;
|
|
114
|
+
|
|
115
|
+
// If we've reached the success threshold, close the circuit
|
|
116
|
+
if (this.successCount >= this.successThreshold) {
|
|
117
|
+
this.state = States.CLOSED;
|
|
118
|
+
this.successCount = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// In CLOSED state, success count is not tracked (stays 0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle failed execution
|
|
126
|
+
* @private
|
|
127
|
+
*/
|
|
128
|
+
_onFailure() {
|
|
129
|
+
// Reset success count on any failure
|
|
130
|
+
this.successCount = 0;
|
|
131
|
+
|
|
132
|
+
// Increment failure count (only in CLOSED or HALF_OPEN)
|
|
133
|
+
if (this.state !== States.OPEN) {
|
|
134
|
+
this.failureCount++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if we should open the circuit
|
|
138
|
+
if (this.failureCount >= this.failureThreshold) {
|
|
139
|
+
this.state = States.OPEN;
|
|
140
|
+
this.nextAttempt = Date.now() + this.timeout;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reset circuit breaker to initial state
|
|
146
|
+
* Useful for manual recovery or testing
|
|
147
|
+
*/
|
|
148
|
+
reset() {
|
|
149
|
+
this.state = States.CLOSED;
|
|
150
|
+
this.failureCount = 0;
|
|
151
|
+
this.successCount = 0;
|
|
152
|
+
this.nextAttempt = Date.now();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get current circuit breaker state
|
|
157
|
+
*
|
|
158
|
+
* @returns {string} Current state (CLOSED, OPEN, or HALF_OPEN)
|
|
159
|
+
*/
|
|
160
|
+
getState() {
|
|
161
|
+
return this.state;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { CircuitBreaker, States };
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Encryption utility for secure API key storage
|
|
3
|
+
* Implements AES-256-CBC encryption with PBKDF2 key derivation
|
|
4
|
+
*
|
|
5
|
+
* Based on Context7 documentation patterns:
|
|
6
|
+
* - node-config: Hierarchical configuration patterns
|
|
7
|
+
* - Node.js crypto: AES-256-CBC, PBKDF2, scrypt for encryption
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const encryption = new Encryption();
|
|
11
|
+
* const encrypted = encryption.encrypt('my-api-key', 'password');
|
|
12
|
+
* const decrypted = encryption.decrypt(encrypted, 'password');
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encryption utility class for API keys and sensitive data
|
|
19
|
+
*/
|
|
20
|
+
class Encryption {
|
|
21
|
+
constructor() {
|
|
22
|
+
// Algorithm configuration
|
|
23
|
+
this.algorithm = 'aes-256-cbc';
|
|
24
|
+
this.keyLength = 32; // 256 bits
|
|
25
|
+
this.ivLength = 16; // 128 bits
|
|
26
|
+
this.saltLength = 32; // 256 bits
|
|
27
|
+
this.iterations = 100000; // PBKDF2 iterations
|
|
28
|
+
this.digest = 'sha512';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate cryptographically secure random salt
|
|
33
|
+
*
|
|
34
|
+
* @returns {Buffer} Random salt buffer (32 bytes)
|
|
35
|
+
*/
|
|
36
|
+
generateSalt() {
|
|
37
|
+
return crypto.randomBytes(this.saltLength);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate cryptographically secure random IV
|
|
42
|
+
*
|
|
43
|
+
* @returns {Buffer} Random IV buffer (16 bytes)
|
|
44
|
+
*/
|
|
45
|
+
generateIV() {
|
|
46
|
+
return crypto.randomBytes(this.ivLength);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Derive encryption key from password using PBKDF2
|
|
51
|
+
*
|
|
52
|
+
* @param {string} password - Master password
|
|
53
|
+
* @param {Buffer} salt - Salt for key derivation
|
|
54
|
+
* @returns {Buffer} Derived key (32 bytes)
|
|
55
|
+
* @throws {Error} If password or salt is invalid
|
|
56
|
+
*/
|
|
57
|
+
deriveKey(password, salt) {
|
|
58
|
+
// Validate inputs
|
|
59
|
+
if (!password || typeof password !== 'string' || password.length === 0) {
|
|
60
|
+
throw new Error('Password is required');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!Buffer.isBuffer(salt)) {
|
|
64
|
+
throw new Error('Salt must be a Buffer');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Derive key using PBKDF2
|
|
68
|
+
return crypto.pbkdf2Sync(
|
|
69
|
+
password,
|
|
70
|
+
salt,
|
|
71
|
+
this.iterations,
|
|
72
|
+
this.keyLength,
|
|
73
|
+
this.digest
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Encrypt plaintext using AES-256-CBC
|
|
79
|
+
*
|
|
80
|
+
* @param {string} plaintext - Data to encrypt
|
|
81
|
+
* @param {string} password - Master password
|
|
82
|
+
* @returns {Object} Encrypted data object
|
|
83
|
+
* @returns {string} returns.encrypted - Base64-encoded encrypted data
|
|
84
|
+
* @returns {string} returns.iv - Base64-encoded IV
|
|
85
|
+
* @returns {string} returns.salt - Base64-encoded salt
|
|
86
|
+
* @returns {string} returns.algorithm - Encryption algorithm used
|
|
87
|
+
* @throws {Error} If plaintext or password is invalid
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* const result = encryption.encrypt('my-api-key', 'password');
|
|
91
|
+
* // {
|
|
92
|
+
* // encrypted: 'base64string',
|
|
93
|
+
* // iv: 'base64string',
|
|
94
|
+
* // salt: 'base64string',
|
|
95
|
+
* // algorithm: 'aes-256-cbc'
|
|
96
|
+
* // }
|
|
97
|
+
*/
|
|
98
|
+
encrypt(plaintext, password) {
|
|
99
|
+
// Validate inputs
|
|
100
|
+
if (plaintext === null || plaintext === undefined) {
|
|
101
|
+
throw new Error('Plaintext is required');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!password || typeof password !== 'string' || password.length === 0) {
|
|
105
|
+
throw new Error('Password is required');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Convert plaintext to string if needed
|
|
109
|
+
const plaintextStr = String(plaintext);
|
|
110
|
+
|
|
111
|
+
// Generate random salt and IV
|
|
112
|
+
const salt = this.generateSalt();
|
|
113
|
+
const iv = this.generateIV();
|
|
114
|
+
|
|
115
|
+
// Derive key from password
|
|
116
|
+
const key = this.deriveKey(password, salt);
|
|
117
|
+
|
|
118
|
+
// Create cipher
|
|
119
|
+
const cipher = crypto.createCipheriv(this.algorithm, key, iv);
|
|
120
|
+
|
|
121
|
+
// Encrypt data
|
|
122
|
+
let encrypted = cipher.update(plaintextStr, 'utf8', 'base64');
|
|
123
|
+
encrypted += cipher.final('base64');
|
|
124
|
+
|
|
125
|
+
// Return encrypted data object
|
|
126
|
+
return {
|
|
127
|
+
encrypted,
|
|
128
|
+
iv: iv.toString('base64'),
|
|
129
|
+
salt: salt.toString('base64'),
|
|
130
|
+
algorithm: this.algorithm
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decrypt encrypted data using AES-256-CBC
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} encryptedData - Encrypted data object from encrypt()
|
|
138
|
+
* @param {string} encryptedData.encrypted - Base64-encoded encrypted data
|
|
139
|
+
* @param {string} encryptedData.iv - Base64-encoded IV
|
|
140
|
+
* @param {string} encryptedData.salt - Base64-encoded salt
|
|
141
|
+
* @param {string} encryptedData.algorithm - Encryption algorithm
|
|
142
|
+
* @param {string} password - Master password
|
|
143
|
+
* @returns {string} Decrypted plaintext
|
|
144
|
+
* @throws {Error} If data is invalid or password is wrong
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* const decrypted = encryption.decrypt(encryptedData, 'password');
|
|
148
|
+
*/
|
|
149
|
+
decrypt(encryptedData, password) {
|
|
150
|
+
// Validate inputs
|
|
151
|
+
if (!encryptedData || typeof encryptedData !== 'object') {
|
|
152
|
+
throw new Error('Encrypted data must be an object');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!encryptedData.encrypted) {
|
|
156
|
+
throw new Error('Encrypted data is required');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!encryptedData.iv) {
|
|
160
|
+
throw new Error('IV is required');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!encryptedData.salt) {
|
|
164
|
+
throw new Error('Salt is required');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!encryptedData.algorithm || encryptedData.algorithm !== this.algorithm) {
|
|
168
|
+
throw new Error('Invalid algorithm');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!password || typeof password !== 'string' || password.length === 0) {
|
|
172
|
+
throw new Error('Password is required');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Parse base64-encoded values
|
|
177
|
+
const salt = Buffer.from(encryptedData.salt, 'base64');
|
|
178
|
+
const iv = Buffer.from(encryptedData.iv, 'base64');
|
|
179
|
+
|
|
180
|
+
// Derive key from password
|
|
181
|
+
const key = this.deriveKey(password, salt);
|
|
182
|
+
|
|
183
|
+
// Create decipher
|
|
184
|
+
const decipher = crypto.createDecipheriv(this.algorithm, key, iv);
|
|
185
|
+
|
|
186
|
+
// Decrypt data
|
|
187
|
+
let decrypted = decipher.update(encryptedData.encrypted, 'base64', 'utf8');
|
|
188
|
+
decrypted += decipher.final('utf8');
|
|
189
|
+
|
|
190
|
+
return decrypted;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Don't leak password in error messages
|
|
193
|
+
if (error.message.includes('bad decrypt')) {
|
|
194
|
+
throw new Error('Decryption failed - wrong password or corrupted data');
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = Encryption;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RateLimiter
|
|
3
|
+
*
|
|
4
|
+
* Token Bucket Algorithm implementation for rate limiting.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Token bucket with configurable capacity
|
|
8
|
+
* - Multiple time windows (second, minute, hour, day)
|
|
9
|
+
* - Burst support (bucket size > rate)
|
|
10
|
+
* - Async wait or immediate failure modes
|
|
11
|
+
* - In-memory storage (no dependencies)
|
|
12
|
+
* - High precision (millisecond accuracy)
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* const limiter = new RateLimiter({
|
|
16
|
+
* tokensPerInterval: 60,
|
|
17
|
+
* interval: 'minute',
|
|
18
|
+
* bucketSize: 100 // Allow burst
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // Async - waits if needed
|
|
22
|
+
* await limiter.removeTokens(1);
|
|
23
|
+
*
|
|
24
|
+
* // Sync - immediate response
|
|
25
|
+
* if (limiter.tryRemoveTokens(1)) {
|
|
26
|
+
* // proceed
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* // fireImmediately mode for HTTP 429
|
|
30
|
+
* const limiter2 = new RateLimiter({
|
|
31
|
+
* tokensPerInterval: 100,
|
|
32
|
+
* interval: 'hour',
|
|
33
|
+
* fireImmediately: true
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* const remaining = await limiter2.removeTokens(1);
|
|
37
|
+
* if (remaining < 0) {
|
|
38
|
+
* res.status(429).send('Rate limit exceeded');
|
|
39
|
+
* }
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
class RateLimiter {
|
|
43
|
+
/**
|
|
44
|
+
* Create a new RateLimiter
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} options - Configuration options
|
|
47
|
+
* @param {number} options.tokensPerInterval - Number of tokens to add per interval (default: 60)
|
|
48
|
+
* @param {string|number} options.interval - Time interval ('second', 'minute', 'hour', 'day') or milliseconds (default: 'minute')
|
|
49
|
+
* @param {number} options.bucketSize - Maximum tokens in bucket (default: tokensPerInterval)
|
|
50
|
+
* @param {boolean} options.fireImmediately - Don't wait, return negative on exceeded (default: false)
|
|
51
|
+
*/
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
// Validate and set tokensPerInterval
|
|
54
|
+
this.tokensPerInterval = options.tokensPerInterval !== undefined
|
|
55
|
+
? options.tokensPerInterval
|
|
56
|
+
: 60;
|
|
57
|
+
|
|
58
|
+
if (typeof this.tokensPerInterval !== 'number' || this.tokensPerInterval <= 0) {
|
|
59
|
+
throw new Error('tokensPerInterval must be greater than 0');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse and validate interval
|
|
63
|
+
this.interval = this._parseInterval(
|
|
64
|
+
options.interval !== undefined ? options.interval : 'minute'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (typeof this.interval !== 'number' || this.interval <= 0) {
|
|
68
|
+
throw new Error('interval must be greater than 0');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate and set bucket size
|
|
72
|
+
this.bucketSize = options.bucketSize !== undefined
|
|
73
|
+
? options.bucketSize
|
|
74
|
+
: this.tokensPerInterval;
|
|
75
|
+
|
|
76
|
+
if (typeof this.bucketSize !== 'number' || this.bucketSize <= 0) {
|
|
77
|
+
throw new Error('bucketSize must be greater than 0');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Set fireImmediately mode
|
|
81
|
+
this.fireImmediately = options.fireImmediately === true;
|
|
82
|
+
|
|
83
|
+
// Initialize token bucket
|
|
84
|
+
this.tokens = this.bucketSize;
|
|
85
|
+
this.lastRefill = Date.now();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove tokens from the bucket (async)
|
|
90
|
+
*
|
|
91
|
+
* Waits if insufficient tokens (unless fireImmediately=true)
|
|
92
|
+
*
|
|
93
|
+
* @param {number} count - Number of tokens to remove (default: 1)
|
|
94
|
+
* @returns {Promise<number>} Remaining tokens after removal
|
|
95
|
+
* @throws {Error} If count exceeds bucket size
|
|
96
|
+
*/
|
|
97
|
+
async removeTokens(count = 1) {
|
|
98
|
+
// fireImmediately mode: allow any count, can go negative
|
|
99
|
+
if (!this.fireImmediately && count > this.bucketSize) {
|
|
100
|
+
throw new Error(`Requested ${count} tokens exceeds bucket size ${this.bucketSize}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Refill tokens based on time passed
|
|
104
|
+
this._refill();
|
|
105
|
+
|
|
106
|
+
// fireImmediately mode: return immediately, even if negative
|
|
107
|
+
if (this.fireImmediately) {
|
|
108
|
+
this.tokens -= count;
|
|
109
|
+
return this.tokens;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wait mode: wait until sufficient tokens available
|
|
113
|
+
while (this.tokens < count) {
|
|
114
|
+
const tokensNeeded = count - this.tokens;
|
|
115
|
+
const timeToWait = (tokensNeeded / this.tokensPerInterval) * this.interval;
|
|
116
|
+
|
|
117
|
+
await this._delay(timeToWait);
|
|
118
|
+
this._refill();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.tokens -= count;
|
|
122
|
+
return this.tokens;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Try to remove tokens (sync, non-blocking)
|
|
127
|
+
*
|
|
128
|
+
* Returns immediately without waiting
|
|
129
|
+
*
|
|
130
|
+
* @param {number} count - Number of tokens to remove (default: 1)
|
|
131
|
+
* @returns {boolean} True if tokens were removed, false if insufficient
|
|
132
|
+
*/
|
|
133
|
+
tryRemoveTokens(count = 1) {
|
|
134
|
+
this._refill();
|
|
135
|
+
|
|
136
|
+
if (this.tokens >= count) {
|
|
137
|
+
this.tokens -= count;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get current number of tokens available
|
|
146
|
+
*
|
|
147
|
+
* @returns {number} Available tokens
|
|
148
|
+
*/
|
|
149
|
+
getTokensRemaining() {
|
|
150
|
+
return this.tokens;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reset the rate limiter to initial state
|
|
155
|
+
*
|
|
156
|
+
* Refills bucket to capacity and resets timer
|
|
157
|
+
*/
|
|
158
|
+
reset() {
|
|
159
|
+
this.tokens = this.bucketSize;
|
|
160
|
+
this.lastRefill = Date.now();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Refill tokens based on time passed
|
|
165
|
+
*
|
|
166
|
+
* Uses token bucket algorithm:
|
|
167
|
+
* - Calculate time since last refill
|
|
168
|
+
* - Add tokens proportional to time passed
|
|
169
|
+
* - Cap at bucket size
|
|
170
|
+
*
|
|
171
|
+
* @private
|
|
172
|
+
*/
|
|
173
|
+
_refill() {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const timePassed = now - this.lastRefill;
|
|
176
|
+
|
|
177
|
+
if (timePassed <= 0) {
|
|
178
|
+
return; // No time passed, no refill
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Calculate tokens to add based on time passed
|
|
182
|
+
const tokensToAdd = (timePassed / this.interval) * this.tokensPerInterval;
|
|
183
|
+
|
|
184
|
+
// Add tokens up to bucket size
|
|
185
|
+
this.tokens = Math.min(this.bucketSize, this.tokens + tokensToAdd);
|
|
186
|
+
|
|
187
|
+
// Update last refill time
|
|
188
|
+
this.lastRefill = now;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse interval string to milliseconds
|
|
193
|
+
*
|
|
194
|
+
* Supports: 'second', 'minute', 'hour', 'day' (case insensitive, with/without 's')
|
|
195
|
+
* Also accepts numeric milliseconds
|
|
196
|
+
*
|
|
197
|
+
* @param {string|number} interval - Interval to parse
|
|
198
|
+
* @returns {number} Interval in milliseconds
|
|
199
|
+
* @throws {Error} If interval is invalid
|
|
200
|
+
* @private
|
|
201
|
+
*/
|
|
202
|
+
_parseInterval(interval) {
|
|
203
|
+
// If already a number, return as-is
|
|
204
|
+
if (typeof interval === 'number') {
|
|
205
|
+
return interval;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// String parsing (case insensitive)
|
|
209
|
+
if (typeof interval === 'string') {
|
|
210
|
+
const normalized = interval.toLowerCase().replace(/s$/, ''); // Remove trailing 's'
|
|
211
|
+
|
|
212
|
+
switch (normalized) {
|
|
213
|
+
case 'second':
|
|
214
|
+
return 1000;
|
|
215
|
+
case 'minute':
|
|
216
|
+
return 60000;
|
|
217
|
+
case 'hour':
|
|
218
|
+
return 3600000;
|
|
219
|
+
case 'day':
|
|
220
|
+
return 86400000;
|
|
221
|
+
default:
|
|
222
|
+
throw new Error(`Invalid interval: ${interval}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error(`Invalid interval: ${interval}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Delay execution for specified milliseconds
|
|
231
|
+
*
|
|
232
|
+
* @param {number} ms - Milliseconds to delay
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
_delay(ms) {
|
|
237
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = RateLimiter;
|