Zip R2 Objects in Memory with Cloudflare Workers

I have a list of files stored in R2 and I want to serve them bundled as a Zip file to my user, but I want to do this through Cloudflare Workers and all in memory so it never hits even temporary storage.

It seems like a super basic thing you’d want to do with Cloudflare Workers when you store files in R2, but I’ve googled far and wide and couldn’t find an example of this anywhere.

So here’s my take on this, after much trial and error:

import { z } from 'zod';
import { ZipWriter, BlobReader, configure } from '@zip.js/zip.js';

// Without this, we get uncaught error due to Workers runtime bug
// See: https://github.com/gildas-lormeau/zip.js/discussions/514
configure({
	useCompressionStream: false,
});

// Payload schema that lists the files to be bundled, their filenames and the archive filename
const schema = z.object({
	archive_filename: z.string().endsWith('.zip'),
	files: z.array(
		z.object({
			r2key: z.string(),
			filename: z.string(),
		})
	),
});

export default {
	async fetch(request, env, ctx): Promise<Response> {
		const body = await request.json();

		const payload = schema.safeParse(body);
		if (!payload.success) {
			return new Response(JSON.stringify({ status: 'failed', error: payload.error }), {
				status: 409,
				headers: { 'Content-Type': 'application/json' },
			});
		}

		let { readable, writable } = new IdentityTransformStream();

		// Create a ZIP archive stream
		const archive = new ZipWriter(writable);

		// Store all the promises to catch any errors all together later
		let promises: Promise<any>[] = [];

		for (const somefile of payload.data.files) {
			const { r2key, filename } = somefile;

			// Fetch the file from the R2 bucket
			const fileContent = await env.STORAGE_BUCKET.get(r2key);
			if (!fileContent) {
				return new Response(`Object not found: ${r2key}`, { status: 404 });
			}

			// Add the file to the ZIP archive
			promises.push(archive.add(filename, new BlobReader(await fileContent.blob())));
		}

		promises.push(archive.close());

		Promise.all(promises).catch((err) => {
			console.log(err);
		});

		return new Response(readable, {
			headers: {
				'Content-Type': 'application/zip',
				'Content-Disposition': `attachment; filename="${payload.data.archive_filename}"`,
			},
		});
	},
} satisfies ExportedHandler<Env>;

You can just POST to it a JSON object with the output filename for the zip file and a list of R2 object keys with a filename to set for each.

Don’t forget to set your bindings in the wrangler.toml.


Comments

One response to “Zip R2 Objects in Memory with Cloudflare Workers”

  1. Thank you for this helpful script. Btw there are some improvements I’m thinking of:
    – You should wait for all the archive.add to be completed first before running archive.close, I think doing this will make the archive close prematurely.
    – Since CF workers only have 128MB or ram for each workers, so this would not work if some of the files are large.
    – Zipping file in CF workers will consume a lot of CPU time, so you should add a warning for people who read this to limit the CPU time of the worker, or use it responsibly, or it will burn their credit pretty fast.

Leave a Reply

Your email address will not be published. Required fields are marked *