sdxc

Type to search, or start from one of these:

[ Everything else ]

@sdxc/zip

Streaming ZIP archive writer with CRC-32, storing every entry uncompressed

npm add @sdxc/zip
pnpm add @sdxc/zip
yarn add @sdxc/zip
bun add @sdxc/zip
Installs with
@sdxc/result

Streaming ZIP archive writer with CRC-32, storing every entry uncompressed.

Every entry is written stored (method 0), with its CRC-32 and sizes in the local header, so the archive opens in every unarchiver and streaming reader. Stored entries are larger than compressed ones: a text-heavy archive is three to four times the size a DEFLATE archive would be, while images, fonts and other already-compressed files cost nothing extra. Compression is planned and will arrive behind the same API.

Installation

npm add @sdxc/zip

Failures are values from @sdxc/result, which installs alongside this package and supplies isFailure and isSuccess. The writer needs only Web Streams and TextEncoder, so it runs in Workers, Bun, Node and the browser.

Usage

A Download Built From Strings And Bytes

import { Zip } from "@sdxc/zip";

let zip = new Zip();
zip.add("README.txt", "Exported on 2026-10-08\n");
zip.add("feeds.opml", opml);
zip.add("images/avatar.png", avatarBytes, { modified: new Date("2026-10-01T00:00:00Z") });

return new Response(zip.stream(), {
	headers: {
		"content-type": "application/zip",
		"content-disposition": `attachment; filename="export.zip"`,
	},
});

Entries Produced As Streams

Entries are written one after another in the order they were added. A stream entry is read whole when the archive reaches it, so memory holds one entry at a time.

import { isFailure } from "@sdxc/result";
import { Zip } from "@sdxc/zip";

let zip = new Zip();
for (let kind of ["summary", "daily", "incidents"]) {
	let added = zip.add(`reports/${kind}.csv`, reportStream(kind));
	if (isFailure(added)) return new Response(added.error.message, { status: 400 });
}
return new Response(zip.stream(), { headers: { "content-type": "application/zip" } });

The Whole Archive As Bytes

let bytes = await zip.bytes();
if (isFailure(bytes)) throw bytes.error;
await bucket.put(`exports/${userId}.zip`, bytes.data);

CRC-32 On Its Own

import { crc32 } from "@sdxc/zip";

crc32(new TextEncoder().encode("123456789")); // 0xcbf43926

let running = 0;
for (let chunk of chunks) running = crc32(chunk, running);

API

Zip

An archive being assembled. Entries keep the order they were added in, and the same entries with the same timestamps always produce the same bytes.

zip.add(path: string, source: ZipSource, options?: ZipEntryOptions): Result<void, ZipError>

Adds an entry from a string (written as UTF-8), a Uint8Array, or a ReadableStream<Uint8Array>. The name is checked immediately: an empty name, a leading /, a backslash, an empty, . or .. segment, a control character or a duplicate fails with invalid-entry, and the archive is left unchanged. The 65,536th entry fails with too-large.

OptionDefaultMeaning
modified1980-01-01The entry's timestamp, recorded in UTC with two-second precision
commentNoneA comment written in the central directory

A stream source is read once, when the archive reaches it, so an archive holding one is written once.

zip.stream(): ReadableStream<Uint8Array>

The archive as a stream, ready for a Response body. When a source stream errors, the output stream errors with a ZipError whose code is source-failed and whose cause is the source's error; a response already under way has no other channel. Cancelling the output cancels every stream source it has not reached.

zip.bytes(): Promise<Result<Uint8Array, ZipError>>

The whole archive in one buffer, or the error the stream failed with.

crc32(bytes: Uint8Array, previous?: number): number

The CRC-32 ZIP, PNG and gzip use. Pass the previous value to continue across chunks. The lookup table is built on the first call.

ZipError

Carries a code, the entry name it concerns when there is one, and the source's error as cause for source-failed.

CodeWhen
invalid-entryA name, timestamp (outside 1980–2107) or comment (over 64 KiB) ZIP cannot record
too-largeAn entry or the archive past 4 GiB, or more than 65,535 entries; ZIP64 is not written
source-failedA stream source errored or yielded something other than a Uint8Array while being read

Types

ZipSource is string | Uint8Array | ReadableStream<Uint8Array>; ZipEntryOptions holds modified and comment.

Pattern: Handling Every Failure

import { isFailure } from "@sdxc/result";
import { Zip } from "@sdxc/zip";

let zip = new Zip();
let added = zip.add(userSuppliedName, content);
if (isFailure(added)) {
	// invalid-entry: "../secrets.txt", a duplicate, a bad date
	// too-large: the 65,536th entry
	return new Response(added.error.message, { status: 400 });
}

let result = await zip.bytes();
if (isFailure(result) && result.error.code === "source-failed") {
	console.error("A source stream failed", result.error.entry, result.error.cause);
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.