Skip to content

Guide: Modern (Buildless)

101arrowz edited this page Feb 7, 2024 · 4 revisions

If you are only planning to support modern browsers, you can enjoy the benefits of ES Modules without build tooling. This guide will be a walkthrough for building a simple ZIP creation website. We'll start by creating an HTML file to display an <input> element, with which we will be receiving our files.

In index.html, add the following:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ZIP Creator</title>
</head>
<body>
  <!-- This will be where the user will input files -->
  <!-- Add the "webkitdirectory" attribute for folders -->
  <input type="file" multiple id="file-input">

  <!-- This will contain buttons to download the ZIPs -->
  <ul id="zip-download-list"></ul>

  <script type="module" src="index.js"></script>
</body>
</html>

Notice the <script type="module">. The type="module" specifies that we want to use ES Modules as part of this script. You'll see some people use .mjs for ES Modules scripts, but not all servers correctly set the MIME type to application/javascript, so it's better to use index.js over index.mjs.

We'll start the script file by importing fflate.

In index.js:

// Development optimized import
import * as fflate from 'https://cdn.skypack.dev/fflate';

This imports fflate from Skypack, a CDN designed for ES Modules. This import is not suitable for production because it may use any version of fflate, which could cause your application to break in the future. Moreover, the files it serves are unminified. Therefore, once you're ready to deploy your app, make sure to change this import to this:

// Replace x.y.z with your version, e.g. 0.7.2
import * as fflate from 'https://cdn.skypack.dev/fflate@x.y.z?min';

Since Skypack does not support tree shaking, importing everything at once is no different from importing just what you need; there is no difference in bundle size. Unfortunately, you'll often download more than you need for this reason, but the bundle size for the entire build is 28kB minified and 10kB minified + gzipped. Therefore, on average, you'll still only be downloading 10kB because of fflate. However, it's for this reason I highly recommend build tooling if possible; the bundle size reduces to 11kB minified or 5kB minified + gzipped.

You'll also need the excellent StreamSaver library because for some reason it's not possible to download a ReadableStream natively in the browser.

// Replace x.y.z with your version, e.g. 2.0.5
import streamSaver from 'https://cdn.skypack.dev/streamsaver@x.y.z?min';

Now, we will add our file input handler.

const fileInput = document.getElementById('file-input');
const zipDownloadList = document.getElementById('zip-download-list');
fileInput.addEventListener('input', event => {
  const fileArray = Array.from(fileInput.files);
  handleFiles(fileArray);
  fileInput.files = null;
});

We've added an event listener for whenever the user uploads files that calls our handler with the files converted to an array, then resets the files held by the <input>. Now, we'll write the handler logic.

const handleFiles = fileArray => {
  // This starts creating a new streaming ZIP archive
  const zip = new fflate.Zip();
  // Convert fflate stream to ReadableStream
  const zipReadableStream = fflToRS(zip);
  // Allow the user to download while the stream is still receiving data
  addDownloadButton(zipReadableStream, `fflate-zip-${fileArray[0].name}.zip`);
  for (const file of fileArray) addFileToZip(file, zip);
  zip.end();
}

Although this function may appear complicated, it's very simple once broken down. It initializes a new ZIP archive stream and calls a function fflToRS that converts the ZIP stream to a ReadableStream so we can stream the download to the user's computer. Then, it adds every file that we received from the user through the <input> field. Finally, it signals that it's finished writing files, creates a Response for the download to actually function, and adds a download button with that Response.

Next, let's take a look at fflToRS, which will act as a bridge between fflate's API and the browser's native API.

// Convert an fflate stream to a ReadableStream
const fflToRS = fflateStream =>
  new ReadableStream({
    start(controller) {
      // Push to the ReadableStream whenever the fflate
      // stream gets data
      fflateStream.ondata = (err, data, final) => {
        if (err) controller.error(err);
        else {
          controller.enqueue(data);
          // End the stream on the final chunk
          if (final) controller.close()
        }
      }
    },
    cancel() {
      // We can stop working if the stream is cancelled
      // This may happen if the user cancels the download
      fflateStream.terminate();
    }
  });

We can create a ReadableStream with a custom controller, which allows us to push data to the stream whenever data is received from fflate, throw an error whenever fflate has an error, and save resources by cancelling any running compression operations if the user cancels the download. If you want to adapt this for a stream with a different ondata handler, the MDN docs may help.

Note that this simple implementation does not support backpressure. Refer to the demo code for a more robust bridge.

Next, we'll write addFileToZip, which will stream files into our ZIP archive creator.

const incompressibleTypes = new Set([
  'zip', 'gz', 'png', 'jpg', 'jpeg', 'pdf', 'doc', 'docx',
  'ppt', 'pptx', 'xls', 'xlsx', 'heic', 'heif', '7z', 'bz2',
  'rar', 'gif', 'webp', 'webm', 'mp4', 'mov', 'mp3', 'aifc'
   // Add whatever extensions you don't want to compress
]);

// Any file above 500 kB will be compressed in a worker thread
const largeFileSize = 500000;

