Compression of buffers, streams and files, plus tar/zip archiving, built
exclusively on Node's built-in modules (node:zlib, node:stream, node:fs).
Zero runtime dependencies, TypeScript types included, ESM, with a CLI.
Algorithms, all provided by node:zlib: gzip, deflate, deflate-raw, brotli,
zstd. Archive formats: tar (ustar/PAX, with outer compression) and zip (deflate,
store, zstd, ZIP64).
Requirements: Node >= 22.15 (for zstd). Without zstd, Node >= 18 is enough.
npm install @open-rlb/node-compressorimport {
compress, decompress, compressFile, createCompressStream,
createArchive, extractArchive,
} from '@open-rlb/node-compressor';
// buffers and strings
const packed = await compress('something worth shrinking', { algorithm: 'zstd' });
const original = await decompress(packed); // algorithm read from the magic bytes
// files, streamed
const result = await compressFile('dump.sql', { algorithm: 'zstd', preset: 'best' });
// -> dump.sql.zst
// streams, composable with pipeline()
await pipeline(request, createCompressStream({ algorithm: 'brotli' }), response);
// multi-file archives: format and compression come from the extension
await createArchive('release.tar.zst', ['dist', 'README.md']);
await extractArchive('release.tar.zst', 'target/');Nothing here reimplements compression. Every byte of gzip, deflate, brotli and
zstd goes through node:zlib, and the zip CRC-32 is zlib.crc32. What the
library adds is everything around that: container framing, format detection,
path safety, atomic writes, and one consistent API over the four shapes your
data can take.
┌──────────────────────────────────┐
│ index.ts │ public barrel
└───┬────────┬────────┬────────┬───┘
┌───────────────┘ │ │ └────────────────┐
▼ ▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌──────────┐ ┌─────────────────────┐
│ buffer.ts │ │ stream.ts │ │ file.ts │ │ archive/dispatch.ts │
│ │ │ │ │ │ │ │
│ one shot, │ │ Transforms │ │ file to │ │ picks the format, │
│ in memory │ │ + header │ │ file, │ │ tar or zip, from │
│ │ │ sniffing │ │ streamed │ │ name or content │
└─────┬─────┘ └──────┬─────┘ └────┬─────┘ └──────────┬──────────┘
│ │ │ │
│ │ │ ┌──────────┴──────────┐
│ │ │ ▼ ▼
│ │ │ ┌─────────┐ ┌──────────┐
│ │ │ │ tar/ │ │ zip/ │
│ │ │ │ header │ │ write │
│ │ │ │ pack │ │ read │
│ │ │ │ extract │ │ │
│ │ │ └────┬────┘ └────┬─────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ shared services │
│ │
│ algorithms.ts presets to native levels, the only zlib entry point │
│ detect.ts magic bytes to algorithm │
│ fsutil.ts destination checks, write-to-temp-then-rename │
│ archive/sources disk and memory to a stream of entries │
│ archive/target entries to disk: filters, strip, modes, timestamps │
│ archive/paths normalisation and the zip-slip refusals │
│ archive/reader exact-length reads, random access for zip │
│ archive/open decompresses only what is actually compressed │
└─────────────────────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Node built-ins - the only dependency │
│ │
│ node:zlib every compress and decompress, plus crc32 for zip │
│ node:stream pipeline, backpressure, Duplex.from for the sniffing │
│ node:fs read and write streams, atomic rename, symlinks, modes │
│ node:util parseArgs, for the CLI │
└─────────────────────────────────────────────────────────────────────────────┘
| Module | Responsibility |
|---|---|
index.ts |
The single public surface; every folder re-exports through its own barrel. |
buffer.ts |
One-shot compression of data already in memory. |
stream.ts |
The Transforms, the duplex that sniffs the header before choosing a decompressor, the progress counter. |
file.ts |
File to file, streamed, never loading the payload; picks algorithm and destination from the extensions. |
archive/dispatch.ts |
Chooses tar or zip — from the destination name when writing, from the content when reading. |
archive/sources.ts |
Turns paths, in-memory entries, directories and symlinks into one stream of entries, walking directories. |
archive/target.ts |
The other direction: writes entries to disk applying filters, strip, permissions and timestamps. |
archive/paths.ts |
Path normalisation and the refusals that stop zip slip. |
archive/reader.ts |
Exact-length sequential reads over a stream, and random access over a file or buffer for zip. |
archive/open.ts |
Opens an archive, decompressing only when it really is compressed. |
tar/header.ts |
The 512-byte ustar blocks: fields, checksum, PAX records, base-256 sizes. |
tar/pack.ts · tar/extract.ts |
Tar out as a stream, tar in as an entry iterator. |
zip/write.ts |
Local headers, data descriptors, central directory, ZIP64 promotion. |
zip/read.ts |
Finds the EOCD, parses the central directory, reads single entries on demand. |
algorithms.ts |
The only module that touches node:zlib directly: presets to native levels, compressor factories. |
detect.ts |
Magic bytes to algorithm. |
fsutil.ts |
Destination checks and the write-to-temp-then-rename dance. |
data/ |
Types, enums and CompressionError. No behaviour. |
cli/ |
Argument parsing, output formatting, glob filters, skill installer. |
Every layer shares the same options: what holds for buffers holds unchanged for streams and files.
| Function | Description |
|---|---|
compress(input, options?) |
Compresses a Buffer, Uint8Array or string (UTF-8). |
compressSync(input, options?) |
Synchronous variant: blocks the event loop. |
decompress(input, options?) |
Decompresses, detecting the format by default. |
decompressSync(input, options?) |
Synchronous variant. |
decompressToString(input, options?) |
Decompresses and decodes as text. |
| Function | Description |
|---|---|
createCompressStream(options?) |
Transform that compresses. |
createDecompressStream(options?) |
Duplex that decompresses; 'auto' sniffs the header. |
compressStream(source, options?) |
Wires a source up and returns the compressed Readable. |
decompressStream(source, options?) |
The same, decompressing. |
createProgressStream(cb, total?) |
PassThrough that counts bytes without altering them. |
import {
compressStream,
createCompressStream,
createProgressStream,
decompressStream,
} from '@open-rlb/node-compressor';
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// A compressor is just a Transform: drop it into any pipeline. Nothing is
// buffered, so the file never sits in memory.
await pipeline(
createReadStream('events.ndjson'),
createProgressStream(({ bytesRead }) => report(bytesRead)),
createCompressStream({ algorithm: 'zstd', preset: 'fast' }),
createWriteStream('events.ndjson.zst')
);
// Reading back, the algorithm comes from the first bytes: whoever consumes the
// stream does not need to know what produced it.
for await (const chunk of decompressStream(createReadStream('events.ndjson.zst'))) {
handle(chunk);
}
// Any async iterable is a valid source, not just a Readable.
const gzipped = compressStream(rowsAsCsv(), { algorithm: 'gzip' });Backpressure is honoured throughout. The only thing ever held back is the 4-byte header the detector needs, which is why automatic decompression works even when chunks arrive one byte at a time.
| Function | Description |
|---|---|
compressFile(source, destination?, options?) |
Streamed, written atomically. |
decompressFile(source, destination?, options?) |
Streamed, written atomically. |
Both return a FileResult:
{
source: string;
destination: string;
algorithm: Algorithm;
bytesRead: number;
bytesWritten: number;
compressionRatio: number; // bytesWritten / bytesRead
durationMs: number;
}The destination may be omitted: compression appends the algorithm's extension
(dump.sql -> dump.sql.zst), decompression drops it (dump.sql.gz -> dump.sql,
backup.tgz -> backup.tar).
| Function | Description |
|---|---|
createArchive(destination, sources, options?) |
Creates a tar or zip based on the extension. |
extractArchive(source, destination, options?) |
Extracts, detecting the format from the content. |
listArchive(source, options?) |
Lists the entries without extracting them. |
Format-specific entry points, when you need to force the choice:
| tar | zip |
|---|---|
createTarArchive(dest, sources, options?) |
createZipArchive(dest, sources, options?) |
createTarStream(sources, options?) |
createZipStream(sources, options?) |
extractTar(source, dest, options?) |
extractZip(source, dest, options?) |
listTar(source, options?) |
listZip(source) |
readTarEntries(source, options?) |
ZipArchive.open(source), readZipEntry(source, path) |
import {
ZipArchive,
createArchive,
extractArchive,
listArchive,
} from '@open-rlb/node-compressor';
// Sources mix what is on disk with what only exists in memory. The extension
// picks both the container and the compression: this is a zstd-compressed tar.
const built = await createArchive(
'release.tar.zst',
['dist', { path: 'BUILD.txt', data: `commit ${commit}\n` }],
{
filter: (entry) => !entry.path.endsWith('.map'),
compressionOptions: { preset: 'best' },
}
);
console.log(`${built.entries.length} entries, ${built.bytesWritten} bytes`);
// Listing reads the archive without unpacking it.
const entries = await listArchive('release.tar.zst');
const total = entries.reduce((bytes, entry) => bytes + entry.size, 0);
// Extraction never overwrites unless asked, and refuses any entry that would
// escape the destination.
const restored = await extractArchive('release.tar.zst', 'deploy');
// A zip can be opened for random access instead, one entry at a time.
const zip = await ZipArchive.open('release.zip');
try {
const source = (await zip.read('dist/app.js')).toString('utf8');
} finally {
await zip.close();
}createArchive and extractArchive both report what they did — entry list,
bytes in and out, elapsed time — so a build script can log the numbers without
stat-ing anything afterwards.
detect(buffer), detectFile(path), detectFromPath(path), extensionFor(algorithm),
algorithmForExtension(ext), levelRange(algorithm), isAlgorithm(value).
| Option | Default | Notes |
|---|---|---|
algorithm |
'gzip' |
One of the five supported algorithms. |
preset |
'balanced' |
'fastest' | 'fast' | 'balanced' | 'best', portable across algorithms. |
level |
— | Native level; takes precedence over the preset. |
sizeHint |
actual size | Improves brotli and zstd. Set automatically for buffers and files. |
chunkSize |
zlib | Size of the internal blocks. |
signal |
— | AbortSignal. |
native |
— | Raw node:zlib options, applied last. |
Presets map to each algorithm's native level:
| Preset | gzip / deflate (0-9) | brotli (0-11) | zstd (1-22) |
|---|---|---|---|
fastest |
1 | 0 | 1 |
fast |
3 | 2 | 3 |
balanced |
6 | 5 | 9 |
best |
9 | 11 | 19 |
| Option | Default | Notes |
|---|---|---|
algorithm |
'auto' |
'auto' reads the format from the magic bytes. |
fallbackAlgorithm |
— | Used when detection is inconclusive. |
maxOutputSize |
— | Output ceiling in bytes: the zip-bomb guard. |
passthroughUncompressed |
false |
With 'auto', pass plain data through instead of failing. |
chunkSize |
zlib | |
signal |
— | AbortSignal. |
native |
— | Raw node:zlib options. |
overwrite (default false), createDestinationDir (default false),
onProgress(progress).
Each entry can come from disk or from memory:
await createArchive('delivery.zip', [
'dist', // file or directory, recursive
{ path: 'README.txt', data: text }, // in-memory content
{ path: 'bin/app', source: '/tmp/build/app' }, // file on disk, renamed
{ path: 'cache', type: 'directory' }, // empty directory
{ path: 'latest', type: 'symlink', linkPath: 'v2' },
]);For plain strings the path stored in the archive is relative to the directory containing
the source ('dist' yields dist/...); root picks a different base. filter drops
entries, onEntry reports them one by one.
| Destination | Result |
|---|---|
.zip |
zip, every entry deflated (method: 'store' or 'zstd' to change it) |
.tar |
uncompressed tar |
.tar.gz, .tgz |
tar + gzip |
.tar.br, .tar.zst, .tar.zz |
tar + brotli / zstd / deflate |
compression and compressionOptions override what the file name implies:
await createArchive('backup.tar.gz', ['data'], {
compression: 'zstd',
compressionOptions: { preset: 'best' },
});When reading, the format is detected from the content (the PK signature, the ustar
magic, the outer compression). Only .tar.br relies on the extension, because brotli has
no magic bytes.
const result = await extractArchive('release.tar.zst', 'target/', {
strip: 1, // drop the first path component
filter: (entry) => entry.path.endsWith('.js'),
overwrite: true,
onEntry: (entry) => console.log(entry.path),
});Defences that are always on, with nothing to opt into:
- absolute paths and paths containing
..are rejected withERR_UNSAFE_ENTRY_PATH(zip slip); - symbolic links that would point outside the destination are not created
(
symlinks: 'error'turns that into an error,'skip'ignores links entirely); - in zip, CRC-32 and size are verified on every entry read.
Extraction does not overwrite by default: an existing file raises
ERR_DESTINATION_EXISTS.
ZipArchive reads the central directory and reaches individual entries without touching
the rest of the archive:
const zip = await ZipArchive.open('package.zip');
try {
const manifest = JSON.parse((await zip.read('package.json')).toString('utf8'));
await pipeline(await zip.stream('dist/app.js'), createWriteStream('app.js'));
} finally {
await zip.close();
}It also accepts a zip already in memory: ZipArchive.open(buffer).
createTarStream and readTarEntries work on streams, never touching the disk:
await pipeline(
createTarStream(['dist']),
createCompressStream({ algorithm: 'zstd' }),
response,
);
for await (const entry of readTarEntries('backup.tar.gz')) {
if (entry.path.endsWith('.json')) console.log(await streamToBuffer(entry.body));
// entries whose body is not consumed are skipped automatically
}The archives produced here are read by GNU tar, by libarchive (the tar shipped with
Windows and macOS) and by Windows Explorer; conversely, archives produced by those tools
are read back, including tars using the GNU extensions (L/K) or PAX headers, and zips
with or without ZIP64. Not supported: encrypted zips, multi-volume archives, and zip
compression methods other than store, deflate and zstd.
The package ships a node-compressor command:
npx node-compressor --help| Command | Purpose |
|---|---|
compress <file...> |
Compress files |
decompress <file...> |
Decompress files |
pack <archive> <sources...> |
Create a tar or zip archive |
unpack <archive> [directory] |
Extract an archive |
list <archive> |
List an archive |
info <file...> |
Show format, algorithm and size |
install-skill [directory] |
Copy the bundled Claude skill into a project |
node-compressor compress -a zstd -p best dump.sqlnode-compressor pack release.tar.zst dist README.md --exclude '*.map'node-compressor unpack release.tar.zst ./target --strip 1- reads from stdin and writes to stdout, so the commands compose:
cat dump.sql | node-compressor compress -a brotli - > dump.sql.brEvery command accepts --json for machine-readable output and --quiet to silence
progress. Exit codes: 0 success, 1 runtime failure, 2 usage error.
Every error is a CompressionError carrying a stable code; the original zlib error
stays in cause.
import { isCompressionError } from '@open-rlb/node-compressor';
try {
await decompressFile('archive.gz');
} catch (error) {
if (isCompressionError(error) && error.code === 'ERR_DESTINATION_EXISTS') { /* ... */ }
}Codes: ERR_UNKNOWN_ALGORITHM, ERR_DETECTION_FAILED, ERR_INVALID_LEVEL,
ERR_INVALID_INPUT, ERR_DESTINATION_EXISTS, ERR_DESTINATION_REQUIRED,
ERR_COMPRESSION_FAILED, ERR_DECOMPRESSION_FAILED, ERR_ARCHIVE_INVALID,
ERR_ARCHIVE_UNSUPPORTED, ERR_UNSAFE_ENTRY_PATH, ERR_ENTRY_NOT_FOUND,
ERR_CHECKSUM_MISMATCH.
HTTP response compressed per Accept-Encoding
import { createCompressStream } from '@open-rlb/node-compressor';
import { pipeline } from 'node:stream/promises';
const accepted = request.headers['accept-encoding'] ?? '';
const algorithm = accepted.includes('zstd') ? 'zstd' : accepted.includes('br') ? 'brotli' : 'gzip';
const encoding = algorithm === 'brotli' ? 'br' : algorithm;
response.setHeader('Content-Encoding', encoding);
await pipeline(source, createCompressStream({ algorithm, preset: 'fast' }), response);Backup with progress and cancellation
const controller = new AbortController();
const result = await compressFile('backup.tar', 'backup.tar.zst', {
preset: 'best',
overwrite: true,
signal: controller.signal,
onProgress: ({ ratio }) => process.stdout.write(`\r${Math.round((ratio ?? 0) * 100)}%`),
});
console.log(`\n${result.bytesRead} -> ${result.bytesWritten} bytes in ${result.durationMs | 0} ms`);Defensive decompression of untrusted input
const data = await decompress(payload, { maxOutputSize: 10 * 1024 * 1024 });- Atomic writes: file operations and archive creation write to a temporary file in the same directory and rename only once the pipeline completes; on failure the temporary file is removed and the destination is left untouched.
- Backpressure: no stream API buffers the whole content in memory; automatic detection buffers only the first 4 bytes.
- zstd
pledgedSrcSizeis set only when the input size is certain (data already in memory), never for streams or files, where a mismatch would fail the compression. - tar: ustar format, with PAX headers generated automatically for paths beyond 100
bytes and base-256 fields for files beyond 8 GiB. Reading additionally accepts the GNU
L/Kextensions and global PAX records. - zip: every entry uses the data descriptor (bit 3), which keeps writing fully streamed without knowing CRC and sizes upfront. ZIP64 kicks in on its own past 4 GiB per entry, 4 GiB of offset, or 65,535 entries. POSIX permissions travel in the external attributes, with a UNIX "version made by".
The package ships a Claude Code skill, so an agent working in a project that depends on this library knows the API without being handed it:
| File | Contents |
|---|---|
SKILL.md |
When to reach for the library, which layer fits the task, and the behaviour that surprises people — nothing overwrites by default, brotli is undetectable, untrusted input needs maxOutputSize. |
reference.md |
The full API surface: every function, every option, the error codes, the CLI grammar. |
It lands in the consuming project at .claude/skills/node-compressor/.
The copy happens on npm install through a postinstall hook. npm 11 and
later block dependency install scripts by default, so on those versions either
approve the package once:
npm install-scripts approve @open-rlb/node-compressoror add the policy to your package.json:
{
"allowScripts": {
"@open-rlb/node-compressor": true
}
}Otherwise — or any time you want to refresh the skill after an upgrade — install it explicitly:
npx node-compressor install-skillThe command takes an optional target directory, replaces any previous copy, and
accepts --json. Set NODE_COMPRESSOR_SKIP_SKILL=1 to disable the automatic
install. The hook never writes outside the project that triggered the install,
never touches a directory without a package.json, and never fails an install.
The source of truth is skills/node-compressor/ in
this repository. npm run build copies that folder into dist/, which is the
root of the published package, so edits ship with the next release. Change those
files rather than the copy under a consumer's .claude/, which is replaced on
every install.
npm testnpm run buildTests run straight off the TypeScript sources using Node's native type stripping, with no build step in between.