Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion QiniuUploader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,29 @@ npx qiniu-uploader --upload-folder dist \
--qiniu-base-path some-folder-on-qiniu/sub-directory \
--qiniu-access-key *** \
--qiniu-secret-key *** \
--qiniu-bucket some-bucket
--qiniu-bucket some-bucket \
--prefetch \
--prefetch-domain https://cdn.example.com
```

`qiniu-access-key`, `qiniu-secret-key`, `qiniu-bucket` can be replaced with the system environments `QINIU_ACCESS_KEY`, `QINIU_SECRET_KEY`, `QINIU_BUCKET`.

## Prefetch

When `--prefetch` is enabled, all successfully uploaded file URLs will be submitted to Qiniu Fusion CDN for prefetching (cache warming) after the upload is completed.

### Options

| Option | Description | Required |
|---|---|---|
| `--prefetch` | Enable CDN prefetch after upload. | No |
| `--prefetch-domain <url>` | The CDN domain for constructing prefetch URLs, e.g. `https://cdn.example.com`. | Yes (when `--prefetch` is enabled) |
| `--prefetch-product <str>` | The prefetch product type (e.g. `dcdn`). Not required for Fusion CDN. | No |

`prefetch-domain` can also be set via the environment variable `QINIU_PREFETCH_DOMAIN`.

### How it works

- URLs are submitted in batches of 20 (Qiniu Fusion CDN API limit).
- A 1-second interval is applied between batches to respect the rate limit.
- On QPS throttling (403024) or server errors (500000), the batch will be retried up to 3 times with exponential backoff.

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says retries use "exponential backoff", but the implementation uses a linear delay (retries * 2000). Please update either the documentation or the retry logic so they match.

Suggested change
- On QPS throttling (403024) or server errors (500000), the batch will be retried up to 3 times with exponential backoff.
- On QPS throttling (403024) or server errors (500000), the batch will be retried up to 3 times with a linearly increasing delay (2s, 4s, 6s).

Copilot uses AI. Check for mistakes.
189 changes: 184 additions & 5 deletions QiniuUploader/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import https from 'https'
import qiniu from 'qiniu'
import { program } from 'commander'
import assert from 'assert'
Expand All @@ -15,6 +17,9 @@ program
.option('--qiniu-bucket <char>', '七牛的 Bucket 名称.')
.option('--qiniu-access-key <char>', '七牛 Access Key.')
.option('--qiniu-secret-key <char>', '七牛 Secret Key.')
.option('--prefetch', '上传完成后执行预取.')
.option('--prefetch-domain <char>', '预取使用的 CDN 域名, 如 https://cdn.example.com.')
.option('--prefetch-product <char>', '预取产品类型, 如 dcdn. 不指定则为融合 CDN.')

program.parse()

Expand All @@ -24,15 +29,28 @@ const qiniuBucket = options.qiniuBucket || process.env.QINIU_BUCKET
const qiniuAccessKey = options.qiniuAccessKey || process.env.QINIU_ACCESS_KEY
const qiniuSecretKey = options.qiniuSecretKey || process.env.QINIU_SECRET_KEY
const qiniuBasePath = options.qiniuBasePath
const prefetchEnabled = !!options.prefetch
const prefetchDomain = options.prefetchDomain || process.env.QINIU_PREFETCH_DOMAIN
const prefetchProduct = options.prefetchProduct

assert(distDirPath, '--upload-folder 未指定.')
assert(qiniuBucket, '--qiniu-bucket 未指定.')
assert(qiniuAccessKey, '--qiniu-access-key 未指定.')
assert(qiniuSecretKey, '--qiniu-secret-key 未指定.')
assert(qiniuBasePath, '--qiniu-base-path 未指定.')

if (prefetchEnabled) {
assert(prefetchDomain, '--prefetch-domain 未指定, 启用 --prefetch 时必须提供.')
}

distDirPath = path.resolve(process.cwd(), distDirPath)

// ============================================================
// Upload.
// ============================================================

const uploadedKeys = []

