phantom-frame 0.2.10

A high-performance prerendering proxy engine with caching support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
# Phantom Frame

A high-performance prerendering proxy engine written in Rust. Cache and serve prerendered content with ease.

<p align="center">
  <img src="https://raw.githubusercontent.com/ErdemGKSL/phantom-frame/refs/heads/main/banner.jpg" alt="Phantom Frame Banner" width="100%">
</p>

## Features

- 🚀 **Fast caching proxy** - Cache prerendered content and serve it instantly
- 🗂️ **Multi-server routing** - Declare multiple named `[server.NAME]` blocks in one config, each bound to a different path prefix
- 🎛️ **Flexible cache strategies** - Disable caching entirely or target HTML, images, and assets only
- 🗜️ **Cache-aware compression** - Store cached bodies as Brotli, gzip, or deflate and fall back to identity when a client does not support the stored encoding
- 🔧 **Dual mode operation** - Run as standalone HTTP server or integrate as a library
- 🔄 **Dynamic cache refresh** - Trigger cache invalidation via control endpoint or programmatically
- 🔐 **Optional authentication** - Secure control endpoints with bearer token auth
-**Async/await** - Built on Tokio and Axum for high performance
- 📦 **Easy integration** - Simple API for library usage
- 🌐 **WebSocket support** - Automatic detection and proxying of WebSocket and other protocol upgrade connections with bidirectional streaming
- 🔒 **HTTPS / TLS** - Optional TLS listener via `https_port` with rustls (default) or OpenSSL
- 📸 **SSG / PreGenerate mode** - Pre-fetch a set of paths at startup and serve them exclusively from cache

## Usage

### Mode 1: Standalone HTTP Server

Run as a standalone server with a TOML configuration file:

```bash
./phantom-frame ./config.toml
```

#### Configuration File (`config.toml`)

Global settings (ports, TLS, control auth) live at the TOML root without a section header.
Each proxy entry is declared as a `[server.NAME]` block.

```toml
# ── Global settings ───────────────────────────────────────────────────────────

# HTTP listen port (default: 3000)
http_port = 3000

# Control port for cache management endpoints (default: 17809)
control_port = 17809

# Optional: Bearer token for /refresh-cache authentication
# If set, callers must include: Authorization: Bearer <token>
# control_auth = "your-secret-token-here"

# Optional: HTTPS port — cert_path and key_path are required when set
# https_port = 443
# cert_path = "/etc/ssl/certs/fullchain.pem"
# key_path  = "/etc/ssl/private/privkey.pem"

# ── Server blocks ─────────────────────────────────────────────────────────────
# bind_to = "*"     → catch-all fallback (registered last)
# bind_to = "/api"  → nested under /api (Router::nest strips the prefix)

[server.default]
bind_to = "*"
proxy_url = "http://localhost:8080"

# Optional: Paths to include in caching (empty means include all)
# Supports wildcards: * can appear anywhere in the pattern
# Supports method prefixes: "GET /api/*", "POST /*/users", etc.
include_paths = ["/api/*", "/public/*", "GET /admin/stats"]

# Optional: Paths to exclude from caching (empty means exclude none)
# Exclude patterns override include patterns
exclude_paths = ["/api/admin/*", "/api/*/private", "POST *", "PUT *", "DELETE *"]

# Optional: Enable WebSocket and protocol upgrade support (default: true)
# Only active in Dynamic mode or PreGenerate mode with pre_generate_fallthrough = true.
# Pure SSG servers always return 501 for upgrade requests.
enable_websocket = true

# Optional: Only allow GET requests, reject all others (default: false)
forward_get_only = false

# Optional: Control which response types are cached (default: "all")
# Available values: "all", "none", "only_html", "no_images", "only_images", "only_assets"
cache_strategy = "all"

# Optional: Control how cached responses are stored in memory (default: "brotli")
# Available values: "none", "brotli", "gzip", "deflate"
compress_strategy = "brotli"

# Optional: Control where cached response bodies are stored (default: "memory")
# Available values: "memory", "filesystem"
cache_storage_mode = "memory"

# Optional: Override the directory used for filesystem-backed cache bodies
# cache_directory = "./.phantom-frame-cache"
```

#### Multi-Server Config

Multiple backends can be composed into a single Axum router. Longer `bind_to` prefixes are matched first so more-specific routes shadow shorter ones. `bind_to = "*"` is always the catch-all fallback.

> **Note**: `Router::nest("/api", …)` strips the `/api` prefix before the inner handler sees the path. Requests to `/api/users` are forwarded upstream as `/users`. If the upstream expects the full path, include the prefix in `proxy_url`.

