Node.js e i formati di compressione
In questo articolo vediamo come usare questi formati in modo corretto, sia con le API sincrone e basate su Promise sia con gli stream, come scegliere i parametri di compressione, come integrare la compressione in un server HTTP e come proteggersi dagli attacchi basati su archivi malevoli.
Node.js include nel suo core il modulo zlib, che nonostante il nome non si limita alla libreria omonima: offre Gzip, Deflate, Deflate raw, Brotli e, nelle versioni più recenti, anche Zstandard. In questo articolo vediamo come usare questi formati in modo corretto, sia con le API sincrone e basate su Promise sia con gli stream, come scegliere i parametri di compressione, come integrare la compressione in un server HTTP e come proteggersi dagli attacchi basati su archivi malevoli.
Codec e contenitori: una distinzione necessaria
Prima di scrivere codice conviene chiarire una distinzione che genera spesso confusione. Un codec è un algoritmo che trasforma un flusso di byte in un flusso più compatto (DEFLATE, Brotli, Zstandard, LZMA, bzip2). Un contenitore o formato di archivio organizza invece più file, con i relativi metadati, in un unico file (ZIP, TAR). I due livelli si combinano: un file .tar.gz è un archivio TAR compresso con Gzip, mentre un file ZIP comprime ciascuna voce singolarmente, di solito con DEFLATE.
Anche all'interno della famiglia DEFLATE esistono tre varianti che differiscono solo per l'intestazione e il codice di controllo:
- Deflate raw: il flusso DEFLATE puro, senza intestazioni (RFC 1951). È quello usato all'interno dei file ZIP.
- Zlib: DEFLATE con un'intestazione di 2 byte e un checksum Adler-32 finale (RFC 1950). In HTTP corrisponde, in modo un po' ambiguo, a
Content-Encoding: deflate. - Gzip: DEFLATE con un'intestazione più ricca (nome file, timestamp, sistema operativo) e un CRC-32 finale (RFC 1952).
Il modulo zlib di Node.js espone una coppia di funzioni per ciascuna variante: deflateRaw/inflateRaw, deflate/inflate e gzip/gunzip. Esiste inoltre unzip, che, nonostante il nome, non c'entra con i file ZIP: decomprime automaticamente sia Gzip sia Zlib riconoscendo l'intestazione.
Compressione in memoria
Per dati di dimensioni contenute, come un payload JSON o un piccolo file di configurazione, le API in memoria sono la soluzione più semplice. Le varianti sincrone bloccano l'event loop e vanno quindi riservate agli script CLI o alla fase di avvio; in un server è preferibile la versione asincrona, che esegue il lavoro nel thread pool di libuv.
import { gzip, gunzip, deflate, inflate, brotliCompress, brotliDecompress } from 'node:zlib';
import { promisify } from 'node:util';
// Trasformiamo le API basate su callback in API basate su Promise
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
const deflateAsync = promisify(deflate);
const inflateAsync = promisify(inflate);
const brotliCompressAsync = promisify(brotliCompress);
const brotliDecompressAsync = promisify(brotliDecompress);
const payload = Buffer.from(JSON.stringify({
users: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `user-${i}`, active: i % 2 === 0 }))
}));
const gzipped = await gzipAsync(payload);
const zlibbed = await deflateAsync(payload);
const brotlied = await brotliCompressAsync(payload);
console.log(`Originale: ${payload.length} byte`);
console.log(`Gzip: ${gzipped.length} byte`);
console.log(`Zlib: ${zlibbed.length} byte`);
console.log(`Brotli: ${brotlied.length} byte`);
// Verifichiamo che la decompressione restituisca esattamente i dati originali
const restored = await gunzipAsync(gzipped);
console.log(restored.equals(payload)); // true
console.log((await inflateAsync(zlibbed)).equals(payload)); // true
console.log((await brotliDecompressAsync(brotlied)).equals(payload)); // true
Il valore restituito è sempre un Buffer. Se si lavora con stringhe, la conversione va fatta esplicitamente con Buffer.from(text, 'utf8') in ingresso e buffer.toString('utf8') in uscita: il codec opera sui byte, non sui caratteri.
Livelli e parametri di compressione
Ogni algoritmo offre un compromesso tra velocità e rapporto di compressione. Per la famiglia DEFLATE il parametro principale è level, che va da 0 (nessuna compressione) a 9 (massima compressione), con un default di 6 (zlib.constants.Z_DEFAULT_COMPRESSION vale -1 e corrisponde proprio a 6). Altri parametri utili sono memLevel, che controlla la memoria usata dallo stato interno, windowBits, che definisce la dimensione della finestra di ricerca, e strategy, che permette di ottimizzare per dati particolari come immagini già filtrate (Z_FILTERED) o sequenze ripetitive (Z_RLE).
Brotli usa invece un oggetto params indicizzato da costanti numeriche. Il parametro più importante è BROTLI_PARAM_QUALITY, da 0 a 11, con default 11: un valore pensato per asset statici compressi una volta sola, ma decisamente troppo lento per la compressione al volo delle risposte HTTP, dove valori tra 4 e 6 sono più sensati.
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
const input = readFileSync(process.argv[2] ?? 'package-lock.json');
// Misura dimensione e tempo per una funzione di compressione
function measure(label, fn) {
const start = performance.now();
const output = fn();
const elapsed = (performance.now() - start).toFixed(2);
const ratio = ((output.length / input.length) * 100).toFixed(1);
console.log(`${label.padEnd(14)} ${String(output.length).padStart(10)} byte ${ratio.padStart(5)}% ${elapsed} ms`);
}
for (const level of [1, 6, 9]) {
measure(`gzip -${level}`, () => gzipSync(input, { level }));
}
for (const quality of [4, 6, 11]) {
measure(`brotli q${quality}`, () => brotliCompressSync(input, {
params: {
[constants.BROTLI_PARAM_QUALITY]: quality,
// Suggerire il tipo di contenuto aiuta Brotli a scegliere il dizionario adatto
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
// Conoscere in anticipo la dimensione dell'input migliora le scelte interne
[constants.BROTLI_PARAM_SIZE_HINT]: input.length
}
}));
}
Eseguendo lo script su file di testo reali si osserva un andamento tipico: il passaggio dal livello 1 al livello 6 di Gzip riduce sensibilmente la dimensione, mentre il passaggio da 6 a 9 costa molto più tempo per un guadagno di pochi punti percentuali. Brotli alla qualità 11 produce i file più piccoli, ma con tempi di un ordine di grandezza superiori.
Zstandard
Zstandard (zstd), sviluppato da Meta, è diventato lo standard di fatto per la compressione generica grazie a un rapporto tra velocità e compressione molto favorevole e a una decompressione estremamente rapida. Node.js lo supporta nativamente a partire dalle versioni 23.8.0 e 22.15.0, con API ancora contrassegnate come sperimentali. L'interfaccia ricalca quella di Brotli: i parametri si passano tramite params usando le costanti ZSTD_c_*.
import { zstdCompressSync, zstdDecompressSync, constants } from 'node:zlib';
const data = Buffer.from('Lorem ipsum dolor sit amet, '.repeat(5000));
// Il livello di zstd va da 1 a 22 (con valori negativi per modalità ultra veloci), il default è 3
const compressed = zstdCompressSync(data, {
params: {
[constants.ZSTD_c_compressionLevel]: 10,
// Aggiunge un checksum al frame per rilevare corruzioni in fase di decompressione
[constants.ZSTD_c_checksumFlag]: 1
}
});
const restored = zstdDecompressSync(compressed);
console.log(`${data.length} -> ${compressed.length} byte`);
console.log(restored.equals(data)); // true
Se il codice deve girare anche su versioni di Node.js precedenti, conviene verificare la disponibilità della funzione prima di usarla, ad esempio con typeof zlib.zstdCompress === 'function', ripiegando su Gzip o su un pacchetto esterno.
Compressione con gli stream
Quando i dati sono grandi, come un dump di database o un file di log da diversi gigabyte, caricarli interamente in memoria non è un'opzione. Tutte le funzioni di zlib hanno una controparte basata su stream (createGzip, createGunzip, createBrotliCompress e così via) che produce un oggetto Transform. Il modo corretto di collegarli è pipeline del modulo node:stream/promises, che propaga gli errori e chiude correttamente tutti gli stream coinvolti, cosa che il semplice .pipe() non fa.
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip, createGunzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
// Comprime un file in formato Gzip senza caricarlo in memoria
export async function gzipFile(source, destination, level = 6) {
await pipeline(
createReadStream(source),
createGzip({ level }),
createWriteStream(destination)
);
}
// Decomprime un file Gzip
export async function gunzipFile(source, destination) {
await pipeline(
createReadStream(source),
createGunzip(),
createWriteStream(destination)
);
}
try {
await gzipFile('access.log', 'access.log.gz', 9);
await gunzipFile('access.log.gz', 'access.restored.log');
console.log('Operazione completata');
} catch (err) {
// Un file corrotto genera un errore con codice Z_DATA_ERROR o Z_BUF_ERROR
console.error(`Errore: ${err.code ?? ''} ${err.message}`);
}
Con pipeline è facile inserire anche trasformazioni intermedie. L'esempio seguente legge un log compresso, filtra le righe che contengono errori e scrive il risultato compresso in Brotli, il tutto con un consumo di memoria costante indipendentemente dalla dimensione del file.
import { createReadStream, createWriteStream } from 'node:fs';
import { createGunzip, createBrotliCompress, constants } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
// Transform che lascia passare solo le righe che contengono il testo indicato
function grep(needle) {
let remainder = '';
return new Transform({
transform(chunk, _encoding, callback) {
const lines = (remainder + chunk.toString('utf8')).split('\n');
// L'ultima riga potrebbe essere incompleta: la conserviamo per il prossimo chunk
remainder = lines.pop();
const matched = lines.filter((line) => line.includes(needle));
callback(null, matched.length ? matched.join('\n') + '\n' : undefined);
},
flush(callback) {
callback(null, remainder.includes(needle) ? remainder + '\n' : undefined);
}
});
}
await pipeline(
createReadStream('app.log.gz'),
createGunzip(),
grep(' ERROR '),
createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 6 } }),
createWriteStream('errors.log.br')
);
File Gzip con più membri
Il formato Gzip consente di concatenare più membri compressi in un unico file: è ciò che accade, ad esempio, quando si appendono log compressi uno dopo l'altro con cat a.gz b.gz > c.gz. Lo stream Gunzip di Node.js gestisce correttamente questa situazione e decomprime tutti i membri in sequenza, comportamento coerente con l'utility gunzip. È un dettaglio da tenere presente se si confrontano i risultati con altre librerie, alcune delle quali si fermano al primo membro.
Compressione delle risposte HTTP
Un caso d'uso molto comune è la compressione delle risposte di un server HTTP in base all'intestazione Accept-Encoding inviata dal client. In produzione questo compito viene spesso delegato al reverse proxy, ma è utile sapere come farlo direttamente in Node.js. Il punto delicato è la negoziazione: bisogna rispettare i valori di qualità (q) indicati dal client e aggiungere l'intestazione Vary: Accept-Encoding per non confondere le cache intermedie.
import { createServer } from 'node:http';
import { createReadStream, statSync } from 'node:fs';
import { createGzip, createBrotliCompress, constants } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
// Codifiche supportate in ordine di preferenza lato server
const SUPPORTED = ['br', 'gzip'];
// Analizza Accept-Encoding e restituisce la codifica migliore oppure null
function negotiateEncoding(header = '') {
const accepted = new Map();
for (const part of header.split(',')) {
const [name, ...params] = part.trim().toLowerCase().split(';');
if (!name) continue;
const qParam = params.find((p) => p.trim().startsWith('q='));
const q = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1;
accepted.set(name, Number.isNaN(q) ? 0 : q);
}
let best = null;
let bestQ = 0;
for (const encoding of SUPPORTED) {
const q = accepted.get(encoding) ?? accepted.get('*') ?? 0;
if (q > bestQ) {
best = encoding;
bestQ = q;
}
}
return best;
}
function createEncoder(encoding) {
if (encoding === 'br') {
return createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 5 } });
}
return createGzip({ level: 6 });
}
const server = createServer(async (req, res) => {
const file = './public/app.js';
const { size } = statSync(file);
const encoding = size > 1024 ? negotiateEncoding(req.headers['accept-encoding']) : null;
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
res.setHeader('Vary', 'Accept-Encoding');
try {
if (encoding) {
// Con la compressione al volo la dimensione finale non è nota: niente Content-Length
res.writeHead(200, { 'Content-Encoding': encoding });
await pipeline(createReadStream(file), createEncoder(encoding), res);
} else {
res.writeHead(200, { 'Content-Length': size });
await pipeline(createReadStream(file), res);
}
} catch (err) {
// Il client può chiudere la connessione a metà trasferimento
console.error(`Trasferimento interrotto: ${err.message}`);
}
});
server.listen(3000, () => console.log('Server in ascolto su http://localhost:3000'));
Per gli asset statici è molto più efficiente precomprimere i file in fase di build (ad esempio app.js.br e app.js.gz con la massima qualità) e servire direttamente la variante corretta, evitando di ricomprimere lo stesso contenuto a ogni richiesta. Comprimere file già compressi, come immagini JPEG o archivi ZIP, è invece inutile e spreca CPU: conviene limitare la compressione ai tipi MIME testuali.
Il flush e i flussi in tempo reale
Gli stream di compressione accumulano dati internamente prima di emettere output, perché l'algoritmo ha bisogno di contesto per trovare ripetizioni. Questo comportamento è un problema con i Server-Sent Events o con altri protocolli in cui ogni messaggio deve arrivare subito al client. Il metodo flush() forza l'emissione dei dati accumulati, al prezzo di un rapporto di compressione leggermente inferiore.
import { createServer } from 'node:http';
import { createGzip, constants } from 'node:zlib';
createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Content-Encoding': 'gzip',
'Cache-Control': 'no-cache'
});
const gzip = createGzip();
gzip.pipe(res);
const timer = setInterval(() => {
gzip.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
// Senza flush il client riceverebbe gli eventi a blocchi, con ritardo
gzip.flush(constants.Z_SYNC_FLUSH);
}, 1000);
req.on('close', () => {
clearInterval(timer);
gzip.end();
});
}).listen(3001);
Proteggersi dalle decompression bomb
Una decompression bomb è un input di pochi kilobyte che, una volta decompresso, occupa gigabyte di memoria o di disco. Qualsiasi servizio che decomprime dati provenienti dall'esterno, come un endpoint che accetta richieste con Content-Encoding: gzip, deve imporre un limite alla dimensione dell'output. Per le API in memoria Node.js offre l'opzione maxOutputLength, che fa fallire l'operazione con un RangeError quando il limite viene superato.
import { gzipSync, gunzipSync } from 'node:zlib';
// Cento megabyte di zeri si comprimono in circa cento kilobyte
const bomb = gzipSync(Buffer.alloc(1024 * 1024 * 100));
console.log(`Input compresso: ${bomb.length} byte`);
try {
gunzipSync(bomb, { maxOutputLength: 10 * 1024 * 1024 });
} catch (err) {
console.error(`Decompressione bloccata: ${err.code}`); // ERR_BUFFER_TOO_LARGE
}
Con gli stream il limite va invece applicato contando i byte in uscita e distruggendo la pipeline quando si supera la soglia. Una piccola Transform riutilizzabile risolve il problema in modo pulito.
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib';
import { createServer } from 'node:http';
// Transform che interrompe il flusso oltre una certa dimensione
function limitSize(maxBytes) {
let total = 0;
return new Transform({
transform(chunk, _encoding, callback) {
total += chunk.length;
if (total > maxBytes) {
const err = new Error(`Limite di ${maxBytes} byte superato`);
err.code = 'PAYLOAD_TOO_LARGE';
callback(err);
return;
}
callback(null, chunk);
}
});
}
createServer(async (req, res) => {
const chunks = [];
const source = req.headers['content-encoding'] === 'gzip'
? [req, createGunzip()]
: [req];
try {
await pipeline(
...source,
limitSize(1024 * 1024),
async function (stream) {
for await (const chunk of stream) chunks.push(chunk);
}
);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: Object.keys(body).length }));
} catch (err) {
const status = err.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400;
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(err.message);
}
}).listen(3002);
Archivi ZIP e TAR
Il core di Node.js non include un'implementazione dei formati ZIP o TAR, per cui è necessario ricorrere a pacchetti esterni. Le scelte più diffuse sono archiver per la creazione di archivi ZIP e TAR in streaming, yauzl per la lettura di ZIP e tar (mantenuto dal team di npm) per gli archivi TAR. Indipendentemente dalla libreria scelta, l'estrazione di un archivio richiede una verifica che spesso viene dimenticata: lo zip slip.
Un archivio malevolo può contenere voci con nomi come ../../etc/cron.d/job o percorsi assoluti. Estraendolo senza controlli, il file finirebbe fuori dalla directory di destinazione. La difesa consiste nel risolvere il percorso finale e verificare che resti all'interno della destinazione.
import { resolve, sep } from 'node:path';
// Restituisce il percorso assoluto sicuro oppure lancia un errore
export function safeJoin(destination, entryName) {
const root = resolve(destination);
const target = resolve(root, entryName);
if (target !== root && !target.startsWith(root + sep)) {
throw new Error(`Voce dell'archivio non valida: ${entryName}`);
}
return target;
}
console.log(safeJoin('/tmp/out', 'docs/readme.txt')); // /tmp/out/docs/readme.txt
try {
safeJoin('/tmp/out', '../../etc/passwd');
} catch (err) {
console.error(err.message);
}
Ecco come combinare archiver con questa funzione per creare e poi estrarre in sicurezza un archivio, usando yauzl per la lettura. Le dipendenze si installano con npm install archiver yauzl.
import archiver from 'archiver';
import yauzl from 'yauzl';
import { createWriteStream, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
import { safeJoin } from './safe-join.js';
// Crea un archivio ZIP a partire da una directory
export async function zipDirectory(sourceDir, zipPath) {
const output = createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
const done = pipeline(archive, output);
archive.directory(sourceDir, false);
await archive.finalize();
await done;
}
// Estrae un archivio ZIP verificando percorsi, numero di voci e dimensione totale
export async function unzipSafely(zipPath, destination, { maxEntries = 10000, maxTotalBytes = 1024 ** 3 } = {}) {
const zip = await promisify(yauzl.open)(zipPath, { lazyEntries: true });
const openReadStream = promisify(zip.openReadStream.bind(zip));
let entries = 0;
let totalBytes = 0;
await new Promise((resolvePromise, reject) => {
zip.on('error', reject);
zip.on('end', resolvePromise);
zip.on('entry', async (entry) => {
try {
if (++entries > maxEntries) throw new Error('Troppe voci nell\'archivio');
// La dimensione dichiarata può mentire: yauzl la verifica durante la lettura
totalBytes += entry.uncompressedSize;
if (totalBytes > maxTotalBytes) throw new Error('Archivio troppo grande una volta estratto');
const target = safeJoin(destination, entry.fileName);
if (entry.fileName.endsWith('/')) {
mkdirSync(target, { recursive: true });
} else {
mkdirSync(dirname(target), { recursive: true });
await pipeline(await openReadStream(entry), createWriteStream(target));
}
zip.readEntry();
} catch (err) {
zip.close();
reject(err);
}
});
zip.readEntry();
});
}
Scegliere il formato giusto
Non esiste un formato migliore in assoluto: la scelta dipende dal contesto. Alcune indicazioni pratiche:
- Gzip resta la scelta più compatibile: lo supportano tutti i browser, tutti i proxy e tutti i linguaggi. È il default ragionevole per lo scambio di dati con sistemi esterni.
- Brotli è ideale per gli asset web statici precompressi alla qualità massima e per le risposte HTTP compresse al volo a qualità medie.
- Zstandard eccelle nella compressione di log, backup, cache e messaggi tra servizi interni, dove si controllano entrambe le estremità e conta la velocità.
- ZIP è il formato da usare quando l'utente finale deve aprire l'archivio con gli strumenti del sistema operativo; TAR combinato con Gzip o Zstandard è preferibile quando bisogna preservare permessi e metadati Unix.
Conclusioni
Il modulo zlib copre in modo nativo tutti i codec più importanti, da Gzip a Zstandard, con API coerenti tra loro sia in memoria sia in streaming. Le regole da ricordare sono poche: usare le API asincrone o gli stream nei server, collegare gli stream con pipeline, scegliere il livello di compressione in base al contesto invece di usare sempre il massimo e, soprattutto, non fidarsi mai dei dati compressi che arrivano dall'esterno, limitando la dimensione dell'output e verificando i percorsi delle voci degli archivi.