Skip to content

Commit a6c9c3f

Browse files
JamieMageeisaacs
authored andcommitted
Add support for zstd
PR-URL: #35 Credit: @JamieMagee Close: #35 Reviewed-by: @isaacs
1 parent c77e92c commit a6c9c3f

9 files changed

Lines changed: 224 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ provided by that class.
4141
- Unzip
4242
- BrotliCompress (Node v10 and higher)
4343
- BrotliDecompress (Node v10 and higher)
44+
- ZstdCompress (Node v22.15 and higher)
45+
- ZstdDecompress (Node v22.15 and higher)
4446

4547
## USAGE
4648

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
3434
"license": "MIT",
3535
"devDependencies": {
36-
"@types/node": "^22.13.14",
36+
"@types/node": "^22.15.29",
3737
"tap": "^21.1.0",
3838
"tshy": "^3.0.2",
3939
"typedoc": "^0.28.1"

src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type ZlibHandle =
6767
| realZlib.DeflateRaw
6868
| realZlib.InflateRaw
6969
export type BrotliMode = 'BrotliCompress' | 'BrotliDecompress'
70+
export type ZstdMode = 'ZstdCompress' | 'ZstdDecompress'
7071

7172
abstract class ZlibBase extends Minipass<Buffer, ChunkWithFlushFlag> {
7273
#sawError: boolean = false
@@ -89,7 +90,7 @@ abstract class ZlibBase extends Minipass<Buffer, ChunkWithFlushFlag> {
8990
}
9091
/* c8 ignore stop */
9192

92-
constructor(opts: ZlibBaseOptions, mode: ZlibMode | BrotliMode) {
93+
constructor(opts: ZlibBaseOptions, mode: ZlibMode | BrotliMode | ZstdMode) {
9394
if (!opts || typeof opts !== 'object')
9495
throw new TypeError('invalid options for ZlibBase constructor')
9596

@@ -439,3 +440,26 @@ export class BrotliDecompress extends Brotli {
439440
super(opts, 'BrotliDecompress')
440441
}
441442
}
443+
444+
export class Zstd extends ZlibBase {
445+
constructor(opts: ZlibOptions, mode: ZstdMode) {
446+
opts = opts || {}
447+
448+
opts.flush = opts.flush || constants.ZSTD_e_continue
449+
opts.finishFlush = opts.finishFlush || constants.ZSTD_e_end
450+
opts.fullFlushFlag = constants.ZSTD_e_flush
451+
super(opts, mode)
452+
}
453+
}
454+
455+
export class ZstdCompress extends Zstd {
456+
constructor(opts: ZlibOptions) {
457+
super(opts, 'ZstdCompress')
458+
}
459+
}
460+
461+
export class ZstdDecompress extends Zstd {
462+
constructor(opts: ZlibOptions) {
463+
super(opts, 'ZstdDecompress')
464+
}
465+
}

test/fixtures/person.jpg.zst

44.3 KB
Binary file not shown.

test/zstd-flush.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict'
2+
import t from 'tap'
3+
import { ZstdCompress } from '../dist/esm/index.js'
4+
import { readFileSync } from 'fs'
5+
import { fileURLToPath } from 'url'
6+
import * as console from 'console'
7+
8+
const fixture = fileURLToPath(
9+
new URL('fixtures/person.jpg', import.meta.url),
10+
)
11+
const file = readFileSync(fixture)
12+
const chunkSize = 16
13+
const deflater = new ZstdCompress()
14+
15+
const chunk = file.subarray(0, chunkSize)
16+
const expectedFull = Buffer.from('KLUv/QBYgAAA/9j/4AAQSkZJRgABAQEASA==', 'base64')
17+
18+
deflater.write(chunk)
19+
deflater.flush()
20+
const bufs = []
21+
deflater.on('data', b => bufs.push(b))
22+
const actualFull = Buffer.concat(bufs)
23+
t.same(actualFull, expectedFull)

test/zstd-from-string.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
// Test compressing and uncompressing a string with zstd
3+
4+
import t from 'tap'
5+
import {ZstdCompress, ZstdDecompress} from '../dist/esm/index.js'
6+
7+
const inputString =
8+
'ΩΩLorem ipsum dolor sit amet, consectetur adipiscing eli' +
9+
't. Morbi faucibus, purus at gravida dictum, libero arcu ' +
10+
'convallis lacus, in commodo libero metus eu nisi. Nullam' +
11+
' commodo, neque nec porta placerat, nisi est fermentum a' +
12+
'ugue, vitae gravida tellus sapien sit amet tellus. Aenea' +
13+
'n non diam orci. Proin quis elit turpis. Suspendisse non' +
14+
' diam ipsum. Suspendisse nec ullamcorper odio. Vestibulu' +
15+
'm arcu mi, sodales non suscipit id, ultrices ut massa. S' +
16+
'ed ac sem sit amet arcu malesuada fermentum. Nunc sed. '
17+
const compressedString =
18+
'KLUv/WT5AF0JAGbXPCCgJUkH/8+rqgA3KaVsW+6LfK3JL' +
19+
'cnP+I/Gy1/3Qv9XDTQAMwA0AK+Ch9LCub6tnT62C7Quwr' +
20+
'HQHDhhNPcCQltMWOrafGy3KO2D79QZ95omy09vwp/TFEA' +
21+
'kEIlHOO99cOlZmfRizXQ79GvDoY9TxrTgBBfR+77Nd7Lk' +
22+
'OWlHaGW+aEwd2rSeegWaj9NsWAJJ0253u1jQpe3ByWLS5' +
23+
'i+24QhTAZygaf4UlqNER3XoAk7QYar9tjHHV4yHj+tC10' +
24+
'8zuqMBJ+X2hlpwUqX6vE3r3N7q5QYntVvn3N8zVDb9UfC' +
25+
'MCW1790yV3A88pgvkvQAniSWvFxMAELvECFu0tC1R9Ijs' +
26+
'ri5bt2kE/2mLoi2wCpkElnidDMS//DemxlNdHClyl6KeN' +
27+
'TCugmAGfEYAXA=='
28+
29+
t.test('compress then decompress', async t =>
30+
new ZstdCompress()
31+
.end(inputString)
32+
.concat()
33+
.then(async buffer => {
34+
t.ok(
35+
inputString.length > buffer.length,
36+
'buffer is shorter than input',
37+
)
38+
39+
return new ZstdDecompress()
40+
.end(buffer)
41+
.concat()
42+
.then(buffer => t.equal(buffer.toString(), inputString))
43+
}),
44+
)
45+
46+
t.test('decompress then check', t =>
47+
new ZstdDecompress({ encoding: 'utf8' })
48+
.end(compressedString, 'base64')
49+
.concat()
50+
.then(result => t.equal(result, inputString)),
51+
)

test/zstd-from-zstd.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict'
2+
// Test unzipping a file that was created with a non-node zstd lib,
3+
// piped in as fast as possible.
4+
5+
import t from 'tap'
6+
import { ZstdDecompress } from '../dist/esm/index.js'
7+
import fs from 'fs'
8+
import { resolve } from 'path'
9+
import { fileURLToPath } from 'url'
10+
11+
const tmpdir = t.testdir({})
12+
13+
const decompress = new ZstdDecompress()
14+
15+
const fixture = fileURLToPath(
16+
new URL('fixtures/person.jpg.zst', import.meta.url),
17+
)
18+
const unzippedFixture = fileURLToPath(
19+
new URL('fixtures/person.jpg', import.meta.url),
20+
)
21+
const outputFile = resolve(tmpdir, 'person.jpg')
22+
const expect = fs.readFileSync(unzippedFixture)
23+
const inp = fs.createReadStream(fixture)
24+
const out = fs.createWriteStream(outputFile)
25+
26+
t.test('decompress and test output', t => {
27+
inp
28+
.pipe(decompress)
29+
.pipe(out)
30+
.on('close', () => {
31+
const actual = fs.readFileSync(outputFile)
32+
t.same(actual, expect)
33+
t.end()
34+
})
35+
})

test/zstd.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import t from 'tap'
2+
import { constants, ZstdCompress } from '../dist/esm/index.js'
3+
import fs from 'fs'
4+
import { fileURLToPath } from 'url'
5+
6+
const fixture = fileURLToPath(
7+
new URL('fixtures/pss-vectors.json', import.meta.url),
8+
)
9+
const sampleBuffer = fs.readFileSync(fixture)
10+
11+
// Test some zstd-specific properties of the zstd streams that can not
12+
// be easily covered through expanding zlib-only tests.
13+
14+
t.test('set compression level at stream creation', t => {
15+
// Test setting the compression level at stream creation:
16+
const sizes = []
17+
for (
18+
let quality = 0;
19+
quality <= 5;
20+
quality++
21+
) {
22+
const encoded = new ZstdCompress({
23+
params: {
24+
[constants.ZSTD_c_compressionLevel]: quality,
25+
},
26+
})
27+
.end(sampleBuffer)
28+
.read()
29+
sizes.push(encoded.length)
30+
}
31+
32+
// Increasing quality should roughly correspond to decreasing compressed size:
33+
for (let i = 0; i < sizes.length - 1; i++) {
34+
t.ok(
35+
sizes[i + 1] <= sizes[i] * 1.05,
36+
`size ${i + 1} should be smaller than size ${i}`,
37+
) // 5 % margin of error.
38+
}
39+
t.ok(sizes[0] > sizes[sizes.length - 1], 'first size larger than last')
40+
41+
t.end()
42+
})
43+
44+
t.test('setting out of bound option valules or keys fails', t => {
45+
// Test that setting out-of-bounds option values or keys fails.
46+
t.throws(
47+
() => {
48+
new ZstdCompress({
49+
params: {
50+
10000: 0,
51+
},
52+
})
53+
},
54+
{
55+
code: 'ERR_ZSTD_INVALID_PARAM',
56+
errno: undefined,
57+
name: "ZlibError",
58+
},
59+
)
60+
61+
// Test that accidentally using duplicate keys fails.
62+
t.throws(
63+
() => {
64+
new ZstdCompress({
65+
params: {
66+
0: 0,
67+
'00': 0,
68+
},
69+
})
70+
},
71+
{
72+
code: 'ERR_ZSTD_INVALID_PARAM',
73+
errno: undefined,
74+
name: "ZlibError",
75+
},
76+
)
77+
78+
t.end()
79+
})

0 commit comments

Comments
 (0)