UNPKG

vite-upload-assets-oss

Version:

🚀 A powerful Vite plugin that automatically uploads media assets to multiple cloud storage providers (OSS, COS, S3, OBS) and replaces references with CDN URLs. Supports multiple import methods, caching, and concurrent uploads. | 一个强大的Vite插件,自动将媒体资源上传到多个云

1,323 lines (1,312 loc) 49.1 kB
import * as path from 'path'; import * as fs from 'fs'; import OSS from 'ali-oss'; import PQueue from 'p-queue'; import { createHash } from 'crypto'; import { lookup } from 'mime-types'; /** * 云存储抽象接口 * Cloud Storage Abstract Interface */ /** * 云存储抽象基类 */ class CloudStorageProvider { constructor(config) { this.uploadResults = new Map(); this.config = config; } /** * 生成文件完整URL */ getFileUrl(filePath) { const publicPath = this.config.publicPath; const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath; return publicPath.endsWith('/') ? `${publicPath}${normalizedPath}` : `${publicPath}/${normalizedPath}`; } /** * 获取上传结果 */ getUploadResult(filePath) { return this.uploadResults.get(filePath); } /** * 获取所有上传结果 */ getAllUploadResults() { return this.uploadResults; } /** * 清理资源 */ cleanup() { // 基础实现,子类可以重写 } /** * 生成目标文件路径 */ generateTargetPath(asset) { if (!this.config.prefix) { return asset.fileName; } const { fileName, fileHash } = asset; const ext = fileName.split('.').pop() || ''; const name = fileName.replace(`.${ext}`, ''); return this.config.prefix .replace(/\[name\]/g, name) .replace(/\[ext\]/g, ext) .replace(/\[hash\]/g, fileHash) .replace(/\[hash:(\d+)\]/g, (_, length) => fileHash.substring(0, parseInt(length))); } } /** * 云存储工厂类 */ class CloudStorageFactory { /** * 注册存储提供商 */ static registerProvider(name, provider) { this.providers.set(name, provider); } /** * 创建存储提供商实例 */ static createProvider(config) { const ProviderClass = this.providers.get(config.provider); if (!ProviderClass) { throw new Error(`Unsupported cloud storage provider: ${config.provider}`); } return new ProviderClass(config); } /** * 获取支持的提供商列表 */ static getSupportedProviders() { return Array.from(this.providers.keys()); } } CloudStorageFactory.providers = new Map(); /** * 阿里云OSS存储提供商 * Alibaba Cloud OSS Storage Provider */ class OSSProvider extends CloudStorageProvider { constructor(config) { super(config); this.client = null; this.cache = new Map(); this.config = config; this.queue = new PQueue({ concurrency: config.maxConcurrency || 3 }); if (config.cache !== false) { this.loadCache(); } } async initialize() { const { oss } = this.config; this.client = new OSS({ accessKeyId: oss.accessKeyId, accessKeySecret: oss.accessKeySecret, bucket: oss.bucket, region: oss.region, endpoint: oss.endpoint, secure: oss.secure !== false, timeout: oss.timeout || 60000 }); // 验证连接 try { await this.client.getBucketInfo(oss.bucket); if (this.config.verbose) { console.log(`✓ Connected to OSS bucket: ${oss.bucket}`); } } catch (error) { throw new Error(`Failed to connect to OSS: ${error}`); } } async uploadFile(asset, content) { if (!this.client) { throw new Error('OSS client not initialized'); } const targetPath = this.generateTargetPath(asset); // 检查缓存 const cacheEntry = this.cache.get(asset.filePath); if (cacheEntry && cacheEntry.hash === asset.fileHash) { if (this.config.verbose) { console.log(`✓ Cached ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } const result = { success: true, filePath: asset.filePath, url: cacheEntry.url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } try { // 上传文件 const uploadResult = await this.client.put(targetPath, content, { headers: { 'Content-Type': asset.mimeType } }); const url = this.getFileUrl(targetPath); if (this.config.verbose) { console.log(`✓ Uploaded ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } // 更新缓存 if (this.config.cache !== false) { this.cache.set(asset.filePath, { hash: asset.fileHash, url, size: asset.fileSize, uploadTime: Date.now() }); this.saveCache(); } const result = { success: true, filePath: asset.filePath, url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } catch (error) { const result = { success: false, filePath: asset.filePath, url: '', size: 0, error: error }; this.uploadResults.set(asset.filePath, result); return result; } } async uploadAssets(assets) { if (assets.length === 0) return; if (this.config.verbose) { console.log(`📦 Starting upload ${assets.length} assets`); } const uploadPromises = assets.map(({ asset, content }) => this.queue.add(() => this.uploadFile(asset, content))); const results = await Promise.all(uploadPromises); // 统计结果 const uploaded = results.filter(r => r.success && !this.cache.has(r.filePath)).length; const cached = results.filter(r => r.success && this.cache.has(r.filePath)).length; const failed = results.filter(r => !r.success).length; const totalSize = results.reduce((sum, r) => sum + r.size, 0); if (this.config.verbose) { console.log(`\n📊 Upload Summary:`); console.log(` Total: ${assets.length} files (${this.formatSize(totalSize)})`); if (uploaded > 0) console.log(` Uploaded: ${uploaded} files`); if (cached > 0) console.log(` Cached: ${cached} files`); if (failed > 0) console.log(` Failed: ${failed} files`); } if (failed > 0) { const failedResults = results.filter(r => !r.success); const errorMessages = failedResults.map(r => r.error?.message).join(', '); throw new Error(`Failed to upload ${failed} files: ${errorMessages}`); } } async fileExists(filePath) { if (!this.client) return false; try { await this.client.head(filePath); return true; } catch { return false; } } async deleteFile(filePath) { if (!this.client) return false; try { await this.client.delete(filePath); return true; } catch { return false; } } cleanup() { if (this.config.cache !== false) { this.saveCache(); } } loadCache() { const cacheFile = this.config.cacheFile || '.oss-cache.json'; try { if (fs.existsSync(cacheFile)) { const data = fs.readFileSync(cacheFile, 'utf8'); const cacheData = JSON.parse(data); this.cache = new Map(Object.entries(cacheData)); } } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to load cache file: ${error}`); } } } saveCache() { const cacheFile = this.config.cacheFile || '.oss-cache.json'; try { const cacheData = Object.fromEntries(this.cache); fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to save cache file: ${error}`); } } } formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; } } /** * 腾讯云COS存储提供商 * Tencent Cloud COS Storage Provider */ class COSProvider extends CloudStorageProvider { constructor(config) { super(config); this.client = null; this.cache = new Map(); this.config = config; this.queue = new PQueue({ concurrency: config.maxConcurrency || 3 }); if (config.cache !== false) { this.loadCache(); } } async initialize() { try { // 动态导入腾讯云COS SDK const COS = await import('cos-nodejs-sdk-v5'); const COSClass = COS.default || COS; const { cos } = this.config; this.client = new COSClass({ SecretId: cos.secretId, SecretKey: cos.secretKey, Protocol: cos.protocol || 'https', Domain: cos.domain, Timeout: cos.timeout || 60000 }); // 验证连接 try { await new Promise((resolve, reject) => { this.client.getBucket({ Bucket: cos.bucket, Region: cos.region }, (err, data) => { if (err) reject(err); else resolve(data); }); }); if (this.config.verbose) { console.log(`✓ Connected to COS bucket: ${cos.bucket}`); } } catch (error) { throw new Error(`Failed to connect to COS: ${error}`); } } catch (error) { throw new Error(`Failed to initialize COS SDK. Please install: npm install cos-nodejs-sdk-v5`); } } async uploadFile(asset, content) { if (!this.client) { throw new Error('COS client not initialized'); } const targetPath = this.generateTargetPath(asset); // 检查缓存 const cacheEntry = this.cache.get(asset.filePath); if (cacheEntry && cacheEntry.hash === asset.fileHash) { if (this.config.verbose) { console.log(`✓ Cached ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } const result = { success: true, filePath: asset.filePath, url: cacheEntry.url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } try { // 上传文件 const uploadResult = await new Promise((resolve, reject) => { this.client.putObject({ Bucket: this.config.cos.bucket, Region: this.config.cos.region, Key: targetPath, Body: content, ContentType: asset.mimeType }, (err, data) => { if (err) reject(err); else resolve(data); }); }); const url = this.getFileUrl(targetPath); if (this.config.verbose) { console.log(`✓ Uploaded ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } // 更新缓存 if (this.config.cache !== false) { this.cache.set(asset.filePath, { hash: asset.fileHash, url, size: asset.fileSize, uploadTime: Date.now() }); this.saveCache(); } const result = { success: true, filePath: asset.filePath, url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } catch (error) { const result = { success: false, filePath: asset.filePath, url: '', size: 0, error: error }; this.uploadResults.set(asset.filePath, result); return result; } } async uploadAssets(assets) { if (assets.length === 0) return; if (this.config.verbose) { console.log(`📦 Starting upload ${assets.length} assets to COS`); } const uploadPromises = assets.map(({ asset, content }) => this.queue.add(() => this.uploadFile(asset, content))); const results = await Promise.all(uploadPromises); // 统计结果 const uploaded = results.filter(r => r.success && !this.cache.has(r.filePath)).length; const cached = results.filter(r => r.success && this.cache.has(r.filePath)).length; const failed = results.filter(r => !r.success).length; const totalSize = results.reduce((sum, r) => sum + r.size, 0); if (this.config.verbose) { console.log(`\n📊 COS Upload Summary:`); console.log(` Total: ${assets.length} files (${this.formatSize(totalSize)})`); if (uploaded > 0) console.log(` Uploaded: ${uploaded} files`); if (cached > 0) console.log(` Cached: ${cached} files`); if (failed > 0) console.log(` Failed: ${failed} files`); } if (failed > 0) { const failedResults = results.filter(r => !r.success); const errorMessages = failedResults.map(r => r.error?.message).join(', '); throw new Error(`Failed to upload ${failed} files to COS: ${errorMessages}`); } } async fileExists(filePath) { if (!this.client) return false; try { await new Promise((resolve, reject) => { this.client.headObject({ Bucket: this.config.cos.bucket, Region: this.config.cos.region, Key: filePath }, (err, data) => { if (err) reject(err); else resolve(data); }); }); return true; } catch { return false; } } async deleteFile(filePath) { if (!this.client) return false; try { await new Promise((resolve, reject) => { this.client.deleteObject({ Bucket: this.config.cos.bucket, Region: this.config.cos.region, Key: filePath }, (err, data) => { if (err) reject(err); else resolve(data); }); }); return true; } catch { return false; } } cleanup() { if (this.config.cache !== false) { this.saveCache(); } } loadCache() { const cacheFile = this.config.cacheFile || '.cos-cache.json'; try { if (fs.existsSync(cacheFile)) { const data = fs.readFileSync(cacheFile, 'utf8'); const cacheData = JSON.parse(data); this.cache = new Map(Object.entries(cacheData)); } } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to load COS cache file: ${error}`); } } } saveCache() { const cacheFile = this.config.cacheFile || '.cos-cache.json'; try { const cacheData = Object.fromEntries(this.cache); fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to save COS cache file: ${error}`); } } } formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; } } /** * AWS S3存储提供商 * AWS S3 Storage Provider */ class S3Provider extends CloudStorageProvider { constructor(config) { super(config); this.client = null; this.cache = new Map(); this.config = config; this.queue = new PQueue({ concurrency: config.maxConcurrency || 3 }); if (config.cache !== false) { this.loadCache(); } } async initialize() { try { // 动态导入AWS SDK const AWS = await import('aws-sdk'); const { s3 } = this.config; // 配置AWS AWS.config.update({ accessKeyId: s3.accessKeyId, secretAccessKey: s3.secretAccessKey, region: s3.region, signatureVersion: s3.signatureVersion || 'v4' }); this.client = new AWS.S3({ endpoint: s3.endpoint, s3ForcePathStyle: s3.s3ForcePathStyle || false, httpOptions: { timeout: s3.timeout || 60000 } }); // 验证连接 try { await this.client.headBucket({ Bucket: s3.bucket }).promise(); if (this.config.verbose) { console.log(`✓ Connected to S3 bucket: ${s3.bucket}`); } } catch (error) { throw new Error(`Failed to connect to S3: ${error}`); } } catch (error) { throw new Error(`Failed to initialize AWS SDK. Please install: npm install aws-sdk`); } } async uploadFile(asset, content) { if (!this.client) { throw new Error('S3 client not initialized'); } const targetPath = this.generateTargetPath(asset); // 检查缓存 const cacheEntry = this.cache.get(asset.filePath); if (cacheEntry && cacheEntry.hash === asset.fileHash) { if (this.config.verbose) { console.log(`✓ Cached ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } const result = { success: true, filePath: asset.filePath, url: cacheEntry.url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } try { // 上传文件 const uploadResult = await this.client.upload({ Bucket: this.config.s3.bucket, Key: targetPath, Body: content, ContentType: asset.mimeType, ACL: 'public-read' // 可以通过配置来控制 }).promise(); const url = this.getFileUrl(targetPath); if (this.config.verbose) { console.log(`✓ Uploaded ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } // 更新缓存 if (this.config.cache !== false) { this.cache.set(asset.filePath, { hash: asset.fileHash, url, size: asset.fileSize, uploadTime: Date.now() }); this.saveCache(); } const result = { success: true, filePath: asset.filePath, url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } catch (error) { const result = { success: false, filePath: asset.filePath, url: '', size: 0, error: error }; this.uploadResults.set(asset.filePath, result); return result; } } async uploadAssets(assets) { if (assets.length === 0) return; if (this.config.verbose) { console.log(`📦 Starting upload ${assets.length} assets to S3`); } const uploadPromises = assets.map(({ asset, content }) => this.queue.add(() => this.uploadFile(asset, content))); const results = await Promise.all(uploadPromises); // 统计结果 const uploaded = results.filter(r => r.success && !this.cache.has(r.filePath)).length; const cached = results.filter(r => r.success && this.cache.has(r.filePath)).length; const failed = results.filter(r => !r.success).length; const totalSize = results.reduce((sum, r) => sum + r.size, 0); if (this.config.verbose) { console.log(`\n📊 S3 Upload Summary:`); console.log(` Total: ${assets.length} files (${this.formatSize(totalSize)})`); if (uploaded > 0) console.log(` Uploaded: ${uploaded} files`); if (cached > 0) console.log(` Cached: ${cached} files`); if (failed > 0) console.log(` Failed: ${failed} files`); } if (failed > 0) { const failedResults = results.filter(r => !r.success); const errorMessages = failedResults.map(r => r.error?.message).join(', '); throw new Error(`Failed to upload ${failed} files to S3: ${errorMessages}`); } } async fileExists(filePath) { if (!this.client) return false; try { await this.client.headObject({ Bucket: this.config.s3.bucket, Key: filePath }).promise(); return true; } catch { return false; } } async deleteFile(filePath) { if (!this.client) return false; try { await this.client.deleteObject({ Bucket: this.config.s3.bucket, Key: filePath }).promise(); return true; } catch { return false; } } cleanup() { if (this.config.cache !== false) { this.saveCache(); } } loadCache() { const cacheFile = this.config.cacheFile || '.s3-cache.json'; try { if (fs.existsSync(cacheFile)) { const data = fs.readFileSync(cacheFile, 'utf8'); const cacheData = JSON.parse(data); this.cache = new Map(Object.entries(cacheData)); } } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to load S3 cache file: ${error}`); } } } saveCache() { const cacheFile = this.config.cacheFile || '.s3-cache.json'; try { const cacheData = Object.fromEntries(this.cache); fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to save S3 cache file: ${error}`); } } } formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; } } /** * 华为云OBS存储提供商 * Huawei Cloud OBS Storage Provider */ class OBSProvider extends CloudStorageProvider { constructor(config) { super(config); this.client = null; this.cache = new Map(); this.config = config; this.queue = new PQueue({ concurrency: config.maxConcurrency || 3 }); if (config.cache !== false) { this.loadCache(); } } async initialize() { try { // 动态导入华为云OBS SDK const ObsClient = await import('esdk-obs-nodejs'); const OBSClass = ObsClient.default || ObsClient; const { obs } = this.config; this.client = new OBSClass({ access_key_id: obs.accessKeyId, secret_access_key: obs.secretAccessKey, server: obs.endpoint, signature: obs.signature || 'v4', timeout: obs.timeout || 60000 }); // 验证连接 try { const result = await new Promise((resolve, reject) => { this.client.getBucketMetadata({ Bucket: obs.bucket }, (err, result) => { if (err) reject(err); else resolve(result); }); }); if (this.config.verbose) { console.log(`✓ Connected to OBS bucket: ${obs.bucket}`); } } catch (error) { throw new Error(`Failed to connect to OBS: ${error}`); } } catch (error) { throw new Error(`Failed to initialize OBS SDK. Please install: npm install esdk-obs-nodejs`); } } async uploadFile(asset, content) { if (!this.client) { throw new Error('OBS client not initialized'); } const targetPath = this.generateTargetPath(asset); // 检查缓存 const cacheEntry = this.cache.get(asset.filePath); if (cacheEntry && cacheEntry.hash === asset.fileHash) { if (this.config.verbose) { console.log(`✓ Cached ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } const result = { success: true, filePath: asset.filePath, url: cacheEntry.url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } try { // 上传文件 const uploadResult = await new Promise((resolve, reject) => { this.client.putObject({ Bucket: this.config.obs.bucket, Key: targetPath, Body: content, ContentType: asset.mimeType }, (err, result) => { if (err) reject(err); else resolve(result); }); }); const url = this.getFileUrl(targetPath); if (this.config.verbose) { console.log(`✓ Uploaded ${asset.fileName} ${this.formatSize(asset.fileSize)}`); } // 更新缓存 if (this.config.cache !== false) { this.cache.set(asset.filePath, { hash: asset.fileHash, url, size: asset.fileSize, uploadTime: Date.now() }); this.saveCache(); } const result = { success: true, filePath: asset.filePath, url, size: asset.fileSize }; this.uploadResults.set(asset.filePath, result); return result; } catch (error) { const result = { success: false, filePath: asset.filePath, url: '', size: 0, error: error }; this.uploadResults.set(asset.filePath, result); return result; } } async uploadAssets(assets) { if (assets.length === 0) return; if (this.config.verbose) { console.log(`📦 Starting upload ${assets.length} assets to OBS`); } const uploadPromises = assets.map(({ asset, content }) => this.queue.add(() => this.uploadFile(asset, content))); const results = await Promise.all(uploadPromises); // 统计结果 const uploaded = results.filter(r => r.success && !this.cache.has(r.filePath)).length; const cached = results.filter(r => r.success && this.cache.has(r.filePath)).length; const failed = results.filter(r => !r.success).length; const totalSize = results.reduce((sum, r) => sum + r.size, 0); if (this.config.verbose) { console.log(`\n📊 OBS Upload Summary:`); console.log(` Total: ${assets.length} files (${this.formatSize(totalSize)})`); if (uploaded > 0) console.log(` Uploaded: ${uploaded} files`); if (cached > 0) console.log(` Cached: ${cached} files`); if (failed > 0) console.log(` Failed: ${failed} files`); } if (failed > 0) { const failedResults = results.filter(r => !r.success); const errorMessages = failedResults.map(r => r.error?.message).join(', '); throw new Error(`Failed to upload ${failed} files to OBS: ${errorMessages}`); } } async fileExists(filePath) { if (!this.client) return false; try { await new Promise((resolve, reject) => { this.client.getObjectMetadata({ Bucket: this.config.obs.bucket, Key: filePath }, (err, result) => { if (err) reject(err); else resolve(result); }); }); return true; } catch { return false; } } async deleteFile(filePath) { if (!this.client) return false; try { await new Promise((resolve, reject) => { this.client.deleteObject({ Bucket: this.config.obs.bucket, Key: filePath }, (err, result) => { if (err) reject(err); else resolve(result); }); }); return true; } catch { return false; } } cleanup() { if (this.config.cache !== false) { this.saveCache(); } } loadCache() { const cacheFile = this.config.cacheFile || '.obs-cache.json'; try { if (fs.existsSync(cacheFile)) { const data = fs.readFileSync(cacheFile, 'utf8'); const cacheData = JSON.parse(data); this.cache = new Map(Object.entries(cacheData)); } } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to load OBS cache file: ${error}`); } } } saveCache() { const cacheFile = this.config.cacheFile || '.obs-cache.json'; try { const cacheData = Object.fromEntries(this.cache); fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); } catch (error) { if (this.config.verbose) { console.warn(`Warning: Failed to save OBS cache file: ${error}`); } } } formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; } } /** * 云存储提供商注册 * Cloud Storage Providers Registration */ // 注册所有云存储提供商 CloudStorageFactory.registerProvider('oss', OSSProvider); CloudStorageFactory.registerProvider('cos', COSProvider); CloudStorageFactory.registerProvider('s3', S3Provider); CloudStorageFactory.registerProvider('obs', OBSProvider); /** * 检查文件是否为媒体资源 */ function isMediaAsset(filePath, options) { const ext = path.extname(filePath).toLowerCase().slice(1); return options.include.includes(ext) && !(options.exclude?.includes(ext) ?? false); } /** * 计算文件哈希值 */ function calculateFileHash(content) { return createHash('md5').update(content).digest('hex'); } /** * 解析相对路径为绝对路径 */ function resolveAssetPath(relativePath, importer, root) { // 处理 './assets/image.png' 或 '../images/banner.jpg' if (relativePath.startsWith('./') || relativePath.startsWith('../')) { return path.resolve(path.dirname(importer), relativePath); } // 处理 '@/assets/image.png' 别名路径 if (relativePath.startsWith('@/')) { return path.resolve(root, 'src', relativePath.slice(2)); } // 处理 '/assets/image.png' 公共资源路径 if (relativePath.startsWith('/')) { return path.resolve(root, 'public', relativePath.slice(1)); } // 其他相对路径 return path.resolve(path.dirname(importer), relativePath); } /** * 读取文件并生成资源信息 */ function createAssetInfo(filePath) { try { if (!fs.existsSync(filePath)) { return null; } const stat = fs.statSync(filePath); if (!stat.isFile()) { return null; } const content = fs.readFileSync(filePath); const hash = calculateFileHash(content); const mimeType = lookup(filePath) || 'application/octet-stream'; return { filePath, fileName: path.basename(filePath), fileSize: stat.size, fileHash: hash, mimeType }; } catch (error) { return null; } } /** * 检查是否为绝对URL */ function isAbsoluteUrl(url) { return /^https?:\/\//.test(url); } /** * 规范化路径分隔符(统一为POSIX风格) */ function normalizePath(filePath) { return filePath.replace(/\\/g, '/'); } const PLUGIN_NAME = 'vite-cloud-upload'; const PLACEHOLDER_PREFIX = '___VITE_CLOUD_ASSET_START___'; const PLACEHOLDER_SUFFIX = '___VITE_CLOUD_ASSET_END___'; function createViteCloudPlugin(options) { let config; let provider; let isProduction; // 资源注册表:记录需要处理的资源 const assetRegistry = new Map(); // 绝对路径 -> importer const processedAssets = new Set(); // 已处理的资源路径 return { name: PLUGIN_NAME, enforce: 'pre', // 确保在Vite内置插件之前执行 configResolved(resolvedConfig) { config = resolvedConfig; isProduction = config.command === 'build'; // 初始化云存储提供商 provider = CloudStorageFactory.createProvider(options); // 初始化提供商 if (isProduction) { provider.initialize().catch(error => { console.error(`[${PLUGIN_NAME}] Failed to initialize cloud provider:`, error); }); } }, // 🎯 核心钩子1: 拦截资源模块解析 resolveId(id, importer) { // 跳过开发模式(如果配置为跳过) if (!isProduction && options.dev === false) { return null; } // 跳过已处理的资源 if (id.includes('?cloud-processed')) { return null; } // 检查是否为媒体资源导入 if (importer && isMediaAsset(id, options)) { const resolvedPath = resolveAssetPath(id, importer, config.root); if (fs.existsSync(resolvedPath)) { // 注册到待处理列表 assetRegistry.set(resolvedPath, importer); // 返回带标识的ID,避免与其他插件冲突 return `${resolvedPath}?cloud-processed`; } } return null; }, // 🎯 核心钩子2: 处理资源加载 async load(id) { if (!id.includes('?cloud-processed')) { return null; } const assetPath = id.replace('?cloud-processed', ''); // 跳过开发模式的实际上传 if (!isProduction && options.dev === false) { // 开发模式下返回本地路径 const relativePath = path.relative(config.root, assetPath); return `export default "/${normalizePath(relativePath)}"`; } // 生产模式:创建占位符,稍后在generateBundle中处理 const encodedPath = Buffer.from(assetPath).toString('base64'); const placeholder = `${PLACEHOLDER_PREFIX}${encodedPath}${PLACEHOLDER_SUFFIX}`; return `export default "${placeholder}"`; }, // 🎯 核心钩子3: 转换代码内容 transform(code, id) { // 跳过开发模式(如果配置为跳过) if (!isProduction && options.dev === false) { return null; } let transformedCode = code; let hasTransform = false; // 处理 new URL() 构造的动态导入 const urlConstructorRegex = /new\s+URL\s*\(\s*['"`]([^'"`]+?)['"`]\s*,\s*import\.meta\.url\s*\)(?:\.href)?/g; transformedCode = transformedCode.replace(urlConstructorRegex, (match, assetPath) => { if (isMediaAsset(assetPath, options)) { const resolvedPath = resolveAssetPath(assetPath, id, config.root); if (fs.existsSync(resolvedPath)) { // 注册资源并创建占位符 assetRegistry.set(resolvedPath, id); const encodedPath = Buffer.from(resolvedPath).toString('base64'); const placeholder = `${PLACEHOLDER_PREFIX}${encodedPath}${PLACEHOLDER_SUFFIX}`; hasTransform = true; return isProduction ? `"${placeholder}"` : `new URL("/${normalizePath(path.relative(config.root, resolvedPath))}", import.meta.url).href`; } } return match; }); // 处理Object.assign映射中的资源引用 (Vue动态导入优化) const objectAssignRegex = /Object\.assign\(\{([^}]+)\}\)/g; transformedCode = transformedCode.replace(objectAssignRegex, (match, content) => { let hasAssignTransform = false; const transformedContent = content.replace(/"([^"]+?)"\s*:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/g, (innerMatch, assetPath, varName) => { if (isMediaAsset(assetPath, options)) { const resolvedPath = resolveAssetPath(assetPath, id, config.root); if (fs.existsSync(resolvedPath)) { // 注册资源并创建占位符 assetRegistry.set(resolvedPath, id); const encodedPath = Buffer.from(resolvedPath).toString('base64'); const placeholder = `${PLACEHOLDER_PREFIX}${encodedPath}${PLACEHOLDER_SUFFIX}`; hasAssignTransform = true; return isProduction ? `"${assetPath}":"${placeholder}"` : innerMatch; } } return innerMatch; }); if (hasAssignTransform) { hasTransform = true; return `Object.assign({${transformedContent}})`; } return match; }); // 处理CSS中的url()引用 const cssUrlRegex = /url\s*\(\s*(['"]?)([^'")]+?)\1\s*\)/g; transformedCode = transformedCode.replace(cssUrlRegex, (match, quote, assetPath) => { // 跳过绝对URL和data URL if (isAbsoluteUrl(assetPath) || assetPath.startsWith('data:') || assetPath.startsWith('#')) { return match; } if (isMediaAsset(assetPath, options)) { const resolvedPath = resolveAssetPath(assetPath, id, config.root); if (fs.existsSync(resolvedPath)) { // 注册资源并创建占位符 assetRegistry.set(resolvedPath, id); const encodedPath = Buffer.from(resolvedPath).toString('base64'); const placeholder = `${PLACEHOLDER_PREFIX}${encodedPath}${PLACEHOLDER_SUFFIX}`; hasTransform = true; return isProduction ? `url("${placeholder}")` : `url("/${normalizePath(path.relative(config.root, resolvedPath))}")`; } } return match; }); return hasTransform ? { code: transformedCode, map: null } : null; }, // 🎯 核心钩子4: 生成最终输出 async generateBundle(options, bundle) { if (!isProduction) { return; } try { // 收集所有需要上传的资源 const assetsToUpload = []; for (const assetPath of assetRegistry.keys()) { if (!processedAssets.has(assetPath)) { const assetInfo = createAssetInfo(assetPath); if (assetInfo) { assetsToUpload.push(assetInfo); processedAssets.add(assetPath); } } } // 批量上传资源 if (assetsToUpload.length > 0) { const assetsWithContent = assetsToUpload.map(asset => ({ asset, content: fs.readFileSync(asset.filePath) })); await provider.uploadAssets(assetsWithContent); } // 替换所有占位符为云存储URL replacePlaceholdersInBundle(bundle); } catch (error) { console.error(`[${PLUGIN_NAME}] Error:`, error); this.error(`Cloud upload failed: ${error}`); } }, // 构建结束时清理资源 buildEnd() { if (provider) { provider.cleanup(); } } }; // 🛠️ 工具方法:替换bundle中的占位符 function replacePlaceholdersInBundle(bundle) { const placeholderRegex = new RegExp(`${PLACEHOLDER_PREFIX.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(.+?)${PLACEHOLDER_SUFFIX.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}`, 'g'); for (const [fileName, chunk] of Object.entries(bundle)) { if (chunk.type === 'chunk') { chunk.code = chunk.code.replace(placeholderRegex, (match, encodedPath) => { const assetPath = Buffer.from(encodedPath, 'base64').toString('utf8'); const result = provider.getUploadResult(assetPath); if (result && result.success) { return result.url; } // fallback: 返回原始路径 const relativePath = path.relative(config.root, assetPath); return `/${normalizePath(relativePath)}`; }); // 处理Vite生成的本地资源引用,替换为云存储URL const localAssetRegex = /new URL\("\.\.\/local-assets\/([^"]+)",import\.meta\.url\)\.href/g; chunk.code = chunk.code.replace(localAssetRegex, (match, assetName) => { // 根据生成的资源名称查找对应的原始文件 for (const [originalPath, uploadResult] of provider.getAllUploadResults().entries()) { if (typeof originalPath === 'string') { const originalBaseName = path.basename(originalPath, path.extname(originalPath)); if (assetName.includes(originalBaseName) && uploadResult.success) { return `"${uploadResult.url}"`; } } } return match; }); // 处理变量赋值中的本地资源引用 const variableAssignRegex = /=""\+new URL\("\.\.\/local-assets\/([^"]+)",import\.meta\.url\)\.href/g; chunk.code = chunk.code.replace(variableAssignRegex, (match, assetName) => { // 根据生成的资源名称查找对应的原始文件 for (const [originalPath, uploadResult] of provider.getAllUploadResults().entries()) { if (typeof originalPath === 'string') { const originalBaseName = path.basename(originalPath, path.extname(originalPath)); if (assetName.includes(originalBaseName) && uploadResult.success) { return `="${uploadResult.url}"`; } } } return match; }); // 处理单独的new URL模式(无前置字符串拼接) const simpleUrlRegex = /new URL\("\.\.\/local-assets\/([^"]+)",import\.meta\.url\)\.href/g; chunk.code = chunk.code.replace(simpleUrlRegex, (match, assetName) => { // 根据生成的资源名称查找对应的原始文件 for (const [originalPath, uploadResult] of provider.getAllUploadResults().entries()) { if (typeof originalPath === 'string') { const originalBaseName = path.basename(originalPath, path.extname(originalPath)); if (assetName.includes(originalBaseName) && uploadResult.success) { return `"${uploadResult.url}"`; } } } return match; }); } else if (chunk.type === 'asset' && typeof chunk.source === 'string') { chunk.source = chunk.source.replace(placeholderRegex, (match, encodedPath) => { const assetPath = Buffer.from(encodedPath, 'base64').toString('utf8'); const result = provider.getUploadResult(assetPath); if (result && result.success) { return result.url; } // fallback: 返回原始路径 const relativePath = path.relative(config.root, assetPath); return `/${normalizePath(relativePath)}`; }); } } } } // 多云存储插件主入口 const viteCloudUpload = createViteCloudPlugin; const viteOSSUpload = createViteCloudPlugin; // 向后兼容 export { CloudStorageFactory, createViteCloudPlugin, createViteCloudPlugin as createViteOSSPlugin, createViteCloudPlugin as default, viteCloudUpload, viteOSSUpload };