Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Async HTTP client for Apple platforms. **iOS 18+ / Swift 6** (`swift-tools 6.2`,
- **Verbs:** `get`/`post`/`put`/`patch`/`delete` → `Result<T, NetworkingError>`. Pick `T`: any `Decodable` model, `Data`, `Void`, or `JSONResponse` (`{ statusCode, headers, body }`) when you want metadata.
- **Typed bodies — no `Any`.** The encoding is chosen by the method, not an untyped `parameters:`/`parameterType:` pair (`Networking+HTTPRequests.swift`): `post`/`put`/`patch` take `body:` (any `Encodable` → JSON, ISO-8601 dates), `form:` (any flat `Encodable` → url-encoded), `parts:`+`fields:` (multipart), or `data:contentType:` (raw); `get`/`delete` take `query:` (a `[URLQueryItem]` for ordering/dupes, or any flat `Encodable`). `form:`/`query:` flatten an `Encodable` to `[String: String]` via `formFields` in `Networking+FormEncoding.swift` (JSON bridge + scalar re-decode so `Bool`→"true", not NSNumber "1"). Bodies flow through the typed `RequestBody` enum in `Networking+New.swift`.
- **Downloads:** `downloadImage`/`downloadData` → `Result<T, NetworkingError>` where `T` is the payload (`Image`/`Data`) or an envelope (`ImageResponse`/`DataResponse`).
- **Cache lifecycle (`CacheExpiry.swift`, `Networking+Private.swift`):** a key→blob store — in-memory `NSCache` (the **warm** tier, pressure-evicted, no exposed limits) over on-disk files. Cleanup is instance-based and symmetric: `clearCache()` empties **both** tiers (the old disk-only static `deleteCachedFiles()` is gone — it left memory serving deleted data); `reset()` = `clearCache()` + wipe credentials/headers/fakes. On-disk entries carry a **sliding TTL** (`cacheTTL`, default 7 days) whose clock is the **file's modification date** — persisted, no in-memory map, no manifest. `objectFromCache` drops a disk entry whose mtime is older than the TTL (`CacheExpiry.isExpired(fileDate:)`); a disk hit (memory miss) re-warms by bumping the mtime — and since `NSCache` absorbs repeat reads, that touch is ~once per entry per launch, so no explicit debounce is needed. (Memory hits deliberately don't re-stamp the disk file, so an entry kept warm *only* in memory past `cacheTTL` can read as cold once evicted — accepted; normal use cycles through disk hits that re-warm it.) **Sharded from the start:** `destinationURL` lays files out under `domain/<shard>/<file>` where the shard is one hex nibble of the key hash (`shardName`, `shardCount` = 16); a detached `sweepExpiredCacheFiles` runs once per init and sweeps **one shard per launch** (rotated via a `.sweep-shard` cursor file), so per-launch work is O(N / shardCount) and a full pass takes `shardCount` launches (it also clears pre-sharding strays at the domain root). Sweep and `deleteCacheFolder` serialize on `cacheMutationLock` so the background sweep can't race a clear. `destinationURL` also hashes the filename component past the filesystem's 255-byte limit (readable prefix + SHA-256 suffix, `filesystemSafeComponent`).
- **Cache lifecycle (`CacheStore.swift`, `CacheExpiry.swift`):** the whole two-tier cache subsystem lives in **`CacheStore`** — a non-actor `@unchecked Sendable` keyed by an already-resolved resource string (composing a key from baseURL + path stays the networking layer's job; `Networking.cacheResource(for:cacheName:)` does it and `destinationURL`/`objectFromCache`/`cacheOrPurgeData`/`cacheOrPurgeImage` are now thin nonisolated shims that delegate). The store is an in-memory `NSCache` (the **warm** tier, pressure-evicted by iOS, no exposed limits — assume it can be wiped at any time) over on-disk files; `Networking` holds the same `NSCache` by reference so it stays injectable/inspectable. **Reads are pure** (`CacheStore.object`): `.memory` serves the warm tier only and `.none` reports a miss — neither touches disk, so a read can't destroy a durable `.memoryAndFile` copy (purging is the write path's and `clearCache`'s job). Cleanup is symmetric: `clearCache()` → `cacheStore.clear()` empties **both** tiers (the old disk-only static `deleteCachedFiles()` is gone — it left memory serving deleted data); `reset()` = `clearCache()` + wipe credentials/headers/fakes. On-disk entries carry a **sliding TTL** (`cacheTTL`, default 7 days) whose clock is the **file's modification date** — persisted, no in-memory map, no manifest. The store drops a disk entry whose mtime is older than the TTL (`CacheExpiry.isExpired(fileDate:)`); a disk hit (memory miss) re-warms by bumping the mtime — and since `NSCache` absorbs repeat reads, that touch is ~once per entry per launch, so no explicit debounce is needed. (Memory hits deliberately don't re-stamp the disk file, so an entry kept warm *only* in memory past `cacheTTL` can read as cold once evicted — accepted; normal use cycles through disk hits that re-warm it.) **Sharded from the start:** `CacheStore.destinationURL(forResource:)` lays files out under `domain/<shard>/<file>` where the shard is one hex nibble of the key hash (`shardName`, `shardCount` = 16); a detached `CacheStore.sweepExpired` runs once per init and sweeps **one shard per launch** (rotated via a `.sweep-shard` cursor file), so per-launch work is O(N / shardCount) and a full pass takes `shardCount` launches (it also clears pre-sharding strays at the domain root). Sweep and `clear` serialize on `CacheStore.mutationLock` (a static, shared across instances) so the background sweep can't race a clear. `destinationURL` also hashes the filename component past the filesystem's 255-byte limit (readable prefix + SHA-256 suffix, `filesystemSafeComponent`).
- **Fakes (testing):** `fakeGET`/`fakePOST`/… take `response:` over any `Encodable` (encoded to JSON), a no-body overload for status-only fakes, or `fileName:` for bundled raw `Data`; `FakeRequest` holds a typed `Payload` enum.
- **Errors (`NetworkingError.swift`):** categorized by where the failure happened — `invalidRequest(InvalidRequestReason)`, `transport(URLError)`, `http(HTTPError)`, `decoding(DecodingError, ResponseMetadata)`, `validation(reason:ResponseMetadata)` (a 2xx rejected by a `ResponseValidatorInterceptor`), `invalidResponse`, `cancelled`. `HTTPError` carries `statusCode`/`isClientError`/`isServerError`/`metadata`; cross-cutting `statusCode`, `responseMetadata`, and conservative `isRetryable` (transient transport + 408/429/5xx). **The core makes no assumption about the error body's shape** — `ResponseMetadata` retains the **full** `body: Data` (with `bodySnippet` a derived, truncated log excerpt and `decode(_:)` a convenience over `body`), so a caller decodes its own error envelope; there's no `serverMessage`/in-core parse. `ResponseMetadata(response:body:)` is the single source for metadata extraction. Construction lives in `Networking+New.swift` (`handleResponse`/`handleSuccessfulResponse`/`mapThrownError`).
- **Observability (`NetworkingEvent.swift`):** no `print`, no closure observer. One consumer hook — `events()`, an `AsyncStream<NetworkingEvent>` (multi-consumer, `for await` accumulation without `Box`/`@unchecked`; continuations registry + `onTermination` cleanup on the actor). Every request emits `.started(RequestContext)` then `.completed(RequestContext, outcome:duration:metrics:)` — verbs via `handle`/`emitPreflightFailure`, downloads via `handleDataRequest`/`handleImageRequest` (all share `complete`/`makeContext` in `Networking+New.swift`; downloads inline the emit/complete rather than a closure, to satisfy Swift 6.2 region isolation). `RequestContext`: per-request id + the real request headers (raw — redaction is a log-path concern); `TransactionMetrics` distills `URLSessionTaskMetrics` (via `MetricsCollector`, a lock-synchronized per-task delegate).
Expand Down
223 changes: 223 additions & 0 deletions Sources/Networking/CacheStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import Foundation
import CryptoKit

