@tamyla/clodo-framework 4.0.14 → 4.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.
@@ -180,6 +180,9 @@ export class BaseGenerator {
180
180
  const fullPath = path.join(this.servicePath, relativePath);
181
181
  const overwrite = options.overwrite !== false; // Default to true
182
182
 
183
+ // Debug: log the target file path for diagnosis
184
+ this.logger.info(`BaseGenerator: writing file -> ${fullPath}`);
185
+
183
186
  // Check if file exists and overwrite is disabled
184
187
  try {
185
188
  await fs.access(fullPath);
@@ -197,13 +200,35 @@ export class BaseGenerator {
197
200
  recursive: true
198
201
  });
199
202
 
200
- // Write file
201
- try {
202
- await fs.writeFile(fullPath, content, 'utf8');
203
- this.logger.info(`Generated: ${relativePath}`);
204
- } catch (error) {
205
- throw new Error(`Failed to write file '${relativePath}': ${error.message}`);
203
+ // Write file with retry logic for filesystem stability
204
+ const maxRetries = 3;
205
+ let lastError;
206
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
207
+ try {
208
+ this.logger.info(`BaseGenerator: attempt ${attempt} - writing to ${fullPath}`);
209
+ await fs.writeFile(fullPath, content, 'utf8');
210
+
211
+ // Add a small delay to let file system settle, especially on Windows
212
+ if (attempt === 1) {
213
+ await new Promise(resolve => setTimeout(resolve, 10));
214
+ }
215
+
216
+ // Verify the file was written successfully by checking it exists
217
+ await fs.access(fullPath, fs.constants.F_OK);
218
+ this.logger.info(`Generated: ${relativePath}`);
219
+ return; // Success
220
+ } catch (error) {
221
+ lastError = error;
222
+ this.logger.warn(`Write attempt ${attempt}/${maxRetries} failed for ${relativePath}: ${error.message}`);
223
+ if (attempt < maxRetries) {
224
+ // Wait before retry (exponential backoff)
225
+ await new Promise(resolve => setTimeout(resolve, 50 * attempt));
226
+ }
227
+ }
206
228
  }
229
+
230
+ // If all retries failed, throw the last error
231
+ throw new Error(`Failed to write and verify file '${relativePath}' after ${maxRetries} attempts: ${lastError.message}`);
207
232
  }
208
233
 
