|
| 1 | +--- |
| 2 | +layout: 'layouts/blog-post.njk' |
| 3 | +title: Reading and writing files and directories with the browser-fs-access library |
| 4 | +authors: |
| 5 | + - thomassteiner |
| 6 | +description: All modern browsers can read local files and directories; however, true write access, that is, more than just downloading files, is limited to browsers that implement the File System Access API. This post introduces a support library called browser-fs-access that acts as an abstraction layer on top of the File System Access API and that transparently falls back to legacy approaches for dealing with files. |
| 7 | +date: 2020-07-27 |
| 8 | +updated: 2022-01-25 |
| 9 | +hero: image/admin/Y4wGmGP8P0Dc99c3eKkT.jpg |
| 10 | +alt: '' |
| 11 | +tags: |
| 12 | + - progressive-web-apps |
| 13 | + - capabilities |
| 14 | +feedback: |
| 15 | + - api |
| 16 | +--- |
| 17 | + |
| 18 | +Browsers have been able to deal with files and directories for a long time. |
| 19 | +The [File API](https://w3c.github.io/FileAPI/) |
| 20 | +provides features for representing file objects in web applications, |
| 21 | +as well as programmatically selecting them and accessing their data. |
| 22 | +The moment you look closer, though, all that glitters is not gold. |
| 23 | + |
| 24 | +## The traditional way of dealing with files |
| 25 | + |
| 26 | +{% Aside %} |
| 27 | +If you know how it used to work the old way, you can |
| 28 | +[jump down straight to the new way](#the-file-system-access-api). |
| 29 | +{% endAside %} |
| 30 | + |
| 31 | +### Opening files |
| 32 | + |
| 33 | +As a developer, you can open and read files via the |
| 34 | +[`<input type="file">`](https://developer.mozilla.org/docs/Web/HTML/Element/input/file) |
| 35 | +element. |
| 36 | +In its simplest form, opening a file can look something like the code sample below. |
| 37 | +The `input` object gives you a [`FileList`](https://developer.mozilla.org/docs/Web/API/FileList), |
| 38 | +which in the case below consists of just one |
| 39 | +[`File`](https://developer.mozilla.org/docs/Web/API/File). |
| 40 | +A `File` is a specific kind of [`Blob`](https://developer.mozilla.org/docs/Web/API/Blob), |
| 41 | +and can be used in any context that a Blob can. |
| 42 | + |
| 43 | +```js |
| 44 | +const openFile = async () => { |
| 45 | + return new Promise((resolve) => { |
| 46 | + const input = document.createElement('input'); |
| 47 | + input.type = 'file'; |
| 48 | + input.addEventListener('change', () => { |
| 49 | + resolve(input.files[0]); |
| 50 | + }); |
| 51 | + input.click(); |
| 52 | + }); |
| 53 | +}; |
| 54 | +``` |
| 55 | + |
| 56 | +### Opening directories |
| 57 | + |
| 58 | +For opening folders (or directories), you can set the |
| 59 | +[`<input webkitdirectory>`](https://developer.mozilla.org/docs/Web/HTML/Element/input#attr-webkitdirectory) |
| 60 | +attribute. |
| 61 | +Apart from that, everything else works the same as above. |
| 62 | +Despite its vendor-prefixed name, |
| 63 | +`webkitdirectory` is not only usable in Chromium and WebKit browsers, but also in the legacy EdgeHTML-based Edge as well as in Firefox. |
| 64 | + |
| 65 | +### Saving (rather: downloading) files |
| 66 | + |
| 67 | +For saving a file, traditionally, you are limited to *downloading* a file, |
| 68 | +which works thanks to the |
| 69 | +[`<a download>`](https://developer.mozilla.org/docs/Web/HTML/Element/a#attr-download:~:text=download) |
| 70 | +attribute. |
| 71 | +Given a Blob, you can set the anchor's `href` attribute to a `blob:` URL that you can get from the |
| 72 | +[`URL.createObjectURL()`](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL) |
| 73 | +method. |
| 74 | +{% Aside 'caution' %} |
| 75 | +To prevent memory leaks, always revoke the URL after the download. |
| 76 | +{% endAside %} |
| 77 | + |
| 78 | +```js |
| 79 | +const saveFile = async (blob) => { |
| 80 | + const a = document.createElement('a'); |
| 81 | + a.download = 'my-file.txt'; |
| 82 | + a.href = URL.createObjectURL(blob); |
| 83 | + a.addEventListener('click', (e) => { |
| 84 | + setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000); |
| 85 | + }); |
| 86 | + a.click(); |
| 87 | +}; |
| 88 | +``` |
| 89 | + |
| 90 | +### The problem |
| 91 | + |
| 92 | +A massive downside of the *download* approach is that there is no way to make a classic |
| 93 | +open→edit→save flow happen, that is, there is no way to *overwrite* the original file. |
| 94 | +Instead, you end up with a new *copy* of the original file |
| 95 | +in the operating system's default Downloads folder whenever you "save". |
| 96 | + |
| 97 | +## The File System Access API |
| 98 | + |
| 99 | +The File System Access API makes both operations, opening and saving, a lot simpler. |
| 100 | +It also enables *true saving*, that is, you can not only choose where to save a file, |
| 101 | +but also overwrite an existing file. |
| 102 | + |
| 103 | +{% Aside %} |
| 104 | +For a more thorough introduction to the File System Access API, see the article |
| 105 | +[The File System Access API: simplifying access to local files](/file-system-access/). |
| 106 | +{% endAside %} |
| 107 | + |
| 108 | +### Opening files |
| 109 | + |
| 110 | +With the [File System Access API](https://wicg.github.io/file-system-access/), |
| 111 | +opening a file is a matter of one call to the `window.showOpenFilePicker()` method. |
| 112 | +This call returns a file handle, from which you can get the actual `File` via the `getFile()` method. |
| 113 | + |
| 114 | +```js |
| 115 | +const openFile = async () => { |
| 116 | + try { |
| 117 | + // Always returns an array. |
| 118 | + const [handle] = await window.showOpenFilePicker(); |
| 119 | + return handle.getFile(); |
| 120 | + } catch (err) { |
| 121 | + console.error(err.name, err.message); |
| 122 | + } |
| 123 | +}; |
| 124 | +``` |
| 125 | + |
| 126 | +### Opening directories |
| 127 | + |
| 128 | +Open a directory by calling |
| 129 | +`window.showDirectoryPicker()` that makes directories selectable in the file dialog box. |
| 130 | + |
| 131 | +### Saving files |
| 132 | + |
| 133 | +Saving files is similarly straightforward. |
| 134 | +From a file handle, you create a writable stream via `createWritable()`, |
| 135 | +then you write the Blob data by calling the stream's `write()` method, |
| 136 | +and finally you close the stream by calling its `close()` method. |
| 137 | + |
| 138 | +```js |
| 139 | +const saveFile = async (blob) => { |
| 140 | + try { |
| 141 | + const handle = await window.showSaveFilePicker({ |
| 142 | + types: [{ |
| 143 | + accept: { |
| 144 | + // Omitted |
| 145 | + }, |
| 146 | + }], |
| 147 | + }); |
| 148 | + const writable = await handle.createWritable(); |
| 149 | + await writable.write(blob); |
| 150 | + await writable.close(); |
| 151 | + return handle; |
| 152 | + } catch (err) { |
| 153 | + console.error(err.name, err.message); |
| 154 | + } |
| 155 | +}; |
| 156 | +``` |
| 157 | + |
| 158 | +## Introducing browser-fs-access |
| 159 | + |
| 160 | +As perfectly fine as the File System Access API is, |
| 161 | +it's [not yet widely available](https://caniuse.com/native-filesystem-api). |
| 162 | + |
| 163 | +<figure> |
| 164 | + {% Img src="image/tcFciHGuF3MxnTr1y5ue01OGLBn2/G1jsSjCBR871W1uKQWeN.png", alt="Browser support table for the File System Access API. All browsers are marked as 'no support' or 'behind a flag'.", width="800", height="224" %} |
| 165 | + <figcaption> |
| 166 | + Browser support table for the File System Access API. |
| 167 | + (<a href="https://caniuse.com/native-filesystem-api">Source</a>) |
| 168 | + </figcaption> |
| 169 | +</figure> |
| 170 | + |
| 171 | +This is why I see the File System Access API as a [progressive enhancement](https://web.dev/progressively-enhance-your-pwa). |
| 172 | +As such, I want to use it when the browser supports it, |
| 173 | +and use the traditional approach if not; |
| 174 | +all while never punishing the user with unnecessary downloads of unsupported JavaScript code. |
| 175 | +The [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) |
| 176 | +library is my answer to this challenge. |
| 177 | + |
| 178 | +### Design philosophy |
| 179 | + |
| 180 | +Since the File System Access API is still likely to change in the future, |
| 181 | +the browser-fs-access API is not modeled after it. |
| 182 | +That is, the library is not a [polyfill](https://developer.mozilla.org/docs/Glossary/Polyfill), |
| 183 | +but rather a [ponyfill](https://github.com/sindresorhus/ponyfill). |
| 184 | +You can (statically or dynamically) exclusively import whatever functionality you need to keep your app as small as possible. |
| 185 | +The available methods are the aptly named |
| 186 | +[`fileOpen()`](https://github.com/GoogleChromeLabs/browser-fs-access#opening-files), |
| 187 | +[`directoryOpen()`](https://github.com/GoogleChromeLabs/browser-fs-access#opening-directories), and |
| 188 | +[`fileSave()`](https://github.com/GoogleChromeLabs/browser-fs-access#saving-files). |
| 189 | +Internally, the library feature-detects if the File System Access API is supported, |
| 190 | +and then imports the corresponding code path. |
| 191 | + |
| 192 | +### Using the browser-fs-access library |
| 193 | + |
| 194 | +The three methods are intuitive to use. |
| 195 | +You can specify your app's accepted `mimeTypes` or file `extensions`, and set a `multiple` flag |
| 196 | +to allow or disallow selection of multiple files or directories. |
| 197 | +For full details, see the |
| 198 | +[browser-fs-access API documentation](https://github.com/GoogleChromeLabs/browser-fs-access#api-documentation). |
| 199 | +The code sample below shows how you can open and save image files. |
| 200 | + |
| 201 | +```js |
| 202 | +// The imported methods will use the File |
| 203 | +// System Access API or a fallback implementation. |
| 204 | +import { |
| 205 | + fileOpen, |
| 206 | + directoryOpen, |
| 207 | + fileSave, |
| 208 | +} from 'https://unpkg.com/browser-fs-access'; |
| 209 | + |
| 210 | +(async () => { |
| 211 | + // Open an image file. |
| 212 | + const blob = await fileOpen({ |
| 213 | + mimeTypes: ['image/*'], |
| 214 | + }); |
| 215 | + |
| 216 | + // Open multiple image files. |
| 217 | + const blobs = await fileOpen({ |
| 218 | + mimeTypes: ['image/*'], |
| 219 | + multiple: true, |
| 220 | + }); |
| 221 | + |
| 222 | + // Open all files in a directory, |
| 223 | + // recursively including subdirectories. |
| 224 | + const blobsInDirectory = await directoryOpen({ |
| 225 | + recursive: true |
| 226 | + }); |
| 227 | + |
| 228 | + // Save a file. |
| 229 | + await fileSave(blob, { |
| 230 | + fileName: 'Untitled.png', |
| 231 | + }); |
| 232 | +})(); |
| 233 | +``` |
| 234 | + |
| 235 | +### Demo |
| 236 | + |
| 237 | +You can see the above code in action in a [demo](https://browser-fs-access.glitch.me/) on Glitch. |
| 238 | +Its [source code](https://glitch.com/edit/#!/browser-fs-access) is likewise available there. |
| 239 | +Since for security reasons cross origin sub frames are not allowed to show a file picker, |
| 240 | +the demo cannot be embedded in this article. |
| 241 | + |
| 242 | +## The browser-fs-access library in the wild |
| 243 | + |
| 244 | +In my free time, I contribute a tiny bit to an |
| 245 | +[installable PWA](https://web.dev/progressive-web-apps/#make-it-installable) |
| 246 | +called [Excalidraw](https://excalidraw.com/), |
| 247 | +a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel. |
| 248 | +It is fully responsive and works well on a range of devices from small mobile phones to computers with large screens. |
| 249 | +This means it needs to deal with files on all the various platforms |
| 250 | +whether or not they support the File System Access API. |
| 251 | +This makes it a great candidate for the browser-fs-access library. |
| 252 | + |
| 253 | +I can, for example, start a drawing on my iPhone, |
| 254 | +save it (technically: download it, since Safari does not support the File System Access API) |
| 255 | +to my iPhone Downloads folder, open the file on my desktop (after transferring it from my phone), |
| 256 | +modify the file, and overwrite it with my changes, or even save it as a new file. |
| 257 | + |
| 258 | +<figure> |
| 259 | + {% Img src="image/admin/u1Gwxp5MxS39wl8PW2vz.png", alt="An Excalidraw drawing on an iPhone.", width="300", height="649" %} |
| 260 | + <figcaption>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.</figcaption> |
| 261 | +</figure> |
| 262 | + |
| 263 | +<figure> |
| 264 | + {% Img src="image/admin/W1lt36DtKuveBJJTzonC.png", alt="The modified Excalidraw drawing on Chrome on the desktop.", width="800", height="592" %} |
| 265 | + <figcaption>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.</figcaption> |
| 266 | +</figure> |
| 267 | + |
| 268 | +<figure> |
| 269 | + {% Img src="image/admin/srqhiMKy2i9UygEP4t8e.png", alt="Overwriting the original file with the modifications.", width="800", height="585" %} |
| 270 | + <figcaption>Overwriting the original file with the modifications to the original Excalidraw drawing file. The browser shows a dialog asking me whether this is fine.</figcaption> |
| 271 | +</figure> |
| 272 | + |
| 273 | +<figure> |
| 274 | + {% Img src="image/admin/FLzOZ4eXZ1lbdQaA4MQi.png", alt="Saving the modifications to a new Excalidraw drawing file.", width="800", height="592" %} |
| 275 | + <figcaption>Saving the modifications to a new Excalidraw file. The original file remains untouched.</figcaption> |
| 276 | +</figure> |
| 277 | + |
| 278 | +### Real life code sample |
| 279 | + |
| 280 | +Below, you can see an actual example of browser-fs-access as it is used in Excalidraw. |
| 281 | +This excerpt is taken from |
| 282 | +[`/src/data/json.ts`](https://github.com/excalidraw/excalidraw/blob/cd87bd6901b47430a692a06a8928b0f732d77097/src/data/json.ts#L24-L52). |
| 283 | +Of special interest is how the `saveAsJSON()` method passes either a file handle or `null` to browser-fs-access' |
| 284 | +`fileSave()` method, which causes it to overwrite when a handle is given, |
| 285 | +or to save to a new file if not. |
| 286 | + |
| 287 | +```js |
| 288 | +export const saveAsJSON = async ( |
| 289 | + elements: readonly ExcalidrawElement[], |
| 290 | + appState: AppState, |
| 291 | + fileHandle: any, |
| 292 | +) => { |
| 293 | + const serialized = serializeAsJSON(elements, appState); |
| 294 | + const blob = new Blob([serialized], { |
| 295 | + type: "application/json", |
| 296 | + }); |
| 297 | + const name = `${appState.name}.excalidraw`; |
| 298 | + (window as any).handle = await fileSave( |
| 299 | + blob, |
| 300 | + { |
| 301 | + fileName: name, |
| 302 | + description: "Excalidraw file", |
| 303 | + extensions: ["excalidraw"], |
| 304 | + }, |
| 305 | + fileHandle || null, |
| 306 | + ); |
| 307 | +}; |
| 308 | + |
| 309 | +export const loadFromJSON = async () => { |
| 310 | + const blob = await fileOpen({ |
| 311 | + description: "Excalidraw files", |
| 312 | + extensions: ["json", "excalidraw"], |
| 313 | + mimeTypes: ["application/json"], |
| 314 | + }); |
| 315 | + return loadFromBlob(blob); |
| 316 | +}; |
| 317 | +``` |
| 318 | + |
| 319 | +### UI considerations |
| 320 | + |
| 321 | +Whether in Excalidraw or your app, |
| 322 | +the UI should adapt to the browser's support situation. |
| 323 | +If the File System Access API is supported (`if ('showOpenFilePicker' in window) {}`) |
| 324 | +you can show a **Save As** button in addition to a **Save** button. |
| 325 | +The screenshots below show the difference between Excalidraw's responsive main app toolbar on iPhone and on Chrome desktop. |
| 326 | +Note how on iPhone the **Save As** button is missing. |
| 327 | + |
| 328 | +<figure> |
| 329 | + {% Img src="image/admin/c2sjjj86zh53VDrPIo6M.png", alt="Excalidraw app toolbar on iPhone with just a 'Save' button.", width="300", height="226" %} |
| 330 | + <figcaption>Excalidraw app toolbar on iPhone with just a <strong>Save</strong> button.</figcaption> |
| 331 | +</figure> |
| 332 | + |
| 333 | +<figure> |
| 334 | + {% Img src="image/admin/unUUghwH5mG2hLnaViHK.png", alt="Excalidraw app toolbar on Chrome desktop with a 'Save' and a 'Save As' button.", width="300", height="66" %} |
| 335 | + <figcaption>Excalidraw app toolbar on Chrome with a <strong>Save</strong> and a focused <strong>Save As</strong> button.</figcaption> |
| 336 | +</figure> |
| 337 | + |
| 338 | +## Conclusions |
| 339 | + |
| 340 | +Working with system files technically works on all modern browsers. |
| 341 | +On browsers that support the File System Access API, you can make the experience better by allowing |
| 342 | +for true saving and overwriting (not just downloading) of files and |
| 343 | +by letting your users create new files wherever they want, |
| 344 | +all while remaining functional on browsers that do not support the File System Access API. |
| 345 | +The [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) makes your life easier |
| 346 | +by dealing with the subtleties of progressive enhancement and making your code as simple as possible. |
| 347 | + |
| 348 | +## Acknowledgements |
| 349 | + |
| 350 | +This article was reviewed by [Joe Medley](https://github.com/jpmedley) and |
| 351 | +[Kayce Basques](https://github.com/kaycebasques). |
| 352 | +Thanks to the [contributors to Excalidraw](https://github.com/excalidraw/excalidraw/graphs/contributors) |
| 353 | +for their work on the project and for reviewing my Pull Requests. |
| 354 | +[Hero image](https://unsplash.com/photos/hXrPSgGFpqQ) by |
| 355 | +[Ilya Pavlov](https://unsplash.com/@ilyapavlov) on Unsplash. |
0 commit comments