UNPKG

@voilajsx/appkit

Version:

Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development

470 lines 17.9 kB
/** * Cloudflare R2 storage strategy with zero egress fees and automatic CDN integration * @module @voilajsx/appkit/storage * @file src/storage/strategies/r2.ts * * @llm-rule WHEN: App has CLOUDFLARE_R2_BUCKET env var for cost-effective distributed storage * @llm-rule AVOID: Manual R2 setup - this handles connection, CDN integration, and S3-compatible API * @llm-rule NOTE: Production-ready with zero egress fees, automatic CDN, S3-compatible operations */ /** * Cloudflare R2 storage strategy with cost optimization and CDN integration */ export class R2Strategy { config; r2Client = null; connected = false; bucket; accountId; cdnUrl; endpoint; /** * Creates R2 strategy with direct environment access (like auth pattern) * @llm-rule WHEN: Storage initialization with Cloudflare R2 environment variables detected * @llm-rule AVOID: Manual R2 configuration - environment detection handles Cloudflare specifics */ constructor(config) { this.config = config; if (!config.r2) { throw new Error('R2 storage configuration missing'); } this.bucket = config.r2.bucket; this.accountId = config.r2.accountId; this.cdnUrl = config.r2.cdnUrl; // Build R2 endpoint URL this.endpoint = `https://${this.accountId}.r2.cloudflarestorage.com`; } /** * Connects to Cloudflare R2 with automatic account validation * @llm-rule WHEN: Storage initialization or reconnection after failure * @llm-rule AVOID: Manual R2 client setup - this handles Cloudflare-specific configuration */ async connect() { if (this.connected) return; try { // Dynamic import for S3 client (R2 is S3-compatible) const { S3Client } = await import('@aws-sdk/client-s3'); const r2Config = this.config.r2; // Build R2 client configuration (S3-compatible) const clientConfig = { region: 'auto', // R2 uses 'auto' region endpoint: this.endpoint, credentials: { accessKeyId: r2Config.accessKeyId, secretAccessKey: r2Config.secretAccessKey, }, forcePathStyle: false, // R2 uses virtual-hosted-style maxAttempts: 3, // Built-in retry logic }; // Create R2 client using S3 SDK this.r2Client = new S3Client(clientConfig); // Test connection by checking if bucket exists await this.testConnection(); this.connected = true; if (this.config.environment.isDevelopment) { console.log(`✅ [AppKit] R2 storage connected (account: ${this.accountId}, bucket: ${this.bucket})`); if (this.cdnUrl) { console.log(`🚀 [AppKit] R2 CDN enabled: ${this.cdnUrl}`); } } } catch (error) { this.connected = false; this.r2Client = null; throw new Error(`R2 storage connection failed: ${error.message}`); } } /** * Tests R2 connection by checking bucket access */ async testConnection() { const { HeadBucketCommand } = await import('@aws-sdk/client-s3'); try { await this.r2Client.send(new HeadBucketCommand({ Bucket: this.bucket })); } catch (error) { if (error.name === 'NotFound') { throw new Error(`R2 bucket not found: ${this.bucket}`); } if (error.name === 'Forbidden') { throw new Error(`R2 bucket access denied: ${this.bucket}. Check API token permissions.`); } throw error; } } /** * Stores file to R2 with automatic content type detection and zero egress cost * @llm-rule WHEN: Uploading files to Cloudflare R2 for cost-effective storage * @llm-rule AVOID: Manual R2 operations - this handles R2-specific optimizations */ async put(key, data, options) { if (!this.connected || !this.r2Client) { throw new Error('R2 storage not connected'); } try { const { PutObjectCommand } = await import('@aws-sdk/client-s3'); // Build R2 put parameters (S3-compatible) const params = { Bucket: this.bucket, Key: key, Body: data, ContentType: options?.contentType || this.detectContentType(key, data), }; // Add R2-optimized parameters if (options?.cacheControl) { params.CacheControl = options.cacheControl; } else { // R2 default cache control for better CDN performance params.CacheControl = 'public, max-age=31536000'; // 1 year for static assets } if (options?.expires) { params.Expires = options.expires; } if (options?.metadata) { params.Metadata = options.metadata; } // Execute upload to R2 await this.r2Client.send(new PutObjectCommand(params)); if (this.config.environment.isDevelopment) { console.log(`☁️ [AppKit] R2 file uploaded: ${key} (${data.length} bytes, zero egress cost)`); } return key; } catch (error) { throw new Error(`R2 upload failed: ${error.message}`); } } /** * Retrieves file from R2 with streaming support and zero egress cost * @llm-rule WHEN: Downloading files from Cloudflare R2 * @llm-rule AVOID: Manual R2 operations - this handles streaming and cost optimization */ async get(key) { if (!this.connected || !this.r2Client) { throw new Error('R2 storage not connected'); } try { const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, Key: key, }; const result = await this.r2Client.send(new GetObjectCommand(params)); if (!result.Body) { throw new Error(`R2 object has no body: ${key}`); } // Convert stream to buffer const buffer = await this.streamToBuffer(result.Body); if (this.config.environment.isDevelopment) { console.log(`☁️ [AppKit] R2 file downloaded: ${key} (${buffer.length} bytes, zero egress cost)`); } return buffer; } catch (error) { if (error.name === 'NoSuchKey') { throw new Error(`File not found: ${key}`); } throw new Error(`R2 download failed: ${error.message}`); } } /** * Deletes file from R2 with confirmation * @llm-rule WHEN: Removing files from Cloudflare R2 storage * @llm-rule AVOID: Silent failures - this confirms deletion success */ async delete(key) { if (!this.connected || !this.r2Client) { console.error('R2 storage not connected'); return false; } try { const { DeleteObjectCommand } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, Key: key, }; await this.r2Client.send(new DeleteObjectCommand(params)); if (this.config.environment.isDevelopment) { console.log(`🗑️ [AppKit] R2 file deleted: ${key}`); } return true; } catch (error) { console.error(`[AppKit] R2 delete error for "${key}":`, error.message); return false; } } /** * Lists files with prefix filtering and R2-optimized pagination * @llm-rule WHEN: Browsing R2 files or implementing file managers * @llm-rule AVOID: Loading all objects - R2 has same limits as S3 */ async list(prefix = '') { if (!this.connected || !this.r2Client) { throw new Error('R2 storage not connected'); } try { const { ListObjectsV2Command } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, MaxKeys: 1000, // R2 limit same as S3 }; if (prefix) { params.Prefix = prefix; } const result = await this.r2Client.send(new ListObjectsV2Command(params)); const files = []; if (result.Contents) { for (const object of result.Contents) { if (object.Key) { files.push({ key: object.Key, size: object.Size || 0, lastModified: object.LastModified || new Date(), etag: object.ETag?.replace(/"/g, ''), // Remove quotes from ETag contentType: await this.getObjectContentType(object.Key), }); } } } if (this.config.environment.isDevelopment) { console.log(`☁️ [AppKit] R2 files listed: ${prefix}* (${files.length} files)`); } return files; } catch (error) { console.error(`[AppKit] R2 list error for prefix "${prefix}":`, error.message); return []; } } /** * Gets CDN or public URL for R2 object with automatic CDN detection * @llm-rule WHEN: Generating URLs for R2 file access with CDN optimization * @llm-rule AVOID: Hardcoded URLs - this handles CDN and R2-specific URLs */ url(key) { // Use custom CDN URL if configured (recommended for production) if (this.cdnUrl) { const baseUrl = this.cdnUrl.endsWith('/') ? this.cdnUrl : this.cdnUrl + '/'; const cleanKey = key.startsWith('/') ? key.slice(1) : key; return baseUrl + cleanKey; } // Use R2 public URL (has rate limits, CDN recommended) return `https://pub-${this.generatePublicHash()}.r2.dev/${key}`; } /** * Generates R2 public URL hash (simplified for demo) */ generatePublicHash() { // In production, this would be the actual R2 public URL hash // For now, we'll use a placeholder that works with the R2 endpoint return this.accountId.slice(0, 8); } /** * Generates signed URL for temporary R2 object access * @llm-rule WHEN: Creating temporary download/upload links for R2 objects * @llm-rule AVOID: Public URLs for private files - use signed URLs with expiration */ async signedUrl(key, expiresIn = 3600) { if (!this.connected || !this.r2Client) { throw new Error('R2 storage not connected'); } try { const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner'); const command = new GetObjectCommand({ Bucket: this.bucket, Key: key, }); const signedUrl = await getSignedUrl(this.r2Client, command, { expiresIn, }); if (this.config.environment.isDevelopment) { console.log(`🔐 [AppKit] R2 signed URL generated: ${key} (expires in ${expiresIn}s)`); } return signedUrl; } catch (error) { throw new Error(`R2 signed URL generation failed: ${error.message}`); } } /** * Checks if R2 object exists without downloading * @llm-rule WHEN: Validating R2 object existence efficiently * @llm-rule AVOID: Downloading objects just to check existence */ async exists(key) { if (!this.connected || !this.r2Client) { return false; } try { const { HeadObjectCommand } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, Key: key, }; await this.r2Client.send(new HeadObjectCommand(params)); return true; } catch (error) { if (error.name === 'NotFound' || error.name === 'NoSuchKey') { return false; } console.error(`[AppKit] R2 exists check error for "${key}":`, error.message); return false; } } /** * Copies R2 object efficiently using server-side copy (zero egress cost) * @llm-rule WHEN: Duplicating R2 objects without bandwidth costs * @llm-rule AVOID: Download and upload - R2 server-side copy has zero egress fees */ async copy(sourceKey, destKey) { if (!this.connected || !this.r2Client) { throw new Error('R2 storage not connected'); } try { const { CopyObjectCommand } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, Key: destKey, CopySource: `${this.bucket}/${sourceKey}`, }; await this.r2Client.send(new CopyObjectCommand(params)); if (this.config.environment.isDevelopment) { console.log(`☁️ [AppKit] R2 file copied: ${sourceKey}${destKey} (zero egress cost)`); } return destKey; } catch (error) { throw new Error(`R2 copy failed: ${error.message}`); } } /** * Disconnects R2 strategy gracefully * @llm-rule WHEN: App shutdown or storage cleanup * @llm-rule AVOID: Abrupt disconnection - graceful shutdown prevents connection issues */ async disconnect() { if (!this.connected) return; try { // R2 client doesn't need explicit disconnection (uses S3 SDK) this.connected = false; this.r2Client = null; if (this.config.environment.isDevelopment) { console.log(`👋 [AppKit] R2 storage strategy disconnected`); } } catch (error) { console.error(`[AppKit] R2 disconnect error:`, error.message); } } // Private helper methods /** * Converts readable stream to buffer */ async streamToBuffer(stream) { const chunks = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(chunk)); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }); } /** * Detects content type from file extension and buffer */ detectContentType(key, buffer) { // Get extension from key const ext = key.split('.').pop()?.toLowerCase(); // Common MIME types optimized for web delivery const mimeTypes = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'avif': 'image/avif', // Modern format support 'svg': 'image/svg+xml', 'pdf': 'application/pdf', 'txt': 'text/plain', 'json': 'application/json', 'csv': 'text/csv', 'zip': 'application/zip', 'mp4': 'video/mp4', 'webm': 'video/webm', 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'woff2': 'font/woff2', 'woff': 'font/woff', 'css': 'text/css', 'js': 'text/javascript', }; if (ext && mimeTypes[ext]) { return mimeTypes[ext]; } // Buffer-based detection for common formats const magic = buffer.subarray(0, 4); if (magic[0] === 0xFF && magic[1] === 0xD8 && magic[2] === 0xFF) { return 'image/jpeg'; } if (magic[0] === 0x89 && magic[1] === 0x50 && magic[2] === 0x4E && magic[3] === 0x47) { return 'image/png'; } if (magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46) { return 'image/gif'; } return 'application/octet-stream'; } /** * Gets content type for existing R2 object */ async getObjectContentType(key) { try { const { HeadObjectCommand } = await import('@aws-sdk/client-s3'); const params = { Bucket: this.bucket, Key: key, }; const result = await this.r2Client.send(new HeadObjectCommand(params)); return result.ContentType; } catch (error) { // If we can't get content type, return undefined return undefined; } } /** * Gets R2 connection info for debugging */ getConnectionInfo() { return { connected: this.connected, bucket: this.bucket, accountId: this.accountId, endpoint: this.endpoint, cdnEnabled: !!this.cdnUrl, zeroEgressFees: true, // R2's key advantage }; } /** * Gets R2-specific cost optimization info */ getCostInfo() { return { egressFees: 'Zero egress fees', storageClass: 'Hot storage with instant access', cdnIntegration: !!this.cdnUrl, recommendedFor: [ 'High-bandwidth applications', 'Media streaming', 'Global CDN delivery', 'Cost-sensitive workloads' ], }; } } //# sourceMappingURL=r2.js.map