```toml
http_port = 3000
control_port = 17809

# SSG frontend — pre-generated at startup, no backend at request time
[server.frontend]
bind_to = "*"
proxy_url = "http://localhost:5173"
proxy_mode = "pre_generate"
pre_generate_paths = ["/", "/about", "/blog"]
enable_websocket = false

# Dynamic API backend — requests forwarded and cached on demand
[server.api]
bind_to = "/api"
proxy_url = "http://localhost:8080"
proxy_mode = "dynamic"
enable_websocket = true
```

#### HTTPS / TLS

Set `https_port` at the root to enable a TLS listener. Both `cert_path` and `key_path` are required when this is set. Startup fails with a clear error if either is missing.

```toml
http_port  = 80
https_port = 443
cert_path  = "/etc/ssl/certs/fullchain.pem"
key_path   = "/etc/ssl/private/privkey.pem"

[server.default]
bind_to   = "*"
proxy_url = "http://localhost:8080"
```

TLS backend is selected by the active Cargo feature:
- **`rustls`** (default) — pure Rust, no system dependencies (`axum-server/tls-rustls`)
- **`native-tls`** — OpenSSL (`axum-server/tls-openssl`); requires OpenSSL as a system library

#### SSG / PreGenerate Mode

Set `proxy_mode = "pre_generate"` on a server block to pre-fetch a list of paths at startup.

```toml
[server.frontend]
bind_to = "*"
proxy_url = "http://localhost:5173"
proxy_mode = "pre_generate"
pre_generate_paths = ["/", "/about", "/blog/post-1"]

# On a cache miss:
#   false (default) → return 404 immediately, no backend contact
#   true            → fall through to the upstream backend
pre_generate_fallthrough = false
```

#### Cache Strategies

Use `cache_strategy` to control which backend responses are stored:

- `all`: Cache every response that passes your include/exclude rules.
- `none`: Disable cache reads and writes entirely. Useful for dev mode or plain proxying.
- `only_html`: Cache HTML documents only.
- `no_images`: Cache everything except `image/*` responses.
- `only_images`: Cache `image/*` responses only.
- `only_assets`: Cache static/application assets (CSS, JS, JSON, fonts, WebAssembly, XML, images).

#### Cache Compression Strategies

Use `compress_strategy` to control how cached bodies are stored in memory:

- `none`: Store uncompressed.
- `brotli` (default): Store with Brotli.
- `gzip`: Store with gzip.
- `deflate`: Store with deflate.

If the browser does not support the stored encoding, phantom-frame decodes the cached body and serves identity.

#### Cache Body Storage Modes

- `memory` (default): Cached bodies stay in process memory.
- `filesystem`: Bodies are written to a temp directory (or `cache_directory` if set) and loaded on cache hits. Metadata stays in memory.

#### Path Filtering

- **`include_paths`**: Only paths matching these patterns are cached. Empty = all.
- **`exclude_paths`**: Paths matching these patterns are never cached. Overrides include.
- `*` matches any sequence of characters anywhere in a pattern.
- Method prefixes: `GET /api/*`, `POST *`, `PUT /users/*`.

#### Control Endpoints

**POST /refresh-cache** — invalidate all server caches.

```bash
# Without authentication
curl -X POST http://localhost:17809/refresh-cache

# With authentication
curl -X POST http://localhost:17809/refresh-cache \
  -H "Authorization: Bearer your-secret-token-here"
```

### Mode 2: Library Integration

Add to your `Cargo.toml`:

```toml
[dependencies]
phantom-frame = { version = "0.2.3" }
tokio = { version = "1.40", features = ["full"] }
axum = "0.8"
```

Use in your code:

```rust
use phantom_frame::{
    create_proxy,
    cache::CacheHandle,
    CacheStrategy,
    CompressStrategy,
    CreateProxyConfig,
};
use axum::Router;

#[tokio::main]
async fn main() {
    let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
        .with_include_paths(vec![
            "/api/*".to_string(),
            "/public/*".to_string(),
            "GET /admin/stats".to_string(),
        ])
        .with_exclude_paths(vec![
            "/api/admin/*".to_string(),
            "POST *".to_string(),
            "PUT *".to_string(),
            "DELETE *".to_string(),
        ])
        .caching_strategy(CacheStrategy::OnlyHtml)
        .compression_strategy(CompressStrategy::Brotli)
        .with_websocket_enabled(true);

    let (proxy_app, handle): (Router, CacheHandle) = create_proxy(proxy_config);

    // Invalidate all cache entries
    handle.invalidate_all();

    // Invalidate only entries matching a pattern
    handle.invalidate("GET:/api/*");

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, proxy_app).await.unwrap();
}
```

