File-System Access in the 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.