Skip to content

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 runsHow you get a folder handle
A browsernavigator.storage.getDirectory() or showDirectoryPicker(), as they are
NodenodeDirectory(path, { create }) from @wtf/platform-fs-node/handles
A testmemoryDirectory() from @wtf/platform-fs-memory/handles
Bun, Deno, memfsfromNodeShaped(fs.promises, path) from @wtf/platform-fs-adapter/handles
A program’s platformplatform.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. With create, 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/glob yields each entry whose path matches a pattern, with its handle. It matches paths the way Node’s glob does.
  • getFile() returns a snapshot, a File, which you read with stream(), text() or arrayBuffer(). 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, a Blob, or a write, seek or truncate command as an object.
  • A folder lists its entries with entries(), removes one with removeEntry(name, { recursive }), and returns the path of an entry inside it with resolve(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 way
await readText(platform, 'notes/today.md') // '# Today\n'
for await (const path of globPaths(platform, 'notes/*.md')) console.log(path) // notes/today.md
if (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()
}
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()

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 ofcreateSyncAccessHandle()
Memory, memoryDirectoryyes
Node’s disk, nodeDirectoryyes, by node:fs
A file system shaped like Node’s, fromNodeShapedgiven sync, its synchronous calls
A browser’s origin folderin 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 nameReason
NotFoundErrorNotFound
TypeMismatchError, InvalidModificationError, InvalidStateErrorBadResource
NoModificationAllowedErrorBusy
NotAllowedError, SecurityErrorPermissionDenied
QuotaExceededErrorWriteZero
NotReadableErrorInvalidData

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.