All files / src/upload base.ts

15.48% Statements 13/84
0% Branches 0/28
10% Functions 1/10
16.05% Lines 13/81

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259  1x   1x   1x                                                                                                                                                             1x   1x                                                                                                                     1x                   1x                                                                                                                       1x                   1x           1x       1x                             1x             1x  
import { region } from '../config'
import { getUploadUrl } from '../api'
import Logger, { LogLevel } from '../logger'
import * as utils from '../utils'
 
export const DEFAULT_CHUNK_SIZE = 4 // 单位 MB
 
/** 上传文件的资源信息配置 */
export interface Extra {
  /** 文件原文件名 */
  fname: string
  /** 用来放置自定义变量 */
  customVars?: { [key: string]: string }
  /** 自定义元信息 */
  metadata?: { [key: string]: string }
  /** 文件类型设置 */
  mimeType?: string //
}
 
/** 上传任务的配置信息 */
export interface Config {
  /** 是否开启 cdn 加速 */
  useCdnDomain: boolean
  /** 是否对分片进行 md5校验 */
  checkByMD5: boolean
  /** 强制直传 */
  forceDirect: boolean
  /** 上传失败后重试次数 */
  retryCount: number
  /** 自定义上传域名 */
  uphost: string
  /** 自定义分片上传并发请求量 */
  concurrentRequestLimit: number
  /** 分片大小,单位为 MB */
  chunkSize: number
  /** 上传域名协议 */
  upprotocol: 'http:' | 'https:'
  /** 上传区域 */
  region?: typeof region[keyof typeof region]
  /** 是否禁止统计日志上报 */
  disableStatisticsReport: boolean
  /** 设置调试日志输出模式,默认 `OFF`,不输出任何日志 */
  debugLogLevel?: LogLevel
}
 
export interface UploadOptions {
  file: File
  key: string | null | undefined
  token: string
  putExtra?: Partial<Extra>
  config?: Partial<Config>
}
 
export interface UploadInfo {
  id: string
  url: string
}
 
/** 传递给外部的上传进度信息 */
export interface UploadProgress {
  total: ProgressCompose
  uploadInfo?: UploadInfo
  chunks?: ProgressCompose[]
}
 
export interface UploadHandler {
  onData: (data: UploadProgress) => void
  onError: (err: utils.CustomError) => void
  onComplete: (res: any) => void
}
 
export interface Progress {
  loaded: number
  total: number
}
 
export interface ProgressCompose {
  loaded: number
  size: number
  percent: number
}
 
export type XHRHandler = (xhr: XMLHttpRequest) => void
 
const GB = 1024 ** 3
 
export default abstract class Base {
  protected config: Config
  protected putExtra: Extra
  protected xhrList: XMLHttpRequest[] = []
  protected file: File
  protected key: string | null | undefined
  protected aborted = false
  protected retryCount = 0
  protected token: string
  protected uploadUrl: string
  protected bucket: string
  protected uploadAt: number
  protected progress: UploadProgress
 
  protected onData: (data: UploadProgress) => void
  protected onError: (err: utils.CustomError) => void
  protected onComplete: (res: any) => void
 
  protected abstract run(): utils.Response<any>
 
  constructor(options: UploadOptions, handlers: UploadHandler, protected logger: Logger) {
    this.config = {
      useCdnDomain: true,
      disableStatisticsReport: false,
      retryCount: 3,
      checkByMD5: false,
      uphost: '',
      upprotocol: 'https:',
      forceDirect: false,
      chunkSize: DEFAULT_CHUNK_SIZE,
      concurrentRequestLimit: 3,
      ...options.config
    }
 
    logger.info('config inited.', this.config)
 
    this.putExtra = {
      fname: '',
      ...options.putExtra
    }
 
    logger.info('putExtra inited.', this.putExtra)
 
    this.file = options.file
    this.key = options.key
    this.token = options.token
 
    this.onData = handlers.onData
    this.onError = handlers.onError
    this.onComplete = handlers.onComplete
 
    try {
      this.bucket = utils.getPutPolicy(this.token).bucket
    } catch (e) {
      logger.error('get bucket from token failed.', e)
      this.onError(e)
    }
  }
 
  private handleError(message: string) {
    const err = new Error(message)
    this.logger.error(message)
    this.onError(err)
  }
 
  /**
   * @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('file size exceed maximum value 10000G.')
      return
    }
 
    if (this.putExtra.customVars) {
      if (!utils.isCustomVarsValid(this.putExtra.customVars)) {
        this.handleError('customVars key should start width x:.')
        return
      }
    }
 
    if (this.putExtra.metadata) {
      if (!utils.isMetaDataValid(this.putExtra.metadata)) {
        this.handleError('metadata key should start with x-qn-meta-.')
        return
      }
    }
 
    try {
      this.uploadUrl = await getUploadUrl(this.config, this.token)
      this.logger.info('get uploadUrl from api.', this.uploadUrl)
      this.uploadAt = new Date().getTime()
 
      const result = await this.run()
      this.onComplete(result.data)
      this.sendLog(result.reqId, 200)
      return
    } catch (err) {
      this.logger.error(err)
 
      this.clear()
      if (err.isRequestError) {
        const reqId = this.aborted ? '' : err.reqId
        const code = this.aborted ? -2 : err.code
        this.sendLog(reqId, code)
      }
 
      const needRetry = err.isRequestError && err.code === 0 && !this.aborted
      const notReachRetryCount = ++this.retryCount <= this.config.retryCount
      // 以下条件满足其中之一则会进行重新上传:
      // 1. 满足 needRetry 的条件且 retryCount 不为 0
      // 2. uploadId 无效时在 resume 里会清除本地数据,并且这里触发重新上传
      if (needRetry && notReachRetryCount || err.code === 612) {
        this.logger.warn(`error auto retry: ${this.retryCount}/${this.config.retryCount}.`)
        this.putFile()
        return
      }
 
      this.onError(err)
    }
  }
 
  private clear() {
    this.logger.info('start cleaning all xhr.')
    this.xhrList.forEach(xhr => {
      xhr.onreadystatechange = null
      xhr.abort()
    })
    this.logger.info('cleanup completed.')
    this.xhrList = []
  }
 
  public stop() {
    this.logger.info('stop.')
    this.clear()
    this.aborted = true
  }
 
  public addXhr(xhr: XMLHttpRequest) {
    this.xhrList.push(xhr)
  }
 
  private sendLog(reqId: string, code: number) {
    this.logger.report({
      code,
      reqId,
      host: utils.getDomainFromUrl(this.uploadUrl),
      remoteIp: '',
      port: utils.getPortFromUrl(this.uploadUrl),
      duration: (new Date().getTime() - this.uploadAt) / 1000,
      time: Math.floor(this.uploadAt / 1000),
      bytesSent: this.progress ? this.progress.total.loaded : 0,
      upType: 'jssdk-h5',
      size: this.file.size
    })
  }
 
  public getProgressInfoItem(loaded: number, size: number) {
    return {
      loaded,
      size,
      percent: loaded / size * 100
    }
  }
}