files-sdk is a TypeScript SDK that wraps object and blob storage — S3, GCS, Azure, Vercel Blob, Dropbox, FTP, the local filesystem, 47 backends in total — behind one upload / download / list / url API.
Why I starred it
Every storage abstraction I've used eventually lies to you. You call list({ delimiter: "/" }) on a provider with no folder concept and get a flat array back with no error, or you set cacheControl on a provider that silently drops the header. The bug shows up in production, not in a type error. The README's claim — "one small, honest API" — is the kind of thing that's easy to say and hard to actually build, so I went looking for where it breaks.
How it works
The core is packages/files-sdk/src/index.ts, a single 2,782-line file that defines the Adapter interface every provider implements and the Files class that wraps it. The Adapter interface is honest by construction: instead of every method silently supporting every option, capability is opt-in via readonly flags — supportsRange, supportsDelimiter, supportsMetadata, supportsCacheControl. Files gates on these before ever calling the provider:
// src/index.ts — download()
if (op.options?.range) {
this.#assertRangeSupported(op.options.range);
}
Pass range to an adapter that never set supportsRange, and you get a FilesError before any network call — not a full-object download silently standing in for a partial one, not a dropped header. Files.capabilities surfaces the same flags at runtime (AdapterCapabilities), so an AI tool or a UI can branch on rangeRead instead of catching an error after the fact.
The plugin system is the more interesting design decision. FilesOptions.plugins is an ordered array wrapping the instance like an onion — plugins[0] outermost — and #dispatch folds them at construction time:
// src/index.ts — #dispatch()
let chain: InternalNext = base;
for (const wrap of this.#wraps.toReversed()) {
const next = chain;
chain = (nextOp) => wrap(nextOp, next);
}
return chain(op) as Promise<OperationResult<O>>;
Each plugin gets a wrap(op, next) that can transform the operation, veto it, or just observe — and an extend(files) that grafts new namespaced methods onto the instance. #applyExtensions walks every contributed key and throws on collision with an existing Files method (or a then key, which would make the instance thenable and corrupt await files) — a small detail that tells you someone hit that bug once.
The plugin I actually read end to end is src/dedup/index.ts. On upload it SHA-256-hashes the body via Web Crypto, writes the bytes once to a content-addressed blob under a .dedup/ prefix, and stores a tiny empty-body pointer at the logical key with the hash in metadata. Re-upload identical content and the byte transfer is skipped entirely — only the pointer write happens:
// test/dedup.test.ts
test("skips the byte upload for content already in the store", async () => {
const { adapter, blobUploads } = countingAdapter();
const files = withDedup({}, adapter);
await files.upload("a.txt", "hello");
await files.upload("b.txt", "hello");
expect(blobUploads()).toHaveLength(1);
});
copy() on a deduplicated key just relocates the pointer, so copying a de-duped file is near-free and both keys end up sharing the same blob. It's built on nothing but Web Crypto, so it works against every adapter that supports metadata — no native dependency, no provider-specific code path.
Resumable uploads get the same provider-agnostic treatment in src/internal/resumable.ts: chunk slicing, pause/resume gating, and per-chunk retry live in one orchestrator, and each adapter implements a thin ResumableDriver. The session token is a discriminated union keyed by provider (s3 tracks uploadId + partSize, dropbox tracks sessionId + offset, onedrive tracks an opaque uploadUrl) — different providers resume differently, and the SDK doesn't pretend otherwise.
Using it
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
await files.upload("avatars/abc.png", file, { contentType: "image/png" });
const url = await files.url("avatars/abc.png", { expiresIn: 300 });
Swapping providers is an import change (files-sdk/r2, files-sdk/gcs, files-sdk/azure), not a rewrite. For tools that pick a provider at runtime, files-sdk/loader reads FILES_SDK_PROVIDER and only imports the adapter you actually selected — the other 46 never touch your bundle, since each adapter is a separate exports entry point (sideEffects: false in package.json).
The files-sdk/claude subpath wraps a configured Files instance as an in-process MCP server for the Claude Agent SDK — createSdkMcpServer in src/claude/index.ts registers upload/download/list/delete tools with a canUseTool callback that gates writes behind approval by default. There's a matching CLI (files binary, src/cli/) with its own --json MCP mode via @modelcontextprotocol/sdk, so the same tool surface works from a terminal or from an agent.
Rough edges
"One small, honest API" undersells what this actually is. The core index.ts alone is 2,782 lines before you count 47 adapters, 15+ plugins, and framework bindings for React, Vue, Svelte, Next, Hono, Express, Fastify, NestJS, and more — 44,760 lines of source in the package. It's not small; it's disciplined. Whether that's a compliment depends on whether you need 5 adapters or all 47 — you only bundle what you import, but you're trusting one maintainer's release cadence for whichever five you picked.
And it mostly is one maintainer: git log shows Hayden Bleasel authoring the overwhelming majority of commits, with dependabot and a handful of contributors filling in the rest. Test coverage is real — 119 test files, 2,706 lines just for dedup and s3 — but it's mocked against a fakeAdapter, not live provider calls (the *.live.test.ts suites are opt-in and skipped by default). That's the right tradeoff for CI speed, but it means adapter correctness against a real S3 or GCS account isn't verified on every push, only when a maintainer manually triggers it.
Bottom line
If you're building something that has to work across S3-compatible object stores, blob platforms, and legacy protocols like FTP without hand-rolling an adapter layer, this is worth the dependency — the capability-flag gating means you find out about a missing feature at the call site, not in production. If you only ever talk to one provider, you don't need it; use that provider's SDK directly.