// Owns both cache tiers: the in-memory `NSCache` (warm, pressure-evicted by iOS) over the on-disk shard
// layout (cold, durable). A non-actor, synchronous type so the nonisolated cache reads
// (`imageFromCache`/`dataFromCache`) stay synchronous; thread-safety comes from `NSCache` (thread-safe),
// `CacheExpiry` (lock-synchronized), and a static lock serializing whole-folder mutations against the
// background sweep. Keyed by an already-resolved resource string — composing a key from baseURL + path is
// the networking layer's job, not the store's.
final class CacheStore: @unchecked Sendable {
let memory: NSCache<AnyObject, AnyObject>
let expiry: CacheExpiry
let folderName: String

init(memory: NSCache<AnyObject, AnyObject>, ttl: Duration, folderName: String) {
self.memory = memory
self.expiry = CacheExpiry(ttl: ttl)
self.folderName = folderName
}

var ttl: Duration { expiry.ttl }
func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) }

// MARK: - Layout

/// The on-disk URL for a resolved resource key, laid out under `folderName/<shard>/<file>`. Creates the
/// shard directory if needed.
func destinationURL(forResource resource: String) throws -> URL {
let component = Self.filesystemSafeComponent(resource.replacingOccurrences(of: "/", with: "-"))
// Shard the cache directory by a hash of the key so the per-launch sweep is O(N / shardCount), not
// O(N). Always sharded — one uniform layout, no migration or size threshold (see `sweepExpired`).
let folderPath = "\(folderName)/\(Self.shardName(for: component))"
let finalPath = "\(folderPath)/\(component)"

guard let url = URL(string: finalPath),
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
throw NSError(domain: folderName, code: 9999, userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"])
}

let folderURL = cachesURL.appendingPathComponent(URL(string: folderPath)!.absoluteString)
if FileManager.default.exists(at: folderURL) == false {
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
}
return cachesURL.appendingPathComponent(url.absoluteString)
}

