Skip to content

Commit 3334fae

Browse files
committed
Add support for gzip de/encode streaming
1 parent d3d3575 commit 3334fae

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,17 @@ This library also exports consistent async methods to compress and decompress ea
5959

6060
Each method accepts a buffer and returns a promise for a buffer.
6161

62+
## Streaming methods
63+
64+
This library also supports streaming decoding, using web de(compression) streams wherever possible.
65+
66+
* `createGzipStream`
67+
* `createGunzipStream`
68+
6269
## Browser usage
6370

6471
To use this in a browser, you'll need to use a bundler (e.g. Webpack) that can include standard Node.js polyfill packages, you may need to install those polyfill packages, and your bundler needs to support bundling WebAssembly (e.g. Webpack v4+).
6572

66-
In Webpack v4 this should all work automatically. In Webpack v5 this will require explicit dependencies and configuration. See this package's own [test webpack config](./karma.conf.js#L14-L44) and [dev dependencies](./package.json) for a working example.
73+
In Webpack v4 this should all work automatically. In Webpack v5 and most other builders this will require explicit dependencies and configuration. See this package's own [test webpack config](./karma.conf.js#L14-L44) and [dev dependencies](./package.json) for a working example.
6774

6875
Brotli and Zstandard are only supported in runtime environments that support WebAssembly. All WebAssembly packages are loaded on-demand and only when native methods (e.g. Node's `zlib.brotli*`) are not available.

‎src/index.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,39 @@ export { encodeBase64Promisified as encodeBase64 };
115115
const decodeBase64Promisified = promisify<Uint8Array, Uint8Array>(decodeBase64);
116116
export { decodeBase64Promisified as decodeBase64 };
117117

118+
// --- Streaming APIs ---
119+
120+
// Lazily loaded to avoid bundling Node's stream polyfill for browsers
121+
let Duplex: typeof import('stream').Duplex | undefined;
122+
const getDuplex = () => {
123+
if (!Duplex) {
124+
Duplex = require('stream').Duplex;
125+
}
126+
return Duplex!;
127+
};
128+
129+
type BufferSource = ArrayBufferView | ArrayBuffer;
130+
131+
export function createGzipStream(): TransformStream<BufferSource, Uint8Array> {
132+
// Use native CompressionStream where available:
133+
if (typeof CompressionStream !== 'undefined') {
134+
return new CompressionStream('gzip');
135+
}
136+
// Turn zlib node built-in into a web stream if not:
137+
return getDuplex().toWeb(zlib.createGzip()) as TransformStream<BufferSource, Uint8Array>;
138+
}
139+
140+
export function createGunzipStream(): TransformStream<BufferSource, Uint8Array> {
141+
// Use native DecompressionStream where available:
142+
if (typeof DecompressionStream !== 'undefined') {
143+
return new DecompressionStream('gzip');
144+
}
145+
// Turn zlib node built-in into a web stream if not:
146+
return getDuplex().toWeb(zlib.createGunzip()) as TransformStream<BufferSource, Uint8Array>;
147+
}
148+
149+
// --- Buffer helpers ---
150+
118151
const asBuffer = (input: Buffer | Uint8Array | ArrayBuffer): Buffer => {
119152
if (Buffer.isBuffer(input)) {
120153
return input;

‎test/stream.spec.ts‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import * as zlib from 'zlib';
2+
3+
import chai = require("chai");
4+
const expect = chai.expect;
5+
6+
import {
7+
createGzipStream,
8+
createGunzipStream
9+
} from '../src/index';
10+
11+
// Helper to collect all chunks from a ReadableStream into a single Uint8Array
12+
async function collectStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
13+
const reader = stream.getReader();
14+
const chunks: Uint8Array[] = [];
15+
let totalLength = 0;
16+
17+
while (true) {
18+
const { done, value } = await reader.read();
19+
if (done) break;
20+
chunks.push(value);
21+
totalLength += value.length;
22+
}
23+
24+
const result = new Uint8Array(totalLength);
25+
let offset = 0;
26+
for (const chunk of chunks) {
27+
result.set(chunk, offset);
28+
offset += chunk.length;
29+
}
30+
return result;
31+
}
32+
33+
// Helper to create a ReadableStream from a Uint8Array
34+
function createReadableStream(data: Uint8Array): ReadableStream<Uint8Array> {
35+
return new ReadableStream({
36+
start(controller) {
37+
controller.enqueue(data);
38+
controller.close();
39+
}
40+
});
41+
}
42+
43+
describe("Streaming", () => {
44+
describe("Gzip", () => {
45+
it('should compress data with gzip stream', async () => {
46+
const input = Buffer.from('Hello streaming gzip world!');
47+
const inputStream = createReadableStream(input);
48+
49+
const compressedStream = inputStream.pipeThrough(createGzipStream());
50+
const compressed = await collectStream(compressedStream);
51+
52+
// Verify the compressed data can be decompressed with zlib
53+
const decompressed = zlib.gunzipSync(Buffer.from(compressed));
54+
expect(decompressed.toString()).to.equal('Hello streaming gzip world!');
55+
});
56+
57+
it('should decompress gzip data with stream', async () => {
58+
const original = 'Hello streaming gunzip world!';
59+
const compressed = zlib.gzipSync(original);
60+
const inputStream = createReadableStream(compressed);
61+
62+
const decompressedStream = inputStream.pipeThrough(createGunzipStream());
63+
const decompressed = await collectStream(decompressedStream);
64+
65+
expect(Buffer.from(decompressed).toString()).to.equal(original);
66+
});
67+
68+
it('should handle round-trip compression and decompression', async () => {
69+
const original = 'Round-trip streaming test with some repeated data data data data';
70+
const inputStream = createReadableStream(Buffer.from(original));
71+
72+
const compressedStream = inputStream.pipeThrough(createGzipStream());
73+
const decompressedStream = compressedStream.pipeThrough(createGunzipStream());
74+
const result = await collectStream(decompressedStream);
75+
76+
expect(Buffer.from(result).toString()).to.equal(original);
77+
});
78+
79+
it('should handle multiple chunks', async () => {
80+
const chunks = ['chunk1', 'chunk2', 'chunk3', 'chunk4'];
81+
const inputStream = new ReadableStream<Uint8Array>({
82+
start(controller) {
83+
for (const chunk of chunks) {
84+
controller.enqueue(Buffer.from(chunk));
85+
}
86+
controller.close();
87+
}
88+
});
89+
90+
const compressedStream = inputStream.pipeThrough(createGzipStream());
91+
const decompressedStream = compressedStream.pipeThrough(createGunzipStream());
92+
const result = await collectStream(decompressedStream);
93+
94+
expect(Buffer.from(result).toString()).to.equal(chunks.join(''));
95+
});
96+
97+
it('should handle empty input', async () => {
98+
const inputStream = createReadableStream(new Uint8Array(0));
99+
100+
const compressedStream = inputStream.pipeThrough(createGzipStream());
101+
const decompressedStream = compressedStream.pipeThrough(createGunzipStream());
102+
const result = await collectStream(decompressedStream);
103+
104+
expect(result.length).to.equal(0);
105+
});
106+
107+
it('should handle large data', async () => {
108+
// Create a 1MB buffer of repeated data
109+
const pattern = 'This is a test pattern that will be repeated many times. ';
110+
const repeated = pattern.repeat(20000);
111+
const inputStream = createReadableStream(Buffer.from(repeated));
112+
113+
const compressedStream = inputStream.pipeThrough(createGzipStream());
114+
const compressed = await collectStream(compressedStream);
115+
116+
// Compressed should be smaller than original due to repetition
117+
expect(compressed.length).to.be.lessThan(repeated.length);
118+
119+
// Verify decompression
120+
const decompressed = zlib.gunzipSync(Buffer.from(compressed));
121+
expect(decompressed.toString()).to.equal(repeated);
122+
});
123+
});
124+
});

‎tsconfig.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"compilerOptions": {
3-
"target": "es6",
3+
"target": "es2020",
44
"module": "commonjs",
55
"outDir": "./dist",
66
"declaration": true,

0 commit comments

Comments
 (0)