// A minimal ZIP writer. // // Deliberately dependency-free: node's zlib already does deflate, and the // container around it is a few dozen lines of little-endian headers. Adding an // archiver dependency would mean regenerating package-lock.json for the Space's // `npm ci` (which hard-fails on any disagreement) — not worth it for this. // // Writes one local header + deflated body per entry, then the central directory. // No zip64: an export is markdown and screenshots, orders of magnitude below the // 4GB/65535-entry ceilings. import zlib from 'node:zlib' const CRC_TABLE = (() => { const table = new Int32Array(256) for (let i = 0; i < 256; i++) { let c = i for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 table[i] = c } return table })() function crc32(buf) { let c = -1 for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) return (c ^ -1) >>> 0 } // DOS time/date: seconds have 2-second resolution, years count from 1980 function dosTime(date) { const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f) const day = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f) return { time, day } } // entries: [{ name, data: Buffer|string }] — name uses forward slashes, a // trailing slash marks a directory entry (stored empty). export function makeZip(entries, { mtime = new Date() } = {}) { const { time, day } = dosTime(mtime) const chunks = [] const central = [] let offset = 0 for (const entry of entries) { const name = Buffer.from(entry.name, 'utf8') const isDir = entry.name.endsWith('/') const raw = isDir ? Buffer.alloc(0) : Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8') // store directories and empty files; deflate everything else const deflated = isDir || !raw.length ? raw : zlib.deflateRawSync(raw, { level: 9 }) // a "compressed" body larger than the source happens with incompressible // input (already-encoded PNGs): store it instead, which is always legal const stored = deflated.length >= raw.length const body = stored ? raw : deflated const method = stored ? 0 : 8 const crc = crc32(raw) const local = Buffer.alloc(30) local.writeUInt32LE(0x04034b50, 0) local.writeUInt16LE(20, 4) // version needed local.writeUInt16LE(0, 6) // flags local.writeUInt16LE(method, 8) local.writeUInt16LE(time, 10) local.writeUInt16LE(day, 12) local.writeUInt32LE(crc, 14) local.writeUInt32LE(body.length, 18) local.writeUInt32LE(raw.length, 22) local.writeUInt16LE(name.length, 26) local.writeUInt16LE(0, 28) // extra length chunks.push(local, name, body) const dir = Buffer.alloc(46) dir.writeUInt32LE(0x02014b50, 0) dir.writeUInt16LE(20, 4) // made by dir.writeUInt16LE(20, 6) // version needed dir.writeUInt16LE(0, 8) dir.writeUInt16LE(method, 10) dir.writeUInt16LE(time, 12) dir.writeUInt16LE(day, 14) dir.writeUInt32LE(crc, 16) dir.writeUInt32LE(body.length, 20) dir.writeUInt32LE(raw.length, 24) dir.writeUInt16LE(name.length, 28) dir.writeUInt16LE(0, 30) // extra dir.writeUInt16LE(0, 32) // comment dir.writeUInt16LE(0, 34) // disk dir.writeUInt16LE(0, 36) // internal attrs dir.writeUInt32LE(isDir ? 0x41ed0010 : 0x81a40000, 38) // unix mode + dir flag dir.writeUInt32LE(offset, 42) central.push(dir, name) offset += local.length + name.length + body.length } const centralBuf = Buffer.concat(central) const end = Buffer.alloc(22) end.writeUInt32LE(0x06054b50, 0) end.writeUInt16LE(0, 4) // this disk end.writeUInt16LE(0, 6) // disk with central dir end.writeUInt16LE(entries.length, 8) end.writeUInt16LE(entries.length, 10) end.writeUInt32LE(centralBuf.length, 12) end.writeUInt32LE(offset, 16) end.writeUInt16LE(0, 20) // comment length return Buffer.concat([...chunks, centralBuf, end]) }