Reading and writing files and directories with the browser-fs-access library

Browsers have been able to deal with files and directories for a long time. The File API provides features for representing file objects in web applications, as well as programmatically selecting them and accessing their data. The moment you look closer, though, all that glitters is not gold.

The traditional way of dealing with files

Opening files

As a developer, you can open and read files via the <input type="file"> element. In its simplest form, opening a file can look something like the code sample below. The input object gives you a FileList, which in the case below consists of just one File. A File is a specific kind of Blob, and can be used in any context that a Blob can.

const openFile = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

Opening directories

For opening folders (or directories), you can set the <input webkitdirectory> attribute. Apart from that, everything else works the same as above. Despite its vendor-prefixed name, webkitdirectory is not only usable in Chromium and WebKit browsers, but also in the legacy EdgeHTML-based Edge as well as in Firefox.

Saving (rather: downloading) files

For saving a file, traditionally, you are limited to downloading a file, which works thanks to the <a download> attribute. Given a Blob, you can set the anchor's href attribute to a blob: URL that you can get from the URL.createObjectURL() method.

const saveFile = async (blob) => {
  const a = document.createElement('a');
  a.download = 'my-file.txt';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

The problem

A massive downside of the download approach is that there is no way to make a classic open→edit→save flow happen, that is, there is no way to overwrite the original file. Instead, you end up with a new copy of the original file in the operating system's default Downloads folder whenever you "save".

The File System Access API

The File System Access API makes both operations, opening and saving, a lot simpler. It also enables true saving, that is, you can not only choose where to save a file, but also overwrite an existing file.

Opening files

With the File System Access API, opening a file is a matter of one call to the window.showOpenFilePicker() method. This call returns a file handle, from which you can get the actual File via the getFile() method.

const openFile = async () => {
  try {
    // Always returns an array.
    const [handle] = await window.showOpenFilePicker();
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Opening directories

Open a directory by calling window.showDirectoryPicker() that makes directories selectable in the file dialog box.

Saving files

Saving files is similarly straightforward. From a file handle, you create a writable stream via createWritable(), then you write the Blob data by calling the stream's write() method, and finally you close the stream by calling its close() method.

const saveFile = async (blob) => {
  try {
    const handle = await window.showSaveFilePicker({
      types: [{
        accept: {
          // Omitted
        },
      }],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Introducing browser-fs-access

As perfectly fine as the File System Access API is, it's not yet widely available.

Browser support table for the File System Access API. All browsers are marked as 'no support' or 'behind a flag'.
Browser support table for the File System Access API. (Source)

This is why I see the File System Access API as a progressive enhancement. As such, I want to use it when the browser supports it, and use the traditional approach if not; all while never punishing the user with unnecessary downloads of unsupported JavaScript code. The browser-fs-access library is my answer to this challenge.

Design philosophy

Since the File System Access API is still likely to change in the future, the browser-fs-access API is not modeled after it. That is, the library is not a polyfill, but rather a ponyfill. You can (statically or dynamically) exclusively import whatever functionality you need to keep your app as small as possible. The available methods are the aptly named fileOpen(), directoryOpen(), and fileSave(). Internally, the library feature-detects if the File System Access API is supported, and then imports the corresponding code path.

Using the browser-fs-access library

The three methods are intuitive to use. You can specify your app's accepted mimeTypes or file extensions, and set a multiple flag to allow or disallow selection of multiple files or directories. For full details, see the browser-fs-access API documentation. The code sample below shows how you can open and save image files.

// The imported methods will use the File
// System Access API or a fallback implementation.
import {
  fileOpen,
  directoryOpen,
  fileSave,
} from 'https://unpkg.com/browser-fs-access';

(async () => {
  // Open an image file.
  const blob = await fileOpen({
    mimeTypes: ['image/*'],
  });

  // Open multiple image files.
  const blobs = await fileOpen({
    mimeTypes: ['image/*'],
    multiple: true,
  });

  // Open all files in a directory,
  // recursively including subdirectories.
  const blobsInDirectory = await directoryOpen({
    recursive: true
  });

  // Save a file.
  await fileSave(blob, {
    fileName: 'Untitled.png',
  });
})();

Demo

You can see the above code in action in a demo on Glitch. Its source code is likewise available there. Since for security reasons cross origin sub frames are not allowed to show a file picker, the demo cannot be embedded in this article.

The browser-fs-access library in the wild

In my free time, I contribute a tiny bit to an installable PWA called Excalidraw, a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel. It is fully responsive and works well on a range of devices from small mobile phones to computers with large screens. This means it needs to deal with files on all the various platforms whether or not they support the File System Access API. This makes it a great candidate for the browser-fs-access library.

I can, for example, start a drawing on my iPhone, save it (technically: download it, since Safari does not support the File System Access API) to my iPhone Downloads folder, open the file on my desktop (after transferring it from my phone), modify the file, and overwrite it with my changes, or even save it as a new file.

An Excalidraw drawing on an iPhone.
Starting an Excalidraw drawing on an iPhone where the File System Access API is not supported, but where a file can be saved (downloaded) to the Downloads folder.
The modified Excalidraw drawing on Chrome on the desktop.
Opening and modifying the Excalidraw drawing on the desktop where the File System Access API is supported and thus the file can be accessed via the API.
Overwriting the original file with the modifications.
Overwriting the original file with the modifications to the original Excalidraw drawing file. The browser shows a dialog asking me whether this is fine.
Saving the modifications to a new Excalidraw drawing file.
Saving the modifications to a new Excalidraw file. The original file remains untouched.

Real life code sample

Below, you can see an actual example of browser-fs-access as it is used in Excalidraw. This excerpt is taken from /src/data/json.ts. Of special interest is how the saveAsJSON() method passes either a file handle or null to browser-fs-access' fileSave() method, which causes it to overwrite when a handle is given, or to save to a new file if not.

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
  fileHandle: any,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: "application/json",
  });
  const name = `${appState.name}.excalidraw`;
  (window as any).handle = await fileSave(
    blob,
    {
      fileName: name,
      description: "Excalidraw file",
      extensions: ["excalidraw"],
    },
    fileHandle || null,
  );
};

export const loadFromJSON = async () => {
  const blob = await fileOpen({
    description: "Excalidraw files",
    extensions: ["json", "excalidraw"],
    mimeTypes: ["application/json"],
  });
  return loadFromBlob(blob);
};

UI considerations

Whether in Excalidraw or your app, the UI should adapt to the browser's support situation. If the File System Access API is supported (if ('showOpenFilePicker' in window) {}) you can show a Save As button in addition to a Save button. The screenshots below show the difference between Excalidraw's responsive main app toolbar on iPhone and on Chrome desktop. Note how on iPhone the Save As button is missing.

Excalidraw app toolbar on iPhone with just a 'Save' button.
Excalidraw app toolbar on iPhone with just a Save button.
Excalidraw app toolbar on Chrome desktop with a 'Save' and a 'Save As' button.
Excalidraw app toolbar on Chrome with a Save and a focused Save As button.

Conclusions

Working with system files technically works on all modern browsers. On browsers that support the File System Access API, you can make the experience better by allowing for true saving and overwriting (not just downloading) of files and by letting your users create new files wherever they want, all while remaining functional on browsers that do not support the File System Access API. The browser-fs-access makes your life easier by dealing with the subtleties of progressive enhancement and making your code as simple as possible.

Acknowledgements

This article was reviewed by Joe Medley and Kayce Basques. Thanks to the contributors to Excalidraw for their work on the project and for reviewing my Pull Requests. Hero image by Ilya Pavlov on Unsplash.