runtime:archive
Read and write tar, tar.gz and zip (DECISIONS D174). Bun.Archive's shape: new Archive(input) builds from a plain object of entries or opens bytes, a Blob or a runtime:fs file(); bytes()/blob() return the archive, entries() exposes metadata with lazy entry reads, files() returns Map<string, File>, and extract() writes it out.
Bytes in and bytes out need nothing. A runtime:fs file() source needs FileRead; extract() and Archive.write() need FileWrite, with the destination as the capability target. A file source keeps its open handle, but it is not a snapshot: in-place writes before or during extraction are visible. Status: Available.
Import
import { Archive, ArchiveEntry } from "runtime:archive"; import { file } from "runtime:fs";
new Archive(input, options?)
input is one of:
a plain object whose keys are entry paths and whose values are a string (UTF-8), a
Uint8Array/ArrayBuffer/view, aBloborFile,nullfor a directory (the key must end with/), or{ data, lastModified }wheredatais any of the former andlastModifiedis epoch milliseconds or aDate;a
Uint8Array,ArrayBuffer, view orBlobholding an archive;a
runtime:fsfile().
A path string is a TypeError: pass file(path). A SharedArrayBuffer is refused. Bytes — a source, or an entry's data — are copied when the Archive is constructed, so changing the buffer afterwards does not change the archive. Opening detects the format from the leading bytes — gzip, zip or tar — and never from a name or extension.
For build entries, Blob and File mean the web APIs. A runtime:fs file() handle is not a build entry value; use await file(path).bytes() to add its contents. This reads the whole input file into memory.
| Option | Default | Effect |
|---|---|---|
format | "tar" | "tar" or "zip" when building. Ignored when opening. |
compress | "none" | "gzip" for tar, "deflate" for zip, or "none". A combination the format cannot carry is a TypeError. |
level | 6 | Compression level with "gzip" or "deflate": 1 is fastest, 9 smallest. Outside 1–9 is a RangeError; with compress: "none" it is a TypeError. |
maxEntries | 100000 | Entry-count cap, on build and read. For zip the count is read from the end record before the central directory is parsed. Over it is RangeError with ERR_ARCHIVE_TOO_LARGE. |
maxBytes | 1073741824 (1 GiB) | Cap on uncompressed bytes, on build and read, checked against declared sizes before allocation and against bytes actually decompressed. Over it is RangeError with ERR_ARCHIVE_TOO_LARGE. |
Builds are deterministic: entries are sorted by path, ownership is 0:0, modes are 0644 for files and 0755 for directories, and the timestamp is 0 unless an entry supplied lastModified (zip clamps to 1980–2107). The same input produces the same bytes.
import { Archive } from "runtime:archive"; import { file } from "runtime:fs"; const entries = { "hello.txt": "Hello, world!", "assets/logo.png": await file("assets/logo.png").bytes(), "empty/": null, }; await new Archive(entries).bytes(); // tar await new Archive(entries, { format: "zip" }).bytes(); await new Archive(entries, { compress: "gzip" }).bytes(); // tar.gz
Methods
| Method | Resolves to |
|---|---|
bytes() | A copy of the archive's bytes (Uint8Array). An opened archive returns its source bytes unchanged. |
blob() | A Blob whose type is application/x-tar, application/gzip or application/zip, from the format in the bytes. |
entries() | An array of ArchiveEntry, in archive order. A snapshot: use the async iterator to stream it. |
[Symbol.asyncIterator] | An AsyncIterable<ArchiveEntry> over the same metadata, parsed one entry at a time. |
files(filter?) | A Map<string, File> of the file entries (directories skipped) that filter takes — see Filters. |
extract(directory, options?) | Writes the entries under directory (a path string or file()) and resolves to { files, directories } written. options.filter writes only the entries it takes (Filters); the whole archive is still validated. options may also set maxEntries / maxBytes; a limit it leaves out keeps the Archive's. Needs FileWrite. |
Archive.write(destination, input, options?) | Builds input as above and writes it to destination (a path string or file()), resolving to the byte count. Needs FileWrite. |
for await (const entry of archive) streams metadata instead of collecting an array, so the guest holds only the current entry. Per-entry validation and the entry limits still apply, but the cross-entry checks run as entries arrive: a duplicate or file/directory collision later in the archive rejects the loop after earlier entries were yielded. A zip parser still holds its central directory, bounded by maxEntries.
An ArchiveEntry has path, size, directory and lastModified (epoch milliseconds, or null), plus lazy bytes(), text(), blob() and stream(). Reading an entry does not materialize the other entries: stream() yields bounded chunks, files(filter) reads only the entries the filter selected, and a file() source is scanned through the filesystem rather than loaded whole. (A tar.gz entry still has to decompress through its predecessors to reach its data; those bytes are discarded as they are skipped.)
import { Archive } from "runtime:archive"; import { file } from "runtime:fs"; const opened = new Archive(file("bundle.tar.gz")); for (const entry of await opened.entries()) { if (!entry.directory) console.log(entry.path, entry.size); } const text = await (await opened.entries())[0].text(); const images = await opened.files(/^assets\/.*\.png$/); await opened.extract("public"); // creates public/ as needed await Archive.write(file("bundle.zip"), { "hello.txt": "Hello" }, { format: "zip" });
Filters
files() and extract({ filter }) take the same filter:
A glob (
"src/**") or a list of globs (["**/*.ts", "!**/*.test.ts"]), matched against the entry path with the rules ofruntime:fs'sGlob:*does not cross/,**does. In a list, a pattern starting with!excludes: an entry is taken when it matches a pattern without!and none with it, and a list of only!patterns takes everything else.A
RegExp, tested against the path.A function
(path) => boolean.
An invalid glob, or a list with a non-string in it, is a TypeError.
Safety
Every entry path is validated before that entry is returned or a file is written, and the eager listings finish the layout check before returning. A path must be relative, slash-separated, valid UTF-8, with no empty, . or .. component, no backslash and no drive letter. Tar symlinks and hard links, zip symlinks, special tar types (character/block/FIFO/sparse/global PAX) and encrypted zip entries are refused, as is zip compression other than Stored and Deflated. Duplicate entries, file/directory collisions, and names that would collide on a case-insensitive filesystem (Windows, macOS) are refused. On Windows, device names (NUL, COM1.txt), names with : or another character Windows forbids, and names ending in a dot or space are refused too. An archive is never sanitized into a different one. extract() checks the destination, parents and output paths for existing symlinks, then decodes and validates the whole archive into a temporary directory beneath the destination before moving staged files into place. Nothing beneath the destination is followed if it is a link, including one that appears during extraction. A corrupt or unsafe archive therefore does not partially change destination files. Staging uses temporary disk space up to the uncompressed output size. An I/O error while moving staged files into place can leave partial output.
Errors
| Failure | Thrown |
|---|---|
| Not a well-formed archive of its detected format | Error with code ERR_ARCHIVE_DECODE_FAILED |
| A format or compression method the runtime does not read | Error with code ERR_ARCHIVE_FORMAT_UNSUPPORTED |
| An encrypted zip | Error with code ERR_ARCHIVE_ENCRYPTED |
| An unsafe path, a link, a special entry, a duplicate or a collision | Error with code ERR_ARCHIVE_UNSAFE_PATH |
Over maxEntries or maxBytes | RangeError with code ERR_ARCHIVE_TOO_LARGE |
entry.bytes() for a name not in the archive | Error with code ERR_NOT_FOUND |
| A bad argument or option | TypeError |
Reading or writing a file(), or extracting | The runtime:fs error (NotAllowedError, ERR_NOT_FOUND, …) |
See also
Archives guide — bundling downloads and unpacking uploads.
Internals: archives — what is validated, what is held in memory, and why extraction stages before writing.