browser-fs
@noy-db/to-browser-fs·pnpm add @noy-db/to-browser-fsThe browser sibling toto-file— same on-disk layout, a directory handle instead of a path.
| Shape | record |
| Atomic CAS | ✗ |
| Locality | local |
| Speed | medium |
| Persistence | durable |
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.
| state | what to show |
|---|---|
granted | nothing — proceed |
prompt | a button; requestAccess() must run inside its click |
denied | the user said no — offer to pick a different folder |
unreachable | the 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 withto-file. Two tabs writing the same record can interleave; useto-browser-idbas the primary store where that matters.txAtomic: false— everydb.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
- to-* family
- browser-idb — the recommended browser primary store
- file — the Node sibling sharing this layout
- Browser durability — this store as a backup target rather than a primary store
- store capabilities