For dev mode or plain proxying without any cache reads/writes:

```rust
let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .caching_strategy(CacheStrategy::None)
    .compression_strategy(CompressStrategy::None);
```

#### Custom Cache Key Function

```rust
use phantom_frame::{CreateProxyConfig, create_proxy, RequestInfo};

let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_cache_key_fn(|req_info: &RequestInfo| {
        let filtered_query = req_info.query
            .split('&')
            .filter(|p| !p.starts_with("session=") && !p.starts_with("token="))
            .collect::<Vec<_>>()
            .join("&");

        if filtered_query.is_empty() {
            format!("{}:{}", req_info.method, req_info.path)
        } else {
            format!("{}:{}?{}", req_info.method, req_info.path, filtered_query)
        }
    });
```

The `RequestInfo` struct provides:
- `method`: HTTP method (e.g., "GET", "POST")
- `path`: Request path (e.g., "/api/users")
- `query`: Query string (e.g., "id=123&sort=asc")
- `headers`: Request headers (for cache key logic based on Accept-Language, User-Agent, etc.)

#### Pattern-Based Cache Invalidation

```rust
// Clear all cache entries
handle.invalidate_all();

// Clear entries matching a wildcard pattern
handle.invalidate("GET:/api/*");
handle.invalidate("*/users/*");
handle.invalidate("POST:*");
```

## WebSocket and Protocol Upgrade Support

phantom-frame automatically detects and handles WebSocket connections and other HTTP protocol upgrades via `Connection: Upgrade` / `Upgrade` headers.

### Mode gating

WebSocket support is only active when the proxy has a live backend to tunnel to:

| `proxy_mode`                                | `enable_websocket = true` | Result          |
|---------------------------------------------|---------------------------|-----------------|
| `dynamic`                                   | yes                       | tunnel          |
| `pre_generate` + `pre_generate_fallthrough = true` | yes              | tunnel          |
| `pre_generate` + `pre_generate_fallthrough = false` (pure SSG) | any | 501 Not Implemented |

### Disabling WebSocket Support

```toml
# In server block
enable_websocket = false
```

```rust
// In library mode
let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_websocket_enabled(false);
```

## TLS Feature Flags

```toml
# Default — pure Rust, no system dependencies
phantom-frame = { version = "0.2.3" }

# OpenSSL backend (requires libssl-dev / openssl-devel / OPENSSL_DIR on Windows)
phantom-frame = { version = "0.2.3", default-features = false, features = ["native-tls"] }
```

## Building

```bash
# Build the project (default: rustls)
cargo build --release

# Build with OpenSSL backend
cargo build --release --no-default-features --features native-tls

# Run in development
cargo run -- ./config.toml

# Run the library example
cargo run --example library_usage
```

## How It Works

1. **Request Flow**: Incoming request → check 404 cache → check main cache → fetch from backend → store in cache → return response
2. **WebSocket/Upgrade**: Requests with `Connection: Upgrade` bypass caching and establish a direct bidirectional TCP tunnel to the backend (Dynamic / PreGenerate+fallthrough modes only)
3. **Multi-Server**: Multiple `[server.NAME]` blocks are composed into one Axum router. Specific prefixes (`/api`) are nested longest-first; `bind_to = "*"` is the fallback
4. **SSG Mode**: Specified paths are pre-fetched at startup. Cache misses either return 404 immediately or fall through to the backend depending on `pre_generate_fallthrough`
5. **Cache Refresh**: Invalidation is triggered via `/refresh-cache` or programmatically via `CacheHandle`

## API Reference

### Library API

#### `CreateProxyConfig`

- `CreateProxyConfig::new(proxy_url: String)` — create with defaults
- `with_include_paths(paths: Vec<String>)`
- `with_exclude_paths(paths: Vec<String>)`
- `with_websocket_enabled(enabled: bool)`
- `with_forward_get_only(enabled: bool)`
- `with_cache_key_fn(f: impl Fn(&RequestInfo) -> String)`
- `with_cache_404_capacity(capacity: usize)`
- `with_use_404_meta(enabled: bool)`
- `with_cache_strategy(strategy: CacheStrategy)` / `caching_strategy(…)`
- `with_compress_strategy(strategy: CompressStrategy)` / `compression_strategy(…)`
- `with_cache_storage_mode(mode: CacheStorageMode)`
- `with_cache_directory(directory: impl Into<PathBuf>)`
- `with_proxy_mode(mode: ProxyMode)`

