15eb8acbd0
- FeedCache actor: conditional GET with ETag/Last-Modified, returns 304 hits from cache - PartialFeedFetcher: Range requests for header-only Discover fetches and date-filtered timeline fetches (2 requests instead of 3 per feed) - TimelineFetcher: AsyncStream-based streaming emits posts as each feed arrives instead of waiting for all feeds - TimelineViewModel: consumes stream progressively so posts appear immediately on first feed - Discover uses header-only fetch (16 KB vs full file) - maxConcurrentDownloads raised from 10 to 20
58 lines
2.1 KiB
Swift
58 lines
2.1 KiB
Swift
import Foundation
|
|
|
|
/// In-memory store for HTTP conditional GET headers (ETag / Last-Modified).
|
|
///
|
|
/// When `FeedFetcher` receives a successful response it stores the server's
|
|
/// caching headers here. On the next request for the same URL it sends
|
|
/// `If-None-Match` / `If-Modified-Since`; a 304 response lets it return
|
|
/// the cached body without re-downloading or re-parsing.
|
|
///
|
|
/// `FeedCache.shared` is the default singleton. Tests can create isolated
|
|
/// instances (`FeedCache()`) to avoid cross-test pollution.
|
|
public actor FeedCache: Sendable {
|
|
|
|
public static let shared = FeedCache()
|
|
|
|
private struct Entry {
|
|
var etag: String?
|
|
var lastModified: String?
|
|
var content: String
|
|
}
|
|
|
|
private var store: [String: Entry] = [:]
|
|
|
|
// MARK: - Write
|
|
|
|
/// Stores caching headers and body for `url`.
|
|
/// Does nothing when neither `etag` nor `lastModified` is present
|
|
/// (no usable validator means a conditional GET would always get 200).
|
|
public func record(url: URL, etag: String?, lastModified: String?, content: String) {
|
|
guard etag != nil || lastModified != nil else { return }
|
|
store[url.absoluteString] = Entry(etag: etag, lastModified: lastModified, content: content)
|
|
}
|
|
|
|
/// Removes the cache entry for `url`. Call before a forced refresh.
|
|
public func invalidate(_ url: URL) {
|
|
store.removeValue(forKey: url.absoluteString)
|
|
}
|
|
|
|
// MARK: - Read
|
|
|
|
/// Returns the conditional request headers to include for `url`.
|
|
/// Both values are `nil` when no entry exists.
|
|
public func conditionalHeaders(for url: URL) -> (ifNoneMatch: String?, ifModifiedSince: String?) {
|
|
guard let entry = store[url.absoluteString] else { return (nil, nil) }
|
|
return (entry.etag, entry.lastModified)
|
|
}
|
|
|
|
/// Returns the previously cached body for `url`, or `nil` when not cached.
|
|
public func cachedContent(for url: URL) -> String? {
|
|
store[url.absoluteString]?.content
|
|
}
|
|
|
|
/// Returns `true` when a cache entry exists for `url`.
|
|
public func has(_ url: URL) -> Bool {
|
|
store[url.absoluteString] != nil
|
|
}
|
|
}
|