@wtf/platform-fs
Documentation / @wtf/platform-fs
Read and write files on any folder handle of the
WHATWG File System Standard, the browser’s own file system API. Find
a file by its path, match paths with a glob, read and write a platform’s files by their paths, and
turn a handle’s failure into an error your code can match. A browser gives you a folder handle
already. For anything else, get one from
@wtf/platform-fs-node on Node’s disk, from
@wtf/platform-fs-memory in memory, or from
@wtf/platform-fs-adapter on any file system shaped like
Node’s fs/promises, such as Bun’s, Deno’s or memfs.
The handle types are @wtf/platform/fs’s, so a browser’s handle fits them as it is.
| Subpath | What you get |
|---|---|
. | Movable, HoldsExclusive and SyncAccess: the types of what the standard has a handle do that a browser’s may not |
./path | at: finds or makes the handle at a path; parent: a path’s folder and its name there; partsOf and normalize, which never leave the folder |
./glob | glob: each entry in a folder that matches a pattern (*, ?, […], {a,b} and **, as Node’s glob matches them), with its path and handle; matches(pattern, path): whether one path does |
./files | A platform’s files by path: handleAt, readText, writeText, isThere, removeAt, isNotFound, absolutePathOf, globPaths, expandPaths, tempFolder; FilesPlatform |
./errors | fsErrorOf: turns a handle’s DOMException into a PlatformError; REASONS, the reason for each name; isFileSystemError |
./features | Guards for what a browser’s handle may lack: canMove, holdsExclusive, hasSyncAccess |
./writable | FileWritableStream: the standard’s writable, on top of a sink that writes bytes, for a file system’s createWritable() |
./sync-access | FileSyncAccessHandle: the standard’s sync access handle, on top of a file opened synchronously, for a file system’s createSyncAccessHandle() |
import type { FileSystemDirectoryHandle } from '@wtf/platform/fs'import { at } from '@wtf/platform-fs/path'
async function save(root: FileSystemDirectoryHandle, path: string, text: string) { const file = await at(root, path, { kind: 'file', create: true }) const writable = await file.createWritable() await writable.write(text) await writable.close() // the file holds it now, whole}The standard
A handle names an entry by where it is.
- A folder finds an entry by its name with
getFileHandleandgetDirectoryHandle, or makes it with{ create: true }. It lists its entries withentries(),keys()andvalues(). It removes one withremoveEntry, and a folder with everything in it with{ recursive: true }.resolvegives the path of an entry inside it. - A file’s
getFile()returns a snapshot, aFile, which you read withstream(),text()andarrayBuffer(). A snapshot of a file on disk, in a browser or in Node, fails to read once the file has been written since. A snapshot of a file in memory is a copy. - A file’s
createWritable()returns aWritableStream, which a stream can pipe to. It takes text, bytes, aBlob, or a write, seek or truncate as an object. What it writes replaces the file when it closes, and an abort leaves the file as it was.keepExistingDatawrites over what the file already holds.
A handle fails with a DOMException named by the standard: NotFoundError for an entry that is
not there, TypeMismatchError for one of the other kind, InvalidModificationError for removing a
folder that is not empty without recursive, and NoModificationAllowedError for a file another
writable holds. A name that is not valid, such as .. or a/b, is a TypeError.
Some of what the standard has a handle do, a browser’s may not, or TypeScript’s lib leaves out.
Check with the guard of ./features before you use it:
| Guard | What it lets you do |
|---|---|
canMove | Move or rename the entry with move(), which browsers have and TypeScript’s lib lacks |
holdsExclusive | Hold a file while you write it: createWritable({ mode: 'exclusive' }) |
hasSyncAccess | Read and write a file synchronously: createSyncAccessHandle() |
import type { FileSystemFileHandle } from '@wtf/platform/fs'import { canMove } from '@wtf/platform-fs/features'
declare const file: FileSystemFileHandle
if (canMove(file)) await file.move('renamed.txt')Errors
Raise a handle’s failure as a PlatformError of @wtf/platform/errors with
fsErrorOf(error, { method, path }). A DOMException becomes a SystemError with the reason for
its name, and keeps the exception as its cause. A TypeError becomes a BadArgument.
| Name | Reason |
|---|---|
NotFoundError | NotFound |
TypeMismatchError | BadResource |
InvalidModificationError | BadResource |
InvalidStateError | BadResource |
NoModificationAllowedError | Busy |
NotAllowedError | PermissionDenied |
SecurityError | PermissionDenied |
QuotaExceededError | WriteZero |
NotReadableError | InvalidData |
TimeoutError | TimedOut |
NotSupportedError | Unknown |
AbortError | Unknown |
The cause’s name tells apart two failures with the same reason. at, parent and partsOf
raise errors the way fsErrorOf does, and a path that leads out of its folder with .. is a
BadArgument.
Matching
glob(root, pattern, { exclude }) yields each entry in root whose path matches pattern, or any
pattern in a list, the way Node’s glob from fs/promises matches a folder’s paths. It yields each
entry once, as it finds it, with its path from root (names joined by /) and its handle. It reads
folders with entries(), so it runs on any folder handle, a browser’s too.
import type { FileSystemDirectoryHandle } from '@wtf/platform/fs'import { glob } from '@wtf/platform-fs/glob'
declare const root: FileSystemDirectoryHandle
for await (const [path, file] of glob(root, 'src/**/*.{ts,tsx}')) console.log(path, file.kind)*,?,[…]and[!…]match within a name, and match a name starting with a dot only where the pattern part starts with one.{a,b}matches each of its choices.**matches any number of folders, includingrootitself (.), but none starting with a dot.- A
\takes the next character literally. - A
/at the end matches only folders. - A pattern is relative to
root, and a..that leads out of it is aBadArgument. exclude(path)leaves out a path, and everything inside a folder it leaves out.
The standard has no links. A folder of a file system that has them may say which of its entries is
one, by a readSymlink(name) of its own; where a folder has it, glob calls it, and ** follows no
link to a folder, as Node’s glob does. @wtf/platform-fs-node’s tests hold glob to Node’s own,
on a folder on disk. matches(pattern, path) tells whether one path, of names joined by /,
matches a pattern, as Node’s path.matchesGlob does, without reading any folder.
A platform’s files
./files reaches a platform’s files by path, the way a program refers to them: it resolves the
path with the platform’s path against its working folder, dirs.cwd, and walks down from the
root its storage gives, navigator.storage.getDirectory(). A Platform of @wtf/platform is a
FilesPlatform, and so is any object with those three members.
import type { Platform } from '@wtf/platform'import { globPaths, readText, writeText } from '@wtf/platform-fs/files'
declare const platform: Platform
await writeText(platform, 'notes/today.md', '# Today\n') // made with each folder on the wayconst text = await readText(platform, 'notes/today.md') // '# Today\n'for await (const path of globPaths(platform, 'notes/*.md')) console.log(path) // notes/today.mdhandleAtreturns the handle at a path, made if you passcreate.isTherechecks that a path exists,removeAtremoves it, andisNotFoundtells whether a failure means it was not there, aSystemErrorwithNotFound.expandPaths(platform, path)returns a plain path as it is, or a pattern as the sorted paths it matches. A pattern that matches nothing is aSystemErrorwithNotFound.
tempFolder(platform, { prefix }) makes a folder in the platform’s temporary folder, dirs.temp,
named from prefix and a UUID from the platform’s crypto.randomUUID, and returns its handle
and path. remove() removes it with everything in it, and await using calls it for you:
import type { Platform } from '@wtf/platform'import { tempFolder, writeText } from '@wtf/platform-fs/files'
declare const platform: Platform
await using folder = await tempFolder(platform, { prefix: 'build-' })await writeText(platform, platform.path.join(folder.path, 'input.json'), '{}')Write a file system
./writable and ./sync-access are for a file system’s own handles. FileWritableStream is the
standard’s writable over a sink that writes bytes at a position, cuts the file, and puts what was
written in the file’s place as it closes: it reads each chunk as the standard has it, keeps where
it writes, and grows the file with zeros to a write past its end. FileSyncAccessHandle is the
standard’s sync access handle over a file opened synchronously: it keeps its cursor, takes a
position as Web IDL does, and fails every call after close() with an InvalidStateError.
Install
The package publishes the built dist, and depends on @wtf/platform for the File System
Standard’s types and its errors. Its tests run ./path, ./glob and ./files on
native-file-system-adapter’s handles, which follow the standard.