#### `create_proxy(config: CreateProxyConfig) -> (Router, CacheHandle)`

Creates a proxy router and cache handle.

#### `CacheHandle`

- `invalidate_all()` — clear all cache entries
- `invalidate(pattern: &str)` — clear entries matching a wildcard pattern
- `add_snapshot(path)` — (PreGenerate) fetch and cache a new path
- `refresh_snapshot(path)` — (PreGenerate) re-fetch a single cached path
- `remove_snapshot(path)` — (PreGenerate) evict a path from cache
- `refresh_all_snapshots()` — (PreGenerate) re-fetch all tracked paths

### Control Endpoints

#### `POST /refresh-cache`

Invalidates all server caches. Requires `Authorization: Bearer <token>` header if `control_auth` is set.

## Limitations and Important Notes

phantom-frame caches a single rendered version per cache key and serves it to all users. This works well for public, user-agnostic content. Avoid caching:

- Cookie- or session-based SSR (personalized content will be served to the wrong user)
- Pages that vary by user-specific headers (Authorization, Cookie, Session)

**Safe patterns:**

- Use `exclude_paths` to skip routes that depend on session state
- Set `Cache-Control: private` / `no-store` on the backend for user-specific responses
- Vary the cache key by safe attributes (Accept-Language) but never by user identifiers
- Cache a shared skeleton and load user-specific data client-side via XHR/fetch

## License

See LICENSE file for details


- 🚀 **Fast caching proxy** - Cache prerendered content and serve it instantly
- 🎛️ **Flexible cache strategies** - Disable caching entirely or target HTML, images, and assets only
- 🗜️ **Cache-aware compression** - Store cached bodies as Brotli, gzip, or deflate and fall back to identity when a client does not support the stored encoding
- 🔧 **Dual mode operation** - Run as standalone HTTP server or integrate as a library
- 🔄 **Dynamic cache refresh** - Trigger cache invalidation via control endpoint or programmatically
- 🔐 **Optional authentication** - Secure control endpoints with bearer token auth
-**Async/await** - Built on Tokio and Axum for high performance
- 📦 **Easy integration** - Simple API for library usage
- 🌐 **WebSocket support** - Automatic detection and proxying of WebSocket and other protocol upgrade connections with bidirectional streaming

## Usage

### Mode 1: Standalone HTTP Server

Run as a standalone server with a TOML configuration file:

```bash
./phantom-frame ./config.toml
```

#### Configuration File (`config.toml`)

```toml
[server]
# Control port for cache management endpoints (default: 17809)
control_port = 17809

# Proxy port for serving prerendered content (default: 3000)
proxy_port = 3000

# The backend URL to proxy requests to (default: http://localhost:8080)
proxy_url = "http://localhost:8080"

# Optional: Paths to include in caching (empty means include all)
# Supports wildcards: * can appear anywhere in the pattern
# Supports method prefixes: "GET /api/*", "POST /*/users", etc.
# Examples: "/api/*", "/*/users", "/public/*/assets", "GET *"
include_paths = ["/api/*", "/public/*", "GET /admin/stats"]

# Optional: Paths to exclude from caching (empty means exclude none)
# Supports wildcards: * can appear anywhere in the pattern
# Supports method prefixes: "POST /api/*", "PUT *", etc.
# Exclude patterns override include patterns
exclude_paths = ["/api/admin/*", "/api/*/private", "POST *", "PUT *", "DELETE *"]

# Optional: Enable WebSocket and protocol upgrade support (default: true)
# When enabled, requests with Connection: Upgrade headers will bypass the cache
# and establish a direct bidirectional TCP tunnel to the backend
# Set to false to disable WebSocket/upgrade support and return 501 Not Implemented
enable_websocket = true

# Optional: Only allow GET requests, reject all others (default: false)
# When enabled, only GET requests are processed; POST, PUT, DELETE, etc. return 405 Method Not Allowed
# Useful for static site prerendering or development proxying where mutations shouldn't be allowed
forward_get_only = false

# Optional: Control which response types are cached (default: "all")
# Available values: "all", "none", "only_html", "no_images", "only_images", "only_assets"
# Use "none" when you want phantom-frame to behave like a plain proxy in development.
cache_strategy = "all"

# Optional: Control how cached responses are stored in memory (default: "brotli")
# Available values: "none", "brotli", "gzip", "deflate"
# Only responses that are actually written to cache are compressed.
# If a later client does not support the stored encoding, phantom-frame decodes
# the cached body and serves it without Content-Encoding.
compress_strategy = "brotli"

# Optional: Control where cached response bodies are stored (default: "memory")
# Available values: "memory", "filesystem"
# Filesystem mode stores cache bodies under the OS temp directory unless cache_directory is set.
cache_storage_mode = "memory"

# Optional: Override the directory used for filesystem-backed cache bodies
# cache_directory = "./.phantom-frame-cache"

# Optional: Bearer token for control endpoint authentication
# If set, requests to /refresh-cache must include: Authorization: Bearer <token>
control_auth = "your-secret-token-here"
```

