The contract
interface StorageAdapter {
readonly name: string;
upload(file: StorageFile, ctx: StorageContext): Promise<StorageObject>;
exists?(key: string): Promise<boolean>; // unlocks cache: 'storage'
remove?(key: string): Promise<void>; // unlocks optimizer.delete()
getUrl?(key: string): string;
dispose?(): Promise<void> | void;
}Only upload is required. Lens handles retries, bounded concurrency and error wrapping around whatever you implement, so an adapter is genuinely just the storage call.
S3, and anything that speaks it
npm install @lens-image/adapter-s3 @aws-sdk/client-s3The AWS SDK is a peer dependency. It is ~15 MB installed and you almost certainly already have it; pinning our own copy would duplicate it and fight your version choices.
import { S3Adapter } from '@lens-image/adapter-s3';
new S3Adapter({
bucket: 'my-images',
region: 'us-east-1',
prefix: 'products',
publicUrl: 'https://cdn.example.com', // your CloudFront distribution
});Cloudflare R2
new S3Adapter({
bucket: 'images',
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2_KEY, secretAccessKey: R2_SECRET },
publicUrl: 'https://images.example.com',
});MinIO and self-hosted
new S3Adapter({
bucket: 'images',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
credentials: { accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin' },
});forcePathStyle is required by MinIO and most self-hosted S3 services. DigitalOcean Spaces and Backblaze B2 work the same way, set endpoint and, for Spaces, publicUrl to the CDN hostname.
Error messages
SDK errors are translated into ones that name the likely cause, because AccessDenied on its own has never helped anybody:
S3 AccessDenied for "a/b.webp" in "my-images". Check the IAM policy grants
s3:PutObject on this bucket, and that any configured ACL is allowed by
Object Ownership settings.
S3 PermanentRedirect for "a/b.webp" in "my-images". The bucket "my-images"
lives in a different region than the one configured.Filesystem
npm install @lens-image/adapter-localimport { LocalAdapter } from '@lens-image/adapter-local';
new LocalAdapter({
root: './public/uploads', // where files land
baseUrl: '/uploads', // how the browser reaches them
});Writes are atomic by default, each file goes to a temp name and is then renamed into place, so a crash mid-write leaves the previous file intact rather than a truncated one, and a concurrent reader never sees half an image.
Every key is resolved against root and refused if it escapes. Lens generates keys itself, but a custom key function could interpolate a user-supplied filename, so that has to be impossible here rather than assumed away upstream.
Serving the files
const adapter = new LocalAdapter({ root: './uploads', baseUrl: '/images' });
app.use('/images', express.static(adapter.root, {
immutable: true,
maxAge: '1y', // safe: keys are content-addressed
}));Cloudinary
npm install @lens-image/adapter-cloudinaryZero dependencies, signed uploads are an HTTP POST with a SHA-1 signature, which node:crypto and the built-in fetch cover in a couple of hundred lines.
import { CloudinaryAdapter } from '@lens-image/adapter-cloudinary';
new CloudinaryAdapter({
cloudName: process.env.CLOUDINARY_CLOUD_NAME!,
apiKey: process.env.CLOUDINARY_API_KEY!,
apiSecret: process.env.CLOUDINARY_API_SECRET!,
folder: 'products',
});Writing your own
A complete Google Cloud Storage adapter:
import { Storage } from '@google-cloud/storage';
import type { StorageAdapter } from '@lens-image/core';
export class GCSAdapter implements StorageAdapter {
readonly name = 'gcs';
#bucket = new Storage().bucket(this.bucketName);
constructor(private bucketName: string) {}
async upload(file) {
const object = this.#bucket.file(file.key);
await object.save(file.data, {
contentType: file.contentType,
metadata: { cacheControl: file.cacheControl },
});
return {
key: file.key,
url: `https://storage.googleapis.com/${this.bucketName}/${file.key}`,
size: file.data.byteLength,
};
}
async exists(key) {
const [exists] = await this.#bucket.file(key).exists();
return exists;
}
async remove(key) {
await this.#bucket.file(key).delete({ ignoreNotFound: true });
}
}exists unlocks storage-aware caching; remove unlocks optimizer.delete(). Both are optional, and calling a method an adapter does not implement gives a clear ADAPTER_UNSUPPORTED rather than a TypeError.
What a good adapter does
- Throws on failure. Do not swallow errors or return a partial result. Lens needs the throw to trigger a retry.
- Returns the key it actually wrote. If you prefix or rewrite, report the final key so deletion and caching address the right object.
- Honours
ctx.signalwhere the client supports it, so cancellation is not just cosmetic. - Leaves retrying to the core. Adapter-level retries compound with the ones above them into surprising delays.
MemoryAdapter
Ships in the core. It is the reference implementation of the contract, about forty lines of real logic, and it makes every example runnable without credentials.
import { MemoryAdapter, ImageOptimizer, createPassthroughEngine } from '@lens-image/core';
const adapter = new MemoryAdapter();
const optimizer = new ImageOptimizer({ adapter, engine: createPassthroughEngine() });
await optimizer.optimize({ source: bytes, formats: ['webp'] });
adapter.keys(); // ['a1b2c3d4e5/photo-original.webp']
adapter.get(key)?.data; // the encoded bytes
// Injected failures, for testing retry behaviour without mocking a network.
new MemoryAdapter({ failAttempts: 2 });