zip
Stream many stored objects as one ZIP archive, store archives back as objects, and extract archives into individual keys - no native dependencies.
The built-in zip() plugin bundles stored objects into ZIP archives — and back out of them — entirely through the Files instance. It’s an extend-only plugin: it intercepts nothing and adds three methods.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { zip } from "files-sdk/zip";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [zip()],
});
// Stream a folder as one download — pipe it straight into a Response:
return new Response(files.zip({ prefix: "reports/2026/" }), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": 'attachment; filename="reports.zip"',
},
});
Archives are standard, classic ZIP: every mainstream tool opens them. Writing (record assembly and deflate) is handled by fflate, a small pure-JS library, so the plugin has no native dependencies and works on any adapter.
The three methods
| Method | What it does |
|---|---|
zip(selection, options?) |
Returns a ReadableStream<Uint8Array> of the archive. Entries download lazily, one at a time, as it’s read. |
zipTo(key, selection, options?) |
Builds the same archive and stores it at key (as application/zip). Returns the UploadResult. |
unzip(key, options?) |
Extracts a stored archive: each file entry becomes an object. Returns one UploadResult per extracted entry. |
A selection is either an explicit array of keys or { prefix } (resolved with listAll, so it spans pages — {} selects the whole bucket):
await files.zipTo("exports/all.zip", ["a.csv", "b.csv"]);
await files.zipTo("exports/docs.zip", { prefix: "docs/" });
const entries = await files.unzip("incoming/batch.zip", { into: "imported/" });
// → [{ key: "imported/data.csv", ... }, ...]
Options
zip / zipTo
| Option | Default | What it does |
|---|---|---|
method |
"deflate" |
How entry bodies are stored. "store" writes bytes verbatim — right for already-compressed sources (JPEG, video). |
name |
the key | Derive an entry’s archive path from its key — strip a prefix, flatten folders. Duplicate resulting names fail closed. |
files.zip(
{ prefix: "exports/" },
{
method: "store", // sources are already-compressed media
name: (key) => key.slice("exports/".length),
}
);
unzip
| Option | Default | What it does |
|---|---|---|
into |
"" |
Key prefix extracted entries land under (a trailing / is added when missing). |
filter |
— | (name) => boolean — keep only matching entries, judged by their archive path. |
maxEntries |
10_000 |
Refuse an archive whose central directory lists more entries than this, before extracting anything. |
maxEntrySize |
512 MiB | Refuse any entry whose declared uncompressed size exceeds this many bytes. Inflation is capped at the declared size too. |
maxTotalSize |
1 GiB | Refuse the archive once the running total of declared uncompressed sizes exceeds this. Entries uploaded before the limit is hit stay uploaded. |
Extracted entries get a content type inferred from their extension; directory entries are skipped. The three limits guard against zip bombs — raise them deliberately when you trust the archive.
Composition
Everything goes through the fully-wrapped instance, so zip() composes with the rest of the pipeline: with encryption() installed, zipped entries are read as plaintext, and an archive stored via zipTo is encrypted at rest. Because there’s no wrap, the plugin’s position in the array doesn’t matter.
plugins: [zip(), compression(), encryption(key)];
Things to keep in mind
zip()streams;unzip()buffers. Writing needs only one entry in flight at a time, so archiving many objects runs with flat memory and an unconsumed or cancelled stream does no further work. Reading needs the central directory at the end of the file, sounzipdownloads the whole archive into memory first.- No ZIP64. Archives are classic ZIP: at most 65,534 entries (65,535 is the ZIP64 marker) and under 4 GiB per entry / per archive. Limits fail closed — a clear error, never a silently corrupt archive — and reading a ZIP64 archive throws too.
- Entry names are validated on both sides. Writing rejects duplicate names,
..segments, backslashes, and absolute paths; extraction rejects the same (the classic zip-slip escape), and refuses encrypted entries and unknown compression methods rather than guessing. Extracted data is verified against each entry’s recorded CRC-32 and size. "store"is for already-compressed sources. The default"deflate"shrinks text well, but JPEGs, videos, and encrypted-at-rest objects read back as high-entropy bytes —method: "store"skips the wasted CPU.- Selection errors surface on the stream.
zip()returns its stream synchronously; an unsafe or duplicate name rejects the first read, and a missing key or an oversized entry rejects the read that reaches it (andzipTo/ the consumingResponse).