#### Cache Strategies

Use `cache_strategy` to control which backend responses are stored:

- `all`: Preserve the current behavior and cache every response that passes your include/exclude rules.
- `none`: Disable cache reads and writes entirely, including the 404 cache. Useful for dev mode or plain proxying.
- `only_html`: Cache HTML documents only.
- `no_images`: Cache everything except `image/*` responses.
- `only_images`: Cache `image/*` responses only.
- `only_assets`: Cache static/application assets such as CSS, JavaScript, JSON, fonts, WebAssembly, XML, and images.

Examples:

```toml
# Run as an uncached development proxy
cache_strategy = "none"

# Cache HTML pages only but still proxy assets through
cache_strategy = "only_html"

# Cache scripts, styles, fonts, JSON, and images but skip HTML documents
cache_strategy = "only_assets"
```

#### Cache Compression Strategies

Use `compress_strategy` to control how cached bodies are stored in memory:

- `none`: Keep cached bodies uncompressed.
- `brotli`: Store cached bodies with Brotli compression.
- `gzip`: Store cached bodies with gzip compression.
- `deflate`: Store cached bodies with deflate compression.

Behavior notes:

- Compression is applied only when phantom-frame is going to store the response in cache.
- Non-cacheable responses are proxied directly with the backend body and headers unchanged.
- Cached entries are stored once per cache key, not once per encoding.
- If the browser supports the stored encoding, phantom-frame serves the cached compressed bytes directly.
- If the browser does not support the stored encoding, phantom-frame decodes the cached body and serves identity instead of creating another cache entry.

Examples:

```toml
# Keep cached responses uncompressed
compress_strategy = "none"

# Store cached responses as gzip instead of Brotli
compress_strategy = "gzip"
```

#### Cache Body Storage Modes

Use `cache_storage_mode` to control whether cached bodies stay in RAM or move to the filesystem after compression:

- `memory`: Preserve the previous behavior and keep cached bodies in process memory.
- `filesystem`: Write cached bodies to a phantom-frame temp directory and load them back from disk on cache hits.

Behavior notes:

- Metadata such as status code, headers, and cache keys still stays in memory.
- `compress_strategy` still applies before the body is stored, so cache-hit content negotiation behaves the same in either mode.
- `cache_directory` is optional. If omitted, phantom-frame uses a subdirectory under the OS temp directory.
- On startup, phantom-frame removes orphaned cache files left in its filesystem cache subdirectories from previous process exits.
- Clearing the cache, wildcard invalidation, and 404 eviction also delete the backing files.

Examples:

```toml
# Reduce RAM usage by writing cached bodies to the temp directory
cache_storage_mode = "filesystem"

# Use a project-local directory for cache files instead of the OS temp directory
cache_storage_mode = "filesystem"
cache_directory = "./.phantom-frame-cache"
```

#### Path Filtering

You can control which paths are cached using `include_paths` and `exclude_paths`:

- **include_paths**: If specified, only paths matching these patterns will be cached. If empty, all paths are included (subject to exclusions).
- **exclude_paths**: Paths matching these patterns will never be cached. If empty, no paths are excluded.
- **Wildcard support**: Use `*` anywhere in a pattern to match any sequence of characters.
- **Method filtering**: Prefix patterns with HTTP methods like `GET /api/*`, `POST *`, `PUT /users/*`.
- **Priority**: Exclude patterns override include patterns.

**Examples:**

```toml
# Cache only API and public content
include_paths = ["/api/*", "/public/*"]

# Cache everything except admin and private paths
exclude_paths = ["/admin/*", "/*/private/*"]

# Cache API but exclude admin endpoints
include_paths = ["/api/*"]
exclude_paths = ["/api/admin/*"]

# Cache only GET requests (exclude all mutations)
exclude_paths = ["POST *", "PUT *", "DELETE *", "PATCH *"]

# Cache only specific methods for specific paths
include_paths = ["GET *"]  # Only cache GET requests
exclude_paths = ["GET /api/admin/*"]  # But not admin GET requests

# Mixed method and path filtering
include_paths = ["/api/*", "GET /admin/stats"]
exclude_paths = ["POST /api/*", "PUT /api/*", "/api/*/private"]
```

