Skip to content

@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.

SubpathWhat you get
.Movable, HoldsExclusive and SyncAccess: the types of what the standard has a handle do that a browser’s may not
./pathat: 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
./globglob: 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
./filesA platform’s files by path: handleAt, readText, writeText, isThere, removeAt, isNotFound, absolutePathOf, globPaths, expandPaths, tempFolder; FilesPlatform
./errorsfsErrorOf: turns a handle’s DOMException into a PlatformError; REASONS, the reason for each name; isFileSystemError
./featuresGuards for what a browser’s handle may lack: canMove, holdsExclusive, hasSyncAccess
./writableFileWritableStream: the standard’s writable, on top of a sink that writes bytes, for a file system’s createWritable()
./sync-accessFileSyncAccessHandle: 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 getFileHandle and getDirectoryHandle, or makes it with { create: true }. It lists its entries with entries(), keys() and values(). It removes one with removeEntry, and a folder with everything in it with { recursive: true }. resolve gives the path of an entry inside it.
  • A file’s getFile() returns a snapshot, a File, which you read with stream(), text() and arrayBuffer(). 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 a WritableStream, which a stream can pipe to. It takes text, bytes, a Blob, 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. keepExistingData writes 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:

GuardWhat it lets you do
canMoveMove or rename the entry with move(), which browsers have and TypeScript’s lib lacks
holdsExclusiveHold a file while you write it: createWritable({ mode: 'exclusive' })
hasSyncAccessRead 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.

NameReason
NotFoundErrorNotFound
TypeMismatchErrorBadResource
InvalidModificationErrorBadResource
InvalidStateErrorBadResource
NoModificationAllowedErrorBusy
NotAllowedErrorPermissionDenied
SecurityErrorPermissionDenied
QuotaExceededErrorWriteZero
NotReadableErrorInvalidData
TimeoutErrorTimedOut
NotSupportedErrorUnknown
AbortErrorUnknown

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, including root itself (.), 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 a BadArgument.
  • 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 way
const text = await readText(platform, 'notes/today.md') // '# Today\n'
for await (const path of globPaths(platform, 'notes/*.md')) console.log(path) // notes/today.md
  • handleAt returns the handle at a path, made if you pass create.
  • isThere checks that a path exists, removeAt removes it, and isNotFound tells whether a failure means it was not there, a SystemError with NotFound.
  • 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 a SystemError with NotFound.

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.

Modules