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
132 lines
5.0 KiB
Swift
132 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
/// Errors that can occur when fetching an Org Social feed.
|
|
public enum FeedFetcherError: Error, Sendable, Equatable {
|
|
/// The URL provided is not a valid HTTP/HTTPS URL.
|
|
case invalidURL
|
|
/// The server returned a non-2xx HTTP status code.
|
|
case httpError(statusCode: Int)
|
|
/// The response body could not be decoded as UTF-8 text.
|
|
case decodingError
|
|
/// A network-level error occurred (timeout, no connection, etc.).
|
|
case networkError(underlying: String)
|
|
}
|
|
|
|
extension FeedFetcherError: LocalizedError {
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL:
|
|
return "The URL is not a valid HTTP or HTTPS address."
|
|
case .httpError(let code):
|
|
return "The server returned HTTP \(code)."
|
|
case .decodingError:
|
|
return "The feed content could not be decoded as UTF-8 text."
|
|
case .networkError(let message):
|
|
return "Network error: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetches the raw content of an Org Social feed from a remote URL.
|
|
///
|
|
/// Conditional GET (ETag / Last-Modified) is applied automatically on every
|
|
/// normal fetch. When the server returns 304 Not Modified the previous body
|
|
/// is returned from `FeedCache` without hitting the network payload. Pass
|
|
/// `bypassCache: true` to force a fresh download (e.g. right after an upload).
|
|
public struct FeedFetcher: Sendable {
|
|
|
|
private let session: URLSession
|
|
private let cache: FeedCache
|
|
|
|
public init(session: URLSession = .shared, cache: FeedCache = .shared) {
|
|
self.session = session
|
|
self.cache = cache
|
|
}
|
|
|
|
/// Downloads the raw UTF-8 string content of a `social.org` file.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: The public URL of the `social.org` file.
|
|
/// - bypassCache: When `true`, forces a fresh download: the cache entry
|
|
/// is invalidated, `Cache-Control: no-cache` is sent, and a cache-busting
|
|
/// query parameter is appended. Use right after uploads.
|
|
/// - Returns: The raw UTF-8 string content of the feed.
|
|
/// - Throws: `FeedFetcherError` if the request fails or the response is invalid.
|
|
public func fetch(from url: URL, bypassCache: Bool = false) async throws -> String {
|
|
guard url.scheme == "http" || url.scheme == "https" else {
|
|
throw FeedFetcherError.invalidURL
|
|
}
|
|
|
|
if bypassCache {
|
|
await cache.invalidate(url)
|
|
}
|
|
|
|
let requestURL: URL
|
|
if bypassCache {
|
|
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
var items = comps?.queryItems ?? []
|
|
items.append(URLQueryItem(name: "_t", value: "\(Int(Date().timeIntervalSince1970 * 1000))"))
|
|
comps?.queryItems = items
|
|
requestURL = comps?.url ?? url
|
|
} else {
|
|
requestURL = url
|
|
}
|
|
|
|
var request = URLRequest(url: requestURL)
|
|
request.timeoutInterval = 10
|
|
|
|
if bypassCache {
|
|
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
|
request.setValue("no-cache", forHTTPHeaderField: "Pragma")
|
|
} else {
|
|
let (ifNoneMatch, ifModifiedSince) = await cache.conditionalHeaders(for: url)
|
|
if let etag = ifNoneMatch {
|
|
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
|
}
|
|
if let modified = ifModifiedSince {
|
|
request.setValue(modified, forHTTPHeaderField: "If-Modified-Since")
|
|
}
|
|
}
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
|
|
do {
|
|
(data, response) = try await session.data(for: request)
|
|
} catch {
|
|
throw FeedFetcherError.networkError(underlying: error.localizedDescription)
|
|
}
|
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw FeedFetcherError.networkError(underlying: "No HTTP response received")
|
|
}
|
|
|
|
// 304 Not Modified: the server confirmed our cached copy is still fresh.
|
|
if http.statusCode == 304 {
|
|
if let cached = await cache.cachedContent(for: url) {
|
|
return cached
|
|
}
|
|
// Cache miss on a 304 is a protocol violation from the server — treat
|
|
// as a network error so the caller can retry with a fresh request.
|
|
throw FeedFetcherError.networkError(underlying: "Received 304 but no cached content available")
|
|
}
|
|
|
|
if !(200..<300).contains(http.statusCode) {
|
|
throw FeedFetcherError.httpError(statusCode: http.statusCode)
|
|
}
|
|
|
|
guard let content = String(data: data, encoding: .utf8) else {
|
|
throw FeedFetcherError.decodingError
|
|
}
|
|
|
|
if !bypassCache {
|
|
let etag = http.value(forHTTPHeaderField: "ETag")
|
|
let lastModified = http.value(forHTTPHeaderField: "Last-Modified")
|
|
await cache.record(url: url, etag: etag, lastModified: lastModified, content: content)
|
|
}
|
|
|
|
return content
|
|
}
|
|
}
|