#### Control Endpoints

**POST /refresh-cache** - Trigger cache invalidation

```bash
# Without authentication
curl -X POST http://localhost:17809/refresh-cache

# With authentication (if control_auth is set)
curl -X POST http://localhost:17809/refresh-cache \
  -H "Authorization: Bearer your-secret-token-here"
```

### Mode 2: Library Integration

Add to your `Cargo.toml`:

```toml
[dependencies]
phantom-frame = { version = "0.1.17" }
tokio = { version = "1.40", features = ["full"] }
axum = "0.8.6"
```

Use in your code:

```rust
use phantom_frame::{
    create_proxy,
    cache::RefreshTrigger,
    CacheStrategy,
    CompressStrategy,
    CreateProxyConfig,
};
use axum::Router;

#[tokio::main]
async fn main() {
    // Create proxy configuration with method-based filtering
    let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
        .with_include_paths(vec![
            "/api/*".to_string(),
            "/public/*".to_string(),
            "GET /admin/stats".to_string(), // Only cache GET requests to this endpoint
        ])
        .with_exclude_paths(vec![
            "/api/admin/*".to_string(),
            "POST *".to_string(),   // Don't cache any POST requests
            "PUT *".to_string(),    // Don't cache any PUT requests  
            "DELETE *".to_string(), // Don't cache any DELETE requests
        ])
        .caching_strategy(CacheStrategy::OnlyHtml)
        .compression_strategy(CompressStrategy::Brotli)
        .with_websocket_enabled(true); // Enable WebSocket support (default: true)
    
    // Create proxy - returns router and refresh trigger
    let (proxy_app, refresh_trigger): (Router, RefreshTrigger) = 
        create_proxy(proxy_config);
    
    // Clone and use the refresh_trigger anywhere in your app
    let trigger_clone = refresh_trigger.clone();
    
    // Trigger cache refresh programmatically
    tokio::spawn(async move {
        // Clear all cache entries
        trigger_clone.trigger();
        
        // Or clear only specific cache entries matching a pattern
        trigger_clone.trigger_by_key_match("GET:/api/*");
        trigger_clone.trigger_by_key_match("*/users/*");
    });
    
    // Start the proxy server
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    
    axum::serve(listener, proxy_app).await.unwrap();
}
```

For dev mode or plain proxying without any cache reads/writes:

```rust
use phantom_frame::{CacheStrategy, CompressStrategy, CreateProxyConfig};

let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .caching_strategy(CacheStrategy::None)
    .compression_strategy(CompressStrategy::None);
```

#### Custom Cache Key Function

You can customize how cache keys are generated. The cache key function receives a `RequestInfo` struct containing the HTTP method, path, and query string:

```rust
use phantom_frame::{CreateProxyConfig, create_proxy, RequestInfo};

let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_cache_key_fn(|req_info: &RequestInfo| {
        // Custom cache key logic
        // For example, ignore certain query parameters
        
        // Filter out session-specific query params
        let filtered_query = if !req_info.query.is_empty() {
            req_info.query
                .split('&')
                .filter(|p| !p.starts_with("session=") && !p.starts_with("token="))
                .collect::<Vec<_>>()
                .join("&")
        } else {
            String::new()
        };
        
        // Include method in cache key
        if filtered_query.is_empty() {
            format!("{}:{}", req_info.method, req_info.path)
        } else {
            format!("{}:{}?{}", req_info.method, req_info.path, filtered_query)
        }
    });

let (proxy_app, refresh_trigger) = create_proxy(proxy_config);
```

The `RequestInfo` struct provides:
- `method`: HTTP method (e.g., "GET", "POST", "PUT")
- `path`: Request path (e.g., "/api/users")
- `query`: Query string (e.g., "id=123&sort=asc")
- `headers`: Request headers (for cache key logic based on headers like Accept-Language, User-Agent, etc.)

**Advanced example with headers:**

```rust
use phantom_frame::{CreateProxyConfig, create_proxy, RequestInfo};

let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_cache_key_fn(|req_info: &RequestInfo| {
        // Include Accept-Language header in cache key for i18n
        let lang = req_info.headers
            .get("accept-language")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.split(',').next()) // Get primary language
            .unwrap_or("en");
        
        // Create cache key with method, path, and language
        if req_info.query.is_empty() {
            format!("{}:{}:lang={}", req_info.method, req_info.path, lang)
        } else {
            format!("{}:{}?{}:lang={}", req_info.method, req_info.path, req_info.query, lang)
        }
    });

let (proxy_app, refresh_trigger) = create_proxy(proxy_config);
```

