Note
This document describes the public Workspace.fs surface and is kept
in step with the code in @cloudflare/dofs. A few spots are
explicitly flagged where the doc reflects an intended target (true
streaming writeFile, mount-layer error codes); everything else is
what ships today.
Workspace.fs is the file API. It's inspired by node:fs/promises for
familiarity β same method names, similar option shapes β but it's a much
smaller surface and it leans on ReadableStream<Uint8Array> wherever a
file could be large.
interface Workspace {
fs: WorkspaceFilesystem;
runtime: WorkspaceRuntime; // see 05_runtime_interface.md
}Three things to keep in mind when porting Node code over:
- Every method is async, even ones Node ships as sync-only.
- Paths are absolute and POSIX-style (see 01. VFS).
- The default
readFilereturn is a stream, not a Buffer. Pass"utf8"(or{ encoding: "utf8" }) when you actually want a string in memory. Use streams whenever the file could be larger than a few hundred KB β they pipe directly intoResponse,fetch, R2put, and any otherReadableStreamconsumer without buffering.
See the appendix for a
method-by-method mapping against node:fs/promises.
type ReadFileRange = {
byteOffset?: number; // default 0
byteLength?: number; // default: remainder of the file
};
readFile(path: string): Promise<ReadableStream<Uint8Array>>
readFile(path: string, encoding: "utf8"): Promise<string>
readFile(path: string, options: ReadFileRange): Promise<ReadableStream<Uint8Array>>
readFile(
path: string,
options: ReadFileRange & { encoding: "utf8" },
): Promise<string>Defaulting to a stream is deliberate β most reads in an agent context
are "send this file somewhere" and never need to be in memory. A ranged
stream resolves the file and captures its overlapping chunk rows once,
then lazily sends those content-addressed blobs. This preserves the existing
whole-file stream behavior across ordinary concurrent writes while avoiding
reads before byteOffset. The same single stream
crosses the Workers RPC boundary; callers do not issue one RPC invocation
per storage chunk.
// Stream a large file straight to the client.
const stream = await fs.readFile("/workspace/build/out.wasm");
return new Response(stream, { headers: { "content-type": "application/wasm" } });
// Resume a stream at a byte continuation and cap the transfer.
const continuation = await fs.readFile("/workspace/build/out.wasm", {
byteOffset: 1_048_576,
byteLength: 262_144,
});
// Read a small text file into a string.
const todo = await fs.readFile("/workspace/notes/todo.md", "utf8");
// The verbose form, for symmetry with node:fs/promises.
const config = await fs.readFile("/workspace/config.json", { encoding: "utf8" });writeFile(
path: string,
content: string | Uint8Array | ReadableStream<Uint8Array>,
options?: { mode?: number }
): Promise<void>Accepts a stream so callers can supply uploads, R2 bodies, and fetch
responses without an intermediate arrayBuffer(). Stream sources are
consumed incrementally: bytes are re-windowed into fixed CHUNK_SIZE
(512 KiB) pieces, hashed, and staged into vfs_blobs as they arrive,
so peak memory is bounded by one chunk plus whatever the source
yields per pull β not the full file. The inode, dirent, chunk-list,
and manifest rows are committed in one short transaction once the
source drains; a mid-stream failure leaves orphan blob rows that
gc() reaps on its next pass.
// Text.
await fs.writeFile("/workspace/notes/todo.md", "- [ ] ship it\n");
// Binary.
await fs.writeFile("/workspace/data/blob.bin", new Uint8Array([1, 2, 3]));
// Supply an HTTP upload as a stream (consumed incrementally).
await fs.writeFile("/workspace/uploads/big.csv", request.body!);
// Pipe an R2 object into the workspace.
const obj = await env.BUCKET.get("imports/data.parquet");
if (obj) await fs.writeFile("/workspace/imports/data.parquet", obj.body);
// Mark a script executable.
await fs.writeFile("/workspace/bin/run.sh", "#!/bin/sh\necho hi\n", { mode: 0o755 });rm(path: string, options?: { recursive?: true; force?: true }): Promise<void>Replaces both unlink and rmdir. Pass recursive: true for non-empty
directories; force: true silences ENOENT.
The
recursive?: true/force?: trueliteral types are intentional today and rejectfalse. Widening tobooleanfornode:fs/promisesparity is a deferred follow-up.
// Single file.
await fs.rm("/workspace/notes/todo.md");
// Recursive directory wipe.
await fs.rm("/workspace/build", { recursive: true });
// Idempotent cleanup.
await fs.rm("/workspace/cache", { recursive: true, force: true });mkdir(path: string, options?: { recursive?: true; mode?: number }): Promise<void>Same literal-true caveat as rm β see the note above.
await fs.mkdir("/workspace/notes");
await fs.mkdir("/workspace/projects/a/b/c", { recursive: true });readdir(path: string): Promise<Array<{
name: string;
parentPath: string;
isFile: boolean;
isDirectory: boolean;
}>>Returns dirent-shaped entries by default so you don't need a follow-up
stat() to tell files from directories.
for (const entry of await fs.readdir("/workspace/notes")) {
if (entry.isDirectory) console.log(`d ${entry.name}/`);
else console.log(`f ${entry.name}`);
}stat(path: string): Promise<{
name: string;
mode: number;
mtime: number; // ms since epoch
size: number;
isFile: boolean;
isDirectory: boolean;
}>name is the last segment of the canonicalized path. For the workspace
root this is the empty string: (await fs.stat("/")).name === "".
stat follows symlinks transparently; there is no lstat. See the
note on internal symlink support in the appendix.
When a parent path segment is itself a file,
statreportsENOENT(because resolution returnsnullfor that case) rather thanENOTDIR.mkdirandwriteFileraiseENOTDIRexplicitly for the same shape β see the error table.
const s = await fs.stat("/workspace/build/out.wasm");
console.log(`${s.size} bytes, modified ${new Date(s.mtime).toISOString()}`);find(
directory: string,
pattern?: string, // simple glob (`*.ts`, `**/*.md`)
options?: {
limit?: number;
offset?: number;
},
): Promise<Array<{ path; type: "file" | "dir" }>>Resolves directory first: throws ENOENT if the directory does not
exist and ENOTDIR if directory points at a file. The glob is
matched against each candidate's path relative to directory, not
its absolute path β so **/*.ts under /workspace/src matches
a/b.ts, not /workspace/src/a/b.ts.
The glob supports *, **, **/, and ?. Character classes and
brace expansions are matched literally.
// Every TypeScript file in the project.
const ts = await fs.find("/workspace/src", "**/*.ts");
// Everything under a directory (no pattern).
const all = await fs.find("/workspace/notes");ls(prefix: string): Promise<string[]>Flat list of every file at or under prefix. The match is
segment-aware, not pure string-prefix: ls("/workspace/notes")
returns the file /workspace/notes (if it is a file) and every file
under /workspace/notes/β¦, but never /workspace/notes-archive/x.
Cheaper than find when you don't need the directory rows.
ls does not validate the prefix β a missing path returns []
silently rather than throwing ENOENT. Use stat first if you need to
distinguish "empty directory" from "no such directory".
const paths = await fs.ls("/workspace/.agents/skills");Workspace.fs.grep accepts this interface:
interface GrepOptions {
regex?: boolean;
ignoreCase?: boolean;
context?: number;
limit?: number;
offset?: number;
include?: string;
}
interface WorkspaceGrepContextLine {
line: number;
text: string;
isMatch: boolean;
}
interface WorkspaceGrepMatch {
path: string;
line: number;
text: string;
context?: WorkspaceGrepContextLine[];
}
grep(
pattern: string,
path: string,
options?: GrepOptions,
): Promise<WorkspaceGrepMatch[]>Matching is literal and case-sensitive by default. Set regex: true to
interpret pattern as a regular expression and ignoreCase: true to ignore
letter case. context adds that many lines before and after each match.
include is a glob relative to a searched directory. limit and offset
paginate matching lines.
path may be a directory or a single file. Directory searches return matches
in deterministic depth-first discovery order, then line order within each
file. Results are not globally sorted by full path.
const hits = await fs.grep("TODO", "/workspace/src", {
ignoreCase: true,
include: "**/*.ts",
});
for (const hit of hits) {
console.log(`${hit.path}:${hit.line}: ${hit.text}`);
}Workspace.runtime exposes a narrower container-side variant that accepts only
ignoreCase and treats its pattern as a literal string. See
05. Shell Interface for that variant.
Errors thrown by fs are POSIX-style β a NodeJS.ErrnoException-shaped
object with a code property (and a path property where it applies) β
so handlers from Node code port over directly.
| Code | When |
|---|---|
ENOENT |
Path does not exist and force is not true. Also raised by stat when a parent segment turns out to be a file. |
ENOTEMPTY |
Path is a non-empty directory and recursive is not true. |
ENOTDIR |
A parent path segment is a file (raised explicitly by mkdir and writeFile; find raises it when its directory argument is a file). |
EISDIR |
Expected a file, got a directory (e.g. readFile on a dir, writeFile on /). |
EEXIST |
mkdir without recursive: true on an existing path. |
EINVAL |
Invalid path or unsupported options. |
ELOOP |
Symlink traversal exceeded 40 hops. Thrown by the internal resolver when the node:vfs adapter wires up a cycle. |
EPERM |
Operation is forbidden, e.g. deleting the workspace root. |
EIO |
Backing storage failed unexpectedly. |
EACCES |
Reserved for future mount layer (see 06. Mount Interface). No code path in workspace-fs currently throws it. |
EROFS |
Reserved for future mount layer (see 06. Mount Interface). No code path in workspace-fs currently throws it. |
async function readConfig(): Promise<Config> {
try {
const text = await this.workspace.fs.readFile("/workspace/config.json", "utf8");
return JSON.parse(text) as Config;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// First boot: seed a default config and return it.
const seed: Config = { version: 1, theme: "dark" };
await this.workspace.fs.writeFile(
"/workspace/config.json",
JSON.stringify(seed, null, 2),
);
return seed;
}
// Anything else (EIO, ...) is a real problem β let it surface so
// the agent's outer error handler logs it and the request fails
// loudly.
throw err;
}
}// Equivalent to `rm -rf` β never throws on missing paths.
await this.workspace.fs.rm("/workspace/build", { recursive: true, force: true });For reference, here's the public surface of node:fs/promises and how it
maps to Workspace.fs:
node:fs/promises |
Workspace.fs |
Notes |
|---|---|---|
readFile |
readFile |
Stream by default; pass "utf8" for a string. |
writeFile |
writeFile |
Accepts string, Uint8Array, or ReadableStream (consumed incrementally). |
appendFile |
β | Read, concat, write. Not a primitive. |
mkdir |
mkdir |
{ recursive: true } supported. |
rmdir |
rm |
One method for files and dirs (matches modern Node). |
rm |
rm |
{ recursive: true } for non-empty dirs. |
unlink |
rm |
Same. |
readdir |
readdir |
Always returns dirent-shaped entries. |
stat / lstat |
stat |
No lstat; stat follows symlinks. See note below. |
truncate |
β | Read, slice, write. |
chmod |
β | Pass mode to writeFile / mkdir at create time. There is no way to chmod an existing file without rewriting its bytes. |
chown |
β | No ownership model. |
utimes |
β | mtime is managed by the VFS. |
cp / copyFile |
β | Read + write. |
rename |
β | Read + write + delete. |
realpath |
β | Paths are already canonical. |
symlink / readlink |
β | Not on the public surface; see note below. |
watch |
β | Low-level primitive in fs/watch.ts (createWatcher, createWatchAsyncIterable, WatchHandle, WatchOptions); not exposed on the WorkspaceFilesystem class. |
open / FileHandle |
β | Use streams instead. |
glob |
find |
Limited glob support (*, **, **/, and ?). |
| β | grep |
Not in node:fs; literal by default, with optional regular expressions. |
| β | find |
Recursive directory walk with an optional glob, relative-rooted. |
| β | ls |
Flat list of file paths under a directory (segment-aware). |
Symlinks exist as an internal primitive used by the node:vfs
adapter β the schema supports a 'symlink' node type with a
link_target, and the resolver in fs/resolve.ts follows them with a
40-hop cap (throws ELOOP on overflow). They are not part of the
public WorkspaceFilesystem surface: there are no fs.symlink or
fs.readlink methods on Workspace.fs, and callers should treat all
visible paths as if they pointed straight at real files.