UNPKG

@voilajsx/appkit

Version:

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

368 lines 14.7 kB
/** * Local filesystem storage strategy with automatic directory management * @module @voilajsx/appkit/storage * @file src/storage/strategies/local.ts * * @llm-rule WHEN: No cloud storage env vars - perfect for development and single-server apps * @llm-rule AVOID: Production use across multiple servers - files don't sync across instances * @llm-rule NOTE: Fast local storage, automatic directory creation, file serving support */ import fs from 'fs/promises'; import path from 'path'; import { existsSync } from 'fs'; /** * Local filesystem storage strategy with intelligent file management */ export class LocalStrategy { config; baseDir; baseUrl; maxFileSize; allowedTypes; /** * Creates local strategy with direct environment access (like auth pattern) * @llm-rule WHEN: Storage initialization without cloud env vars - automatic fallback * @llm-rule AVOID: Manual local configuration - environment detection handles this */ constructor(config) { this.config = config; if (!config.local) { throw new Error('Local storage configuration missing'); } this.baseDir = path.resolve(config.local.dir); this.baseUrl = config.local.baseUrl; this.maxFileSize = config.local.maxFileSize; this.allowedTypes = config.local.allowedTypes; // Ensure base directory exists on initialization this.ensureDirectoryExists(this.baseDir); if (this.config.environment.isDevelopment) { console.log(`✅ [AppKit] Local storage initialized (dir: ${this.baseDir}, maxSize: ${Math.round(this.maxFileSize / 1048576)}MB)`); } } /** * Stores file to local filesystem with automatic directory creation * @llm-rule WHEN: Saving files to local storage for development or single-server apps * @llm-rule AVOID: Manual directory management - this handles nested paths automatically */ async put(key, data, options) { try { const filePath = this.getFilePath(key); // Ensure parent directory exists const parentDir = path.dirname(filePath); await this.ensureDirectoryExists(parentDir); // Write file to disk await fs.writeFile(filePath, data); // Set file metadata if supported (extended attributes not available on all systems) if (options?.metadata) { try { await this.setFileMetadata(filePath, options.metadata); } catch (error) { // Metadata setting is optional, don't fail the operation if (this.config.environment.isDevelopment) { console.warn(`[AppKit] Could not set metadata for ${key}:`, error.message); } } } if (this.config.environment.isDevelopment) { console.log(`📁 [AppKit] Local file stored: ${key} (${data.length} bytes)`); } return key; } catch (error) { throw new Error(`Failed to store file locally: ${error.message}`); } } /** * Retrieves file from local filesystem with error handling * @llm-rule WHEN: Loading files from local storage * @llm-rule AVOID: Direct fs operations - this handles errors and validation */ async get(key) { try { const filePath = this.getFilePath(key); // Check if file exists before reading if (!existsSync(filePath)) { throw new Error(`File not found: ${key}`); } // Read file from disk const data = await fs.readFile(filePath); if (this.config.environment.isDevelopment) { console.log(`📁 [AppKit] Local file retrieved: ${key} (${data.length} bytes)`); } return data; } catch (error) { throw new Error(`Failed to retrieve file locally: ${error.message}`); } } /** * Deletes file from local filesystem with confirmation * @llm-rule WHEN: Removing files from local storage * @llm-rule AVOID: Silent failures - this confirms deletion or reports issues */ async delete(key) { try { const filePath = this.getFilePath(key); // Check if file exists if (!existsSync(filePath)) { return false; // File doesn't exist, consider it "deleted" } // Delete file await fs.unlink(filePath); // Clean up empty directories await this.cleanupEmptyDirectories(path.dirname(filePath)); if (this.config.environment.isDevelopment) { console.log(`🗑️ [AppKit] Local file deleted: ${key}`); } return true; } catch (error) { console.error(`[AppKit] Local delete error for "${key}":`, error.message); return false; } } /** * Lists files with prefix filtering and metadata * @llm-rule WHEN: Browsing local files or implementing file managers * @llm-rule AVOID: Loading all files - this efficiently scans directories */ async list(prefix = '') { try { const searchDir = prefix ? path.join(this.baseDir, path.dirname(prefix)) : this.baseDir; const searchPattern = prefix ? path.basename(prefix) : ''; if (!existsSync(searchDir)) { return []; // Directory doesn't exist, return empty list } const files = []; await this.scanDirectory(searchDir, files, searchPattern, prefix); // Sort by key for consistent ordering files.sort((a, b) => a.key.localeCompare(b.key)); if (this.config.environment.isDevelopment) { console.log(`📋 [AppKit] Local files listed: ${prefix}* (${files.length} files)`); } return files; } catch (error) { console.error(`[AppKit] Local list error for prefix "${prefix}":`, error.message); return []; } } /** * Gets public URL for local file access * @llm-rule WHEN: Generating URLs for local file serving * @llm-rule AVOID: Hardcoded paths - this handles base URL configuration */ url(key) { // Normalize key to use forward slashes for URLs const normalizedKey = key.replace(/\\/g, '/'); // Ensure base URL ends with / and key doesn't start with / const baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : this.baseUrl + '/'; const cleanKey = normalizedKey.startsWith('/') ? normalizedKey.slice(1) : normalizedKey; return baseUrl + cleanKey; } /** * Checks if file exists without reading content * @llm-rule WHEN: Validating file existence efficiently * @llm-rule AVOID: Reading entire file just to check existence */ async exists(key) { try { const filePath = this.getFilePath(key); return existsSync(filePath); } catch (error) { return false; } } /** * Generates signed URL for temporary local file access (not applicable for local storage) * @llm-rule WHEN: API compatibility - local storage doesn't support signed URLs * @llm-rule AVOID: Using signed URLs with local storage - use regular URLs instead */ async signedUrl(key, expiresIn = 3600) { // Local storage doesn't support signed URLs, return regular URL console.warn('[AppKit] Signed URLs not supported with local storage, returning public URL'); return this.url(key); } /** * Copies file within local filesystem efficiently * @llm-rule WHEN: Duplicating files within local storage * @llm-rule AVOID: Read/write operations - this uses efficient copy operations */ async copy(sourceKey, destKey) { try { const sourcePath = this.getFilePath(sourceKey); const destPath = this.getFilePath(destKey); // Check source exists if (!existsSync(sourcePath)) { throw new Error(`Source file not found: ${sourceKey}`); } // Ensure destination directory exists const destDir = path.dirname(destPath); await this.ensureDirectoryExists(destDir); // Copy file await fs.copyFile(sourcePath, destPath); if (this.config.environment.isDevelopment) { console.log(`📁 [AppKit] Local file copied: ${sourceKey} → ${destKey}`); } return destKey; } catch (error) { throw new Error(`Failed to copy file locally: ${error.message}`); } } /** * Disconnects local strategy gracefully * @llm-rule WHEN: App shutdown or storage cleanup * @llm-rule AVOID: Leaving temp files - this cleans up if configured */ async disconnect() { // Local storage doesn't need explicit disconnection // Could implement cleanup of temp files here if needed if (this.config.environment.isDevelopment) { console.log(`👋 [AppKit] Local storage strategy disconnected`); } } // Private helper methods /** * Gets absolute file path from storage key */ getFilePath(key) { // Normalize path separators and prevent directory traversal const normalizedKey = key.replace(/\\/g, '/').replace(/\.\.+/g, ''); return path.join(this.baseDir, normalizedKey); } /** * Ensures directory exists, creating it if necessary */ async ensureDirectoryExists(dirPath) { try { if (!existsSync(dirPath)) { await fs.mkdir(dirPath, { recursive: true }); } } catch (error) { throw new Error(`Failed to create directory: ${error.message}`); } } /** * Recursively scans directory for files matching pattern */ async scanDirectory(dirPath, files, pattern = '', prefix = '') { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { // Recursively scan subdirectories await this.scanDirectory(fullPath, files, pattern, prefix); } else if (entry.isFile()) { // Check if file matches pattern if (!pattern || entry.name.startsWith(pattern)) { const relativePath = path.relative(this.baseDir, fullPath); const key = relativePath.replace(/\\/g, '/'); // Normalize to forward slashes // Get file stats const stats = await fs.stat(fullPath); files.push({ key, size: stats.size, lastModified: stats.mtime, contentType: this.getContentTypeFromExtension(key), }); } } } } catch (error) { // Directory access error, skip silently if (this.config.environment.isDevelopment) { console.warn(`[AppKit] Error scanning directory ${dirPath}:`, error.message); } } } /** * Cleans up empty directories after file deletion */ async cleanupEmptyDirectories(dirPath) { try { // Don't clean up the base directory if (dirPath === this.baseDir) { return; } const entries = await fs.readdir(dirPath); // If directory is empty, remove it and check parent if (entries.length === 0) { await fs.rmdir(dirPath); // Recursively check parent directory const parentDir = path.dirname(dirPath); if (parentDir !== dirPath && parentDir !== this.baseDir) { await this.cleanupEmptyDirectories(parentDir); } } } catch (error) { // Cleanup is optional, don't fail if it doesn't work if (this.config.environment.isDevelopment) { console.warn(`[AppKit] Could not cleanup directory ${dirPath}:`, error.message); } } } /** * Sets file metadata using extended attributes (where supported) */ async setFileMetadata(filePath, metadata) { // Extended attributes are not universally supported // This is a placeholder for future metadata support // Could implement using file naming conventions or sidecar files // For now, we'll store metadata in a separate .meta file const metaPath = filePath + '.meta'; const metaContent = JSON.stringify(metadata, null, 2); try { await fs.writeFile(metaPath, metaContent); } catch (error) { // Metadata is optional throw new Error(`Failed to write metadata: ${error.message}`); } } /** * Gets content type from file extension */ getContentTypeFromExtension(key) { const ext = path.extname(key).toLowerCase(); 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', }; return mimeTypes[ext] || 'application/octet-stream'; } /** * Gets detailed local storage statistics */ getDetailedStats() { return { strategy: 'local', baseDir: this.baseDir, totalFiles: 0, // Would need to scan to get accurate count totalSize: 0, // Would need to scan to get accurate size maxFileSize: this.maxFileSize, allowedTypes: this.allowedTypes, }; } } //# sourceMappingURL=local.js.map