Skip to content

Commit 6c19e3a

Browse files
committed
For issue #40, support DEFLATE compression in Zipper where the runtime supports it via CompressionStream.
1 parent c07aa83 commit 6c19e3a

File tree

4 files changed

+88
-18
lines changed

4 files changed

+88
-18
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.2.2] - 2024-01-??
6+
7+
### Added
8+
9+
- archive: Support DEFLATE in Zipper where JS implementations support it in CompressionStream.
10+
- io: Added a skip() method to BitStream to match ByteStream.
11+
512
## [1.2.1] - 2024-01-19
613

714
### Added

archive/compress.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,24 @@ export class Zipper {
7676
*/
7777
constructor(options) {
7878
/**
79-
* @type {ZipCompressionMethod}
79+
* @type {CompressorOptions}
8080
* @private
8181
*/
82+
this.zipOptions = options;
8283
this.zipCompressionMethod = options.zipCompressionMethod || ZipCompressionMethod.STORE;
83-
if (this.zipCompressionMethod === ZipCompressionMethod.DEFLATE) throw `DEFLATE not supported.`;
84+
if (!Object.values(ZipCompressionMethod).includes(this.zipCompressionMethod)) {
85+
throw `Compression method ${this.zipCompressionMethod} not supported`;
86+
}
87+
88+
if (this.zipCompressionMethod === ZipCompressionMethod.DEFLATE) {
89+
// As per https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream, NodeJS only
90+
// supports deflate-raw from 21.2.0+ (Nov 2023). https://nodejs.org/en/blog/release/v21.2.0.
91+
try {
92+
new CompressionStream('deflate-raw');
93+
} catch (err) {
94+
throw `CompressionStream with deflate-raw not supported by JS runtime: ${err}`;
95+
}
96+
}
8497

8598
/**
8699
* @type {CompressStatus}
@@ -155,7 +168,7 @@ export class Zipper {
155168
};
156169

157170
this.compressState = CompressStatus.READY;
158-
this.appendFiles(files, isLastFile);
171+
this.port_.postMessage({ files, isLastFile, compressionMethod: this.zipCompressionMethod});
159172
});
160173
}
161174

@@ -170,4 +183,4 @@ export class Zipper {
170183
this.byteArray.set(oldArray);
171184
this.byteArray.set(newBytes, oldArray.byteLength);
172185
}
173-
}
186+
}

archive/zip.js

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import { ByteBuffer } from '../io/bytebuffer.js';
1515
import { CENTRAL_FILE_HEADER_SIG, CRC32_MAGIC_NUMBER, END_OF_CENTRAL_DIR_SIG,
16-
LOCAL_FILE_HEADER_SIG } from './common.js';
16+
LOCAL_FILE_HEADER_SIG, ZipCompressionMethod } from './common.js';
1717

1818
/** @typedef {import('./common.js').FileInfo} FileInfo */
1919

@@ -37,9 +37,10 @@ let hostPort;
3737
* @typedef CompressFilesMessage A message the client sends to the implementation.
3838
* @property {FileInfo[]} files A set of files to add to the zip file.
3939
* @property {boolean} isLastFile Indicates this is the last set of files to add to the zip file.
40+
* @property {ZipCompressionMethod=} compressionMethod The compression method to use. Ignored except
41+
* for the first message sent.
4042
*/
4143

42-
// TODO: Support DEFLATE.
4344
// TODO: Support options that can let client choose levels of compression/performance.
4445

4546
/**
@@ -54,6 +55,9 @@ let hostPort;
5455
* @property {number} byteOffset (4 bytes)
5556
*/
5657

58+
/** @type {ZipCompressionMethod} */
59+
let compressionMethod = ZipCompressionMethod.STORE;
60+
5761
/** @type {FileInfo[]} */
5862
let filesCompressed = [];
5963

@@ -138,30 +142,39 @@ function dateToDosTime(jsDate) {
138142

139143
/**
140144
* @param {FileInfo} file
141-
* @returns {ByteBuffer}
145+
* @returns {Promise<ByteBuffer>}
142146
*/
143-
function zipOneFile(file) {
147+
async function zipOneFile(file) {
148+
/** @type {Uint8Array} */
149+
let compressedBytes;
150+
if (compressionMethod === ZipCompressionMethod.STORE) {
151+
compressedBytes = file.fileData;
152+
} else if (compressionMethod === ZipCompressionMethod.DEFLATE) {
153+
const blob = new Blob([file.fileData.buffer]);
154+
const compressedStream = blob.stream().pipeThrough(new CompressionStream('deflate-raw'));
155+
compressedBytes = new Uint8Array(await new Response(compressedStream).arrayBuffer());
156+
}
157+
144158
// Zip Local File Header has 30 bytes and then the filename and extrafields.
145159
const fileHeaderSize = 30 + file.fileName.length;
146160

147161
/** @type {ByteBuffer} */
148-
const buffer = new ByteBuffer(fileHeaderSize + file.fileData.byteLength);
162+
const buffer = new ByteBuffer(fileHeaderSize + compressedBytes.byteLength);
149163

150164
buffer.writeNumber(LOCAL_FILE_HEADER_SIG, 4); // Magic number.
151165
buffer.writeNumber(0x0A, 2); // Version.
152166
buffer.writeNumber(0, 2); // General Purpose Flags.
153-
buffer.writeNumber(0, 2); // Compression Method. 0 = Store only.
167+
buffer.writeNumber(compressionMethod, 2); // Compression Method.
154168

155169
const jsDate = new Date(file.lastModTime);
156170

157171
/** @type {CentralDirectoryFileHeaderInfo} */
158172
const centralDirectoryInfo = {
159-
compressionMethod: 0,
173+
compressionMethod,
160174
lastModFileTime: dateToDosTime(jsDate),
161175
lastModFileDate: dateToDosDate(jsDate),
162176
crc32: calculateCRC32(0, file.fileData),
163-
// TODO: For now, this is easy. Later when we do DEFLATE, we will have to calculate.
164-
compressedSize: file.fileData.byteLength,
177+
compressedSize: compressedBytes.byteLength,
165178
uncompressedSize: file.fileData.byteLength,
166179
fileName: file.fileName,
167180
byteOffset: numBytesWritten,
@@ -176,7 +189,7 @@ function zipOneFile(file) {
176189
buffer.writeNumber(centralDirectoryInfo.fileName.length, 2); // Filename length.
177190
buffer.writeNumber(0, 2); // Extra field length.
178191
buffer.writeASCIIString(centralDirectoryInfo.fileName); // Filename. Assumes ASCII.
179-
buffer.insertBytes(file.fileData); // File data.
192+
buffer.insertBytes(compressedBytes);
180193

181194
return buffer;
182195
}
@@ -195,7 +208,7 @@ function writeCentralFileDirectory() {
195208
buffer.writeNumber(0, 2); // Version made by. // 0x31e
196209
buffer.writeNumber(0, 2); // Version needed to extract (minimum). // 0x14
197210
buffer.writeNumber(0, 2); // General purpose bit flag
198-
buffer.writeNumber(0, 2); // Compression method.
211+
buffer.writeNumber(compressionMethod, 2); // Compression method.
199212
buffer.writeNumber(cdInfo.lastModFileTime, 2); // Last Mod File Time.
200213
buffer.writeNumber(cdInfo.lastModFileDate, 2); // Last Mod Date.
201214
buffer.writeNumber(cdInfo.crc32, 4); // crc32.
@@ -228,7 +241,7 @@ function writeCentralFileDirectory() {
228241
* @param {{data: CompressFilesMessage}} evt The event for the implementation to process. It is an
229242
* error to send any more events after a previous event had isLastFile is set to true.
230243
*/
231-
const onmessage = function(evt) {
244+
const onmessage = async function(evt) {
232245
if (state === CompressorState.FINISHED) {
233246
throw `The zip implementation was sent a message after last file received.`;
234247
}
@@ -239,11 +252,19 @@ const onmessage = function(evt) {
239252

240253
state = CompressorState.COMPRESSING;
241254

255+
if (filesCompressed.length === 0 && evt.data.compressionMethod !== undefined) {
256+
if (!Object.values(ZipCompressionMethod).includes(evt.data.compressionMethod)) {
257+
throw `Do not support compression method ${evt.data.compressionMethod}`;
258+
}
259+
260+
compressionMethod = evt.data.compressionMethod;
261+
}
262+
242263
const msg = evt.data;
243264
const filesToCompress = msg.files;
244265
while (filesToCompress.length > 0) {
245266
const fileInfo = filesToCompress.shift();
246-
const fileBuffer = zipOneFile(fileInfo);
267+
const fileBuffer = await zipOneFile(fileInfo);
247268
filesCompressed.push(fileInfo);
248269
numBytesWritten += fileBuffer.data.byteLength;
249270
hostPort.postMessage({ type: 'compress', bytes: fileBuffer.data }, [ fileBuffer.data.buffer ]);

tests/archive-compress.spec.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ describe('bitjs.archive.compress', () => {
3636
}
3737
});
3838

39-
it('zipper works', (done) => {
39+
it('zipper throws for invalid compression method', async () => {
40+
expect(() => new Zipper({zipCompressionMethod: 42})).throws();
41+
});
42+
43+
it('zipper works for STORE', (done) => {
4044
const files = new Map(inputFileInfos);
4145
const zipper = new Zipper({zipCompressionMethod: ZipCompressionMethod.STORE});
4246
zipper.start(Array.from(files.values()), true).then(byteArray => {
@@ -57,4 +61,29 @@ describe('bitjs.archive.compress', () => {
5761
unarchiver.start();
5862
});
5963
});
64+
65+
it('zipper works for DEFLATE, where supported', async () => {
66+
const files = new Map(inputFileInfos);
67+
try {
68+
const zipper = new Zipper({zipCompressionMethod: ZipCompressionMethod.DEFLATE});
69+
const byteArray = await zipper.start(Array.from(files.values()), true);
70+
71+
expect(zipper.compressState).equals(CompressStatus.COMPLETE);
72+
expect(byteArray.byteLength < decompressedFileSize).equals(true);
73+
74+
const unarchiver = getUnarchiver(byteArray.buffer);
75+
unarchiver.addEventListener('extract', evt => {
76+
const {filename, fileData} = evt.unarchivedFile;
77+
expect(files.has(filename)).equals(true);
78+
const inputFile = files.get(filename).fileData;
79+
expect(inputFile.byteLength).equals(fileData.byteLength);
80+
for (let b = 0; b < inputFile.byteLength; ++b) {
81+
expect(inputFile[b]).equals(fileData[b]);
82+
}
83+
});
84+
await unarchiver.start();
85+
} catch (err) {
86+
// Do nothing. This runtime did not support DEFLATE. (Node < 21.2.0)
87+
}
88+
});
6089
});

0 commit comments

Comments
 (0)