Filesystem
Local filesystem - the dev/test adapter. Uses node:fs/promises with a sidecar .meta.json per file. Not for production.
Installation
This adapter has no extra peer dependencies - the runtime (Node or Bun) provides everything it needs.
npm install files-sdkpnpm add files-sdkyarn add files-sdkbun add files-sdknub add files-sdkaube add files-sdkUsage
Local filesystem. The dev/test adapter - point it at a directory and it implements the same Adapter contract as the cloud adapters using node:fs/promises. Each upload writes the body and a sidecar .meta.json file alongside it (Content-Type, ETag, user metadata) so reads round-trip cleanly. Not for production: there’s no replication, no signing, no auth.
import { Files } from "files-sdk";
import { fs } from "files-sdk/fs";
// Writes objects under `./.uploads` with a sidecar `.meta.json`
// per file for Content-Type, ETag, and user metadata. Designed for
// dev and CI - same Adapter contract as the cloud adapters, so swap
// it in via env without changing call sites.
const files = new Files({
adapter: fs({
root: "./.uploads",
// Optional: configure if a dev server exposes the same root over
// HTTP, so url() returns a browser-friendly URL instead of file://.
// urlBaseUrl: "http://localhost:3000/files",
}),
});
Options
rootstring
Absolute or relative directory the adapter manages. Created on first upload if it doesn't exist. All operations are scoped to this root — keys that resolve outside it (e.g. `../etc/passwd`) throw `Invalid`.
stringurlBaseUrl?string
Optional URL prefix for `url()`. When set, `url(key)` returns `${urlBaseUrl}/${key}` — useful when a dev server (Next.js `/public` mount, `serve-static`, etc.) is exposing the same root. When unset, `url()` returns a `file://` URL — appropriate for CLIs/tests, not for browsers.
stringStorage layout
Body at `${root}/${key}`; sidecar at `${root}/${key}.meta.json`. Sidecars survive cp -r / git mv / partial-tree deletion. list() hides them. ETag is a SHA-1-derived stable hash computed at upload time. Files written into root by hand without a sidecar are still readable - contentType falls back to application/octet-stream and etag is absent.
The separate body and sidecar layout cannot provide a cross-process atomic ETag compare-and-set boundary. The adapter therefore advertises no conditional operations and rejects them before filesystem I/O; it does not emulate CAS with a read followed by a rename.
Directories aren’t objects. head, exists, download, copy, and move on a key that names a directory report NotFound, the way a cloud store treats a prefix with no object, and delete on one is a no-op. A key that ends in / (or in a . or .. segment) names a folder outright, so every method rejects it with Invalid instead of resolving it onto the file it would otherwise alias (dir/ → dir). A write that would need a directory to be a file (EISDIR, ENOTEMPTY) fails with Conflict. If root itself isn’t a directory, delete fails with Invalid instead of looking like an empty store.
Compatibility
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | |
delete |
✅ | |
list |
✅ | |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | |
url |
⚠️ | Returns a file:// URL by default - fine for CLIs and tests, not browsers. With urlBaseUrl set, returns <urlBaseUrl>/<key> so a dev server (Next.js /public mount, serve-static, etc.) can deliver the body. responseContentDisposition throws because neither file:// nor static-server URLs have a signature mechanism in which to bind the override. Only urlBaseUrl makes capabilities.publicUrl true: a file:// link isn’t a public URL, so without it the gateway proxies downloads instead of redirecting. |
signedUploadUrl |
❌ | Throws - the fs adapter has no built-in upload server, signer, or verifier, so it cannot bind expiry, content type, or size limits into an upload capability. Upload through files.upload() or an application route that enforces those controls server-side. |
| resumable uploads | ✅ | upload({ control }) writes chunks to a <key>.fls-part partial next to the body (hidden from list(), reserved for writes) and renames it into place on completion. The partial is never opened through a symlink: a link at that path is replaced when the upload begins, and a chunk or completion that finds one fails with Conflict. |
| conditional operations | ❌ | Body and metadata are separate files, so there is no honest cross-process native CAS primitive. |