stable

browser-fs

File System Access store — a page reads and writes a folder the OS has mounted, in a layout byte-identical to to-file. Chromium-only by construction.

@noy-db/to-browser-fs · pnpm add @noy-db/to-browser-fs The browser sibling to to-file — same on-disk layout, a directory handle instead of a path.

Shaperecord
Atomic CAS✗
Localitylocal
Speedmedium
Persistencedurable

What it does

Takes a FileSystemDirectoryHandle rather than a path, so a page can read and write a folder the operating system has already mounted — a Windows SMB share, a NAS volume, a USB stick. The adapter never speaks a wire protocol: once the volume is mounted, the protocol stops mattering.

The layout is byte-identical to to-file, so a Node process and a browser can point at the same folder and read each other's writes:

{root}/
  {vault}/
    {collection}/
      {id}.json          ← EncryptedEnvelope, pretty-printed by default
    _keyring/
      {userId}.json
    _sync/
      meta.json
import { toBrowserFs } from '@noy-db/to-browser-fs'

const handle = await window.showDirectoryPicker({ mode: 'readwrite' })
const store = toBrowserFs({ handle })

BrowserFsOptions carries handle (required), pretty (default true, matching to-file) and verifyWrites (default true — reads every write back and throws FsWriteVerifyError on a mismatch, at the cost of one extra read per write).

⚠️ Chromium desktop only, by construction

This store needs the File System Access API. That is not a gap waiting to be filled: Safari's OPFS is origin-private and cannot see a mounted volume at all, so no amount of polyfilling reaches the same capability.

Treat it as progressive enhancement and never as the only path, or users on other browsers have no store. The degradation is cheap to write, because recallDirectory() returns null where the API is absent — a missing capability arrives as "no folder chosen" rather than as an exception.

Permissions — spend the gesture first

requestPermission() needs transient user activation (~5 s in Chrome), and a vault unlock's PBKDF2 blows straight past that window. So requestAccess() is the only method in this package that prompts — reads, writes, sync and ping never do.

⛔ Order matters, and the intuitive order is the broken one. Ask for permission before the slow work, not when the first write needs it:

if (await store.access() !== 'granted') {
  if (!await store.requestAccess()) return   // user declined
}
await unlockVault(password)                  // activation already spent

Unlock first and the activation has expired by the time you ask, so the prompt never appears and the write fails on a permission you were never able to request.

access() reports four states, not two

type FsAccessState = 'granted' | 'prompt' | 'denied' | 'unreachable'

access() never prompts, and only touches the volume when permission is already granted — probing a dead mount can block for seconds. The four states exist because "click to reconnect" and "you're off the office network" are different situations for the user, and must not be told apart by string-matching an error message.

statewhat to show
grantednothing — proceed
prompta button; requestAccess() must run inside its click
deniedthe user said no — offer to pick a different folder
unreachablethe volume is gone: unplugged, unmounted, off the network

Remembering a folder across reloads

import { rememberDirectory, recallDirectory, forgetDirectory } from '@noy-db/to-browser-fs'

await rememberDirectory('vault-folder', handle)   // persists the handle in IndexedDB
const again = await recallDirectory('vault-folder')  // → handle, or null
await forgetDirectory('vault-folder')             // user chose elsewhere, or signed out

⚠️ A recalled handle is usable but not necessarily permitted. Persisting it saves the user re-picking the folder; it does not carry the permission with it. Always re-check store.access() after recalling, and be ready to call requestAccess() from a fresh user gesture — 'prompt' is a normal state on a new page load, not an error.

Notes

  • casAtomic: false — the version check is read-then-write, as with to-file. Two tabs writing the same record can interleave; use to-browser-idb as the primary store where that matters.
  • txAtomic: false — every db.transaction() takes the per-record OCC path.
  • Record shape only; no whole-vault bundle store.
  • Errors: FsPermissionError, FsUnreachableError, FsWriteVerifyError.
  • Factory: toBrowserFs(options: BrowserFsOptions).

See also