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())Updating the attachment payload
When your upload API returns extra metadata (title, codec, dimensions, signed thumbnail URLs, custom IDs) that should be written into the attachment payload, mutate the payload inside the upload completion using AnyAttachmentUpdater and return the updated attachment via the optional attachment parameter on UploadedFile.
final class CustomStorage: CDNStorage {
private let attachmentUpdater = AnyAttachmentUpdater()
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) { [attachmentUpdater] result in
switch result {
case let .success(response):
var updatedAttachment = attachment
attachmentUpdater.update(
&updatedAttachment,
forPayload: ImageAttachmentPayload.self
) { payload in
payload.title = response.name
payload.extraData = [
"thumbnailUrl": .string(response.thumbnailURL.absoluteString)
]
}
attachmentUpdater.update(
&updatedAttachment,
forPayload: AudioAttachmentPayload.self
) { payload in
payload.title = response.name
payload.extraData = [
"codec": .string(response.codec)
]
}
completion(.success(UploadedFile(
fileURL: response.url,
thumbnailURL: response.thumbnailURL,
attachment: updatedAttachment
)))
case let .failure(error):
completion(.failure(error))
}
}
}
}AnyAttachmentUpdater is a helper provided by Stream that updates the typed payload of a type-erased AnyChatMessageAttachment. Pass the attachment as inout and specify which payload type to update; the updater is a no-op when the attachment type does not match.
The SDK always overrides URL fields on the payload (imageURL, videoURL, audioURL, assetURL, voiceRecordingURL, and the video thumbnailURL) using UploadedFile.fileURL and UploadedFile.thumbnailURL, so populate those canonical fields rather than setting URLs on the payload yourself.
The attachment field on UploadedFile is only meaningful when returned from uploadAttachment(_:options:completion:) (the message-attached overload). It is ignored by uploadAttachment(localUrl:options:completion:) because standalone file uploads have no message attachment context.
Media metadata (dimensions and duration)
When using a custom CDN, your upload API may require image dimensions (width, height) or video dimensions and duration. The SDK provides this metadata on the attachment payload so you can pass it to your upload service without recomputing it.
ImageAttachmentPayloadexposesoriginalWidthandoriginalHeight(in pixels). When creating an attachment from a local file, passAnyAttachmentLocalMetadatawithoriginalResolution: (width: Double, height: Double)intoAnyAttachmentPayload(localFileURL:attachmentType:localMetadata:extraData:)and the payload will be filled.VideoAttachmentPayloadexposesoriginalWidth,originalHeight, andduration(seconds). Use the samelocalMetadata: setoriginalResolutionfor dimensions anddurationfor video length. Both are optional.
Read these from the attachment payload inside your upload step and forward them to your upload API:
attachmentUpdater.update(&updatedAttachment, forPayload: ImageAttachmentPayload.self) { payload in
if let width = payload.originalWidth, let height = payload.originalHeight {
// Pass width, height to your upload API if needed
}
}
attachmentUpdater.update(&updatedAttachment, forPayload: VideoAttachmentPayload.self) { payload in
if let width = payload.originalWidth, let height = payload.originalHeight {
// Pass width, height to your upload API
}
if let duration = payload.duration {
// Pass duration (seconds) to your upload API
}
}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 |