csfs
Client Side File System
About
One read API for data that lives somewhere a browser can reach: a static HTTP host, a directory the user picked, the origin private file system — or inside a zip archive in any of those.
import { httpFileSystem } from "@emdzej/csfs-http";
import { withArchives } from "@emdzej/csfs-zip";
const fs = withArchives(httpFileSystem("https://example.test/data"));
await fs.read("/pr/index.dat"); // Uint8Array
await fs.read("/media.zip#/photo.png"); // out of the archive, one Range request
const file = await fs.file("/huge.bin");
await file?.slice(0, 1024).bytes(); // 1 KB fetched, not the fileNothing is downloaded that was not asked for, and the same code runs against every backend.
Why the interface looks like this
A file is modelled on Blob — size, slice, arrayBuffer, bytes, stream, text. Reading part of a file is the operation that makes remote data usable at all: an archive is read from its end, a sorted index is binary-searched, a video is seeked. An interface whose only read is "give me the whole thing" forces a download per lookup. Because Blob and File already have this shape, the local backends need no adapter.
Absence is not an error. file() and directory() return null, so testing whether something exists needs no try/catch.
Writing is a separate interface. Two of the backends cannot write; a combined interface would make every consumer check capabilities it never uses.
Archives
Two ways in, for two different situations.
// `#` addressing, when the caller knows the archive is there
await fs.read("/pack.zip#/inside.txt");
await fs.read("/outer.zip#/inner.zip#/deep.txt"); // nesting works
// an archive answering for a directory that does not exist
const fs = withTransparentArchives(httpFileSystem(base), [
{ archive: "/drawings.zip", serves: "/drawings", entry: "basename" },
]);
await fs.read("/drawings/1132/1132C000.png");That second case is not exotic. A parts catalogue ships 38,488 drawings as one flat drawings.zip and as a tree bucketed by name, and every reference in the data uses the tree's shape. A real file always wins, so a tree that was extracted keeps working, and a half-extracted one falls back file by file.
The manifest
HTTP cannot list a directory. A static host will serve any file you name and tell you nothing about what is there, so a tree served over HTTP carries a description of itself.
csfs manifest ./data --label "my tree" --archive "/drawings.zip:/drawings:basename"Measured on a real tree: 43,915 entries describing 15.30 GB come to 4.02 MB of JSON, 0.42 MB gzipped — 9.4:1 at a host's default level, paid once when the tree is opened.
Extracted, the same bytes need 228,515 entries and the manifest grows to 14.72 MB. Declaring an archive is what makes the difference, because paths are what a manifest costs, not bytes.
Packages
| Package | What it is |
|---|---|
@emdzej/csfs-core | The contract, paths, BlobFile/RangeFile, walk |
@emdzej/csfs-zip | Archives as a file system, and # addressing |
@emdzej/csfs-http | Static HTTP, manifest-driven, Range reads |
@emdzej/csfs-fsa | A picked directory (File System Access) |
@emdzej/csfs-opfs | The origin private file system |
@emdzej/csfs-node | node:fs, for tooling and tests |
@emdzej/csfs-manifest | The manifest format and its builder |
@emdzej/csfs-cli | Build manifests and inspect a tree from a terminal |
What each backend costs
- HTTP — one
Rangerequest per read. Needs a manifest and a host that honoursRange; a host that ignores it is rejected rather than trusted, because its 200 response is the whole file and using that as a slice returns wrong bytes silently. - A picked directory — each path segment is a round trip. Permission does not survive a reload, so a remembered directory needs one click.
- OPFS — no prompt ever, which is the reason to import into it. But it is evictable unless
persist()is granted, and shared across the origin, so use anamespace.
Zip handling is @zip.js/zip.js, not ours — zip64, data descriptors, cp437 names, and archives whose local headers lie while the central directory holds the truth.
