Lens

Reference

Storage adapters

One interface, one required method. S3, filesystem and Cloudinary ship today; anything else is about forty lines.


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-s3

The 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-local
import { 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-cloudinary

Zero 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:

gcs-adapter.tsts
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.signal where 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 });