// Adds a file to the fflate ZIP archive
const addFileToZip = async (file, zip) => {
  // This allows for folders with webkitdirectory inputs
  const filename = file.webkitRelativePath || file.name;
  // File extension
  const ext = filename.slice(filename.lastIndexOf('.') + 1);

  // This will asynchronously compress compressible files
  // but ignore incompressible files. It will also avoid
  // using worker threads for small files
  const zippedFileStream = incompressibleTypes.has(ext)
    ? new fflate.ZipPassThrough(filename)
    : file.size > largeFileSize
      ? new fflate.AsyncZipDeflate(filename, { level: 9 });
      : new fflate.ZipDeflate(filename, { level: 9 });

  // Add metadata to the ZIP entry
  zippedFileStream.mtime = file.lastModified;

  // This adds the file to the ZIP archive
  zip.add(zippedFileStream);
  
  // Now, we stream the file into the ZIP
  // First, we acquire a reader
  const fileReader = file.stream().getReader();
  // Now, we keep pulling data until none is available
  while (true) {
    const { done, value } = await fileReader.read();
    // When done is true, the stream has finished
    if (done) {
      zippedFileStream.push(new Uint8Array(0), true);
      break;
    }
    // Push the data chunk from the file
    zippedFileStream.push(value);
  }
}

This one is pretty complex. First, it determines which type of ZIP stream to use: uncompressed, synchronously compressed, or asynchronously compressed. For files that are already compressed, such as PNG images, we will probably be unable to reduce file size by compressing, so we use ZipPassThrough. For files that are compressible but very small, a worker thread is overkill, and the time it takes to create a worker is not worth it. However, for larger, compressible files, asynchronous compression is optimal. Next, it sets file metadata and adds the file to the ZIP stream. Finally, it reads every chunk with the FileStreamDefaultReader and adds them to the ZIP entry stream.

Finally, addDownloadButton adds a button to download the ZIP file that the app creates.

const addDownloadButton = (stream, filename) => {
  const downloadButton = document.createElement('button');
  downloadButton.textContent = `Download ${filename}`;
  downloadButton.addEventListener('click', event => {
    // The download will stream and have progress
    stream.pipeTo(streamSaver.createWriteStream(filename));
    // You can't read a ReadableStream than once
    zipDownloadList.removeChild(downloadElement);
  });
  const downloadElement = document.createElement('li');
  downloadElement.appendChild(downloadButton);
  zipDownloadList.appendChild(downloadElement);
}

That's all! You can expand these sections for copy-paste-friendly code.

Final HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ZIP Creator</title>
</head>
<body>
  <!--
  Optionally add the "webkitdirectory" attribute.
  If you want both files and directories, check out
  DataTransferItem.webkitGetAsEntry().
  -->
  <input type="file" multiple id="file-input">
  <ul id="zip-download-list"></ul>
  <script type="module" src="index.js"></script>
</body>
</html>
Final script
// Replace imports with the current versions if you wish
import * as fflate from 'https://cdn.skypack.dev/fflate@0.7.2?min';
import streamSaver from 'https://cdn.skypack.dev/streamsaver@2.0.5?min';

const fileInput = document.getElementById('file-input');
const zipDownloadList = document.getElementById('zip-download-list');

fileInput.addEventListener('input', event => {
  const fileArray = Array.from(fileInput.files);
  handleFiles(fileArray);
  fileInput.files = null;
});

const handleFiles = fileArray => {
  const zip = new fflate.Zip();
  const zipReadableStream = fflToRS(zip);
  addDownloadButton(zipReadableStream, `fflate-zip-${fileArray[0].name}.zip`);
  for (const file of fileArray) addFileToZip(file, zip);
  zip.end();
}

const fflToRS = fflateStream =>
  new ReadableStream({
    start(controller) {
      fflateStream.ondata = (err, data, final) => {
        if (err) controller.error(err);
        else {
          controller.enqueue(data);
          if (final) controller.close()
        }
      }
    },
    cancel() {
      fflateStream.terminate();
    }
  });

const incompressibleTypes = new Set([
  'zip', 'gz', 'png', 'jpg', 'jpeg', 'pdf', 'doc', 'docx',
  'ppt', 'pptx', 'xls', 'xlsx', 'heic', 'heif', '7z', 'bz2',
  'rar', 'gif', 'webp', 'webm', 'mp4', 'mov', 'mp3', 'aifc'
]);

const largeFileSize = 500000;

const addFileToZip = async (file, zip) => {
  const filename = file.webkitRelativePath || file.name;
  const ext = filename.slice(filename.lastIndexOf('.') + 1);
  const zippedFileStream = incompressibleTypes.has(ext)
    ? new fflate.ZipPassThrough(filename)
    : file.size > largeFileSize
      ? new fflate.AsyncZipDeflate(filename, { level: 9 })
      : new fflate.ZipDeflate(filename, { level: 9 });
  zippedFileStream.mtime = file.lastModified;
  zip.add(zippedFileStream);
  const fileReader = file.stream().getReader();
  while (true) {
    const { done, value } = await fileReader.read();
    if (done) {
      zippedFileStream.push(new Uint8Array(0), true);
      break;
    }
    zippedFileStream.push(value);
  }
}

const addDownloadButton = (stream, filename) => {
  const downloadButton = document.createElement('button');
  downloadButton.textContent = `Download ${filename}`;
  downloadButton.addEventListener('click', event => {
    stream.pipeTo(streamSaver.createWriteStream(filename));
    zipDownloadList.removeChild(downloadElement);
  });
  const downloadElement = document.createElement('li');
  downloadElement.appendChild(downloadButton);
  zipDownloadList.appendChild(downloadElement);
}

If you don't understand something here, don't hesitate to open an issue, create a discussion, or email me directly.

Clone this wiki locally