public protocol CDNStorage: Sendable {
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
options: AttachmentUploadOptions,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
)
func uploadAttachment(
localUrl: URL,
options: AttachmentUploadOptions,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
)
func deleteAttachment(
remoteUrl: URL,
options: AttachmentDeleteOptions,
completion: @escaping @Sendable (Error?) -> Void
)
}Custom CDN
By default, files are uploaded to Stream's CDN, but you can also use your own CDN. The following components are available to customize the CDN behavior:
- CDN Storage: Provide a custom
CDNStorageto upload files to your own CDN or storage service. - CDN Requester: Provide a custom
CDNRequesterto intercept cdn reads (signed URLs, auth headers, custom hosts).
CDN Storage
To use a custom CDN storage service (such as AWS S3, Firebase Storage, or your own backend), implement the CDNStorage protocol and configure it on ChatClientConfig.
The CDNStorage protocol defines the contract for uploading and deleting files:
uploadAttachment(_:options:completion:)is called when uploading message attachments. Upload progress is reported throughoptions.progress.uploadAttachment(localUrl:options:completion:)is used for standalone file uploads (not tied to a message).deleteAttachment(remoteUrl:options:completion:)deletes a previously uploaded file from the CDN.
Custom CDNStorage
In order to create a custom CDNStorage, you need to implement the CDNStorage protocol, here is an example of a custom CDNStorage implementation:
final class CustomStorage: CDNStorage {
let uploadApi: YourUploadApi
init(uploadApi: YourUploadApi) {
self.uploadApi = uploadApi
}
func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
options: AttachmentUploadOptions,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
) {
guard let uploadingState = attachment.uploadingState,
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
completion(.failure(ClientError("Failed to upload")))
return
}
uploadApi.upload(data: fileData, progress: options.progress) { result in
switch result {
case let .success(response):
completion(.success(UploadedFile(
fileURL: response.url,
thumbnailURL: response.thumbnailURL
)))
case let .failure(error):
completion(.failure(error))
}
}
}
func uploadAttachment(
localUrl: URL,
options: AttachmentUploadOptions,
completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
) {
guard let fileData = try? Data(contentsOf: localUrl) else {
completion(.failure(ClientError("Failed to read file")))
return
}
uploadApi.upload(data: fileData, progress: options.progress) { result in
completion(result.map { UploadedFile(fileURL: $0.url, thumbnailURL: $0.thumbnailURL) })
}
}
func deleteAttachment(
remoteUrl: URL,
options: AttachmentDeleteOptions,
completion: @escaping @Sendable (Error?) -> Void
) {
uploadApi.delete(url: remoteUrl, completion: completion)
}
}Then, you need to create an instance of your custom storage and pass it to ChatClientConfig:
var config = ChatClientConfig(apiKeyString: apiKeyString)
config.cdnStorage = CustomStorage(uploadApi: YourUploadApi())CDN Requester
If your CDN requires signed URLs, authentication tokens, or custom headers when loading files and images, implement the CDNRequester protocol. This provides a single configuration point that covers all media loading paths in the SDK:
- Images loaded via the
MediaLoader(avatars, image attachments, link previews) - Video previews generated by the
MediaLoader(video attachment thumbnails) - Videos played via AVPlayer (video attachments)
- File downloads and document previews
The CDNRequester protocol defines two methods, each returning a CDNRequest:
public protocol CDNRequester: Sendable {
func imageRequest(
for url: URL,
options: ImageRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
)
func fileRequest(
for url: URL,
options: FileRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
)
}imageRequestis called for every image URL (avatar loading, image attachment download, link previews). Theoptionsparameter carries optionalCDNImageResizeparameters for server-side resizing.fileRequestis called for non-image files (video/audio playback, file download).
Both methods use completion handlers to support asynchronous operations such as fetching pre-signed URLs from a remote server.
CDNRequest
CDNRequest holds the (potentially rewritten) URL, optional headers, and an optional caching key:
public struct CDNRequest: Sendable {
public var url: URL
public var headers: [String: String]?
public var cachingKey: String?
}The cachingKey controls cache behaviour. For example, a CDN with signed URLs can strip the token from the key so the same image is cached regardless of token rotation.
Pre-Signed URLs
If your CDN serves files behind pre-signed URLs that must be fetched from a server, implement CDNRequester directly:
final class PreSignedCDNRequester: CDNRequester {
let signingService: SigningService
func imageRequest(
for url: URL,
options: ImageRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
signingService.fetchSignedURL(for: url) { result in
switch result {
case let .success(signedURL):
completion(.success(CDNRequest(
url: signedURL,
headers: ["Authorization": "Bearer \(self.signingService.token)"],
cachingKey: url.absoluteString
)))
case let .failure(error):
completion(.failure(error))
}
}
}
func fileRequest(
for url: URL,
options: FileRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
signingService.fetchSignedURL(for: url) { result in
completion(result.map { CDNRequest(url: $0) })
}
}
}Custom Authentication Headers
If you only need to add headers without URL rewriting, implement CDNRequester and delegate to a StreamCDNRequester instance to preserve the default resize and caching behaviour:
final class AuthenticatedCDNRequester: CDNRequester {
let streamRequester = StreamCDNRequester()
let tokenProvider: () -> String
init(tokenProvider: @escaping () -> String) {
self.tokenProvider = tokenProvider
}
func imageRequest(
for url: URL,
options: ImageRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
streamRequester.imageRequest(for: url, options: options) { result in
guard var request = try? result.get() else {
completion(result)
return
}
request.headers = ["Authorization": "Bearer \(self.tokenProvider())"]
completion(.success(request))
}
}
func fileRequest(
for url: URL,
options: FileRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
streamRequester.fileRequest(for: url, options: options) { result in
guard var request = try? result.get() else {
completion(result)
return
}
request.headers = ["Authorization": "Bearer \(self.tokenProvider())"]
completion(.success(request))
}
}
}Custom Image Resizing
If your CDN supports server-side image resizing, override imageRequest to append your CDN's resize query parameters:
final class CustomResizeCDNRequester: CDNRequester {
private let cdnHost = "your-cdn.com"
func imageRequest(
for url: URL,
options: ImageRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host, host.contains(cdnHost),
let resize = options.resize else {
completion(.success(CDNRequest(url: url)))
return
}
let scale = UIScreen.main.scale
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(
name: "width",
value: String(format: "%.0f", resize.width * scale)
))
queryItems.append(URLQueryItem(
name: "height",
value: String(format: "%.0f", resize.height * scale)
))
components.queryItems = queryItems
let cachingKey = buildCachingKey(for: url)
completion(.success(CDNRequest(
url: components.url ?? url,
cachingKey: cachingKey
)))
}
func fileRequest(
for url: URL,
options: FileRequestOptions,
completion: @escaping (Result<CDNRequest, Error>) -> Void
) {
completion(.success(CDNRequest(url: url)))
}
private func buildCachingKey(for url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return url.absoluteString
}
let persistedParameters = ["width", "height"]
let filtered = components.queryItems?.filter {
persistedParameters.contains($0.name)
}
components.queryItems = filtered?.isEmpty == true ? nil : filtered
return components.string ?? url.absoluteString
}
}Configuration
The CDNRequester is configured as a dependency of StreamMediaLoader. This ensures it's set in a single place and automatically applies to all media loading, file downloads, and URL resolution throughout the SDK.
UIKit:
let customRequester = CustomCDNRequester(signingService: myService)
Components.default.mediaLoader = StreamMediaLoader(downloader: StreamImageDownloader(), cdnRequester: customRequester)SwiftUI:
let customRequester = CustomCDNRequester(signingService: myService)
let loader = StreamMediaLoader(downloader: StreamImageDownloader(), cdnRequester: customRequester)
let utils = Utils(mediaLoader: loader)
let streamChat = StreamChat(chatClient: chatClient, utils: utils)Custom MediaLoader
If you need full control over how images and video previews are loaded (for example, to use a different image loading library or custom caching strategy), you can provide a custom MediaLoader.
The MediaLoader protocol defines the contract for loading images, video content, and resolving file URLs:
public protocol MediaLoader: AnyObject, Sendable {
func loadImage(
url: URL?,
options: ImageLoadOptions,
completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void
)
func loadVideoAsset(
at url: URL,
options: VideoLoadOptions,
completion: @escaping @MainActor (Result<MediaLoaderVideoAsset, Error>) -> Void
)
func loadVideoPreview(
with attachment: ChatMessageVideoAttachment,
options: VideoLoadOptions,
completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
)
func loadVideoPreview(
at url: URL,
options: VideoLoadOptions,
completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
)
func loadFileRequest(
for url: URL,
options: DownloadFileRequestOptions,
completion: @escaping @MainActor (Result<MediaLoaderFileRequest, Error>) -> Void
)
}All methods are required when creating a custom loader from scratch. The CDN requester is a constructor dependency of StreamMediaLoader, so options structs like ImageLoadOptions and VideoLoadOptions no longer carry a CDN requester. The loadVideoPreview(with:) method receives the full video attachment, allowing implementations to use the attachment's thumbnailURL when available and fall back to generating a preview frame from the video URL. The loadFileRequest method resolves a file URL through the CDN and returns a ready-to-use URLRequest with any required HTTP headers, which can be passed to downloadAttachment or loaded directly in a web view.
Subclassing StreamMediaLoader
If you only need to customize part of the media loading pipeline, you can subclass StreamMediaLoader and override just the methods you need. All other methods continue to use the default implementation:
class AnalyticsMediaLoader: StreamMediaLoader {
let analytics: AnalyticsService
init(analytics: AnalyticsService, cdnRequester: CDNRequester = StreamCDNRequester()) {
self.analytics = analytics
super.init(cdnRequester: cdnRequester, downloader: StreamImageDownloader())
}
override func loadImage(
url: URL?,
options: ImageLoadOptions,
completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void
) {
let startTime = CFAbsoluteTimeGetCurrent()
super.loadImage(url: url, options: options) { [analytics] result in
let duration = CFAbsoluteTimeGetCurrent() - startTime
analytics.track("image_load", properties: [
"url": url?.absoluteString ?? "nil",
"duration": duration,
"success": (try? result.get()) != nil
])
completion(result)
}
}
}Configuration
To use a custom MediaLoader:
// UIKit
Components.default.mediaLoader = CustomMediaLoader()
// SwiftUI
let utils = Utils(mediaLoader: CustomMediaLoader())
let streamChat = StreamChat(chatClient: chatClient, utils: utils)Migration from v4
| v4 Type | v5 Replacement |
|---|---|
CDNClient | CDNStorage |
AttachmentUploader | CDNStorage + UploadedAttachmentPostProcessor |
ChatClientConfig.customCDNClient | ChatClientConfig.cdnStorage |
ChatClientConfig.customAttachmentUploader | ChatClientConfig.cdnStorage |
ImageCDN | CDNRequester |
StreamImageCDN | StreamCDNRequester |
FileCDN | CDNRequester (via fileRequest) |
ImageLoading / NukeImageLoader | MediaLoader / StreamMediaLoader |
VideoLoading / VideoPreviewLoader | MediaLoader (via loadVideoPreview) |
StreamVideoLoader / DefaultVideoPreviewLoader | StreamMediaLoader |
Components.imageCDN | StreamMediaLoader(cdnRequester:) |
Components.imageLoader + Components.videoLoader | Components.mediaLoader |
Utils.imageCDN / Utils.fileCDN | StreamMediaLoader(cdnRequester:) |
Utils.imageLoader + Utils.videoLoader | Utils.mediaLoader |