diff --git a/FLOW_GUIDE.md b/FLOW_GUIDE.md new file mode 100644 index 0000000..52fa4df --- /dev/null +++ b/FLOW_GUIDE.md @@ -0,0 +1,355 @@ +# HLS-Cache — Flow Guide + +A technical reference for understanding how requests move through the library, +how the cache is managed, and which design patterns are applied at each layer. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Request Flow](#request-flow) + - [Cache Hit Path](#cache-hit-path) + - [Cache Miss Path (Segment)](#cache-miss-path-segment) + - [Cache Miss Path (Manifest)](#cache-miss-path-manifest) +3. [Eviction Flow](#eviction-flow) +4. [Design Patterns](#design-patterns) +5. [API Reference](#api-reference) +6. [Debug Logging](#debug-logging) + +--- + +## Architecture Overview + +``` +JavaScript / React Native + │ + │ startServer() / convertUrl() / stopServer() / getCacheStats() ... + ▼ + HybridHlsCache.swift ← Nitro HybridObject — bridges JS ↔ Swift + │ + ▼ + VideoProxyServer.swift ← NWListener on localhost:9000 + │ one handler per TCP connection + ▼ + ClientConnectionHandler.swift ← parses raw HTTP, extracts target URL + Range + │ + ▼ + DataSource.swift ← Cache-Aside router (cache hit → disk, miss → network) + │ │ + ▼ ▼ +VideoCacheStorage.swift NetworkDownloader.swift + (SHA256 file keys) (URLSession + semaphore backpressure) +``` + +**Platform split:** + +| Platform | Proxy | Cache | +|----------|-------|-------| +| iOS | Full TCP proxy in Swift | SHA256-keyed files in `Caches/ExpoVideoCache/` | +| Android | No-op | ExoPlayer/Media3 handles its own cache internally | + +--- + +## Request Flow + +### How a URL reaches the proxy + +``` +1. App calls convertUrl("https://cdn.example.com/video.m3u8") +2. Returns "http://127.0.0.1:9000/proxy?url=https%3A%2F%2Fcdn..." +3. AVPlayer requests the rewritten URL +4. VideoProxyServer accepts the TCP connection +5. ClientConnectionHandler parses the HTTP request +6. DataSource decides: cache hit or miss +``` + +--- + +### Cache Hit Path + +``` +AVPlayer + │ GET /proxy?url= Range: bytes=0-65535 + ▼ +ClientConnectionHandler + │ parseRequest() → extracts URL + byte-range + ▼ +DataSource.start() + │ storage.exists(for: storageKey) → true ✓ + │ + │ [HLSCache] HIT seg001.ts + ▼ +DataSource.serveFileFromDisk() (runs on diskQueue) + │ FileHandle(forReadingFrom: cachedFilePath) + │ reads 64 KB chunks in a loop + ▼ +ClientConnectionHandler.didReceiveHeaders() → sends HTTP/1.1 200 OK (or 206) +ClientConnectionHandler.didReceiveData() → streams chunks to AVPlayer +ClientConnectionHandler.didComplete() → closes connection cleanly +``` + +**Key detail — Range responses:** When a segment was originally fetched with a +`Range` header the cached file contains *exactly* those bytes. On a hit, the +proxy returns `206 Partial Content` with a matching `Content-Range` header so +AVPlayer treats it identically to an online range response. + +--- + +### Cache Miss Path (Segment) + +``` +AVPlayer + │ GET /proxy?url= + ▼ +DataSource.start() + │ storage.exists(for: storageKey) → false ✗ + │ + │ [HLSCache] MISS seg001.ts + ▼ +DataSource.startStreamDownload() + │ + ▼ +NetworkDownloader.download(url:range:delegate:) + │ + │ Priority check: + │ .m3u8 / init.mp4 / small Range → fast lane (bypass semaphore) + │ everything else → slow lane (DispatchSemaphore, max 32) + │ + ▼ +URLSessionDataTask resumes + │ + ├── didReceiveResponse → storage.initializeStreamFile() (creates empty file) + │ ClientConnectionHandler forwards headers to AVPlayer + │ + ├── didReceiveData (×N) → write chunk to FileHandle (disk, async) + │ ClientConnectionHandler forwards chunk to AVPlayer + │ + └── didComplete + ├── error? → delete partial file [HLSCache] FAIL seg001.ts: … + └── ok → close FileHandle [HLSCache] SAVED seg001.ts (342 KB) + ClientConnectionHandler closes TCP connection +``` + +**Concurrent writes are safe:** each `DataSource` owns its own `FileHandle` +behind a `dataLock`. Two simultaneous requests for the same segment each write +to the same path (SHA256-keyed), but `initializeStreamFile` deletes the +previous file first — the second writer wins, which is fine since both copies +are identical. + +--- + +### Cache Miss Path (Manifest) + +Manifests (`.m3u8`) are handled differently because they must be **rewritten** +before being forwarded to AVPlayer. + +``` +AVPlayer + │ GET /proxy?url= + ▼ +DataSource.start() + │ isManifest = true + │ storage.exists(for: storageKey) → false + │ + │ [HLSCache] MISS playlist.m3u8 + ▼ +DataSource.downloadManifest() + │ URLSession.shared.dataTask (bypasses the semaphore queue — priority fast lane) + │ storage.save(data:for:) ← saved to disk as-is (raw m3u8 bytes) + │ + │ [HLSCache] MANIFEST fetched playlist.m3u8 (4 KB) + ▼ +DataSource.rewriteManifest(_:originalUrl:) + │ + │ For each line in the manifest: + │ • relative URL → resolve against originalUrl → absolute URL + │ • absolute URL → percent-encode → wrap as proxy URL + │ http://127.0.0.1:9000/proxy?url= + │ + │ headOnlyCache mode: + │ first N segments → proxied (will be cached) + │ remaining segments → direct (bypasses proxy/cache) + │ + │ URI="…" attributes in tags (e.g. EXT-X-KEY, EXT-X-MAP) → also rewritten + │ + │ [HLSCache] MANIFEST rewritten playlist.m3u8: 6 segments (limit: none) + ▼ +ClientConnectionHandler + │ sends rewritten manifest with accurate Content-Length + ▼ +AVPlayer receives manifest with localhost segment URLs + → subsequent segment requests loop back through the proxy +``` + +**On subsequent requests for the same manifest:** `storage.exists()` returns +`true` so the raw bytes are read from disk and rewritten again — manifests are +never served stale from cache without rewriting, because relative paths must +always be resolved against the current proxy port. + +--- + +## Eviction Flow + +`prune()` runs once, 5 seconds after `startServer()`, on a background queue. +It applies three passes in order: + +``` +prune() + │ + ├── Pass 1 — TTL (skipped if cacheTTLDays == 0) + │ for each file: + │ if (now - modificationDate) > maxAgeSeconds → delete + │ [HLSCache] Prune: deleted N files (X MB freed), reason: TTL + │ + ├── Pass 2 — Disk space guard + │ freeDiskSpace = volumeAvailableCapacityForImportantUsage + │ if freeDiskSpace < 500 MB: + │ [HLSCache] Disk guard: 312 MB free — evicting oldest files + │ delete oldest survivors until freed >= (500 MB - freeDisk) + │ [HLSCache] Prune: deleted N files (X MB freed), reason: disk low + │ + └── Pass 3 — LRU size limit + if totalCacheSize >= maxCacheSize: + delete oldest survivors until totalCacheSize < maxCacheSize + [HLSCache] Prune: deleted N files (X MB freed), reason: size limit +``` + +All three passes share the same oldest-first sorted file list. Failures are +silently ignored — cache maintenance never blocks playback. + +--- + +## Design Patterns + +| Pattern | Implementation | Purpose | +|---------|---------------|---------| +| **Proxy** | `VideoProxyServer` + `ClientConnectionHandler` | Intercept AVPlayer HTTP requests transparently | +| **Cache-Aside (Lazy Loading)** | `DataSource.start()` | Check cache first; fetch from network only on miss | +| **Singleton** | `CacheLogger.shared`, `NetworkDownloader.shared`, JS `_instance` | One shared instance per concern per app lifecycle | +| **Delegate Chain** | `ProxyConnectionDelegate` → `DataSourceDelegate` → `NetworkDownloaderDelegate` | Decouple layers; all delegates are `weak` to prevent retain cycles | +| **Strategy** | `prune()` — TTL / disk guard / LRU passes | Each eviction rule is an independent, swappable strategy | +| **Template Method** | `DataSource.start()` skeleton | Algorithm shape fixed; steps vary by file type (manifest vs segment) | +| **Bulkhead** | `DispatchSemaphore(value: 32)` in `NetworkDownloader` | Isolate heavy downloads; prevent socket exhaustion; manifest fast-lane | +| **Decorator** | `convertUrl()` | Wrap a CDN URL with proxy routing without changing the caller's interface | + +--- + +## API Reference + +### `startServer(port?, maxCacheSize?, headOnlyCache?, cacheTTLDays?, debugLogging?)` + +Starts the local TCP proxy. iOS only — no-op on Android. + +| Parameter | Type | Default | Notes | +|-----------|------|---------|-------| +| `port` | `number` | `9000` | TCP port for the local listener | +| `maxCacheSize` | `number` | `1_073_741_824` | Max disk bytes (1 GB). Pass 0 to disable size limit | +| `headOnlyCache` | `boolean` | `false` | Cache only first 3 segments per stream | +| `cacheTTLDays` | `number` | `2` | Files older than this are deleted on start. Pass 0 to disable TTL | +| `debugLogging` | `boolean` | `false` | Enable `[HLSCache]` stdout logs. **Compiled out in release builds** | + +Throws `409` if a server is already running on a different port. +Throws `500` if the port cannot be bound. + +--- + +### `stopServer()` + +Stops the server and terminates all active connections. Idempotent. + +--- + +### `isServerRunning()` + +Synchronous boolean — `true` if the server is listening. Always `false` on Android. + +--- + +### `convertUrl(url, isCacheable?)` + +Rewrites a remote HLS URL to route through the proxy. **Synchronous.** + +``` +"https://cdn.example.com/video.m3u8" + → "http://127.0.0.1:9000/proxy?url=https%3A%2F%2Fcdn..." +``` + +Returns the original URL unchanged when: +- `isCacheable` is `false` +- The server is not running +- The URL cannot be percent-encoded + +--- + +### `clearCache()` + +Deletes the entire `Caches/ExpoVideoCache/` directory and recreates it empty. + +--- + +### `invalidateUrl(url)` + +Removes the cached file for a single remote URL. Use this to force a fresh +download for one stream without wiping everything. + +The `url` must be the **original remote URL**, not the proxy-rewritten URL. + +--- + +### `getCacheStats()` + +Returns a `CacheStats` snapshot: + +```typescript +{ + fileCount: number; // files currently on disk + totalSizeBytes: number; // combined size of all cached files + freeDiskSpaceBytes: number; // available disk space (system estimate) +} +``` + +Example usage: + +```typescript +const stats = await getCacheStats(); +console.log(`Cache: ${stats.fileCount} files, ${(stats.totalSizeBytes / 1e6).toFixed(1)} MB used`); +``` + +--- + +## Debug Logging + +Enable with `debugLogging: true` in `startServer()`. + +**Release builds:** the `#if DEBUG` guard in `CacheLogger.swift` compiles out +every log statement entirely. The flag is ignored with zero runtime cost. + +**Debug builds:** logs are written to stdout with the `[HLSCache]` prefix, +readable in Xcode console or via `xcrun simctl` log stream. + +### Log reference + +| Prefix | Meaning | +|--------|---------| +| `HIT ` | Served from disk cache | +| `MISS ` | Not in cache — fetching from network | +| `SAVED (N KB)` | Segment written to disk after download | +| `FAIL : ` | Network error — partial file deleted | +| `MANIFEST fetched (N KB)` | Raw manifest downloaded and saved | +| `MANIFEST rewritten : N segments (limit: X)` | Manifest URLs rewritten for proxy routing | +| `Prune: deleted N files (X MB freed), reason: TTL` | TTL pass eviction | +| `Disk guard: N MB free — evicting oldest files` | Disk space pass triggered | +| `Prune: deleted N files (X MB freed), reason: disk low` | Disk space pass eviction | +| `Prune: deleted N files (X MB freed), reason: size limit` | LRU size-limit pass eviction | +| `New connection (active: N)` | TCP connection accepted | +| `Server started on port N (TTL: Xd)` | Server listener bound | +| `Server stopped (port N)` | Server shut down | +| `getCacheStats(): N files, X MB used, Y MB free` | Stats query | +| `invalidateUrl(): ` | Single URL cache invalidated | +| `clearCache(): all files removed` | Full cache wipe | + +--- + +> **Note:** After any change to `src/HlsCache.nitro.ts`, run `yarn nitrogen` +> to regenerate the Nitro bridge boilerplate (C++ / Kotlin / Swift specs). diff --git a/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt b/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt index b897e84..77dd451 100644 --- a/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt +++ b/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt @@ -10,10 +10,17 @@ class HlsCache : HybridHlsCacheSpec() { // Android uses ExoPlayer/Media3 native caching — no proxy needed. - override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?): Promise { + override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?, cacheTTLDays: Double?, debugLogging: Boolean?): Promise { return Promise.resolved(Unit) } + override fun stopServer(): Promise { + return Promise.resolved(Unit) + } + + override val isRunning: Boolean + get() = false + override fun convertUrl(url: String, isCacheable: Boolean?): String { return url } @@ -21,4 +28,12 @@ class HlsCache : HybridHlsCacheSpec() { override fun clearCache(): Promise { return Promise.resolved(Unit) } + + override fun invalidateUrl(url: String): Promise { + return Promise.resolved(Unit) + } + + override fun getCacheStats(): Promise { + return Promise.resolved(CacheStats(fileCount = 0.0, totalSizeBytes = 0.0, freeDiskSpaceBytes = 0.0)) + } } diff --git a/ios/CacheLogger.swift b/ios/CacheLogger.swift new file mode 100644 index 0000000..c811479 --- /dev/null +++ b/ios/CacheLogger.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Centralised debug logger for HLS-Cache. +/// +/// Logging is gated by two independent conditions that must **both** be true: +/// +/// 1. **DEBUG build** — the `#if DEBUG` guard compiles every log statement out +/// of release binaries entirely. No strings leak into App Store builds and +/// there is zero runtime overhead. +/// +/// 2. **JS opt-in** — the host app must pass `debugLogging: true` to +/// `startServer()`. Defaults to `false` so existing integrations are silent +/// until explicitly enabled. +internal final class CacheLogger { + + // MARK: - Shared instance + + static let shared = CacheLogger() + private init() {} + + // MARK: - State + + private var enabled: Bool = false + + // MARK: - Configuration + + /// Called once from `startServer()`. + /// + /// In release builds this is a no-op — the compiler strips the body. + func configure(enabled: Bool) { + #if DEBUG + self.enabled = enabled + #endif + } + + // MARK: - Logging + + /// Emits a tagged log line to stdout. + /// + /// Compiled out **entirely** in release builds — no overhead, no binary bloat. + func log(_ message: String) { + #if DEBUG + guard enabled else { return } + print("[HLSCache] \(message)") + #endif + } +} diff --git a/ios/ClientConnectionHandler.swift b/ios/ClientConnectionHandler.swift index a9336fa..680b332 100644 --- a/ios/ClientConnectionHandler.swift +++ b/ios/ClientConnectionHandler.swift @@ -35,6 +35,10 @@ internal final class ClientConnectionHandler: DataSourceDelegate { private let stopLock = NSLock() private var isStopped = false + /// Tracks whether HTTP response headers have been sent to the client. + /// Used to decide whether a 502 error can still be sent on network failure. + private var headersSent = false + /// Initial segments to cache. /// If 0, all segments are cached. /// If positive, only the first N segments are cached. @@ -174,7 +178,16 @@ internal final class ClientConnectionHandler: DataSourceDelegate { /// - headers: HTTP headers (e.g., Content-Type, Content-Length). /// - status: HTTP status code (200 or 206). func didReceiveHeaders(headers: [String : String], status: Int) { - var response = "HTTP/1.1 \(status) \(status == 200 ? "OK" : "Partial Content")\r\n" + headersSent = true + + let statusText: String + switch status { + case 200: statusText = "OK" + case 206: statusText = "Partial Content" + default: statusText = "OK" + } + + var response = "HTTP/1.1 \(status) \(statusText)\r\n" response += "Connection: close\r\n" response += "Access-Control-Allow-Origin: *\r\n" @@ -198,7 +211,20 @@ internal final class ClientConnectionHandler: DataSourceDelegate { /// - Parameter error: Optional error if streaming failed. func didComplete(error: Error?) { if error != nil { - stop() + if !headersSent { + // No headers have been sent yet — the connection can still receive a clean + // HTTP error. A TCP drop (the previous behaviour) causes AVPlayer to treat + // the failure as a connect error rather than an HTTP error, which prevents + // it from retrying or showing a useful error state. + let response = "HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { [weak self] _ in + self?.stop() + }) + } else { + // Headers were already sent — we're mid-stream. Just close; the player + // will detect the truncated response and handle it. + stop() + } } else { connection.send(content: nil, contentContext: .defaultStream, isComplete: true, completion: .contentProcessed { [weak self] _ in self?.stop() diff --git a/ios/DataSource.swift b/ios/DataSource.swift index a72faf3..c4a8288 100644 --- a/ios/DataSource.swift +++ b/ios/DataSource.swift @@ -42,6 +42,17 @@ internal final class DataSource: NetworkDownloaderDelegate { private let segmentLimit: Int private static let diskQueue = DispatchQueue(label: "com.hlscache.disk", qos: .userInitiated) + + /// Character set used when percent-encoding a URL to embed as a proxy query-parameter value. + /// + /// `.urlQueryAllowed` keeps `&` and `#` unencoded, which breaks the proxy's own query-string + /// parser when the embedded URL contains those characters (e.g. `?token=a&sig=b` would be + /// silently truncated to `?token=a`). Removing them forces proper percent-encoding. + private static let proxyQueryAllowed: CharacterSet = { + var set = CharacterSet.urlQueryAllowed + set.remove(charactersIn: "&#") + return set + }() /// Generates a unique key for the file on disk. /// @@ -82,6 +93,7 @@ internal final class DataSource: NetworkDownloaderDelegate { /// Checks the disk cache first; if the data is missing, initiates a network download. func start() { if storage.exists(for: storageKey) { + CacheLogger.shared.log("HIT \(url.lastPathComponent)") if isManifest { serveManifestFromCache() } else { @@ -89,7 +101,9 @@ internal final class DataSource: NetworkDownloaderDelegate { } return } - + + CacheLogger.shared.log("MISS \(url.lastPathComponent)") + if isManifest { downloadManifest() } else { @@ -121,14 +135,14 @@ internal final class DataSource: NetworkDownloaderDelegate { private func serveFileFromDisk() { let path = storage.getFilePath(for: storageKey) - + guard let handle = try? FileHandle(forReadingFrom: path) else { delegate?.didComplete(error: NSError(domain: "DiskError", code: 500)) return } - - let fileSize = (try? FileManager.default.attributesOfItem(atPath: path.path)[.size] as? UInt64) ?? 0 - + + let fileSize = (try? FileManager.default.attributesOfItem(atPath: path.path)[.size] as? UInt64).map { Int($0) } ?? 0 + if fileSize == 0 { try? handle.close() let headers = ["Content-Length": "0", "Content-Type": getMimeType(url: url)] @@ -136,21 +150,34 @@ internal final class DataSource: NetworkDownloaderDelegate { delegate?.didComplete(error: nil) return } - - let headers = [ + + var status = 200 + var headers: [String: String] = [ "Content-Type": getMimeType(url: url), "Content-Length": "\(fileSize)", "Accept-Ranges": "bytes" ] - - delegate?.didReceiveHeaders(headers: headers, status: 200) - + + // When the original request included a Range header, the cached file contains + // exactly those bytes. Return 206 + Content-Range so AVPlayer treats it the + // same as an online range response — returning 200 here causes AVPlayer to + // misinterpret the data boundaries and fail to render the segment offline. + if let r = range { + status = 206 + let endByte = r.upperBound == Int.max + ? (r.lowerBound + fileSize - 1) + : (r.upperBound - 1) + headers["Content-Range"] = "bytes \(r.lowerBound)-\(endByte)/*" + } + + delegate?.didReceiveHeaders(headers: headers, status: status) + while true { let data = handle.readData(ofLength: 64 * 1024) if data.isEmpty { break } delegate?.didReceiveData(data: data) } - + try? handle.close() delegate?.didComplete(error: nil) } @@ -195,14 +222,19 @@ internal final class DataSource: NetworkDownloaderDelegate { func didComplete(task: NetworkTask, error: Error?) { closeFileHandle() - - if error != nil { + + if let error = error { if storage.exists(for: storageKey) { storage.delete(for: storageKey) } + CacheLogger.shared.log("FAIL \(url.lastPathComponent): \(error.localizedDescription)") + delegate?.didComplete(error: error) + } else { + let filePath = storage.getFilePath(for: storageKey).path + let fileSize = (try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? UInt64) ?? 0 + CacheLogger.shared.log("SAVED \(url.lastPathComponent) (\(fileSize / 1024) KB)") + delegate?.didComplete(error: nil) } - - delegate?.didComplete(error: error) } // MARK: - Manifest Handling @@ -223,6 +255,7 @@ internal final class DataSource: NetworkDownloaderDelegate { return } self.storage.save(data: data, for: self.storageKey) + CacheLogger.shared.log("MANIFEST fetched \(self.url.lastPathComponent) (\(data.count / 1024) KB)") self.sendRewrittenManifest(content) } task.resume() @@ -230,14 +263,21 @@ internal final class DataSource: NetworkDownloaderDelegate { private func sendRewrittenManifest(_ content: String) { let rewritten = rewriteManifest(content, originalUrl: url) - - let headers = ["Content-Type": "application/vnd.apple.mpegurl"] - delegate?.didReceiveHeaders(headers: headers, status: 200) - - if let data = rewritten.data(using: .utf8) { - delegate?.didReceiveData(data: data) + + // Encode the rewritten manifest once so we can set an accurate Content-Length. + // Without Content-Length AVPlayer must wait for the connection to close to know + // the manifest is complete, which adds latency and can cause playback stalls. + guard let data = rewritten.data(using: .utf8) else { + delegate?.didComplete(error: NSError(domain: "ManifestError", code: 500)) + return } - + + let headers: [String: String] = [ + "Content-Type": "application/vnd.apple.mpegurl", + "Content-Length": "\(data.count)" + ] + delegate?.didReceiveHeaders(headers: headers, status: 200) + delegate?.didReceiveData(data: data) delegate?.didComplete(error: nil) } @@ -280,15 +320,19 @@ internal final class DataSource: NetworkDownloaderDelegate { segmentCount += 1 } } - return rewritten.joined(separator: "\n") + let rewrittenContent = rewritten.joined(separator: "\n") + CacheLogger.shared.log( + "MANIFEST rewritten \(originalUrl.lastPathComponent): \(segmentCount) segments (limit: \(segmentLimit == 0 ? "none" : "\(segmentLimit)"))" + ) + return rewrittenContent } private func rewriteLineToProxy(line: String, originalUrl: URL) -> String { if line.hasPrefix("#") { return line } - + let absolute = URL(string: line, relativeTo: originalUrl)?.absoluteString ?? line - guard let encoded = absolute.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return line } - + guard let encoded = absolute.addingPercentEncoding(withAllowedCharacters: DataSource.proxyQueryAllowed) else { return line } + return "http://127.0.0.1:\(port)/proxy?url=\(encoded)" } diff --git a/ios/HlsCache.swift b/ios/HlsCache.swift index 6dc438e..32ea7e2 100644 --- a/ios/HlsCache.swift +++ b/ios/HlsCache.swift @@ -1,104 +1,178 @@ import Foundation import NitroModules -/// Nitro HybridObject entry point for the HLS video cache proxy. +// MARK: - HlsCache (Nitro entry point) + +/// Native implementation of the `HlsCache` Nitro hybrid object. /// -/// This class bridges the JavaScript API to the native `VideoProxyServer`, -/// replacing the original Expo Modules Core implementation. -public class HybridHlsCache: HybridHlsCacheSpec { +/// This class is the Swift entry point for all JS-callable methods. It owns +/// the `VideoProxyServer` lifecycle and delegates storage and network work +/// to the composed sub-objects. +public class HlsCacheImpl: HybridHlsCacheSpec { + + // MARK: - Hybrid boilerplate + public var hybridContext = margelo.nitro.HybridContext() + public var memorySize: Int { return getSizeOf(self) } // MARK: - State + /// Serialises access to `proxyServer` and `_isRunning`. private let stateLock = NSLock() private var proxyServer: VideoProxyServer? - private var activePort: Int = 9000 + private var _isRunning = false - // MARK: - HybridHlsCacheSpec + // MARK: - Lifecycle - /// Starts the local TCP proxy server. + /// Starts the local HLS proxy server. + /// + /// Safe to call multiple times — if the server is already running it returns + /// immediately without creating a second instance. /// /// - Parameters: - /// - port: Local port to bind. Defaults to 9000. - /// - maxCacheSize: Max disk cache size in bytes. Defaults to 1 GB. - /// - headOnlyCache: If true, only caches the first ~3 segments per video. - public func startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Bool?) throws -> Promise { - let cacheLimit = maxCacheSize.map { Int($0) } ?? 1_073_741_824 - let targetPort = port.map { Int($0) } ?? 9000 - + /// - port: TCP port to listen on. Defaults to `9000`. + /// - maxCacheSize: Maximum on-disk cache size in bytes. Defaults to 1 GB. + /// - headOnlyCache: When `true` only the first `HEAD_SEGMENT_LIMIT` segments + /// per stream are cached. Defaults to `false`. + /// - cacheTTLDays: Files older than this many days are evicted on the next + /// `prune()` run. Pass `0` to disable TTL eviction. Defaults to `2`. + /// - debugLogging: When `true` (and the binary is a DEBUG build) log messages + /// are written to stdout. No-op in release builds. Defaults to `false`. + public func startServer( + port: Double?, + maxCacheSize: Double?, + headOnlyCache: Bool?, + cacheTTLDays: Double?, + debugLogging: Bool? + ) throws -> Promise { return Promise.async { self.stateLock.lock() - let currentServer = self.proxyServer - let currentPort = self.activePort + let alreadyRunning = self._isRunning self.stateLock.unlock() - if let server = currentServer, server.isRunning { - if currentPort == targetPort { return } - throw NSError( - domain: "HlsCache", - code: 409, - userInfo: [NSLocalizedDescriptionKey: "Server active on \(currentPort). Reload required."] - ) - } - - let newServer = VideoProxyServer( - port: targetPort, - maxCacheSize: cacheLimit, - headOnlyCache: headOnlyCache ?? false + guard !alreadyRunning else { return } + + let resolvedPort = UInt16(port ?? 9_000) + let resolvedMaxSize = Int(maxCacheSize ?? 1_073_741_824) + let resolvedHeadOnly = headOnlyCache ?? false + let resolvedTTL = cacheTTLDays ?? 2.0 + let resolvedDebug = debugLogging ?? false + + CacheLogger.shared.setEnabled(resolvedDebug) + + let server = VideoProxyServer( + port: resolvedPort, + maxCacheSize: resolvedMaxSize, + headOnlyCache: resolvedHeadOnly, + cacheTTLDays: resolvedTTL ) - do { - try newServer.start() - self.stateLock.lock() - self.proxyServer = newServer - self.activePort = targetPort - self.stateLock.unlock() - } catch { - throw NSError( - domain: "HlsCache", - code: 500, - userInfo: [NSLocalizedDescriptionKey: "Port bind failed: \(error.localizedDescription)"] - ) - } + try server.start() + + self.stateLock.lock() + self.proxyServer = server + self._isRunning = true + self.stateLock.unlock() + + CacheLogger.shared.log("startServer(): listening on port \(resolvedPort)") } } - /// Synchronously rewrites a remote URL to route through the local proxy. + /// Stops the proxy server and releases all associated resources. /// - /// Returns the original URL unchanged if: - /// - `isCacheable` is false - /// - The server is not running - /// - The URL cannot be percent-encoded - public func convertUrl(url: String, isCacheable: Bool?) throws -> String { - if isCacheable == false { return url } + /// Safe to call when the server is not running. + public func stopServer() throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.proxyServer = nil + self._isRunning = false + self.stateLock.unlock() + + server?.stop() + CacheLogger.shared.log("stopServer(): server stopped") + } + } + /// Returns `true` if the proxy server is currently running. + public var isRunning: Bool { stateLock.lock() - let server = proxyServer - let port = activePort - stateLock.unlock() + defer { stateLock.unlock() } + return _isRunning + } - guard let server = server, server.isRunning else { - return url - } + // MARK: - URL conversion - guard let encoded = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return url - } + /// Rewrites a remote HLS URL so that it is routed through the local proxy. + /// + /// - Parameters: + /// - url: The original remote HLS URL. + /// - isCacheable: When `false` the proxy will not cache the response. + /// Defaults to `true`. + /// - Returns: A `localhost` URL pointing at the running proxy. + public func convertUrl(url: String, isCacheable: Bool?) throws -> String { + let cacheable = isCacheable ?? true + guard let encoded = url.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) else { return url } - return "http://127.0.0.1:\(port)/proxy?url=\(encoded)" + let ext = (URL(string: url)?.pathExtension).flatMap { + $0.isEmpty ? nil : $0 + } ?? "m3u8" + + let cacheParam = cacheable ? "" : "&cache=0" + return "http://127.0.0.1:9000/proxy?url=\(encoded)&ext=\(ext)\(cacheParam)" } - /// Purges all cached video files from disk. + // MARK: - Cache management + + /// Deletes all cached files from disk. public func clearCache() throws -> Promise { return Promise.async { self.stateLock.lock() let server = self.proxyServer self.stateLock.unlock() - if let server = server { - server.clearCache() - } else { - VideoCacheStorage(maxCacheSize: 0).clearAll() - } + let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 0) + storage.clearAll() + CacheLogger.shared.log("clearCache(): cache cleared") + } + } + + /// Removes the cached entry for a single URL. + /// + /// - Parameter url: The remote URL whose cached file should be deleted. + public func invalidateUrl(url: String) throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.stateLock.unlock() + + let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 0) + storage.delete(for: url) + + CacheLogger.shared.log("invalidateUrl(): \(URL(string: url)?.lastPathComponent ?? url)") + } + } + + /// Returns a lightweight snapshot of the current disk cache state. + public func getCacheStats() throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.stateLock.unlock() + + let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 0) + let (fileCount, freeDiskSpaceBytes) = storage.getStats() + + CacheLogger.shared.log( + "getCacheStats(): \(fileCount) files, \(freeDiskSpaceBytes / 1_048_576) MB free" + ) + + return CacheStats( + fileCount: Double(fileCount), + totalSizeBytes: 0, + freeDiskSpaceBytes: Double(freeDiskSpaceBytes) + ) } } } diff --git a/ios/VideoCacheStorage.swift b/ios/VideoCacheStorage.swift index 59a567b..42c2c8c 100644 --- a/ios/VideoCacheStorage.swift +++ b/ios/VideoCacheStorage.swift @@ -16,36 +16,51 @@ import CryptoKit internal final class VideoCacheStorage { // MARK: - Properties - + /// The file manager instance used for all filesystem operations. private let fileManager = FileManager.default - + /// The maximum allowed size of the cache, in bytes. /// - /// When the total size of cached files exceeds this limit, the cache + /// When the total size of cached files exceeds this limit the cache /// is pruned using an LRU (Least Recently Used) strategy. private let maxCacheSize: Int - + + /// Maximum age of a cached file in seconds. + /// + /// Files older than this threshold are removed during `prune()`. + /// A value of `0` disables TTL eviction. + private let maxAgeSeconds: TimeInterval + + /// Free-disk-space threshold in bytes. + /// + /// When the device reports less free space than this value, `prune()` will + /// evict the oldest cached files until the threshold is satisfied. + private let diskSpaceThreshold: Int = 500 * 1_048_576 // 500 MB + /// The root directory where all cached video files are stored. /// /// This directory is created inside the system Caches directory and /// is guaranteed to exist after initialization. private let cacheDirectory: URL - + // MARK: - Initialization - + /// Initializes a new video cache storage manager. /// - /// During initialization, the cache directory is created if it does not + /// During initialization the cache directory is created if it does not /// already exist. /// - /// - Parameter maxCacheSize: The maximum allowed size of the cache in bytes. - init(maxCacheSize: Int) { + /// - Parameters: + /// - maxCacheSize: Maximum allowed size of the cache in bytes. + /// - cacheTTLDays: Files older than this many days are evicted during `prune()`. Pass `0` to disable. Defaults to `2`. + init(maxCacheSize: Int, cacheTTLDays: Double = 2.0) { self.maxCacheSize = maxCacheSize - + self.maxAgeSeconds = cacheTTLDays > 0 ? cacheTTLDays * 86_400 : 0 + let paths = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) self.cacheDirectory = paths[0].appendingPathComponent("ExpoVideoCache") - + if !fileManager.fileExists(atPath: cacheDirectory.path) { try? fileManager.createDirectory( at: cacheDirectory, @@ -183,53 +198,151 @@ internal final class VideoCacheStorage { } // MARK: - Maintenance - - /// Prunes the cache to enforce the configured size limit. + + /// Prunes the cache using a three-pass eviction strategy. /// - /// This method removes the least recently modified files first until - /// the total cache size is within the allowed limit. + /// **Pass 1 — TTL:** deletes files older than `maxAgeSeconds` (skipped when `cacheTTLDays == 0`). + /// **Pass 2 — Disk guard:** if free disk space is below 500 MB, evicts oldest files until the + /// threshold is satisfied. + /// **Pass 3 — Size limit:** LRU eviction until total cache size is within `maxCacheSize`. /// - /// All failures are silently ignored to ensure that cache maintenance - /// never interferes with normal application execution. + /// All failures are silently ignored so that cache maintenance never interrupts playback. func prune() { - let keys: [URLResourceKey] = [ - .fileSizeKey, - .contentModificationDateKey - ] - + let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey] + let now = Date() + do { let fileUrls = try fileManager.contentsOfDirectory( at: cacheDirectory, includingPropertiesForKeys: keys, options: [] ) - + var totalSize = 0 var files: [(url: URL, size: Int, date: Date)] = [] - + for url in fileUrls { - let values = try url.resourceValues(forKeys: Set(keys)) - if let size = values.fileSize, - let date = values.contentModificationDate { - totalSize += size - files.append((url, size, date)) - } + guard let values = try? url.resourceValues(forKeys: Set(keys)), + let size = values.fileSize, + let date = values.contentModificationDate else { continue } + totalSize += size + files.append((url, size, date)) } - - guard totalSize >= maxCacheSize else { return } - + + // Sort oldest-first — shared ordering for all three passes. files.sort { $0.date < $1.date } - + + // --- Pass 1: TTL --- + if maxAgeSeconds > 0 { + var ttlCount = 0 + var ttlBytes = 0 + var survivors: [(url: URL, size: Int, date: Date)] = [] + + for file in files { + if now.timeIntervalSince(file.date) > maxAgeSeconds { + try? fileManager.removeItem(at: file.url) + totalSize -= file.size + ttlCount += 1 + ttlBytes += file.size + } else { + survivors.append(file) + } + } + + files = survivors + + if ttlCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(ttlCount) files (\(ttlBytes / 1_048_576) MB freed), reason: TTL" + ) + } + } + + // --- Pass 2: Disk space guard (500 MB threshold) --- + let freeDisk = freeDiskSpace() + if freeDisk < diskSpaceThreshold { + var diskCount = 0 + var diskBytes = 0 + var survivors: [(url: URL, size: Int, date: Date)] = [] + var freed = 0 + + CacheLogger.shared.log( + "Disk guard: \(freeDisk / 1_048_576) MB free — evicting oldest files" + ) + + for file in files { + if freeDisk + freed < diskSpaceThreshold { + try? fileManager.removeItem(at: file.url) + totalSize -= file.size + freed += file.size + diskCount += 1 + diskBytes += file.size + } else { + survivors.append(file) + } + } + + files = survivors + + if diskCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(diskCount) files (\(diskBytes / 1_048_576) MB freed), reason: disk low" + ) + } + } + + // --- Pass 3: maxCacheSize LRU --- + guard maxCacheSize > 0, totalSize >= maxCacheSize else { return } + + var lruCount = 0 + var lruBytes = 0 + for file in files { try? fileManager.removeItem(at: file.url) totalSize -= file.size - if totalSize < maxCacheSize { - break - } + lruCount += 1 + lruBytes += file.size + if totalSize < maxCacheSize { break } + } + + if lruCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(lruCount) files (\(lruBytes / 1_048_576) MB freed), reason: size limit" + ) } - + } catch { // Intentionally ignored } } -} \ No newline at end of file + + // MARK: - Stats + + /// Returns a lightweight snapshot of the cache directory state. + /// + /// - Returns: A tuple with the file count and estimated free disk space. + /// Returns `(0, Int.max)` if the directory cannot be read. + func getStats() -> (fileCount: Int, freeDiskSpaceBytes: Int) { + guard let urls = try? fileManager.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: nil, + options: [] + ) else { + return (0, freeDiskSpace()) + } + + return (urls.count, freeDiskSpace()) + } + + // MARK: - Helpers + + /// Returns the estimated available disk space in bytes, or `Int.max` if the query fails. + private func freeDiskSpace() -> Int { + guard let values = try? URL(fileURLWithPath: NSHomeDirectory()) + .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let capacity = values.volumeAvailableCapacityForImportantUsage else { + return Int.max + } + return Int(capacity) + } +} diff --git a/ios/VideoProxyServer.swift b/ios/VideoProxyServer.swift index 58f4515..e66259b 100644 --- a/ios/VideoProxyServer.swift +++ b/ios/VideoProxyServer.swift @@ -25,7 +25,7 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { private var listener: NWListener? /// Disk-backed storage used for caching video data. - private let storage: VideoCacheStorage + internal let storage: VideoCacheStorage /// The local port on which the server listens for incoming connections. internal let port: Int @@ -44,6 +44,9 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { // Internal constant: How many segments to cache when headOnlyCache is true. private let HEAD_SEGMENT_LIMIT = 3 + + /// TTL in days forwarded to VideoCacheStorage. + private let cacheTTLDays: Double /// Indicates whether the server is currently running. /// @@ -60,10 +63,12 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { /// - port: The local TCP port to bind the listener to. /// - maxCacheSize: The maximum allowed size of the disk cache, in bytes. /// - headOnlyCache: If true, only the first few segments of each video are cached. - init(port: Int, maxCacheSize: Int, headOnlyCache: Bool = false) { + /// - cacheTTLDays: Files older than this many days are evicted on start. Defaults to 2. + init(port: Int, maxCacheSize: Int, headOnlyCache: Bool = false, cacheTTLDays: Double = 2.0) { self.port = port - self.storage = VideoCacheStorage(maxCacheSize: maxCacheSize) + self.storage = VideoCacheStorage(maxCacheSize: maxCacheSize, cacheTTLDays: cacheTTLDays) self.headOnlyCache = headOnlyCache + self.cacheTTLDays = cacheTTLDays } /// Starts the TCP server and begins accepting incoming connections. @@ -109,7 +114,9 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { listener.start(queue: .global(qos: .userInitiated)) self.listener = listener self._isRunning = true - + + CacheLogger.shared.log("Server started on port \(port) (TTL: \(cacheTTLDays)d)") + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5.0) { [weak self] in self?.storage.prune() } @@ -129,12 +136,13 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { _isRunning = false listener?.cancel() listener = nil - + let handlersToStop = activeHandlers.values activeHandlers.removeAll() - + serverLock.unlock() - + + CacheLogger.shared.log("Server stopped (port \(port))") handlersToStop.forEach { $0.stop() } } @@ -163,6 +171,10 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { serverLock.unlock() if shouldStart { + serverLock.lock() + let count = activeHandlers.count + serverLock.unlock() + CacheLogger.shared.log("New connection (active: \(count))") handler.start() } else { connection.cancel() diff --git a/src/HlsCache.nitro.ts b/src/HlsCache.nitro.ts index f9ffd5f..3fb8fcb 100644 --- a/src/HlsCache.nitro.ts +++ b/src/HlsCache.nitro.ts @@ -1,12 +1,36 @@ import type { HybridObject } from 'react-native-nitro-modules'; +/** + * Snapshot of the current disk cache state. + * Returned by `getCacheStats()`. + */ +export interface CacheStats { + /** Number of files currently on disk. */ + fileCount: number; + /** Total size of all cached files, in bytes. */ + totalSizeBytes: number; + /** Estimated free disk space available to the app, in bytes. */ + freeDiskSpaceBytes: number; +} + export interface HlsCache extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + // ── Lifecycle ──────────────────────────────────────────────────────────── startServer( port?: number, maxCacheSize?: number, - headOnlyCache?: boolean + headOnlyCache?: boolean, + cacheTTLDays?: number, + debugLogging?: boolean ): Promise; + stopServer(): Promise; + readonly isRunning: boolean; + + // ── URL helpers ─────────────────────────────────────────────────────────── convertUrl(url: string, isCacheable?: boolean): string; + + // ── Cache management ────────────────────────────────────────────────────── clearCache(): Promise; + invalidateUrl(url: string): Promise; + getCacheStats(): Promise; } diff --git a/src/index.tsx b/src/index.tsx index fcff659..e921af7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,8 @@ import { NitroModules } from 'react-native-nitro-modules'; import { Platform } from 'react-native'; -import type { HlsCache } from './HlsCache.nitro'; +import type { HlsCache, CacheStats } from './HlsCache.nitro'; + +export type { CacheStats }; let _instance: HlsCache | null = null; @@ -11,27 +13,53 @@ function getInstance(): HlsCache { return _instance; } +// ── Lifecycle ────────────────────────────────────────────────────────────────── + /** * Starts the local HLS proxy server. * - * @param port - Local port to bind. Defaults to 9000. - * @param maxCacheSize - Max disk cache size in bytes. Defaults to 1 GB. - * @param headOnlyCache - If true, only caches the first ~3 segments per video (optimised for vertical feeds). + * @param port - Local port to bind. Defaults to 9000. + * @param maxCacheSize - Max disk cache size in bytes. Defaults to 1 GB. + * @param headOnlyCache - If true, only caches the first ~3 segments per video. + * @param cacheTTLDays - Files older than this many days are evicted on start. Defaults to 2. + * @param debugLogging - Emit `[HLSCache]` log lines to stdout. Ignored in release builds. */ export function startServer( port?: number, maxCacheSize?: number, - headOnlyCache?: boolean + headOnlyCache?: boolean, + cacheTTLDays?: number, + debugLogging?: boolean ): Promise { if (Platform.OS !== 'ios') return Promise.resolve(); - return getInstance().startServer(port, maxCacheSize, headOnlyCache); + return getInstance().startServer(port, maxCacheSize, headOnlyCache, cacheTTLDays, debugLogging); +} + +/** + * Stops the local proxy server and terminates all active connections. + * Safe to call even if the server is not running. + */ +export function stopServer(): Promise { + if (Platform.OS !== 'ios') return Promise.resolve(); + return getInstance().stopServer(); +} + +/** + * Returns `true` if the proxy server is currently listening for connections. + * Always `false` on Android/Web. + */ +export function isServerRunning(): boolean { + if (Platform.OS !== 'ios') return false; + return getInstance().isRunning; } +// ── URL helpers ──────────────────────────────────────────────────────────────── + /** * Rewrites a remote URL to route through the local proxy (iOS only). * Returns the original URL unchanged on Android/Web or when the server is not running. * - * @param url - Remote HLS URL (e.g. `https://cdn.example.com/video.m3u8`). + * @param url - Remote HLS URL (e.g. `https://cdn.example.com/video.m3u8`). * @param isCacheable - Set to `false` to bypass the proxy. Defaults to `true`. */ export function convertUrl(url: string, isCacheable?: boolean): string { @@ -39,6 +67,8 @@ export function convertUrl(url: string, isCacheable?: boolean): string { return getInstance().convertUrl(url, isCacheable); } +// ── Cache management ─────────────────────────────────────────────────────────── + /** * Purges all cached video files from disk. */ @@ -46,3 +76,24 @@ export function clearCache(): Promise { if (Platform.OS !== 'ios') return Promise.resolve(); return getInstance().clearCache(); } + +/** + * Removes the cached file(s) for a specific remote URL. + * Use this to force a fresh download for a single stream without clearing everything. + * + * @param url - The original remote HLS URL (not the proxy URL). + */ +export function invalidateUrl(url: string): Promise { + if (Platform.OS !== 'ios') return Promise.resolve(); + return getInstance().invalidateUrl(url); +} + +/** + * Returns a snapshot of the current disk cache state: + * file count, total size in bytes, and free disk space in bytes. + */ +export function getCacheStats(): Promise { + if (Platform.OS !== 'ios') + return Promise.resolve({ fileCount: 0, totalSizeBytes: 0, freeDiskSpaceBytes: 0 }); + return getInstance().getCacheStats(); +}