Custom CDN

Introduction

By default, the Stream Chat Flutter SDK uploads attachments to Stream's own CDN. If you need to store files on your own infrastructure (for example AWS S3, Google Cloud Storage, or your own backend), you customize the pieces below:

  • Uploads and deletes are handled by an AttachmentFileUploader. Provide a custom implementation to send files to your own storage. The URLs it returns are stored on the message, so reads automatically go wherever your uploader put the file. This is the only piece most custom CDN setups need.
  • Image URL transformation is handled by a StreamImageCDN. It rewrites image URLs before they are fetched, for resizing, signed URLs, or cache keys. Override it only when you need to modify the request to your CDN, not to point reads at it.

Custom attachment uploader

AttachmentFileUploader defines how the SDK uploads and deletes files. The default implementation, StreamAttachmentFileUploader, sends everything to Stream's CDN. To use your own storage, implement AttachmentFileUploader and register it on the client.

The interface has eight methods split into two groups.

Channel-scoped methods handle files that belong to a channel. The SDK calls sendImage/sendFile when a message with attachments is sent, and deleteImage/deleteFile for each of a message's attachments when the message is hard deleted (channel.deleteMessage(message, hard: true)). They also run when your app calls channel.sendImage, channel.sendFile, channel.deleteImage or channel.deleteFile. A soft delete, or removing an attachment while editing a message, does not remove the file from your storage:

Method Returns Purpose
sendImage Future<SendImageResponse> Upload an image attached to a message
sendFile Future<SendFileResponse> Upload a non-image file attached to a message
deleteImage Future<EmptyResponse> Delete a previously uploaded image
deleteFile Future<EmptyResponse> Delete a previously uploaded file

Standalone methods upload or remove files without any channel context. The SDK does not call them itself; they back client.uploadImage, client.uploadFile, client.removeImage and client.removeFile, which you can use for things like user avatars or app-specific uploads:

Method Returns Purpose
uploadImage Future<UploadImageResponse> Upload a standalone image
uploadFile Future<UploadFileResponse> Upload a standalone file
removeImage Future<EmptyResponse> Remove a standalone image
removeFile Future<EmptyResponse> Remove a standalone file

Response types

The upload methods return a SendAttachmentResponse whose file field holds the URL of the uploaded asset. SendImageResponse, UploadImageResponse, and UploadFileResponse are all type aliases for SendAttachmentResponse. Only SendFileResponse adds a thumbUrl field, which you should populate when the uploaded file is a video. The delete and remove methods return an EmptyResponse.

Progress and cancellation

The upload methods accept an optional onSendProgress callback and a cancelToken. Both come from Dio and are re-exported by stream_chat, so you do not need to add dio as a direct dependency:

  • ProgressCallback is void Function(int count, int total). Forward it to your upload client to report progress in the composer.
  • CancelToken lets you abort an in-flight upload with cancelToken.cancel().

Implementing a custom uploader

Implement AttachmentFileUploader and return a SendAttachmentResponse whose file field points at the URL on your CDN. Call AttachmentFile.toMultipartFile() if your upload client expects multipart data.

import 'package:stream_chat/stream_chat.dart';

class MyCdnUploader implements AttachmentFileUploader {
  const MyCdnUploader(this._httpClient);

  // The SDK passes its configured StreamHttpClient. Reuse it or ignore it.
  final StreamHttpClient _httpClient;

  @override
  Future<SendImageResponse> sendImage(
    AttachmentFile image,
    String channelId,
    String channelType, {
    ProgressCallback? onSendProgress,
    CancelToken? cancelToken,
    Map<String, Object?>? extraData,
  }) async {
    final url = await _upload(image, onSendProgress: onSendProgress);
    return SendAttachmentResponse()..file = url;
  }

  @override
  Future<SendFileResponse> sendFile(
    AttachmentFile file,
    String channelId,
    String channelType, {
    ProgressCallback? onSendProgress,
    CancelToken? cancelToken,
    Map<String, Object?>? extraData,
  }) async {
    final url = await _upload(file, onSendProgress: onSendProgress);
    // Set thumbUrl as well when the uploaded file is a video.
    return SendFileResponse()..file = url;
  }

  @override
  Future<EmptyResponse> deleteImage(
    String url,
    String channelId,
    String channelType, {
    CancelToken? cancelToken,
    Map<String, Object?>? extraData,
  }) async {
    await _delete(url);
    return EmptyResponse();
  }

