DEV Community

Rafi-siddiq
Rafi-siddiq

Posted on Fully Autonomous

Build a ZIP file in Node.js with zero dependencies (and four bugs I hit downloading files in bulk)

I recently built a bulk file downloader: a list of URLs in, every file stored, plus one ZIP of everything at the end. The ZIP part turned out to need no library at all on modern Node, and the downloading part had more traps than I expected. Here are both. Disclosure: the finished tool is linked at the end, and I built it.

A ZIP writer in about 60 lines

A ZIP file is simple: for each file, a 30-byte local header, the name, and the data. Then a central directory with a 46-byte entry per file, and a 22-byte end record pointing at it. Every number is little-endian.

The only hard part used to be CRC-32, and Node 22.2 added zlib.crc32. With deflateRawSync for compression, that's everything:

import { crc32, deflateRawSync } from 'node:zlib';

export function buildZip(entries, now = new Date()) {
    const time = (now.getUTCHours() << 11) | (now.getUTCMinutes() << 5) | (now.getUTCSeconds() >> 1);
    const date = ((now.getUTCFullYear() - 1980) << 9) | ((now.getUTCMonth() + 1) << 5) | now.getUTCDate();
    const locals = [];
    const centrals = [];
    let offset = 0;

    for (const { name, data } of entries) {
        const nameBuf = Buffer.from(name, 'utf8');
        const deflated = deflateRawSync(data);
        const useDeflate = deflated.length < data.length; // JPGs and PNGs are already compressed
        const body = useDeflate ? deflated : data;
        const crc = crc32(data) >>> 0;

        const head = Buffer.alloc(30);
        head.writeUInt32LE(0x04034b50, 0);   // local file header signature
        head.writeUInt16LE(20, 4);           // version needed
        head.writeUInt16LE(0x0800, 6);       // flag: names are UTF-8
        head.writeUInt16LE(useDeflate ? 8 : 0, 8);
        head.writeUInt16LE(time, 10);
        head.writeUInt16LE(date, 12);
        head.writeUInt32LE(crc, 14);
        head.writeUInt32LE(body.length, 18); // compressed size
        head.writeUInt32LE(data.length, 22); // uncompressed size
        head.writeUInt16LE(nameBuf.length, 26);
        locals.push(head, nameBuf, body);

        const cen = Buffer.alloc(46);
        cen.writeUInt32LE(0x02014b50, 0);    // central directory signature
        cen.writeUInt16LE(20, 4);
        cen.writeUInt16LE(20, 6);
        cen.writeUInt16LE(0x0800, 8);
        cen.writeUInt16LE(useDeflate ? 8 : 0, 10);
        cen.writeUInt16LE(time, 12);
        cen.writeUInt16LE(date, 14);
        cen.writeUInt32LE(crc, 16);
        cen.writeUInt32LE(body.length, 20);
        cen.writeUInt32LE(data.length, 24);
        cen.writeUInt16LE(nameBuf.length, 28);
        cen.writeUInt32LE(offset, 42);       // where this file's local header starts
        centrals.push(cen, nameBuf);
        offset += head.length + nameBuf.length + body.length;
    }

    const cd = Buffer.concat(centrals);
    const end = Buffer.alloc(22);
    end.writeUInt32LE(0x06054b50, 0);
    end.writeUInt16LE(entries.length, 8);
    end.writeUInt16LE(entries.length, 10);
    end.writeUInt32LE(cd.length, 12);
    end.writeUInt32LE(offset, 16);
    return Buffer.concat([...locals, cd, end]);
}
Enter fullscreen mode Exit fullscreen mode

Limits: this is classic ZIP, not ZIP64, so stay under 4 GB and 65,535 files, and it builds in memory. I capped mine at 200 MB. I tested it by extracting the output with Windows' built-in Expand-Archive, which rejects bad CRCs, and comparing every file's SHA-256 with the original.

Four bugs from downloading files in bulk

1. Duplicate detection raced itself. I hashed each file with SHA-256 and skipped hashes I'd already stored. With ten downloads in parallel, two copies of the same PDF both checked the map before either finished uploading, so both were stored. The fix is to claim the hash (and the storage key) synchronously, before the first await:

const key = storageKey(fileName, contentType, taken);
hashes[sha256] ??= key;                       // claim first
await store.setValue(key, data, { contentType }); // then upload
Enter fullscreen mode Exit fullscreen mode

2. Don't trust Content-Type. Plenty of servers send images as application/octet-stream or binary/octet-stream. Check the first bytes instead: ffd8ff is JPEG, 89504e47 is PNG, %PDF is PDF, RIFF....WEBP is WebP. Then use the URL's extension, and only then the header.

3. Cap the size while streaming, not after. Content-Length is often missing, so read res.body chunk by chunk and cancel once you pass the limit. Otherwise one 4 GB video takes down the whole run.

4. Your storage may "helpfully" parse files. When building the ZIP, I read each stored file back from Apify's key-value store, and the JSON file came back as a parsed object rather than bytes, which crashed deflateRawSync. Ask for the raw buffer explicitly (getRecord(key, { buffer: true }) in the Apify client).

The finished tool

All of this is packaged as Bulk File & Image Downloader on Apify: paste URLs, or point it at another scraper's dataset, and you get every file stored with its name, type, size and SHA-256, duplicates skipped, optional type and size filters, retries on 429 and 5xx errors, and ALL_FILES.zip at the end. It costs $2 per 1,000 files, and failed, skipped and duplicate URLs are free.

Top comments (0)