342 lines
9.1 KiB
TypeScript
342 lines
9.1 KiB
TypeScript
import { QiniuErrorName, QiniuError, QiniuRequestError } from '../errors'
|
|
import Logger, { LogLevel } from '../logger'
|
|
import { region } from '../config'
|
|
import * as utils from '../utils'
|
|
|
|
import { Host, HostPool } from './hosts'
|
|
|
|
export const DEFAULT_CHUNK_SIZE = 4 // 单位 MB
|
|
|
|
// code 信息地址 https://developer.qiniu.com/kodo/3928/error-responses
|
|
export const FREEZE_CODE_LIST = [0, 502, 503, 504, 599] // 将会冻结当前 host 的 code
|
|
export const RETRY_CODE_LIST = [...FREEZE_CODE_LIST, 612] // 会进行重试的 code
|
|
|
|
/** 上传文件的资源信息配置 */
|
|
export interface Extra {
|
|
/** 文件原文件名 */
|
|
fname: string
|
|
/** 用来放置自定义变量 */
|
|
customVars?: { [key: string]: string }
|
|
/** 自定义元信息 */
|
|
metadata?: { [key: string]: string }
|
|
/** 文件类型设置 */
|
|
mimeType?: string //
|
|
}
|
|
|
|
export interface InternalConfig {
|
|
/** 是否开启 cdn 加速 */
|
|
useCdnDomain: boolean
|
|
/** 是否开启服务端校验 */
|
|
checkByServer: boolean
|
|
/** 是否对分片进行 md5校验 */
|
|
checkByMD5: boolean
|
|
/** 强制直传 */
|
|
forceDirect: boolean
|
|
/** 上传失败后重试次数 */
|
|
retryCount: number
|
|
/** 自定义上传域名 */
|
|
uphost: string[]
|
|
/** 自定义分片上传并发请求量 */
|
|
concurrentRequestLimit: number
|
|
/** 分片大小,单位为 MB */
|
|
chunkSize: number
|
|
/** 上传域名协议 */
|
|
upprotocol: 'https' | 'http'
|
|
/** 上传区域 */
|
|
region?: typeof region[keyof typeof region]
|
|
/** 是否禁止统计日志上报 */
|
|
disableStatisticsReport: boolean
|
|
/** 设置调试日志输出模式,默认 `OFF`,不输出任何日志 */
|
|
debugLogLevel?: LogLevel
|
|
}
|
|
|
|
/** 上传任务的配置信息 */
|
|
export interface Config extends Partial<Omit<InternalConfig, 'upprotocol' | 'uphost'>> {
|
|
/** 上传域名协议 */
|
|
upprotocol?: InternalConfig['upprotocol'] | 'https:' | 'http:'
|
|
/** 自定义上传域名 */
|
|
uphost?: InternalConfig['uphost'] | string
|
|
}
|
|
|
|
export interface UploadOptions {
|
|
file: File
|
|
key: string | null | undefined
|
|
token: string
|
|
config: InternalConfig
|
|
putExtra?: Partial<Extra>
|
|
}
|
|
|
|
export interface UploadInfo {
|
|
id: string
|
|
url: string
|
|
}
|
|
|
|
/** 传递给外部的上传进度信息 */
|
|
export interface UploadProgress {
|
|
total: ProgressCompose
|
|
uploadInfo?: UploadInfo
|
|
chunks?: ProgressCompose[]
|
|
}
|
|
|
|
export interface UploadHandlers {
|
|
onData: (data: UploadProgress) => void
|
|
onError: (err: QiniuError) => void
|
|
onComplete: (res: any) => void
|
|
}
|
|
|
|
export interface Progress {
|
|
total: number
|
|
loaded: number
|
|
}
|
|
|
|
export interface ProgressCompose {
|
|
size: number
|
|
loaded: number
|
|
percent: number
|
|
fromCache?: boolean
|
|
}
|
|
|
|
export type XHRHandler = (xhr: XMLHttpRequest) => void
|
|
|
|
const GB = 1024 ** 3
|
|
|
|
export default abstract class Base {
|
|
protected config: InternalConfig
|
|
protected putExtra: Extra
|
|
|
|
protected aborted = false
|
|
protected retryCount = 0
|
|
|
|
protected uploadHost?: Host
|
|
protected xhrList: XMLHttpRequest[] = []
|
|
|
|
protected file: File
|
|
protected key: string | null | undefined
|
|
|
|
protected token: string
|
|
protected assessKey: string
|
|
protected bucketName: string
|
|
|
|
protected uploadAt: number
|
|
protected progress: UploadProgress
|
|
|
|
protected onData: (data: UploadProgress) => void
|
|
protected onError: (err: QiniuError) => void
|
|
protected onComplete: (res: any) => void
|
|
|
|
/**
|
|
* @returns utils.Response<any>
|
|
* @description 子类通过该方法实现具体的任务处理
|
|
*/
|
|
protected abstract run(): utils.Response<any>
|
|
|
|
constructor(
|
|
options: UploadOptions,
|
|
handlers: UploadHandlers,
|
|
protected hostPool: HostPool,
|
|
protected logger: Logger
|
|
) {
|
|
|
|
this.config = options.config
|
|
logger.info('config inited.', this.config)
|
|
|
|
this.putExtra = {
|
|
fname: '',
|
|
...options.putExtra
|
|
}
|
|
|
|
logger.info('putExtra inited.', this.putExtra)
|
|
|
|
this.key = options.key
|
|
this.file = options.file
|
|
this.token = options.token
|
|
|
|
this.onData = handlers.onData
|
|
this.onError = handlers.onError
|
|
this.onComplete = handlers.onComplete
|
|
|
|
try {
|
|
const putPolicy = utils.getPutPolicy(this.token)
|
|
this.bucketName = putPolicy.bucketName
|
|
this.assessKey = putPolicy.assessKey
|
|
} catch (error) {
|
|
logger.error('get putPolicy from token failed.', error)
|
|
this.onError(error)
|
|
}
|
|
}
|
|
|
|
// 检查并更新 upload host
|
|
protected async checkAndUpdateUploadHost() {
|
|
// 从 hostPool 中获取一个可用的 host 挂载在 this
|
|
this.logger.info('get available upload host.')
|
|
const newHost = await this.hostPool.getUp(
|
|
this.assessKey,
|
|
this.bucketName,
|
|
this.config.upprotocol
|
|
)
|
|
|
|
if (newHost == null) {
|
|
throw new QiniuError(
|
|
QiniuErrorName.NotAvailableUploadHost,
|
|
'no available upload host.'
|
|
)
|
|
}
|
|
|
|
if (this.uploadHost != null && this.uploadHost.host !== newHost.host) {
|
|
this.logger.warn(`host switches from ${this.uploadHost.host} to ${newHost.host}.`)
|
|
} else {
|
|
this.logger.info(`use host ${newHost.host}.`)
|
|
}
|
|
|
|
this.uploadHost = newHost
|
|
}
|
|
|
|
// 检查并解冻当前的 host
|
|
protected checkAndUnfreezeHost() {
|
|
this.logger.info('check unfreeze host.')
|
|
if (this.uploadHost != null && this.uploadHost.isFrozen()) {
|
|
this.logger.warn(`${this.uploadHost.host} will be unfrozen.`)
|
|
this.uploadHost.unfreeze()
|
|
}
|
|
}
|
|
|
|
// 检查并更新冻结当前的 host
|
|
private checkAndFreezeHost(error: QiniuError) {
|
|
this.logger.info('check freeze host.')
|
|
if (error instanceof QiniuRequestError && this.uploadHost != null) {
|
|
if (FREEZE_CODE_LIST.includes(error.code)) {
|
|
this.logger.warn(`${this.uploadHost.host} will be temporarily frozen.`)
|
|
this.uploadHost.freeze()
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleError(error: QiniuError) {
|
|
this.logger.error(error.message)
|
|
this.onError(error)
|
|
}
|
|
|
|
/**
|
|
* @returns Promise 返回结果与上传最终状态无关,状态信息请通过 [Subscriber] 获取。
|
|
* @description 上传文件,状态信息请通过 [Subscriber] 获取。
|
|
*/
|
|
public async putFile(): Promise<void> {
|
|
this.aborted = false
|
|
if (!this.putExtra.fname) {
|
|
this.logger.info('use file.name as fname.')
|
|
this.putExtra.fname = this.file.name
|
|
}
|
|
|
|
if (this.file.size > 10000 * GB) {
|
|
this.handleError(new QiniuError(
|
|
QiniuErrorName.InvalidFile,
|
|
'file size exceed maximum value 10000G'
|
|
))
|
|
return
|
|
}
|
|
|
|
if (this.putExtra.customVars) {
|
|
if (!utils.isCustomVarsValid(this.putExtra.customVars)) {
|
|
this.handleError(new QiniuError(
|
|
QiniuErrorName.InvalidCustomVars,
|
|
// FIXME: width => with
|
|
'customVars key should start width x:'
|
|
))
|
|
return
|
|
}
|
|
}
|
|
|
|
if (this.putExtra.metadata) {
|
|
if (!utils.isMetaDataValid(this.putExtra.metadata)) {
|
|
this.handleError(new QiniuError(
|
|
QiniuErrorName.InvalidMetadata,
|
|
'metadata key should start with x-qn-meta-'
|
|
))
|
|
return
|
|
}
|
|
}
|
|
|
|
try {
|
|
this.uploadAt = new Date().getTime()
|
|
await this.checkAndUpdateUploadHost()
|
|
const result = await this.run()
|
|
this.onComplete(result.data)
|
|
this.checkAndUnfreezeHost()
|
|
this.sendLog(result.reqId, 200)
|
|
return
|
|
} catch (err) {
|
|
if (this.aborted) {
|
|
this.logger.warn('upload is aborted.')
|
|
this.sendLog('', -2)
|
|
return
|
|
}
|
|
|
|
this.clear()
|
|
this.logger.error(err)
|
|
if (err instanceof QiniuRequestError) {
|
|
this.sendLog(err.reqId, err.code)
|
|
|
|
// 检查并冻结当前的 host
|
|
this.checkAndFreezeHost(err)
|
|
|
|
const notReachRetryCount = ++this.retryCount <= this.config.retryCount
|
|
const needRetry = RETRY_CODE_LIST.includes(err.code)
|
|
|
|
// 以下条件满足其中之一则会进行重新上传:
|
|
// 1. 满足 needRetry 的条件且 retryCount 不为 0
|
|
// 2. uploadId 无效时在 resume 里会清除本地数据,并且这里触发重新上传
|
|
if (needRetry && notReachRetryCount) {
|
|
this.logger.warn(`error auto retry: ${this.retryCount}/${this.config.retryCount}.`)
|
|
this.putFile()
|
|
return
|
|
}
|
|
}
|
|
|
|
this.onError(err)
|
|
}
|
|
}
|
|
|
|
private clear() {
|
|
this.xhrList.forEach(xhr => {
|
|
xhr.onreadystatechange = null
|
|
xhr.abort()
|
|
})
|
|
this.xhrList = []
|
|
this.logger.info('cleanup uploading xhr.')
|
|
}
|
|
|
|
public stop() {
|
|
this.logger.info('aborted.')
|
|
this.clear()
|
|
this.aborted = true
|
|
}
|
|
|
|
public addXhr(xhr: XMLHttpRequest) {
|
|
this.xhrList.push(xhr)
|
|
}
|
|
|
|
private sendLog(reqId: string, code: number) {
|
|
this.logger.report({
|
|
code,
|
|
reqId,
|
|
remoteIp: '',
|
|
upType: 'jssdk-h5',
|
|
size: this.file.size,
|
|
time: Math.floor(this.uploadAt / 1000),
|
|
port: utils.getPortFromUrl(this.uploadHost?.getUrl()),
|
|
host: utils.getDomainFromUrl(this.uploadHost?.getUrl()),
|
|
bytesSent: this.progress ? this.progress.total.loaded : 0,
|
|
duration: Math.floor((new Date().getTime() - this.uploadAt) / 1000)
|
|
})
|
|
}
|
|
|
|
public getProgressInfoItem(loaded: number, size: number, fromCache?: boolean): ProgressCompose {
|
|
return {
|
|
size,
|
|
loaded,
|
|
percent: loaded / size * 100,
|
|
...(fromCache == null ? {} : { fromCache })
|
|
}
|
|
}
|
|
}
|