209
234
  /**
@@ -69,12 +69,22 @@ export class FileWriter {
69
69
  }
70
70
 
71
71
  // Ensure parent directory exists
72
- await this.ensureDirectory(path.dirname(fullPath));
72
+ const dirPath = path.dirname(fullPath);
73
+ await this.ensureDirectory(dirPath);
73
74
 
74
75
  // Write file atomically (write to temp file, then rename)
75
76
  const tempPath = `${fullPath}.tmp`;
76
77
  try {
78
+ // Write to temp file first
77
79
  await fs.writeFile(tempPath, content, encoding);
80
+
81
+ // Small delay to ensure file is fully written on Windows
82
+ await new Promise(resolve => setTimeout(resolve, 10));
83
+
84
+ // Verify temp file exists before rename (prevents ENOENT)
85
+ await fs.access(tempPath);
86
+
87
+ // Rename to final destination
78
88
  await fs.rename(tempPath, fullPath);
79
89
  this.writtenFiles.push(fullPath);
80
90
  return {
@@ -106,6 +116,8 @@ export class FileWriter {
106
116
  await fs.mkdir(dirPath, {
107
117
  recursive: true
108
118
  });
119
+ // Verify the directory was created
120
+ await fs.access(dirPath);
109
121
  } catch (error) {
110
122
  // Ignore error if directory already exists
111
123
  if (error.code !== 'EEXIST') {
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Service Client for Inter-Service Communication
3
+ * Provides circuit breaker, retry logic, and service discovery
4
+ */
5
+
6
+ import { CircuitBreaker } from '../utils/CircuitBreaker.js';
7
+
8
+ /**
9
+ * Service Client for inter-service communication
10
+ */
11
+ export class ServiceClient {
12
+ constructor(options = {}) {
13
+ this.options = {
14
+ timeout: options.timeout || 10000,
15
+ retries: options.retries || 3,
16
+ retryDelay: options.retryDelay || 1000,
17
+ circuitBreakerEnabled: options.circuitBreakerEnabled !== false,
18
+ serviceDiscovery: options.serviceDiscovery || this.defaultServiceDiscovery,
19
+ ...options
20
+ };
21
+
22
+ // Initialize circuit breaker if enabled
23
+ if (this.options.circuitBreakerEnabled) {
24
+ this.circuitBreaker = new CircuitBreaker({
25
+ failureThreshold: options.failureThreshold || 5,
26
+ recoveryTimeout: options.recoveryTimeout || 60000,
27
+ monitoringPeriod: options.monitoringPeriod || 10000
28
+ });
29
+ }
30
+
31
+ // Service URL cache
32
+ this.serviceCache = new Map();
33
+ this.cacheTimeout = options.cacheTimeout || 300000; // 5 minutes
34
+ }
35
+
36
+ /**
37
+ * Make a call to another service
38
+ * @param {string} serviceName - Name of the target service
39
+ * @param {string} endpoint - API endpoint path
40
+ * @param {Object} options - Call options
41
+ * @returns {Promise<Object>} Response data
42
+ */
43
+ async call(serviceName, endpoint, options = {}) {
44
+ const callOptions = {
45
+ method: options.method || 'GET',
46
+ headers: options.headers || {},
47
+ body: options.body,
48
+ timeout: options.timeout || this.options.timeout,
49
+ retries: options.retries || this.options.retries,
50
+ ...options
51
+ };
52
+
53
+ // Get service URL
54
+ const serviceUrl = await this.getServiceUrl(serviceName);
55
+ if (!serviceUrl) {
56
+ throw new Error(`Service ${serviceName} not found`);
57
+ }
58
+ const fullUrl = `${serviceUrl}${endpoint}`;
59
+
60
+ // Check circuit breaker
61
+ if (this.circuitBreaker && !this.circuitBreaker.canExecute(serviceName)) {
62
+ throw new Error(`Circuit breaker open for service ${serviceName}`);
63
+ }
64
+ let lastError;
65
+
66
+ // Retry logic
67
+ for (let attempt = 0; attempt <= callOptions.retries; attempt++) {
68
+ try {
69
+ const response = await this.makeRequest(fullUrl, callOptions);
70
+
71
+ // Record success for circuit breaker
72
+ if (this.circuitBreaker) {
73
+ this.circuitBreaker.recordSuccess(serviceName);
74
+ }
75
+ return response;
76
+ } catch (error) {
77
+ lastError = error;
78
+
79
+ // Record failure for circuit breaker
80
+ if (this.circuitBreaker) {
81
+ this.circuitBreaker.recordFailure(serviceName);
82
+ }
83
+
84
+ // Don't retry on the last attempt
85
+ if (attempt === callOptions.retries) {
86
+ break;
87
+ }
88
+
89
+ // Wait before retry
90
+ const delay = this.options.retryDelay * Math.pow(2, attempt); // Exponential backoff
91
+ await this.sleep(delay);
92
+ }
93
+ }
94
+ throw lastError;
95
+ }
96
+
97
+ /**
98
+ * Make an HTTP request with timeout
99
+ * @param {string} url - Request URL
100
+ * @param {Object} options - Request options
101
+ * @returns {Promise<Object>} Response data
102
+ */
103
+ async makeRequest(url, options) {
104
+ const controller = new AbortController();
105
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout);
106
+ try {
107
+ const fetchOptions = {
108
+ method: options.method,
109
+ headers: {
110
+ 'Content-Type': 'application/json',
111
+ ...options.headers
112
+ },
113
+ signal: controller.signal
114
+ };
115
+ if (options.body && typeof options.body === 'object') {
116
+ fetchOptions.body = JSON.stringify(options.body);
117
+ } else if (options.body) {
118
+ fetchOptions.body = options.body;
119
+ }
120
+ const response = await fetch(url, fetchOptions);
121
+ clearTimeout(timeoutId);
122
+ if (!response.ok) {
123
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
124
+ }
125
+ const contentType = response.headers.get('content-type');
126
+ if (contentType && contentType.includes('application/json')) {
127
+ return await response.json();
128
+ } else {
129
+ return await response.text();
130
+ }
131
+ } catch (error) {
132
+ clearTimeout(timeoutId);
133
+ if (error.name === 'AbortError') {
134
+ throw new Error(`Request timeout after ${options.timeout}ms`);
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get service URL using service discovery
142
+ * @param {string} serviceName - Name of the service
143
+ * @returns {Promise<string|null>} Service URL or null if not found
144
+ */
145
+ async getServiceUrl(serviceName) {
146
+ // Check cache first
147
+ const cached = this.serviceCache.get(serviceName);
148
+ if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
149
+ return cached.url;
150
+ }
151
+
152
+ // Use service discovery
153
+ const url = await this.options.serviceDiscovery(serviceName);
154
+ if (url) {
155
+ this.serviceCache.set(serviceName, {
156
+ url: url,
157
+ timestamp: Date.now()
158
+ });
159
+ }
160
+ return url;
161
+ }
162
+
163
+ /**
164
+ * Default service discovery implementation
165
+ * Uses environment variables for service URLs
166
+ * @param {string} serviceName - Name of the service
167
+ * @returns {Promise<string|null>} Service URL
168
+ */
169
+ async defaultServiceDiscovery(serviceName) {
170
+ // Try environment-based discovery
171
+ const envKey = `SERVICE_URL_${serviceName.toUpperCase().replace(/-/g, '_')}`;
172
+ const envUrl = process.env[envKey];
173
+ if (envUrl) {
174
+ return envUrl;
175
+ }
176
+
177
+ // Try development localhost patterns
178
+ if (process.env.NODE_ENV === 'development') {
179
+ const localhostUrl = `http://localhost:8787`; // Default Cloudflare Workers dev port
180
+ return localhostUrl;
181
+ }
182
+
183
+ // Try production Cloudflare Workers pattern
184
+ const productionUrl = `https://${serviceName}.your-domain.workers.dev`;
185
+ return productionUrl;
186
+ }
187
+
188
+ /**
189
+ * Health check for a service
190
+ * @param {string} serviceName - Name of the service
191
+ * @returns {Promise<boolean>} True if service is healthy
192
+ */
193
+ async healthCheck(serviceName) {
194
+ try {
195
+ await this.call(serviceName, '/health', {
196
+ timeout: 5000,
197
+ retries: 0
198
+ });
199
+ return true;
200
+ } catch (error) {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Get circuit breaker status
207
+ * @param {string} serviceName - Name of the service
208
+ * @returns {Object} Circuit breaker status
209
+ */
210
+ getCircuitBreakerStatus(serviceName) {
211
+ if (!this.circuitBreaker) {
212
+ return {
213
+ enabled: false
214
+ };
215
+ }
216
+ return this.circuitBreaker.getStatus(serviceName);
217
+ }
218
+
219
+ /**
220
+ * Clear service URL cache
221
+ * @param {string} serviceName - Specific service to clear (optional)
222
+ */
223
+ clearCache(serviceName = null) {
224
+ if (serviceName) {
225
+ this.serviceCache.delete(serviceName);
226
+ } else {
227
+ this.serviceCache.clear();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Sleep utility
233
+ * @param {number} ms - Milliseconds to sleep
234
+ * @returns {Promise<void>}
235
+ */
236
+ sleep(ms) {
237
+ return new Promise(resolve => setTimeout(resolve, ms));
238
+ }
239
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Circuit Breaker Implementation
3
+ * Prevents cascading failures in distributed systems
4
+ */
5
+
6
+ /**
7
+ * Circuit Breaker for resilient service communication
8
+ */
9
+ export class CircuitBreaker {
10
+ constructor(options = {}) {
11
+ this.options = {
12
+ failureThreshold: options.failureThreshold || 5,
13
+ recoveryTimeout: options.recoveryTimeout || 60000,
14
+ monitoringPeriod: options.monitoringPeriod || 10000,
15
+ ...options
16
+ };
17
+
18
+ // State: 'closed', 'open', 'half-open'
19
+ this.state = 'closed';
20
+
21
+ // Failure tracking
22
+ this.failureCount = 0;
23
+ this.lastFailureTime = null;
24
+ this.nextAttemptTime = null;
25
+
26
+ // Success tracking for half-open state
27
+ this.successCount = 0;
28
+ this.requiredSuccessCount = options.requiredSuccessCount || 3;
29
+
30
+ // Service-specific tracking
31
+ this.services = new Map();
32
+ }
33
+
34
+ /**
35
+ * Check if an operation can be executed
36
+ * @param {string} serviceName - Name of the service
37
+ * @returns {boolean} True if operation can proceed
38
+ */
39
+ canExecute(serviceName) {
40
+ const serviceState = this.getServiceState(serviceName);
41
+ switch (serviceState.state) {
42
+ case 'closed':
43
+ return true;
44
+ case 'open':
45
+ if (Date.now() >= serviceState.nextAttemptTime) {
46
+ // Transition to half-open
47
+ this.setServiceState(serviceName, 'half-open');
48
+ return true;
49
+ }
50
+ return false;
51
+ case 'half-open':
52
+ return true;
53
+ default:
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Record a successful operation
60
+ * @param {string} serviceName - Name of the service
61
+ */
62
+ recordSuccess(serviceName) {
63
+ const serviceState = this.getServiceState(serviceName);
64
+ switch (serviceState.state) {
65
+ case 'half-open':
66
+ serviceState.successCount++;
67
+ if (serviceState.successCount >= this.requiredSuccessCount) {
68
+ // Transition back to closed
69
+ this.setServiceState(serviceName, 'closed');
70
+ }
71
+ break;
72
+ case 'closed':
73
+ // Reset failure count on success
74
+ serviceState.failureCount = 0;
75
+ serviceState.lastFailureTime = null;
76
+ break;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Record a failed operation
82
+ * @param {string} serviceName - Name of the service
83
+ */
84
+ recordFailure(serviceName) {
85
+ const serviceState = this.getServiceState(serviceName);
86
+ serviceState.failureCount++;
87
+ serviceState.lastFailureTime = Date.now();
88
+ if (serviceState.failureCount >= this.options.failureThreshold) {
89
+ // Transition to open
90
+ this.setServiceState(serviceName, 'open');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Get the current state for a service
96
+ * @param {string} serviceName - Name of the service
97
+ * @returns {Object} Service state information
98
+ */
99
+ getStatus(serviceName) {
100
+ const serviceState = this.getServiceState(serviceName);
101
+ return {
102
+ service: serviceName,
103
+ state: serviceState.state,
104
+ failureCount: serviceState.failureCount,
105
+ lastFailureTime: serviceState.lastFailureTime,
106
+ nextAttemptTime: serviceState.nextAttemptTime,
107
+ successCount: serviceState.successCount,
108
+ canExecute: this.canExecute(serviceName)
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Get service state (internal)
114
+ * @param {string} serviceName - Name of the service
115
+ * @returns {Object} Service state
116
+ */
117
+ getServiceState(serviceName) {
118
+ if (!this.services.has(serviceName)) {
119
+ this.services.set(serviceName, {
120
+ state: 'closed',
121
+ failureCount: 0,
122
+ lastFailureTime: null,
123
+ nextAttemptTime: null,
124
+ successCount: 0
125
+ });
126
+ }
127
+ return this.services.get(serviceName);
128
+ }
129
+
130
+ /**
131
+ * Set service state (internal)
132
+ * @param {string} serviceName - Name of the service
133
+ * @param {string} state - New state
134
+ */
135
+ setServiceState(serviceName, state) {
136
+ const serviceState = this.getServiceState(serviceName);
137
+ serviceState.state = state;
138
+ if (state === 'open') {
139
+ serviceState.nextAttemptTime = Date.now() + this.options.recoveryTimeout;
140
+ serviceState.successCount = 0;
141
+ } else if (state === 'half-open') {
142
+ serviceState.successCount = 0;
143
+ } else if (state === 'closed') {
144
+ serviceState.failureCount = 0;
145
+ serviceState.lastFailureTime = null;
146
+ serviceState.nextAttemptTime = null;
147
+ serviceState.successCount = 0;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Manually reset a service's circuit breaker
153
+ * @param {string} serviceName - Name of the service
154
+ */
155
+ reset(serviceName) {
156
+ this.setServiceState(serviceName, 'closed');
157
+ }
158
+
159
+ /**
160
+ * Force a service into open state
161
+ * @param {string} serviceName - Name of the service
162
+ */
163
+ trip(serviceName) {
164
+ this.setServiceState(serviceName, 'open');
165
+ }
166
+
167
+ /**
168
+ * Get all service statuses
169
+ * @returns {Object} All service statuses
170
+ */
171
+ getAllStatuses() {
172
+ const statuses = {};
173
+ for (const [serviceName] of this.services) {
174
+ statuses[serviceName] = this.getStatus(serviceName);
175
+ }
176
+ return statuses;
177
+ }
178
+
179
+ /**
180
+ * Clean up old service states (optional maintenance)
181
+ */
182
+ cleanup() {
183
+ const now = Date.now();
184
+ const maxAge = this.options.monitoringPeriod * 10; // 10 monitoring periods
185
+
186
+ for (const [serviceName, serviceState] of this.services.entries()) {
187
+ if (serviceState.lastFailureTime && now - serviceState.lastFailureTime > maxAge && serviceState.state === 'closed') {
188
+ this.services.delete(serviceName);
189
+ }
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Environment Variable Validator
3
+ * Validates required environment variables at runtime
4
+ */
5
+
6
+ /**
7
+ * Environment Variable Validation
8
+ */
9
+ export class EnvironmentValidator {
10
+ /**
11
+ * Common required environment variables for Cloudflare services
12
+ */
13
+ static COMMON_REQUIRED_VARS = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
14
+
15
+ /**
16
+ * Validate that all required environment variables are set
17
+ * @param {string[]} requiredVars - Array of required environment variable names
18
+ * @param {Object} env - Environment object (defaults to process.env)
19
+ * @throws {Error} Throws descriptive error if any required vars are missing
20
+ */
21
+ static validateRequired(requiredVars, env = process.env) {
22
+ const missing = [];
23
+ const empty = [];
24
+ for (const varName of requiredVars) {
25
+ const value = env[varName];
26
+ if (value === undefined || value === null) {
27
+ missing.push(varName);
28
+ } else if (typeof value === 'string' && value.trim() === '') {
29
+ empty.push(varName);
30
+ }
31
+ }
32
+ if (missing.length > 0 || empty.length > 0) {
33
+ const errors = [];
34
+ if (missing.length > 0) {
35
+ errors.push(`Missing required environment variables: ${missing.join(', ')}`);
36
+ }
37
+ if (empty.length > 0) {
38
+ errors.push(`Empty required environment variables: ${empty.join(', ')}`);
39
+ }
40
+ const errorMessage = `Environment validation failed:\n${errors.join('\n')}\n\nPlease set these variables in your wrangler.toml or deployment environment.`;
41
+ throw new Error(errorMessage);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Validate Cloudflare-specific environment variables
47
+ * @param {Object} env - Environment object (defaults to process.env)
48
+ * @throws {Error} Throws error if Cloudflare vars are missing
49
+ */
50
+ static validateCloudflareVars(env = process.env) {
51
+ const cloudflareVars = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
52
+ this.validateRequired(cloudflareVars, env);
53
+ }
54
+
55
+ /**
56
+ * Validate service-specific environment variables
57
+ * @param {string[]} serviceVars - Service-specific required variables
58
+ * @param {Object} env - Environment object (defaults to process.env)
59
+ * @throws {Error} Throws error if service vars are missing
60
+ */
61
+ static validateServiceVars(serviceVars, env = process.env) {
62
+ this.validateRequired(serviceVars, env);
63
+ }
64
+
65
+ /**
66
+ * Validate all environment variables for a service
67
+ * @param {Object} config - Service configuration with required vars
68
+ * @param {Object} env - Environment object (defaults to process.env)
69
+ * @throws {Error} Throws error if any vars are missing
70
+ */
71
+ static validateServiceConfig(config, env = process.env) {
72
+ const requiredVars = [];
73
+
74
+ // Add common Cloudflare vars
75
+ requiredVars.push(...this.COMMON_REQUIRED_VARS);
76
+
77
+ // Add service-specific vars from config
78
+ if (config.requiredEnvironmentVars) {
79
+ requiredVars.push(...config.requiredEnvironmentVars);
80
+ }
81
+
82
+ // Add feature-specific vars
83
+ if (config.features) {
84
+ if (config.features.d1) {
85
+ // D1 might require additional vars, but usually handled by wrangler
86
+ }
87
+ if (config.features.kv) {
88
+ // KV vars are usually handled by wrangler binding
89
+ }
90
+ if (config.features.r2) {
91
+ // R2 vars are usually handled by wrangler binding
92
+ }
93
+ }
94
+ this.validateRequired(requiredVars, env);
95
+ }
96
+
97
+ /**
98
+ * Get a summary of environment variable status
99
+ * @param {string[]} vars - Variables to check
100
+ * @param {Object} env - Environment object (defaults to process.env)
101
+ * @returns {Object} Status summary
102
+ */
103
+ static getStatus(vars, env = process.env) {
104
+ const status = {
105
+ total: vars.length,
106
+ set: 0,
107
+ missing: [],
108
+ empty: []
109
+ };
110
+ for (const varName of vars) {
111
+ const value = env[varName];
112
+ if (value === undefined || value === null) {
113
+ status.missing.push(varName);
114
+ } else if (typeof value === 'string' && value.trim() === '') {
115
+ status.empty.push(varName);
116
+ } else {
117
+ status.set++;
118
+ }
119
+ }
120
+ status.valid = status.missing.length === 0 && status.empty.length === 0;
121
+ return status;
122
+ }
123
+
124
+ /**
125
+ * Log environment variable validation results
126
+ * @param {string[]} vars - Variables that were checked
127
+ * @param {Object} status - Status from getStatus()
128
+ */
129
+ static logValidationResults(vars, status) {
130
+ console.log('\nšŸŒ Environment Variable Validation');
131
+ console.log('='.repeat(40));
132
+ if (status.valid) {
133
+ console.log(`āœ… PASSED - All ${status.total} variables are set`);
134
+ } else {
135
+ console.log(`āŒ ISSUES - ${status.missing.length + status.empty.length} problems found`);
136
+ if (status.missing.length > 0) {
137
+ console.log('\nšŸ“­ MISSING VARIABLES:');
138
+ status.missing.forEach(variable => console.log(` - ${variable}`));
139
+ }
140
+ if (status.empty.length > 0) {
141
+ console.log('\nšŸ“ EMPTY VARIABLES:');
142
+ status.empty.forEach(variable => console.log(` - ${variable}`));
143
+ }
144
+ console.log('\nšŸ’” Set these in your wrangler.toml [vars] section or deployment environment');
145
+ }
146
+ }
147
+ }