Under the Hood

Everything the filesystem API does, you can do directly.

The node:fs-style surface is a convenience layer over a set of composable primitives, and those primitives are exported too. They map one to one onto the specification. If you'd rather work with the block graph itself, this page builds one small tree from nothing, reads it back, and then lists the rest of the toolkit for lookup.

#Build a tree by hand

We'll make a directory holding one file and one symlink, then bind it to a name. Four steps: open a store, write the file's content, assemble the directory, commit.

#1. Open a store

import { initStore, memoryStore } from "unfs";

const store = await initStore(memoryStore());

initStore writes a meta:format block recording the hash function and writer parameters this store uses. Every write that goes through it follows those. The defaults are sha2-256 and DEFAULT_PARAMS.

#2. Write the file's content

import { writeFileContent } from "unfs";

const content = await writeFileContent(store, new TextEncoder().encode("hello unfs\n"));

Content is chunked (FastCDC by default), deduplicated, and stored under the hash of each chunk. What comes back describes that content, ready to spread into a directory entry.

#3. Assemble the directory

import { writeDirectory } from "unfs";

const dirRef = await writeDirectory(store, [
  ["hello.txt", { t: "f", ...content, m: 0o644 }],
  ["link", { t: "l", tgt: "hello.txt" }],
]);

Entries carry their own unix metadata. t is the type ("f" for a file, "d" for a directory, "l" for a symlink), m is the mode, and a symlink puts its target in tgt. You get back a ref to the directory node.

#4. Commit it under a name

A directory ref is just a hash, so it never changes. To have something that moves as you make changes, wrap it in a root node (a "commit") and bind that to a mutable name:

import { CODEC_NODE, encodeNode, putBlockRef, setRef } from "unfs";

const root = await putBlockRef(
  store,
  encodeNode({ v: 1, t: "r", e: { t: "d", r: dirRef, m: 0o755 }, ts: 1_700_000_000 }),
  CODEC_NODE,
);
await setRef(store, "heads:main", root);

That's the whole write path. createFs does exactly this on every call you make to it.

#Read it back

Start from the name, resolve a path, then read:

import { getRef, readDirectory, readFileBytes, resolvePath } from "unfs";

const rootRef = await getRef(store, "heads:main");
const entry = await resolvePath(store, rootRef!, "hello.txt"); // symlinks are not followed
const bytes = await readFileBytes(store, entry); // the whole file
const slice = await readFileBytes(store, entry, { offset: 6, length: 4 }); // "unfs"
const listing = await readDirectory(store, dirRef); // sorted [name, entry] pairs

If you only want one entry, lookupEntry is the point-lookup counterpart to readDirectory. On a paged directory it binary-searches and fetches only the segment it touches, rather than the whole listing:

import { lookupEntry } from "unfs";

const helloEntry = await lookupEntry(store, dirRef, "hello.txt");

#Change one entry

Rewriting a whole directory to change one name would be wasteful in a wide one. updateDirectory applies a single set, replace, or delete, touching only the segments affected:

import { updateDirectory } from "unfs";

const nextDir = await updateDirectory(store, dirRef, "readme.md", {
  t: "f",
  ...content,
  m: 0o644,
});
const withoutLink = await updateDirectory(store, nextDir, "link", undefined); // removes it

The ref you get back is byte-identical to writing the whole updated listing out yourself. That's what makes single-entry commits in wide directories fast.

#Move it to another store

Pack the root into a CARv1 file and import it elsewhere:

import { exportPackBytes, importPack } from "unfs";

const pack = await exportPackBytes(store, [root]); // canonical, byte-reproducible CARv1
const target = await initStore(memoryStore());
const { roots } = await importPack(target, pack); // hash-verified and closure-checked

Packs & Sync covers the filesystem-level versions of these, plus store-to-store sync.

#Reference

#Opening a store: initStore or openStore

initStore writes a fresh meta:format block the first time it sees a store, and on every later call checks that any hash or params you pass still agree with what's recorded. openStore only reads and validates an existing meta:format. It takes no hash or params options, since those were fixed by whoever initialized the store, and it throws NOT_FOUND when there's nothing there.

Reach for initStore when you're the one bringing the store into existence, and openStore when you're reopening someone else's: a shared KV namespace, or a driver whose creation you don't control.

import { initStore, memoryStore, openStore } from "unfs";

const kv = memoryStore();
await initStore(kv); // the first caller creates it
const reopened = await openStore(kv); // later callers just open it

#Custom hashing and chunking

initStore's hash option takes any MultihashFn, and sha256 is the only one built in. Its params option is a partial WriterParams merged over DEFAULT_PARAMS: pageThreshold (where directories start paging, and the packing threshold), chunkMin, chunkAvg and chunkMax (the FastCDC bounds), maxChunkRefs, and inlineThreshold.

Both are recorded in meta:format and govern every write through the store. A tree built under different parameters cuts differently, so its blocks won't deduplicate against one built under the defaults.

