React
One hook, every Files verb. Imperative methods with upload progress and ambient error state, plus optional reactive useList / useFile / useSearch.
files-sdk/react brings the full Files API to the browser as a React hook. useFiles returns one method per Files verb against the gateway you mounted, plus ambient upload/error state. Keys are plain strings (relative to your authorized prefix).
const files = useFiles({
endpoint: "/api/files", // default "/api/files"
headers: () => ({ authorization: token }), // sent on every call (static or lazy)
});
The verbs
// Reads
await files.head("docs/a.txt"); // → StoredFile (lazy body)
await files.exists("docs/a.txt"); // → boolean
await files.list({ prefix: "docs/" }); // → { items, prefixes?, cursor? }
await files.url("img.png"); // → string (for <img src>)
await files.capabilities(); // → AdapterCapabilities
// Writes
await files.upload(file); // → { key, size, type, etag }
await files.delete("old.txt");
await files.copy("a.txt", "b.txt");
await files.move("a.txt", "c.txt");
// Streaming iterators
for await (const f of files.listAll()) {
/* every object */
}
for await (const f of files.search("docs/*.pdf")) {
/* glob/regex */
}
Every verb mirrors the SDK’s signature, including the bulk array forms — files.delete([a, b]), files.head([a, b]), files.download([a, b]) — which return the same partial-result shapes ({ deleted, errors? }, …) and never throw on a single bad key.
Uploading
upload has three forms, resolved by argument shape:
// Keyless - the server mints the key (the common case).
const { key } = await files.upload(file);
// Explicit key - you choose it (subject to the authorized prefix).
await files.upload("avatars/me.png", file, { contentType: "image/png" });
// Bulk - pooled at the configured concurrency.
await files.upload([
{ key: "a.txt", body: "one" },
{ key: "b.txt", body: "two" },
]);
Track progress per call, or read the hook’s ambient state:
await files.upload(file, {
onProgress: (p, perFile) => console.log(p.fraction, perFile[0]?.status),
});
// or read the ambient state this hook keeps across every upload() call:
files.isUploading; // true while any upload() from this hook is running
files.uploads; // FileUploadState[] - one entry per file
files.progress; // { loaded, total, fraction } over the current entries
uploads accumulates: every file from every upload() call — keyless, keyed, and each item of a bulk upload([...]), whether they run one after another or at the same time — gets one entry. An entry keeps its position for the whole upload and is replaced by a fresh snapshot each time it changes, so it re-renders like any other state. Its status moves from "uploading" to a terminal value on every path: "success", "error" (with error set to the FilesError), or "aborted". onProgress follows the same lifecycle and fires once more after the terminal status.
files.reset() clears the finished entries. Uploads that are still running keep their entries, and isUploading stays true until they settle.
Downloading vs linking
Two tools, two jobs:
// download(key) → StoredFile: fetched via your gateway (proxied, or redirected
// to a signed URL). Works on every adapter. Use it when you need the data
// programmatically.
const file = await files.download("report.pdf");
const blob = await file.blob(); // lazy - only fetched on access
const text = await file.text();
const chunk = await files.download("video.mp4", {
range: { start: 0, end: 1023 },
});
// url(key) → string: a direct link. Use it for <img src> / <a href> / <video>.
// Only meaningful when the adapter can sign (capabilities.signedUrl.supported).
const src = await files.url("avatar.png");
The returned StoredFile is the same lazy, File-shaped value the SDK returns — blob()/text()/arrayBuffer()/stream() plus key, size, type, etag, metadata.
A ranged download must come back as 206 Partial Content. If the gateway (or the storage it redirects to) ignores the range and sends the whole object, the call rejects rather than handing you the full body as if it were the slice.
Errors & cancellation
A thrown error is a FilesError (.code of "NotFound" | "Unauthorized" | "Conflict" | "ReadOnly" | "Provider"), rebuilt from the wire envelope. The last error from any verb is also mirrored to files.error for render-time display. That includes errors thrown while you iterate listAll() or search():
try {
await files.delete(key);
} catch (e) {
// e instanceof FilesError
}
// …and in the component's render:
return files.error ? (
<p>
{files.error.code}: {files.error.message}
</p>
) : null;
The hook owns an AbortController merged into every call. files.abort() cancels everything in flight (their uploads entries end "aborted"); unmounting does the same automatically. files.reset() clears the ambient error and the finished upload entries, and re-arms after an abort. Pass a per-call signal (or a hook-level signal option) to cancel selectively.
Reactive reads
For a file browser you usually want declarative state, not imperative calls. Three optional hooks wrap the read verbs with data / error / isLoading / isFetching / refetch, aborting their in-flight request on dependency change or unmount:
import { useList, useFile, useSearch } from "files-sdk/react";
const { data, isLoading, refetch } = useList({ prefix: "docs/" });
const file = useFile(selectedKey); // head() for a preview; disabled without a key
const hits = useSearch(query, { match: "substring" });
They are deliberately cache-free and dependency-light. For shared caching, bring React Query or SWR and call a useFiles() method inside your queryFn — the imperative methods are designed for exactly that.
Outside React
The hook is a thin shell over createFilesClient from files-sdk/client — the same verb set, framework-agnostic, usable from Node, a worker, or a Vue/Svelte binding:
import { createFilesClient } from "files-sdk/client";
const client = createFilesClient({
endpoint: "https://app.example.com/api/files",
});
const file = await client.download("report.pdf");