import crypto from 'node:crypto' import fs from 'node:fs/promises' import https from 'node:https' import path from 'node:path' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const args = new Set(process.argv.slice(2)) const skipUpload = args.has('--skip-upload') const timestamp = process.env.BUILD_TIMESTAMP || formatTimestamp(new Date()) const bucket = process.env.OSS_BUCKET || 'yuedong-wechatapplet' const region = process.env.OSS_REGION || 'oss-cn-shanghai' const endpoint = normalizeEndpoint(process.env.OSS_ENDPOINT || `${bucket}.${region}.aliyuncs.com`) const ossPrefix = normalizeObjectPrefix(process.env.OSS_PREFIX || 'static/web/samart-static') const uploadPrefix = normalizeObjectPrefix(`${ossPrefix}/${timestamp}`) const publicBase = normalizePublicBase( process.env.OSS_PUBLIC_BASE || `https://${endpoint}/${uploadPrefix}/` ) const outDir = path.resolve(root, process.env.BUILD_OUT_DIR || path.join('.oss-build', timestamp)) console.log(`[build] timestamp: ${timestamp}`) console.log(`[build] output: ${path.relative(root, outDir)}`) console.log(`[build] public base: ${publicBase}`) await runViteBuild() if (skipUpload) { console.log('[oss] skip upload by --skip-upload') process.exit(0) } const accessKeyId = 'LTAI5tAUZ6o8r8K2vkZX4shA' const accessKeySecret = 'Mz898g6O3OQ8Kzsi7WaK2XPwZFKoRk' if (!accessKeyId || !accessKeySecret) { throw new Error( 'Missing OSS credentials. Please set OSS_ACCESS_KEY_ID and OSS_ACCESS_KEY_SECRET, or use `npm run build -- --skip-upload`.' ) } const files = await listFiles(outDir) console.log(`[oss] uploading ${files.length} files to oss://${bucket}/${uploadPrefix}/`) for (const file of files) { const relativePath = toPosixPath(path.relative(outDir, file)) const objectKey = `${uploadPrefix}/${relativePath}` await uploadObject({ file, objectKey, bucket, endpoint, accessKeyId, accessKeySecret }) console.log(`[oss] uploaded ${objectKey}`) } console.log(`[oss] done: ${publicBase}`) function formatTimestamp(date) { const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: process.env.BUILD_TIMEZONE || 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(date) const get = (type) => parts.find((item) => item.type === type)?.value || '' return `${get('year')}${get('month')}${get('day')}${get('hour')}${get('minute')}` } function runViteBuild() { const viteBin = path.resolve(root, 'node_modules/vite/bin/vite.js') const env = { ...process.env, BUILD_TIMESTAMP: timestamp, VITE_BUILD_OUT_DIR: outDir, VITE_APP_PUBLIC_BASE: publicBase } return new Promise((resolve, reject) => { const child = spawn(process.execPath, [viteBin, 'build'], { cwd: root, env, stdio: 'inherit' }) child.on('error', reject) child.on('close', (code) => { if (code === 0) { resolve() } else { reject(new Error(`vite build failed with exit code ${code}`)) } }) }) } async function listFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }) const files = [] for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { files.push(...await listFiles(fullPath)) } else if (entry.isFile()) { files.push(fullPath) } } return files } async function uploadObject({ file, objectKey, bucket, endpoint, accessKeyId, accessKeySecret }) { const body = await fs.readFile(file) const contentType = getContentType(file) const date = new Date().toUTCString() const resource = `/${bucket}/${objectKey}` const signature = crypto .createHmac('sha1', accessKeySecret) .update(`PUT\n\n${contentType}\n${date}\n${resource}`) .digest('base64') const requestPath = `/${objectKey.split('/').map(encodeURIComponent).join('/')}` await new Promise((resolve, reject) => { const req = https.request({ method: 'PUT', hostname: endpoint, path: requestPath, headers: { Authorization: `OSS ${accessKeyId}:${signature}`, Date: date, 'Content-Type': contentType, 'Content-Length': body.length } }, (res) => { const chunks = [] res.on('data', (chunk) => chunks.push(chunk)) res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve() } else { const message = Buffer.concat(chunks).toString('utf8') reject(new Error(`OSS upload failed: ${res.statusCode} ${res.statusMessage}\n${message}`)) } }) }) req.on('error', reject) req.end(body) }) } function getContentType(file) { const ext = path.extname(file).toLowerCase() const map = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.map': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8' } return map[ext] || 'application/octet-stream' } function normalizeEndpoint(value) { return value.replace(/^https?:\/\//, '').replace(/\/+$/, '') } function normalizeObjectPrefix(value) { return toPosixPath(value).replace(/^\/+|\/+$/g, '') } function normalizePublicBase(value) { return value.endsWith('/') ? value : `${value}/` } function toPosixPath(value) { return value.split(path.sep).join('/') }