import { DEFAULT_PARAMS, initStore, memoryStore, sha256 } from "unfs";

const store = await initStore(memoryStore(), {
  hash: sha256,
  params: { ...DEFAULT_PARAMS, pageThreshold: 65_536, chunkAvg: 16_384 },
});

writeFileContent separately accepts a one-off Chunker, independent of the store's recorded parameters:

import { fastcdcChunker, fixedSizeChunker, writeFileContent } from "unfs";

const data = new TextEncoder().encode("hello unfs\n");
await writeFileContent(store, data, { chunker: fixedSizeChunker(4096) });
await writeFileContent(store, data, {
  chunker: fastcdcChunker({ min: 4096, avg: 16_384, max: 65_536 }),
});

#Refs and codecs

A Ref is a binary CIDv1: a codec plus a multihash. CODEC_RAW marks opaque bytes such as a file chunk, and CODEC_NODE marks a dag-cbor node. The putBlockRef call above wrote the root under CODEC_NODE, and writeFileContent and writeDirectory do the same internally for the blocks they write.

import { formatRef, parseRef, refEquals } from "unfs";

const text = formatRef(root); // "b" + base32(bytes), the same form Ref#toString() gives
const same = parseRef(text);
refEquals(root, same); // true

#Block primitives

getRef, setRef, and putBlockRef are built on a lower layer. Reach for it when you want raw block bytes instead of a typed Node, or want to write a block without wrapping it in a ref:

import { getBlock, getNode, hasBlock, putBlock } from "unfs";

const multihash = await putBlock(store, someBytes); // a binary multihash, not a Ref
const raw = await getBlock(store, rootRef); // hash-verified bytes
const node = await getNode(store, rootRef); // fetched, verified, decoded, and cached
const exists = await hasBlock(store, rootRef);

decodeNode is the inverse of encodeNode. It strict-decodes canonical CBOR bytes into a typed Node, applying the same structural validation a read does. It needs a hash spec (store.hash, or any { code, size }) to check the digest length of embedded refs:

import { decodeNode, getBlock } from "unfs";

const node = decodeNode(await getBlock(store, rootRef), store.hash);

#Paths

resolvePath is built on splitPath, which is exported separately. It splits a path into components, drops empty segments and ".", and rejects "..", since the block graph is a DAG with no parent pointers and upward traversal has nothing to resolve against.

import { splitPath } from "unfs";

splitPath("a/./b//c"); // ["a", "b", "c"]

#Managing names

deleteRef removes a name, and does nothing if it's already gone. listRefs enumerates every live name, skipping tombstones, and needs kv.keys:

import { deleteRef, listRefs } from "unfs";

for await (const [name, ref] of listRefs(store)) {
  console.log(name, ref.toString());
}
await deleteRef(store, "heads:old");

Pass a name prefix to enumerate one slice of the namespace. It narrows the driver's key scan rather than filtering afterwards, so a value is read only for the names that match — worth reaching for whenever one namespace holds far more names than you want:

for await (const [name, ref] of listRefs(store, "heads:")) {
  console.log(name, ref.toString());
}

deleteRefIfMatch is the conditional form, and the one to reach for whenever you're deleting a name because of what you last read it to hold. It unbinds only if the ref still holds expected, in one step at the driver — reading first and then calling deleteRef would drop any commit that landed in between, silently, and report success:

import { deleteRefIfMatch, getRef } from "unfs";

const head = await getRef(store, "heads:old");
if (head !== undefined && !(await deleteRefIfMatch(store, "heads:old", head))) {
  // Someone moved it while we were deciding. Re-read; don't delete blind.
}

It needs a driver with kv.deleteIfMatchmemoryStore(), fsStore, sqliteStore and lmdbStore have it — and raises UNSUPPORTED on one without, rather than falling back to the race.

#Errors

Everything validates strictly on read: canonical CBOR, CIDv1 refs, tree invariants. Failures throw a typed UnfsError, exported alongside its UnfsErrorCode union:

import { UnfsError, type UnfsErrorCode } from "unfs";

try {
  await getBlock(store, rootRef!);
} catch (error) {
  if (error instanceof UnfsError) {
    error.code satisfies UnfsErrorCode;
  }
}
codemeaning
MALFORMEDThe input violates the format: non-canonical encoding, out of range, bad structure.
TRUNCATEDThe input ended mid-item.
UNSUPPORTEDRecognized but unsupported: a foreign codec, a future version, an unknown hash.
NOT_FOUNDA referenced block or name isn't in the store.
NOT_DIRA path component resolved to something that isn't a directory.
LIMITA bound was exceeded: nesting depth, frame size, directory size.
INVALIDA caller contract violation, such as bad parameters or ".." in a path.
BUSYA store lock couldn't be taken, so the operation never ran — see compare-and-set.

The lowest-level primitives (the canonical CBOR codec, varint, base32, and the name and segment helpers) stay unexported on purpose. The conformance vectors they're tested against are public.

unfs  A filesystem you can put in any key–value store.