#### Pattern-Based Cache Invalidation

The `RefreshTrigger` supports both full cache clears and pattern-based invalidation using wildcards:

```rust
use phantom_frame::{create_proxy, CreateProxyConfig};

let (proxy_app, refresh_trigger) = create_proxy(
    CreateProxyConfig::new("http://localhost:8080".to_string())
);

// Clear all cache entries
refresh_trigger.trigger();

// Clear only entries matching specific patterns (with wildcard support)
refresh_trigger.trigger_by_key_match("GET:/api/*");        // Clear all GET /api/* requests
refresh_trigger.trigger_by_key_match("*/users/*");         // Clear all requests with /users/ in path
refresh_trigger.trigger_by_key_match("POST:*");            // Clear all POST requests
refresh_trigger.trigger_by_key_match("GET:/api/users");    // Clear exact match

// Use in response to specific events
tokio::spawn(async move {
    // Example: Clear user-related cache when user data changes
    refresh_trigger.trigger_by_key_match("*/users/*");
    
    // Example: Clear API cache after data update
    refresh_trigger.trigger_by_key_match("GET:/api/*");
});
```

**Pattern Matching Rules:**
- `*` matches any sequence of characters
- Patterns can include the HTTP method prefix (e.g., `GET:/api/*`)
- Multiple wildcards are supported (e.g., `*/api/*/users/*`)
- Exact matches work without wildcards (e.g., `GET:/api/users`)

## WebSocket and Protocol Upgrade Support

phantom-frame automatically detects and handles WebSocket connections and other HTTP protocol upgrades (e.g., HTTP/2, Server-Sent Events with upgrade):

### How it works

1. **Automatic Detection**: Any request with `Connection: Upgrade` or `Upgrade` headers is automatically detected
2. **Direct Proxying**: Upgrade requests bypass the cache entirely and establish a direct bidirectional TCP tunnel
3. **Full Transparency**: The WebSocket handshake is completed between client and backend, and all data flows directly through the proxy
4. **Long-lived Connections**: The tunnel remains open for the lifetime of the connection, supporting real-time bidirectional communication

### Example

Your backend WebSocket endpoints will work seamlessly through phantom-frame:

```javascript
// Frontend code - connect to WebSocket through the proxy
const ws = new WebSocket('ws://localhost:3000/api/ws');

ws.onopen = () => {
  console.log('Connected');
  ws.send('Hello Server!');
};

ws.onmessage = (event) => {
  console.log('Received:', event.data);
};
```

```rust
// Backend code - your WebSocket handler runs as normal
// phantom-frame will tunnel the connection transparently
use axum::{
    routing::get,
    extract::ws::{WebSocket, WebSocketUpgrade},
    Router,
};

async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(msg) = socket.recv().await {
        // Handle WebSocket messages
    }
}
```

**Note**: WebSocket and upgrade connections are never cached, as they are inherently stateful and bidirectional. The proxy acts as a transparent tunnel for these connections.

### Disabling WebSocket Support

If you don't need WebSocket support or want to explicitly block protocol upgrades, you can disable it:

**In config.toml:**
```toml
[server]
enable_websocket = false  # Disable WebSocket support
```

**In library mode:**
```rust
let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_websocket_enabled(false);  // Disable WebSocket support
```

When disabled, any upgrade request (WebSocket, etc.) will receive a `501 Not Implemented` response.

## Building

```bash
# Build the project
cargo build --release

# Run in development
cargo run -- ./config.toml

# Run the library example
cargo run --example library_usage
```

## How It Works

1. **Request Flow**: When a request comes in, phantom-frame first checks if the content is cached
2. **Cache Miss**: If not cached, it fetches from the backend, caches the response, and returns it
3. **Cache Hit**: If cached, it serves the cached content immediately
4. **Cache Refresh**: The cache can be invalidated via the control endpoint or programmatically
5. **WebSocket/Upgrade Handling**: Requests with `Connection: Upgrade` or `Upgrade` headers (e.g., WebSocket) are automatically detected and bypass the cache entirely. Instead, a direct bidirectional TCP tunnel is established between the client and backend, allowing long-lived connections to work seamlessly.

## API Reference

### Library API

#### `RequestInfo`

Information about an incoming request for cache key generation.

- **Fields**:
  - `method: &str` - HTTP method (e.g., "GET", "POST")
  - `path: &str` - Request path (e.g., "/api/users")
  - `query: &str` - Query string (e.g., "id=123&sort=asc")
  - `headers: &HeaderMap` - Request headers (e.g., for cache keys based on Accept-Language, User-Agent, etc.)

