Skip to content
Closed
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
355 changes: 355 additions & 0 deletions FLOW_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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=<encoded-segment-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=<encoded-segment-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=<encoded-manifest-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=<encoded>
│ 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 <file>` | Served from disk cache |
| `MISS <file>` | Not in cache — fetching from network |
| `SAVED <file> (N KB)` | Segment written to disk after download |
| `FAIL <file>: <reason>` | Network error — partial file deleted |
| `MANIFEST fetched <file> (N KB)` | Raw manifest downloaded and saved |
| `MANIFEST rewritten <file>: 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(): <filename>` | 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).
17 changes: 16 additions & 1 deletion android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,30 @@ class HlsCache : HybridHlsCacheSpec() {

// Android uses ExoPlayer/Media3 native caching — no proxy needed.

override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?): Promise<Unit> {
override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?, cacheTTLDays: Double?, debugLogging: Boolean?): Promise<Unit> {
return Promise.resolved(Unit)
}

override fun stopServer(): Promise<Unit> {
return Promise.resolved(Unit)
}

override val isRunning: Boolean
get() = false

override fun convertUrl(url: String, isCacheable: Boolean?): String {
return url
}

override fun clearCache(): Promise<Unit> {
return Promise.resolved(Unit)
}

override fun invalidateUrl(url: String): Promise<Unit> {
return Promise.resolved(Unit)
}

override fun getCacheStats(): Promise<CacheStats> {
return Promise.resolved(CacheStats(fileCount = 0.0, totalSizeBytes = 0.0, freeDiskSpaceBytes = 0.0))
}
}
Loading
Loading