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:
ProgressCallbackisvoid Function(int count, int total). Forward it to your upload client to report progress in the composer.CancelTokenlets you abort an in-flight upload withcancelToken.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
StreamAttachmentFileUploaderinstance created with theStreamHttpClientthe 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.