zipnative
A safe, deterministic, streaming ZIP engine for modern apps — and for the agents that operate them.

Zero runtime dependencies. 100% TypeScript. One API across Node.js ≥ 22, browsers, Deno, Bun and Workers. Built for the archives that actually matter in 2026 — OOXML, EPUB, JAR/VSIX, and multi-gigabyte data drops that must never be buffered whole — under the same engineering doctrine as pdfnative.
Status: 1.0 — stable. The public API surface, the 39-code error vocabulary and the deterministic: true output bytes are frozen under semantic versioning — removals and byte changes are semver-major (the full promise is in SECURITY.md). Built up through read (v0.1), deterministic write (v0.2), incremental modification (v0.4), workers + forward streaming (v0.5), the resumable inflater (v0.6), the interop gate (v0.7), the frozen error codes (v0.8) and one-call verification (v0.9). Documentation: zipnative.dev (site sources in docs/, interactive playgrounds included).
Why zipnative?
Most ZIP libraries make you choose between speed, safety and capability. zipnative's positioning is different:
- Safe by default. Extraction refuses path traversal (zip-slip), symlink entries, duplicate names and decompression bombs unless you explicitly opt out. Every parser loop runs under a named, CWE-tagged, caller-configurable bound. Ambiguous archives (conflicting end-of-central-directory records, Zip64 field spoofing, overlapping entries) are rejected, not guessed at.
- Random access. Read one entry from a 4 GB archive without extracting — or even scanning — the rest. The central directory is parsed lazily; entry payloads are zero-copy subarrays.
- Streaming. Iterate entries and decompress through async iterables with bounded memory. Designed for serverless and Cloudflare Workers, not just long-lived servers.
- Deterministic. Reproducible-build mode with a written determinism contract: same inputs, same SHA-256 on every runtime via the pinned pure-TS deflate encoder. Canonical entry ordering, pinned timestamps, no environment leakage.
- Incremental modification. Replace, remove, add or rename entries and
save() without recompressing the untouched 99% of the archive — the append-only overlay model proven in pdfnative's PDF incremental updates. saveCompact() is the true-deletion path (removed content is otherwise still recoverable — documented loudly).
- Agent-pilotable. Every thrown error carries a stable machine-readable
err.code from a frozen 39-code vocabulary (v0.8+ — registry in docs/data/errors.json, guide in docs/guides/errors.md): branch on the code, never on message text. Plus verifyZip() — one call, a machine-readable verification report that never throws for archive problems (v0.9), remedy-bearing messages, a structured diagnostics channel, executable recipes, four documented production use cases, llms.txt, and a human-in-the-loop AI governance policy.
Comparison (honest version)
| Zero runtime dependencies | ✅ | ✅ | ❌ | ❌ | ❌ |
| Random access (1 entry without full parse) | ✅ | ❌ | ❌ | yauzl ✅ | ❌ |
| Streaming read + write | ✅ | partial (low-level) | ❌ (memory-bound) | read or write per lib | ❌ |
| Safe-extract defaults (slip/bomb/ambiguity) | ✅ | DIY | DIY | DIY | historical CVEs |
| Deterministic output (documented contract) | ✅ | DIY | ❌ | ❌ | ❌ |
| Modify in place, no recompression | ✅ | ❌ | rewrite-all | ❌ | partial |
| Browser + Node + Deno + Bun + Workers | ✅ | ✅ | ✅ | Node-only | Node-only |
| Raw deflate throughput | good (platform zlib) | best | slow | good | poor |
fflate keeps the raw-deflate-speed crown and we do not chase it: zipnative uses the platform's native codecs (node:zlib, CompressionStream) behind a pluggable seam, and wins on scenarios — random access, bounded-memory streaming, in-place updates — not drag races.
Installation
npm install zipnative
Quick start
import { openZip, extractZip } from 'zipnative';
const zip = openZip(bytes);
console.log(zip.entryCount);
for (const entry of zip.entries()) {
console.log(entry.name, entry.uncompressedSize);
}
const manifest = zip.readEntry('manifest.json');
for await (const chunk of zip.readEntryStream('video.mp4')) {
}
const files = extractZip(bytes, {
limits: { maxEntries: 10_000, maxTotalUncompressedSize: 1024 * 1024 * 1024 },
});
Creating archives (v0.2):
import { createZip } from 'zipnative';
const zip = createZip({
compression: { deterministic: true },
});
zip.add('manifest.json', JSON.stringify(manifest));
zip.add('assets/logo.png', logoBytes, { compression: { method: 'store' } });
zip.addDirectory('assets');
const bytes = zip.toBytes();
for await (const chunk of zip.stream({ chunkSize: 64 * 1024 })) {
}
zip.addStream('video.bin', chunkSource);
Modifying an existing archive (v0.4):
import { createZipModifier, openZip } from 'zipnative';
const modifier = createZipModifier(openZip(bytes));
modifier.replaceEntry('word/document.xml', newDocumentXml);
modifier.addEntry('docProps/custom.xml', customProps);
modifier.removeEntry('word/obsolete.xml');
const updated = modifier.save();
const compacted = modifier.saveCompact();
Parallel creation across worker threads (v0.5, zipnative/worker):
import { createParallelZip } from 'zipnative/worker';
const zip = createParallelZip();
zip.add('a.bin', bigBufferA);
zip.add('b.bin', bigBufferB);
const bytes = await zip.toBytes();
Reading an unseekable stream (v0.5 — pipes, uploads, serverless bodies; a web ReadableStream<Uint8Array> or any AsyncIterable<Uint8Array> is accepted since v0.9):
import { iterateZipEntries } from 'zipnative';
for await (const entry of iterateZipEntries(request.body)) {
console.log(entry.header.name, entry.header.uncompressedSize);
if (wanted(entry.header.name)) {
for await (const chunk of entry.data()) { }
} else if (entry.header.compressedSize > 0) {
await entry.skip();
}
}
Verifying an archive in one call (v0.9):
import { verifyZip } from 'zipnative';
const report = verifyZip(bytes);
if (!report.ok) {
console.log(report.error?.code, report.entries.filter((e) => !e.crcMatch));
}
Bundler notes for zipnative/worker: the worker script is resolved as new URL('./zip-worker.js', import.meta.url), which Vite and webpack 5 detect and bundle automatically. If your bundler cannot (or your CSP restricts worker sources), pass workerUrl explicitly — e.g. createParallelZip({ workerUrl: new URL('zip-worker.js', yourAssetBase) }) — pointing at a copy of the script served from your origin (locate it with import.meta.resolve('zipnative/worker/zip-worker.js') — a dedicated subpath export since 0.8). On runtimes without workers the same code runs entirely on the calling thread.
Everything public is exported from the two entry points — zipnative and zipnative/worker; if it is not exported there, it is private.
Security model
zipnative treats every archive as untrusted input. The guards, their defaults and their CWE mappings are documented in SECURITY.md. Highlights:
- decompression output capped per entry and in total, with a compression-ratio bound (CWE-400/409);
- path traversal rejected —
.. segments, absolute paths, drive letters, backslashes, NUL bytes, NTFS alternate data streams (CWE-22);
- symlink entries rejected by default (CWE-59);
- overlapping entries and central-directory/local-header disagreement rejected (parser-differential smuggling);
- Zip64 sentinel spoofing cross-checked; ambiguous EOCD placement refused;
- the engine never opens a socket, never touches the filesystem, and never evals.
Conformance
ZIP has no veraPDF — JHOVE never shipped a ZIP module, and no ISO/IEC 21320-1 validator existed. So zipnative ships both halves of the answer: the first open clause-by-clause ISO/IEC 21320-1:2015 conformance validator (npm run validate:zip — an independent raw parser, never the engine's own, checking the ISO-standardised ZIP profile the Library of Congress recognises), and an Archivematica-grade differential extraction matrix (npm run test:interop — six independent parsers extract and byte-compare zipnative's archives on Linux and Windows). Both gates are blocking in CI and re-run before every npm publish. Every archive zipnative writes conforms to the ISO profile; the full story — including why spec-valid ≠ safe — is in the conformance guide.
Known limitations
iterateZipEntries reads data-descriptor entries (flag bit 3) for plain deflate since v0.6 — including zipnative's own addStream() output and bsdtar-style archives. Still refused: store+bit3 (not self-delimiting), encrypted+bit3, and custom-codec+bit3; skip() on a bit-3 entry costs a full decompress-and-discard.
- Codec injection (
setDeflateImpl, registerCodec) on the main entry does not propagate to the zipnative/worker bundle (separate module state); parallel/sequential byte-identity is promised for the built-in tiers.
save() keeps every original byte: removed/replaced content remains recoverable in the output (use saveCompact() for true deletion); saveCompact() drops SFX prefixes; archives with duplicate entry names cannot be modified incrementally.
addStream entries beyond 4 GiB are rejected with a typed error — buffer via add(); the per-entry Zip64-streaming opt-in is designed (0.9 decision record in ROADMAP.md) and lands post-1.0. Buffered entries, entry counts and archive offsets are fully Zip64.
- Since v0.8.1, default extraction also refuses entries whose names are Windows reserved device names (
CON, NUL, COM1…LPT9 — CWE-67) or that collapse to nothing (., ./). Archives authored on POSIX systems containing files like aux.h therefore throw by default on every platform; pass rejectTraversal: false to skip such entries instead.
- Without
CompressionStream on the runtime (or when deterministic: true is requested), stream-entry compression buffers the entry before compressing — a documented memory caveat.
- Number fields above
Number.MAX_SAFE_INTEGER (≈ 9 PB) are rejected; the public API uses number, not bigint.
- Default deflate output is byte-stable per environment but not across zlib builds; pin
compression: { deterministic: true } for cross-runtime identity — the full contract lives in docs/guides/determinism.md.
What zipnative will NOT do
- No encryption, read or write, in 1.x. ZipCrypto is cryptographically broken (Biham–Kocher); writing it would be harm dressed as a feature. AES (AE-2) may come in a later major behind an injected crypto provider. Encrypted entries are detected (
entry.isEncrypted) and reads fail with a typed ZipUnsupportedError.
- No other archive formats. No 7z, RAR, tar, gzip; no zstd/bzip2/LZMA codecs built in (the codec registry is the extension point).
- No multi-disk/spanned archives — detected and refused cleanly.
- No filesystem I/O in the engine. Extraction returns data plus sanitized paths; writing files to disk is the CLI's job.
- No archive repair/salvage (rebuilding a central directory from local headers) — v1 errors cleanly instead of guessing.
- No network access, ever.
Ecosystem
zipnative | core engine (this repo) | active |
zipnative-cli | command-line tool, agent-grade JSON contract | planned |
zipnative-mcp | MCP server for AI agents | planned |
The core stays dependency-free by exiling every dependency-bearing integration to a satellite repo — the pdfnative ecosystem pattern.
Development
npm ci
npm run typecheck:all
npm run lint
npm run test:coverage
npm run build
npm run test:interop
Conventions live in AGENTS.md and .github/instructions/. Contributions welcome — see CONTRIBUTING.md.
Origin
zipnative is the second library in the native family, applying the architecture proven by pdfnative: zero dependencies, closure factories instead of classes, append-only incremental modification, a shared segment generator guaranteeing buffered and streaming output are byte-identical, determinism as a product feature, and CWE-tagged bounds on every untrusted-input loop.
License
MIT