├── .editorconfig ├── .gitattributes ├── .github ├── security.md └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── fixture └── fixture.txt ├── gulpfile.js ├── index.js ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | dest 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /fixture/fixture.txt: -------------------------------------------------------------------------------- 1 | hello world 2 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import zip from './index.js'; 3 | 4 | export default function main() { 5 | return gulp.src('fixture/fixture.txt') 6 | .pipe(zip('test.zip')) 7 | .pipe(gulp.dest('dest')); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {constants as BufferConstants} from 'node:buffer'; 3 | import Vinyl from 'vinyl'; 4 | import Yazl from 'yazl'; 5 | import {getStreamAsBuffer, MaxBufferError} from 'get-stream'; 6 | import {gulpPlugin} from 'gulp-plugin-extras'; 7 | 8 | export default function gulpZip(filename, options) { 9 | if (!filename) { 10 | throw new Error('gulp-zip: `filename` required'); 11 | } 12 | 13 | options = { 14 | compress: true, 15 | buffer: true, 16 | ...options, 17 | }; 18 | 19 | let firstFile; 20 | const zip = new Yazl.ZipFile(); 21 | 22 | return gulpPlugin('gulp-zip', async file => { 23 | firstFile ??= file; 24 | 25 | // Because Windows... 26 | const pathname = file.relative.replaceAll('\\', '/'); 27 | 28 | if (!pathname) { 29 | return; 30 | } 31 | 32 | if (file.isDirectory()) { 33 | zip.addEmptyDirectory(pathname, { 34 | mtime: options.modifiedTime || file.stat.mtime || new Date(), 35 | // Do *not* pass a mode for a directory, because it creates platform-dependent 36 | // ZIP files (ZIP files created on Windows that cannot be opened on macOS). 37 | // Re-enable if this PR is resolved: https://github.com/thejoshwolfe/yazl/pull/59 38 | // mode: file.stat.mode 39 | }); 40 | } else { 41 | const stat = { 42 | compress: options.compress, 43 | mtime: options.modifiedTime || (file.stat ? file.stat.mtime : new Date()), 44 | mode: file.stat ? file.stat.mode : null, 45 | }; 46 | 47 | if (file.isStream()) { 48 | zip.addReadStream(file.contents, pathname, stat); 49 | } 50 | 51 | if (file.isBuffer()) { 52 | zip.addBuffer(file.contents, pathname, stat); 53 | } 54 | } 55 | }, { 56 | supportsAnyType: true, 57 | async * onFinish() { 58 | zip.end(); 59 | 60 | if (!firstFile) { 61 | return; 62 | } 63 | 64 | let data; 65 | if (options.buffer) { 66 | try { 67 | data = await getStreamAsBuffer(zip.outputStream, {maxBuffer: BufferConstants.MAX_LENGTH}); 68 | } catch (error) { 69 | const error_ = error instanceof MaxBufferError ? new Error('The output ZIP file is too big to store in a buffer (larger than Buffer MAX_LENGTH). To output a stream instead, set the gulp-zip buffer option to `false`.') : error; 70 | throw error_; 71 | } 72 | } else { 73 | data = zip.outputStream; 74 | } 75 | 76 | yield new Vinyl({ 77 | cwd: firstFile.cwd, 78 | base: firstFile.base, 79 | path: path.join(firstFile.base, filename), 80 | contents: data, 81 | }); 82 | }, 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-zip", 3 | "version": "6.1.0", 4 | "description": "ZIP compress files", 5 | "license": "MIT", 6 | "repository": "sindresorhus/gulp-zip", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "sideEffects": false, 16 | "engines": { 17 | "node": ">=18" 18 | }, 19 | "scripts": { 20 | "test": "xo && ava" 21 | }, 22 | "files": [ 23 | "index.js" 24 | ], 25 | "keywords": [ 26 | "gulpplugin", 27 | "zip", 28 | "archive", 29 | "archiver", 30 | "compress", 31 | "compression", 32 | "file" 33 | ], 34 | "dependencies": { 35 | "get-stream": "^9.0.1", 36 | "gulp-plugin-extras": "^1.1.0", 37 | "vinyl": "^3.0.0", 38 | "yazl": "^3.3.1" 39 | }, 40 | "devDependencies": { 41 | "ava": "^6.2.0", 42 | "decompress-unzip": "^3.0.0", 43 | "easy-transform-stream": "^1.0.1", 44 | "gulp": "^5.0.0", 45 | "p-event": "^6.0.1", 46 | "vinyl-assign": "^1.2.1", 47 | "vinyl-file": "^5.0.0", 48 | "xo": "^0.60.0" 49 | }, 50 | "peerDependencies": { 51 | "gulp": ">=4" 52 | }, 53 | "peerDependenciesMeta": { 54 | "gulp": { 55 | "optional": true 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # gulp-zip 2 | 3 | > ZIP compress files 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install --save-dev gulp-zip 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import gulp from 'gulp'; 15 | import zip from 'gulp-zip'; 16 | 17 | export default () => ( 18 | gulp.src('src/*') 19 | .pipe(zip('archive.zip')) 20 | .pipe(gulp.dest('dist')) 21 | ); 22 | ``` 23 | 24 | ## API 25 | 26 | Supports [streaming mode](https://github.com/gulpjs/gulp/blob/master/docs/API.md#optionsbuffer). 27 | 28 | ### zip(filename, options?) 29 | 30 | #### filename 31 | 32 | Type: `string` 33 | 34 | #### options 35 | 36 | Type: `object` 37 | 38 | ##### compress 39 | 40 | Type: `boolean`\ 41 | Default: `true` 42 | 43 | ##### modifiedTime 44 | 45 | Type: `Date`\ 46 | Default: `undefined` 47 | 48 | Overrides the modification timestamp for all files added to the archive. 49 | 50 | Tip: Setting it to the same value across executions enables you to create stable archives that change only when the contents of their entries change, regardless of whether those entries were "touched" or regenerated. 51 | 52 | ##### buffer 53 | 54 | Type: `boolean`\ 55 | Default: `true` 56 | 57 | If `true`, the resulting ZIP file contents will be a buffer. Large zip files may not be possible to buffer, depending on the size of [Buffer MAX_LENGTH](https://nodejs.org/api/buffer.html#buffer_buffer_constants_max_length). 58 | 59 | If `false`, the ZIP file contents will be a stream. 60 | 61 | We use this option instead of relying on [gulp.src's `buffer` option](https://gulpjs.com/docs/en/api/src/#options) because we are mapping many input files to one output file and can't reliably detect what the output mode should be based on the inputs, since Vinyl streams could contain mixed streaming and buffered content. 62 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import {fileURLToPath} from 'node:url'; 5 | import test from 'ava'; 6 | import Vinyl from 'vinyl'; 7 | import unzip from 'decompress-unzip'; 8 | import vinylAssign from 'vinyl-assign'; 9 | import {vinylFileSync} from 'vinyl-file'; 10 | import yazl from 'yazl'; 11 | import {pEvent} from 'p-event'; 12 | import easyTransformStream from 'easy-transform-stream'; 13 | import zip from './index.js'; 14 | 15 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 16 | 17 | test('should zip files', async t => { 18 | const zipStream = zip('test.zip'); 19 | const unzipStream = unzip(); 20 | const stats = fs.statSync(path.join(__dirname, 'fixture/fixture.txt')); 21 | const files = []; 22 | 23 | const finalStream = zipStream.pipe(vinylAssign({extract: true})).pipe(unzipStream); 24 | const promise = pEvent(finalStream, 'end'); 25 | 26 | unzipStream.on('data', file => { 27 | files.push(file); 28 | }); 29 | 30 | zipStream.on('data', file => { 31 | t.is(path.normalize(file.path), path.join(__dirname, 'fixture/test.zip')); 32 | t.is(file.relative, 'test.zip'); 33 | t.true(file.isBuffer()); 34 | t.true(file.contents.length > 0); 35 | }); 36 | 37 | zipStream.write(new Vinyl({ 38 | cwd: __dirname, 39 | base: path.join(__dirname, 'fixture'), 40 | path: path.join(__dirname, 'fixture/fixture.txt'), 41 | contents: Buffer.from('hello world'), 42 | stat: { 43 | mode: stats.mode, 44 | mtime: stats.mtime, 45 | }, 46 | })); 47 | 48 | zipStream.write(new Vinyl({ 49 | cwd: __dirname, 50 | base: path.join(__dirname, 'fixture'), 51 | path: path.join(__dirname, 'fixture/fixture2.txt'), 52 | contents: Buffer.from('hello world 2'), 53 | stat: { 54 | mode: stats.mode, 55 | mtime: stats.mtime, 56 | }, 57 | })); 58 | 59 | zipStream.end(); 60 | 61 | await promise; 62 | 63 | t.is(files[0].path, 'fixture.txt'); 64 | t.is(files[1].path, 'fixture2.txt'); 65 | t.is(files[0].contents.toString(), 'hello world'); 66 | t.is(files[1].contents.toString(), 'hello world 2'); 67 | t.is(files[0].stat.mode, stats.mode); 68 | t.is(files[1].stat.mode, stats.mode); 69 | }); 70 | 71 | test('should zip files (using streams)', async t => { 72 | const file = vinylFileSync(path.join(__dirname, 'fixture/fixture.txt'), {buffer: false}); 73 | const stats = fs.statSync(path.join(__dirname, 'fixture/fixture.txt')); 74 | const zipStream = zip('test.zip'); 75 | const unzipStream = unzip(); 76 | const files = []; 77 | 78 | zipStream.pipe(vinylAssign({extract: true})).pipe(unzipStream); 79 | 80 | unzipStream.on('data', file => { 81 | files.push(file); 82 | }); 83 | 84 | zipStream.on('data', file => { 85 | t.is(path.normalize(file.path), path.join(__dirname, 'test.zip')); 86 | t.is(file.relative, 'test.zip'); 87 | t.true(file.contents.length > 0); 88 | }); 89 | 90 | zipStream.end(file); 91 | 92 | await pEvent(unzipStream, 'end'); 93 | 94 | t.is(path.normalize(files[0].path), path.normalize('fixture/fixture.txt')); 95 | t.is(files[0].contents.toString(), 'hello world\n'); 96 | t.is(files[0].stat.mode, stats.mode); 97 | }); 98 | 99 | test('should not skip empty directories', async t => { 100 | const zipStream = zip('test.zip'); 101 | const unzipStream = unzip(); 102 | const files = []; 103 | 104 | const stats = { 105 | isDirectory() { 106 | return true; 107 | }, 108 | mode: 0o664, 109 | }; 110 | 111 | const promise = pEvent(unzipStream, 'end'); 112 | 113 | zipStream.pipe(vinylAssign({extract: true})).pipe(unzipStream); 114 | 115 | unzipStream.on('data', file => { 116 | files.push(file); 117 | }); 118 | 119 | zipStream.on('data', file => { 120 | t.is(path.normalize(file.path), path.join(__dirname, 'test.zip')); 121 | t.is(file.relative, 'test.zip'); 122 | t.true(file.contents.length > 0); 123 | }); 124 | 125 | zipStream.write(new Vinyl({ 126 | cwd: __dirname, 127 | base: __dirname, 128 | path: path.join(__dirname, 'foo'), 129 | contents: null, 130 | stat: stats, 131 | })); 132 | 133 | zipStream.end(); 134 | 135 | await promise; 136 | 137 | t.is(files[0].path, 'foo'); 138 | t.is(files[0].stat.mode & 0o777, 0o775); // eslint-disable-line no-bitwise 139 | }); 140 | 141 | test('when `options.modifiedTime` is specified, should override files\' actual `mtime`s', async t => { 142 | const modifiedTime = new Date(); 143 | const zipStream = zip('test.zip', {modifiedTime}); 144 | const unzipStream = unzip(); 145 | zipStream.pipe(vinylAssign({extract: true})).pipe(unzipStream); 146 | const promise = pEvent(unzipStream, 'end'); 147 | 148 | const files = []; 149 | unzipStream.on('data', file => { 150 | files.push(file); 151 | }); 152 | 153 | // Send the fixture file through the pipeline as a test case of a file having a real modification timestamp. 154 | const fixtureFile = vinylFileSync(path.join(__dirname, 'fixture/fixture.txt'), {buffer: false}); 155 | zipStream.write(fixtureFile); 156 | 157 | // Send a fake file through the pipeline as another test case of a file with a different modification timestamp. 158 | const fakeFile = new Vinyl({ 159 | cwd: __dirname, 160 | base: path.join(__dirname, 'fixture'), 161 | path: path.join(__dirname, 'fixture/fake.txt'), 162 | contents: Buffer.from('hello world'), 163 | stat: { 164 | mode: 0, 165 | mtime: new Date(0), 166 | }, 167 | }); 168 | zipStream.write(fakeFile); 169 | 170 | zipStream.end(); 171 | 172 | await promise; 173 | 174 | for (const file of files) { 175 | t.deepEqual(yazl.dateToDosDateTime(file.stat.mtime), yazl.dateToDosDateTime(modifiedTime)); 176 | } 177 | }); 178 | 179 | test('when `options.modifiedTime` is specified, should create identical zips when files\' `mtime`s change but their content doesn\'t', async t => { 180 | const modifiedTime = new Date(); 181 | const stream1 = zip('test1.zip', {modifiedTime}); 182 | let zipFile1; 183 | stream1.pipe(easyTransformStream({objectMode: true}, chunk => { 184 | zipFile1 = chunk; 185 | return chunk; 186 | })); 187 | 188 | const stream2 = zip('test2.zip', {modifiedTime}); 189 | let zipFile2; 190 | stream2.pipe(easyTransformStream({objectMode: true}, chunk => { 191 | zipFile2 = chunk; 192 | return chunk; 193 | })); 194 | 195 | // Send a fake file through the first pipeline. 196 | stream1.end(new Vinyl({ 197 | cwd: __dirname, 198 | base: path.join(__dirname, 'fixture'), 199 | path: path.join(__dirname, 'fixture/fake.txt'), 200 | contents: Buffer.from('hello world'), 201 | stat: { 202 | mode: 0, 203 | mtime: new Date(0), 204 | }, 205 | })); 206 | 207 | // Send a fake file through the second pipeline with the same contents but a different timestamp. 208 | stream2.end(new Vinyl({ 209 | cwd: __dirname, 210 | base: path.join(__dirname, 'fixture'), 211 | path: path.join(__dirname, 'fixture/fake.txt'), 212 | contents: Buffer.from('hello world'), 213 | stat: { 214 | mode: 0, 215 | mtime: new Date(999_999_999_999), 216 | }, 217 | })); 218 | 219 | await Promise.all([pEvent(stream1, 'end'), pEvent(stream2, 'end')]); 220 | 221 | t.true(zipFile1.contents.equals(zipFile2.contents)); 222 | }); 223 | 224 | test('should produce a buffer by default', async t => { 225 | t.plan(1); 226 | 227 | const zipStream = zip('test.zip'); 228 | const promise = pEvent(zipStream, 'end'); 229 | const file = vinylFileSync(path.join(__dirname, 'fixture/fixture.txt')); 230 | 231 | zipStream.on('data', file => { 232 | t.true(file.isBuffer()); 233 | }); 234 | 235 | zipStream.write(file); 236 | zipStream.end(); 237 | 238 | await promise; 239 | }); 240 | 241 | test('should produce a stream if requested', async t => { 242 | t.plan(1); 243 | 244 | const zipStream = zip('test.zip', {buffer: false}); 245 | const promise = pEvent(zipStream, 'end'); 246 | const file = vinylFileSync(path.join(__dirname, 'fixture/fixture.txt')); 247 | 248 | zipStream.on('data', file => { 249 | t.true(file.isStream()); 250 | }); 251 | 252 | zipStream.write(file); 253 | zipStream.end(); 254 | 255 | await promise; 256 | }); 257 | 258 | // FIXME 259 | // test('should explain buffer size errors', async t => { 260 | // const zipStream = zip('test.zip', {compress: false}); 261 | // const unzipStream = unzip(); 262 | // const stats = fs.statSync(path.join(__dirname, 'fixture/fixture.txt')); 263 | // zipStream.pipe(vinylAssign({extract: true})).pipe(unzipStream); 264 | 265 | // const errorPromise = pEvent(zipStream, 'error'); 266 | 267 | // function addFile(contents) { 268 | // zipStream.write(new Vinyl({ 269 | // cwd: __dirname, 270 | // base: path.join(__dirname, 'fixture'), 271 | // path: path.join(__dirname, 'fixture/file.txt'), 272 | // contents, 273 | // stat: stats, 274 | // })); 275 | // } 276 | 277 | // const maxYazlBuffer = 1_073_741_823; 278 | // const filesNeeded = Math.floor(BufferConstants.MAX_LENGTH / maxYazlBuffer); 279 | // for (let files = 0; files < filesNeeded; files++) { 280 | // addFile(Buffer.allocUnsafe(maxYazlBuffer)); 281 | // } 282 | 283 | // addFile(Buffer.allocUnsafe(BufferConstants.MAX_LENGTH % maxYazlBuffer)); 284 | 285 | // zipStream.end(); 286 | 287 | // const error = await errorPromise; 288 | // t.is(error.message, 'The output ZIP file is too big to store in a buffer (larger than Buffer MAX_LENGTH). To output a stream instead, set the gulp-zip buffer option to `false`.'); 289 | // }); 290 | --------------------------------------------------------------------------------