├── preview.png ├── .gitignore ├── package.json ├── LICENSE ├── README.md └── index.js /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyfeyaj/qn-webpack/HEAD/preview.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qn-webpack", 3 | "version": "2.0.3", 4 | "description": "Qiniu webpack plugin", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/lyfeyaj/qn-webpack.git" 12 | }, 13 | "keywords": [ 14 | "qiniu", 15 | "webpack", 16 | "plugin" 17 | ], 18 | "author": "Felix Liu", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/lyfeyaj/qn-webpack/issues" 22 | }, 23 | "homepage": "https://github.com/lyfeyaj/qn-webpack#readme", 24 | "dependencies": { 25 | "lodash.isregexp": "^4.0.1", 26 | "lodash.isstring": "^4.0.1", 27 | "ora": "^4.0.5", 28 | "qiniu": "^7.3.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 - 2019 Felix Liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Qiniu Webpack Plugin 2 | ==================== 3 | 4 | > 上传 Webpack Assets 至 七牛 CDN 5 | 6 | ## 前提 7 | 8 | 需要 Node 版本在 v4.0 以上 9 | 10 | ## 安装 11 | 12 | ```sh 13 | npm i -D qn-webpack@2.0.3 14 | ``` 15 | 16 | ## 使用方法 17 | 18 | 支持的配置项: 19 | 20 | + `accessKey` 七牛 AccessKey 21 | + `secretKey` 七牛 SecretKey 22 | + `bucket` 七牛存储对象名称 23 | + `path` 存储路径, 默认为 `[hash]`,也可以指定 hash 长度,如: `[hash:8]` 24 | + `exclude` 可选,排除特定文件,正则表达式,如: `/index\.html$/` 25 | + `include` 可选,指定要上传的文件,正则表达式,如: `/app\.js$/` 26 | + `maxRetryTimes` 可选,最大重试次数,默认 3 27 | + `batch` 可选,批量上传文件并发数,默认 20 28 | + `zone` 可选,存储在七牛的机房(华东 `Zone_z0`、华北 `Zone_z1`、华南 `Zone_z2`、北美 `Zone_na0`) 29 | 30 | ***注: Webpack 的 `output.publicPath` 要指向七牛云(或自定义的)域名地址*** 31 | 32 | ```js 33 | // 引入 34 | const QiniuPlugin = require('qn-webpack'); 35 | 36 | // 配置 Plugin 37 | const qiniuPlugin = new QiniuPlugin({ 38 | accessKey: 'my-access-key', 39 | secretKey: 'my-secret-key', 40 | bucket: 'my-bucket', 41 | path: '[hash]/' 42 | }); 43 | 44 | // Webpack 的配置 45 | module.exports = { 46 | output: { 47 | // 此处为七牛提供的域名(http://7xqhak.com1.z0.glb.clouddn.com) 加上 path([hash]/) 48 | publicPath: "http://7xqhak.com1.z0.glb.clouddn.com/[hash]/" 49 | // ... 50 | }, 51 | plugins: [ 52 | qiniuPlugin 53 | // ... 54 | ] 55 | // ... 56 | } 57 | ``` 58 | 59 | ## 效果图 60 | 61 | 无图无真相 ^\_\^ 62 | 63 | ![Preview](preview.png) 64 | 65 | ## License 66 | 67 | [The MIT License](./LICENSE) -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const qiniu = require('qiniu'); 4 | const path = require('path'); 5 | const ora = require('ora'); 6 | const isRegExp = require('lodash.isregexp'); 7 | const isString = require('lodash.isstring'); 8 | 9 | // Constants 10 | const REGEXP_HASH = /\[hash(?::(\d+))?\]/gi; 11 | 12 | // Uploading progress tip 13 | const tip = (uploaded, failed, total, retrying) => { 14 | let percentage = Math.round(uploaded / total * 100); 15 | let msg = retrying ? 'Retrying failed files:' : 'Uploading to Qiniu CDN:'; 16 | return `${msg} ${percentage}% ${uploaded}/${total} files uploaded, ${failed} files failed`; 17 | }; 18 | 19 | // Replace path variable by hash with length 20 | const withHashLength = (replacer) => { 21 | return function(_, hashLength) { 22 | const length = hashLength && parseInt(hashLength, 10); 23 | const hash = replacer.apply(this, arguments); 24 | return length ? hash.slice(0, length) : hash; 25 | }; 26 | }; 27 | 28 | // Perform hash replacement 29 | const getReplacer = (value, allowEmpty) => { 30 | return function(match) { 31 | // last argument in replacer is the entire input string 32 | const input = arguments[arguments.length - 1]; 33 | if(value === null || value === undefined) { 34 | if(!allowEmpty) throw new Error(`Path variable ${match} not implemented in this context of qn-webpack plugin: ${input}`); 35 | return ''; 36 | } else { 37 | return `${value}`; 38 | } 39 | }; 40 | }; 41 | 42 | module.exports = class QiniuPlugin { 43 | constructor(options) { 44 | this.options = Object.assign({}, options); 45 | this.options.path = this.options.path == null ? '[hash]' : this.options.path; 46 | if (!isString(this.options.path)) throw new Error('qn-webpack plugin: path is invalid'); 47 | } 48 | 49 | apply(compiler) { 50 | const uploadFiles = (compilation, callback) => { 51 | 52 | let assets = compilation.assets; 53 | let hash = compilation.hash; 54 | let exclude = isRegExp(this.options.exclude) && this.options.exclude; 55 | let include = isRegExp(this.options.include) && this.options.include; 56 | let batch = this.options.batch || 20; 57 | let maxRetryTimes = this.options.maxRetryTimes || 3; 58 | let mac = new qiniu.auth.digest.Mac(this.options.accessKey, this.options.secretKey); 59 | let qiniuConfig = new qiniu.conf.Config(); 60 | let bucket = this.options.bucket; 61 | let zone = qiniu.zone[this.options.zone]; 62 | if (zone) qiniuConfig.zone = zone; 63 | let uploadPath = this.options.path; 64 | uploadPath = uploadPath.replace(REGEXP_HASH, withHashLength(getReplacer(hash))); 65 | 66 | let filesNames = Object.keys(assets); 67 | let totalFiles = 0; 68 | let uploadedFiles = 0; 69 | let retryFiles = []; 70 | let retryFilesCountDown = 0; 71 | 72 | // Mark finished 73 | let _finish = (err) => { 74 | spinner.succeed(); 75 | // eslint-disable-next-line no-console 76 | console.log('\n'); 77 | callback(err); 78 | }; 79 | 80 | // Filter files that should be uploaded to Qiniu CDN 81 | filesNames = filesNames.filter(fileName => { 82 | let file = assets[fileName] || {}; 83 | 84 | // Ignore unemitted files 85 | if (!file.emitted) return false; 86 | 87 | // Check excluced files 88 | if (exclude && exclude.test(fileName)) return false; 89 | 90 | // Check included files 91 | if (include) return include.test(fileName); 92 | 93 | return true; 94 | }); 95 | 96 | totalFiles = filesNames.length; 97 | 98 | // eslint-disable-next-line no-console 99 | console.log('\n'); 100 | let spinner = ora({ 101 | text: tip(0, retryFiles.length, totalFiles, false), 102 | color: 'green' 103 | }).start(); 104 | 105 | // Perform upload to qiniu 106 | const performUpload = function(fileName, retrying) { 107 | let file = assets[fileName] || {}; 108 | let key = path.posix.join(uploadPath, fileName); 109 | let putPolicy = new qiniu.rs.PutPolicy({ scope: bucket + ':' + key }); 110 | let uploadToken = putPolicy.uploadToken(mac); 111 | let formUploader = new qiniu.form_up.FormUploader(qiniuConfig); 112 | let putExtra = new qiniu.form_up.PutExtra(); 113 | 114 | return new Promise((resolve) => { 115 | let begin = Date.now(); 116 | 117 | formUploader.putFile( 118 | uploadToken, 119 | key, 120 | file.existsAt, 121 | putExtra, 122 | function(err, body) { 123 | // handle upload error 124 | if (err) { 125 | // eslint-disable-next-line no-console 126 | console.log(`Upload file ${fileName} failed: ${err.message || err.name || err.stack}`); 127 | if (!~retryFiles.indexOf(fileName)) retryFiles.push(fileName); 128 | } else { 129 | uploadedFiles++; 130 | } 131 | 132 | spinner.text = tip(uploadedFiles, retryFiles.length, totalFiles, retrying); 133 | body.duration = Date.now() - begin; 134 | resolve(body); 135 | }); 136 | }); 137 | }; 138 | 139 | // Retry all failed files one by one 140 | const retryFailedFiles = function(err) { 141 | if (err) { 142 | // eslint-disable-next-line no-console 143 | console.log('\n'); 144 | return Promise.reject(err); 145 | } 146 | 147 | if (retryFilesCountDown < 0) retryFilesCountDown = 0; 148 | 149 | // Get batch files 150 | let _files = retryFiles.splice( 151 | 0, 152 | batch <= retryFilesCountDown ? batch : retryFilesCountDown 153 | ); 154 | retryFilesCountDown = retryFilesCountDown - _files.length; 155 | 156 | 157 | if (_files.length) { 158 | return Promise.all( 159 | _files.map(file => performUpload(file, true)) 160 | ).then(() => retryFailedFiles(), retryFailedFiles); 161 | } else { 162 | if (retryFiles.length) { 163 | return Promise.reject(new Error('File uploaded failed')); 164 | } else { 165 | return Promise.resolve(); 166 | } 167 | } 168 | }; 169 | 170 | // Execute stack according to `batch` option 171 | const execStack = function(err) { 172 | if (err) { 173 | // eslint-disable-next-line no-console 174 | console.log('\n'); 175 | return Promise.reject(err); 176 | } 177 | 178 | // Get batch files 179 | let _files = filesNames.splice(0, batch); 180 | 181 | if (_files.length) { 182 | return Promise.all( 183 | _files.map(file => performUpload(file, false)) 184 | ).then(() => execStack(), execStack); 185 | } else { 186 | return Promise.resolve(); 187 | } 188 | }; 189 | 190 | execStack().then(() => { 191 | retryFilesCountDown = retryFiles.length * maxRetryTimes; 192 | return retryFailedFiles(); 193 | }).then(() => _finish(), _finish); 194 | }; 195 | 196 | // For webpack >= 4 197 | if (compiler.hooks) { 198 | compiler.hooks.afterEmit.tapAsync('QiniuWebpackPlugin', uploadFiles); 199 | } 200 | // For webpack < 4 201 | else { 202 | compiler.plugin('after-emit', uploadFiles); 203 | } 204 | } 205 | }; 206 | --------------------------------------------------------------------------------