#### `CreateProxyConfig`

Configuration struct for creating a proxy.

- **Constructor**: `CreateProxyConfig::new(proxy_url: String)` - Create with default settings
- **Methods**:
  - `with_include_paths(paths: Vec<String>)` - Set paths to include in caching (supports method prefixes like "GET /api/*")
  - `with_exclude_paths(paths: Vec<String>)` - Set paths to exclude from caching (supports method prefixes like "POST *")
  - `with_websocket_enabled(enabled: bool)` - Enable or disable WebSocket and protocol upgrade support (default: true)
  - `with_cache_key_fn(f: impl Fn(&RequestInfo) -> String)` - Set custom cache key generator

#### `create_proxy(config: CreateProxyConfig) -> (Router, RefreshTrigger)`

Creates a proxy router and refresh trigger.

- **Parameters**: `config` - Proxy configuration
- **Returns**: Tuple of `(Router, RefreshTrigger)`

#### `create_proxy_with_trigger(config: CreateProxyConfig, refresh_trigger: RefreshTrigger) -> Router`

Creates a proxy router with an existing refresh trigger.

- **Parameters**: 
  - `config` - Proxy configuration
  - `refresh_trigger` - Existing refresh trigger to use
- **Returns**: `Router`

#### `RefreshTrigger`

A clonable trigger for cache invalidation.

- `trigger()` - Trigger a full cache refresh (clears all entries)
- `trigger_by_key_match(pattern: &str)` - Trigger a cache refresh for entries matching a pattern (supports wildcards like `/api/*`, `GET:/api/*`, etc.)
- `subscribe()` - Subscribe to refresh events (returns a broadcast receiver)

### Control Endpoints

#### `POST /refresh-cache`

Triggers cache invalidation. Requires `Authorization: Bearer <token>` header if `control_auth` is configured.

## Limitations and important notes

phantom-frame is designed as a high-performance prerendering proxy that caches responses and serves them to subsequent requests. This works well for pages whose rendered HTML is identical for all users. However, there are important limitations you should be aware of:

- Cookie- or session-based SSR will not work correctly when cached: if your backend renders different content depending on cookies, authentication, or per-user session state, phantom-frame will cache a single rendered version and serve it to other users. That means personalized content (for example, "Hello, Alice" vs "Hello, Bob"), shopping carts, or any user-specific sections may be shown to the wrong user.

- Pages that vary by request headers (besides a safe, small set such as Accept-Language) may be incorrectly cached. If your site renders differently based on headers like Authorization, Cookie, or custom headers, the proxy must avoid caching or must vary the cache key accordingly.

**Safe header-based cache variations:**

You can use the `headers` field in `RequestInfo` to vary cache keys based on safe headers like Accept-Language for internationalization:

```rust
let proxy_config = CreateProxyConfig::new("http://localhost:8080".to_string())
    .with_cache_key_fn(|req_info: &RequestInfo| {
        // Vary cache by Accept-Language for i18n
        let lang = req_info.headers
            .get("accept-language")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.split(',').next())
            .unwrap_or("en");
        
        format!("{}:{}:lang={}", req_info.method, req_info.path, lang)
    });
```

**Warning:** Never include user-specific headers (Authorization, Cookie, Session tokens) in cache keys, as this would create a separate cache entry per user, defeating the purpose of caching and potentially exposing user data.

Recommendations

- Only enable caching for pages that are truly public and identical across users (for example, marketing pages, blog posts, documentation, and other static content).

- For personalized pages, prefer one of these patterns:
    - Disable caching for routes that depend on cookies or session state. Let those requests pass through directly to the backend.
    - Use server-side cache-control and vary headers: have your backend set Cache-Control: private or no-store for responses that must never be cached.
    - Add a cache-variation strategy: include relevant request attributes in the cache key (for example, language or AB-test id) but avoid including user-specific identifiers like session ids or user ids.
    - Serve a public, cached shell and hydrate per-user data client-side: render a shared skeleton HTML via phantom-frame, then load user-specific data in the browser over XHR/fetch after page load. This keeps the prerendering benefits while avoiding serving user-specific HTML from the cache.

- If you need mixed content (mostly public content with a small personalized part), prefer using edge-side includes (ESI) or client-side fragments for the personalized bits.

If no cookie- or per-user SSR exists (i.e., your pages are identical across users), phantom-frame will operate stably and provide the full benefits of caching and prerendering.

## License

See LICENSE file for details