Files and folders
By the end of this guide you will know how to get a folder handle wherever your code runs, read and write the files in it, reach a platform’s files by their paths, read and write a file synchronously, and turn a failure into an error your code can match.
The file system is the browser’s own file system API, the
WHATWG File System Standard. Your code takes a folder handle, a
FileSystemDirectoryHandle from @wtf/platform/fs, and works with whichever one it is given.
Get a folder handle
| Where your code runs | How you get a folder handle |
|---|---|
| A browser | navigator.storage.getDirectory() or showDirectoryPicker(), as they are |
| Node | nodeDirectory(path, { create }) from @wtf/platform-fs-node/handles |
| A test | memoryDirectory() from @wtf/platform-fs-memory/handles |
| Bun, Deno, memfs | fromNodeShaped(fs.promises, path) from @wtf/platform-fs-adapter/handles |
| A program’s platform | platform.navigator.storage.getDirectory(), the root of the platform’s files |
In Node, nodeStorage() from @wtf/platform-fs-node/storage is the storage you pass to
nodePlatform({ fetch, storage }): its root is the disk’s, /. In a test, pass a folder in memory
to testPlatform({ directory }) or memoryPlatform({ directory }).
Read and write a file
import type { FileSystemDirectoryHandle } from '@wtf/platform/fs'import { at } from '@wtf/platform-fs/path'
/** The text of a file of `root`. */export const read = async (root: FileSystemDirectoryHandle, path: string) => (await (await at(root, path, { kind: 'file' })).getFile()).text()
/** A file of `root` written whole, of what `body` streams. */export async function write( root: FileSystemDirectoryHandle, path: string, body: ReadableStream<Uint8Array>) { const file = await at(root, path, { kind: 'file', create: true }) await body.pipeTo(await file.createWritable())}at(root, path, { kind, create })finds the handle for a path. Withcreate, it makes the entry and every folder on the way. A path can never lead out of its folder.parent(root, path)returns the folder that holds a path, and the name of the entry in it.glob(root, 'src/**/*.ts')from@wtf/platform-fs/globyields each entry whose path matches a pattern, with its handle. It matches paths the way Node’sglobdoes.getFile()returns a snapshot, aFile, which you read withstream(),text()orarrayBuffer(). A file on the disk is read as it streams, so its snapshot fails to read once the file has been written since.createWritable()writes next to the file, and replaces the file only when it closes. A reader never sees a half-written file, and an abort leaves the file as it was. A writable takes text, bytes, aBlob, or a write, seek or truncate command as an object.- A folder lists its entries with
entries(), removes one withremoveEntry(name, { recursive }), and returns the path of an entry inside it withresolve(handle).
Reach a platform’s files by their paths
A program refers to a file by its path. @wtf/platform-fs/files resolves the path against the
platform’s working folder, dirs.cwd, and walks down from the root of its storage:
import type { Platform } from '@wtf/platform'import { globPaths, isThere, readText, removeAt, writeText } from '@wtf/platform-fs/files'
declare const platform: Platform
await writeText(platform, 'notes/today.md', '# Today\n') // made with each folder on the wayawait readText(platform, 'notes/today.md') // '# Today\n'for await (const path of globPaths(platform, 'notes/*.md')) console.log(path) // notes/today.mdif (await isThere(platform, 'notes/old.md')) await removeAt(platform, 'notes/old.md')tempFolder(platform, { prefix }) makes a folder of its own in dirs.temp, and removes it with
everything in it when you call remove(), or as await using ends.
Use what a browser’s handle may lack
The standard has a handle do some things a browser’s may not, or that TypeScript’s lib leaves out.
Check with the guard from @wtf/platform-fs/features before you use one:
import type { FileSystemFileHandle } from '@wtf/platform/fs'import { canMove, holdsExclusive } from '@wtf/platform-fs/features'
declare const file: FileSystemFileHandle
if (canMove(file)) await file.move('renamed.txt')if (holdsExclusive(file)) { const writable = await file.createWritable({ mode: 'exclusive' }) // no other writer meanwhile await writable.close()}| 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() |
The handles of Node’s disk, of memory, and of a file system shaped like Node’s have them all, but sync access, which a file system shaped like Node’s has only when you give it its synchronous calls.
Read and write synchronously
Some code cannot wait for a promise: SQLite compiled to WebAssembly reads and writes its database file in synchronous calls, and so does TypeScript’s compiler host. Give it a file you open first, then use synchronously.
A file’s createSyncAccessHandle() is the standard’s. A browser has it on a file of the origin’s
folder, in a worker. It returns a FileSystemSyncAccessHandle: read(buffer, { at }) and
write(buffer, { at }) return how many bytes they read or wrote, at at or where the call before
ended; truncate(size), getSize(), flush() and close(). A write past the end fills the gap
with zeros. Until you close it, the handle holds the file: a writable, another sync access handle,
and removing or moving the file fail with NoModificationAllowedError.
import type { FileSystemFileHandle } from '@wtf/platform/fs'import { hasSyncAccess } from '@wtf/platform-fs/features'
declare const file: FileSystemFileHandle
if (hasSyncAccess(file)) { const handle = await file.createSyncAccessHandle() handle.write(new TextEncoder().encode('hello'), { at: 0 }) const bytes = new Uint8Array(handle.getSize()) handle.read(bytes, { at: 0 }) handle.close()}| Handles of | createSyncAccessHandle() |
|---|---|
Memory, memoryDirectory | yes |
Node’s disk, nodeDirectory | yes, by node:fs |
A file system shaped like Node’s, fromNodeShaped | given sync, its synchronous calls |
| A browser’s origin folder | in a worker |
Handle errors
A handle fails with the standard’s DOMException. When your code raises it, wrap it with
fsErrorOf from @wtf/platform-fs/errors. You get a PlatformError from @wtf/platform/errors,
with the DOMException as its cause:
import type { FileSystemDirectoryHandle } from '@wtf/platform/fs'import { fsErrorOf } from '@wtf/platform-fs/errors'
declare const root: FileSystemDirectoryHandle
try { await root.removeEntry('out')} catch (error) { throw fsErrorOf(error, { method: 'clean', path: 'out' })}The error is a SystemError, and its reason depends on the exception’s name:
DOMException name | Reason |
|---|---|
NotFoundError | NotFound |
TypeMismatchError, InvalidModificationError, InvalidStateError | BadResource |
NoModificationAllowedError | Busy |
NotAllowedError, SecurityError | PermissionDenied |
QuotaExceededError | WriteZero |
NotReadableError | InvalidData |
A TypeError, which a handle throws for a name that is not valid, becomes a BadArgument. The
helpers of @wtf/platform-fs raise their failures this way already.
Add calls of your own
The handles of @wtf/platform-fs-adapter and @wtf/platform-fs-memory are classes you can extend.
A folder makes the handle of each entry in it through one method, handleOf, so a folder class of
yours that overrides it makes every entry, however deep, one of your classes; root, called on your
class, makes the root. Your classes reach the entry through protected members: its path, its stats
or its node, the file system, and, in memory, emit, which tells the file system’s listeners of a
change. Their READMEs show how.
