Files Facade
Files facade
Files-SDK–style unified facade over any BaseStorage adapter. One small surface (upload, download, head, exists, delete, copy, move, list, listAll, url, signedUploadUrl) plus a .raw escape hatch — works against every adapter the package ships, including the in-memory adapter for tests.
import { Files } from "@visulima/storage";
import { S3Storage } from "@visulima/storage/provider/aws";
const files = new Files({
adapter: new S3Storage({ bucket: "uploads", region: "us-east-1" }),
});The user-supplied key is both the storage path and the metadata id — subsequent operations look up the object by the same key used to upload it.
When to use it
- One-shot puts and gets (uploading a
Blob, fetching aBuffer). - Provider-portable code — swap
S3StorageforBunnyStoragewithout changing call sites. - Bulk operations and cross-provider migrations (
upload([...]),delete([...]),transfer(from, to)). - AI-agent and LLM tool integrations (the
@visulima/storage/ai/*subpaths build on top ofFiles). - Quick scripts, serverless functions, and small services that don't need TUS / multipart / lifecycle hooks.
For client-driven uploads (browser forms, large resumable files, chunked transfers), skip the facade and use the adapter directly with an HTTP handler.
Method surface
| Method | Single-key signature | Bulk-array signature | Notes |
|---|---|---|---|
upload | (key, body, opts?) → Promise<FileObject> | (items: BulkUploadItem[], opts?) → Promise<…> | Accepts any supported body type. |
download | (key, opts?) → Promise<DownloadResult> | (keys: string[], opts?) → Promise<…> | Optional range for byte slices. |
head | (key, opts?) → Promise<FileObject> | (keys: string[], opts?) → Promise<…> | Metadata only. |
exists | (key, opts?) → Promise<boolean> | (keys: string[], opts?) → Promise<…> | Never throws for a missing object. |
delete | (key, opts?) → Promise<void> | (keys: string[], opts?) → Promise<…> | Uses the adapter's native bulk primitive when present. |
copy | (source, destination, opts?) → Promise<FileObject> | — | Server-side where supported; download + re-upload otherwise. |
move | (from, to, opts?) → Promise<FileObject> | (items: BulkMoveItem[], opts?) → Promise<…> | Uses native rename where one exists, otherwise copy + delete. |
list | (opts?) → Promise<FileObject[]> | — | Optional prefix filter and limit. Single page. |
listAll | (opts?) → AsyncGenerator<FileObject> | — | Pages internally; yields one at a time. |
url | (key, opts?) → Promise<string> | — | Signed-read URL when the provider supports it. |
signedUploadUrl | (key, opts?) → Promise<string> | — | Presigned-PUT URL for direct-from-browser uploads. |
raw (getter) | → unknown | — | The adapter's native client (S3Client, BlobServiceClient, …). |
Per-call OperationOptions
Every method accepts signal, timeout, and retries per call. Defaults set on the constructor are merged in, per-call wins; the signal is combined with any constructor signal via AbortSignal.any so either one aborts the operation.
const files = new Files({
adapter,
defaults: { timeout: 30_000, retries: { maxRetries: 5 } },
});
const ac = new AbortController();
await files.download("k", { signal: ac.signal, timeout: 5_000 });See the Retry mechanism page for the full RetryConfig shape and how onRetry integrates with the facade hooks.
Constructor prefix
Namespace every key under a prefix. Reads, writes, copies, listings, URLs, and signed uploads all resolve keys as ${prefix}/${key}; returned keys (including from list() / listAll()) have the prefix stripped back off. Leading/trailing slashes are normalized so "/users/" and "users" behave identically.
const files = new Files({ adapter, prefix: "tenant-x" });
await files.upload("docs/report.pdf", buffer); // → tenant-x/docs/report.pdf
const objects = await files.list({ prefix: "docs/" }); // result keys are un-prefixedlist() and listAll() scope results on a path boundary, so prefix: "users" never matches the sibling users-archive/.
Lifecycle hooks
Pass hooks: { onAction, onError, onRetry } to observe activity. Hooks are fire-and-forget — called, not awaited — and exceptions are swallowed so a hook can never fail the operation it observes.
const files = new Files({
adapter,
hooks: {
onAction: (event) => log.info(event.type, event.key, event.durationMs),
onError: (event) => log.error(event.type, event.key, event.error.message),
onRetry: (event) => log.warn(event.type, event.key, "retry attempt", event.attempt),
},
});Every event carries type (one of upload, download, head, exists, delete, copy, move, list, listAll, url, signedUploadUrl, transfer) plus the relevant key / keys / from / to fields. onAction / onError include durationMs; onRetry adds attempt (1-based) and error. The listAll terminal event fires even when the consumer breaks out of the for await early.
Body types
Files.upload(key, body, opts?) accepts:
| Type | Size hint | Notes |
|---|---|---|
string | derived | UTF-8 encoded. |
Buffer | derived | |
Uint8Array / ArrayBuffer / ArrayBufferView | derived | |
Blob | derived | Web Blob (Node 18+ and all modern runtimes). |
ReadableStream<Uint8Array> | required via opts.size for some adapters | Web stream, not Node Readable. |
NodeJS.ReadableStream | required via opts.size for some adapters | Node Readable. |
Stream inputs don't carry a length; pass opts.size when the underlying adapter or upload protocol needs to know it up front (multipart, content-length headers, presigned-PUT bodies).
Return shape: FileObject
Every method that returns object data returns the same provider-agnostic shape:
interface FileObject {
contentType: string;
etag?: string;
key: string;
lastModified?: Date | number | string;
metadata?: Record<string, unknown>;
size?: number;
}
interface DownloadResult extends FileObject {
body: Buffer;
}No S3-style ETag capitalization quirks, no Azure-specific fields leaking through — adapter-side mapping is the facade's job.
Typed .raw escape hatch
Files is generic over the adapter type, so files.raw is typed to the adapter's native client when you pin it:
import { Files, S3Storage } from "@visulima/storage";
import type { S3Client } from "@aws-sdk/client-s3";
const files = new Files({ adapter: new S3Storage({ bucket: "u", region: "us-east-1" }) });
// `files.raw` is `S3Client` (not `unknown`).
const s3: S3Client = files.raw;
await s3.send(/* …adapter-specific call… */);Adapters without a native client (e.g. DiskStorage) return undefined from raw. The in-memory adapter exposes its backing Map via raw.
Examples
Save a Blob from a form
const file = formData.get("avatar") as Blob;
await files.upload(`avatars/${userId}.png`, file, {
contentType: file.type,
metadata: { userId },
});Read into a Buffer
const { body, contentType, size } = await files.download(`avatars/${userId}.png`);
response.setHeader("Content-Type", contentType);
response.setHeader("Content-Length", String(size));
response.end(body);Download a byte range
// Fetch only the first 1 KiB to inspect a magic-number header without pulling the whole object.
const { body } = await files.download("video.mp4", { range: { start: 0, end: 1023 } });
// Resume from a saved offset — omit `end` to read to EOF.
const { body: tail } = await files.download("video.mp4", { range: { start: 8 * 1024 * 1024 } });range is the HTTP bytes=start-end semantics: both bounds 0-based, end inclusive. The facade checks adapter.supportsRange and throws synchronously for adapters that don't opt in, so the bandwidth saving is never silently lost client-side.
Upload with progress
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
const { size } = await stat(path);
await files.upload(key, createReadStream(path), {
contentType: "video/mp4",
size,
onProgress: ({ loaded, total }) => {
const percent = total ? Math.round((loaded / total) * 100) : "?";
log.info(`uploaded ${loaded} / ${total ?? "?"} bytes (${percent}%)`);
},
});For buffered bodies a coarse start/done pair is emitted. For streaming bodies each chunk reports loaded cumulatively. A throwing callback can never fail the upload — exceptions are swallowed. Adapters that report progress natively (reportsUploadProgress = true) supersede the facade's PassThrough wrap with byte-accurate events.
Multipart upload for large objects
await files.upload("backups/db-2026-05.tar.gz", stream, {
size,
multipart: { partSize: 16 * 1024 * 1024, concurrency: 4 },
});multipart: true uses the adapter's defaults; pass an object to tune the per-part size and the number of parts uploaded in parallel. Adapters that don't have a multipart primitive ignore the option.
Mint a signed URL for a private object
const url = await files.url(`docs/${docId}.pdf`, {
expiresIn: 900,
responseContentDisposition: `attachment; filename="${docId}.pdf"`,
});responseContentDisposition and responseContentType are honored where the provider supports them; otherwise the call throws UploadError(METHOD_NOT_ALLOWED) so the caller knows the feature is unsupported.
Presigned upload for a browser client
const uploadUrl = await files.signedUploadUrl(`raw/${id}.bin`, {
contentType: "application/octet-stream",
contentLength: bytes,
expiresIn: 600,
});
return Response.json({ uploadUrl });Not every adapter supports presigned PUT. Adapters without a primitive (UploadThing, Bunny) throw UploadError(METHOD_NOT_ALLOWED) with a message pointing to the supported alternative.
List a single page
const userFiles = await files.list({ prefix: `users/${userId}/`, limit: 100 });list() is intentionally simple: one page from the adapter, then filter by prefix in memory. Use listAll for full-bucket walks.
Walk every object (paginated async iterable)
for await (const file of files.listAll({ prefix: "avatars/" })) {
if (file.size && file.size > 5 * 1024 * 1024) {
log.warn("oversized avatar", file.key);
}
}listAll pages internally so callers don't have to thread a cursor. Returned keys have any constructor prefix stripped off; out-of-namespace keys are filtered out, mirroring list(). Breaking out of the for await early still emits the terminal onAction / onError hook event.
Copy and rename
await files.copy(`tmp/${id}`, `final/${id}`);
await files.move(`tmp/${id}`, `final/${id}`); // copy + delete, or native renameFor providers without server-side copy (Bunny, UploadThing), copy is a download + re-upload roundtrip and move = copy + delete is non-atomic — a failed delete leaves the source behind. Moving a key onto itself (move("k", "k")) is a no-op and returns the existing metadata without emitting a second hook event.
Bulk operations
Every single-key method has a bulk-array overload that runs the keys with bounded concurrency and returns a structured result instead of throwing on partial failure.
const { uploaded, errors } = await files.upload([
{ key: "a.txt", body: "A" },
{ key: "b.txt", body: "B" },
{ key: "c.txt", body: "C" },
]);
const { downloaded, errors: dErr } = await files.download(["a.txt", "b.txt", "c.txt"], {
concurrency: 4,
});
const { deleted } = await files.delete(["a.txt", "b.txt"]);
const { moved } = await files.move([
{ from: "tmp/a.txt", to: "final/a.txt" },
{ from: "tmp/b.txt", to: "final/b.txt" },
]);
const { existing, missing } = await files.exists(["a.txt", "b.txt", "missing.txt"]);Each bulk call accepts concurrency (default 8) and stopOnError (default false). When false, every key is attempted and per-key failures land in errors; when true, dispatching stops on the first failure (in-flight operations still complete). delete prefers the adapter's native bulk primitive (S3 DeleteObjects, Supabase remove, UploadThing deleteFiles) when one exists and stopOnError isn't set.
Cross-provider migration: transfer(source, destination, opts?)
Stream every object from one Files instance to another. Built entirely on the public surface — no adapter implements anything new.
import { Files, transfer } from "@visulima/storage";
import { S3Storage } from "@visulima/storage/provider/aws";
import { BunnyStorage } from "@visulima/storage/provider/bunny";
const from = new Files({ adapter: new S3Storage({ bucket: "old", region: "us-east-1" }) });
const to = new Files({ adapter: new BunnyStorage({ zone, accessKey, region: "de" }) });
const { transferred, skipped, errors } = await transfer(from, to, {
prefix: "uploads/",
concurrency: 8,
onProgress: ({ done, key, status }) => log.info(done, key, status),
});Each object is downloaded and re-uploaded with bounded concurrency. By default keys that already exist at the destination are skipped — pass overwrite: true to force re-upload. transformKey rewrites the destination key, stopOnError switches to sequential execution and aborts on the first failure, signal aborts mid-walk. Body, content type, and user metadata travel; etag/lastModified are destination-assigned.
The walk pulls from source.listAll() directly via N concurrent workers — no eager buffering of keys, so very large buckets transfer in constant memory.
In-memory adapter for tests
MemoryStorage is a BaseStorage adapter backed by a Map<string, MemoryEntry>. It implements the full surface (including ranged reads) without touching disk or any external service — useful for tests, ephemeral environments, and as a reference implementation.
import { Files } from "@visulima/storage";
import MemoryStorage from "@visulima/storage/provider/memory";
const files = new Files({
adapter: new MemoryStorage({
initial: {
"users/1.json": '{"id":1}',
"users/2.json": '{"id":2}',
},
}),
});
await files.upload("docs/x.txt", "x");
// `raw` exposes the backing Map for direct inspection / reset:
files.raw.clear();supportsRange is true, so download({ range }) works against the in-memory store.
Errors
Every method that hits the network throws an UploadError from @visulima/storage on failure, with a stable UploadErrorCode from the ERRORS enum:
import { ERRORS, isUploadError } from "@visulima/storage";
try {
await files.download("missing.bin");
} catch (error) {
if (isUploadError(error) && error.UploadErrorCode === ERRORS.FILE_NOT_FOUND) {
// …
}
throw error;
}Adapters normalize provider-specific error shapes into the same enum: FILE_NOT_FOUND (404), FORBIDDEN (401/403), BAD_REQUEST (400), METHOD_NOT_ALLOWED (unsupported primitive), STORAGE_ERROR (everything else). The original error is preserved on UploadError.detail.
→ See Error handling for the full enum and how to wrap your own adapter errors.
When to drop down to the adapter
The facade is intentionally narrow. Reach for the adapter (files.adapter) when you need:
- Resumable uploads —
BaseStorage.write(part)takes aFilePartwithstartoffsets;Files.uploadis one-shot only. - Lifecycle hooks —
onCreate,onUpdate,onComplete,onDelete,onErrorare configured on the adapter (distinct from the facadeonAction/onError/onRetryhooks). - Validators / locking — file-type, size, and MIME validation; lock tokens for concurrent writers.
- Expiration / purge — the adapter's
expirationconfig. - OpenAPI export — schema generation against the adapter, not the facade.
- Transformers —
ImageTransformer,VideoTransformer,AudioTransformer,MediaTransformerall take an adapter.
The facade and the adapter are not exclusive — files.adapter always returns the underlying BaseStorage, so you can mix both styles in the same codebase.