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

Leave a Reply

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