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> { /** 上传域名协议 */ upprotocol?: InternalConfig['upprotocol'] | 'https:' | 'http:' /** 自定义上传域名 */ uphost?: InternalConfig['uphost'] | string } export interface UploadOptions { file: File key: string | null | undefined token: string config: InternalConfig putExtra?: Partial } 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 * @description 子类通过该方法实现具体的任务处理 */ protected abstract run(): utils.Response 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 { 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 }) } } }