UNPKG

@voilajsx/appkit

Version:

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

439 lines 16.2 kB
/** * Core storage class with automatic strategy selection and ultra-simple API * @module @voilajsx/appkit/storage * @file src/storage/storage.ts * * @llm-rule WHEN: Building apps that need file storage with automatic Local/S3/R2 selection * @llm-rule AVOID: Using directly - always get instance via storageClass.get() * @llm-rule NOTE: Auto-detects Local vs S3 vs R2 based on environment variables */ import { LocalStrategy } from './strategies/local.js'; import { S3Strategy } from './strategies/s3.js'; import { R2Strategy } from './strategies/r2.js'; /** * Storage class with automatic strategy selection and ultra-simple API */ export class StorageClass { config; strategy; connected = false; constructor(config) { this.config = config; this.strategy = this.createStrategy(); } /** * Creates appropriate strategy based on configuration * @llm-rule WHEN: Storage initialization - selects Local, S3, or R2 based on environment * @llm-rule AVOID: Manual strategy creation - configuration handles strategy selection */ createStrategy() { switch (this.config.strategy) { case 'local': return new LocalStrategy(this.config); case 's3': return new S3Strategy(this.config); case 'r2': return new R2Strategy(this.config); default: throw new Error(`Unknown storage strategy: ${this.config.strategy}`); } } /** * Connects to storage backend with automatic setup * @llm-rule WHEN: Storage initialization or reconnection after failure * @llm-rule AVOID: Manual connection management - this handles connection state */ async connect() { if (this.connected) return; try { // Strategy-specific connection (S3/R2 connect, Local creates dirs) if ('connect' in this.strategy) { await this.strategy.connect(); } this.connected = true; if (this.config.environment.isDevelopment) { console.log(`✅ [AppKit] Storage system connected using ${this.config.strategy} strategy`); } } catch (error) { console.error(`❌ [AppKit] Storage connection failed:`, error.message); throw error; } } /** * Disconnects from storage backend gracefully * @llm-rule WHEN: App shutdown or storage cleanup * @llm-rule AVOID: Abrupt disconnection - graceful shutdown prevents data loss */ async disconnect() { if (!this.connected) return; try { await this.strategy.disconnect(); this.connected = false; if (this.config.environment.isDevelopment) { console.log(`👋 [AppKit] Storage system disconnected`); } } catch (error) { console.error(`⚠️ [AppKit] Storage disconnect error:`, error.message); } } /** * Stores file with automatic validation and type detection * @llm-rule WHEN: Uploading files to storage backend * @llm-rule AVOID: Manual file validation - this handles size, type, and path validation * @llm-rule NOTE: Returns the key/path where file was stored */ async put(key, data, options) { this.validateKey(key); await this.ensureConnected(); try { // Convert data to Buffer for consistency const buffer = this.normalizeData(data); // Validate file size this.validateFileSize(buffer); // Auto-detect content type if not provided const contentType = options?.contentType || this.detectContentType(key, buffer); // Validate file type this.validateFileType(contentType); // Store via strategy with enhanced options const enhancedOptions = { ...options, contentType, }; const result = await this.strategy.put(key, buffer, enhancedOptions); // Log in development if (this.config.environment.isDevelopment) { console.log(`📤 [AppKit] File stored: ${key} (${buffer.length} bytes, ${contentType})`); } return result; } catch (error) { console.error(`[AppKit] Storage put error for "${key}":`, error.message); throw error; } } /** * Retrieves file with automatic error handling * @llm-rule WHEN: Downloading files from storage backend * @llm-rule AVOID: Manual error handling - this provides consistent error messages */ async get(key) { this.validateKey(key); await this.ensureConnected(); try { const result = await this.strategy.get(key); // Log in development if (this.config.environment.isDevelopment) { console.log(`📥 [AppKit] File retrieved: ${key} (${result.length} bytes)`); } return result; } catch (error) { console.error(`[AppKit] Storage get error for "${key}":`, error.message); throw new Error(`File not found: ${key}`); } } /** * Deletes file with confirmation * @llm-rule WHEN: Removing files from storage backend * @llm-rule AVOID: Silent failures - this confirms deletion success */ async delete(key) { this.validateKey(key); await this.ensureConnected(); try { const result = await this.strategy.delete(key); // Log in development if (this.config.environment.isDevelopment) { console.log(`🗑️ [AppKit] File deleted: ${key} (success: ${result})`); } return result; } catch (error) { console.error(`[AppKit] Storage delete error for "${key}":`, error.message); return false; } } /** * Lists files with prefix filtering and metadata * @llm-rule WHEN: Browsing files or implementing file managers * @llm-rule AVOID: Loading all files - use prefix filtering for performance */ async list(prefix = '', limit) { await this.ensureConnected(); try { const files = await this.strategy.list(prefix); // Apply limit if specified const result = limit ? files.slice(0, limit) : files; // Log in development if (this.config.environment.isDevelopment) { console.log(`📋 [AppKit] Files listed: ${prefix}* (${result.length} files)`); } return result; } catch (error) { console.error(`[AppKit] Storage list error for prefix "${prefix}":`, error.message); return []; } } /** * Gets public URL for file access * @llm-rule WHEN: Generating URLs for file access in web applications * @llm-rule AVOID: Hardcoding URLs - this handles CDN and strategy-specific URLs */ url(key) { this.validateKey(key); try { const url = this.strategy.url(key); // Log in development if (this.config.environment.isDevelopment) { console.log(`🔗 [AppKit] URL generated: ${key} → ${url}`); } return url; } catch (error) { console.error(`[AppKit] URL generation error for "${key}":`, error.message); throw error; } } /** * Generates signed URL for temporary access * @llm-rule WHEN: Creating temporary download links or private file access * @llm-rule AVOID: Permanent URLs for private files - use signed URLs with expiration */ async signedUrl(key, expiresIn = 3600) { this.validateKey(key); await this.ensureConnected(); try { if (!this.strategy.signedUrl) { throw new Error(`Signed URLs not supported with ${this.config.strategy} strategy`); } const url = await this.strategy.signedUrl(key, expiresIn); // Log in development if (this.config.environment.isDevelopment) { console.log(`🔐 [AppKit] Signed URL generated: ${key} (expires in ${expiresIn}s)`); } return url; } catch (error) { console.error(`[AppKit] Signed URL error for "${key}":`, error.message); throw error; } } /** * Checks if file exists without downloading * @llm-rule WHEN: Validating file existence before operations * @llm-rule AVOID: Downloading files just to check existence - this is more efficient */ async exists(key) { this.validateKey(key); await this.ensureConnected(); try { const result = await this.strategy.exists(key); // Log in development if (this.config.environment.isDevelopment) { console.log(`🔍 [AppKit] File exists check: ${key} → ${result}`); } return result; } catch (error) { console.error(`[AppKit] Exists check error for "${key}":`, error.message); return false; } } /** * Copies file from one location to another * @llm-rule WHEN: Duplicating files or moving between folders * @llm-rule AVOID: Download and upload - this uses efficient copy operations when possible */ async copy(sourceKey, destKey) { this.validateKey(sourceKey); this.validateKey(destKey); await this.ensureConnected(); try { // Strategy-specific copy if available, otherwise download/upload if ('copy' in this.strategy) { return await this.strategy.copy(sourceKey, destKey); } // Fallback: download and upload const data = await this.get(sourceKey); return await this.put(destKey, data); } catch (error) { console.error(`[AppKit] Copy error "${sourceKey}""${destKey}":`, error.message); throw error; } } /** * Gets current storage strategy name for debugging * @llm-rule WHEN: Debugging or health checks to see which strategy is active * @llm-rule AVOID: Using for application logic - storage should be transparent */ getStrategy() { return this.config.strategy; } /** * Gets storage configuration summary for debugging * @llm-rule WHEN: Health checks or debugging storage configuration * @llm-rule AVOID: Exposing sensitive details - this only shows safe info */ getConfig() { // Get config values based on strategy let maxFileSize = 52428800; // 50MB default let allowedTypes = []; if (this.config.strategy === 'local' && this.config.local) { maxFileSize = this.config.local.maxFileSize; allowedTypes = this.config.local.allowedTypes; } else { // S3 and R2 use global config or defaults maxFileSize = 52428800; // 50MB default for cloud allowedTypes = ['*']; // Allow all for cloud storage } return { strategy: this.config.strategy, connected: this.connected, maxFileSize, allowedTypes, }; } // Private helper methods /** * Ensures storage system is connected before operations */ async ensureConnected() { if (!this.connected) { await this.connect(); } } /** * Validates storage key format and security */ validateKey(key) { if (!key || typeof key !== 'string') { throw new Error('Storage key must be a non-empty string'); } if (key.length > 1024) { throw new Error('Storage key too long (max 1024 characters)'); } // Security: prevent path traversal if (key.includes('..') || key.includes('//')) { throw new Error('Storage key contains invalid path components'); } // Normalize separators if (key.includes('\\')) { throw new Error('Storage key must use forward slashes (/) as separators'); } // Remove leading slash for consistency if (key.startsWith('/')) { throw new Error('Storage key should not start with forward slash'); } } /** * Normalizes input data to Buffer */ normalizeData(data) { if (Buffer.isBuffer(data)) { return data; } if (data instanceof Uint8Array) { return Buffer.from(data); } if (typeof data === 'string') { return Buffer.from(data, 'utf8'); } throw new Error('Data must be Buffer, Uint8Array, or string'); } /** * Validates file size against configured limits */ validateFileSize(buffer) { let maxSize = 52428800; // 50MB default if (this.config.strategy === 'local' && this.config.local) { maxSize = this.config.local.maxFileSize; } // S3 and R2 use default limit for now if (buffer.length > maxSize) { const maxMB = Math.round(maxSize / 1048576); const fileMB = Math.round(buffer.length / 1048576); throw new Error(`File too large: ${fileMB}MB (max: ${maxMB}MB)`); } } /** * Detects content type from file extension and buffer */ detectContentType(key, buffer) { // Get extension from key const ext = key.split('.').pop()?.toLowerCase(); // Common MIME types const mimeTypes = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', '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', }; if (ext && mimeTypes[ext]) { return mimeTypes[ext]; } // Simple buffer-based detection 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'; // Default binary } /** * Validates file type against allowed types */ validateFileType(contentType) { let allowedTypes = []; if (this.config.strategy === 'local' && this.config.local) { allowedTypes = this.config.local.allowedTypes; } else { // S3 and R2 allow all types by default (filtering done at app level) allowedTypes = ['*']; } // Allow all types if wildcard is present if (allowedTypes.includes('*')) { return; } // Check exact match or wildcard patterns const isAllowed = allowedTypes.some(allowedType => { if (allowedType === contentType) { return true; } // Support wildcard patterns like "image/*" if (allowedType.endsWith('/*')) { const prefix = allowedType.slice(0, -2); return contentType.startsWith(prefix + '/'); } return false; }); if (!isAllowed) { throw new Error(`File type not allowed: ${contentType}. ` + `Allowed types: ${allowedTypes.join(', ')}`); } } } //# sourceMappingURL=storage.js.map