  @override
  Future<EmptyResponse> deleteFile(
    String url,
    String channelId,
    String channelType, {
    CancelToken? cancelToken,
    Map<String, Object?>? extraData,
  }) async {
    await _delete(url);
    return EmptyResponse();
  }

  // Standalone uploads without channel context.
  @override
  Future<UploadImageResponse> uploadImage(
    AttachmentFile image, {
    ProgressCallback? onSendProgress,
    CancelToken? cancelToken,
  }) async {
    final url = await _upload(image, onSendProgress: onSendProgress);
    return SendAttachmentResponse()..file = url;
  }

  @override
  Future<UploadFileResponse> uploadFile(
    AttachmentFile file, {
    ProgressCallback? onSendProgress,
    CancelToken? cancelToken,
  }) async {
    final url = await _upload(file, onSendProgress: onSendProgress);
    return SendAttachmentResponse()..file = url;
  }

  @override
  Future<EmptyResponse> removeImage(String url, {CancelToken? cancelToken}) async {
    await _delete(url);
    return EmptyResponse();
  }

  @override
  Future<EmptyResponse> removeFile(String url, {CancelToken? cancelToken}) async {
    await _delete(url);
    return EmptyResponse();
  }

  Future<String> _upload(
    AttachmentFile file, {
    ProgressCallback? onSendProgress,
  }) async {
    // Send the bytes to your storage and return the public URL.
    // final multipart = await file.toMultipartFile();
    throw UnimplementedError('Add your upload logic here');
  }

  Future<void> _delete(String url) async {
    // Delete the asset from your storage.
    throw UnimplementedError('Add your delete logic here');
  }
}

If you only need custom behaviour for some methods, you can delegate the rest to a StreamAttachmentFileUploader instance created with the StreamHttpClient the SDK passes to your factory.

Registering the uploader

Pass a factory to the attachmentFileUploaderProvider parameter when creating the client. The factory receives the fully configured StreamHttpClient (with auth interceptors and base URL already set up):

final client = StreamChatClient(
  'your-api-key',
  attachmentFileUploaderProvider: (httpClient) => MyCdnUploader(httpClient),
);

When omitted, the provider defaults to StreamAttachmentFileUploader.new, which uploads to Stream's CDN.

Custom image CDN

Before an image attachment (including link-preview images and media thumbnails) is fetched, the SDK passes its URL through a StreamImageCDN, which can rewrite the URL and produce a stable cache key. Because your custom uploader already stores files at URLs on your own CDN, reads go there directly, so most custom CDN setups do not need a custom StreamImageCDN.

StreamImageCDN exposes two methods:

const imageCDN = StreamImageCDN();

// Resolve a resized URL for a given image.
final url = imageCDN.resolveUrl(
  originalUrl,
  resize: ImageResize(
    width: 200,
    height: 300,
    mode: ResizeMode.clip,
    crop: CropMode.center,
  ),
);

// Stable cache key for use with CachedNetworkImage.
final cacheKey = imageCDN.cacheKey(url);

Using a custom image CDN

Override StreamImageCDN only when you need to modify how image URLs are requested from your CDN: to sign URLs, apply your CDN's own resize parameters, rewrite the host, or keep cache keys stable across rotating tokens. Extend StreamImageCDN (overriding resolveUrl and/or cacheKey) and inject it via StreamChatConfigurationData.imageCDN. The base implementation returns URLs on your CDN unchanged, so your override has to build the final URL itself rather than delegating to super. For example, to pass the requested size to your CDN:

class MyImageCDN extends StreamImageCDN {
  const MyImageCDN();

  @override
  String resolveUrl(String sourceUrl, {ImageResize? resize}) {
    final uri = Uri.tryParse(sourceUrl);
    if (uri == null || resize == null) return sourceUrl;
    return uri.replace(
      queryParameters: {
        ...uri.queryParameters,
        'width': resize.width.round().toString(),
        'height': resize.height.round().toString(),
      },
    ).toString();
  }
}
StreamChat(
  client: client,
  configData: StreamChatConfigurationData(
    imageCDN: const MyImageCDN(),
  ),
  child: child,
)

Overriding the cache key is useful when your CDN uses signed URLs: strip the rotating token from the key so the same image stays cached across token refreshes.