Skip to content

csfs

Client Side File System

GitHub · csfs.emdzej.pl

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.

ts
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 file

Nothing 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 Blobsize, 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.

ts
// `#` 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.

sh
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

PackageWhat it is
@emdzej/csfs-coreThe contract, paths, BlobFile/RangeFile, walk
@emdzej/csfs-zipArchives as a file system, and # addressing
@emdzej/csfs-httpStatic HTTP, manifest-driven, Range reads
@emdzej/csfs-fsaA picked directory (File System Access)
@emdzej/csfs-opfsThe origin private file system
@emdzej/csfs-nodenode:fs, for tooling and tests
@emdzej/csfs-manifestThe manifest format and its builder
@emdzej/csfs-cliBuild manifests and inspect a tree from a terminal

What each backend costs

  • HTTP — one Range request per read. Needs a manifest and a host that honours Range; 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 a namespace.

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.

Used by: eperx, dialogysx, masax