const walkDir = async (dirPath) => {
for (const filename of fs.readdirSync(dirPath)) {
const filePath = path.resolve(dirPath, filename)
Expand Down Expand Up @@ -64,22 +82,183 @@ const walkDir = async (dirPath) => {
)
const uploadToken = putPolicy.uploadToken(mac)
const putExtra = new qiniu.form_up.PutExtra()
const config = new qiniu.conf.Config({
const uploadConfig = new qiniu.conf.Config({
zone: qiniu.zone.Zone_z0
})
const formUploader = new qiniu.form_up.FormUploader(config)
const uploadResult= await formUploader.putStream(uploadToken, key, stream, putExtra, () => {
const formUploader = new qiniu.form_up.FormUploader(uploadConfig)
const uploadResult = await formUploader.putStream(uploadToken, key, stream, putExtra, () => {
// ...
})
const uploadedKey = uploadResult.data.key
if (uploadedKey) {
console.log('Upload done, key:', uploadedKey)
uploadedKeys.push(uploadedKey)
} else {
console.error('No key was returned from Qiniu, upload may failed.')
}
}
}

walkDir(distDirPath)
.catch(console.error)
// ============================================================
// Prefetch.
// ============================================================

/**
* Generate QBox access token.
* QBox signing only signs the request path, not the body.
*
* @param {string} apiPath - The API path, e.g. "/v2/tune/prefetch".
* @param {string} accessKey
* @param {string} secretKey
* @returns {string} The full Authorization header value.
*/
const generateQBoxToken = (apiPath, accessKey, secretKey) => {
const signingStr = apiPath + '\n'
const sign = crypto
.createHmac('sha1', secretKey)
.update(signingStr)
.digest('base64')
const safeSign = sign
.replace(/\+/g, '-')
.replace(/\//g, '_')
return `QBox ${accessKey}:${safeSign}`
}

/**
* Send a prefetch request to Qiniu Fusion CDN.
*
* @param {string[]} urls - URLs to prefetch, max 20.
* @param {{accessKey: string, secretKey: string, product?: string}} param
* @returns {Promise<{code: number, error: string, requestId: string, taskIds: Object<string, string>|null, invalidUrls: string[]|null, quotaDay: number, surplusDay: number}>}
*/
const sendPrefetchRequest = (urls, { accessKey, secretKey, product }) => {
const apiPath = '/v2/tune/prefetch'
const token = generateQBoxToken(apiPath, accessKey, secretKey)
const payload = { urls }
if (product) {
payload.product = product
}
const body = JSON.stringify(payload)

return new Promise((resolve, reject) => {
const req = https.request({
hostname: 'fusion.qiniuapi.com',
port: 443,
path: apiPath,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
Authorization: token
}
}, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
try {
resolve(JSON.parse(data))
} catch {
reject(new Error(`Failed to parse prefetch response: ${data}`))
Comment on lines +155 to +161

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefetch response handling can crash the whole CLI: if Qiniu returns a non-JSON body (e.g. HTML error page, empty body, proxy error), JSON.parse will throw and reject, and callers don't catch it. Consider checking res.statusCode, buffering only on expected content-types, and returning a structured error (or catching the rejection in prefetchAllUrls and treating it as retryable/failed batch) so uploads don't fail due to a transient prefetch error.

Suggested change
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
try {
resolve(JSON.parse(data))
} catch {
reject(new Error(`Failed to parse prefetch response: ${data}`))
const statusCode = res.statusCode || 0
const contentType = String(res.headers['content-type'] || '').toLowerCase()
const isJsonResponse = contentType.includes('application/json')
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
if (!data) {
resolve({
code: statusCode,
error: `Prefetch API returned an empty response${statusCode ? ` (HTTP ${statusCode})` : ''}`
})
return
}
if (!isJsonResponse) {
resolve({
code: statusCode,
error: `Prefetch API returned a non-JSON response${statusCode ? ` (HTTP ${statusCode})` : ''}: ${data}`
})
return
}
try {
resolve(JSON.parse(data))
} catch {
resolve({
code: statusCode,
error: `Failed to parse prefetch response${statusCode ? ` (HTTP ${statusCode})` : ''}: ${data}`
})

Copilot uses AI. Check for mistakes.
}
})
})

req.on('error', reject)
req.write(body)
req.end()
})
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

/**
* Prefetch all URLs in batches.
* Each batch contains at most 20 URLs, with 1 second interval between batches.
* Retries up to 3 times on QPS limit (403024) or server error (500000).
*
* @param {string[]} allUrls
* @param {{accessKey: string, secretKey: string, product?: string}} param
*/
const prefetchAllUrls = async (allUrls, { accessKey, secretKey, product }) => {
const batchSize = 20
const maxRetries = 3
const batches = []

for (let i = 0; i < allUrls.length; i += batchSize) {
batches.push(allUrls.slice(i, i + batchSize))
}

console.log(`Prefetching ${allUrls.length} URL(s) in ${batches.length} batch(es) ...`)

for (let i = 0; i < batches.length; i++) {
const batch = batches[i]
let retries = 0
let success = false

while (retries <= maxRetries && !success) {
if (retries > 0) {
const delay = retries * 2000
Comment on lines 197 to +200

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "exponential backoff", but the retry delay is currently linear (retries * 2000). Either update the delay calculation to be exponential (e.g. baseDelay * 2^(retries-1)) or adjust the comment to match the actual behavior.

Suggested change
while (retries <= maxRetries && !success) {
if (retries > 0) {
const delay = retries * 2000
const baseDelay = 2000
while (retries <= maxRetries && !success) {
if (retries > 0) {
const delay = baseDelay * Math.pow(2, retries - 1)

Copilot uses AI. Check for mistakes.
console.log(` Retry ${retries}/${maxRetries} after ${delay}ms ...`)
await sleep(delay)
}

const result = await sendPrefetchRequest(batch, { accessKey, secretKey, product })

if (result.code === 200) {
const taskCount = result.taskIds ? Object.keys(result.taskIds).length : 0
console.log(
` Batch ${i + 1}/${batches.length}: ${batch.length} URL(s) prefetched (${taskCount} task(s)).` +
` RequestId: ${result.requestId}.` +
` Quota: ${result.surplusDay}/${result.quotaDay}.`
)
success = true
} else if (result.code === 400033) {
console.error(` Batch ${i + 1}: Daily prefetch quota exceeded. Stopping.`)
return
} else if ((result.code === 403024 || result.code === 500000) && retries < maxRetries) {
console.warn(` Batch ${i + 1}: [${result.code}] ${result.error}, will retry.`)
retries++
} else {
console.error(` Batch ${i + 1}: Failed. [${result.code}] ${result.error}`)
if (result.invalidUrls && result.invalidUrls.length > 0) {
console.error(` Invalid URLs: ${result.invalidUrls.join(', ')}`)
}
success = true // Move on to next batch.
}
}

// Rate limit: wait 1 second between batches to stay within QPS limit.
if (i < batches.length - 1) {
await sleep(1000)
}
}
}

// ============================================================
// Main.
// ============================================================

const main = async () => {
await walkDir(distDirPath)

if (prefetchEnabled) {
if (uploadedKeys.length < 1) {
console.log('\nNo files were uploaded, skipping prefetch.')
return
}

const domain = prefetchDomain.replace(/\/+$/, '')
const urls = uploadedKeys.map((key) => `${domain}/${key}`)

console.log(`\nUpload completed. Starting prefetch for ${urls.length} URL(s) ...`)
await prefetchAllUrls(urls, {
accessKey: qiniuAccessKey,
secretKey: qiniuSecretKey,
product: prefetchProduct
})
console.log('Prefetch completed.')
}
}

main()
.catch(console.error)
4 changes: 2 additions & 2 deletions QiniuUploader/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion QiniuUploader/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lancercomet/qiniu-uploader",
"version": "0.1.2",
"version": "0.2.1-test",

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package version was changed to a prerelease string ("0.2.1-test"). If this is intended only for local testing, consider keeping the real semver version here (e.g. 0.2.1) and publish the prerelease via npm dist-tags or a separate prerelease versioning strategy, otherwise consumers may accidentally depend on the test build.

Suggested change
"version": "0.2.1-test",
"version": "0.2.1",

Copilot uses AI. Check for mistakes.
"description": "This package is used for uploading your assets to the Qiniu cloud.",
"main": "index.mjs",
"scripts": {},
Expand Down
Loading