File-System Access in the Browser

Every so often I create another local web application, for myself or others – mostly because not relying on a server seems more sustainable and eternally trustworthy, especially for personal, single-purpose projects. Sometimes all you want to rely on is a web browser.

In the early TiddlyWiki days, local persistence was a stroke of genius.1 These days it’s an underappreciated commodity in many scenarios, as browsers now give us controlled access to the file system.

While the field is still a little uneven, we can employ progressive enhancement to choose the right saving strategy: Browsers supporting the File System Access API can manipulate files directly while other browsers resort to virtual downloads.

Let’s begin with a convenience abstraction for text files:

let supported = !!window.showSaveFilePicker;

let TextFile = supported && class TextFile {
    static async select(extension = ".txt") {
        let types = [{
            accept: {
                "text/plain": Array.isArray(extension) ? extension : [extension]
            }
        }];
        try {
            var fh = await window.showSaveFilePicker({ types });
        } catch(err) {
            if(err.name === "AbortError") {
                return null;
            }
            throw err;
        }
        return new this(fh);
    }

    constructor(fh) {
        this._fh = fh;
    }
};

Here we use showSaveFilePicker as a canary: If that’s available, we can obtain file handles to read and write content by prompting the user to choose a file they wanna entrust us with. TextFile.select is a little a wrapper to do just that, limiting selection to *.txt by default.

Next we’ll add a couple of instance methods:

class TextFile {
    // …

    async write(content) {
        let stream = await this._fh.createWritable();
        try {
            await stream.write(content);
        } finally {
            await stream.close();
        }
    }

    get name() {
        return this._fh.name;
    }
}

For our fallback, we can programmatically generate download links (i.e. <a href="…" download="…">) for virtual documents, prompting users to download the respective file instead:

function generateDownloadLink(filename, content, type = "text/plain") {
    // create virtual document
    let uri;
    try {
        let blob = new Blob([content], { type });
        uri = URL.createObjectURL(blob);
    } catch(err) { // fallback for ancient browsers
        uri = `data:${type},${encodeURIComponent(content)}`;
    }

    // generate corresponding link
    let el = document.createElement("a");
    el.setAttribute("download", filename);
    el.setAttribute("href", uri);
    return {
        el,
        release: () => uri && URL.revokeObjectURL(uri)
    };
}

NB: revokeObjectURL is required to avoid memory leaks, discarding blob when it’s no longer needed (thanks, tillsc). It depends on the respective application when best to invoke release, though a custom element’s disconnectedCallback would make that fairly straightforward.

Now that we have those two saving mechanisms, we can put them to use:

let file = TextFile && await TextFile.select();

let download = generateDownloadLink("sample.txt", "hello world\n");
let link = download.el;
if(file) {
    link.textContent = "download backup";
    await file.write("hello world\n");
} else {
    link.textContent = "download";
}
document.body.appendChild(link);
// …
download.release();

Of course user experience can be a little cumbersome for this: A download prompt for each individual change might end up frustrating users. Even with direct file-system access, users still need to re-select their file each time they revisit the page. As such, this approach might not be suitable for every scenario and GUI affordances should be carefully considered.

A simplistic demo is included below while a more elaborate sample is available on a less sustainable website, where there’s also a real-world example.