diff --git a/.babelrc b/.babelrc index 71dc9031..6aff9706 100644 --- a/.babelrc +++ b/.babelrc @@ -6,7 +6,8 @@ "useBuiltIns": "usage", // 在文件需要的位置单独按需引入 polyfill "targets": { "browsers": ["last 2 versions", "ie >= 8"] - } + }, + "modules": "commonjs" } ] ], @@ -18,5 +19,6 @@ } ], "@babel/proposal-object-rest-spread" - ] + ], + "ignore": ["**/*.d.ts", "**/*.js.map"] } diff --git a/.gitignore b/.gitignore index dec7869c..dcdf3d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist test/config.json coverage lib +esm diff --git a/README.md b/README.md index 364b9f90..30f143f9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于七牛 API 开发的前端 JavaScript SDK -## 当前版本为 2.x,查看 1.x 的文档请点击[这里](https://github.com/qiniu/js-sdk/tree/1.x) +## 当前版本为 3.x,旧版本文档:[2.x](https://github.com/qiniu/js-sdk/tree/2.x),[1.x](https://github.com/qiniu/js-sdk/tree/1.x) ## 快速导航 @@ -133,7 +133,7 @@ qiniu.compressImage(file, options).then(data => { ``` ## API Reference Interface -### qiniu.upload(file: File, key: string, token: string, putExtra: object, config: object): observable +### qiniu.upload(file: File, key: string, token: string, putExtra?: object, config?: object): observable * **observable**: 为一个带有 subscribe 方法的类实例 @@ -154,10 +154,14 @@ qiniu.compressImage(file, options).then(data => { } } ``` - * next: 接收上传进度信息,res是一个带有 `total` 字段的 `object`,包含`loaded`、`total`、`percent`三个属性,提供上传进度信息。 - * total.loaded: `number`,已上传大小,单位为字节。 - * total.total: `number`,本次上传的总量控制信息,单位为字节,注意这里的 total 跟文件大小并不一致。 - * total.percent: `number`,当前上传进度,范围:0~100。 + * next: 接收上传进度信息的回调函数,回调函数参数值为 `object`,包含字段信息如下: + * uploadInfo: `object`,只有分片上传时才返回该字段 + * uploadInfo.id: 上传任务的唯一标识。 + * uploadInfo.url: 上传地址。 + * total: 包含`loaded`、`total`、`percent`三个属性: + * total.loaded: `number`,已上传大小,单位为字节。 + * total.total: `number`,本次上传的总量控制信息,单位为字节,注意这里的 total 跟文件大小并不一致。 + * total.percent: `number`,当前上传进度,范围:0~100。 * error: 上传错误后触发;自动重试本身并不会触发该错误,而当重试次数到达上限后则可以触发。当不是 xhr 请求错误时,会把当前错误产生原因直接抛出,诸如 JSON 解析异常等;当产生 xhr 请求错误时,参数 err 为一个包含 `code`、`message`、`isRequestError` 三个属性的 `object`: * err.isRequestError: 用于区分是否 xhr 请求错误;当 xhr 请求出现错误并且后端通过 HTTP 状态码返回了错误信息时,该参数为 `true`;否则为 `undefined` 。 @@ -169,10 +173,11 @@ qiniu.compressImage(file, options).then(data => { * subscription: 为一个带有 `unsubscribe` 方法的类实例,通过调用 `subscription.unsubscribe()` 停止当前文件上传。 + * **bucket**: 上传的目标空间 * **file**: `File` 对象,上传的文件 * **key**: 文件资源名 * **token**: 上传验证信息,前端通过接口请求后端获得 - * **config**: `object` + * **config**: `object`,其中的每一项都为可选 ```JavaScript const config = { @@ -189,39 +194,24 @@ qiniu.compressImage(file, options).then(data => { * config.concurrentRequestLimit: 分片上传的并发请求量,`number`,默认为3;因为浏览器本身也会限制最大并发量,所以最大并发量与浏览器有关。 * config.checkByMD5: 是否开启 MD5 校验,为布尔值;在断点续传时,开启 MD5 校验会将已上传的分片与当前分片进行 MD5 值比对,若不一致,则重传该分片,避免使用错误的分片。读取分片内容并计算 MD5 需要花费一定的时间,因此会稍微增加断点续传时的耗时,默认为 false,不开启。 * config.forceDirect: 是否上传全部采用直传方式,为布尔值;为 `true` 时则上传方式全部为直传 form 方式,禁用断点续传,默认 `false`。 + * config.chunkSize: `number`,分片上传时每片的大小,必须为正整数,单位为 `MB`,且最大不能超过 1024,默认值 4。因为 chunk 数最大 10000,所以如果文件以你所设的 `chunkSize` 进行分片并且 chunk 数超过 10000,我们会把你所设的 `chunkSize` 扩大二倍,如果仍不符合则继续扩大,直到符合条件。 - * **putExtra**: + * **putExtra**: `object`,其中的每一项都为可选 ```JavaScript const putExtra = { - fname: "", - params: {}, - mimeType: [] || null + fname: "qiniu.txt", + mimeType: "text/plain", + customVars: { 'x:test': 'qiniu', ... }, + metadata: { 'x-qn-meta': 'qiniu', ... }, }; ``` - * fname: `string`,文件原文件名 - * params: `object`,用来放置自定义变量,变量名必须以 `x:` 开始,自定义变量格式及说明请参考[文档](https://developer.qiniu.com/kodo/manual/1235/vars) - * mimeType: `null || array`,用来限制上传文件类型,为 `null` 时表示不对文件类型限制;限制类型放到数组里: - `["image/png", "image/jpeg", "image/gif"]` - -### qiniu.createMkFileUrl(url: string, file: File, key: string, putExtra: object): string - - 返回创建文件的 url;当分片上传时,我们需要把分片返回的 ctx 信息拼接后通过该 url 上传给七牛以创建文件。 - - * **url**: 上传域名,可以通过qiniu.getUploadUrl()获得 - * **file**: 文件对象 - * **key**: 文件资源名 - * **putExtra**: 同上 - - ```JavaScript - const requestUrl = qiniu.createMkFileUrl( - uploadUrl, - file, - key, - putExtra - ); - ``` + * fname: `string`,文件原始文件名,若未指定,则魔法变量中无法使用 fname、ext、suffix + * customVars: `object`,用来放置自定义变量,变量名必须以 `x:` 开始,自定义变量格式及说明请参考[文档](https://developer.qiniu.com/kodo/manual/1235/vars) + * metadata: `object`,用来防止自定义 meta,变量名必须以 `x-qn-meta-`开始,自定义资源信息格式及说明请参考 + [文档](https://developer.qiniu.com/kodo/api/1252/chgm) + * mimeType: `string`,指定所传的文件类型 ### qiniu.region: object @@ -248,31 +238,10 @@ qiniu.compressImage(file, options).then(data => { const headers = qiniu.getHeadersForChunkUpload(token) ``` -### qiniu.getHeadersForMkFile(token: string): object +### qiniu.deleteUploadedChunks(token: string, key: stting, uploadInfo: object): Promise + 删除指定上传任务中已上传完成的片,`key` 为目标文件名,`uploadInfo` 可通过 `next` 的返回获取,`token` 由服务端生成 - 返回 `object`,包含用来获得文件创建的头信息,参数为 `token` 字符串;当分片上传完需要把 ctx 信息传给七牛用来创建文件时,请求需要带该函数返回的头信息 - - ```JavaScript - const headers = qiniu.getHeadersForMkFile(token) - ``` - -### qiniu.getResumeUploadedSize(file: File): number - 断点续传时返回文件之前已上传的字节数,为 0 代表当前并无该文件的断点信息 - -### qiniu.filterParams(params: object): array - - 返回[[k, v],...]格式的数组,k 为自定义变量 `key` 名,v 为自定义变量值,用来提取 `putExtra.params` 包含的自定义变量 - - ```JavaScript - const customVarList = qiniu.filterParams(putExtra.params) - - for (let i = 0; i < customVarList.length; i++) { - const k = customVarList[i] - multipart_params_obj[k[0]] = k[1] - } - ``` -### -### qiniu.compressImage(file: File, options: object) : Promise (上传前图片压缩) +### qiniu.compressImage(file: File, options: object): Promise (上传前图片压缩) ```JavaScript const imgLink = qiniu.compressImage(file, options).then(res => { @@ -291,6 +260,10 @@ qiniu.compressImage(file, options).then(data => { * options.maxHeight: `number`,压缩图片的最大高度值 (注意:当 `maxWidth` 和 `maxHeight` 都不设置时,则采用原图尺寸大小) * options.noCompressIfLarger: `boolean`,为 `true` 时如果发现压缩后图片大小比原来还大,则返回源图片(即输出的 dist 直接返回了输入的 file);默认 `false`,即保证图片尺寸符合要求,但不保证压缩后的图片体积一定变小 + * CompressResult: `object`,包含如下字段: + * dist: 压缩后输出的 File 对象,或原始的 file,具体看下面的 options 配置 + * width: 压缩后的图片宽度 + * height: 压缩后的图片高度 ### qiniu.watermark(options: object, key?: string, domain?: string): string(水印) diff --git a/package-lock.json b/package-lock.json index 6ed4d6d3..44d576de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,284 @@ { "name": "qiniu-js", - "version": "2.6.0", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { + "@babel/cli": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.10.1.tgz", + "integrity": "sha512-cVB+dXeGhMOqViIaZs3A9OUAe4pKw4SBNdMw6yHJMYR7s4TB+Cei7ThquV/84O19PdIFWuwe03vxxES0BHUm5g==", + "dev": true, + "requires": { + "chokidar": "^2.1.8", + "commander": "^4.0.1", + "convert-source-map": "^1.1.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.0.0", + "lodash": "^4.17.13", + "make-dir": "^2.1.0", + "slash": "^2.0.0", + "source-map": "^0.5.0" + }, + "dependencies": { + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "optional": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "optional": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true, + "optional": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "optional": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "optional": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "optional": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "optional": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "optional": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "optional": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "optional": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "optional": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, "@babel/code-frame": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", @@ -2367,12 +2642,6 @@ "negotiator": "0.6.2" } }, - "acorn": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", - "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", - "dev": true - }, "acorn-globals": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", @@ -2463,12 +2732,6 @@ "integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==", "dev": true }, - "amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", - "dev": true - }, "ansi-colors": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz", @@ -2964,12 +3227,6 @@ } } }, - "base62": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/base62/-/base62-1.2.8.tgz", - "integrity": "sha512-V6YHUbjLxN1ymqNLb1DPHoU1CpfdL7d2YTIp5W3U4hhoG4hhxNmsFDs66M9EXxBiSEke5Bt5dwdfMwwZF70iLA==", - "dev": true - }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -3564,38 +3821,6 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, - "commoner": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/commoner/-/commoner-0.10.8.tgz", - "integrity": "sha1-NPw2cs0kOT6LtH5wyqApOBH08sU=", - "dev": true, - "requires": { - "commander": "^2.5.0", - "detective": "^4.3.1", - "glob": "^5.0.15", - "graceful-fs": "^4.1.2", - "iconv-lite": "^0.4.5", - "mkdirp": "^0.5.0", - "private": "^0.1.6", - "q": "^1.1.2", - "recast": "^0.11.17" - }, - "dependencies": { - "glob": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", - "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", - "dev": true, - "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -4124,12 +4349,6 @@ } } }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", - "dev": true - }, "degenerator": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz", @@ -4221,16 +4440,6 @@ "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", "dev": true }, - "detective": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/detective/-/detective-4.7.1.tgz", - "integrity": "sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig==", - "dev": true, - "requires": { - "acorn": "^5.2.1", - "defined": "^1.0.0" - } - }, "diff-sequences": { "version": "25.2.6", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz", @@ -4511,28 +4720,6 @@ "is-symbol": "^1.0.2" } }, - "es3ify": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/es3ify/-/es3ify-0.2.2.tgz", - "integrity": "sha1-Xa4+ZQ5b42hLiAZlE9Uo0JJimGI=", - "dev": true, - "requires": { - "esprima": "^2.7.1", - "jstransform": "~11.0.0", - "through": "~2.3.4" - } - }, - "es3ify-webpack-plugin": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/es3ify-webpack-plugin/-/es3ify-webpack-plugin-0.1.0.tgz", - "integrity": "sha512-bjzQ9E+PLttQVsBYg9yFLqrzMO7fu43S3p+RIP9LzuCowqyDYUgu7todGPcfjbvooIRAGYjg+4jdlRxnHa6Y3Q==", - "dev": true, - "requires": { - "es3ify": "^0.2.2", - "source-map": "^0.5.6", - "webpack-sources": "^0.1.2" - } - }, "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -4890,12 +5077,6 @@ } } }, - "esprima": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", - "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", - "dev": true - }, "esquery": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", @@ -5762,6 +5943,23 @@ } } }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true + }, "fs-write-stream-atomic": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", @@ -6057,6 +6255,27 @@ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", "dev": true }, + "handlebars": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", + "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -6181,6 +6400,12 @@ "minimalistic-assert": "^1.0.1" } }, + "highlight.js": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.1.1.tgz", + "integrity": "sha512-b4L09127uVa+9vkMgPpdUQP78ickGbHEQTWeBrQFTJZ4/n2aihWOGS0ZoUqAwjVmfjhq/C76HRzkqwZhK4sBbg==", + "dev": true + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -9341,6 +9566,15 @@ "minimist": "^1.2.5" } }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -9353,36 +9587,6 @@ "verror": "1.10.0" } }, - "jstransform": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/jstransform/-/jstransform-11.0.3.tgz", - "integrity": "sha1-CaeJk+CuTU70SH9hVakfYZDLQiM=", - "dev": true, - "requires": { - "base62": "^1.1.0", - "commoner": "^0.10.1", - "esprima-fb": "^15001.1.0-dev-harmony-fb", - "object-assign": "^2.0.0", - "source-map": "^0.4.2" - }, - "dependencies": { - "esprima-fb": { - "version": "15001.1.0-dev-harmony-fb", - "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1.0-dev-harmony-fb.tgz", - "integrity": "sha1-MKlHMDxrjV6VW+4rmbHSMyBqaQE=", - "dev": true - }, - "source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", - "dev": true, - "requires": { - "amdefine": ">=0.0.4" - } - } - } - }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -9550,6 +9754,12 @@ "yallist": "^3.0.2" } }, + "lunr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.8.tgz", + "integrity": "sha512-oxMeX/Y35PNFuZoHp+jUj5OSEmLCaIH4KTFJh7a93cHBoFmpw2IoPs22VIz7vyO2YUnx2Tn9dzIwO2P/4quIRg==", + "dev": true + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -9599,6 +9809,12 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-1.0.0.tgz", + "integrity": "sha512-Wo+L1pWTVibfrSr+TTtMuiMfNzmZWiOPeO7rZsQUY5bgsxpHesBEcIWJloWVTFnrMXnf/TL30eTFSGJddmQAng==", + "dev": true + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -10155,12 +10371,6 @@ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "dev": true }, - "object-assign": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz", - "integrity": "sha1-Q8NuXVaf+OSBbE76i+AtJpZ8GKo=", - "dev": true - }, "object-copy": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", @@ -10990,12 +11200,6 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, - "q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", - "dev": true - }, "qiniu": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/qiniu/-/qiniu-7.3.1.tgz", @@ -11209,24 +11413,13 @@ "picomatch": "^2.2.1" } }, - "recast": { - "version": "0.11.23", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", - "integrity": "sha1-RR/TAEqx5N+bTktmN2sqIZEkYtM=", + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", "dev": true, "requires": { - "ast-types": "0.9.6", - "esprima": "~3.1.0", - "private": "~0.1.5", - "source-map": "~0.5.0" - }, - "dependencies": { - "esprima": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", - "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", - "dev": true - } + "resolve": "^1.1.6" } }, "redent": { @@ -11985,6 +12178,17 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, + "shelljs": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", + "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", @@ -12241,12 +12445,6 @@ } } }, - "source-list-map": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.8.tgz", - "integrity": "sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY=", - "dev": true - }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -13068,12 +13266,6 @@ "punycode": "^2.1.1" } }, - "transform-runtime": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/transform-runtime/-/transform-runtime-0.0.0.tgz", - "integrity": "sha1-5xTZtpIR3ZU3k51Q46pXiMRCuFw=", - "dev": true - }, "trim-newlines": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz", @@ -13212,6 +13404,33 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.17.7.tgz", + "integrity": "sha512-PEnzjwQAGjb0O8a6VDE0lxyLAadqNujN5LltsTUhZETolRMiIJv6Ox+Toa8h0XhKHqAOh8MOmB0eBVcWz6nuAw==", + "dev": true, + "requires": { + "fs-extra": "^8.1.0", + "handlebars": "^4.7.6", + "highlight.js": "^10.0.0", + "lodash": "^4.17.15", + "lunr": "^2.3.8", + "marked": "1.0.0", + "minimatch": "^3.0.0", + "progress": "^2.0.3", + "shelljs": "^0.8.4", + "typedoc-default-themes": "^0.10.1" + } + }, + "typedoc-default-themes": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.10.1.tgz", + "integrity": "sha512-SuqAQI0CkwhqSJ2kaVTgl37cWs733uy9UGUqwtcds8pkFK8oRF4rZmCq+FXTGIb9hIUOu40rf5Kojg0Ha6akeg==", + "dev": true, + "requires": { + "lunr": "^2.3.8" + } + }, "typescript": { "version": "3.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", @@ -13372,6 +13591,12 @@ "imurmurhash": "^0.1.4" } }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -15057,16 +15282,6 @@ "lodash": "^4.17.15" } }, - "webpack-sources": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-0.1.5.tgz", - "integrity": "sha1-qh86vw8NdNtxEcQOUAuE+WZkB1A=", - "dev": true, - "requires": { - "source-list-map": "~0.1.7", - "source-map": "~0.5.3" - } - }, "websocket-driver": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz", @@ -15146,6 +15361,12 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, "worker-farm": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", diff --git a/package.json b/package.json index 74fffc89..5ea45acc 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "qiniu-js", "jsName": "qiniu", - "version": "2.5.5", + "version": "3.0.0", "private": false, "description": "Javascript SDK for Qiniu Resource (Cloud) Storage AP", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "esm/index.d.ts", + "module": "esm/index.js", "scripts": { - "clean": "del \"./(lib|dist)\"", + "clean": "del \"./(lib|dist|esm)\"", "test": "jest --coverage", - "build": "npm run clean && tsc && webpack --optimize-minimize --config webpack.prod.js", + "build": "npm run clean && tsc && babel esm --out-dir lib && webpack --optimize-minimize --config webpack.prod.js", "dev": "npm run lint && webpack-dev-server --open --config webpack.dev.js", "lint": "tsc --noEmit && eslint --ext .ts", "server": "node test/server.js" @@ -33,21 +34,21 @@ } ], "devDependencies": { + "@babel/cli": "^7.10.1", "@babel/core": "^7.10.2", "@babel/plugin-proposal-object-rest-spread": "^7.10.1", "@babel/plugin-transform-runtime": "^7.10.1", "@babel/preset-env": "^7.10.2", - "babel-plugin-syntax-flow": "^6.18.0", - "babel-loader": "^8.1.0", "@qiniu/eslint-config": "0.0.4", "@types/jest": "^25.2.3", "@types/node": "^13.1.4", "@types/spark-md5": "^3.0.2", "@typescript-eslint/eslint-plugin": "^2.34.0", "@typescript-eslint/parser": "^2.14.0", + "babel-loader": "^8.1.0", + "babel-plugin-syntax-flow": "^6.18.0", "body-parser": "^1.18.2", "connect-multiparty": "^2.1.0", - "es3ify-webpack-plugin": "^0.1.0", "del-cli": "^3.0.1", "eslint": "^6.8.0", "eslint-plugin-import": "^2.20.2", @@ -58,8 +59,8 @@ "qiniu": "^7.3.1", "request": "^2.88.1", "spark-md5": "^3.0.0", - "transform-runtime": "0.0.0", "ts-loader": "^6.2.1", + "typedoc": "^0.17.7", "typescript": "^3.9.5", "uglifyjs-webpack-plugin": "^2.2.0", "webpack": "^4.41.5", diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..0a629962 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,149 @@ +import * as utils from './utils' +import { regionUphostMap } from './config' +import { urlSafeBase64Encode } from './base64' +import { Config, UploadInfo } from './upload' + +interface UpHosts { + data: { + up: { + acc: { + main: string[] + } + } + } +} + +async function getUpHosts(token: string): Promise { + const putPolicy = utils.getPutPolicy(token) + const url = utils.getAPIProtocol() + '//api.qiniu.com/v2/query?ak=' + putPolicy.ak + '&bucket=' + putPolicy.bucket + return utils.request(url, { method: 'GET' }) +} + +/** 获取上传url */ +export async function getUploadUrl(config: Config, token: string): Promise { + const protocol = utils.getAPIProtocol() + + if (config.uphost) { + return `${protocol}//${config.uphost}` + } + + if (config.region) { + const upHosts = regionUphostMap[config.region] + const host = config.useCdnDomain ? upHosts.cdnUphost : upHosts.srcUphost + return `${protocol}//${host}` + } + + const res = await getUpHosts(token) + const hosts = res.data.up.acc.main + return `${protocol}//${hosts[0]}` +} + +/** + * @param bucket 空间名 + * @param key 目标文件名 + * @param uploadInfo 上传信息 + */ +function getBaseUrl(bucket: string, key: string, uploadInfo: UploadInfo) { + const { url, id } = uploadInfo + return `${url}/buckets/${bucket}/objects/${urlSafeBase64Encode(key)}/uploads/${id}` +} + +export interface InitPartsData { + /** 该文件的上传 id, 后续该文件其他各个块的上传,已上传块的废弃,已上传块的合成文件,都需要该 id */ + uploadId: string + /** uploadId 的过期时间 */ + expireAt: number +} + +/** + * @param token 上传鉴权凭证 + * @param bucket 上传空间 + * @param key 目标文件名 + * @param uploadUrl 上传地址 + */ +export function initUploadParts( + token: string, + bucket: string, + key: string, + uploadUrl: string +): utils.Response { + const url = `${uploadUrl}/buckets/${bucket}/objects/${urlSafeBase64Encode(key)}/uploads` + return utils.request( + url, + { + method: 'POST', + headers: utils.getAuthHeaders(token) + } + ) +} + +export interface UploadChunkData { + etag: string + md5: string +} + +/** + * @param token 上传鉴权凭证 + * @param index 当前 chunk 的索引 + * @param uploadInfo 上传信息 + * @param options 请求参数 + */ +export function uploadChunk( + token: string, + key: string, + index: number, + uploadInfo: UploadInfo, + options: Partial +): utils.Response { + const bucket = utils.getPutPolicy(token).bucket + const url = getBaseUrl(bucket, key, uploadInfo) + `/${index}` + return utils.request(url, { + ...options, + method: 'PUT', + headers: utils.getHeadersForChunkUpload(token) + }) +} + +export type UploadCompleteData = any + +/** + * @param token 上传鉴权凭证 + * @param key 目标文件名 + * @param uploadInfo 上传信息 + * @param options 请求参数 + */ +export function uploadComplete( + token: string, + key: string, + uploadInfo: UploadInfo, + options: Partial +): utils.Response { + const bucket = utils.getPutPolicy(token).bucket + const url = getBaseUrl(bucket, key, uploadInfo) + return utils.request(url, { + ...options, + method: 'POST', + headers: utils.getHeadersForMkFile(token) + }) +} + +/** + * @param token 上传鉴权凭证 + * @param key 目标文件名 + * @param uploadInfo 上传信息 + */ +export function deleteUploadedChunks( + token: string, + key: string, + uploadinfo: UploadInfo +): utils.Response { + const bucket = utils.getPutPolicy(token).bucket + const url = getBaseUrl(bucket, key, uploadinfo) + return utils.request( + url, + { + method: 'DELETE', + headers: utils.getAuthHeaders(token) + } + ) +} diff --git a/src/compress.ts b/src/compress.ts index 23a28438..8859c41d 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -13,6 +13,12 @@ export interface Dimension { height?: number } +export interface CompressResult { + dist: Blob + width: number + height: number +} + const mimeTypes = { PNG: 'image/png', JPEG: 'image/jpeg', @@ -42,7 +48,7 @@ class Compress { } } - async process() { + async process(): Promise { this.outputType = this.file.type const srcDimension: Dimension = {} if (!isSupportedType(this.file.type)) { @@ -87,7 +93,8 @@ class Compress { ctx.clearRect(0, 0, width, height) } } - // 通过 file 初始化 image 对象 + + /** 通过 file 初始化 image 对象 */ getOriginImage(): Promise { return new Promise((resolve, reject) => { const url = createObjectURL(this.file) @@ -187,7 +194,7 @@ class Compress { return canvas } - // 这里把 base64 字符串转为 blob 对象 + /** 这里把 base64 字符串转为 blob 对象 */ toBlob(result: HTMLCanvasElement) { const dataURL = result.toDataURL(this.outputType, this.config.quality) const buffer = atob(dataURL.split(',')[1]).split('').map(char => char.charCodeAt(0)) diff --git a/src/config.ts b/src/config.ts index e4f9dd2d..49a7f34c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +/** 上传区域 */ export const region = { z0: 'z0', z1: 'z1', @@ -6,6 +7,7 @@ export const region = { as0: 'as0' } as const +/** 上传区域对应的 host */ export const regionUphostMap = { [region.z0]: { srcUphost: 'up.qiniup.com', diff --git a/src/index.ts b/src/index.ts index a7e37201..86594a44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,27 @@ -import { region } from './config' -import { - createMkFileUrl, - getUploadUrl, - getResumeUploadedSize, - getHeadersForMkFile, - getHeadersForChunkUpload, - filterParams, - CustomError -} from './utils' import StatisticsLogger from './statisticsLog' -import { UploadManager, Extra, Config, UploadOptions, UploadProgress } from './upload' -import { imageMogr2, watermark, imageInfo, exif, pipeline } from './image' +import createUploadManager, { Extra, Config, UploadOptions, UploadProgress } from './upload' import { Observable, IObserver } from './observable' +import { CustomError } from './utils' +import { UploadCompleteData } from './api' import compressImage from './compress' const statisticsLogger = new StatisticsLogger() +/** + * @param file 上传文件 + * @param key 目标文件名 + * @param token 上传凭证 + * @param putExtra 上传文件的相关资源信息配置 + * @param config 上传任务的配置 + * @returns 返回用于上传任务的可观察对象 + */ function upload( file: File, key: string, token: string, - putExtra: Partial, - config: Partial -): Observable { + putExtra?: Partial, + config?: Partial +): Observable { const options: UploadOptions = { file, @@ -32,30 +31,26 @@ function upload( config } - return new Observable((observer: IObserver) => { - const uploadManager = new UploadManager(options, { + return new Observable((observer: IObserver) => { + const manager = createUploadManager(options, { onData: (data: UploadProgress) => observer.next(data), onError: (err: CustomError) => observer.error(err), onComplete: (res: any) => observer.complete(res) }, statisticsLogger) - uploadManager.putFile() - return uploadManager.stop.bind(uploadManager) + manager.putFile() + return manager.stop.bind(manager) }) } export { - upload, - region, - createMkFileUrl, - getHeadersForChunkUpload, - getResumeUploadedSize, getHeadersForMkFile, - filterParams, - getUploadUrl, - imageMogr2, - watermark, - imageInfo, - exif, - compressImage, - pipeline -} + getHeadersForChunkUpload +} from './utils' + +export { CompressResult } from './compress' + +export { deleteUploadedChunks, getUploadUrl } from './api' +export { imageMogr2, watermark, imageInfo, exif, pipeline } from './image' +export { region } from './config' + +export { upload, compressImage } diff --git a/src/observable.ts b/src/observable.ts index 36fa8d88..e4bc19d1 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -1,41 +1,47 @@ -// 消费者接口 -export interface IObserver { +/** 消费者接口 */ +export interface IObserver { + /** 用来接收 Observable 中的 next 类型通知 */ next: (value: T) => void + /** 用来接收 Observable 中的 error 类型通知 */ error: (err: E) => void - complete: (res: any) => void + /** 用来接收 Observable 中的 complete 类型通知 */ + complete: (res: C) => void } -export interface NextObserver { +export interface NextObserver { next: (value: T) => void error?: (err: E) => void - complete?: (res: any) => void + complete?: (res: C) => void } export interface IUnsubscribable { + /** 取消 observer 的订阅 */ unsubscribe(): void } +/** Subscription 的接口 */ export interface ISubscriptionLike extends IUnsubscribable { - unsubscribe(): void readonly closed: boolean } export type TeardownLogic = () => void -export interface ISubscribable { - subscribe(observer?: NextObserver): IUnsubscribable - subscribe(next: null | undefined, error: null | undefined, complete: () => void): IUnsubscribable - subscribe(next: null | undefined, error: (error: E) => void, complete?: () => void): IUnsubscribable - subscribe(next: (value: T) => void, error: null | undefined, complete: () => void): IUnsubscribable +export interface ISubscribable { + subscribe(observer?: NextObserver): IUnsubscribable + subscribe(next: null | undefined, error: null | undefined, complete: (res: C) => void): IUnsubscribable + subscribe(next: null | undefined, error: (error: E) => void, complete?: (res: C) => void): IUnsubscribable + subscribe(next: (value: T) => void, error: null | undefined, complete: (res: C) => void): IUnsubscribable } -// 表示可清理的资源,比如 Observable 的执行 +/** 表示可清理的资源,比如 Observable 的执行 */ class Subscription implements ISubscriptionLike { + /** 用来标示该 Subscription 是否被取消订阅的标示位 */ public closed = false + /** 清理 subscription 持有的资源 */ private _unsubscribe: TeardownLogic | undefined - // 取消 observer 的订阅 + /** 取消 observer 的订阅 */ unsubscribe() { if (this.closed) { return @@ -47,22 +53,24 @@ class Subscription implements ISubscriptionLike { } } + /** 添加一个 tear down 在该 Subscription 的 unsubscribe() 期间调用 */ add(teardown: TeardownLogic) { this._unsubscribe = teardown } } -// 实现 Observer 接口并且继承 Subscription 类. -// Observer 是消费 Observable 值的公有 API -// 所有 Observers 都转化成了 Subscriber,以便提供类似 Subscription 的能力,比如 unsubscribe -export class Subscriber extends Subscription implements IObserver { +/** + * 实现 Observer 接口并且继承 Subscription 类,Observer 是消费 Observable 值的公有 API + * 所有 Observers 都转化成了 Subscriber,以便提供类似 Subscription 的能力,比如 unsubscribe +*/ +export class Subscriber extends Subscription implements IObserver { protected isStopped = false - protected destination: Partial> + protected destination: Partial> constructor( - observerOrNext?: NextObserver | ((value: T) => void) | null, + observerOrNext?: NextObserver | ((value: T) => void) | null, error?: ((err: E) => void) | null, - complete?: ((res: any) => void) | null + complete?: ((res: C) => void) | null ) { super() @@ -99,7 +107,7 @@ export class Subscriber extends Subscription implements IObserver { } } - complete(result: any) { + complete(result: C) { if (!this.isStopped && this.destination.complete) { this.isStopped = true this.destination.complete(result) @@ -107,18 +115,19 @@ export class Subscriber extends Subscription implements IObserver { } } -export class Observable implements ISubscribable { +/** 可观察对象,当前的上传事件的集合 */ +export class Observable implements ISubscribable { - constructor(private _subscribe: (subscriber: Subscriber) => TeardownLogic) {} + constructor(private _subscribe: (subscriber: Subscriber) => TeardownLogic) {} - subscribe(observer: NextObserver): Subscription - subscribe(next: null | undefined, error: null | undefined, complete: (res: any) => void): Subscription - subscribe(next: null | undefined, error: (error: E) => void, complete?: (res: any) => void): Subscription - subscribe(next: (value: T) => void, error: null | undefined, complete: (res: any) => void): Subscription + subscribe(observer: NextObserver): Subscription + subscribe(next: null | undefined, error: null | undefined, complete: (res: C) => void): Subscription + subscribe(next: null | undefined, error: (error: E) => void, complete?: (res: C) => void): Subscription + subscribe(next: (value: T) => void, error: null | undefined, complete: (res: C) => void): Subscription subscribe( - observerOrNext?: NextObserver | ((value: T) => void) | null, + observerOrNext?: NextObserver | ((value: T) => void) | null, error?: ((err: E) => void) | null, - complete?: ((res: any) => void) | null + complete?: ((res: C) => void) | null ): Subscription { const sink = new Subscriber(observerOrNext, error, complete) sink.add(this._subscribe(sink)) diff --git a/src/upload.ts b/src/upload.ts deleted file mode 100644 index de0353d6..00000000 --- a/src/upload.ts +++ /dev/null @@ -1,387 +0,0 @@ -import * as utils from './utils' -import { Pool } from './pool' -import StatisticsLogger from './statisticsLog' -import { region } from './config' - -const BLOCK_SIZE = 4 * 1024 * 1024 - -export interface Extra { - fname: string // 文件原文件名 - params: { [key: string]: string } // 用来放置自定义变量 - mimeType: string[] | null // 用来限制上传文件类型,为 null 时表示不对文件类型限制;限制类型放到数组里: ['image/png', 'image/jpeg', 'image/gif'] -} - -export interface Config { - useCdnDomain: boolean - checkByMD5: boolean - forceDirect: boolean - retryCount: number - uphost: string - concurrentRequestLimit: number - disableStatisticsReport: boolean - region?: typeof region[keyof typeof region] -} - -export interface UploadOptions { - file: File - key: string - token: string - putExtra: Partial - config: Partial -} - -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 interface UploadProgress { - total: ProgressCompose - chunks?: ProgressCompose[] -} - -export interface CtxInfo { - time: number - ctx: string - size: number - md5: string -} - -export interface ChunkLoaded { - mkFileProgress: 0 | 1 - chunks: number[] -} - -export interface ChunkInfo { - chunk: Blob - index: number -} - -export class UploadManager { - private config: Config - private putExtra: Extra - private xhrList: XMLHttpRequest[] = [] - private xhrHandler: utils.XHRHandler = xhr => this.xhrList.push(xhr) - private file: File - private key: string - private aborted = false - private retryCount = 0 - private token: string - private uploadUrl: string - private uploadAt: number - - private onData: (data: UploadProgress) => void - private onError: (err: utils.CustomError) => void - private onComplete: (res: any) => void - private progress: UploadProgress - private ctxList: CtxInfo[] - private loaded: ChunkLoaded - private chunks: Blob[] - private localInfo: CtxInfo[] - - constructor(options: UploadOptions, handlers: UploadHandler, private statisticsLogger: StatisticsLogger) { - this.config = { - useCdnDomain: true, - disableStatisticsReport: false, - retryCount: 3, - checkByMD5: false, - uphost: '', - forceDirect: false, - concurrentRequestLimit: 3, - ...options.config - } - - this.putExtra = { - fname: '', - params: {}, - mimeType: null, - ...options.putExtra - } - - this.file = options.file - this.key = options.key - this.token = options.token - Object.assign(this, handlers) - } - - putFile() { - this.aborted = false - if (!this.putExtra.fname) { - this.putExtra.fname = this.file.name - } - - if (this.putExtra.mimeType && this.putExtra.mimeType.length) { - if (!utils.isContainFileMimeType(this.file.type, this.putExtra.mimeType)) { - const err = new Error("file type doesn't match with what you specify") - this.onError(err) - return - } - } - - const upload = utils.getUploadUrl(this.config, this.token).then(res => { - this.uploadUrl = res - this.uploadAt = new Date().getTime() - - if (this.config.forceDirect) { - return this.directUpload() - } - - return this.file.size > BLOCK_SIZE ? this.resumeUpload() : this.directUpload() - }) - - upload.then(res => { - this.onComplete(res.data) - if (!this.config.disableStatisticsReport) { - this.sendLog(res.reqId, 200) - } - }, (err: utils.CustomError) => { - - this.clear() - if (err.isRequestError && !this.config.disableStatisticsReport) { - 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 - if (needRetry && notReachRetryCount) { - this.putFile() - return - } - - this.onError(err) - }) - return upload - } - - clear() { - this.xhrList.forEach(xhr => xhr.abort()) - this.xhrList = [] - } - - stop() { - this.clear() - this.aborted = true - } - - sendLog(reqId: string, code: number) { - this.statisticsLogger.log({ - 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 - }, this.token) - } - - // 直传 - async directUpload() { - const formData = new FormData() - formData.append('file', this.file) - formData.append('token', this.token) - if (this.key != null) { - formData.append('key', this.key) - } - formData.append('fname', this.putExtra.fname) - utils.filterParams(this.putExtra.params).forEach(item => formData.append(item[0], item[1])) - - const result = await utils.request(this.uploadUrl, { - method: 'POST', - body: formData, - onProgress: data => { - this.updateDirectProgress(data.loaded, data.total) - }, - onCreate: this.xhrHandler - }) - - this.finishDirectProgress() - return result - } - - // 分片上传 - resumeUpload() { - this.initBeforeUploadChunks() - - const pool = new Pool( - (chunkInfo: ChunkInfo) => this.uploadChunk(chunkInfo), - this.config.concurrentRequestLimit - ) - const uploadChunks = this.chunks.map((chunk, index) => pool.enqueue({ chunk, index })) - - const result = Promise.all(uploadChunks).then(() => this.mkFileReq()) - result.then( - () => { - utils.removeLocalFileInfo(this.file) - }, - (err: utils.CustomError) => { - // ctx错误或者过期情况下 - if (err.code === 701) { - utils.removeLocalFileInfo(this.file) - } - } - ) - return result - } - - async uploadChunk(chunkInfo: ChunkInfo) { - const { index, chunk } = chunkInfo - const info = this.localInfo[index] - const requestUrl = this.uploadUrl + '/mkblk/' + chunk.size - - const savedReusable = info && !utils.isChunkExpired(info.time) - const shouldCheckMD5 = this.config.checkByMD5 - const reuseSaved = () => { - this.updateChunkProgress(chunk.size, index) - this.ctxList[index] = { - ctx: info.ctx, - size: info.size, - time: info.time, - md5: info.md5 - } - } - - if (savedReusable && !shouldCheckMD5) { - reuseSaved() - return - } - - const md5 = await utils.computeMd5(chunk) - - if (savedReusable && md5 === info.md5) { - reuseSaved() - return - } - - const headers = utils.getHeadersForChunkUpload(this.token) - const onProgress = (data: Progress) => { - this.updateChunkProgress(data.loaded, index) - } - const onCreate = this.xhrHandler - const method = 'POST' - - const response = await utils.request<{ ctx: string }>(requestUrl, { - method, - headers, - body: chunk, - onProgress, - onCreate - }) - // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里在每次分片上传完成后都手动更新下 progress - onProgress({ - loaded: chunk.size, - total: chunk.size - }) - - this.ctxList[index] = { - time: new Date().getTime(), - ctx: response.data.ctx, - size: chunk.size, - md5 - } - - utils.setLocalFileInfo(this.file, this.ctxList) - } - - async mkFileReq() { - const putExtra = { - mimeType: 'application/octet-stream', - ...this.putExtra - } - - const requestUrL = utils.createMkFileUrl( - this.uploadUrl, - this.file, - this.key, - putExtra - ) - - const body = this.ctxList.map(value => value.ctx).join(',') - const headers = utils.getHeadersForMkFile(this.token) - const onCreate = this.xhrHandler - const method = 'POST' - const result = await utils.request(requestUrL, { method, body, headers, onCreate }) - this.updateMkFileProgress(1) - return result - } - - updateDirectProgress(loaded: number, total: number) { - // 当请求未完成时可能进度会达到100,所以total + 1来防止这种情况出现 - this.progress = { total: this.getProgressInfoItem(loaded, total + 1) } - this.onData(this.progress) - } - - finishDirectProgress() { - // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null, 这里 fake 下 - if (!this.progress) { - this.progress = { total: this.getProgressInfoItem(this.file.size, this.file.size) } - this.onData(this.progress) - return - } - - const { total } = this.progress - this.progress = { total: this.getProgressInfoItem(total.loaded + 1, total.size) } - this.onData(this.progress) - } - - initBeforeUploadChunks() { - - this.localInfo = utils.getLocalFileInfo(this.file) - this.chunks = utils.getChunks(this.file, BLOCK_SIZE) - - this.ctxList = [] - this.loaded = { - mkFileProgress: 0, - chunks: this.chunks.map(_ => 0) - } - this.notifyResumeProgress() - } - - updateChunkProgress(loaded: number, index: number) { - this.loaded.chunks[index] = loaded - this.notifyResumeProgress() - } - - updateMkFileProgress(progress: 0 | 1) { - this.loaded.mkFileProgress = progress - this.notifyResumeProgress() - } - - notifyResumeProgress() { - this.progress = { - total: this.getProgressInfoItem( - utils.sum(this.loaded.chunks) + this.loaded.mkFileProgress, - this.file.size + 1 - ), - chunks: this.chunks.map((chunk, index) => ( - this.getProgressInfoItem(this.loaded.chunks[index], chunk.size) - )) - } - this.onData(this.progress) - } - - getProgressInfoItem(loaded: number, size: number) { - return { - loaded, - size, - percent: loaded / size * 100 - } - } -} diff --git a/src/upload/base.ts b/src/upload/base.ts new file mode 100644 index 00000000..515c1023 --- /dev/null +++ b/src/upload/base.ts @@ -0,0 +1,232 @@ +import * as utils from '../utils' +import { getUploadUrl, UploadCompleteData } from '../api' + +import StatisticsLogger from '../statisticsLog' +import { region } from '../config' + +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 + /** 是否禁止静态日志上报 */ + disableStatisticsReport: boolean + /** 分片大小,单位为 MB */ + chunkSize: number + /** 上传区域 */ + region?: typeof region[keyof typeof region] +} + +export interface UploadOptions { + file: File + key: string + token: string + putExtra?: Partial + config?: Partial +} + +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 + 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 + + constructor(options: UploadOptions, handlers: UploadHandler, private statisticsLogger: StatisticsLogger) { + this.config = { + useCdnDomain: true, + disableStatisticsReport: false, + retryCount: 3, + checkByMD5: false, + uphost: '', + forceDirect: false, + chunkSize: DEFAULT_CHUNK_SIZE, + concurrentRequestLimit: 3, + ...options.config + } + + this.putExtra = { + fname: '', + ...options.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 + + this.bucket = utils.getPutPolicy(this.token).bucket + } + + public async putFile(): Promise> { + this.aborted = false + if (!this.putExtra.fname) { + this.putExtra.fname = this.file.name + } + + if (this.file.size > 10000 * GB) { + const err = new Error('file size exceed maximum value 10000G') + this.onError(err) + throw err + } + + if (this.putExtra.customVars) { + if (!utils.isCustomVarsValid(this.putExtra.customVars)) { + const err = new Error('customVars key should start width x:') + this.onError(err) + throw err + } + } + + if (this.putExtra.metadata) { + if (!utils.isMetaDataValid(this.putExtra.metadata)) { + const err = new Error('metadata key should start with x-qn-meta-') + this.onError(err) + throw err + } + } + + try { + this.uploadUrl = await getUploadUrl(this.config, this.token) + this.uploadAt = new Date().getTime() + + const result = await this.run() + this.onComplete(result.data) + + if (!this.config.disableStatisticsReport) { + this.sendLog(result.reqId, 200) + } + + return result + + } catch (err) { + this.clear() + if (err.isRequestError && !this.config.disableStatisticsReport) { + 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) { + return this.putFile() + } + + this.onError(err) + throw err + } + } + + private clear() { + this.xhrList.forEach(xhr => xhr.abort()) + this.xhrList = [] + } + + public stop() { + this.clear() + this.aborted = true + } + + public addXhr(xhr: XMLHttpRequest) { + this.xhrList.push(xhr) + } + + private sendLog(reqId: string, code: number) { + this.statisticsLogger.log({ + 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 + }, this.token) + } + + public getProgressInfoItem(loaded: number, size: number) { + return { + loaded, + size, + percent: loaded / size * 100 + } + } +} diff --git a/src/upload/direct.ts b/src/upload/direct.ts new file mode 100644 index 00000000..65352a6d --- /dev/null +++ b/src/upload/direct.ts @@ -0,0 +1,52 @@ +import { request } from '../utils' +import Base from './base' +import { UploadCompleteData } from '../api' + +export default class Direct extends Base { + + protected async run() { + const formData = new FormData() + formData.append('file', this.file) + formData.append('token', this.token) + if (this.key != null) { + formData.append('key', this.key) + } + formData.append('fname', this.putExtra.fname) + + if (this.putExtra.customVars) { + const { customVars } = this.putExtra + Object.keys(customVars).forEach(key => formData.append(key, customVars[key].toString())) + } + + const result = await request(this.uploadUrl, { + method: 'POST', + body: formData, + onProgress: data => { + this.updateDirectProgress(data.loaded, data.total) + }, + onCreate: xhr => this.addXhr(xhr) + }) + + this.finishDirectProgress() + return result + } + + private updateDirectProgress(loaded: number, total: number) { + // 当请求未完成时可能进度会达到100,所以total + 1来防止这种情况出现 + this.progress = { total: this.getProgressInfoItem(loaded, total + 1) } + this.onData(this.progress) + } + + private finishDirectProgress() { + // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里 fake 下 + if (!this.progress) { + this.progress = { total: this.getProgressInfoItem(this.file.size, this.file.size) } + this.onData(this.progress) + return + } + + const { total } = this.progress + this.progress = { total: this.getProgressInfoItem(total.loaded + 1, total.size) } + this.onData(this.progress) + } +} diff --git a/src/upload/index.ts b/src/upload/index.ts new file mode 100644 index 00000000..c3f78b79 --- /dev/null +++ b/src/upload/index.ts @@ -0,0 +1,21 @@ +import Resume from './resume' +import Direct from './direct' +import { UploadOptions, UploadHandler, DEFAULT_CHUNK_SIZE } from './base' +import StatisticsLogger from '../statisticsLog' + +export * from './base' +export * from './resume' + +export default function createUploadManager( + options: UploadOptions, + handlers: UploadHandler, + statisticsLogger: StatisticsLogger +) { + if (options.config && options.config.forceDirect) { + return new Direct(options, handlers, statisticsLogger) + } + + return options.file.size > DEFAULT_CHUNK_SIZE + ? new Resume(options, handlers, statisticsLogger) + : new Direct(options, handlers, statisticsLogger) +} diff --git a/src/upload/resume.ts b/src/upload/resume.ts new file mode 100644 index 00000000..0f7b1134 --- /dev/null +++ b/src/upload/resume.ts @@ -0,0 +1,224 @@ +import * as utils from '../utils' +import { Pool } from '../pool' +import { uploadChunk, uploadComplete, initUploadParts, UploadChunkData } from '../api' +import Base, { Progress, UploadInfo, Extra } from './base' + +export interface UploadedChunkStorage extends UploadChunkData { + size: number +} + +export interface ChunkLoaded { + mkFileProgress: 0 | 1 + chunks: number[] +} + +export interface ChunkInfo { + chunk: Blob + index: number +} + +export interface LocalInfo { + data: UploadedChunkStorage[] + id: string +} + +export interface ChunkPart { + etag: string + partNumber: number +} + +export interface UploadChunkBody extends Extra { + parts: ChunkPart[] +} + +/** 是否为正整数 */ +function isPositiveInteger(n: number) { + var re = /^[1-9]\d*$/ + return re.test(String(n)) +} + +export default class Resume extends Base { + private chunks: Blob[] + /** 当前上传过程中已完成的上传信息 */ + private uploadedList: UploadedChunkStorage[] + /** 当前上传片进度信息 */ + private loaded: ChunkLoaded + private uploadId: string + + protected async run() { + if (!this.config.chunkSize || !isPositiveInteger(this.config.chunkSize)) { + throw new Error('chunkSize must be a positive integer') + } + + if (this.config.chunkSize > 1024) { + throw new Error('chunkSize maximum value is 1024') + } + + await this.initBeforeUploadChunks() + + const pool = new Pool( + (chunkInfo: ChunkInfo) => this.uploadChunk(chunkInfo), + this.config.concurrentRequestLimit + ) + const uploadChunks = this.chunks.map((chunk, index) => pool.enqueue({ chunk, index })) + + const result = Promise.all(uploadChunks).then(() => this.mkFileReq()) + result.then( + () => { + utils.removeLocalFileInfo(this.getLocalKey()) + }, + err => { + // uploadId 无效,上传参数有误(多由于本地存储信息的 uploadId 失效 + if (err.code === 612 || err.code === 400) { + utils.removeLocalFileInfo(this.getLocalKey()) + } + } + ) + return result + } + + private async uploadChunk(chunkInfo: ChunkInfo) { + const { index, chunk } = chunkInfo + const info = this.uploadedList[index] + + const shouldCheckMD5 = this.config.checkByMD5 + const reuseSaved = () => { + this.updateChunkProgress(chunk.size, index) + } + + if (info && !shouldCheckMD5) { + reuseSaved() + return + } + + const md5 = await utils.computeMd5(chunk) + + if (info && md5 === info.md5) { + reuseSaved() + return + } + + const onProgress = (data: Progress) => { + this.updateChunkProgress(data.loaded, index) + } + + const requestOptions = { + body: chunk, + onProgress, + onCreate: (xhr: XMLHttpRequest) => this.addXhr(xhr) + } + + const response = await uploadChunk( + this.token, + this.key, + chunkInfo.index + 1, + this.getUploadInfo(), + requestOptions + ) + + // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里在每次分片上传完成后都手动更新下 progress + onProgress({ + loaded: chunk.size, + total: chunk.size + }) + + this.uploadedList[index] = { + etag: response.data.etag, + md5: response.data.md5, + size: chunk.size + } + + utils.setLocalFileInfo(this.getLocalKey(), { + id: this.uploadId, + data: this.uploadedList + }) + + } + + private async mkFileReq() { + const data: UploadChunkBody = { + parts: this.uploadedList.map((value, index) => ({ + etag: value.etag, + partNumber: index + 1 + })), + fname: this.putExtra.fname, + ...this.putExtra.mimeType && { mimeType: this.putExtra.mimeType }, + ...this.putExtra.customVars && { customVars: this.putExtra.customVars }, + ...this.putExtra.metadata && { metadata: this.putExtra.metadata } + } + + const result = await uploadComplete( + this.token, + this.key, + this.getUploadInfo(), + { + onCreate: xhr => this.addXhr(xhr), + body: JSON.stringify(data) + } + ) + this.updateMkFileProgress(1) + return result + } + + private async initBeforeUploadChunks() { + const localInfo = utils.getLocalFileInfo(this.getLocalKey()) + // 分片必须和当时使用的 uploadId 配套,所以断点续传需要把本地存储的 uploadId 拿出来 + // 假如没有 localInfo 本地信息并重新获取 uploadId + if (!localInfo) { + // 防止本地信息已被破坏,初始化时 clear 一下 + utils.removeLocalFileInfo(this.getLocalKey()) + const res = await initUploadParts(this.token, this.bucket, this.key, this.uploadUrl) + this.uploadId = res.data.uploadId + this.uploadedList = [] + } else { + this.uploadId = localInfo.id + this.uploadedList = localInfo.data + } + + this.chunks = utils.getChunks(this.file, this.config.chunkSize) + this.loaded = { + mkFileProgress: 0, + chunks: this.chunks.map(_ => 0) + } + this.notifyResumeProgress() + } + + private getUploadInfo(): UploadInfo { + return { + id: this.uploadId, + url: this.uploadUrl + } + } + + private getLocalKey() { + return utils.createLocalKey(this.file.name, this.key, this.file.size) + } + + private updateChunkProgress(loaded: number, index: number) { + this.loaded.chunks[index] = loaded + this.notifyResumeProgress() + } + + private updateMkFileProgress(progress: 0 | 1) { + this.loaded.mkFileProgress = progress + this.notifyResumeProgress() + } + + private notifyResumeProgress() { + this.progress = { + total: this.getProgressInfoItem( + utils.sum(this.loaded.chunks) + this.loaded.mkFileProgress, + this.file.size + 1 // 防止在 complete 未调用的时候进度显示 100% + ), + chunks: this.chunks.map((chunk, index) => ( + this.getProgressInfoItem(this.loaded.chunks[index], chunk.size) + )), + uploadInfo: { + id: this.uploadId, + url: this.uploadUrl + } + } + this.onData(this.progress) + } + +} diff --git a/src/utils.ts b/src/utils.ts index 95804351..1f1bb825 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,42 +1,50 @@ import SparkMD5 from 'spark-md5' -import { urlSafeBase64Encode, urlSafeBase64Decode } from './base64' -import { regionUphostMap } from './config' -import { Config, Extra, Progress, CtxInfo } from './upload' - -// 对上传块本地存储时间检验是否过期 -// TODO: 最好用服务器时间来做判断 -export function isChunkExpired(time: number) { - const expireAt = time + 3600 * 24 * 1000 - return new Date().getTime() > expireAt -} +import { Progress, LocalInfo } from './upload' +import { urlSafeBase64Decode } from './base64' + +const MB = 1024 ** 2 // 文件分块 export function getChunks(file: File, blockSize: number): Blob[] { + + let chunkByteSize = blockSize * MB // 转换为字节 + // 如果 chunkByteSize 比文件大,则直接取文件的大小 + if (chunkByteSize > file.size) { + chunkByteSize = file.size + } else { + // 因为最多 10000 chunk,所以如果 chunkSize 不符合则把每片 chunk 大小扩大两倍 + while (file.size > chunkByteSize * 10000) { + chunkByteSize *= 2 + } + } + const chunks: Blob[] = [] - const count = Math.ceil(file.size / blockSize) + const count = Math.ceil(file.size / chunkByteSize) for (let i = 0; i < count; i++) { const chunk = file.slice( - blockSize * i, - i === count - 1 ? file.size : blockSize * (i + 1) + chunkByteSize * i, + i === count - 1 ? file.size : chunkByteSize * (i + 1) ) chunks.push(chunk) } return chunks } -export function filterParams(params: { [key: string]: string }) { - return Object.keys(params) - .filter(value => value.indexOf('x:') === 0) - .map(k => [k, params[k].toString()]) +export function isMetaDataValid(params: { [key: string]: string }) { + return Object.keys(params).every(key => key.indexOf('x-qn-meta-') === 0) +} + +export function isCustomVarsValid(params: { [key: string]: string }) { + return Object.keys(params).every(key => key.indexOf('x:') === 0) } export function sum(list: number[]) { return list.reduce((data, loaded) => data + loaded, 0) } -export function setLocalFileInfo(file: File, info: CtxInfo[]) { +export function setLocalFileInfo(localKey: string, info: LocalInfo) { try { - localStorage.setItem(createLocalKey(file), JSON.stringify(info)) + localStorage.setItem(localKey, JSON.stringify(info)) } catch (err) { if (window.console && window.console.warn) { // eslint-disable-next-line no-console @@ -45,13 +53,13 @@ export function setLocalFileInfo(file: File, info: CtxInfo[]) { } } -function createLocalKey(file: File) { - return 'qiniu_js_sdk_upload_file_' + file.name + '_size_' + file.size +export function createLocalKey(name: string, key: string, size: number): string { + return `qiniu_js_sdk_upload_file_name_${name}_key_${key}_size_${size}` } -export function removeLocalFileInfo(file: File) { +export function removeLocalFileInfo(localKey: string) { try { - localStorage.removeItem(createLocalKey(file)) + localStorage.removeItem(localKey) } catch (err) { if (window.console && window.console.warn) { // eslint-disable-next-line no-console @@ -60,54 +68,20 @@ export function removeLocalFileInfo(file: File) { } } -export function getLocalFileInfo(file: File): CtxInfo[] { +export function getLocalFileInfo(localKey: string): LocalInfo | null { try { - const localInfo = localStorage.getItem(createLocalKey(file)) - return localInfo ? JSON.parse(localInfo) : [] + const localInfo = localStorage.getItem(localKey) + return localInfo ? JSON.parse(localInfo) : null } catch (err) { if (window.console && window.console.warn) { // eslint-disable-next-line no-console console.warn('getLocalFileInfo failed') } - return [] - } -} - -export function getResumeUploadedSize(file: File) { - return getLocalFileInfo(file).filter( - value => value && !isChunkExpired(value.time) - ).reduce( - (result, value) => result + value.size, - 0 - ) -} - -// 构造file上传url -export function createMkFileUrl(url: string, file: File, key: string, putExtra: Extra) { - let requestUrl = url + '/mkfile/' + file.size - if (key != null) { - requestUrl += '/key/' + urlSafeBase64Encode(key) - } - - if (putExtra.mimeType) { - requestUrl += '/mimeType/' + urlSafeBase64Encode(file.type) - } - - const fname = putExtra.fname - if (fname) { - requestUrl += '/fname/' + urlSafeBase64Encode(fname) - } - - if (putExtra.params) { - filterParams(putExtra.params).forEach( - item => { requestUrl += '/' + encodeURIComponent(item[0]) + '/' + urlSafeBase64Encode(item[1]) } - ) + return null } - - return requestUrl } -function getAuthHeaders(token: string) { +export function getAuthHeaders(token: string) { const auth = 'UpToken ' + token return { Authorization: auth } } @@ -123,7 +97,7 @@ export function getHeadersForChunkUpload(token: string) { export function getHeadersForMkFile(token: string) { const header = getAuthHeaders(token) return { - 'content-type': 'text/plain', + 'content-type': 'application/json', ...header } } @@ -170,10 +144,10 @@ export interface ResponseSuccess { } export interface ResponseError { - code: number // 请求错误状态码,只有在 err.isRequestError 为 true 的时候才有效。可查阅码值对应说明。 - message: string // 错误信息,包含错误码,当后端返回提示信息时也会有相应的错误信息。 - isRequestError: true | undefined // 用于区分是否 xhr 请求错误当 xhr 请求出现错误并且后端通过 HTTP 状态码返回了错误信息时,该参数为 true否则为 undefined 。 - reqId: string // xhr请求错误的 X-Reqid。 + code: number /** 请求错误状态码,只有在 err.isRequestError 为 true 的时候才有效。可查阅码值对应说明。*/ + message: string /** 错误信息,包含错误码,当后端返回提示信息时也会有相应的错误信息。 */ + isRequestError: true | undefined /** 用于区分是否 xhr 请求错误当 xhr 请求出现错误并且后端通过 HTTP 状态码返回了错误信息时,该参数为 true否则为 undefined 。 */ + reqId: string /** xhr请求错误的 X-Reqid。 */ } export type CustomError = ResponseError | Error | any @@ -190,7 +164,7 @@ export interface RequestOptions { export type Response = Promise> -export function request(url: string, options: RequestOptions): Response { +export function request(url: string, options: RequestOptions): Response { return new Promise((resolve, reject) => { const xhr = createXHR() xhr.open(options.method, url) @@ -284,26 +258,7 @@ export function getDomainFromUrl(url: string): string { return '' } -// 构造区域上传url -export async function getUploadUrl(config: Config, token: string): Promise { - const protocol = getAPIProtocol() - - if (config.uphost) { - return `${protocol}//${config.uphost}` - } - - if (config.region) { - const upHosts = regionUphostMap[config.region] - const host = config.useCdnDomain ? upHosts.cdnUphost : upHosts.srcUphost - return `${protocol}//${host}` - } - - const res = await getUpHosts(token) - const hosts = res.data.up.acc.main - return `${protocol}//${hosts[0]}` -} - -function getAPIProtocol(): string { +export function getAPIProtocol(): string { if (window.location.protocol === 'http:') { return 'http:' } @@ -315,7 +270,7 @@ interface PutPolicy { scope: string } -function getPutPolicy(token: string) { +export function getPutPolicy(token: string) { const segments = token.split(':') const ak = segments[0] const putPolicy: PutPolicy = JSON.parse(urlSafeBase64Decode(segments[2])) @@ -326,27 +281,6 @@ function getPutPolicy(token: string) { } } -interface UpHosts { - data: { - up: { - acc: { - main: string[] - } - } - } -} - -async function getUpHosts(token: string): Promise { - const putPolicy = getPutPolicy(token) - const url = getAPIProtocol() + '//api.qiniu.com/v2/query?ak=' + putPolicy.ak + '&bucket=' + putPolicy.bucket - return request(url, { method: 'GET' }) - -} - -export function isContainFileMimeType(fileType: string, mimeType: string[]) { - return mimeType.indexOf(fileType) > -1 -} - export function createObjectURL(file: File) { const URL = window.URL || window.webkitURL || window.mozURL return URL.createObjectURL(file) @@ -355,7 +289,7 @@ export function createObjectURL(file: File) { export interface TransformValue { width: number height: number - matrix: [number, number, number, number, number, number] // TODO: 有没有简便的方法? + matrix: [number, number, number, number, number, number] } export function getTransform(image: HTMLImageElement, orientation: number): TransformValue { diff --git a/test/demo1/index.html b/test/demo1/index.html index 4b9901e9..80213891 100644 --- a/test/demo1/index.html +++ b/test/demo1/index.html @@ -214,7 +214,7 @@ - + diff --git a/test/demo1/main.js b/test/demo1/main.js index f6671fca..689f27ba 100644 --- a/test/demo1/main.js +++ b/test/demo1/main.js @@ -8,9 +8,7 @@ region: qiniu.region.z2 }; var putExtra = { - fname: "", - params: {}, - mimeType: null + customVars: {} }; $(".nav-box") .find("a") diff --git a/test/demo1/scripts/uploadWithSDK.js b/test/demo1/scripts/uploadWithSDK.js index 909e1e0d..b31ef101 100644 --- a/test/demo1/scripts/uploadWithSDK.js +++ b/test/demo1/scripts/uploadWithSDK.js @@ -15,7 +15,7 @@ function uploadWithSDK(token, putExtra, config, domain) { if (!board) { return; } - putExtra.params["x:name"] = key.split(".")[0]; + putExtra.customVars["x:name"] = key.split(".")[0]; board.start = true; var dom_total = $(board) .find("#totalBar") @@ -81,7 +81,7 @@ function uploadWithSDK(token, putExtra, config, domain) { compareChunks = chunks; }; - var subObject = { + var subObject = { next: next, error: error, complete: complete diff --git a/tsconfig.json b/tsconfig.json index 7c634d49..570938d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compileOnSave": true, "compilerOptions": { - "outDir": "./lib", - "target": "es3", - "module": "commonjs", + "outDir": "./esm", + "module": "es6", "lib": ["dom", "es2015", "es2016", "es2017"], + "moduleResolution": "node", "allowJs": false, "declaration": true, "downlevelIteration": true, diff --git a/webpack.common.js b/webpack.common.js index aa9a6bec..0a129c97 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -1,25 +1,7 @@ var path = require("path"); -var es3ifyPlugin = require("es3ify-webpack-plugin"); module.exports = { entry: './src/index.ts', - output: { - filename: 'qiniu.min.js', - library: 'qiniu', - libraryTarget: 'umd', - path: path.resolve(__dirname, 'dist'), - publicPath: '/dist/' - }, - module: { - rules: [ - { - test: /\.ts$/, - use: 'ts-loader', - exclude: /node_modules/ - } - ] - }, - plugins: [new es3ifyPlugin()], // TODO: 测试完毕后看是否需要 resolve: { extensions: ['.ts', '.js'] } diff --git a/webpack.dev.js b/webpack.dev.js index 38c94db9..8cf7b0ca 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -4,10 +4,26 @@ const common = require('./webpack.common.js') module.exports = merge(common, { mode: "development", + entry: './src/index.ts', + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + output: { + filename: 'qiniu.min.js', + library: 'qiniu', + libraryTarget: 'umd', + path: path.resolve(__dirname, 'webpack'), + }, devServer: { disableHostCheck: true, progress: true, - hot: false, + hot: true, proxy: { '/api/*': { target: 'http://0.0.0.0:9000', @@ -17,7 +33,7 @@ module.exports = merge(common, { }, host: '0.0.0.0', contentBase: path.join(__dirname, './'), - publicPath: '/dist/', + publicPath: '/webpack/', inline: false } }) diff --git a/webpack.prod.js b/webpack.prod.js index dfad1c8c..9242065e 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -6,21 +6,13 @@ var path = require("path"); module.exports = merge(common, { mode: "production", devtool: "source-map", - devServer: { - disableHostCheck: true, - progress: true, - proxy: { - "/api/*": { - target: "http://0.0.0.0:9000", - changeOrigin: true, - secure: false - } - }, - host: "0.0.0.0", - contentBase: path.join(__dirname, "./"), - publicPath: "/dist/", - hot: false, - inline: false + entry: './lib/index.js', + output: { + filename: 'qiniu.min.js', + library: 'qiniu', + libraryTarget: 'umd', + path: path.resolve(__dirname, 'dist'), + publicPath: '/dist/' }, plugins: [ new UglifyJSPlugin({