static let shardCount = 16

// One hex-nibble shard derived from the key's hash; stable for a given key so reads and writes agree.
static func shardName(for component: String) -> String {
let byte = Array(SHA256.hash(data: Data(component.utf8))).first ?? 0
return String(Int(byte) % shardCount, radix: 16)
}

// A filesystem path component caps at 255 bytes, which a long URL would overflow. Keep a readable,
// byte-bounded prefix and append a hash of the full name so distinct URLs still get distinct files.
static func filesystemSafeComponent(_ name: String) -> String {
let maxBytes = 255
guard name.utf8.count > maxBytes else { return name }
let hash = SHA256.hash(data: Data(name.utf8)).map { String(format: "%02x", $0) }.joined()
let prefixBudget = maxBytes - hash.count - 1
var prefix = ""
var byteCount = 0
for character in name {
let width = String(character).utf8.count
if byteCount + width > prefixBudget { break }
prefix.append(character)
byteCount += width
}
return "\(prefix)-\(hash)"
}

// MARK: - Read

/// A pure read: serve from the warm tier, falling back to a non-expired disk entry (which re-warms both
/// tiers). Never mutates a tier except to drop an entry it finds expired. `.memory`/`.none` never touch
/// disk, so a read can't destroy a durable copy written at `.memoryAndFile`.
func object(forResource resource: String, level: Networking.CachingLevel, asImage: Bool) throws -> Any? {
let destinationURL = try destinationURL(forResource: resource)
let key = destinationURL.absoluteString
switch level {
case .memory:
return memory.object(forKey: key as AnyObject)
case .memoryAndFile:
// Memory is the warm tier — a memory hit is served without touching the disk (the NSCache
// absorbs repeat reads, so the file's mtime is only re-warmed on a memory miss, below).
if let object = memory.object(forKey: key as AnyObject) {
return object
} else if FileManager.default.exists(at: destinationURL) {
let fileDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
if expiry.isExpired(fileDate: fileDate) {
try FileManager.default.remove(at: destinationURL)
return nil
}

// The file can vanish between the exists() check above and this read — the background
// sweep runs concurrently and doesn't share a lock with reads — so a read failure is a
// cache miss, not a crash.
guard let data = FileManager.default.contents(atPath: destinationURL.path) else { return nil }
let returnedObject: Any? = asImage ? Image(data: data) : data
if let returnedObject {
memory.setObject(returnedObject as AnyObject, forKey: key as AnyObject)
// Re-warm: bump the file's mtime so an entry in active use never expires. Only happens
// on a memory miss, so it's ~once per entry per launch — no explicit debounce needed.
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: destinationURL.path)
}

return returnedObject
} else {
return nil
}
case .none:
return nil
}
}

// MARK: - Write

