├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── dev-check.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── site ├── .eslintignore ├── .eslintrc ├── .gitignore ├── package.json ├── src │ ├── components │ │ ├── App │ │ │ ├── index.tsx │ │ │ └── style.less │ │ ├── Input │ │ │ ├── index.tsx │ │ │ └── style.less │ │ ├── Layout │ │ │ ├── assets │ │ │ │ ├── logo.svg │ │ │ │ └── setting.svg │ │ │ ├── index.tsx │ │ │ └── style.less │ │ ├── Queue │ │ │ ├── assets │ │ │ │ ├── start.svg │ │ │ │ └── stop.svg │ │ │ ├── index.tsx │ │ │ ├── item.tsx │ │ │ └── style.less │ │ ├── SelectFile │ │ │ ├── assets │ │ │ │ └── folder.svg │ │ │ ├── index.tsx │ │ │ └── style.less │ │ └── Settings │ │ │ ├── assets │ │ │ └── setting.svg │ │ │ ├── index.tsx │ │ │ └── style.less │ ├── global.less │ ├── index.tsx │ ├── upload.ts │ └── utils.ts ├── tsconfig.json ├── types │ ├── byte-size.d.ts │ ├── fsvg.d.ts │ └── less.d.ts ├── webpack.config.js └── yarn.lock ├── src ├── api │ ├── index.mock.ts │ ├── index.test.ts │ └── index.ts ├── config │ ├── index.ts │ └── region.ts ├── errors │ └── index.ts ├── image │ ├── index.test.ts │ └── index.ts ├── index.ts ├── logger │ ├── index.test.ts │ ├── index.ts │ ├── report-v3.test.ts │ └── report-v3.ts ├── upload │ ├── base.ts │ ├── direct.ts │ ├── hosts.test.ts │ ├── hosts.ts │ ├── index.test.ts │ ├── index.ts │ └── resume.ts └── utils │ ├── base64.test.ts │ ├── base64.ts │ ├── compress.ts │ ├── config.test.ts │ ├── config.ts │ ├── crc32.test.ts │ ├── crc32.ts │ ├── helper.test.ts │ ├── helper.ts │ ├── index.ts │ ├── observable.ts │ ├── pool.test.ts │ └── pool.ts ├── test ├── config.json.example ├── demo1 │ ├── common │ │ └── common.js │ ├── component │ │ ├── ui.js │ │ └── widget.js │ ├── images │ │ ├── default.png │ │ ├── favicon.ico │ │ └── loading.gif │ ├── index.html │ ├── js │ │ ├── Moxie.swf │ │ ├── Moxie.xap │ │ ├── moxie.js │ │ ├── moxie.min.js │ │ └── plupload.full.min.js │ ├── main.js │ ├── scripts │ │ ├── uploadWithForm.js │ │ ├── uploadWithOthers.js │ │ └── uploadWithSDK.js │ └── style │ │ └── index.css ├── demo2 │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── style.css │ └── webpack.config.js ├── demo3 │ ├── index.html │ ├── index.js │ └── style.css └── server.js ├── tsconfig.json ├── types ├── exif.d.ts └── window.d.ts ├── webpack.common.js ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": ["last 2 versions", "ie >= 8"] 8 | }, 9 | "modules": "commonjs" 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | [ 15 | "@babel/transform-runtime", 16 | { 17 | "corejs": 2 18 | } 19 | ], 20 | "@babel/proposal-object-rest-spread" 21 | ], 22 | "ignore": ["**/*.d.ts", "**/*.js.map"] 23 | } 24 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /src/__mock__ 2 | /src/__tests__ 3 | /test/* 4 | dist 5 | site 6 | *.js 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@qiniu' 4 | ], 5 | settings: { 6 | "import/resolver": { 7 | node: { 8 | extensions: ['.js', '.ts'], 9 | moduleDirectory: ['node_modules', 'src/'] 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/dev-check.yml: -------------------------------------------------------------------------------- 1 | name: Dev check 2 | 3 | on: [pull_request,push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | cache: 'npm' 13 | node-version: 12 14 | - run: npm install 15 | - run: npm run lint 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | cache: 'npm' 23 | node-version: 12 24 | - run: npm install 25 | - run: npm run test 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-node@v3 31 | with: 32 | cache: 'npm' 33 | node-version: 12 34 | - run: npm install 35 | - run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Npm publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm install 17 | - run: npm run build 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | node_modules 4 | bower_components 5 | demo/config.js 6 | deploy.sh 7 | npm-debug.log 8 | dist 9 | test/config.json 10 | coverage 11 | lib 12 | esm 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | bower_components 4 | deploy.sh 5 | npm-debug.log 6 | .github 7 | .vscode 8 | test 9 | site 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | ".ts": 'ts-jest' 4 | }, 5 | testRegex: '.+\\.test\\.ts$', 6 | testPathIgnorePatterns: [ 7 | "esm", 8 | "lib", 9 | "examples", 10 | "node_modules" 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qiniu-js", 3 | "jsName": "qiniu", 4 | "version": "3.4.2", 5 | "private": false, 6 | "description": "Javascript SDK for Qiniu Resource (Cloud) Storage AP", 7 | "main": "lib/index.js", 8 | "types": "esm/index.d.ts", 9 | "module": "esm/index.js", 10 | "scripts": { 11 | "test": "jest --coverage", 12 | "clean": "del \"./(lib|dist|esm)\"", 13 | "build": "npm run clean && tsc && babel esm --out-dir lib && webpack --optimize-minimize --config webpack.prod.js", 14 | "dev": "webpack-dev-server --open --config webpack.dev.js", 15 | "lint": "tsc --noEmit && eslint --ext .ts src/", 16 | "server": "node test/server.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/qiniu/js-sdk.git" 21 | }, 22 | "author": "sdk@qiniu.com", 23 | "bugs": { 24 | "url": "https://github.com/qiniu/js-sdk/issues" 25 | }, 26 | "contributors": [ 27 | { 28 | "name": "jinxinxin", 29 | "email": "jinxinxin@qiniu.com" 30 | }, 31 | { 32 | "name": "winddies", 33 | "email": "zhangheng01@qiniu.com" 34 | }, 35 | { 36 | "name": "yinxulai", 37 | "email": "yinxulai@qiniu.com" 38 | } 39 | ], 40 | "devDependencies": { 41 | "@babel/cli": "^7.10.1", 42 | "@babel/core": "^7.10.2", 43 | "@babel/plugin-proposal-object-rest-spread": "^7.10.1", 44 | "@babel/plugin-transform-runtime": "^7.10.1", 45 | "@babel/preset-env": "^7.10.2", 46 | "@qiniu/eslint-config": "^0.0.6-beta.7", 47 | "@types/jest": "^26.0.23", 48 | "@types/node": "^15.3.1", 49 | "@types/spark-md5": "^3.0.2", 50 | "@typescript-eslint/eslint-plugin": "~4.10.0", 51 | "@typescript-eslint/parser": "^4.28.4", 52 | "babel-loader": "^8.1.0", 53 | "babel-plugin-syntax-flow": "^6.18.0", 54 | "body-parser": "^1.18.2", 55 | "connect-multiparty": "^2.1.0", 56 | "del-cli": "^3.0.1", 57 | "eslint": "~7.2.0", 58 | "eslint-import-resolver-typescript": "~2.3.0", 59 | "eslint-plugin-import": "~2.22.1", 60 | "eslint-plugin-jsx-a11y": "~6.3.0", 61 | "eslint-plugin-react": "~7.20.0", 62 | "eslint-plugin-react-hooks": "~4.2.0", 63 | "express": "^4.16.2", 64 | "jest": "^26.0.1", 65 | "multiparty": "^4.1.3", 66 | "qiniu": "^7.3.1", 67 | "request": "^2.88.1", 68 | "terser-webpack-plugin": "4.2.3", 69 | "ts-jest": "25.5.1", 70 | "ts-loader": "^6.2.1", 71 | "typedoc": "^0.17.7", 72 | "typescript": "^3.9.5", 73 | "webpack": "^4.41.5", 74 | "webpack-cli": "^3.3.11", 75 | "webpack-dev-server": "^3.11.0", 76 | "webpack-merge": "^4.2.2" 77 | }, 78 | "license": "MIT", 79 | "dependencies": { 80 | "@babel/runtime-corejs2": "^7.10.2", 81 | "querystring": "^0.2.1", 82 | "spark-md5": "^3.0.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /site/.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config.js 2 | -------------------------------------------------------------------------------- /site/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@qiniu" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript", 3 | "description": "React Typescript", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack serve --mode development --inline --hot --open", 8 | "start": "webpack serve --mode development", 9 | "build": "webpack --mode development", 10 | "lint": "eslint --ext .tsx,.ts src/" 11 | }, 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@qiniu/eslint-config": "0.0.6-beta.7", 15 | "@types/create-hmac": "^1.1.0", 16 | "@types/react": "^17.0.5", 17 | "@types/react-dom": "^17.0.5", 18 | "@typescript-eslint/eslint-plugin": "~4.10.0", 19 | "css-loader": "^5.2.4", 20 | "esbuild-loader": "^2.13.1", 21 | "eslint": "~7.2.0", 22 | "eslint-import-resolver-typescript": "~2.3.0", 23 | "eslint-plugin-import": "~2.22.1", 24 | "eslint-plugin-jsx-a11y": "~6.3.0", 25 | "eslint-plugin-react": "~7.20.0", 26 | "eslint-plugin-react-hooks": "~4.2.0", 27 | "eslint-webpack-plugin": "^2.5.4", 28 | "file-loader": "^6.2.0", 29 | "html-webpack-plugin": "^5.3.1", 30 | "less": "^4.1.1", 31 | "less-loader": "^9.0.0", 32 | "style-loader": "^2.0.0", 33 | "typescript": "4.1.5", 34 | "url-loader": "^4.1.1", 35 | "webpack": "^5.37.0", 36 | "webpack-cli": "^4.7.0", 37 | "webpack-dev-server": "^3.11.2", 38 | "webpackbar": "^5.0.0-3", 39 | "buffer": "^6.0.3", 40 | "stream-browserify": "^3.0.0" 41 | }, 42 | "dependencies": { 43 | "byte-size": "^7.0.1", 44 | "create-hmac": "^1.1.7", 45 | "js-base64": "^3.7.2", 46 | "react": "^17.0.2", 47 | "react-dom": "^17.0.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /site/src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { Queue } from '../Queue' 4 | import { Layout } from '../Layout' 5 | import { Settings } from '../Settings' 6 | import { SelectFile, UniqueFile } from '../SelectFile' 7 | 8 | import settingIcon from '../Settings/assets/setting.svg' 9 | import classnames from './style.less' 10 | 11 | export const App = () => { 12 | const [fileList, setFileList] = React.useState([]) 13 | const [settingVisible, setSettingVisible] = React.useState(true) 14 | 15 | const selectFile = (file: UniqueFile) => { 16 | setFileList(files => [file, ...files]) 17 | } 18 | 19 | const toggleSettingVisible = () => { 20 | setSettingVisible(!settingVisible) 21 | } 22 | 23 | const settingsClassName = React.useMemo(() => { 24 | const list = [classnames.setting] 25 | if (settingVisible) { 26 | list.push(classnames.show) 27 | } else { 28 | list.push(classnames.hidden) 29 | } 30 | return list.join(' ') 31 | }, [settingVisible]) 32 | 33 | return ( 34 | 35 |
36 | 37 |
38 | 39 |
40 | 45 |
46 | 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /site/src/components/App/style.less: -------------------------------------------------------------------------------- 1 | 2 | .content { 3 | display: flex; 4 | flex-direction: row; 5 | align-items: center; 6 | 7 | .setting { 8 | z-index: 0; 9 | position: relative; 10 | margin-left: -300px; 11 | 12 | @width: 400px; 13 | 14 | &.show { 15 | animation: show 1s; 16 | animation-fill-mode: forwards; 17 | } 18 | 19 | &.hidden { 20 | animation: hidden 1s; 21 | animation-fill-mode: forwards; 22 | } 23 | 24 | @keyframes show { 25 | from { margin-left: -@width;opacity: 0; } 26 | to { margin-left: -10px ;opacity: 1; } 27 | } 28 | 29 | @keyframes hidden { 30 | from { margin-left: -10px; opacity: 1; } 31 | to { margin-left: -@width; opacity: 0; } 32 | } 33 | } 34 | } 35 | 36 | 37 | .settingIcon { 38 | width: 20px; 39 | height: 20px; 40 | margin-left: 2rem; 41 | padding: 1rem; 42 | border-radius: 1rem; 43 | background-color: rgba(0, 0, 0, .02); 44 | cursor: pointer; 45 | transition: .3s; 46 | 47 | &:hover { 48 | background-color: rgba(0, 0, 0, .1); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /site/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import classnames from './style.less' 4 | 5 | interface IProps { 6 | value: string 7 | onChange(v: string): void 8 | 9 | placeholder?: string | undefined 10 | } 11 | 12 | export function Input(props: IProps) { 13 | return ( 14 | props.onChange(e.target.value)} /> 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /site/src/components/Input/style.less: -------------------------------------------------------------------------------- 1 | .input { 2 | padding: 0.4rem 0.6rem; 3 | 4 | font-size: 14px; 5 | font-weight: 500; 6 | 7 | border-radius: 4px; 8 | border: 2px solid transparent; 9 | box-shadow: 0px 0px 20px 10px rgba(0, 0, 0, .07) inset; 10 | 11 | &:focus { 12 | outline: none; 13 | border: 2px solid royalblue; 14 | } 15 | 16 | &::placeholder { 17 | color: rgba(0, 0, 0, 0.15); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /site/src/components/Layout/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon_logo 5 | Created with Sketch. 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /site/src/components/Layout/assets/setting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /site/src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import qiniuLogo from './assets/logo.svg' 4 | import classnames from './style.less' 5 | 6 | function Copyright() { 7 | return ( 8 | <> 9 | © {new Date().getFullYear()} 七牛云 10 | 11 | ) 12 | } 13 | 14 | function OfficialDoc() { 15 | return ( 16 | 21 | 官方文档 22 | 23 | ) 24 | } 25 | 26 | function Issue() { 27 | return ( 28 | 33 | 上报问题 34 | 35 | ) 36 | } 37 | 38 | function V2Link() { 39 | return ( 40 | 45 | V2版本 46 | 47 | ) 48 | } 49 | 50 | interface IProps { } 51 | 52 | export function Layout(props: React.PropsWithChildren) { 53 | return ( 54 | <> 55 |
56 | 57 | {props.children} 58 |
59 |
60 | 对象存储文件上传 DEMO 61 | 62 | 63 | 64 |
65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /site/src/components/Layout/style.less: -------------------------------------------------------------------------------- 1 | @footer-height: 30px; 2 | 3 | .layout { 4 | width: 100vw; 5 | min-width: 900px; 6 | min-height: calc(100vh - @footer-height); 7 | 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .footer { 15 | width: 100vw; 16 | height: @footer-height; 17 | 18 | color: #777; 19 | font-size: 12px; 20 | text-align: center; 21 | 22 | a { 23 | color: inherit; 24 | outline: none; 25 | } 26 | 27 | a:hover { 28 | color: rgb(44, 178, 255); 29 | outline: none; 30 | text-decoration: none; 31 | } 32 | 33 | a + a { 34 | margin-left: 0.5rem; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /site/src/components/Queue/assets/start.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/src/components/Queue/assets/stop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/src/components/Queue/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { UniqueFile } from '../SelectFile' 3 | 4 | import { Item } from './item' 5 | 6 | import classnames from './style.less' 7 | 8 | interface IProps { 9 | fileList: UniqueFile[] 10 | } 11 | 12 | export function Queue(props: IProps) { 13 | return ( 14 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /site/src/components/Queue/item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import byteSize from 'byte-size' 3 | import { UploadProgress } from 'qiniu-js/esm/upload' 4 | 5 | import { Status, useUpload } from '../../upload' 6 | import startIcon from './assets/start.svg' 7 | import stopIcon from './assets/stop.svg' 8 | import classnames from './style.less' 9 | 10 | interface IProps { 11 | file: File 12 | } 13 | 14 | export function Item(props: IProps) { 15 | const { 16 | stop, start, 17 | speed, speedPeak, 18 | state, error, progress, completeInfo 19 | } = useUpload(props.file) 20 | 21 | return ( 22 |
23 |
24 |
25 | 26 |
27 | {(state != null && [Status.Processing].includes(state)) && ( 28 | stop()} 31 | src={stopIcon} 32 | height="14" 33 | width="14" 34 | /> 35 | )} 36 | {(state != null && [Status.Ready, Status.Finished].includes(state)) && ( 37 | start()} 39 | className={classnames.img} 40 | src={startIcon} 41 | height="14" 42 | width="14" 43 | /> 44 | )} 45 |
46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 | ) 56 | } 57 | 58 | // 文件名 59 | function FileName(prop: { fileName: string }) { 60 | return ( 61 | 62 | {prop.fileName} 63 | 64 | ) 65 | } 66 | 67 | // 上传速度 68 | function Speed(props: { speed: number | null, peak: number | null }) { 69 | const render = (name: string, value: number) => ( 70 | 71 | {name}: 72 | 73 | {byteSize(value || 0, { precision: 2 }).toString()}/s 74 | 75 | 76 | ) 77 | 78 | return ( 79 | 80 | {render('最大上传速度', props.peak || 0)} 81 | {render('实时平均速度', props.speed || 0)} 82 | 83 | ) 84 | } 85 | 86 | // 进度条 87 | function ProgressBar(props: { progress: UploadProgress | null }) { 88 | const chunks = React.useMemo(() => { 89 | // 分片任务使用显示具体的 chunks 进度信息 90 | if (props.progress?.chunks != null) return props.progress?.chunks 91 | // 直传任务直接显示总的进度信息 92 | if (props.progress?.total != null) return [props.progress?.total] 93 | return [] 94 | }, [props.progress]) 95 | 96 | // 一行以内就需要撑开 97 | const isExpanded = chunks.length < 18 98 | 99 | return ( 100 | 111 | ) 112 | } 113 | 114 | // 错误信息 115 | function ErrorView(props: { error: any }) { 116 | return ( 117 |
console.error(props.error)} 121 | style={props.error == null ? { height: 0, padding: 0 } : {}} 122 | > 123 | {props.error?.message || '发生未知错误!'} 124 |
125 | ) 126 | } 127 | 128 | // 完成信息 129 | function CompleteView(props: { completeInfo: any }) { 130 | const render = (key: string, value: any) => ( 131 |
132 | {key}: 133 | {value} 134 |
135 | ) 136 | 137 | return ( 138 |
console.log(props.completeInfo)} 142 | style={props.completeInfo == null ? { height: 0, padding: 0 } : {}} 143 | > 144 | {Object.entries(props.completeInfo || {}).map(([key, value]) => render(key, value))} 145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /site/src/components/Queue/style.less: -------------------------------------------------------------------------------- 1 | .queue { 2 | width: 400px; 3 | padding-top: 1rem; 4 | 5 | list-style: none; 6 | margin-block-end: 0; 7 | margin-block-start: 0; 8 | padding-inline-start: 0; 9 | 10 | .item { 11 | margin: 10px 0; 12 | 13 | .content { 14 | display: flex; 15 | align-items: center; 16 | flex-direction: column; 17 | 18 | padding: 10px; 19 | border-radius: 14px; 20 | background-color: rgba(255, 255, 255, 0.3); 21 | box-shadow: 0px 5px 20px -5px rgba(0, 0, 0, 0.05); 22 | 23 | .top { 24 | position: relative; 25 | 26 | flex: 1; 27 | width: 100%; 28 | display: flex; 29 | flex-direction: row; 30 | align-items: center; 31 | justify-content: space-between; 32 | } 33 | 34 | .down { 35 | position: relative; 36 | 37 | flex: 1; 38 | width: 100%; 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | align-items: center; 43 | } 44 | } 45 | } 46 | } 47 | 48 | .img { 49 | cursor: pointer; 50 | padding: 0.5rem; 51 | border-radius: 1rem; 52 | border: 1px solid rgba(0, 0, 0, 0.1); 53 | background-color: rgb(255, 255, 255); 54 | transition: .2s; 55 | 56 | &:hover { 57 | border: 1px solid rgba(0, 0, 0, 0.0); 58 | box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.2); 59 | } 60 | } 61 | 62 | .progressBar { 63 | width: 100%; 64 | position: relative; 65 | 66 | display: flex; 67 | flex-wrap: wrap; 68 | flex-direction: row; 69 | align-items: center; 70 | 71 | list-style: none; 72 | padding-inline-start: 0; 73 | 74 | .expanded { 75 | flex: 1; 76 | } 77 | 78 | li { 79 | margin: 1px; 80 | padding: 1px; 81 | min-width: 4px; 82 | border-radius: 2px; 83 | border: #333 solid 1px; 84 | 85 | span { 86 | height: 10px; 87 | display: block; 88 | background-color: rgb(119, 140, 255); 89 | } 90 | 91 | .cachedChunk { 92 | background-color: rgb(53, 66, 139); 93 | } 94 | } 95 | } 96 | 97 | .fileName { 98 | font-size: 14px; 99 | text-overflow: ellipsis; 100 | white-space: nowrap; 101 | overflow: hidden 102 | } 103 | 104 | .speed { 105 | width: 100%; 106 | display: flex; 107 | flex-direction: row; 108 | justify-content: flex-start; 109 | 110 | .speedItem { 111 | padding: 4px; 112 | display: flex; 113 | flex-direction: row; 114 | align-items: center; 115 | 116 | .speedTitle { 117 | color: rgba(51, 51, 51, 0.8); 118 | font-size: 10px; 119 | margin-right: 0.5rem; 120 | } 121 | 122 | .speedValue { 123 | font-size: 14px; 124 | font-weight: bold; 125 | color: rgb(255, 78, 78); 126 | } 127 | } 128 | } 129 | 130 | .complete, .error { 131 | width: 70%; 132 | margin: 0 auto; 133 | color: white; 134 | font-size: 14px; 135 | text-align: center; 136 | padding: 0.2rem 1rem; 137 | border-radius: 0 0 10px 10px; 138 | overflow: hidden; 139 | cursor: pointer; 140 | transition: .2s; 141 | } 142 | 143 | .error { 144 | background-color: rgb(226, 46, 46, 0.5); 145 | } 146 | 147 | .complete { 148 | background-color: rgba(235, 235, 235, 0.788); 149 | 150 | .completeItem { 151 | display: flex; 152 | 153 | .key { 154 | color: #333; 155 | font-size: 10px; 156 | font-weight: bold; 157 | margin-right: 0.5rem; 158 | text-overflow: ellipsis; 159 | white-space: nowrap; 160 | overflow: hidden 161 | } 162 | 163 | .value { 164 | flex: 1; 165 | color: #333; 166 | font-size: 14px; 167 | text-align: left; 168 | 169 | text-overflow: ellipsis; 170 | white-space: nowrap; 171 | overflow: hidden 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /site/src/components/SelectFile/assets/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /site/src/components/SelectFile/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import folderIcon from './assets/folder.svg' 4 | import classnames from './style.less' 5 | 6 | export interface UniqueFile { 7 | key: string 8 | file: File 9 | } 10 | 11 | interface IProps { 12 | onFile(file: UniqueFile): void 13 | } 14 | 15 | enum State { 16 | Drop, 17 | Over, 18 | Leave 19 | } 20 | 21 | export function SelectFile(props: IProps): React.ReactElement { 22 | const [state, setState] = React.useState(null) 23 | const inputRef = React.useRef(null) 24 | 25 | const onClick = () => { 26 | if (!inputRef.current) return 27 | inputRef.current.value = '' 28 | inputRef.current.click() 29 | } 30 | 31 | const onDrop = (event: React.DragEvent) => { 32 | event.preventDefault() 33 | if (!event || !event.dataTransfer || !event.dataTransfer.files) return 34 | Array.from(event.dataTransfer.files).forEach(file => props.onFile({ 35 | key: Date.now() + file.name, 36 | file 37 | })) 38 | setState(State.Drop) 39 | } 40 | 41 | const onChange = (event: React.ChangeEvent) => { 42 | if (!inputRef || !event || !event.target || !event.target.files) return 43 | Array.from(event.target.files).forEach(file => props.onFile({ 44 | key: Date.now() + file.name, 45 | file 46 | })) 47 | } 48 | 49 | // 阻止默认的拖入文件处理 50 | React.useEffect(() => { 51 | const handler = (e: any) => e.preventDefault() 52 | document.addEventListener('dragover', handler) 53 | return () => { 54 | document.removeEventListener('dragover', handler) 55 | } 56 | }, []) 57 | 58 | return ( 59 |
setState(State.Over)} 64 | onDragLeave={() => setState(State.Leave)} 65 | > 66 |
67 | 68 | 69 | 70 | {State.Over === state && (

松开释放文件

)} 71 | {(state == null || State.Over !== state) && ( 72 |

点击选择 OR 拖入文件

73 | )} 74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /site/src/components/SelectFile/style.less: -------------------------------------------------------------------------------- 1 | .selectFile { 2 | position: relative; 3 | z-index: 1; 4 | 5 | padding: 2rem; 6 | border-radius: 1.5rem; 7 | background-color: white; 8 | box-shadow: 0px 20px 40px -10px rgba(0, 0, 0, 0.2); 9 | } 10 | 11 | .card { 12 | display: flex; 13 | align-items: center; 14 | flex-direction: column; 15 | justify-content: center; 16 | 17 | width: 320px; 18 | height: 200px; 19 | border-radius: 0.8rem; 20 | border: 1px dashed rgb(163, 163, 163); 21 | background-color: rgb(248, 251, 255); 22 | 23 | cursor: pointer; 24 | transition: .3s; 25 | 26 | &:hover { 27 | background-color: rgb(232, 237, 243); 28 | } 29 | 30 | &:active { 31 | background-color: rgb(203, 212, 223); 32 | } 33 | 34 | .title { 35 | text-align: center; 36 | margin-top: 1rem; 37 | margin-bottom: 0; 38 | font-weight: bold; 39 | } 40 | 41 | input { 42 | position: absolute; 43 | overflow: hidden; 44 | height: 0; 45 | width: 0; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /site/src/components/Settings/assets/setting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /site/src/components/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as utils from '../../utils' 3 | 4 | import { Input } from '../Input' 5 | import classnames from './style.less' 6 | 7 | interface IProps { } 8 | 9 | export function Settings(props: IProps) { 10 | const setting = React.useMemo(() => utils.loadSetting(), []) 11 | 12 | const [deadline, setDeadline] = React.useState(0) 13 | const [uphost, seUphost] = React.useState(setting.uphost || '') 14 | const [assessKey, setAssessKey] = React.useState(setting.assessKey || '') 15 | const [secretKey, setSecretKey] = React.useState(setting.secretKey || '') 16 | const [bucketName, setBucketName] = React.useState(setting.bucketName || '') 17 | 18 | React.useEffect(() => { 19 | utils.saveSetting({ 20 | assessKey, 21 | secretKey, 22 | bucketName, 23 | deadline, 24 | uphost 25 | }) 26 | }, [assessKey, secretKey, bucketName, deadline, uphost]) 27 | 28 | React.useEffect(()=> { 29 | if (deadline > 0) return 30 | // 基于当前时间加上 3600s 31 | setDeadline(Math.floor(Date.now() / 1000) + 3600) 32 | },[deadline]) 33 | 34 | return ( 35 |
36 |
37 | 38 | assessKey: 39 | setAssessKey(v)} placeholder="请输入 assessKey" /> 40 | 41 | 42 | secretKey: 43 | setSecretKey(v)} placeholder="请输入 secretKey" /> 44 | 45 | 46 | bucketName: 47 | setBucketName(v)} placeholder="请输入 bucketName" /> 48 | 49 | 50 | uphost: 51 | seUphost(v)} placeholder="可选,多个用 , 隔开" /> 52 | 53 | 54 | deadline: 55 | setDeadline(+(v || 0))} placeholder="可选,请输入 deadline" /> 56 | 57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /site/src/components/Settings/style.less: -------------------------------------------------------------------------------- 1 | .settings { 2 | position: relative; 3 | z-index: 1; 4 | 5 | padding: 2rem; 6 | border-radius: 1rem; 7 | background-color: white; 8 | box-shadow: 0px 10px 20px -10px rgba(0, 0, 0, 0.2); 9 | overflow: hidden; 10 | transition: .3s; 11 | 12 | .content { 13 | display: flex; 14 | flex-direction: column; 15 | 16 | > span { 17 | overflow:hidden; 18 | } 19 | 20 | .title { 21 | width: 100px; 22 | color: #333; 23 | font-size: 14px; 24 | font-weight: 500; 25 | display: inline-block; 26 | } 27 | 28 | .title + * { 29 | width: 200px; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /site/src/global.less: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: rgb(246, 248, 250); 4 | } 5 | -------------------------------------------------------------------------------- /site/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | 4 | import { App } from './components/App' 5 | import './global.less' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /site/src/upload.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { upload } from '../../src' 4 | import { UploadProgress } from '../../src/upload' 5 | 6 | import { generateUploadToken, loadSetting } from './utils' 7 | 8 | export enum Status { 9 | Ready, // 准备好了 10 | Processing, // 上传中 11 | Finished // 任务已结束(完成、失败、中断) 12 | } 13 | 14 | // 上传逻辑封装 15 | export function useUpload(file: File) { 16 | const startTimeRef = React.useRef(null) 17 | const [state, setState] = React.useState(null) 18 | const [error, setError] = React.useState(null) 19 | const [token, setToken] = React.useState(null) 20 | const [speedPeak, setSpeedPeak] = React.useState(null) 21 | const [completeInfo, setCompleteInfo] = React.useState(null) 22 | const [progress, setProgress] = React.useState(null) 23 | const [observable, setObservable] = React.useState | null>(null) 24 | const subscribeRef = React.useRef['subscribe']> | null>(null) 25 | 26 | // 开始上传文件 27 | const start = () => { 28 | startTimeRef.current = Date.now() 29 | setCompleteInfo(null) 30 | setProgress(null) 31 | setError(null) 32 | 33 | subscribeRef.current = observable?.subscribe({ 34 | error: newError => { setState(Status.Finished); setError(newError) }, 35 | next: newProgress => { setState(Status.Processing); setProgress(newProgress) }, 36 | complete: newInfo => { setState(Status.Finished); setError(null); setCompleteInfo(newInfo) } 37 | }) || null 38 | } 39 | 40 | // 停止上传文件 41 | const stop = () => { 42 | const subscribe = subscribeRef.current 43 | if (state === Status.Processing && subscribe && !subscribe.closed) { 44 | setState(Status.Finished) 45 | subscribe.unsubscribe() 46 | } 47 | } 48 | 49 | // 获取上传速度 50 | const speed = React.useMemo(() => { 51 | if (progress == null || progress.total == null || progress.total.loaded == null) return 0 52 | const duration = (Date.now() - (startTimeRef.current || 0)) / 1000 53 | 54 | if (Array.isArray(progress.chunks)) { 55 | const size = progress.chunks.reduce(((acc, cur) => ( 56 | !cur.fromCache ? cur.loaded + acc : acc 57 | )), 0) 58 | 59 | return size > 0 ? Math.floor(size / duration) : 0 60 | } 61 | 62 | return progress.total.loaded > 0 63 | ? Math.floor(progress.total.loaded / duration) 64 | : 0 65 | }, [progress, startTimeRef]) 66 | 67 | // 获取 token 68 | React.useEffect(() => { 69 | const { assessKey, secretKey, bucketName, deadline } = loadSetting() 70 | if (!assessKey || !secretKey || !bucketName || !deadline) { 71 | setError(new Error('请点开设置并输入必要的配置信息')) 72 | return 73 | } 74 | 75 | // 线上应该使用服务端生成 token 76 | setToken(generateUploadToken({ assessKey, secretKey, bucketName, deadline })) 77 | }, [file]) 78 | 79 | // 创建上传任务 80 | React.useEffect(() => { 81 | const { uphost } = loadSetting() 82 | 83 | if (token != null) { 84 | setState(Status.Ready) 85 | setObservable(upload( 86 | file, 87 | file.name, 88 | token, 89 | { 90 | metadata: { 91 | 'x-qn-meta-test': 'tt', 92 | 'x-qn-meta-test1': '222', 93 | 'x-qn-meta-test2': '333', 94 | } 95 | }, 96 | { 97 | checkByMD5: true, 98 | debugLogLevel: 'INFO', 99 | uphost: uphost && uphost.split(',') 100 | } 101 | )) 102 | } 103 | }, [file, token]) 104 | 105 | // 计算峰值上传速度 106 | React.useEffect(() => { 107 | if (speed == null) { 108 | setSpeedPeak(0) 109 | return 110 | } 111 | 112 | if (speed > (speedPeak || 0)) { 113 | setSpeedPeak(speed) 114 | } 115 | }, [speed, speedPeak]) 116 | 117 | return { start, stop, state, progress, error, completeInfo, speed, speedPeak } 118 | } 119 | -------------------------------------------------------------------------------- /site/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64' 2 | import * as createHmac from 'create-hmac' 3 | 4 | export interface TokenOptions { 5 | assessKey?: string 6 | secretKey?: string 7 | bucketName?: string 8 | deadline?: number 9 | } 10 | 11 | function base64UrlSafeEncode(target: string): string { 12 | return target.replace(/\//g, '_').replace(/\+/g, '-') 13 | } 14 | 15 | export function generateUploadToken(options: Required) { 16 | const { deadline, bucketName, assessKey, secretKey } = options 17 | 18 | const hmacEncoder = createHmac('sha1', secretKey) 19 | const putPolicy = JSON.stringify({ scope: bucketName, deadline }) 20 | const encodedPutPolicy = base64UrlSafeEncode(Base64.encode(putPolicy)) 21 | const sign = base64UrlSafeEncode(hmacEncoder.update(encodedPutPolicy).digest('base64')) 22 | const token = `${assessKey}:${sign}:${encodedPutPolicy}` 23 | return token 24 | } 25 | 26 | export interface SettingsData extends TokenOptions { 27 | uphost?: string 28 | } 29 | 30 | // 加载配置,此配置由 Setting 组件设置 31 | export function loadSetting(): SettingsData { 32 | const data = localStorage.getItem('setting') 33 | if (data != null) return JSON.parse(data) 34 | return {} 35 | } 36 | 37 | export function saveSetting(data: SettingsData) { 38 | localStorage.setItem('setting', JSON.stringify(data)) 39 | } 40 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "es6", 5 | "module": "es6", 6 | "strict": true, 7 | "sourceMap": true, 8 | "moduleResolution": "node", 9 | "typeRoots": [ 10 | "node_modules/@types", 11 | "types" 12 | ] 13 | }, 14 | "include": [ 15 | "src", 16 | "types" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /site/types/byte-size.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'byte-size' { 2 | interface Result { 3 | value: string 4 | unit: string 5 | long: string 6 | 7 | toString(): string 8 | } 9 | 10 | const byteSize: (v: number, options: { precision: number }) => Result 11 | export default byteSize 12 | } 13 | -------------------------------------------------------------------------------- /site/types/fsvg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const exported: string 3 | export default exported 4 | } 5 | -------------------------------------------------------------------------------- /site/types/less.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.less' { 2 | const exported: { 3 | [key: string]: string 4 | } 5 | export default exported 6 | } 7 | -------------------------------------------------------------------------------- /site/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { HotModuleReplacementPlugin } = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const ESLintPlugin = require('eslint-webpack-plugin') 5 | const WebpackBar = require('webpackbar') 6 | 7 | const htmlTemp = ` 8 | 9 | 10 | 11 | 12 | 七牛云 - JS SDK 示例 V3 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | ` 23 | 24 | module.exports = { 25 | context: path.join(__dirname, 'src'), 26 | devtool: 'source-map', 27 | 28 | resolve: { 29 | extensions: ['.js', '.ts', '.tsx'], 30 | alias: { 31 | buffer: require.resolve("buffer/"), 32 | stream: require.resolve("stream-browserify") 33 | }, 34 | }, 35 | entry: ['./index.tsx'], 36 | 37 | output: { 38 | filename: 'bundle.js', 39 | path: path.join(__dirname, 'dist') 40 | }, 41 | 42 | devServer: { 43 | port: 7777, 44 | inline: true, 45 | host: '0.0.0.0', 46 | stats: 'errors-only', 47 | contentBase: './dist' 48 | }, 49 | 50 | module: { 51 | rules: [ 52 | { 53 | test: /\.tsx?$/, 54 | exclude: /node_modules/, 55 | loader: 'esbuild-loader', 56 | options: { 57 | loader: 'tsx', 58 | target: 'es2015', 59 | tsconfigRaw: require('./tsconfig.json') 60 | } 61 | }, 62 | { 63 | test: /\.less$/, 64 | use: [ 65 | "style-loader", 66 | { 67 | loader: 'css-loader', 68 | options: { 69 | modules: { 70 | localIdentName: '[name]@[local]:[hash:base64:5]' 71 | } 72 | } 73 | }, 74 | "less-loader" 75 | ] 76 | }, 77 | { 78 | test: /\.(png|jpg|gif|svg)$/, 79 | loader: 'file-loader', 80 | options: { 81 | name: 'static/img/[name].[ext]?[hash]', 82 | esModule: false 83 | } 84 | } 85 | ] 86 | }, 87 | 88 | plugins: [ 89 | new HtmlWebpackPlugin({ 90 | templateContent: htmlTemp, 91 | inject: 'head' 92 | }), 93 | new HotModuleReplacementPlugin(), 94 | new ESLintPlugin(), 95 | new WebpackBar() 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /src/api/index.mock.ts: -------------------------------------------------------------------------------- 1 | import { QiniuNetworkError, QiniuRequestError } from '../errors' 2 | import * as api from '.' 3 | 4 | export const errorMap = { 5 | networkError: new QiniuNetworkError('mock', 'message'), // 网络错误 6 | 7 | invalidParams: new QiniuRequestError(400, 'mock', 'message'), // 无效的参数 8 | expiredToken: new QiniuRequestError(401, 'mock', 'message'), // token 过期 9 | 10 | gatewayUnavailable: new QiniuRequestError(502, 'mock', 'message'), // 网关不可用 11 | serviceUnavailable: new QiniuRequestError(503, 'mock', 'message'), // 服务不可用 12 | serviceTimeout: new QiniuRequestError(504, 'mock', 'message'), // 服务超时 13 | serviceError: new QiniuRequestError(599, 'mock', 'message'), // 服务错误 14 | 15 | invalidUploadId: new QiniuRequestError(612, 'mock', 'message') // 无效的 upload id 16 | } 17 | 18 | export type ApiName = 19 | | 'direct' 20 | | 'getUpHosts' 21 | | 'uploadChunk' 22 | | 'uploadComplete' 23 | | 'initUploadParts' 24 | | 'deleteUploadedChunks' 25 | 26 | export class MockApi { 27 | constructor() { 28 | this.direct = this.direct.bind(this) 29 | this.getUpHosts = this.getUpHosts.bind(this) 30 | this.uploadChunk = this.uploadChunk.bind(this) 31 | this.uploadComplete = this.uploadComplete.bind(this) 32 | this.initUploadParts = this.initUploadParts.bind(this) 33 | this.deleteUploadedChunks = this.deleteUploadedChunks.bind(this) 34 | } 35 | 36 | private interceptorMap = new Map() 37 | public clearInterceptor() { 38 | this.interceptorMap.clear() 39 | } 40 | 41 | public setInterceptor(name: 'direct', interceptor: typeof api.direct): void 42 | public setInterceptor(name: 'getUpHosts', interceptor: typeof api.getUpHosts): void 43 | public setInterceptor(name: 'uploadChunk', interceptor: typeof api.uploadChunk): void 44 | public setInterceptor(name: 'uploadComplete', interceptor: typeof api.uploadComplete): void 45 | public setInterceptor(name: 'initUploadParts', interceptor: typeof api.initUploadParts): void 46 | public setInterceptor(name: 'deleteUploadedChunks', interceptor: typeof api.deleteUploadedChunks): void 47 | public setInterceptor(name: ApiName, interceptor: any): void 48 | public setInterceptor(name: any, interceptor: any): void { 49 | this.interceptorMap.set(name, interceptor) 50 | } 51 | 52 | private callInterceptor(name: ApiName, defaultValue: any): any { 53 | const interceptor = this.interceptorMap.get(name) 54 | if (interceptor != null) { 55 | return interceptor() 56 | } 57 | 58 | return defaultValue 59 | } 60 | 61 | public direct(): ReturnType { 62 | const defaultData: ReturnType = Promise.resolve({ 63 | reqId: 'req-id', 64 | data: { 65 | fsize: 270316, 66 | bucket: 'test2222222222', 67 | hash: 'Fs_k3kh7tT5RaFXVx3z1sfCyoa2Y', 68 | name: '84575bc9e34412d47cf3367b46b23bc7e394912a', 69 | key: '84575bc9e34412d47cf3367b46b23bc7e394912a.html' 70 | } 71 | }) 72 | 73 | return this.callInterceptor('direct', defaultData) 74 | } 75 | 76 | public getUpHosts(): ReturnType { 77 | const defaultData: ReturnType = Promise.resolve({ 78 | reqId: 'req-id', 79 | data: { 80 | ttl: 86400, 81 | io: { src: { main: ['iovip-z2.qbox.me'] } }, 82 | up: { 83 | acc: { 84 | main: ['upload-z2.qiniup.com'], 85 | backup: ['upload-dg.qiniup.com', 'upload-fs.qiniup.com'] 86 | }, 87 | old_acc: { main: ['upload-z2.qbox.me'], info: 'compatible to non-SNI device' }, 88 | old_src: { main: ['up-z2.qbox.me'], info: 'compatible to non-SNI device' }, 89 | src: { main: ['up-z2.qiniup.com'], backup: ['up-dg.qiniup.com', 'up-fs.qiniup.com'] } 90 | }, 91 | uc: { acc: { main: ['uc.qbox.me'] } }, 92 | rs: { acc: { main: ['rs-z2.qbox.me'] } }, 93 | rsf: { acc: { main: ['rsf-z2.qbox.me'] } }, 94 | api: { acc: { main: ['api-z2.qiniu.com'] } } 95 | } 96 | }) 97 | 98 | return this.callInterceptor('getUpHosts', defaultData) 99 | } 100 | 101 | public uploadChunk(): ReturnType { 102 | const defaultData: ReturnType = Promise.resolve({ 103 | reqId: 'req-id', 104 | data: { 105 | etag: 'FuYYVJ1gmVCoGk5C5r5ftrLXxE6m', 106 | md5: '491309eddd8e7233e14eaa25216594b4' 107 | } 108 | }) 109 | 110 | return this.callInterceptor('uploadChunk', defaultData) 111 | } 112 | 113 | public uploadComplete(): ReturnType { 114 | const defaultData: ReturnType = Promise.resolve({ 115 | reqId: 'req-id', 116 | data: { 117 | key: 'test.zip', 118 | hash: 'lsril688bAmXn7kiiOe9fL4mpc39', 119 | fsize: 11009649, 120 | bucket: 'test', 121 | name: 'test' 122 | } 123 | }) 124 | 125 | return this.callInterceptor('uploadComplete', defaultData) 126 | } 127 | 128 | public initUploadParts(): ReturnType { 129 | const defaultData: ReturnType = Promise.resolve({ 130 | reqId: 'req-id', 131 | data: { uploadId: '60878b9408bc044043f5d74f', expireAt: 1620100628 } 132 | }) 133 | 134 | return this.callInterceptor('initUploadParts', defaultData) 135 | } 136 | 137 | public deleteUploadedChunks(): ReturnType { 138 | const defaultData: ReturnType = Promise.resolve({ 139 | reqId: 'req-id', 140 | data: undefined 141 | }) 142 | 143 | return this.callInterceptor('deleteUploadedChunks', defaultData) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/api/index.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CHUNK_SIZE, Config } from '../upload' 2 | import { region } from '../config' 3 | 4 | import { getUploadUrl } from '.' 5 | 6 | jest.mock('../utils', () => ({ 7 | ...jest.requireActual('../utils') as any, 8 | 9 | request: () => Promise.resolve({ 10 | data: { 11 | up: { 12 | acc: { 13 | main: ['mock.qiniu.com'] 14 | } 15 | } 16 | } 17 | }), 18 | getPutPolicy: () => ({ 19 | ak: 'ak', 20 | bucket: 'bucket' 21 | }) 22 | })) 23 | 24 | describe('api function test', () => { 25 | test('getUploadUrl', async () => { 26 | const config: Config = { 27 | useCdnDomain: true, 28 | disableStatisticsReport: false, 29 | retryCount: 3, 30 | checkByMD5: false, 31 | uphost: '', 32 | upprotocol: 'https', 33 | forceDirect: false, 34 | chunkSize: DEFAULT_CHUNK_SIZE, 35 | concurrentRequestLimit: 3 36 | } 37 | 38 | let url: string 39 | const token = 'token' 40 | 41 | url = await getUploadUrl(config, token) 42 | expect(url).toBe('https://mock.qiniu.com') 43 | 44 | config.region = region.z0 45 | url = await getUploadUrl(config, token) 46 | expect(url).toBe('https://upload.qiniup.com') 47 | 48 | config.upprotocol = 'https' 49 | url = await getUploadUrl(config, token) 50 | expect(url).toBe('https://upload.qiniup.com') 51 | 52 | config.upprotocol = 'http' 53 | url = await getUploadUrl(config, token) 54 | expect(url).toBe('http://upload.qiniup.com') 55 | 56 | config.upprotocol = 'https:' 57 | url = await getUploadUrl(config, token) 58 | expect(url).toBe('https://upload.qiniup.com') 59 | 60 | config.upprotocol = 'http:' 61 | url = await getUploadUrl(config, token) 62 | expect(url).toBe('http://upload.qiniup.com') 63 | 64 | config.uphost = 'qiniu.com' 65 | url = await getUploadUrl(config, token) 66 | expect(url).toBe('http://qiniu.com') 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'querystring' 2 | 3 | import { normalizeUploadConfig } from '../utils' 4 | import { Config, InternalConfig, UploadInfo } from '../upload' 5 | import * as utils from '../utils' 6 | 7 | interface UpHosts { 8 | data: { 9 | up: { 10 | acc: { 11 | main: string[] 12 | backup: string[] 13 | } 14 | } 15 | } 16 | } 17 | 18 | export async function getUpHosts(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise { 19 | const params = stringify({ ak: accessKey, bucket: bucketName }) 20 | const url = `${protocol}://api.qiniu.com/v2/query?${params}` 21 | return utils.request(url, { method: 'GET' }) 22 | } 23 | 24 | /** 25 | * @param bucket 空间名 26 | * @param key 目标文件名 27 | * @param uploadInfo 上传信息 28 | */ 29 | function getBaseUrl(bucket: string, key: string | null | undefined, uploadInfo: UploadInfo) { 30 | const { url, id } = uploadInfo 31 | return `${url}/buckets/${bucket}/objects/${key != null ? utils.urlSafeBase64Encode(key) : '~'}/uploads/${id}` 32 | } 33 | 34 | export interface InitPartsData { 35 | /** 该文件的上传 id, 后续该文件其他各个块的上传,已上传块的废弃,已上传块的合成文件,都需要该 id */ 36 | uploadId: string 37 | /** uploadId 的过期时间 */ 38 | expireAt: number 39 | } 40 | 41 | /** 42 | * @param token 上传鉴权凭证 43 | * @param bucket 上传空间 44 | * @param key 目标文件名 45 | * @param uploadUrl 上传地址 46 | */ 47 | export function initUploadParts( 48 | token: string, 49 | bucket: string, 50 | key: string | null | undefined, 51 | uploadUrl: string 52 | ): utils.Response { 53 | const url = `${uploadUrl}/buckets/${bucket}/objects/${key != null ? utils.urlSafeBase64Encode(key) : '~'}/uploads` 54 | return utils.request( 55 | url, 56 | { 57 | method: 'POST', 58 | headers: utils.getAuthHeaders(token) 59 | } 60 | ) 61 | } 62 | 63 | export interface UploadChunkData { 64 | etag: string 65 | md5: string 66 | } 67 | 68 | /** 69 | * @param token 上传鉴权凭证 70 | * @param index 当前 chunk 的索引 71 | * @param uploadInfo 上传信息 72 | * @param options 请求参数 73 | */ 74 | export function uploadChunk( 75 | token: string, 76 | key: string | null | undefined, 77 | index: number, 78 | uploadInfo: UploadInfo, 79 | options: Partial 80 | ): utils.Response { 81 | const bucket = utils.getPutPolicy(token).bucketName 82 | const url = getBaseUrl(bucket, key, uploadInfo) + `/${index}` 83 | const headers = utils.getHeadersForChunkUpload(token) 84 | if (options.md5) headers['Content-MD5'] = options.md5 85 | 86 | return utils.request(url, { 87 | ...options, 88 | method: 'PUT', 89 | headers 90 | }) 91 | } 92 | 93 | export type UploadCompleteData = any 94 | 95 | /** 96 | * @param token 上传鉴权凭证 97 | * @param key 目标文件名 98 | * @param uploadInfo 上传信息 99 | * @param options 请求参数 100 | */ 101 | export function uploadComplete( 102 | token: string, 103 | key: string | null | undefined, 104 | uploadInfo: UploadInfo, 105 | options: Partial 106 | ): utils.Response { 107 | const bucket = utils.getPutPolicy(token).bucketName 108 | const url = getBaseUrl(bucket, key, uploadInfo) 109 | return utils.request(url, { 110 | ...options, 111 | method: 'POST', 112 | headers: utils.getHeadersForMkFile(token) 113 | }) 114 | } 115 | 116 | /** 117 | * @param token 上传鉴权凭证 118 | * @param key 目标文件名 119 | * @param uploadInfo 上传信息 120 | */ 121 | export function deleteUploadedChunks( 122 | token: string, 123 | key: string | null | undefined, 124 | uploadinfo: UploadInfo 125 | ): utils.Response { 126 | const bucket = utils.getPutPolicy(token).bucketName 127 | const url = getBaseUrl(bucket, key, uploadinfo) 128 | return utils.request( 129 | url, 130 | { 131 | method: 'DELETE', 132 | headers: utils.getAuthHeaders(token) 133 | } 134 | ) 135 | } 136 | 137 | /** 138 | * @param {string} url 139 | * @param {FormData} data 140 | * @param {Partial} options 141 | * @returns Promise 142 | * @description 直传接口 143 | */ 144 | export function direct( 145 | url: string, 146 | data: FormData, 147 | options: Partial 148 | ): Promise { 149 | return utils.request(url, { 150 | method: 'POST', 151 | body: data, 152 | ...options 153 | }) 154 | } 155 | 156 | export type UploadUrlConfig = Partial> 157 | 158 | /** 159 | * @param {UploadUrlConfig} config 160 | * @param {string} token 161 | * @returns Promise 162 | * @description 获取上传 url 163 | */ 164 | export async function getUploadUrl(_config: UploadUrlConfig, token: string): Promise { 165 | const config = normalizeUploadConfig(_config) 166 | const protocol = config.upprotocol 167 | 168 | if (config.uphost.length > 0) { 169 | return `${protocol}://${config.uphost[0]}` 170 | } 171 | const putPolicy = utils.getPutPolicy(token) 172 | const res = await getUpHosts(putPolicy.assessKey, putPolicy.bucketName, protocol) 173 | const hosts = res.data.up.acc.main 174 | return `${protocol}://${hosts[0]}` 175 | } 176 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './region' 2 | -------------------------------------------------------------------------------- /src/config/region.ts: -------------------------------------------------------------------------------- 1 | /** 上传区域 */ 2 | export const region = { 3 | z0: 'z0', 4 | z1: 'z1', 5 | z2: 'z2', 6 | na0: 'na0', 7 | as0: 'as0', 8 | cnEast2: 'cn-east-2' 9 | } as const 10 | 11 | /** 上传区域对应的 host */ 12 | export const regionUphostMap = { 13 | [region.z0]: { 14 | srcUphost: ['up.qiniup.com'], 15 | cdnUphost: ['upload.qiniup.com'] 16 | }, 17 | [region.z1]: { 18 | srcUphost: ['up-z1.qiniup.com'], 19 | cdnUphost: ['upload-z1.qiniup.com'] 20 | }, 21 | [region.z2]: { 22 | srcUphost: ['up-z2.qiniup.com'], 23 | cdnUphost: ['upload-z2.qiniup.com'] 24 | }, 25 | [region.na0]: { 26 | srcUphost: ['up-na0.qiniup.com'], 27 | cdnUphost: ['upload-na0.qiniup.com'] 28 | }, 29 | [region.as0]: { 30 | srcUphost: ['up-as0.qiniup.com'], 31 | cdnUphost: ['upload-as0.qiniup.com'] 32 | }, 33 | [region.cnEast2]: { 34 | srcUphost: ['up-cn-east-2.qiniup.com'], 35 | cdnUphost: ['upload-cn-east-2.qiniup.com'] 36 | } 37 | } as const 38 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export enum QiniuErrorName { 2 | // 输入错误 3 | InvalidFile = 'InvalidFile', 4 | InvalidToken = 'InvalidToken', 5 | InvalidMetadata = 'InvalidMetadata', 6 | InvalidChunkSize = 'InvalidChunkSize', 7 | InvalidCustomVars = 'InvalidCustomVars', 8 | NotAvailableUploadHost = 'NotAvailableUploadHost', 9 | 10 | // 缓存相关 11 | ReadCacheFailed = 'ReadCacheFailed', 12 | InvalidCacheData = 'InvalidCacheData', 13 | WriteCacheFailed = 'WriteCacheFailed', 14 | RemoveCacheFailed = 'RemoveCacheFailed', 15 | 16 | // 图片压缩模块相关 17 | GetCanvasContextFailed = 'GetCanvasContextFailed', 18 | UnsupportedFileType = 'UnsupportedFileType', 19 | 20 | // 运行环境相关 21 | FileReaderReadFailed = 'FileReaderReadFailed', 22 | NotAvailableXMLHttpRequest = 'NotAvailableXMLHttpRequest', 23 | InvalidProgressEventTarget = 'InvalidProgressEventTarget', 24 | 25 | // 请求错误 26 | RequestError = 'RequestError' 27 | } 28 | 29 | export class QiniuError implements Error { 30 | public stack: string | undefined 31 | constructor(public name: QiniuErrorName, public message: string) { 32 | this.stack = new Error().stack 33 | } 34 | } 35 | 36 | export class QiniuRequestError extends QiniuError { 37 | 38 | /** 39 | * @description 标记当前的 error 类型是一个 QiniuRequestError 40 | * @deprecated 下一个大版本将会移除,不推荐使用,推荐直接使用 instanceof 进行判断 41 | */ 42 | public isRequestError = true 43 | 44 | /** 45 | * @description 发生错误时服务端返回的错误信息,如果返回不是一个合法的 json、则该字段为 undefined 46 | */ 47 | public data?: any 48 | 49 | constructor(public code: number, public reqId: string, message: string, data?: any) { 50 | super(QiniuErrorName.RequestError, message) 51 | this.data = data 52 | } 53 | } 54 | 55 | /** 56 | * @description 由于跨域、证书错误、断网、host 解析失败、系统拦截等原因导致的错误 57 | */ 58 | export class QiniuNetworkError extends QiniuRequestError { 59 | constructor(message: string, reqId = '') { 60 | super(0, reqId, message) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/image/index.test.ts: -------------------------------------------------------------------------------- 1 | import { urlSafeBase64Encode } from '../utils' 2 | 3 | import { imageView2, imageMogr2, watermark } from '.' 4 | 5 | describe('image func test', () => { 6 | const domain = 'http://otxza7yo2.bkt.clouddn.com' 7 | const key = 'test.png' 8 | 9 | test('imageView2', () => { 10 | const m = { 11 | fop: 'imageView2', 12 | mode: 2, 13 | h: 450, 14 | q: 100 15 | } 16 | const url = imageView2(m, key, domain) 17 | expect(url).toBe( 18 | 'http://otxza7yo2.bkt.clouddn.com/' + key + '?' 19 | + 'imageView2/' + encodeURIComponent(m.mode) 20 | + '/h' 21 | + '/' 22 | + encodeURIComponent(m.h) 23 | + '/q' 24 | + '/' + encodeURIComponent(m.q) 25 | ) 26 | }) 27 | 28 | test('imageMogr2', () => { 29 | const m = { 30 | thumbnail: 1, 31 | strip: true, 32 | gravity: 1, 33 | crop: 1, 34 | quality: 1, 35 | rotate: 1, 36 | format: 1, 37 | blur: 1 38 | } 39 | 40 | const url = imageMogr2(m, key, domain) 41 | expect(url).toBe( 42 | 'http://otxza7yo2.bkt.clouddn.com/' + key + '?imageMogr2/' 43 | + 'thumbnail/1/strip/gravity/1/quality/1/crop/1/rotate/1/format/1/blur/1' 44 | ) 45 | }) 46 | 47 | test('watermark', () => { 48 | const m = { 49 | fop: 'watermark', 50 | mode: 1, 51 | image: 'http://www.b1.qiniudn.com/images/logo-2.png', 52 | dissolve: 100, 53 | dx: 100, 54 | dy: 100 55 | } 56 | const url = watermark(m, key, domain) 57 | expect(url).toBe( 58 | 'http://otxza7yo2.bkt.clouddn.com/' + key + '?' 59 | + 'watermark/' + m.mode + '/image/' + urlSafeBase64Encode(m.image) 60 | + '/dissolve/100/dx/100/dy/100' 61 | ) 62 | m.mode = 3 63 | expect(() => { 64 | watermark(m, key, domain) 65 | }).toThrow('mode is wrong') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/image/index.ts: -------------------------------------------------------------------------------- 1 | import { request, urlSafeBase64Encode } from '../utils' 2 | 3 | export interface ImageViewOptions { 4 | mode: number 5 | format?: string 6 | w?: number 7 | h?: number 8 | q?: number 9 | } 10 | 11 | export interface ImageWatermark { 12 | image: string 13 | mode: number 14 | fontsize?: number 15 | dissolve?: number 16 | dx?: number 17 | dy?: number 18 | gravity?: string 19 | text?: string 20 | font?: string 21 | fill?: string 22 | } 23 | 24 | export interface ImageMogr2 { 25 | 'auto-orient'?: boolean 26 | strip?: boolean 27 | thumbnail?: number 28 | crop?: number 29 | gravity?: number 30 | format?: number 31 | blur?: number 32 | quality?: number 33 | rotate?: number 34 | } 35 | 36 | type Pipeline = 37 | | (ImageWatermark & { fop: 'watermark' }) 38 | | (ImageViewOptions & { fop: 'imageView2' }) 39 | | (ImageMogr2 & { fop: 'imageMogr2' }) 40 | 41 | export interface Entry { 42 | domain: string 43 | key: string 44 | } 45 | 46 | function getImageUrl(key: string, domain: string) { 47 | key = encodeURIComponent(key) 48 | if (domain.slice(domain.length - 1) !== '/') { 49 | domain += '/' 50 | } 51 | 52 | return domain + key 53 | } 54 | 55 | export function imageView2(op: ImageViewOptions, key?: string, domain?: string) { 56 | if (!/^\d$/.test(String(op.mode))) { 57 | throw 'mode should be number in imageView2' 58 | } 59 | 60 | const { mode, w, h, q, format } = op 61 | 62 | if (!w && !h) { 63 | throw 'param w and h is empty in imageView2' 64 | } 65 | 66 | let imageUrl = 'imageView2/' + encodeURIComponent(mode) 67 | imageUrl += w ? '/w/' + encodeURIComponent(w) : '' 68 | imageUrl += h ? '/h/' + encodeURIComponent(h) : '' 69 | imageUrl += q ? '/q/' + encodeURIComponent(q) : '' 70 | imageUrl += format ? '/format/' + encodeURIComponent(format) : '' 71 | if (key && domain) { 72 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl 73 | } 74 | return imageUrl 75 | } 76 | 77 | // invoke the imageMogr2 api of Qiniu 78 | export function imageMogr2(op: ImageMogr2, key?: string, domain?: string) { 79 | const autoOrient = op['auto-orient'] 80 | const { thumbnail, strip, gravity, crop, quality, rotate, format, blur } = op 81 | 82 | let imageUrl = 'imageMogr2' 83 | 84 | imageUrl += autoOrient ? '/auto-orient' : '' 85 | imageUrl += thumbnail ? '/thumbnail/' + encodeURIComponent(thumbnail) : '' 86 | imageUrl += strip ? '/strip' : '' 87 | imageUrl += gravity ? '/gravity/' + encodeURIComponent(gravity) : '' 88 | imageUrl += quality ? '/quality/' + encodeURIComponent(quality) : '' 89 | imageUrl += crop ? '/crop/' + encodeURIComponent(crop) : '' 90 | imageUrl += rotate ? '/rotate/' + encodeURIComponent(rotate) : '' 91 | imageUrl += format ? '/format/' + encodeURIComponent(format) : '' 92 | imageUrl += blur ? '/blur/' + encodeURIComponent(blur) : '' 93 | if (key && domain) { 94 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl 95 | } 96 | return imageUrl 97 | } 98 | 99 | // invoke the watermark api of Qiniu 100 | export function watermark(op: ImageWatermark, key?: string, domain?: string) { 101 | const mode = op.mode 102 | if (!mode) { 103 | throw "mode can't be empty in watermark" 104 | } 105 | 106 | let imageUrl = 'watermark/' + mode 107 | if (mode !== 1 && mode !== 2) { 108 | throw 'mode is wrong' 109 | } 110 | 111 | if (mode === 1) { 112 | const image = op.image 113 | if (!image) { 114 | throw "image can't be empty in watermark" 115 | } 116 | imageUrl += image ? '/image/' + urlSafeBase64Encode(image) : '' 117 | } 118 | 119 | if (mode === 2) { 120 | const { text, font, fontsize, fill } = op 121 | if (!text) { 122 | throw "text can't be empty in watermark" 123 | } 124 | imageUrl += text ? '/text/' + urlSafeBase64Encode(text) : '' 125 | imageUrl += font ? '/font/' + urlSafeBase64Encode(font) : '' 126 | imageUrl += fontsize ? '/fontsize/' + fontsize : '' 127 | imageUrl += fill ? '/fill/' + urlSafeBase64Encode(fill) : '' 128 | } 129 | 130 | const { dissolve, gravity, dx, dy } = op 131 | 132 | imageUrl += dissolve ? '/dissolve/' + encodeURIComponent(dissolve) : '' 133 | imageUrl += gravity ? '/gravity/' + encodeURIComponent(gravity) : '' 134 | imageUrl += dx ? '/dx/' + encodeURIComponent(dx) : '' 135 | imageUrl += dy ? '/dy/' + encodeURIComponent(dy) : '' 136 | if (key && domain) { 137 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl 138 | } 139 | return imageUrl 140 | } 141 | 142 | // invoke the imageInfo api of Qiniu 143 | export function imageInfo(key: string, domain: string) { 144 | const url = getImageUrl(key, domain) + '?imageInfo' 145 | return request(url, { method: 'GET' }) 146 | } 147 | 148 | // invoke the exif api of Qiniu 149 | export function exif(key: string, domain: string) { 150 | const url = getImageUrl(key, domain) + '?exif' 151 | return request(url, { method: 'GET' }) 152 | } 153 | 154 | export function pipeline(arr: Pipeline[], key?: string, domain?: string) { 155 | const isArray = Object.prototype.toString.call(arr) === '[object Array]' 156 | let option: Pipeline 157 | let errOp = false 158 | let imageUrl = '' 159 | if (isArray) { 160 | for (let i = 0, len = arr.length; i < len; i++) { 161 | option = arr[i] 162 | if (!option.fop) { 163 | throw "fop can't be empty in pipeline" 164 | } 165 | switch (option.fop) { 166 | case 'watermark': 167 | imageUrl += watermark(option) + '|' 168 | break 169 | case 'imageView2': 170 | imageUrl += imageView2(option) + '|' 171 | break 172 | case 'imageMogr2': 173 | imageUrl += imageMogr2(option) + '|' 174 | break 175 | default: 176 | errOp = true 177 | break 178 | } 179 | if (errOp) { 180 | throw 'fop is wrong in pipeline' 181 | } 182 | } 183 | 184 | if (key && domain) { 185 | imageUrl = getImageUrl(key, domain) + '?' + imageUrl 186 | const length = imageUrl.length 187 | if (imageUrl.slice(length - 1) === '|') { 188 | imageUrl = imageUrl.slice(0, length - 1) 189 | } 190 | } 191 | return imageUrl 192 | } 193 | 194 | throw "pipeline's first param should be array" 195 | } 196 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { QiniuErrorName, QiniuError, QiniuRequestError, QiniuNetworkError } from './errors' 2 | export { imageMogr2, watermark, imageInfo, exif, pipeline } from './image' 3 | export { deleteUploadedChunks, getUploadUrl } from './api' 4 | export { default as upload } from './upload' 5 | export { region } from './config' 6 | 7 | export { 8 | compressImage, 9 | CompressResult, 10 | urlSafeBase64Encode, 11 | urlSafeBase64Decode, 12 | getHeadersForMkFile, 13 | getHeadersForChunkUpload 14 | } from './utils' 15 | -------------------------------------------------------------------------------- /src/logger/index.test.ts: -------------------------------------------------------------------------------- 1 | import Logger from './index' 2 | 3 | let isCallReport = false 4 | 5 | jest.mock('./report-v3', () => ({ 6 | reportV3: () => { 7 | isCallReport = true 8 | } 9 | })) 10 | 11 | // eslint-disable-next-line no-console 12 | const originalLog = console.log 13 | // eslint-disable-next-line no-console 14 | const originalWarn = console.warn 15 | // eslint-disable-next-line no-console 16 | const originalError = console.error 17 | 18 | const logMessage: unknown[] = [] 19 | const warnMessage: unknown[] = [] 20 | const errorMessage: unknown[] = [] 21 | 22 | beforeAll(() => { 23 | // eslint-disable-next-line no-console 24 | console.log = jest.fn((...args: unknown[]) => logMessage.push(...args)) 25 | // eslint-disable-next-line no-console 26 | console.warn = jest.fn((...args: unknown[]) => warnMessage.push(...args)) 27 | // eslint-disable-next-line no-console 28 | console.error = jest.fn((...args: unknown[]) => errorMessage.push(...args)) 29 | }) 30 | 31 | afterAll(() => { 32 | // eslint-disable-next-line no-console 33 | console.log = originalLog 34 | // eslint-disable-next-line no-console 35 | console.warn = originalWarn 36 | // eslint-disable-next-line no-console 37 | console.error = originalError 38 | }) 39 | 40 | describe('test logger', () => { 41 | test('level', () => { 42 | const infoLogger = new Logger('', true, 'INFO') 43 | infoLogger.info('test1') 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-ignore 46 | expect(logMessage).toStrictEqual([infoLogger.getPrintPrefix('INFO'), 'test1']) 47 | infoLogger.warn('test2') 48 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 49 | // @ts-ignore 50 | expect(warnMessage).toStrictEqual([infoLogger.getPrintPrefix('WARN'), 'test2']) 51 | infoLogger.error('test3') 52 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 53 | // @ts-ignore 54 | expect(errorMessage).toStrictEqual([infoLogger.getPrintPrefix('ERROR'), 'test3']) 55 | 56 | // 清空消息 57 | logMessage.splice(0, logMessage.length) 58 | warnMessage.splice(0, warnMessage.length) 59 | errorMessage.splice(0, errorMessage.length) 60 | 61 | const warnLogger = new Logger('', true, 'WARN') 62 | warnLogger.info('test1') 63 | expect(logMessage).toStrictEqual([]) 64 | warnLogger.warn('test2') 65 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 66 | // @ts-ignore 67 | expect(warnMessage).toStrictEqual([warnLogger.getPrintPrefix('WARN'), 'test2']) 68 | warnLogger.error('test3') 69 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 70 | // @ts-ignore 71 | expect(errorMessage).toStrictEqual([warnLogger.getPrintPrefix('ERROR'), 'test3']) 72 | 73 | // 清空消息 74 | logMessage.splice(0, logMessage.length) 75 | warnMessage.splice(0, warnMessage.length) 76 | errorMessage.splice(0, errorMessage.length) 77 | 78 | const errorLogger = new Logger('', true, 'ERROR') 79 | errorLogger.info('test1') 80 | expect(logMessage).toStrictEqual([]) 81 | errorLogger.warn('test2') 82 | expect(warnMessage).toStrictEqual([]) 83 | errorLogger.error('test3') 84 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 85 | // @ts-ignore 86 | expect(errorMessage).toStrictEqual([errorLogger.getPrintPrefix('ERROR'), 'test3']) 87 | 88 | // 清空消息 89 | logMessage.splice(0, logMessage.length) 90 | warnMessage.splice(0, warnMessage.length) 91 | errorMessage.splice(0, errorMessage.length) 92 | 93 | const offLogger = new Logger('', true, 'OFF') 94 | offLogger.info('test1') 95 | expect(logMessage).toStrictEqual([]) 96 | offLogger.warn('test2') 97 | expect(warnMessage).toStrictEqual([]) 98 | offLogger.error('test3') 99 | expect(errorMessage).toStrictEqual([]) 100 | }) 101 | 102 | test('unique id', () => { 103 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 104 | // @ts-ignore 105 | const startId = Logger.id 106 | // eslint-disable-next-line no-new 107 | new Logger('', true, 'OFF') 108 | // eslint-disable-next-line no-new 109 | new Logger('', true, 'OFF') 110 | const last = new Logger('', true, 'OFF') 111 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 112 | // @ts-ignore 113 | expect(last.id).toStrictEqual(startId + 3) 114 | }) 115 | 116 | test('report', () => { 117 | const logger1 = new Logger('', false, 'OFF') 118 | logger1.report(null as any) 119 | expect(isCallReport).toBeTruthy() 120 | isCallReport = false 121 | const logger2 = new Logger('', true, 'OFF') 122 | logger2.report(null as any) 123 | expect(isCallReport).toBeFalsy() 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { reportV3, V3LogInfo } from './report-v3' 2 | 3 | export type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'OFF' 4 | 5 | export default class Logger { 6 | private static id = 0 7 | 8 | // 为每个类分配一个 id 9 | // 用以区分不同的上传任务 10 | private id = ++Logger.id 11 | 12 | constructor( 13 | private token: string, 14 | private disableReport = true, 15 | private level: LogLevel = 'OFF', 16 | private prefix = 'UPLOAD' 17 | ) { } 18 | 19 | private getPrintPrefix(level: LogLevel) { 20 | return `Qiniu-JS-SDK [${level}][${this.prefix}#${this.id}]:` 21 | } 22 | 23 | /** 24 | * @param {V3LogInfo} data 上报的数据。 25 | * @param {boolean} retry 重试次数,可选,默认为 3。 26 | * @description 向服务端上报统计信息。 27 | */ 28 | report(data: V3LogInfo, retry?: number) { 29 | if (this.disableReport) return 30 | try { 31 | reportV3(this.token, data, retry) 32 | } catch (error) { 33 | this.warn(error) 34 | } 35 | } 36 | 37 | /** 38 | * @param {unknown[]} ...args 39 | * @description 输出 info 级别的调试信息。 40 | */ 41 | info(...args: unknown[]) { 42 | const allowLevel: LogLevel[] = ['INFO'] 43 | if (allowLevel.includes(this.level)) { 44 | // eslint-disable-next-line no-console 45 | console.log(this.getPrintPrefix('INFO'), ...args) 46 | } 47 | } 48 | 49 | /** 50 | * @param {unknown[]} ...args 51 | * @description 输出 warn 级别的调试信息。 52 | */ 53 | warn(...args: unknown[]) { 54 | const allowLevel: LogLevel[] = ['INFO', 'WARN'] 55 | if (allowLevel.includes(this.level)) { 56 | // eslint-disable-next-line no-console 57 | console.warn(this.getPrintPrefix('WARN'), ...args) 58 | } 59 | } 60 | 61 | /** 62 | * @param {unknown[]} ...args 63 | * @description 输出 error 级别的调试信息。 64 | */ 65 | error(...args: unknown[]) { 66 | const allowLevel: LogLevel[] = ['INFO', 'WARN', 'ERROR'] 67 | if (allowLevel.includes(this.level)) { 68 | // eslint-disable-next-line no-console 69 | console.error(this.getPrintPrefix('ERROR'), ...args) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/logger/report-v3.test.ts: -------------------------------------------------------------------------------- 1 | import { reportV3, V3LogInfo } from './report-v3' 2 | 3 | class MockXHR { 4 | sendData: string 5 | openData: string[] 6 | openCount: number 7 | headerData: string[] 8 | 9 | status: number 10 | readyState: number 11 | onreadystatechange() { 12 | // null 13 | } 14 | 15 | clear() { 16 | this.sendData = '' 17 | this.openData = [] 18 | this.headerData = [] 19 | 20 | this.status = 0 21 | this.readyState = 0 22 | } 23 | 24 | open(...args: string[]) { 25 | this.clear() 26 | this.openCount += 1 27 | this.openData = args 28 | } 29 | 30 | send(args: string) { 31 | this.sendData = args 32 | } 33 | 34 | setRequestHeader(...args: string[]) { 35 | this.headerData.push(...args) 36 | } 37 | 38 | changeStatusAndState(readyState: number, status: number) { 39 | this.status = status 40 | this.readyState = readyState 41 | this.onreadystatechange() 42 | } 43 | } 44 | 45 | const mockXHR = new MockXHR() 46 | 47 | jest.mock('../utils', () => ({ 48 | createXHR: () => mockXHR, 49 | getAuthHeaders: (t: string) => t 50 | })) 51 | 52 | describe('test report-v3', () => { 53 | const testData: V3LogInfo = { 54 | code: 200, 55 | reqId: 'reqId', 56 | host: 'host', 57 | remoteIp: 'remoteIp', 58 | port: 'port', 59 | duration: 1, 60 | time: 1, 61 | bytesSent: 1, 62 | upType: 'jssdk-h5', 63 | size: 1 64 | } 65 | 66 | test('stringify send Data', () => { 67 | reportV3('token', testData, 3) 68 | mockXHR.changeStatusAndState(0, 0) 69 | expect(mockXHR.sendData).toBe([ 70 | testData.code || '', 71 | testData.reqId || '', 72 | testData.host || '', 73 | testData.remoteIp || '', 74 | testData.port || '', 75 | testData.duration || '', 76 | testData.time || '', 77 | testData.bytesSent || '', 78 | testData.upType || '', 79 | testData.size || '' 80 | ].join(',')) 81 | }) 82 | 83 | test('retry', () => { 84 | mockXHR.openCount = 0 85 | reportV3('token', testData) 86 | for (let index = 1; index <= 10; index++) { 87 | mockXHR.changeStatusAndState(4, 0) 88 | } 89 | expect(mockXHR.openCount).toBe(4) 90 | 91 | mockXHR.openCount = 0 92 | reportV3('token', testData, 4) 93 | for (let index = 1; index < 10; index++) { 94 | mockXHR.changeStatusAndState(4, 0) 95 | } 96 | expect(mockXHR.openCount).toBe(5) 97 | 98 | mockXHR.openCount = 0 99 | reportV3('token', testData, 0) 100 | for (let index = 1; index < 10; index++) { 101 | mockXHR.changeStatusAndState(4, 0) 102 | } 103 | expect(mockXHR.openCount).toBe(1) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/logger/report-v3.ts: -------------------------------------------------------------------------------- 1 | import { createXHR, getAuthHeaders } from '../utils' 2 | 3 | export interface V3LogInfo { 4 | code: number 5 | reqId: string 6 | host: string 7 | remoteIp: string 8 | port: string 9 | duration: number 10 | time: number 11 | bytesSent: number 12 | upType: 'jssdk-h5' 13 | size: number 14 | } 15 | 16 | /** 17 | * @param {string} token 上传使用的 token 18 | * @param {V3LogInfo} data 上报的统计数据 19 | * @param {number} retry 重试的次数,默认值 3 20 | * @description v3 版本的日志上传接口,参考文档 https://github.com/qbox/product/blob/master/kodo/uplog.md#%E7%89%88%E6%9C%AC-3。 21 | */ 22 | export function reportV3(token: string, data: V3LogInfo, retry = 3) { 23 | const xhr = createXHR() 24 | xhr.open('POST', 'https://uplog.qbox.me/log/3') 25 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded') 26 | xhr.setRequestHeader('Authorization', getAuthHeaders(token).Authorization) 27 | xhr.onreadystatechange = () => { 28 | if (xhr.readyState === 4 && xhr.status !== 200 && retry > 0) { 29 | reportV3(token, data, retry - 1) 30 | } 31 | } 32 | 33 | // 顺序参考:https://github.com/qbox/product/blob/master/kodo/uplog.md#%E7%89%88%E6%9C%AC-3 34 | const stringifyData = [ 35 | data.code || '', 36 | data.reqId || '', 37 | data.host || '', 38 | data.remoteIp || '', 39 | data.port || '', 40 | data.duration || '', 41 | data.time || '', 42 | data.bytesSent || '', 43 | data.upType || '', 44 | data.size || '' 45 | ].join(',') 46 | 47 | xhr.send(stringifyData) 48 | } 49 | -------------------------------------------------------------------------------- /src/upload/base.ts: -------------------------------------------------------------------------------- 1 | import { QiniuErrorName, QiniuError, QiniuRequestError } from '../errors' 2 | import Logger, { LogLevel } from '../logger' 3 | import { region } from '../config' 4 | import * as utils from '../utils' 5 | 6 | import { Host, HostPool } from './hosts' 7 | 8 | export const DEFAULT_CHUNK_SIZE = 4 // 单位 MB 9 | 10 | // code 信息地址 https://developer.qiniu.com/kodo/3928/error-responses 11 | export const FREEZE_CODE_LIST = [0, 502, 503, 504, 599] // 将会冻结当前 host 的 code 12 | export const RETRY_CODE_LIST = [...FREEZE_CODE_LIST, 612] // 会进行重试的 code 13 | 14 | /** 上传文件的资源信息配置 */ 15 | export interface Extra { 16 | /** 文件原文件名 */ 17 | fname: string 18 | /** 用来放置自定义变量 */ 19 | customVars?: { [key: string]: string } 20 | /** 自定义元信息 */ 21 | metadata?: { [key: string]: string } 22 | /** 文件类型设置 */ 23 | mimeType?: string // 24 | } 25 | 26 | export interface InternalConfig { 27 | /** 是否开启 cdn 加速 */ 28 | useCdnDomain: boolean 29 | /** 是否开启服务端校验 */ 30 | checkByServer: boolean 31 | /** 是否对分片进行 md5校验 */ 32 | checkByMD5: boolean 33 | /** 强制直传 */ 34 | forceDirect: boolean 35 | /** 上传失败后重试次数 */ 36 | retryCount: number 37 | /** 自定义上传域名 */ 38 | uphost: string[] 39 | /** 自定义分片上传并发请求量 */ 40 | concurrentRequestLimit: number 41 | /** 分片大小,单位为 MB */ 42 | chunkSize: number 43 | /** 上传域名协议 */ 44 | upprotocol: 'https' | 'http' 45 | /** 上传区域 */ 46 | region?: typeof region[keyof typeof region] 47 | /** 是否禁止统计日志上报 */ 48 | disableStatisticsReport: boolean 49 | /** 设置调试日志输出模式,默认 `OFF`,不输出任何日志 */ 50 | debugLogLevel?: LogLevel 51 | } 52 | 53 | /** 上传任务的配置信息 */ 54 | export interface Config extends Partial> { 55 | /** 上传域名协议 */ 56 | upprotocol?: InternalConfig['upprotocol'] | 'https:' | 'http:' 57 | /** 自定义上传域名 */ 58 | uphost?: InternalConfig['uphost'] | string 59 | } 60 | 61 | export interface UploadOptions { 62 | file: File 63 | key: string | null | undefined 64 | token: string 65 | config: InternalConfig 66 | putExtra?: Partial 67 | } 68 | 69 | export interface UploadInfo { 70 | id: string 71 | url: string 72 | } 73 | 74 | /** 传递给外部的上传进度信息 */ 75 | export interface UploadProgress { 76 | total: ProgressCompose 77 | uploadInfo?: UploadInfo 78 | chunks?: ProgressCompose[] 79 | } 80 | 81 | export interface UploadHandlers { 82 | onData: (data: UploadProgress) => void 83 | onError: (err: QiniuError) => void 84 | onComplete: (res: any) => void 85 | } 86 | 87 | export interface Progress { 88 | total: number 89 | loaded: number 90 | } 91 | 92 | export interface ProgressCompose { 93 | size: number 94 | loaded: number 95 | percent: number 96 | fromCache?: boolean 97 | } 98 | 99 | export type XHRHandler = (xhr: XMLHttpRequest) => void 100 | 101 | const GB = 1024 ** 3 102 | 103 | export default abstract class Base { 104 | protected config: InternalConfig 105 | protected putExtra: Extra 106 | 107 | protected aborted = false 108 | protected retryCount = 0 109 | 110 | protected uploadHost?: Host 111 | protected xhrList: XMLHttpRequest[] = [] 112 | 113 | protected file: File 114 | protected key: string | null | undefined 115 | 116 | protected token: string 117 | protected assessKey: string 118 | protected bucketName: string 119 | 120 | protected uploadAt: number 121 | protected progress: UploadProgress 122 | 123 | protected onData: (data: UploadProgress) => void 124 | protected onError: (err: QiniuError) => void 125 | protected onComplete: (res: any) => void 126 | 127 | /** 128 | * @returns utils.Response 129 | * @description 子类通过该方法实现具体的任务处理 130 | */ 131 | protected abstract run(): utils.Response 132 | 133 | constructor( 134 | options: UploadOptions, 135 | handlers: UploadHandlers, 136 | protected hostPool: HostPool, 137 | protected logger: Logger 138 | ) { 139 | 140 | this.config = options.config 141 | logger.info('config inited.', this.config) 142 | 143 | this.putExtra = { 144 | fname: '', 145 | ...options.putExtra 146 | } 147 | 148 | logger.info('putExtra inited.', this.putExtra) 149 | 150 | this.key = options.key 151 | this.file = options.file 152 | this.token = options.token 153 | 154 | this.onData = handlers.onData 155 | this.onError = handlers.onError 156 | this.onComplete = handlers.onComplete 157 | 158 | try { 159 | const putPolicy = utils.getPutPolicy(this.token) 160 | this.bucketName = putPolicy.bucketName 161 | this.assessKey = putPolicy.assessKey 162 | } catch (error) { 163 | logger.error('get putPolicy from token failed.', error) 164 | this.onError(error) 165 | } 166 | } 167 | 168 | // 检查并更新 upload host 169 | protected async checkAndUpdateUploadHost() { 170 | // 从 hostPool 中获取一个可用的 host 挂载在 this 171 | this.logger.info('get available upload host.') 172 | const newHost = await this.hostPool.getUp( 173 | this.assessKey, 174 | this.bucketName, 175 | this.config.upprotocol 176 | ) 177 | 178 | if (newHost == null) { 179 | throw new QiniuError( 180 | QiniuErrorName.NotAvailableUploadHost, 181 | 'no available upload host.' 182 | ) 183 | } 184 | 185 | if (this.uploadHost != null && this.uploadHost.host !== newHost.host) { 186 | this.logger.warn(`host switches from ${this.uploadHost.host} to ${newHost.host}.`) 187 | } else { 188 | this.logger.info(`use host ${newHost.host}.`) 189 | } 190 | 191 | this.uploadHost = newHost 192 | } 193 | 194 | // 检查并解冻当前的 host 195 | protected checkAndUnfreezeHost() { 196 | this.logger.info('check unfreeze host.') 197 | if (this.uploadHost != null && this.uploadHost.isFrozen()) { 198 | this.logger.warn(`${this.uploadHost.host} will be unfrozen.`) 199 | this.uploadHost.unfreeze() 200 | } 201 | } 202 | 203 | // 检查并更新冻结当前的 host 204 | private checkAndFreezeHost(error: QiniuError) { 205 | this.logger.info('check freeze host.') 206 | if (error instanceof QiniuRequestError && this.uploadHost != null) { 207 | if (FREEZE_CODE_LIST.includes(error.code)) { 208 | this.logger.warn(`${this.uploadHost.host} will be temporarily frozen.`) 209 | this.uploadHost.freeze() 210 | } 211 | } 212 | } 213 | 214 | private handleError(error: QiniuError) { 215 | this.logger.error(error.message) 216 | this.onError(error) 217 | } 218 | 219 | /** 220 | * @returns Promise 返回结果与上传最终状态无关,状态信息请通过 [Subscriber] 获取。 221 | * @description 上传文件,状态信息请通过 [Subscriber] 获取。 222 | */ 223 | public async putFile(): Promise { 224 | this.aborted = false 225 | if (!this.putExtra.fname) { 226 | this.logger.info('use file.name as fname.') 227 | this.putExtra.fname = this.file.name 228 | } 229 | 230 | if (this.file.size > 10000 * GB) { 231 | this.handleError(new QiniuError( 232 | QiniuErrorName.InvalidFile, 233 | 'file size exceed maximum value 10000G' 234 | )) 235 | return 236 | } 237 | 238 | if (this.putExtra.customVars) { 239 | if (!utils.isCustomVarsValid(this.putExtra.customVars)) { 240 | this.handleError(new QiniuError( 241 | QiniuErrorName.InvalidCustomVars, 242 | // FIXME: width => with 243 | 'customVars key should start width x:' 244 | )) 245 | return 246 | } 247 | } 248 | 249 | if (this.putExtra.metadata) { 250 | if (!utils.isMetaDataValid(this.putExtra.metadata)) { 251 | this.handleError(new QiniuError( 252 | QiniuErrorName.InvalidMetadata, 253 | 'metadata key should start with x-qn-meta-' 254 | )) 255 | return 256 | } 257 | } 258 | 259 | try { 260 | this.uploadAt = new Date().getTime() 261 | await this.checkAndUpdateUploadHost() 262 | const result = await this.run() 263 | this.onComplete(result.data) 264 | this.checkAndUnfreezeHost() 265 | this.sendLog(result.reqId, 200) 266 | return 267 | } catch (err) { 268 | if (this.aborted) { 269 | this.logger.warn('upload is aborted.') 270 | this.sendLog('', -2) 271 | return 272 | } 273 | 274 | this.clear() 275 | this.logger.error(err) 276 | if (err instanceof QiniuRequestError) { 277 | this.sendLog(err.reqId, err.code) 278 | 279 | // 检查并冻结当前的 host 280 | this.checkAndFreezeHost(err) 281 | 282 | const notReachRetryCount = ++this.retryCount <= this.config.retryCount 283 | const needRetry = RETRY_CODE_LIST.includes(err.code) 284 | 285 | // 以下条件满足其中之一则会进行重新上传: 286 | // 1. 满足 needRetry 的条件且 retryCount 不为 0 287 | // 2. uploadId 无效时在 resume 里会清除本地数据,并且这里触发重新上传 288 | if (needRetry && notReachRetryCount) { 289 | this.logger.warn(`error auto retry: ${this.retryCount}/${this.config.retryCount}.`) 290 | this.putFile() 291 | return 292 | } 293 | } 294 | 295 | this.onError(err) 296 | } 297 | } 298 | 299 | private clear() { 300 | this.xhrList.forEach(xhr => { 301 | xhr.onreadystatechange = null 302 | xhr.abort() 303 | }) 304 | this.xhrList = [] 305 | this.logger.info('cleanup uploading xhr.') 306 | } 307 | 308 | public stop() { 309 | this.logger.info('aborted.') 310 | this.clear() 311 | this.aborted = true 312 | } 313 | 314 | public addXhr(xhr: XMLHttpRequest) { 315 | this.xhrList.push(xhr) 316 | } 317 | 318 | private sendLog(reqId: string, code: number) { 319 | this.logger.report({ 320 | code, 321 | reqId, 322 | remoteIp: '', 323 | upType: 'jssdk-h5', 324 | size: this.file.size, 325 | time: Math.floor(this.uploadAt / 1000), 326 | port: utils.getPortFromUrl(this.uploadHost?.getUrl()), 327 | host: utils.getDomainFromUrl(this.uploadHost?.getUrl()), 328 | bytesSent: this.progress ? this.progress.total.loaded : 0, 329 | duration: Math.floor((new Date().getTime() - this.uploadAt) / 1000) 330 | }) 331 | } 332 | 333 | public getProgressInfoItem(loaded: number, size: number, fromCache?: boolean): ProgressCompose { 334 | return { 335 | size, 336 | loaded, 337 | percent: loaded / size * 100, 338 | ...(fromCache == null ? {} : { fromCache }) 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/upload/direct.ts: -------------------------------------------------------------------------------- 1 | import { CRC32 } from '../utils/crc32' 2 | 3 | import { direct } from '../api' 4 | 5 | import Base from './base' 6 | 7 | export default class Direct extends Base { 8 | 9 | protected async run() { 10 | this.logger.info('start run Direct.') 11 | 12 | const formData = new FormData() 13 | formData.append('file', this.file) 14 | formData.append('token', this.token) 15 | if (this.key != null) { 16 | formData.append('key', this.key) 17 | } 18 | formData.append('fname', this.putExtra.fname) 19 | 20 | if (this.config.checkByServer) { 21 | const crcSign = await CRC32.file(this.file) 22 | formData.append('crc32', crcSign.toString()) 23 | } 24 | 25 | if (this.putExtra.customVars) { 26 | this.logger.info('init customVars.') 27 | const { customVars } = this.putExtra 28 | Object.keys(customVars).forEach(key => formData.append(key, customVars[key].toString())) 29 | this.logger.info('customVars inited.') 30 | } 31 | 32 | if (this.putExtra.metadata) { 33 | this.logger.info('init metadata.') 34 | const { metadata } = this.putExtra 35 | Object.keys(metadata).forEach(key => formData.append(key, metadata[key].toString())) 36 | } 37 | 38 | this.logger.info('formData inited.') 39 | const result = await direct(this.uploadHost!.getUrl(), formData, { 40 | onProgress: data => { 41 | this.updateDirectProgress(data.loaded, data.total) 42 | }, 43 | onCreate: xhr => this.addXhr(xhr) 44 | }) 45 | 46 | this.logger.info('Direct progress finish.') 47 | this.finishDirectProgress() 48 | return result 49 | } 50 | 51 | private updateDirectProgress(loaded: number, total: number) { 52 | // 当请求未完成时可能进度会达到100,所以total + 1来防止这种情况出现 53 | this.progress = { total: this.getProgressInfoItem(loaded, total + 1) } 54 | this.onData(this.progress) 55 | } 56 | 57 | private finishDirectProgress() { 58 | // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里 fake 下 59 | if (!this.progress) { 60 | this.logger.warn('progress is null.') 61 | this.progress = { total: this.getProgressInfoItem(this.file.size, this.file.size) } 62 | this.onData(this.progress) 63 | return 64 | } 65 | 66 | const { total } = this.progress 67 | this.progress = { total: this.getProgressInfoItem(total.loaded + 1, total.size) } 68 | this.onData(this.progress) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/upload/hosts.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/newline-after-import 2 | import { MockApi } from '../api/index.mock' 3 | const mockApi = new MockApi() 4 | jest.mock('../api', () => mockApi) 5 | 6 | // eslint-disable-next-line import/first 7 | import { Host, HostPool } from './hosts' 8 | 9 | function sleep(time = 100) { 10 | return new Promise((resolve, _) => { 11 | setTimeout(resolve, time) 12 | }) 13 | } 14 | 15 | describe('test hosts', () => { 16 | const getParams = ['accessKey', 'bucket', 'https'] as const 17 | 18 | test('getUp from api', async () => { 19 | const hostPool = new HostPool() 20 | const apiData = await mockApi.getUpHosts() 21 | 22 | // 无冻结行为每次获取到的都是第一个 23 | const actual1 = await hostPool.getUp(...getParams) 24 | expect(actual1?.host).toStrictEqual(apiData.data.up.acc.main[0]) 25 | 26 | const actual2 = await hostPool.getUp(...getParams) 27 | expect(actual2?.host).toStrictEqual(apiData.data.up.acc.main[0]) 28 | 29 | const actual3 = await hostPool.getUp(...getParams) 30 | expect(actual3?.host).toStrictEqual(apiData.data.up.acc.main[0]) 31 | }) 32 | 33 | test('getUp from config', async () => { 34 | const hostPool = new HostPool([ 35 | 'host-1', 36 | 'host-2' 37 | ]) 38 | 39 | // 无冻结行为每次获取到的都是第一个 40 | const actual1 = await hostPool.getUp(...getParams) 41 | expect(actual1).toStrictEqual(new Host('host-1', 'https')) 42 | 43 | const actual2 = await hostPool.getUp(...getParams) 44 | expect(actual2).toStrictEqual(new Host('host-1', 'https')) 45 | 46 | const actual3 = await hostPool.getUp(...getParams) 47 | expect(actual3).toStrictEqual(new Host('host-1', 'https')) 48 | }) 49 | 50 | test('freeze & unfreeze', async () => { 51 | const hostPool = new HostPool([ 52 | 'host-1', 53 | 'host-2' 54 | ]) 55 | 56 | // 测试冻结第一个 57 | const host1 = await hostPool.getUp(...getParams) 58 | expect(host1).toStrictEqual(new Host('host-1', 'https')) 59 | // eslint-disable-next-line no-unused-expressions 60 | host1?.freeze() 61 | await sleep() 62 | 63 | // 自动切换到了下一个可用的 host-2 64 | const host2 = await hostPool.getUp(...getParams) 65 | expect(host2).toStrictEqual(new Host('host-2', 'https')) 66 | // eslint-disable-next-line no-unused-expressions 67 | host2?.freeze() 68 | await sleep() 69 | 70 | // 以下是都被冻结情况的测试 71 | 72 | // 全部都冻结了,拿到的应该是离解冻时间最近的一个 73 | const actual1 = await hostPool.getUp(...getParams) 74 | expect(actual1).toStrictEqual(new Host('host-1', 'https')) 75 | // eslint-disable-next-line no-unused-expressions 76 | host1?.freeze() // 已经冻结的再次冻结相当于更新解冻时间 77 | await sleep() 78 | 79 | // 因为 host-1 刚更新过冻结时间,所以这个时候解冻时间优先的应该是 host-2 80 | const actual2 = await hostPool.getUp(...getParams) 81 | expect(actual2).toStrictEqual(new Host('host-2', 'https')) 82 | await sleep() 83 | 84 | // 测试解冻 host-2,拿到的应该是 host-2 85 | // eslint-disable-next-line no-unused-expressions 86 | host2?.unfreeze() 87 | const actual3 = await hostPool.getUp(...getParams) 88 | expect(actual3).toStrictEqual(new Host('host-2', 'https')) 89 | // eslint-disable-next-line no-unused-expressions 90 | host2?.freeze() // 测试完再冻结住 91 | await sleep() 92 | 93 | // 本来优先的现在应该是 host-1 94 | // 测试 host-2 冻结时间设置为 0,应该获取到 host-2 95 | // eslint-disable-next-line no-unused-expressions 96 | host2?.freeze(0) 97 | const actual4 = await hostPool.getUp(...getParams) 98 | expect(actual4).toStrictEqual(new Host('host-2', 'https')) 99 | // eslint-disable-next-line no-unused-expressions 100 | host2?.freeze() 101 | await sleep() 102 | 103 | // 测试自定义冻结时间 104 | // eslint-disable-next-line no-unused-expressions 105 | host1?.freeze(200) 106 | // eslint-disable-next-line no-unused-expressions 107 | host2?.freeze(100) 108 | const actual5 = await hostPool.getUp(...getParams) 109 | expect(actual5).toStrictEqual(new Host('host-2', 'https')) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /src/upload/hosts.ts: -------------------------------------------------------------------------------- 1 | import { getUpHosts } from '../api' 2 | import { InternalConfig } from './base' 3 | 4 | /** 5 | * @description 解冻时间,key 是 host,value 为解冻时间 6 | */ 7 | const unfreezeTimeMap = new Map() 8 | 9 | export class Host { 10 | constructor(public host: string, public protocol: InternalConfig['upprotocol']) { } 11 | 12 | /** 13 | * @description 当前 host 是否为冻结状态 14 | */ 15 | isFrozen() { 16 | const currentTime = new Date().getTime() 17 | const unfreezeTime = unfreezeTimeMap.get(this.host) 18 | return unfreezeTime != null && unfreezeTime >= currentTime 19 | } 20 | 21 | /** 22 | * @param {number} time 单位秒,默认 20s 23 | * @description 冻结该 host 对象,该 host 将在指定时间内不可用 24 | */ 25 | freeze(time = 20) { 26 | const unfreezeTime = new Date().getTime() + (time * 1000) 27 | unfreezeTimeMap.set(this.host, unfreezeTime) 28 | } 29 | 30 | /** 31 | * @description 解冻该 host 32 | */ 33 | unfreeze() { 34 | unfreezeTimeMap.delete(this.host) 35 | } 36 | 37 | /** 38 | * @description 获取当前 host 的完整 url 39 | */ 40 | getUrl() { 41 | return `${this.protocol}://${this.host}` 42 | } 43 | 44 | /** 45 | * @description 获取解冻时间 46 | */ 47 | getUnfreezeTime() { 48 | return unfreezeTimeMap.get(this.host) 49 | } 50 | } 51 | export class HostPool { 52 | /** 53 | * @description 缓存的 host 表,以 bucket 和 accessKey 作为 key 54 | */ 55 | private cachedHostsMap = new Map() 56 | 57 | /** 58 | * @param {string[]} initHosts 59 | * @description 如果在构造时传入 initHosts,则该 host 池始终使用传入的 initHosts 做为可用的数据 60 | */ 61 | constructor(private initHosts: string[] = []) { } 62 | 63 | /** 64 | * @param {string} accessKey 65 | * @param {string} bucketName 66 | * @param {string[]} hosts 67 | * @param {InternalConfig['upprotocol']} protocol 68 | * @returns {void} 69 | * @description 注册可用 host 70 | */ 71 | private register(accessKey: string, bucketName: string, hosts: string[], protocol: InternalConfig['upprotocol']): void { 72 | this.cachedHostsMap.set( 73 | `${accessKey}@${bucketName}`, 74 | hosts.map(host => new Host(host, protocol)) 75 | ) 76 | } 77 | 78 | /** 79 | * @param {string} accessKey 80 | * @param {string} bucketName 81 | * @param {InternalConfig['upprotocol']} protocol 82 | * @returns {Promise} 83 | * @description 刷新最新的 host 数据,如果用户在构造时该类时传入了 host 或者已经存在缓存则不会发起请求 84 | */ 85 | private async refresh(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise { 86 | const cachedHostList = this.cachedHostsMap.get(`${accessKey}@${bucketName}`) || [] 87 | if (cachedHostList.length > 0) return 88 | 89 | if (this.initHosts.length > 0) { 90 | this.register(accessKey, bucketName, this.initHosts, protocol) 91 | return 92 | } 93 | 94 | const response = await getUpHosts(accessKey, bucketName, protocol) 95 | if (response?.data != null) { 96 | const stashHosts: string[] = [ 97 | ...(response.data.up?.acc?.main || []), 98 | ...(response.data.up?.acc?.backup || []) 99 | ] 100 | this.register(accessKey, bucketName, stashHosts, protocol) 101 | } 102 | } 103 | 104 | /** 105 | * @param {string} accessKey 106 | * @param {string} bucketName 107 | * @param {InternalConfig['upprotocol']} protocol 108 | * @returns {Promise} 109 | * @description 获取一个可用的上传 Host,排除已冻结的 110 | */ 111 | public async getUp(accessKey: string, bucketName: string, protocol: InternalConfig['upprotocol']): Promise { 112 | await this.refresh(accessKey, bucketName, protocol) 113 | const cachedHostList = this.cachedHostsMap.get(`${accessKey}@${bucketName}`) || [] 114 | 115 | if (cachedHostList.length === 0) return null 116 | const availableHostList = cachedHostList.filter(host => !host.isFrozen()) 117 | if (availableHostList.length > 0) return availableHostList[0] 118 | 119 | // 无可用的,去取离解冻最近的 host 120 | const priorityQueue = cachedHostList 121 | .slice().sort( 122 | (hostA, hostB) => (hostA.getUnfreezeTime() || 0) - (hostB.getUnfreezeTime() || 0) 123 | ) 124 | 125 | return priorityQueue[0] 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/upload/index.test.ts: -------------------------------------------------------------------------------- 1 | import { ApiName, errorMap, MockApi } from '../api/index.mock' 2 | 3 | const mockApi = new MockApi() 4 | jest.mock('../api', () => mockApi) 5 | 6 | // eslint-disable-next-line import/first 7 | import { MB, Observable } from '../utils' 8 | // eslint-disable-next-line import/first 9 | import upload from '.' 10 | 11 | const testToken = 'lVgtk5xr03Oz_uvkzDtQ8LtpiEUWx5tGEDUZVg1y:rAwZ6rnPQbjyG6Pzkx4PORzn6C8=:eyJyZXR1cm5Cb2R5Ijoie1wia2V5XCI6ICQoa2V5KX0iLCJzY29wZSI6InFpbml1LWRhcnQtc2RrIiwiZGVhZGxpbmUiOjE2MTkzNjA0Mzh9' 12 | 13 | function mockFile(size = 4, name = 'mock.jpg', type = 'image/jpg'): File { 14 | if (size >= 1024) throw new Error('the size is set too large.') 15 | 16 | const blob = new Blob(['1'.repeat(size * MB)], { type }) 17 | return new File([blob], name) 18 | } 19 | 20 | function observablePromisify(observable: Observable) { 21 | return new Promise((resolve, reject) => { 22 | observable.subscribe({ 23 | error: reject, 24 | complete: resolve 25 | }) 26 | }) 27 | } 28 | 29 | const File3M = mockFile(3) 30 | const File4M = mockFile(4) 31 | const File5M = mockFile(5) 32 | 33 | describe('test upload', () => { 34 | beforeEach(() => { 35 | localStorage.clear() // 清理缓存 36 | mockApi.clearInterceptor() 37 | }) 38 | 39 | test('base Direct.', async () => { 40 | // 文件小于 4M 使用直传 41 | const result1 = await observablePromisify(upload(File3M, null, testToken)) 42 | expect(result1).toStrictEqual((await mockApi.direct()).data) 43 | 44 | // 文件等于 4M 使用直传 45 | const result2 = await observablePromisify(upload(File4M, null, testToken)) 46 | expect(result2).toStrictEqual((await mockApi.direct()).data) 47 | }) 48 | 49 | test('Direct: all api error state.', async () => { 50 | for (const error of Object.values(errorMap)) { 51 | localStorage.clear() 52 | mockApi.clearInterceptor() 53 | mockApi.setInterceptor('direct', () => Promise.reject(error)) 54 | // eslint-disable-next-line no-await-in-loop 55 | await expect(observablePromisify(upload(File3M, null, testToken))) 56 | .rejects.toStrictEqual(error) 57 | } 58 | }) 59 | 60 | test('Resume: base.', async () => { 61 | // 文件大于 4M 使用分片 62 | const result = await observablePromisify(upload(File5M, null, testToken)) 63 | expect(result).toStrictEqual((await mockApi.uploadComplete()).data) 64 | }) 65 | 66 | test('Resume: all api error state.', async () => { 67 | const testApiTable: ApiName[] = [ 68 | 'getUpHosts', 'initUploadParts', 69 | 'uploadChunk', 'uploadComplete' 70 | ] 71 | 72 | for (const apiName of testApiTable) { 73 | for (const error of Object.values(errorMap)) { 74 | localStorage.clear() 75 | mockApi.clearInterceptor() 76 | mockApi.setInterceptor(apiName, (..._: any[]) => Promise.reject(error)) 77 | // eslint-disable-next-line no-await-in-loop 78 | await expect(observablePromisify(upload(File5M, null, testToken))) 79 | .rejects.toStrictEqual(error) 80 | } 81 | } 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /src/upload/index.ts: -------------------------------------------------------------------------------- 1 | import Resume from './resume' 2 | import Direct from './direct' 3 | import Logger from '../logger' 4 | import { UploadCompleteData } from '../api' 5 | import { Observable, IObserver, MB, normalizeUploadConfig } from '../utils' 6 | import { QiniuError, QiniuNetworkError, QiniuRequestError } from '../errors' 7 | import { Extra, UploadOptions, UploadHandlers, UploadProgress, Config } from './base' 8 | import { HostPool } from './hosts' 9 | 10 | export * from './base' 11 | export * from './resume' 12 | 13 | export function createUploadManager( 14 | options: UploadOptions, 15 | handlers: UploadHandlers, 16 | hostPool: HostPool, 17 | logger: Logger 18 | ) { 19 | if (options.config && options.config.forceDirect) { 20 | logger.info('ues forceDirect mode.') 21 | return new Direct(options, handlers, hostPool, logger) 22 | } 23 | 24 | if (options.file.size > 4 * MB) { 25 | logger.info('file size over 4M, use Resume.') 26 | return new Resume(options, handlers, hostPool, logger) 27 | } 28 | 29 | logger.info('file size less or equal than 4M, use Direct.') 30 | return new Direct(options, handlers, hostPool, logger) 31 | } 32 | 33 | /** 34 | * @param file 上传文件 35 | * @param key 目标文件名 36 | * @param token 上传凭证 37 | * @param putExtra 上传文件的相关资源信息配置 38 | * @param config 上传任务的配置 39 | * @returns 返回用于上传任务的可观察对象 40 | */ 41 | export default function upload( 42 | file: File, 43 | key: string | null | undefined, 44 | token: string, 45 | putExtra?: Partial, 46 | config?: Config 47 | ): Observable { 48 | 49 | // 为每个任务创建单独的 Logger 50 | const logger = new Logger(token, config?.disableStatisticsReport, config?.debugLogLevel, file.name) 51 | 52 | const options: UploadOptions = { 53 | file, 54 | key, 55 | token, 56 | putExtra, 57 | config: normalizeUploadConfig(config, logger) 58 | } 59 | 60 | // 创建 host 池 61 | const hostPool = new HostPool(options.config.uphost) 62 | 63 | return new Observable((observer: IObserver< 64 | UploadProgress, 65 | QiniuError | QiniuRequestError | QiniuNetworkError, 66 | UploadCompleteData 67 | >) => { 68 | const manager = createUploadManager(options, { 69 | onData: (data: UploadProgress) => observer.next(data), 70 | onError: (err: QiniuError) => observer.error(err), 71 | onComplete: (res: any) => observer.complete(res) 72 | }, hostPool, logger) 73 | manager.putFile() 74 | return manager.stop.bind(manager) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /src/upload/resume.ts: -------------------------------------------------------------------------------- 1 | import { uploadChunk, uploadComplete, initUploadParts, UploadChunkData } from '../api' 2 | import { QiniuError, QiniuErrorName, QiniuRequestError } from '../errors' 3 | import * as utils from '../utils' 4 | 5 | import Base, { Progress, UploadInfo, Extra } from './base' 6 | 7 | export interface UploadedChunkStorage extends UploadChunkData { 8 | size: number 9 | } 10 | 11 | export interface ChunkLoaded { 12 | mkFileProgress: 0 | 1 13 | chunks: number[] 14 | } 15 | 16 | export interface ChunkInfo { 17 | chunk: Blob 18 | index: number 19 | } 20 | 21 | export interface LocalInfo { 22 | data: UploadedChunkStorage[] 23 | id: string 24 | } 25 | 26 | export interface ChunkPart { 27 | etag: string 28 | partNumber: number 29 | } 30 | 31 | export interface UploadChunkBody extends Extra { 32 | parts: ChunkPart[] 33 | } 34 | 35 | /** 是否为正整数 */ 36 | function isPositiveInteger(n: number) { 37 | const re = /^[1-9]\d*$/ 38 | return re.test(String(n)) 39 | } 40 | 41 | export default class Resume extends Base { 42 | /** 43 | * @description 文件的分片 chunks 44 | */ 45 | private chunks: Blob[] 46 | 47 | /** 48 | * @description 使用缓存的 chunks 49 | */ 50 | private usedCacheList: boolean[] 51 | 52 | /** 53 | * @description 来自缓存的上传信息 54 | */ 55 | private cachedUploadedList: UploadedChunkStorage[] 56 | 57 | /** 58 | * @description 当前上传过程中已完成的上传信息 59 | */ 60 | private uploadedList: UploadedChunkStorage[] 61 | 62 | /** 63 | * @description 当前上传片进度信息 64 | */ 65 | private loaded: ChunkLoaded 66 | 67 | /** 68 | * @description 当前上传任务的 id 69 | */ 70 | private uploadId: string 71 | 72 | /** 73 | * @returns {Promise>} 74 | * @description 实现了 Base 的 run 接口,处理具体的分片上传事务,并抛出过程中的异常。 75 | */ 76 | protected async run() { 77 | this.logger.info('start run Resume.') 78 | if (!this.config.chunkSize || !isPositiveInteger(this.config.chunkSize)) { 79 | throw new QiniuError( 80 | QiniuErrorName.InvalidChunkSize, 81 | 'chunkSize must be a positive integer' 82 | ) 83 | } 84 | 85 | if (this.config.chunkSize > 1024) { 86 | throw new QiniuError( 87 | QiniuErrorName.InvalidChunkSize, 88 | 'chunkSize maximum value is 1024' 89 | ) 90 | } 91 | 92 | await this.initBeforeUploadChunks() 93 | 94 | const pool = new utils.Pool( 95 | async (chunkInfo: ChunkInfo) => { 96 | if (this.aborted) { 97 | pool.abort() 98 | throw new Error('pool is aborted') 99 | } 100 | 101 | await this.uploadChunk(chunkInfo) 102 | }, 103 | this.config.concurrentRequestLimit 104 | ) 105 | 106 | let mkFileResponse = null 107 | const localKey = this.getLocalKey() 108 | const uploadChunks = this.chunks.map((chunk, index) => pool.enqueue({ chunk, index })) 109 | 110 | try { 111 | await Promise.all(uploadChunks) 112 | mkFileResponse = await this.mkFileReq() 113 | } catch (error) { 114 | // uploadId 无效,上传参数有误(多由于本地存储信息的 uploadId 失效) 115 | if (error instanceof QiniuRequestError && (error.code === 612 || error.code === 400)) { 116 | utils.removeLocalFileInfo(localKey, this.logger) 117 | } 118 | 119 | throw error 120 | } 121 | 122 | // 上传成功,清理本地缓存数据 123 | utils.removeLocalFileInfo(localKey, this.logger) 124 | return mkFileResponse 125 | } 126 | 127 | private async uploadChunk(chunkInfo: ChunkInfo) { 128 | const { index, chunk } = chunkInfo 129 | const cachedInfo = this.cachedUploadedList[index] 130 | this.logger.info(`upload part ${index}, cache:`, cachedInfo) 131 | 132 | const shouldCheckMD5 = this.config.checkByMD5 133 | const reuseSaved = () => { 134 | this.usedCacheList[index] = true 135 | this.updateChunkProgress(chunk.size, index) 136 | this.uploadedList[index] = cachedInfo 137 | this.updateLocalCache() 138 | } 139 | 140 | // FIXME: 至少判断一下 size 141 | if (cachedInfo && !shouldCheckMD5) { 142 | reuseSaved() 143 | return 144 | } 145 | 146 | const md5 = await utils.computeMd5(chunk) 147 | this.logger.info('computed part md5.', md5) 148 | 149 | if (cachedInfo && md5 === cachedInfo.md5) { 150 | reuseSaved() 151 | return 152 | } 153 | 154 | // 没有使用缓存设置标记为 false 155 | this.usedCacheList[index] = false 156 | 157 | const onProgress = (data: Progress) => { 158 | this.updateChunkProgress(data.loaded, index) 159 | } 160 | 161 | const requestOptions = { 162 | body: chunk, 163 | md5: this.config.checkByServer ? md5 : undefined, 164 | onProgress, 165 | onCreate: (xhr: XMLHttpRequest) => this.addXhr(xhr) 166 | } 167 | 168 | this.logger.info(`part ${index} start uploading.`) 169 | const response = await uploadChunk( 170 | this.token, 171 | this.key, 172 | chunkInfo.index + 1, 173 | this.getUploadInfo(), 174 | requestOptions 175 | ) 176 | this.logger.info(`part ${index} upload completed.`) 177 | 178 | // 在某些浏览器环境下,xhr 的 progress 事件无法被触发,progress 为 null,这里在每次分片上传完成后都手动更新下 progress 179 | onProgress({ 180 | loaded: chunk.size, 181 | total: chunk.size 182 | }) 183 | 184 | this.uploadedList[index] = { 185 | etag: response.data.etag, 186 | md5: response.data.md5, 187 | size: chunk.size 188 | } 189 | 190 | this.updateLocalCache() 191 | } 192 | 193 | private async mkFileReq() { 194 | const data: UploadChunkBody = { 195 | parts: this.uploadedList.map((value, index) => ({ 196 | etag: value.etag, 197 | // 接口要求 index 需要从 1 开始,所以需要整体 + 1 198 | partNumber: index + 1 199 | })), 200 | fname: this.putExtra.fname, 201 | ...this.putExtra.mimeType && { mimeType: this.putExtra.mimeType }, 202 | ...this.putExtra.customVars && { customVars: this.putExtra.customVars }, 203 | ...this.putExtra.metadata && { metadata: this.putExtra.metadata } 204 | } 205 | 206 | this.logger.info('parts upload completed, make file.', data) 207 | const result = await uploadComplete( 208 | this.token, 209 | this.key, 210 | this.getUploadInfo(), 211 | { 212 | onCreate: xhr => this.addXhr(xhr), 213 | body: JSON.stringify(data) 214 | } 215 | ) 216 | 217 | this.logger.info('finish Resume Progress.') 218 | this.updateMkFileProgress(1) 219 | return result 220 | } 221 | 222 | private async initBeforeUploadChunks() { 223 | this.uploadedList = [] 224 | this.usedCacheList = [] 225 | const cachedInfo = utils.getLocalFileInfo(this.getLocalKey(), this.logger) 226 | 227 | // 分片必须和当时使用的 uploadId 配套,所以断点续传需要把本地存储的 uploadId 拿出来 228 | // 假如没有 cachedInfo 本地信息并重新获取 uploadId 229 | if (!cachedInfo) { 230 | this.logger.info('init upload parts from api.') 231 | const res = await initUploadParts( 232 | this.token, 233 | this.bucketName, 234 | this.key, 235 | this.uploadHost!.getUrl() 236 | ) 237 | this.logger.info(`initd upload parts of id: ${res.data.uploadId}.`) 238 | this.uploadId = res.data.uploadId 239 | this.cachedUploadedList = [] 240 | } else { 241 | const infoMessage = [ 242 | 'resume upload parts from local cache,', 243 | `total ${cachedInfo.data.length} part,`, 244 | `id is ${cachedInfo.id}.` 245 | ] 246 | 247 | this.logger.info(infoMessage.join(' ')) 248 | this.cachedUploadedList = cachedInfo.data 249 | this.uploadId = cachedInfo.id 250 | } 251 | 252 | this.chunks = utils.getChunks(this.file, this.config.chunkSize) 253 | this.loaded = { 254 | mkFileProgress: 0, 255 | chunks: this.chunks.map(_ => 0) 256 | } 257 | this.notifyResumeProgress() 258 | } 259 | 260 | private getUploadInfo(): UploadInfo { 261 | return { 262 | id: this.uploadId, 263 | url: this.uploadHost!.getUrl() 264 | } 265 | } 266 | 267 | private getLocalKey() { 268 | return utils.createLocalKey(this.file.name, this.key, this.file.size) 269 | } 270 | 271 | private updateLocalCache() { 272 | utils.setLocalFileInfo(this.getLocalKey(), { 273 | id: this.uploadId, 274 | data: this.uploadedList 275 | }, this.logger) 276 | } 277 | 278 | private updateChunkProgress(loaded: number, index: number) { 279 | this.loaded.chunks[index] = loaded 280 | this.notifyResumeProgress() 281 | } 282 | 283 | private updateMkFileProgress(progress: 0 | 1) { 284 | this.loaded.mkFileProgress = progress 285 | this.notifyResumeProgress() 286 | } 287 | 288 | private notifyResumeProgress() { 289 | this.progress = { 290 | total: this.getProgressInfoItem( 291 | utils.sum(this.loaded.chunks) + this.loaded.mkFileProgress, 292 | // FIXME: 不准确的 fileSize 293 | this.file.size + 1 // 防止在 complete 未调用的时候进度显示 100% 294 | ), 295 | chunks: this.chunks.map((chunk, index) => { 296 | const fromCache = this.usedCacheList[index] 297 | return this.getProgressInfoItem(this.loaded.chunks[index], chunk.size, fromCache) 298 | }), 299 | uploadInfo: { 300 | id: this.uploadId, 301 | url: this.uploadHost!.getUrl() 302 | } 303 | } 304 | this.onData(this.progress) 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/utils/base64.test.ts: -------------------------------------------------------------------------------- 1 | import * as base64 from './base64' 2 | 3 | // 测试用例来自以下地址 4 | // https://github.com/LinusU/encode-utf8/blob/bd6c09b1c67baafc51853b1bea0e80bfe1e69ed0/test.js 5 | const testCases = [ 6 | ['正', '5q2j'], 7 | ['𝌆', '8J2Mhg'], 8 | ['💩', '8J-SqQ'], 9 | ['Hello, World!', 'SGVsbG8sIFdvcmxkIQ'], 10 | ['🐵 🙈 🙉 🙊', '8J-QtSDwn5mIIPCfmYkg8J-Zig'], 11 | ['åß∂ƒ©˙∆˚¬…æ', 'w6XDn-KIgsaSwqnLmeKIhsuawqzigKbDpg'], 12 | ['사회과학원 어학연구소', '7IKs7ZqM6rO87ZWZ7JuQIOyWtO2VmeyXsOq1rOyGjA'], 13 | ['゚・✿ヾ╲(。◕‿◕。)╱✿・゚', '776f772l4py_44O-4pWyKO-9oeKXleKAv-KXle-9oSnilbHinL_vvaXvvp8'], 14 | ['Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗', 'UG93ZXLZhNmP2YTZj9i12ZHYqNmP2YTZj9mE2LXZkdio2Y_Ysdix2Ysg4KWjIOClo2gg4KWjIOClo-WGlw'], 15 | ['𝕿𝖍𝖊 𝖖𝖚𝖎𝖈𝖐 𝖇𝖗𝖔𝖜𝖓 𝖋𝖔𝖝 𝖏𝖚𝖒𝖕𝖘 𝖔𝖛𝖊𝖗 𝖙𝖍𝖊 𝖑𝖆𝖟𝖞 𝖉𝖔𝖌', '8J2Vv_Cdlo3wnZaKIPCdlpbwnZaa8J2WjvCdlojwnZaQIPCdlofwnZaX8J2WlPCdlpzwnZaTIPCdlovwnZaU8J2WnSDwnZaP8J2WmvCdlpLwnZaV8J2WmCDwnZaU8J2Wm_CdlorwnZaXIPCdlpnwnZaN8J2WiiDwnZaR8J2WhvCdlp_wnZaeIPCdlonwnZaU8J2WjA'] 16 | ] 17 | 18 | describe('test base64', () => { 19 | test('urlSafeBase64Encode', () => { 20 | for (const [input, expected] of testCases) { 21 | const actual = base64.urlSafeBase64Encode(input) 22 | expect(actual).toMatch(expected) 23 | } 24 | }) 25 | test('urlSafeBase64Decode', () => { 26 | for (const [expected, input] of testCases) { 27 | const actual = base64.urlSafeBase64Decode(input) 28 | expect(actual).toMatch(expected) 29 | } 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // https://github.com/locutusjs/locutus/blob/master/src/php/xml/utf8_encode.js 4 | function utf8Encode(argString: string) { 5 | // http://kevin.vanzonneveld.net 6 | // + original by: Webtoolkit.info (http://www.webtoolkit.info/) 7 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 8 | // + improved by: sowberry 9 | // + tweaked by: Jack 10 | // + bugfixed by: Onno Marsman 11 | // + improved by: Yves Sucaet 12 | // + bugfixed by: Onno Marsman 13 | // + bugfixed by: Ulrich 14 | // + bugfixed by: Rafal Kukawski 15 | // + improved by: kirilloid 16 | // + bugfixed by: kirilloid 17 | // * example 1: this.utf8Encode('Kevin van Zonneveld') 18 | // * returns 1: 'Kevin van Zonneveld' 19 | 20 | if (argString === null || typeof argString === 'undefined') { 21 | return '' 22 | } 23 | 24 | let string = argString + '' // .replace(/\r\n/g, '\n').replace(/\r/g, '\n') 25 | let utftext = '', 26 | start, 27 | end, 28 | stringl = 0 29 | 30 | start = end = 0 31 | stringl = string.length 32 | for (let n = 0; n < stringl; n++) { 33 | let c1 = string.charCodeAt(n) 34 | let enc = null 35 | 36 | if (c1 < 128) { 37 | end++ 38 | } else if (c1 > 127 && c1 < 2048) { 39 | enc = String.fromCharCode((c1 >> 6) | 192, (c1 & 63) | 128) 40 | } else if ((c1 & 0xf800 ^ 0xd800) > 0) { 41 | enc = String.fromCharCode( 42 | (c1 >> 12) | 224, 43 | ((c1 >> 6) & 63) | 128, 44 | (c1 & 63) | 128 45 | ) 46 | } else { 47 | // surrogate pairs 48 | if ((c1 & 0xfc00 ^ 0xd800) > 0) { 49 | throw new RangeError('Unmatched trail surrogate at ' + n) 50 | } 51 | let c2 = string.charCodeAt(++n) 52 | if ((c2 & 0xfc00 ^ 0xdc00) > 0) { 53 | throw new RangeError('Unmatched lead surrogate at ' + (n - 1)) 54 | } 55 | c1 = ((c1 & 0x3ff) << 10) + (c2 & 0x3ff) + 0x10000 56 | enc = String.fromCharCode( 57 | (c1 >> 18) | 240, 58 | ((c1 >> 12) & 63) | 128, 59 | ((c1 >> 6) & 63) | 128, 60 | (c1 & 63) | 128 61 | ) 62 | } 63 | if (enc !== null) { 64 | if (end > start) { 65 | utftext += string.slice(start, end) 66 | } 67 | utftext += enc 68 | start = end = n + 1 69 | } 70 | } 71 | 72 | if (end > start) { 73 | utftext += string.slice(start, stringl) 74 | } 75 | 76 | return utftext 77 | } 78 | 79 | // https://github.com/locutusjs/locutus/blob/master/src/php/xml/utf8_decode.js 80 | function utf8Decode(strData: string) { 81 | // eslint-disable-line camelcase 82 | // discuss at: https://locutus.io/php/utf8_decode/ 83 | // original by: Webtoolkit.info (https://www.webtoolkit.info/) 84 | // input by: Aman Gupta 85 | // input by: Brett Zamir (https://brett-zamir.me) 86 | // improved by: Kevin van Zonneveld (https://kvz.io) 87 | // improved by: Norman "zEh" Fuchs 88 | // bugfixed by: hitwork 89 | // bugfixed by: Onno Marsman (https://twitter.com/onnomarsman) 90 | // bugfixed by: Kevin van Zonneveld (https://kvz.io) 91 | // bugfixed by: kirilloid 92 | // bugfixed by: w35l3y (https://www.wesley.eti.br) 93 | // example 1: utf8_decode('Kevin van Zonneveld') 94 | // returns 1: 'Kevin van Zonneveld' 95 | 96 | const tmpArr = [] 97 | let i = 0 98 | let c1 = 0 99 | let seqlen = 0 100 | 101 | strData += '' 102 | 103 | while (i < strData.length) { 104 | c1 = strData.charCodeAt(i) & 0xFF 105 | seqlen = 0 106 | 107 | // https://en.wikipedia.org/wiki/UTF-8#Codepage_layout 108 | if (c1 <= 0xBF) { 109 | c1 = (c1 & 0x7F) 110 | seqlen = 1 111 | } else if (c1 <= 0xDF) { 112 | c1 = (c1 & 0x1F) 113 | seqlen = 2 114 | } else if (c1 <= 0xEF) { 115 | c1 = (c1 & 0x0F) 116 | seqlen = 3 117 | } else { 118 | c1 = (c1 & 0x07) 119 | seqlen = 4 120 | } 121 | 122 | for (let ai = 1; ai < seqlen; ++ai) { 123 | c1 = ((c1 << 0x06) | (strData.charCodeAt(ai + i) & 0x3F)) 124 | } 125 | 126 | if (seqlen === 4) { 127 | c1 -= 0x10000 128 | tmpArr.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF))) 129 | tmpArr.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF))) 130 | } else { 131 | tmpArr.push(String.fromCharCode(c1)) 132 | } 133 | 134 | i += seqlen 135 | } 136 | 137 | return tmpArr.join('') 138 | } 139 | 140 | function base64Encode(data: any) { 141 | // http://kevin.vanzonneveld.net 142 | // + original by: Tyler Akins (http://rumkin.com) 143 | // + improved by: Bayron Guevara 144 | // + improved by: Thunder.m 145 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 146 | // + bugfixed by: Pellentesque Malesuada 147 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 148 | // - depends on: this.utf8Encode 149 | // * example 1: this.base64Encode('Kevin van Zonneveld') 150 | // * returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA==' 151 | // mozilla has this native 152 | // - but breaks in 2.0.0.12! 153 | // if (typeof this.window['atob'] == 'function') { 154 | // return atob(data) 155 | // } 156 | let b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' 157 | let o1, 158 | o2, 159 | o3, 160 | h1, 161 | h2, 162 | h3, 163 | h4, 164 | bits, 165 | i = 0, 166 | ac = 0, 167 | enc = '', 168 | tmp_arr = [] 169 | 170 | if (!data) { 171 | return data 172 | } 173 | 174 | data = utf8Encode(data + '') 175 | 176 | do { 177 | // pack three octets into four hexets 178 | o1 = data.charCodeAt(i++) 179 | o2 = data.charCodeAt(i++) 180 | o3 = data.charCodeAt(i++) 181 | 182 | bits = (o1 << 16) | (o2 << 8) | o3 183 | 184 | h1 = (bits >> 18) & 0x3f 185 | h2 = (bits >> 12) & 0x3f 186 | h3 = (bits >> 6) & 0x3f 187 | h4 = bits & 0x3f 188 | 189 | // use hexets to index into b64, and append result to encoded string 190 | tmp_arr[ac++] = 191 | b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4) 192 | } while (i < data.length) 193 | 194 | enc = tmp_arr.join('') 195 | 196 | switch (data.length % 3) { 197 | case 1: 198 | enc = enc.slice(0, -2) + '==' 199 | break 200 | case 2: 201 | enc = enc.slice(0, -1) + '=' 202 | break 203 | } 204 | 205 | return enc 206 | } 207 | 208 | function base64Decode(data: string) { 209 | // http://kevin.vanzonneveld.net 210 | // + original by: Tyler Akins (http://rumkin.com) 211 | // + improved by: Thunder.m 212 | // + input by: Aman Gupta 213 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 214 | // + bugfixed by: Onno Marsman 215 | // + bugfixed by: Pellentesque Malesuada 216 | // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 217 | // + input by: Brett Zamir (http://brett-zamir.me) 218 | // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 219 | // * example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA==') 220 | // * returns 1: 'Kevin van Zonneveld' 221 | // mozilla has this native 222 | // - but breaks in 2.0.0.12! 223 | // if (typeof this.window['atob'] == 'function') { 224 | // return atob(data) 225 | // } 226 | let b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' 227 | let o1, o2, o3, h1, h2, h3, h4, bits, i = 0, 228 | ac = 0, 229 | dec = '', 230 | tmp_arr = [] 231 | 232 | if (!data) { 233 | return data 234 | } 235 | 236 | data += '' 237 | 238 | do { // unpack four hexets into three octets using index points in b64 239 | h1 = b64.indexOf(data.charAt(i++)) 240 | h2 = b64.indexOf(data.charAt(i++)) 241 | h3 = b64.indexOf(data.charAt(i++)) 242 | h4 = b64.indexOf(data.charAt(i++)) 243 | 244 | bits = h1 << 18 | h2 << 12 | h3 << 6 | h4 245 | 246 | o1 = bits >> 16 & 0xff 247 | o2 = bits >> 8 & 0xff 248 | o3 = bits & 0xff 249 | 250 | if (h3 === 64) { 251 | tmp_arr[ac++] = String.fromCharCode(o1) 252 | } else if (h4 === 64) { 253 | tmp_arr[ac++] = String.fromCharCode(o1, o2) 254 | } else { 255 | tmp_arr[ac++] = String.fromCharCode(o1, o2, o3) 256 | } 257 | } while (i < data.length) 258 | 259 | dec = tmp_arr.join('') 260 | 261 | return utf8Decode(dec) 262 | } 263 | 264 | export function urlSafeBase64Encode(v: any) { 265 | v = base64Encode(v) 266 | 267 | // 参考 https://tools.ietf.org/html/rfc4648#section-5 268 | return v.replace(/\//g, '_').replace(/\+/g, '-') 269 | } 270 | 271 | export function urlSafeBase64Decode(v: any) { 272 | v = v.replace(/_/g, '/').replace(/-/g, '+') 273 | return base64Decode(v) 274 | } 275 | -------------------------------------------------------------------------------- /src/utils/compress.ts: -------------------------------------------------------------------------------- 1 | import { QiniuErrorName, QiniuError } from '../errors' 2 | 3 | import { createObjectURL } from './helper' 4 | 5 | export interface CompressOptions { 6 | quality?: number 7 | noCompressIfLarger?: boolean 8 | maxWidth?: number 9 | maxHeight?: number 10 | } 11 | 12 | export interface Dimension { 13 | width?: number 14 | height?: number 15 | } 16 | 17 | export interface CompressResult { 18 | dist: Blob | File 19 | width: number 20 | height: number 21 | } 22 | 23 | const mimeTypes = { 24 | PNG: 'image/png', 25 | JPEG: 'image/jpeg', 26 | WEBP: 'image/webp', 27 | BMP: 'image/bmp' 28 | } as const 29 | 30 | const maxSteps = 4 31 | const scaleFactor = Math.log(2) 32 | const supportMimeTypes = Object.keys(mimeTypes).map(type => mimeTypes[type]) 33 | const defaultType = mimeTypes.JPEG 34 | 35 | type MimeKey = keyof typeof mimeTypes 36 | 37 | function isSupportedType(type: string): type is typeof mimeTypes[MimeKey] { 38 | return supportMimeTypes.includes(type) 39 | } 40 | 41 | class Compress { 42 | private outputType: string 43 | 44 | constructor(private file: File, private config: CompressOptions) { 45 | this.config = { 46 | quality: 0.92, 47 | noCompressIfLarger: false, 48 | ...this.config 49 | } 50 | } 51 | 52 | async process(): Promise { 53 | this.outputType = this.file.type 54 | const srcDimension: Dimension = {} 55 | if (!isSupportedType(this.file.type)) { 56 | throw new QiniuError( 57 | QiniuErrorName.UnsupportedFileType, 58 | `unsupported file type: ${this.file.type}` 59 | ) 60 | } 61 | 62 | const originImage = await this.getOriginImage() 63 | const canvas = await this.getCanvas(originImage) 64 | let scale = 1 65 | if (this.config.maxWidth) { 66 | scale = Math.min(1, this.config.maxWidth / canvas.width) 67 | } 68 | if (this.config.maxHeight) { 69 | scale = Math.min(1, scale, this.config.maxHeight / canvas.height) 70 | } 71 | srcDimension.width = canvas.width 72 | srcDimension.height = canvas.height 73 | 74 | const scaleCanvas = await this.doScale(canvas, scale) 75 | const distBlob = this.toBlob(scaleCanvas) 76 | if (distBlob.size > this.file.size && this.config.noCompressIfLarger) { 77 | return { 78 | dist: this.file, 79 | width: srcDimension.width, 80 | height: srcDimension.height 81 | } 82 | } 83 | 84 | return { 85 | dist: distBlob, 86 | width: scaleCanvas.width, 87 | height: scaleCanvas.height 88 | } 89 | } 90 | 91 | clear(ctx: CanvasRenderingContext2D, width: number, height: number) { 92 | // jpeg 没有 alpha 通道,透明区间会被填充成黑色,这里把透明区间填充为白色 93 | if (this.outputType === defaultType) { 94 | ctx.fillStyle = '#fff' 95 | ctx.fillRect(0, 0, width, height) 96 | } else { 97 | ctx.clearRect(0, 0, width, height) 98 | } 99 | } 100 | 101 | /** 通过 file 初始化 image 对象 */ 102 | getOriginImage(): Promise { 103 | return new Promise((resolve, reject) => { 104 | const url = createObjectURL(this.file) 105 | const img = new Image() 106 | img.onload = () => { 107 | resolve(img) 108 | } 109 | img.onerror = () => { 110 | reject('image load error') 111 | } 112 | img.src = url 113 | }) 114 | } 115 | 116 | getCanvas(img: HTMLImageElement): Promise { 117 | return new Promise((resolve, reject) => { 118 | const canvas = document.createElement('canvas') 119 | const context = canvas.getContext('2d') 120 | 121 | if (!context) { 122 | reject(new QiniuError( 123 | QiniuErrorName.GetCanvasContextFailed, 124 | 'context is null' 125 | )) 126 | return 127 | } 128 | 129 | const { width, height } = img 130 | canvas.height = height 131 | canvas.width = width 132 | 133 | this.clear(context, width, height) 134 | context.drawImage(img, 0, 0) 135 | resolve(canvas) 136 | }) 137 | } 138 | 139 | async doScale(source: HTMLCanvasElement, scale: number) { 140 | if (scale === 1) { 141 | return source 142 | } 143 | // 不要一次性画图,通过设定的 step 次数,渐进式的画图,这样可以增加图片的清晰度,防止一次性画图导致的像素丢失严重 144 | const sctx = source.getContext('2d') 145 | const steps = Math.min(maxSteps, Math.ceil((1 / scale) / scaleFactor)) 146 | 147 | const factor = scale ** (1 / steps) 148 | 149 | const mirror = document.createElement('canvas') 150 | const mctx = mirror.getContext('2d') 151 | 152 | let { width, height } = source 153 | const originWidth = width 154 | const originHeight = height 155 | mirror.width = width 156 | mirror.height = height 157 | if (!mctx || !sctx) { 158 | throw new QiniuError( 159 | QiniuErrorName.GetCanvasContextFailed, 160 | "mctx or sctx can't be null" 161 | ) 162 | } 163 | 164 | let src!: CanvasImageSource 165 | let context!: CanvasRenderingContext2D 166 | for (let i = 0; i < steps; i++) { 167 | 168 | let dw = width * factor | 0 // eslint-disable-line no-bitwise 169 | let dh = height * factor | 0 // eslint-disable-line no-bitwise 170 | // 到最后一步的时候 dw, dh 用目标缩放尺寸,否则会出现最后尺寸偏小的情况 171 | if (i === steps - 1) { 172 | dw = originWidth * scale 173 | dh = originHeight * scale 174 | } 175 | 176 | if (i % 2 === 0) { 177 | src = source 178 | context = mctx 179 | } else { 180 | src = mirror 181 | context = sctx 182 | } 183 | // 每次画前都清空,避免图像重叠 184 | this.clear(context, width, height) 185 | context.drawImage(src, 0, 0, width, height, 0, 0, dw, dh) 186 | width = dw 187 | height = dh 188 | } 189 | 190 | const canvas = src === source ? mirror : source 191 | // save data 192 | const data = context.getImageData(0, 0, width, height) 193 | 194 | // resize 195 | canvas.width = width 196 | canvas.height = height 197 | 198 | // store image data 199 | context.putImageData(data, 0, 0) 200 | 201 | return canvas 202 | } 203 | 204 | /** 这里把 base64 字符串转为 blob 对象 */ 205 | toBlob(result: HTMLCanvasElement) { 206 | const dataURL = result.toDataURL(this.outputType, this.config.quality) 207 | const buffer = atob(dataURL.split(',')[1]).split('').map(char => char.charCodeAt(0)) 208 | const blob = new Blob([new Uint8Array(buffer)], { type: this.outputType }) 209 | return blob 210 | } 211 | } 212 | 213 | const compressImage = (file: File, options: CompressOptions) => new Compress(file, options).process() 214 | 215 | export default compressImage 216 | -------------------------------------------------------------------------------- /src/utils/config.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CHUNK_SIZE } from '../upload' 2 | import { normalizeUploadConfig } from './config' 3 | import { region, regionUphostMap } from '../config/region' 4 | 5 | describe('test config ', () => { 6 | test('normalizeUploadConfig', () => { 7 | const config1 = normalizeUploadConfig() 8 | expect(config1).toStrictEqual({ 9 | uphost: [], 10 | retryCount: 3, 11 | checkByMD5: false, 12 | checkByServer: false, 13 | forceDirect: false, 14 | useCdnDomain: true, 15 | concurrentRequestLimit: 3, 16 | chunkSize: DEFAULT_CHUNK_SIZE, 17 | upprotocol: 'https', 18 | debugLogLevel: 'OFF', 19 | disableStatisticsReport: false 20 | }) 21 | 22 | const config2 = normalizeUploadConfig({ upprotocol: 'https:' }) 23 | expect(config2).toStrictEqual({ 24 | uphost: [], 25 | retryCount: 3, 26 | checkByMD5: false, 27 | checkByServer: false, 28 | forceDirect: false, 29 | useCdnDomain: true, 30 | concurrentRequestLimit: 3, 31 | chunkSize: DEFAULT_CHUNK_SIZE, 32 | upprotocol: 'https', 33 | debugLogLevel: 'OFF', 34 | disableStatisticsReport: false 35 | }) 36 | 37 | const config3 = normalizeUploadConfig({ region: region.z0 }) 38 | expect(config3).toStrictEqual({ 39 | region: region.z0, 40 | uphost: regionUphostMap[region.z0].cdnUphost, 41 | retryCount: 3, 42 | checkByMD5: false, 43 | checkByServer: false, 44 | forceDirect: false, 45 | useCdnDomain: true, 46 | concurrentRequestLimit: 3, 47 | chunkSize: DEFAULT_CHUNK_SIZE, 48 | upprotocol: 'https', 49 | debugLogLevel: 'OFF', 50 | disableStatisticsReport: false 51 | }) 52 | 53 | const config4 = normalizeUploadConfig({ uphost: ['test'] }) 54 | expect(config4).toStrictEqual({ 55 | uphost: ['test'], 56 | retryCount: 3, 57 | checkByMD5: false, 58 | checkByServer: false, 59 | forceDirect: false, 60 | useCdnDomain: true, 61 | concurrentRequestLimit: 3, 62 | chunkSize: DEFAULT_CHUNK_SIZE, 63 | upprotocol: 'https', 64 | debugLogLevel: 'OFF', 65 | disableStatisticsReport: false 66 | }) 67 | 68 | const config5 = normalizeUploadConfig({ uphost: ['test'], region: region.z0 }) 69 | expect(config5).toStrictEqual({ 70 | region: region.z0, 71 | uphost: ['test'], 72 | retryCount: 3, 73 | checkByMD5: false, 74 | checkByServer: false, 75 | forceDirect: false, 76 | useCdnDomain: true, 77 | concurrentRequestLimit: 3, 78 | chunkSize: DEFAULT_CHUNK_SIZE, 79 | upprotocol: 'https', 80 | debugLogLevel: 'OFF', 81 | disableStatisticsReport: false 82 | }) 83 | 84 | const config6 = normalizeUploadConfig({ useCdnDomain: false, region: region.z0 }) 85 | expect(config6).toStrictEqual({ 86 | region: region.z0, 87 | uphost: regionUphostMap[region.z0].srcUphost, 88 | retryCount: 3, 89 | checkByMD5: false, 90 | checkByServer: false, 91 | forceDirect: false, 92 | useCdnDomain: false, 93 | concurrentRequestLimit: 3, 94 | chunkSize: DEFAULT_CHUNK_SIZE, 95 | upprotocol: 'https', 96 | debugLogLevel: 'OFF', 97 | disableStatisticsReport: false 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../logger' 2 | import { regionUphostMap } from '../config' 3 | import { Config, DEFAULT_CHUNK_SIZE, InternalConfig } from '../upload' 4 | 5 | export function normalizeUploadConfig(config?: Partial, logger?: Logger): InternalConfig { 6 | const { upprotocol, uphost, ...otherConfig } = { ...config } 7 | 8 | const normalizeConfig: InternalConfig = { 9 | uphost: [], 10 | retryCount: 3, 11 | 12 | checkByMD5: false, 13 | forceDirect: false, 14 | useCdnDomain: true, 15 | checkByServer: false, 16 | concurrentRequestLimit: 3, 17 | chunkSize: DEFAULT_CHUNK_SIZE, 18 | 19 | upprotocol: 'https', 20 | 21 | debugLogLevel: 'OFF', 22 | disableStatisticsReport: false, 23 | 24 | ...otherConfig 25 | } 26 | 27 | // 兼容原来的 http: https: 的写法 28 | if (upprotocol) { 29 | normalizeConfig.upprotocol = upprotocol 30 | .replace(/:$/, '') as InternalConfig['upprotocol'] 31 | } 32 | 33 | const hostList: string[] = [] 34 | 35 | if (logger && config?.uphost != null && config?.region != null) { 36 | logger.warn('do not use both the uphost and region config.') 37 | } 38 | 39 | // 如果同时指定了 uphost 参数,添加到可用 host 列表 40 | if (uphost) { 41 | if (Array.isArray(uphost)) { 42 | hostList.push(...uphost) 43 | } else { 44 | hostList.push(uphost) 45 | } 46 | 47 | // 否则如果用户传了 region,添加指定 region 的 host 到可用 host 列表 48 | } else if (normalizeConfig?.region) { 49 | const hostMap = regionUphostMap[normalizeConfig?.region] 50 | if (normalizeConfig.useCdnDomain) { 51 | hostList.push(...hostMap.cdnUphost) 52 | } else { 53 | hostList.push(...hostMap.srcUphost) 54 | } 55 | } 56 | 57 | return { 58 | ...normalizeConfig, 59 | uphost: hostList.filter(Boolean) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/crc32.test.ts: -------------------------------------------------------------------------------- 1 | import { CRC32 } from './crc32' 2 | import { MB } from './helper' 3 | 4 | function mockFile(size = 4, name = 'mock.jpg', type = 'image/jpg'): File { 5 | if (size >= 1024) throw new Error('the size is set too large.') 6 | 7 | const blob = new Blob(['1'.repeat(size * MB)], { type }) 8 | return new File([blob], name) 9 | } 10 | 11 | describe('test crc32', () => { 12 | test('file', async () => { 13 | const crc32One = new CRC32() 14 | await expect(crc32One.file(mockFile(0))).resolves.toEqual(0) 15 | 16 | const crc32Two = new CRC32() 17 | await expect(crc32Two.file(mockFile(0.5))).resolves.toEqual(1610895105) 18 | 19 | const crc32Three = new CRC32() 20 | await expect(crc32Three.file(mockFile(1))).resolves.toEqual(3172987001) 21 | 22 | const crc32Four = new CRC32() 23 | await expect(crc32Four.file(mockFile(2))).resolves.toEqual(847982614) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/utils/crc32.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | 3 | import { MB } from './helper' 4 | 5 | /** 6 | * 以下 class 实现参考 7 | * https://github.com/Stuk/jszip/blob/d4702a70834bd953d4c2d0bc155fad795076631a/lib/crc32.js 8 | * 该实现主要针对大文件优化、对计算的值进行了 `>>> 0` 运算(为与服务端保持一致) 9 | */ 10 | export class CRC32 { 11 | private crc = -1 12 | private table = this.makeTable() 13 | 14 | private makeTable() { 15 | const table = new Array() 16 | for (let i = 0; i < 256; i++) { 17 | let t = i 18 | for (let j = 0; j < 8; j++) { 19 | if (t & 1) { 20 | // IEEE 标准 21 | t = (t >>> 1) ^ 0xEDB88320 22 | } else { 23 | t >>>= 1 24 | } 25 | } 26 | table[i] = t 27 | } 28 | 29 | return table 30 | } 31 | 32 | private append(data: Uint8Array) { 33 | let crc = this.crc 34 | for (let offset = 0; offset < data.byteLength; offset++) { 35 | crc = (crc >>> 8) ^ this.table[(crc ^ data[offset]) & 0xFF] 36 | } 37 | this.crc = crc 38 | } 39 | 40 | private compute() { 41 | return (this.crc ^ -1) >>> 0 42 | } 43 | 44 | private async readAsUint8Array(file: File | Blob): Promise { 45 | if (typeof file.arrayBuffer === 'function') { 46 | return new Uint8Array(await file.arrayBuffer()) 47 | } 48 | 49 | return new Promise((resolve, reject) => { 50 | const reader = new FileReader() 51 | reader.onload = () => { 52 | if (reader.result == null) { 53 | reject() 54 | return 55 | } 56 | 57 | if (typeof reader.result === 'string') { 58 | reject() 59 | return 60 | } 61 | 62 | resolve(new Uint8Array(reader.result)) 63 | } 64 | reader.readAsArrayBuffer(file) 65 | }) 66 | } 67 | 68 | async file(file: File): Promise { 69 | if (file.size <= MB) { 70 | this.append(await this.readAsUint8Array(file)) 71 | return this.compute() 72 | } 73 | 74 | const count = Math.ceil(file.size / MB) 75 | for (let index = 0; index < count; index++) { 76 | const start = index * MB 77 | const end = index === (count - 1) ? file.size : start + MB 78 | // eslint-disable-next-line no-await-in-loop 79 | const chuck = await this.readAsUint8Array(file.slice(start, end)) 80 | this.append(new Uint8Array(chuck)) 81 | } 82 | 83 | return this.compute() 84 | } 85 | 86 | static file(file: File): Promise { 87 | const crc = new CRC32() 88 | return crc.file(file) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/utils/helper.test.ts: -------------------------------------------------------------------------------- 1 | import { computeMd5, createLocalKey, getPortFromUrl } from './helper' 2 | 3 | describe('api function test', () => { 4 | test('createLocalKey', () => { 5 | expect(createLocalKey('test', null, 1024)).toMatch('qiniu_js_sdk_upload_file_name_test_size_1024') 6 | expect(createLocalKey('test', 'demo', 1024)).toMatch('qiniu_js_sdk_upload_file_name_test_key_demo_size_1024') 7 | }) 8 | 9 | test('computeMd5', async () => { 10 | const testData = [ 11 | ['message', '78e731027d8fd50ed642340b7c9a63b3'], 12 | ['undefined', '5e543256c480ac577d30f76f9120eb74'], 13 | ['message áßäöü', '3fc4229d4a54045f5d5b96dd759581d4'] 14 | ] 15 | 16 | for (const [input, expected] of testData) { 17 | const testBlob = new Blob([input], { type: 'text/plain;charset=utf-8' }) 18 | // eslint-disable-next-line no-await-in-loop 19 | const actual = await computeMd5(testBlob) 20 | expect(actual).toStrictEqual(expected) 21 | } 22 | }) 23 | 24 | test('getPortFromUrl', () => { 25 | const testData = [ 26 | ['', ''], 27 | ['//loaclhost', ''], 28 | ['http://loaclhost', '80'], 29 | ['https://loaclhost', '443'], 30 | ['http://loaclhost:3030', '3030'], 31 | ['https://loaclhost:3030', '3030'], 32 | ['http://loaclhost:3030/path', '3030'], 33 | ['http://loaclhost:3030/path?test=3232', '3030'], 34 | ['http://loaclhost.com:3030/path?test=3232', '3030'], 35 | ['http://loaclhost.com.cn:3030/path?test=3232', '3030'] 36 | ] 37 | 38 | for (const [input, expected] of testData) { 39 | const actual = getPortFromUrl(input) 40 | expect(actual).toStrictEqual(expected) 41 | } 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import SparkMD5 from 'spark-md5' 2 | 3 | import { QiniuErrorName, QiniuError, QiniuRequestError, QiniuNetworkError } from '../errors' 4 | import { Progress, LocalInfo } from '../upload' 5 | import Logger from '../logger' 6 | 7 | import { urlSafeBase64Decode } from './base64' 8 | 9 | export const MB = 1024 ** 2 10 | 11 | // 文件分块 12 | export function getChunks(file: File, blockSize: number): Blob[] { 13 | 14 | let chunkByteSize = blockSize * MB // 转换为字节 15 | // 如果 chunkByteSize 比文件大,则直接取文件的大小 16 | if (chunkByteSize > file.size) { 17 | chunkByteSize = file.size 18 | } else { 19 | // 因为最多 10000 chunk,所以如果 chunkSize 不符合则把每片 chunk 大小扩大两倍 20 | while (file.size > chunkByteSize * 10000) { 21 | chunkByteSize *= 2 22 | } 23 | } 24 | 25 | const chunks: Blob[] = [] 26 | const count = Math.ceil(file.size / chunkByteSize) 27 | for (let i = 0; i < count; i++) { 28 | const chunk = file.slice( 29 | chunkByteSize * i, 30 | i === count - 1 ? file.size : chunkByteSize * (i + 1) 31 | ) 32 | chunks.push(chunk) 33 | } 34 | return chunks 35 | } 36 | 37 | export function isMetaDataValid(params: { [key: string]: string }) { 38 | return Object.keys(params).every(key => key.indexOf('x-qn-meta-') === 0) 39 | } 40 | 41 | export function isCustomVarsValid(params: { [key: string]: string }) { 42 | return Object.keys(params).every(key => key.indexOf('x:') === 0) 43 | } 44 | 45 | export function sum(list: number[]) { 46 | return list.reduce((data, loaded) => data + loaded, 0) 47 | } 48 | 49 | export function setLocalFileInfo(localKey: string, info: LocalInfo, logger: Logger) { 50 | try { 51 | localStorage.setItem(localKey, JSON.stringify(info)) 52 | } catch (err) { 53 | logger.warn(new QiniuError( 54 | QiniuErrorName.WriteCacheFailed, 55 | `setLocalFileInfo failed: ${localKey}` 56 | )) 57 | } 58 | } 59 | 60 | export function createLocalKey(name: string, key: string | null | undefined, size: number): string { 61 | const localKey = key == null ? '_' : `_key_${key}_` 62 | return `qiniu_js_sdk_upload_file_name_${name}${localKey}size_${size}` 63 | } 64 | 65 | export function removeLocalFileInfo(localKey: string, logger: Logger) { 66 | try { 67 | localStorage.removeItem(localKey) 68 | } catch (err) { 69 | logger.warn(new QiniuError( 70 | QiniuErrorName.RemoveCacheFailed, 71 | `removeLocalFileInfo failed. key: ${localKey}` 72 | )) 73 | } 74 | } 75 | 76 | export function getLocalFileInfo(localKey: string, logger: Logger): LocalInfo | null { 77 | let localInfoString: string | null = null 78 | try { 79 | localInfoString = localStorage.getItem(localKey) 80 | } catch { 81 | logger.warn(new QiniuError( 82 | QiniuErrorName.ReadCacheFailed, 83 | `getLocalFileInfo failed. key: ${localKey}` 84 | )) 85 | } 86 | 87 | if (localInfoString == null) { 88 | return null 89 | } 90 | 91 | let localInfo: LocalInfo | null = null 92 | try { 93 | localInfo = JSON.parse(localInfoString) 94 | } catch { 95 | // 本地信息已被破坏,直接删除 96 | removeLocalFileInfo(localKey, logger) 97 | logger.warn(new QiniuError( 98 | QiniuErrorName.InvalidCacheData, 99 | `getLocalFileInfo failed to parse. key: ${localKey}` 100 | )) 101 | } 102 | 103 | return localInfo 104 | } 105 | 106 | export function getAuthHeaders(token: string) { 107 | const auth = 'UpToken ' + token 108 | return { Authorization: auth } 109 | } 110 | 111 | export function getHeadersForChunkUpload(token: string) { 112 | const header = getAuthHeaders(token) 113 | return { 114 | 'content-type': 'application/octet-stream', 115 | ...header 116 | } 117 | } 118 | 119 | export function getHeadersForMkFile(token: string) { 120 | const header = getAuthHeaders(token) 121 | return { 122 | 'content-type': 'application/json', 123 | ...header 124 | } 125 | } 126 | 127 | export function createXHR(): XMLHttpRequest { 128 | if (window.XMLHttpRequest) { 129 | return new XMLHttpRequest() 130 | } 131 | 132 | if (window.ActiveXObject) { 133 | return new window.ActiveXObject('Microsoft.XMLHTTP') 134 | } 135 | 136 | throw new QiniuError( 137 | QiniuErrorName.NotAvailableXMLHttpRequest, 138 | 'the current environment does not support.' 139 | ) 140 | } 141 | 142 | export async function computeMd5(data: Blob): Promise { 143 | const buffer = await readAsArrayBuffer(data) 144 | const spark = new SparkMD5.ArrayBuffer() 145 | spark.append(buffer) 146 | return spark.end() 147 | } 148 | 149 | export function readAsArrayBuffer(data: Blob): Promise { 150 | return new Promise((resolve, reject) => { 151 | const reader = new FileReader() 152 | // evt 类型目前存在问题 https://github.com/Microsoft/TypeScript/issues/4163 153 | reader.onload = (evt: ProgressEvent) => { 154 | if (evt.target) { 155 | const body = evt.target.result 156 | resolve(body as ArrayBuffer) 157 | } else { 158 | reject(new QiniuError( 159 | QiniuErrorName.InvalidProgressEventTarget, 160 | 'progress event target is undefined' 161 | )) 162 | } 163 | } 164 | 165 | reader.onerror = () => { 166 | reject(new QiniuError( 167 | QiniuErrorName.FileReaderReadFailed, 168 | 'fileReader read failed' 169 | )) 170 | } 171 | 172 | reader.readAsArrayBuffer(data) 173 | }) 174 | } 175 | 176 | export interface ResponseSuccess { 177 | data: T 178 | reqId: string 179 | } 180 | 181 | export type XHRHandler = (xhr: XMLHttpRequest) => void 182 | 183 | export interface RequestOptions { 184 | method: string 185 | onProgress?: (data: Progress) => void 186 | onCreate?: XHRHandler 187 | body?: BodyInit | null 188 | headers?: { [key: string]: string } 189 | } 190 | 191 | export type Response = Promise> 192 | 193 | export function request(url: string, options: RequestOptions): Response { 194 | return new Promise((resolve, reject) => { 195 | const xhr = createXHR() 196 | xhr.open(options.method, url) 197 | 198 | if (options.onCreate) { 199 | options.onCreate(xhr) 200 | } 201 | 202 | if (options.headers) { 203 | const headers = options.headers 204 | Object.keys(headers).forEach(k => { 205 | xhr.setRequestHeader(k, headers[k]) 206 | }) 207 | } 208 | 209 | xhr.upload.addEventListener('progress', (evt: ProgressEvent) => { 210 | if (evt.lengthComputable && options.onProgress) { 211 | options.onProgress({ 212 | loaded: evt.loaded, 213 | total: evt.total 214 | }) 215 | } 216 | }) 217 | 218 | xhr.onreadystatechange = () => { 219 | const responseText = xhr.responseText 220 | if (xhr.readyState !== 4) { 221 | return 222 | } 223 | 224 | const reqId = xhr.getResponseHeader('x-reqId') || '' 225 | 226 | if (xhr.status === 0) { 227 | // 发生 0 基本都是网络错误,常见的比如跨域、断网、host 解析失败、系统拦截等等 228 | reject(new QiniuNetworkError('network error.', reqId)) 229 | return 230 | } 231 | 232 | if (xhr.status !== 200) { 233 | let message = `xhr request failed, code: ${xhr.status}` 234 | if (responseText) { 235 | message += ` response: ${responseText}` 236 | } 237 | 238 | let data 239 | try { 240 | data = JSON.parse(responseText) 241 | } catch { 242 | // 无需处理该错误、可能拿到非 json 格式的响应是预期的 243 | } 244 | 245 | reject(new QiniuRequestError(xhr.status, reqId, message, data)) 246 | return 247 | } 248 | 249 | try { 250 | resolve({ 251 | data: JSON.parse(responseText), 252 | reqId 253 | }) 254 | } catch (err) { 255 | reject(err) 256 | } 257 | } 258 | 259 | xhr.send(options.body) 260 | }) 261 | } 262 | 263 | export function getPortFromUrl(url: string | undefined) { 264 | if (url && url.match) { 265 | let groups = url.match(/(^https?)/) 266 | 267 | if (!groups) { 268 | return '' 269 | } 270 | 271 | const type = groups[1] 272 | groups = url.match(/^https?:\/\/([^:^/]*):(\d*)/) 273 | 274 | if (groups) { 275 | return groups[2] 276 | } 277 | 278 | if (type === 'http') { 279 | return '80' 280 | } 281 | 282 | return '443' 283 | } 284 | 285 | return '' 286 | } 287 | 288 | export function getDomainFromUrl(url: string | undefined): string { 289 | if (url && url.match) { 290 | const groups = url.match(/^https?:\/\/([^:^/]*)/) 291 | return groups ? groups[1] : '' 292 | } 293 | 294 | return '' 295 | } 296 | 297 | // 非标准的 PutPolicy 298 | interface PutPolicy { 299 | assessKey: string 300 | bucketName: string 301 | scope: string 302 | } 303 | 304 | export function getPutPolicy(token: string): PutPolicy { 305 | if (!token) throw new QiniuError(QiniuErrorName.InvalidToken, 'invalid token.') 306 | 307 | const segments = token.split(':') 308 | if (segments.length === 1) throw new QiniuError(QiniuErrorName.InvalidToken, 'invalid token segments.') 309 | 310 | // token 构造的差异参考:https://github.com/qbox/product/blob/master/kodo/auths/UpToken.md#admin-uptoken-authorization 311 | const assessKey = segments.length > 3 ? segments[1] : segments[0] 312 | if (!assessKey) throw new QiniuError(QiniuErrorName.InvalidToken, 'missing assess key field.') 313 | 314 | let putPolicy: PutPolicy | null = null 315 | 316 | try { 317 | putPolicy = JSON.parse(urlSafeBase64Decode(segments[segments.length - 1])) 318 | } catch (error) { 319 | throw new QiniuError(QiniuErrorName.InvalidToken, 'token parse failed.') 320 | } 321 | 322 | if (putPolicy == null) { 323 | throw new QiniuError(QiniuErrorName.InvalidToken, 'putPolicy is null.') 324 | } 325 | 326 | if (putPolicy.scope == null) { 327 | throw new QiniuError(QiniuErrorName.InvalidToken, 'scope field is null.') 328 | } 329 | 330 | const bucketName = putPolicy.scope.split(':')[0] 331 | if (!bucketName) { 332 | throw new QiniuError(QiniuErrorName.InvalidToken, 'resolve bucketName failed.') 333 | } 334 | 335 | return { assessKey, bucketName, scope: putPolicy.scope } 336 | } 337 | 338 | export function createObjectURL(file: File) { 339 | const URL = window.URL || window.webkitURL || window.mozURL 340 | // FIXME: 需要 revokeObjectURL 341 | return URL.createObjectURL(file) 342 | } 343 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pool' 2 | export * from './observable' 3 | 4 | export * from './base64' 5 | export * from './helper' 6 | export * from './config' 7 | 8 | export { default as compressImage, CompressResult } from './compress' 9 | -------------------------------------------------------------------------------- /src/utils/observable.ts: -------------------------------------------------------------------------------- 1 | /** 消费者接口 */ 2 | export interface IObserver { 3 | /** 用来接收 Observable 中的 next 类型通知 */ 4 | next: (value: T) => void 5 | /** 用来接收 Observable 中的 error 类型通知 */ 6 | error: (err: E) => void 7 | /** 用来接收 Observable 中的 complete 类型通知 */ 8 | complete: (res: C) => void 9 | } 10 | 11 | export interface NextObserver { 12 | next: (value: T) => void 13 | error?: (err: E) => void 14 | complete?: (res: C) => void 15 | } 16 | 17 | export interface ErrorObserver { 18 | next?: (value: T) => void 19 | error: (err: E) => void 20 | complete?: (res: C) => void 21 | } 22 | 23 | export interface CompletionObserver { 24 | next?: (value: T) => void 25 | error?: (err: E) => void 26 | complete: (res: C) => void 27 | } 28 | 29 | export type PartialObserver = NextObserver | ErrorObserver | CompletionObserver 30 | 31 | export interface IUnsubscribable { 32 | /** 取消 observer 的订阅 */ 33 | unsubscribe(): void 34 | } 35 | 36 | /** Subscription 的接口 */ 37 | export interface ISubscriptionLike extends IUnsubscribable { 38 | readonly closed: boolean 39 | } 40 | 41 | export type TeardownLogic = () => void 42 | 43 | export interface ISubscribable { 44 | subscribe( 45 | observer?: PartialObserver | ((value: T) => void), 46 | error?: (error: any) => void, 47 | complete?: () => void 48 | ): IUnsubscribable 49 | } 50 | 51 | /** 表示可清理的资源,比如 Observable 的执行 */ 52 | class Subscription implements ISubscriptionLike { 53 | /** 用来标示该 Subscription 是否被取消订阅的标示位 */ 54 | public closed = false 55 | 56 | /** 清理 subscription 持有的资源 */ 57 | private _unsubscribe: TeardownLogic | undefined 58 | 59 | /** 取消 observer 的订阅 */ 60 | unsubscribe() { 61 | if (this.closed) { 62 | return 63 | } 64 | 65 | this.closed = true 66 | if (this._unsubscribe) { 67 | this._unsubscribe() 68 | } 69 | } 70 | 71 | /** 添加一个 tear down 在该 Subscription 的 unsubscribe() 期间调用 */ 72 | add(teardown: TeardownLogic) { 73 | this._unsubscribe = teardown 74 | } 75 | } 76 | 77 | /** 78 | * 实现 Observer 接口并且继承 Subscription 类,Observer 是消费 Observable 值的公有 API 79 | * 所有 Observers 都转化成了 Subscriber,以便提供类似 Subscription 的能力,比如 unsubscribe 80 | */ 81 | export class Subscriber extends Subscription implements IObserver { 82 | protected isStopped = false 83 | protected destination: Partial> 84 | 85 | constructor( 86 | observerOrNext?: PartialObserver | ((value: T) => void) | null, 87 | error?: ((err: E) => void) | null, 88 | complete?: ((res: C) => void) | null 89 | ) { 90 | super() 91 | 92 | if (observerOrNext && typeof observerOrNext === 'object') { 93 | this.destination = observerOrNext 94 | } else { 95 | this.destination = { 96 | ...observerOrNext && { next: observerOrNext }, 97 | ...error && { error }, 98 | ...complete && { complete } 99 | } 100 | } 101 | } 102 | 103 | unsubscribe(): void { 104 | if (this.closed) { 105 | return 106 | } 107 | 108 | this.isStopped = true 109 | super.unsubscribe() 110 | } 111 | 112 | next(value: T) { 113 | if (!this.isStopped && this.destination.next) { 114 | this.destination.next(value) 115 | } 116 | } 117 | 118 | error(err: E) { 119 | if (!this.isStopped && this.destination.error) { 120 | this.isStopped = true 121 | this.destination.error(err) 122 | } 123 | } 124 | 125 | complete(result: C) { 126 | if (!this.isStopped && this.destination.complete) { 127 | this.isStopped = true 128 | this.destination.complete(result) 129 | } 130 | } 131 | } 132 | 133 | /** 可观察对象,当前的上传事件的集合 */ 134 | export class Observable implements ISubscribable { 135 | 136 | constructor(private _subscribe: (subscriber: Subscriber) => TeardownLogic) {} 137 | 138 | subscribe(observer: PartialObserver): Subscription 139 | subscribe(next: null | undefined, error: null | undefined, complete: (res: C) => void): Subscription 140 | subscribe(next: null | undefined, error: (error: E) => void, complete?: (res: C) => void): Subscription 141 | subscribe(next: (value: T) => void, error: null | undefined, complete: (res: C) => void): Subscription 142 | subscribe( 143 | observerOrNext?: PartialObserver | ((value: T) => void) | null, 144 | error?: ((err: E) => void) | null, 145 | complete?: ((res: C) => void) | null 146 | ): Subscription { 147 | const sink = new Subscriber(observerOrNext, error, complete) 148 | sink.add(this._subscribe(sink)) 149 | return sink 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/utils/pool.test.ts: -------------------------------------------------------------------------------- 1 | import { ChunkInfo } from '../upload' 2 | 3 | import { Pool } from './pool' 4 | 5 | const m = jest.fn() 6 | const task = (): Promise => new Promise((resolve, _) => { 7 | m() 8 | resolve() 9 | }) 10 | 11 | describe('test Pool for control concurrency', () => { 12 | const pool = new Pool(task, 2) 13 | test('pool.js', async () => { 14 | const chunk = new Blob() 15 | const data = [ 16 | { chunk, index: 0 }, 17 | { chunk, index: 1 }, 18 | { chunk, index: 2 }, 19 | { chunk, index: 3 }, 20 | { chunk, index: 4 }, 21 | { chunk, index: 5 } 22 | ] 23 | 24 | return Promise.all(data.map(async value => { 25 | await pool.enqueue(value) 26 | expect(pool.processing.length).toBeLessThanOrEqual(2) 27 | })).then(() => { 28 | expect(m.mock.calls.length).toBe(6) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/pool.ts: -------------------------------------------------------------------------------- 1 | export type RunTask = (...args: T[]) => Promise 2 | 3 | export interface QueueContent { 4 | task: T 5 | resolve: () => void 6 | reject: (err?: any) => void 7 | } 8 | 9 | export class Pool { 10 | aborted = false 11 | queue: Array> = [] 12 | processing: Array> = [] 13 | 14 | constructor(private runTask: RunTask, private limit: number) {} 15 | 16 | enqueue(task: T) { 17 | return new Promise((resolve, reject) => { 18 | this.queue.push({ 19 | task, 20 | resolve, 21 | reject 22 | }) 23 | this.check() 24 | }) 25 | } 26 | 27 | private run(item: QueueContent) { 28 | this.queue = this.queue.filter(v => v !== item) 29 | this.processing.push(item) 30 | this.runTask(item.task).then( 31 | () => { 32 | this.processing = this.processing.filter(v => v !== item) 33 | item.resolve() 34 | this.check() 35 | }, 36 | err => item.reject(err) 37 | ) 38 | } 39 | 40 | private check() { 41 | if (this.aborted) return 42 | const processingNum = this.processing.length 43 | const availableNum = this.limit - processingNum 44 | this.queue.slice(0, availableNum).forEach(item => { 45 | this.run(item) 46 | }) 47 | } 48 | 49 | abort() { 50 | this.queue = [] 51 | this.aborted = true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "AccessKey": "", // https://portal.qiniu.com/user/key 3 | "SecretKey": "", 4 | "Bucket": "", 5 | "Port": 9000, 6 | "UptokenUrl": "uptoken", 7 | "Domain": "" // bucket domain eg:http://qiniu-plupload.qiniudn.com/ 8 | } 9 | -------------------------------------------------------------------------------- /test/demo1/common/common.js: -------------------------------------------------------------------------------- 1 | var BLOCK_SIZE = 4 * 1024 * 1024; 2 | 3 | function addUploadBoard(file, config, key, type) { 4 | var count = Math.ceil(file.size / BLOCK_SIZE); 5 | var board = widget.add("tr", { 6 | data: { num: count, name: key, size: file.size }, 7 | node: $("#fsUploadProgress" + type) 8 | }); 9 | if (file.size > 100 * 1024 * 1024) { 10 | $(board).html("本实例最大上传文件100M"); 11 | return ""; 12 | } 13 | count > 1 && type != "3" 14 | ? "" 15 | : $(board) 16 | .find(".resume") 17 | .addClass("hide"); 18 | return board; 19 | } 20 | 21 | function createXHR() { 22 | var xmlhttp = {}; 23 | if (window.XMLHttpRequest) { 24 | xmlhttp = new XMLHttpRequest(); 25 | } else { 26 | xmlhttp = new ActiveXObject("Microsoft.XMLHTTP"); 27 | } 28 | return xmlhttp; 29 | } 30 | 31 | function getBoardWidth(board) { 32 | var total_width = $(board) 33 | .find("#totalBar") 34 | .outerWidth(); 35 | $(board) 36 | .find(".fragment-group") 37 | .removeClass("hide"); 38 | var child_width = $(board) 39 | .find(".fragment-group li") 40 | .children("#childBar") 41 | .outerWidth(); 42 | $(board) 43 | .find(".fragment-group") 44 | .addClass("hide"); 45 | return { totalWidth: total_width, childWidth: child_width }; 46 | } 47 | 48 | function controlTabDisplay(type) { 49 | switch (type) { 50 | case "sdk": 51 | document.getElementById("box2").className = ""; 52 | document.getElementById("box").className = "hide"; 53 | break; 54 | case "others": 55 | document.getElementById("box2").className = "hide"; 56 | document.getElementById("box").className = ""; 57 | break; 58 | case "form": 59 | document.getElementById("box").className = "hide"; 60 | document.getElementById("box2").className = "hide"; 61 | break; 62 | } 63 | } 64 | 65 | var getRotate = function(url) { 66 | if (!url) { 67 | return 0; 68 | } 69 | var arr = url.split("/"); 70 | for (var i = 0, len = arr.length; i < len; i++) { 71 | if (arr[i] === "rotate") { 72 | return parseInt(arr[i + 1], 10); 73 | } 74 | } 75 | return 0; 76 | }; 77 | 78 | function imageControl(domain) { 79 | $(".modal-body") 80 | .find(".buttonList a") 81 | .on("click", function() { 82 | var img = document.getElementById("imgContainer").getElementsByTagName("img")[0] 83 | var oldUrl = img.src; 84 | var key = img.key; 85 | var originHeight = img.h; 86 | var fopArr = []; 87 | var rotate = getRotate(oldUrl); 88 | if (!$(this).hasClass("no-disable-click")) { 89 | $(this) 90 | .addClass("disabled") 91 | .siblings() 92 | .removeClass("disabled"); 93 | if ($(this).data("imagemogr") !== "no-rotate") { 94 | fopArr.push({ 95 | fop: "imageMogr2", 96 | "auto-orient": true, 97 | strip: true, 98 | rotate: rotate 99 | }); 100 | } 101 | } else { 102 | $(this) 103 | .siblings() 104 | .removeClass("disabled"); 105 | var imageMogr = $(this).data("imagemogr"); 106 | if (imageMogr === "left") { 107 | rotate = rotate - 90 < 0 ? rotate + 270 : rotate - 90; 108 | } else if (imageMogr === "right") { 109 | rotate = rotate + 90 > 360 ? rotate - 270 : rotate + 90; 110 | } 111 | fopArr.push({ 112 | fop: "imageMogr2", 113 | "auto-orient": true, 114 | strip: true, 115 | rotate: rotate 116 | }); 117 | } 118 | $(".modal-body") 119 | .find("a.disabled") 120 | .each(function() { 121 | var watermark = $(this).data("watermark"); 122 | var imageView = $(this).data("imageview"); 123 | var imageMogr = $(this).data("imagemogr"); 124 | 125 | if (watermark) { 126 | fopArr.push({ 127 | fop: "watermark", 128 | mode: 1, 129 | image: "http://www.b1.qiniudn.com/images/logo-2.png", 130 | dissolve: 100, 131 | gravity: watermark, 132 | dx: 100, 133 | dy: 100 134 | }); 135 | } 136 | if (imageView) { 137 | var height; 138 | switch (imageView) { 139 | case "large": 140 | height = originHeight; 141 | break; 142 | case "middle": 143 | height = originHeight * 0.5; 144 | break; 145 | case "small": 146 | height = originHeight * 0.1; 147 | break; 148 | default: 149 | height = originHeight; 150 | break; 151 | } 152 | fopArr.push({ 153 | fop: "imageView2", 154 | mode: 3, 155 | h: parseInt(height, 10), 156 | q: 100 157 | }); 158 | } 159 | 160 | if (imageMogr === "no-rotate") { 161 | fopArr.push({ 162 | fop: "imageMogr2", 163 | "auto-orient": true, 164 | strip: true, 165 | rotate: 0 166 | }); 167 | } 168 | }); 169 | var newUrl = qiniu.pipeline(fopArr, key, domain); 170 | 171 | var newImg = new Image(); 172 | img.src = "images/loading.gif" 173 | newImg.onload = function() { 174 | img.src = newUrl 175 | document.getElementById("imgContainer").href = newUrl 176 | }; 177 | newImg.src = newUrl; 178 | return false; 179 | }); 180 | } 181 | 182 | function imageDeal(board, key, domain) { 183 | var fopArr = []; 184 | //var img = $(".modal-body").find(".display img"); 185 | var img = document.getElementById("imgContainer").getElementsByTagName("img")[0]; 186 | img.key = key 187 | fopArr.push({ 188 | fop: "watermark", 189 | mode: 1, 190 | image: "http://www.b1.qiniudn.com/images/logo-2.png", 191 | dissolve: 100, 192 | gravity: "NorthWest", 193 | ws: 0.8, 194 | dx: 100, 195 | dy: 100 196 | }); 197 | fopArr.push({ 198 | fop: "imageView2", 199 | mode: 2, 200 | h: 450, 201 | q: 100 202 | }); 203 | var newUrl = qiniu.pipeline(fopArr, key, domain); 204 | $(board) 205 | .find(".wraper a") 206 | .html( 207 | '' + 212 | '查看处理效果' 213 | ); 214 | var newImg = new Image(); 215 | img.src = "images/loading.gif" 216 | newImg.onload = function() { 217 | img.src = newUrl 218 | img.h = 450 219 | document.getElementById("imgContainer").href = newUrl 220 | }; 221 | newImg.src = newUrl; 222 | } 223 | -------------------------------------------------------------------------------- /test/demo1/component/ui.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var init = function(obj) { 3 | var li_children = 4 | "
" + 5 | "
" + 6 | "
" + 7 | "
"; 8 | var li = document.createElement("li"); 9 | $(li).addClass("fragment"); 10 | $(li).html(li_children); 11 | obj.node.append(li); 12 | }; 13 | widget.register("li", { 14 | init: init 15 | }); 16 | })(); 17 | 18 | (function() { 19 | var init = function(obj) { 20 | var data = obj.data; 21 | var name = data.name; 22 | var size = data.size; 23 | var parent = 24 | "" + 25 | name + 26 | "
" + 27 | "" + 28 | "" + 29 | size + 30 | "" + 31 | "
" + 32 | "
" + 33 | "

" + 34 | "
" + 35 | "
" + 36 | '' + 37 | "
" + 38 | "
" + 39 | "
    " + 40 | "
"; 41 | var tr = document.createElement("tr"); 42 | $(tr).html(parent); 43 | obj.node.append(tr); 44 | for (var i = 0; i < data.num; i++) { 45 | widget.add("li", { 46 | data: "", 47 | node: $(tr).find(".fragment-group") 48 | }); 49 | } 50 | $(tr) 51 | .find(".resume") 52 | .on("click", function() { 53 | var ulDom = $(tr).find(".fragment-group"); 54 | if (ulDom.hasClass("hide")) { 55 | ulDom.removeClass("hide"); 56 | } else { 57 | ulDom.addClass("hide"); 58 | } 59 | }); 60 | return tr; 61 | }; 62 | widget.register("tr", { 63 | init: init 64 | }); 65 | })(); 66 | -------------------------------------------------------------------------------- /test/demo1/component/widget.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | function widget() { 3 | this.widget = {}; 4 | } 5 | 6 | widget.prototype.register = function(name, component) { 7 | this.widget[name] = component; 8 | }; 9 | widget.prototype.add = function(name, obj) { 10 | if (this.widget[name]) { 11 | this.widget[name].node = obj.node; 12 | return this.widget[name].init(obj); 13 | } 14 | return false; 15 | }; 16 | global.widget = new widget(); 17 | })(window); 18 | -------------------------------------------------------------------------------- /test/demo1/images/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/js-sdk/2ad103cd97320ac7778d9283abf03b2ded353e73/test/demo1/images/default.png -------------------------------------------------------------------------------- /test/demo1/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/js-sdk/2ad103cd97320ac7778d9283abf03b2ded353e73/test/demo1/images/favicon.ico -------------------------------------------------------------------------------- /test/demo1/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/js-sdk/2ad103cd97320ac7778d9283abf03b2ded353e73/test/demo1/images/loading.gif -------------------------------------------------------------------------------- /test/demo1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 七牛云 - JavaScript SDK 7 | 8 | 9 | 10 | 11 | 12 | 13 | 30 |
31 |
32 |
    33 |
  • 34 | 35 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。 36 | 37 |
  • 38 |
  • 39 | 临时上传的空间不定时清空,请勿保存重要文件。 40 |
  • 41 |
  • 42 | H5模式大于4M文件采用分块上传。 43 |
  • 44 |
  • 45 | 上传图片可查看处理效果。 46 |
  • 47 |
  • 48 | 本示例限制最大上传文件100M。 49 |
  • 50 |
51 |
52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 128 |
129 | 130 | 202 | 203 | 205 | 208 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /test/demo1/js/Moxie.swf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/js-sdk/2ad103cd97320ac7778d9283abf03b2ded353e73/test/demo1/js/Moxie.swf -------------------------------------------------------------------------------- /test/demo1/js/Moxie.xap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiniu/js-sdk/2ad103cd97320ac7778d9283abf03b2ded353e73/test/demo1/js/Moxie.xap -------------------------------------------------------------------------------- /test/demo1/main.js: -------------------------------------------------------------------------------- 1 | $.ajax({url: "/api/uptoken", success: function(res){ 2 | var token = res.uptoken; 3 | var domain = res.domain; 4 | var config = { 5 | checkByServer: true, 6 | checkByMD5: true, 7 | forceDirect: false, 8 | useCdnDomain: true, 9 | disableStatisticsReport: false, 10 | retryCount: 6, 11 | region: qiniu.region.z2, 12 | debugLogLevel: 'INFO' 13 | }; 14 | var putExtra = { 15 | customVars: {} 16 | }; 17 | $(".nav-box") 18 | .find("a") 19 | .each(function(index) { 20 | $(this).on("click", function(e) { 21 | switch (e.target.name) { 22 | case "h5": 23 | uploadWithSDK(token, putExtra, config, domain); 24 | break; 25 | case "expand": 26 | uploadWithOthers(token, putExtra, config, domain); 27 | break; 28 | case "directForm": 29 | uploadWithForm(token, putExtra, config); 30 | break; 31 | default: 32 | ""; 33 | } 34 | }); 35 | }); 36 | imageControl(domain); 37 | uploadWithSDK(token, putExtra, config, domain); 38 | }}) 39 | -------------------------------------------------------------------------------- /test/demo1/scripts/uploadWithForm.js: -------------------------------------------------------------------------------- 1 | // 实现form直传无刷新并解决跨域问题 2 | function uploadWithForm(token, putExtra, config) { 3 | controlTabDisplay("form"); 4 | // 获得上传地址 5 | qiniu.getUploadUrl(config, token).then(function(res){ 6 | var uploadUrl = res; 7 | document.getElementsByName("token")[0].value = token; 8 | document.getElementsByName("url")[0].value = uploadUrl; 9 | // 当选择文件后执行的操作 10 | $("#select3").unbind("change").bind("change",function(){ 11 | var iframe = createIframe(); 12 | disableButtonOfSelect(); 13 | var key = this.files[0].name; 14 | // 添加上传dom面板 15 | var board = addUploadBoard(this.files[0], config, key, "3"); 16 | window.showRes = function(res){ 17 | $(board) 18 | .find(".control-container") 19 | .html( 20 | "

Hash:" + 21 | res.hash + 22 | "

" + 23 | "

Bucket:" + 24 | res.bucket + 25 | "

" 26 | ); 27 | } 28 | $(board) 29 | .find("#totalBar") 30 | .addClass("hide"); 31 | $(board) 32 | .find(".control-upload") 33 | .on("click", function() { 34 | enableButtonOfSelect(); 35 | // 把action地址指向我们的 node sdk 后端服务,通过后端来实现跨域访问 36 | $("#uploadForm").attr("target", iframe.name); 37 | $("#uploadForm") 38 | .attr("action", "/api/transfer") 39 | .submit(); 40 | $(this).text("上传中..."); 41 | $(this).attr("disabled", "disabled"); 42 | $(this).css("backgroundColor", "#aaaaaa"); 43 | }); 44 | }) 45 | }); 46 | } 47 | 48 | function createIframe() { 49 | var iframe = document.createElement("iframe"); 50 | iframe.name = "iframe" + Math.random(); 51 | $("#directForm").append(iframe); 52 | iframe.style.display = "none"; 53 | return iframe; 54 | } 55 | 56 | function enableButtonOfSelect() { 57 | $("#select3").removeAttr("disabled", "disabled"); 58 | $("#directForm") 59 | .find("button") 60 | .css("backgroundColor", "#00b7ee"); 61 | } 62 | 63 | function disableButtonOfSelect() { 64 | $("#select3").attr("disabled", "disabled"); 65 | $("#directForm") 66 | .find("button") 67 | .css("backgroundColor", "#aaaaaa"); 68 | } 69 | 70 | -------------------------------------------------------------------------------- /test/demo1/scripts/uploadWithSDK.js: -------------------------------------------------------------------------------- 1 | function uploadWithSDK(token, putExtra, config, domain) { 2 | // 切换tab后进行一些css操作 3 | controlTabDisplay("sdk"); 4 | $("#select2").unbind("change").bind("change",function(){ 5 | var file = this.files[0]; 6 | // eslint-disable-next-line 7 | var finishedAttr = []; 8 | // eslint-disable-next-line 9 | var compareChunks = []; 10 | var observable; 11 | if (file) { 12 | var key = file.name; 13 | // 添加上传dom面板 14 | var board = addUploadBoard(file, config, key, ""); 15 | if (!board) { 16 | return; 17 | } 18 | putExtra.customVars["x:name"] = key.split(".")[0]; 19 | board.start = true; 20 | var dom_total = $(board) 21 | .find("#totalBar") 22 | .children("#totalBarColor"); 23 | 24 | // 设置next,error,complete对应的操作,分别处理相应的进度信息,错误信息,以及完成后的操作 25 | var error = function(err) { 26 | board.start = true; 27 | $(board).find(".control-upload").text("继续上传"); 28 | console.log(err); 29 | alert("上传出错") 30 | }; 31 | 32 | var complete = function(res) { 33 | $(board) 34 | .find("#totalBar") 35 | .addClass("hide"); 36 | $(board) 37 | .find(".control-container") 38 | .html( 39 | "

Hash:" + 40 | res.hash + 41 | "

" + 42 | "

Bucket:" + 43 | res.bucket + 44 | "

" 45 | ); 46 | if (res.key && res.key.match(/\.(jpg|jpeg|png|gif)$/)) { 47 | imageDeal(board, res.key, domain); 48 | } 49 | }; 50 | 51 | var next = function(response) { 52 | var chunks = response.chunks||[]; 53 | var total = response.total; 54 | // 这里对每个chunk更新进度,并记录已经更新好的避免重复更新,同时对未开始更新的跳过 55 | for (var i = 0; i < chunks.length; i++) { 56 | if (chunks[i].percent === 0 || finishedAttr[i]){ 57 | continue; 58 | } 59 | if (compareChunks[i].percent === chunks[i].percent){ 60 | continue; 61 | } 62 | if (chunks[i].percent === 100){ 63 | finishedAttr[i] = true; 64 | } 65 | $(board) 66 | .find(".fragment-group li") 67 | .eq(i) 68 | .find("#childBarColor") 69 | .css( 70 | "width", 71 | chunks[i].percent + "%" 72 | ); 73 | } 74 | $(board) 75 | .find(".speed") 76 | .text("进度:" + total.percent + "% "); 77 | dom_total.css( 78 | "width", 79 | total.percent + "%" 80 | ); 81 | compareChunks = chunks; 82 | }; 83 | 84 | var subObject = { 85 | next: next, 86 | error: error, 87 | complete: complete 88 | }; 89 | var subscription; 90 | // 调用sdk上传接口获得相应的observable,控制上传和暂停 91 | observable = qiniu.upload(file, key, token, putExtra, config); 92 | 93 | $(board) 94 | .find(".control-upload") 95 | .on("click", function() { 96 | if(board.start){ 97 | $(this).text("暂停上传"); 98 | board.start = false; 99 | subscription = observable.subscribe(subObject); 100 | }else{ 101 | board.start = true; 102 | $(this).text("继续上传"); 103 | subscription.unsubscribe(); 104 | } 105 | }); 106 | } 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /test/demo1/style/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(249, 249, 249); 3 | } 4 | .navbar { 5 | background-color: #fff; 6 | } 7 | .mainContainer { 8 | position: relative; 9 | top: 52px; 10 | } 11 | .mainContainer { 12 | width: 1170px; 13 | margin: 0 auto; 14 | padding: 15px 15px; 15 | } 16 | .mainContainer .row .tip li { 17 | list-style: none; 18 | } 19 | .mainContainer .nav-box ul li a { 20 | color: #777; 21 | } 22 | #box, 23 | #box2 { 24 | margin-top: 20px; 25 | height: 46px; 26 | } 27 | .fragment-group { 28 | overflow: hidden; 29 | padding-left: 0; 30 | } 31 | .hide { 32 | visibility: hidden; 33 | } 34 | .fragment-group .fragment { 35 | float: left; 36 | width: 30%; 37 | padding-right: 10px; 38 | list-style: none; 39 | margin-top: 10px; 40 | } 41 | .file-input { 42 | display: inline-block; 43 | box-sizing: border-box; 44 | width: 130px; 45 | height: 46px; 46 | opacity: 0; 47 | cursor: pointer; 48 | } 49 | 50 | .mainContainer .select-button { 51 | position: absolute; 52 | background-color: #00b7ee; 53 | color: #fff; 54 | font-size: 18px; 55 | padding: 0 30px; 56 | line-height: 44px; 57 | font-weight: 100; 58 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); 59 | } 60 | .speed { 61 | margin-top: 15px; 62 | } 63 | .control-upload { 64 | line-height: 14px; 65 | margin-left: 10px; 66 | } 67 | .control-container { 68 | float: left; 69 | width: 20%; 70 | } 71 | #totalBar { 72 | margin-bottom: 40px; 73 | float: left; 74 | width: 80%; 75 | height: 30px; 76 | border: 1px solid; 77 | border-radius: 3px; 78 | } 79 | .linkWrapper img { 80 | width: 100px; 81 | height: 100px; 82 | } 83 | .modal-body { 84 | text-align: center; 85 | } 86 | .buttonList a { 87 | padding: 5px 10px; 88 | background: #fff; 89 | border-radius: 5px; 90 | color: #000; 91 | margin-left: 10px; 92 | cursor: pointer; 93 | } 94 | .buttonList ul { 95 | text-align: left; 96 | } 97 | .buttonList li { 98 | list-style: none; 99 | margin-top: 15px; 100 | } 101 | .disabled { 102 | background: #ccc; 103 | } 104 | .display a { 105 | display: block; 106 | background: #fff; 107 | } 108 | -------------------------------------------------------------------------------- /test/demo2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 七牛云 - JavaScript SDK 7 | 8 | 9 | 10 | 11 | 12 | 13 | 30 |
31 |
32 |
    33 |
  • 34 | 35 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。 36 | 37 |
  • 38 |
39 |
40 |
41 | 42 | 43 |
44 |
45 |

46 |
47 |
48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /test/demo2/index.js: -------------------------------------------------------------------------------- 1 | import * as qiniu from 'qiniu-js' 2 | 3 | $.ajax({url:"/api/uptoken",success: (res)=> initFileInput(res)}) 4 | 5 | let initFileInput = (res) =>{ 6 | 7 | let token = res.uptoken; 8 | 9 | let config = { 10 | useCdnDomain: true, 11 | region: qiniu.region.z2, 12 | debugLogLevel: 'INFO' 13 | }; 14 | let putExtra = { 15 | fname: "", 16 | params: {}, 17 | mimeType: null 18 | }; 19 | 20 | 21 | $("#select").change(function(){ 22 | 23 | let file = this.files[0]; 24 | let key = file.name; 25 | // 添加上传dom面板 26 | let next = (response) =>{ 27 | let total = response.total; 28 | $(".speed").text("进度:" + total.percent + "% "); 29 | } 30 | 31 | 32 | let subscription; 33 | // 调用sdk上传接口获得相应的observable,控制上传和暂停 34 | let observable = qiniu.upload(file, key, token, putExtra, config); 35 | observable.subscribe(next) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /test/demo2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo2", 3 | "version": "1.0.0", 4 | "description": "\"test for qiniu-sdk\"", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server --open --config webpack.config.js" 9 | }, 10 | "author": "", 11 | "devDependencies": { 12 | "@babel/core": "^7.10.2", 13 | "babel-loader": "^8.1.0", 14 | "babel-plugin-syntax-flow": "^6.18.0", 15 | "es3ify-webpack-plugin": "^0.1.0", 16 | "qiniu-js": "^2.5.4", 17 | "webpack": "^4.41.5", 18 | "webpack-cli": "^3.3.11", 19 | "webpack-dev-server": "^3.11.0" 20 | }, 21 | "license": "ISC" 22 | } 23 | -------------------------------------------------------------------------------- /test/demo2/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(249, 249, 249); 3 | } 4 | .navbar { 5 | background-color: #fff; 6 | } 7 | .mainContainer { 8 | position: relative; 9 | top: 52px; 10 | } 11 | .mainContainer { 12 | width: 1170px; 13 | margin: 0 auto; 14 | padding: 15px 15px; 15 | } 16 | .mainContainer .row .tip li { 17 | list-style: none; 18 | } 19 | .mainContainer .nav-box ul li a { 20 | color: #777; 21 | } 22 | #box, 23 | #box2 { 24 | margin-top: 20px; 25 | height: 46px; 26 | } 27 | .fragment-group { 28 | overflow: hidden; 29 | padding-left: 0; 30 | } 31 | .hide { 32 | visibility: hidden; 33 | } 34 | .fragment-group .fragment { 35 | float: left; 36 | width: 30%; 37 | padding-right: 10px; 38 | list-style: none; 39 | margin-top: 10px; 40 | } 41 | .file-input { 42 | display: inline-block; 43 | box-sizing: border-box; 44 | width: 130px; 45 | height: 46px; 46 | opacity: 0; 47 | cursor: pointer; 48 | } 49 | 50 | .mainContainer .select-button { 51 | position: absolute; 52 | background-color: #00b7ee; 53 | color: #fff; 54 | font-size: 18px; 55 | padding: 0 30px; 56 | line-height: 44px; 57 | font-weight: 100; 58 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); 59 | } 60 | .speed { 61 | margin-top: 15px; 62 | } 63 | 64 | -------------------------------------------------------------------------------- /test/demo2/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var es3ifyPlugin = require("es3ify-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: "./index.js", 6 | output: { 7 | filename: "boundle.js", 8 | path: path.resolve(__dirname, "webpack/"), 9 | publicPath: "/test/" 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | include: [path.resolve(__dirname, "./index.js")], 16 | use: { 17 | loader: "babel-loader" 18 | } 19 | } 20 | ] 21 | }, 22 | plugins:[ 23 | new es3ifyPlugin() 24 | ], 25 | devServer: { 26 | disableHostCheck: true, 27 | progress: true, 28 | proxy: { 29 | "/api/*": { 30 | target: "http://0.0.0.0:9000", 31 | changeOrigin: true, 32 | secure: false 33 | } 34 | }, 35 | host: "0.0.0.0", 36 | port: 8000, 37 | contentBase: path.join(__dirname, "./"), 38 | publicPath: "/webpack/", 39 | hot: true, 40 | inline: false 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /test/demo3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 七牛云 - JavaScript SDK 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
    16 |
  • 17 | 18 | JavaScript SDK 基于 h5 file api 开发,可以上传文件至七牛云存储。 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /test/demo3/index.js: -------------------------------------------------------------------------------- 1 | 2 | let lastFile = null; 3 | let sourceImage; 4 | let options = { 5 | quality: 0.92, 6 | noCompressIfLarger: true 7 | // maxWidth: 1000, 8 | // maxHeight: 618 9 | } 10 | $("#select").change(function(){ 11 | options.outputType = this.files[0].type; 12 | sourceImage = new Image(); 13 | let sourceUrl = URL.createObjectURL(this.files[0]); 14 | sourceImage.src = sourceUrl; 15 | sourceImage.onload = () => { 16 | compress(this.files[0]); 17 | } 18 | }) 19 | 20 | $('input[type="range"]').each(function() { 21 | let name = $(this).attr("name"); 22 | $(this).val(options[name]) 23 | $(this).next().text(options[name]) 24 | $(this).on("change", function(){ 25 | options = Object.assign(options, {[name]: +$(this).val()}); 26 | $(this).next().text(options[name]) 27 | compress(); 28 | }) 29 | }) 30 | 31 | function compress(file){ 32 | file = file || lastFile; 33 | lastFile = file; 34 | URL.revokeObjectURL($(".distImage img").attr("src")); 35 | URL.revokeObjectURL($(".sourceImage img").attr("src")); 36 | $(".distImage img").attr("src", ""); 37 | $(".sourceImage img").attr("src", ""); 38 | $(".distImage pre").text(""); 39 | $(".sourceImage pre").text(""); 40 | 41 | qiniu.compressImage(file, options).then(data => { 42 | $(".distImage img").attr("src", URL.createObjectURL(data.dist)) 43 | $(".sourceImage img").attr("src", URL.createObjectURL(file)); 44 | $(".distImage pre").text("File size:" + (data.dist.size / 1024).toFixed(2) + "KB" + "\n" + "File type:" + data.dist.type + "\n" + "Dimensions:" + data.width + "*" + data.height + "\n" + "ratio:" + (data.dist.size / file.size).toFixed(2) * 100 + "%") 45 | $(".sourceImage pre").text("File size:" + (file.size / 1024).toFixed(2) + "KB" + "\n" + "File type:" + file.type + "\n" + "Dimensions:" + sourceImage.width + "*" + sourceImage.height) 46 | }).catch(res => { 47 | console.log(res) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /test/demo3/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(249, 249, 249); 3 | } 4 | .navbar { 5 | background-color: #fff; 6 | } 7 | .mainContainer { 8 | position: relative; 9 | top: 52px; 10 | } 11 | .mainContainer { 12 | width: 1170px; 13 | margin: 0 auto; 14 | padding: 15px 15px; 15 | } 16 | .mainContainer .row .tip li { 17 | list-style: none; 18 | } 19 | .mainContainer .nav-box ul li a { 20 | color: #777; 21 | } 22 | #box, 23 | #box2 { 24 | margin-top: 20px; 25 | height: 46px; 26 | } 27 | .fragment-group { 28 | overflow: hidden; 29 | padding-left: 0; 30 | } 31 | .hide { 32 | visibility: hidden; 33 | } 34 | .fragment-group .fragment { 35 | float: left; 36 | width: 30%; 37 | padding-right: 10px; 38 | list-style: none; 39 | margin-top: 10px; 40 | } 41 | .file-input { 42 | display: inline-block; 43 | box-sizing: border-box; 44 | width: 130px; 45 | height: 46px; 46 | opacity: 0; 47 | cursor: pointer; 48 | } 49 | 50 | .mainContainer .select-button { 51 | position: absolute; 52 | background-color: #00b7ee; 53 | color: #fff; 54 | font-size: 18px; 55 | padding: 0 30px; 56 | line-height: 44px; 57 | font-weight: 100; 58 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); 59 | } 60 | .sourceImage,.distImage{ 61 | float: left; 62 | max-width: 500px; 63 | max-height: 500px; 64 | } 65 | pre{ 66 | border:none 67 | } 68 | .imageContainer img{ 69 | max-width: 100%; 70 | max-height: 100% 71 | } 72 | 73 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var qiniu = require("qiniu"); 2 | var express = require("express"); 3 | var util = require("util"); 4 | var path = require("path") 5 | var request = require("request"); 6 | var app = express(); 7 | app.use(express.static(__dirname + "/")); 8 | var multiparty = require("multiparty"); 9 | 10 | var fs=require('fs'); 11 | var config=JSON.parse(fs.readFileSync(path.resolve(__dirname,"config.json"))); 12 | 13 | var mac = new qiniu.auth.digest.Mac(config.AccessKey, config.SecretKey); 14 | var config2 = new qiniu.conf.Config(); 15 | // 这里主要是为了用 node sdk 的 form 直传,结合 demo 中 form 方式来实现无刷新上传 16 | config2.zone = qiniu.zone.Zone_z2; 17 | var formUploader = new qiniu.form_up.FormUploader(config2); 18 | var putExtra = new qiniu.form_up.PutExtra(); 19 | var options = { 20 | scope: config.Bucket, 21 | // 上传策略设置文件过期时间,正式环境中要谨慎使用,文件在存储空间保存一天后删除 22 | deleteAfterDays: 1, 23 | returnBody: 24 | '{"key":"$(key)","hash":"$(etag)","fsize":$(fsize),"bucket":"$(bucket)","name":"$(x:name)"}' 25 | }; 26 | 27 | var putPolicy = new qiniu.rs.PutPolicy(options); 28 | var bucketManager = new qiniu.rs.BucketManager(mac, null); 29 | 30 | app.get("/api/uptoken", function(req, res, next) { 31 | var token = putPolicy.uploadToken(mac); 32 | res.header("Cache-Control", "max-age=0, private, must-revalidate"); 33 | res.header("Pragma", "no-cache"); 34 | res.header("Expires", 0); 35 | if (token) { 36 | res.json({ 37 | uptoken: token, 38 | domain: config.Domain 39 | }); 40 | } 41 | }); 42 | 43 | app.post("/api/transfer", function(req, res) { 44 | var form = new multiparty.Form(); 45 | form.parse(req, function(err, fields, files) { 46 | var path = files.file[0].path; 47 | var token = fields.token[0]; 48 | var key = fields.key[0]; 49 | formUploader.putFile(token, key, path, putExtra, function( 50 | respErr, 51 | respBody, 52 | respInfo 53 | ) { 54 | if (respErr) { 55 | console.log(respErr); 56 | throw respErr; 57 | } 58 | if (respInfo.statusCode == 200) { 59 | res.send(''); 60 | } else { 61 | console.log(respInfo.statusCode); 62 | console.log(respBody); 63 | } 64 | }); 65 | }); 66 | }); 67 | 68 | app.listen(config.Port, function() { 69 | console.log("Listening on port %d\n", config.Port); 70 | console.log( 71 | "▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ Demos ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽ ▽" 72 | ); 73 | console.log( 74 | "△ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △ △\n" 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "outDir": "./esm", 5 | "module": "es6", 6 | "lib": ["dom", "es2015", "es2016", "es2017"], 7 | "moduleResolution": "node", 8 | "allowJs": false, 9 | "declaration": true, 10 | "downlevelIteration": true, 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "sourceMap": true, 14 | "strictPropertyInitialization": false, 15 | "noImplicitThis": true, 16 | "baseUrl": "./src", 17 | "esModuleInterop": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "typeRoots": [ 20 | "./types", 21 | "./node_modules/@types" 22 | ] 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "examples", 27 | "coverage", 28 | "test", 29 | "dist", 30 | "site", 31 | "lib", 32 | "esm" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /types/exif.d.ts: -------------------------------------------------------------------------------- 1 | // 原类型有问题,这里覆盖下 2 | declare module 'exif-js' { 3 | interface EXIFStatic { 4 | getData(img: HTMLImageElement, callback: () => void): void 5 | getTag(img: HTMLImageElement, tag: string): number 6 | } 7 | 8 | const EXIF: EXIFStatic 9 | export { EXIF } 10 | } 11 | -------------------------------------------------------------------------------- /types/window.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | webkitURL?: typeof URL 3 | mozURL?: typeof URL 4 | ActiveXObject?: any 5 | } 6 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | resolve: { 6 | extensions: ['.ts', '.js'] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge') 2 | const path = require('path') 3 | const common = require('./webpack.common.js') 4 | 5 | module.exports = merge(common, { 6 | mode: "development", 7 | entry: './src/index.ts', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.ts$/, 12 | use: 'ts-loader', 13 | exclude: /node_modules/ 14 | } 15 | ] 16 | }, 17 | output: { 18 | filename: 'qiniu.min.js', 19 | library: 'qiniu', 20 | libraryTarget: 'umd', 21 | path: path.resolve(__dirname, 'webpack'), 22 | }, 23 | devServer: { 24 | disableHostCheck: true, 25 | progress: true, 26 | hot: true, 27 | proxy: { 28 | '/api/*': { 29 | target: 'http://0.0.0.0:9000', 30 | changeOrigin: true, 31 | secure: false 32 | } 33 | }, 34 | host: '0.0.0.0', 35 | contentBase: path.join(__dirname, './'), 36 | publicPath: '/webpack/', 37 | inline: false 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var TerserPlugin = require('terser-webpack-plugin'); 3 | var merge = require("webpack-merge"); 4 | var common = require("./webpack.common.js"); 5 | var path = require("path"); 6 | 7 | module.exports = merge(common, { 8 | mode: "production", 9 | devtool: "source-map", 10 | entry: './lib/index.js', 11 | output: { 12 | filename: 'qiniu.min.js', 13 | library: 'qiniu', 14 | libraryTarget: 'umd', 15 | path: path.resolve(__dirname, 'dist'), 16 | publicPath: '/dist/' 17 | }, 18 | optimization: { 19 | minimize: true, 20 | minimizer: [ 21 | new TerserPlugin({ 22 | cache: true, 23 | parallel: true, 24 | sourceMap: true 25 | }) 26 | ] 27 | }, 28 | plugins: [ 29 | new webpack.DefinePlugin({ 30 | "process.env.NODE_ENV": JSON.stringify("production") 31 | }) 32 | ] 33 | }); 34 | --------------------------------------------------------------------------------