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 a Buffer).
  • Provider-portable code — swap S3Storage for BunnyStorage without 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 of Files).
  • 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

MethodSingle-key signatureBulk-array signatureNotes
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)→ unknownThe 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-prefixed

list() 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:

TypeSize hintNotes
stringderivedUTF-8 encoded.
Bufferderived
Uint8Array / ArrayBuffer / ArrayBufferViewderived
BlobderivedWeb Blob (Node 18+ and all modern runtimes).
ReadableStream<Uint8Array>required via opts.size for some adaptersWeb stream, not Node Readable.
NodeJS.ReadableStreamrequired via opts.size for some adaptersNode 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 rename

For 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 uploadsBaseStorage.write(part) takes a FilePart with start offsets; Files.upload is one-shot only.
  • Lifecycle hooksonCreate, onUpdate, onComplete, onDelete, onError are configured on the adapter (distinct from the facade onAction / onError / onRetry hooks).
  • Validators / locking — file-type, size, and MIME validation; lock tokens for concurrent writers.
  • Expiration / purge — the adapter's expiration config.
  • OpenAPI export — schema generation against the adapter, not the facade.
  • TransformersImageTransformer, VideoTransformer, AudioTransformer, MediaTransformer all 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.

Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues