The Root handle exposes a tight set of write verbs. Each one is atomic at the destination — no half-written intermediate state — and goes through the same boundary checks as reads.
await fs.write("state.json", body);
await fs.create("seed.json", initial); // throws if exists
await fs.writeJson("config.json", state);
await fs.append("logs/today.log", line);
await fs.copyIn("inbox/upload.bin", "/tmp/upload.bin");
await fs.move("notes/draft.md", "notes/published.md");
await fs.remove("logs/yesterday.log");
await fs.mkdir("snapshots/2026/05");- Resolve the relative target against the canonical root and reject anything that escapes (
outside-workspace). - If
mkdir: true, create missing parent directories with the parent fd pinned. - Open the parent directory by fd. Subsequent rename/unlink uses the parent fd, not the path string, so a parent-directory symlink swap mid-call cannot divert the write.
- Write data to a sibling temp file in the same directory.
- Atomically rename the temp file over the destination.
- Stat the resulting fd and verify identity.
A failure at any point either leaves the destination at its previous contents or surfaces an FsSafeError — never a partially-written file at the destination path.
All mutation verbs accept denyMutations?: DenyMutationPolicy, either as a root default or per-call option:
const fs = await root("/srv/workspace", {
denyMutations: {
paths: ["/srv/workspace/.env"],
prefixes: ["/srv/workspace/.ssh"],
},
});
await fs.write(".env", "x"); // throws FsSafeError code "denied-path"
await fs.remove(".ssh/id_rsa"); // throws FsSafeError code "denied-path"paths blocks exact absolute paths. prefixes blocks absolute paths and everything below them. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults.
Overwrite or create. Always atomic.
await fs.write("state/last-run.json", JSON.stringify(run));
await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });data accepts string | Buffer. options are { denyMutations?: DenyMutationPolicy; encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }. mode sets the file's POSIX mode; if omitted, falls back to the mode from RootDefaults and then to umask. overwrite defaults to true; set it to false for the same no-clobber behavior as create().
Don't-clobber variant of write(). Throws already-exists if the target is there.
try {
await fs.create("config/seed.json", initial);
} catch (err) {
if (err instanceof FsSafeError && err.code !== "already-exists") throw err;
}JSON.stringify(value, replacer, space) + atomic write. Adds a trailing newline by default.
await fs.writeJson("config.json", state, { space: 2 });
await fs.writeJson("compact.json", state, { trailingNewline: false });Options:
type RootWriteJsonOptions = {
encoding?: BufferEncoding;
mkdir?: boolean;
mode?: number;
replacer?: (this: any, key: string, value: any) => any | (number | string)[];
space?: number | string;
trailingNewline?: boolean; // default true
};createJson(rel, value, options?) is the don't-clobber variant.
Open in append mode, write, sync the file handle, and close. Honors mkdir for the parent directory and syncs the parent directory when the append creates the file. Pass prependNewlineIfNeeded: true to insert a \n if the file does not already end in one.
await fs.append("logs/today.log", `[${ts}] ${line}\n`);
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });For high-volume logging, consider openWritable and a long-lived append handle. Direct append-mode writes preserve kernel append semantics, but they are not atomic against external rotators that rename or unlink the target.
Bring a file from outside the root into the root, atomically. The source path must be absolute. The library streams the source through the boundary, writes to a sibling temp, and renames over the destination.
await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
maxBytes: 64 * 1024 * 1024,
});Options: { encoding?, mkdir?, maxBytes?, sourceHardlinks? }. Use sourceHardlinks: "reject" to refuse if the source itself is a hardlinked alias.
Rename one path inside the root to another. Defaults to no clobber:
await fs.move("incoming/foo.txt", "archive/foo.txt");
await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });Both from and to are bounded; .. in either is rejected.
Unlink a file or rmdir an empty directory. Non-empty directories throw not-empty. For atomic directory replacement, use replaceDirectoryAtomic.
await fs.remove("logs/yesterday.log");
await fs.remove("snapshots/empty-dir"); // ok
await fs.remove("snapshots/full-dir"); // throws not-emptymkdir -p. Creates missing parents.
await fs.mkdir("snapshots/2026/05");Treats "" / "." as the root itself. Useful when a generic helper computes a relative directory and might end up at the root.
const targetRel = path.relative(fs.rootReal, candidateAbs); // could be "" if candidateAbs === root
await fs.ensureRoot(); // accepts "" without throwingWhen write doesn't fit (very large outputs, slow producers), open a writable handle:
const opened = await fs.openWritable("logs/current.log", { writeMode: "append" });
try {
for await (const chunk of source) {
await opened.handle.appendFile(chunk);
}
} finally {
await opened.handle.close();
}Options: { mkdir?, mode?, writeMode? }, where writeMode is "replace" (default), "append", or "update". replace truncates existing files; update keeps existing contents. Streaming writes go directly to the destination — there is no atomic-rename step. If you need both streaming and atomicity, write to a sibling temp yourself and rename when done; the atomic helpers can do this for you.
Set mkdir: true once on root(); pass text encodings per call when needed:
const fs = await root("/srv/workspace", {
mkdir: true,
});
await fs.write("notes/today.txt", "ascii", { encoding: "utf8" });
await fs.write("data/blob.bin", buffer); // mkdir true, no encoding because data is Buffer
await fs.write("data/blob.bin", buffer, { mkdir: false }); // override| Code | When |
|---|---|
outside-workspace |
Target resolves outside the root. |
already-exists |
create() / createJson() / move({ overwrite: false }) hit an existing target. |
not-found |
Parent does not exist and mkdir is false. |
not-empty |
remove() on a non-empty directory. |
not-removable |
remove() could not unlink/rmdir (typically permissions or device busy). |
path-mismatch |
Post-write fd identity check did not match. Almost always a parallel writer. |
too-large |
copyIn() source exceeded maxBytes. |
symlink |
A path component is a symlink and policy is reject. |
hardlink |
sourceHardlinks: "reject" saw nlink > 1. |
Full list in Errors.
const next = JSON.stringify(state);
const prev = await fs.readText("state.json").catch(() => "");
if (prev !== next) await fs.write("state.json", next);const stagingDir = "snapshots/incoming";
await fs.mkdir(stagingDir);
for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body);
await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });For a true commit-or-rollback over a directory, use replaceDirectoryAtomic.
const today = `logs/${formatDate(new Date())}.log`;
try {
await fs.create(today, "");
} catch (err) {
if (!(err instanceof FsSafeError) || err.code !== "already-exists") throw err;
}
await fs.append(today, line);- Atomic writes — the lower-level
replaceFileAtomicand friends. - JSON files — standalone JSON helpers without going through
root(). - Reading — companion read API.
- Errors — every code, when it fires.