func storeData(_ data: Data?, forResource resource: String, level: Networking.CachingLevel) throws {
let destinationURL = try destinationURL(forResource: resource)
let key = destinationURL.absoluteString

if let returnedData = data, returnedData.count > 0 {
switch level {
case .memory:
memory.setObject(returnedData as AnyObject, forKey: key as AnyObject)
case .memoryAndFile:
_ = try returnedData.write(to: destinationURL, options: [.atomic])
memory.setObject(returnedData as AnyObject, forKey: key as AnyObject)
case .none:
break
}
// The disk write itself sets a fresh mtime — that *is* the entry's last-use timestamp.
} else {
memory.removeObject(forKey: key as AnyObject)
}
}

@discardableResult
func storeImage(data: Data?, forResource resource: String, level: Networking.CachingLevel) throws -> Image? {
let destinationURL = try destinationURL(forResource: resource)
let key = destinationURL.absoluteString

var image: Image?
if let data = data, let nonOptionalImage = Image(data: data), data.count > 0 {
switch level {
case .memory:
memory.setObject(nonOptionalImage, forKey: key as AnyObject)
case .memoryAndFile:
_ = try data.write(to: destinationURL, options: [.atomic])
memory.setObject(nonOptionalImage, forKey: key as AnyObject)
case .none:
break
}
image = nonOptionalImage
} else {
memory.removeObject(forKey: key as AnyObject)
}

return image
}

// MARK: - Clear & sweep

// Serializes whole-folder mutations of the shared cache directory so the background sweep (which
// creates the folder + writes its cursor) can't race a clear.
static let mutationLock = NSLock()
static let sweepCursorFileName = ".sweep-shard"

/// Empties **both** tiers (clearing only one would leave the other serving deleted data). Scoped to the
/// networking folder; unrelated files in Caches are untouched.
func clear() throws {
memory.removeAllObjects()
Self.mutationLock.lock()
defer { Self.mutationLock.unlock() }
guard let folderURL = Self.folderURL(named: folderName) else { return }
if FileManager.default.exists(at: folderURL) {
_ = try FileManager.default.remove(at: folderURL)
}
}

private static func folderURL(named folderName: String) -> URL? {
guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
return cachesURL.appendingPathComponent(URL(string: folderName)!.absoluteString)
}

// Deletes expired files from **one** shard per call (rotated via a tiny cursor file), so each launch's
// sweep is O(N / shardCount) and everything gets visited over `shardCount` launches. Age is judged by
// the file's modification date. Best-effort and off the request path.
func sweepExpired() {
Self.mutationLock.lock()
defer { Self.mutationLock.unlock() }
guard let domainURL = Self.folderURL(named: folderName) else { return }
let now = Date()
let maxAge = CacheExpiry.seconds(expiry.ttl)

let cursorURL = domainURL.appendingPathComponent(Self.sweepCursorFileName)
let cursor = (try? String(contentsOf: cursorURL, encoding: .utf8)).flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } ?? 0
let shardURL = domainURL.appendingPathComponent(String(cursor % Self.shardCount, radix: 16))

if let files = try? FileManager.default.contentsOfDirectory(at: shardURL, includingPropertiesForKeys: [.contentModificationDateKey], options: []) {
for file in files {
guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { continue }
if now.timeIntervalSince(modified) > maxAge {
try? FileManager.default.removeItem(at: file)
}
}
}

// Pre-sharding versions wrote files directly under the domain root; clear those strays (the cursor
// file and the shard subdirectories stay).
if let rootEntries = try? FileManager.default.contentsOfDirectory(at: domainURL, includingPropertiesForKeys: [.isRegularFileKey], options: []) {
for entry in rootEntries where entry.lastPathComponent != Self.sweepCursorFileName {
if (try? entry.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
try? FileManager.default.removeItem(at: entry)
}
}
}

try? FileManager.default.createDirectory(at: domainURL, withIntermediateDirectories: true)
try? String(cursor &+ 1).write(to: cursorURL, atomically: true, encoding: .utf8)
}
}
17 changes: 0 additions & 17 deletions Sources/Networking/JSONResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,6 @@ public struct AnyCodable: Decodable, @unchecked Sendable {
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

if self.value is NSNull {
try container.encodeNil()
} else if let value = self.value as? Bool {
try container.encode(value)
} else if let value = self.value as? Int {
try container.encode(value)
} else if let value = self.value as? Double {
try container.encode(value)
} else if let value = self.value as? String {
try container.encode(value)
} else {
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "The value cannot be encoded"))
}
}
}

extension AnyCodable: Hashable {
Expand Down
Loading