YD_SmartSports.Web/scripts/build-oss.mjs

202 lines
5.9 KiB
JavaScript

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('/')
}