Archives

runtime:archive reads and writes tar, tar.gz and zip. Bytes in and in-memory builds need no capability; reading files needs FileRead, and extraction needs FileWrite.

A downloadable bundle

Build the archive in memory and send it. Output is deterministic, so the same entries produce the same bytes — a cache key or an ETag can be computed from them.

JavaScript
import { Archive } from "runtime:archive";
import { file } from "runtime:fs";

const manifest = { name: "site-bundle", version: 1 };
const logo = await file("public/logo.png").bytes();
const entries = {
  "manifest.json": JSON.stringify(manifest),
  "readme.txt": "Generated by the archive guide.",
  "images/": null,
  "images/logo.png": logo,
};

const tar = await new Archive(entries).bytes();
const zip = await new Archive(entries, { format: "zip" }).bytes();
const compressed = await new Archive(entries, { compress: "gzip" }).bytes();

bytes() returns a copy; the archive keeps its own. blob() returns a Blob already typed application/zip, application/gzip or application/x-tar. Here logo is the image file's bytes. file(...).bytes() reads the whole image into memory; build entries accept bytes or Blob/File values, not a lazy runtime:fs file() handle.

Unpacking an upload

Never join an uploaded name to a directory yourself; extracting validates every path first.

JavaScript
import { Archive } from "runtime:archive";

async function handleUpload(request) {
  const upload = await request.bytes();
  try {
    const result = await new Archive(upload).extract("uploads");
    return Response.json(result);          // { files, directories }
  } catch (error) {
    if (error.code === "ERR_ARCHIVE_UNSAFE_PATH") {
      return new Response("archive contains an unsafe path", { status: 400 });
    }
    if (error.code === "ERR_ARCHIVE_TOO_LARGE") {
      return new Response("archive is too large", { status: 413 });
    }
    if (error.code === "ERR_ARCHIVE_FORMAT_UNSUPPORTED") {
      return new Response("unsupported archive", { status: 415 });
    }
    if (error.code === "ERR_ARCHIVE_DECODE_FAILED") {
      return new Response("not a valid archive", { status: 400 });
    }
    throw error;
  }
}

The archive is decoded and validated into a temporary directory before any staged file is moved into uploads. This catches bad zip CRCs and refuses unsafe paths, links, special entries, encrypted zips and unsupported compression methods before they can partially change destination files. The temporary directory uses disk space up to the uncompressed output size; an I/O failure while moving files into place can still leave partial output. request.bytes() also buffers the compressed upload in memory; maxBytes limits the uncompressed contents, not the size of that incoming buffer.

Lower the limits for uploads

The defaults are 100 000 entries and 1 GiB uncompressed. For a public upload endpoint, lower both:

JavaScript
async function unpackUpload(upload) {
  const archive = new Archive(upload, {
    maxEntries: 10_000,
    maxBytes: 64 * 1024 * 1024,
  });
  return archive.extract("uploads");
}

maxBytes is checked against what the archive declares before anything is allocated and against what the decompressor actually produces, so a small file that expands enormously is stopped.

Read without writing

entries() and files() do not extract. Use them to inspect an archive, pick one file, or serve it without touching disk:

JavaScript
async function inspectUpload(upload) {
  const opened = new Archive(upload);

  // Metadata only; entry bytes are read lazily.
  for (const entry of await opened.entries()) {
    console.log(entry.path, entry.size, entry.lastModified);
  }

  const readme = (await opened.entries()).find((entry) => entry.path === "README.md");
  if (readme) console.log(await readme.text());

  // A Map<string, File> containing selected file contents.
  const files = await opened.files((path) => path.endsWith(".json"));

  // Stream metadata instead of collecting every entry up front.
  for await (const entry of opened) {
    console.log(entry.path);
  }
}

From a file

A file() source is read through the filesystem provider, so FileRead, the root jail and --allow-read apply exactly as they do to file().bytes():

JavaScript
import { Archive } from "runtime:archive";
import { file } from "runtime:fs";

const opened = new Archive(file("release.tar.gz"));
await opened.extract("release");

const source = "console.log('release');";
await Archive.write(file("release.zip"), { "app.js": source }, { format: "zip" });

The file is not read into memory first. Metadata, files(filter) and extract() scan it through the jailed filesystem, and stream() yields decompressed chunks, so a tarball much larger than the agent's memory budget can be inspected or unpacked. The fd is opened once and kept for the archive's lifetime, so operations stay attached to the same file if its path is renamed or replaced. It is not a snapshot: in-place writes before or during extraction are visible. bytes() is the exception: it returns the whole archive, as its name says.

No path strings

new Archive("bundle.zip") is a TypeError. Pass file("bundle.zip"), or the bytes. A path argument would read a file without the provider, its jail or the FileRead grant.

Last updated on
Edit this page