cowrite-dev / server /zip.js
lvwerra's picture
lvwerra HF Staff
Download: this page or the project, as markdown or PDF
3b2fb14
Raw
History Blame Contribute Delete
4.08 kB
// 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])
}