Skip to content

Commit 6235e35

Browse files
committed
Add proper cache control
1 parent 164b89d commit 6235e35

23 files changed

Lines changed: 462 additions & 200 deletions

.idea/php.xml

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/swytch-framework.iml

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cache/AbstractCache.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
readonly abstract class AbstractCache
8+
{
9+
abstract public function tokenize(Tokenizer $tokenizer): Tokenizer;
10+
}

src/Cache/CachePublic.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
readonly class CachePublic extends AbstractCache
8+
{
9+
public function tokenize(Tokenizer $tokenizer): Tokenizer
10+
{
11+
if (!$tokenizer->public) {
12+
return $tokenizer;
13+
}
14+
15+
return $tokenizer->with(public: true);
16+
}
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache\Control;
4+
5+
interface GeneratesEtagInterface
6+
{
7+
public function getEtagComponents(): array;
8+
}

src/Cache/Control/Tokenizer.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache\Control;
4+
5+
readonly class Tokenizer
6+
{
7+
public function __construct(
8+
/**
9+
* @var int|null Indicates that caches can store this response and reuse it for subsequent requests while it's fresh.
10+
*/
11+
public int|null $maxAge = null,
12+
13+
/**
14+
* @var int|null indicates how long the response remains fresh in a shared cache.
15+
*/
16+
public int|null $sMaxAge = null,
17+
18+
/**
19+
* @var bool indicates that the response can be stored in caches, but the response must be validated with the origin
20+
* server before each reuse, even when the cache is disconnected from the origin server.
21+
*/
22+
public bool $noCache = false,
23+
24+
/**
25+
* @var bool indicates that the response can be stored in caches and can be reused while fresh. If the response
26+
* becomes stale, it must be validated with the origin server before reuse.
27+
*/
28+
public bool $mustRevalidate = false,
29+
30+
/**
31+
* @var bool same as must-revalidate, but for proxies
32+
*/
33+
public bool $proxyRevalidate = false,
34+
35+
/**
36+
* @var bool indicates that any caches of any kind (public or shared) should not store this response.
37+
*/
38+
public bool $noStore = false,
39+
40+
/**
41+
* @var bool indicates that any caches of any kind (public or shared) should not store this response.
42+
*/
43+
public bool $public = true,
44+
45+
/**
46+
* @var bool indicates that the response will not be updated while it's fresh.
47+
*/
48+
public bool $immutable = false,
49+
50+
/**
51+
* @var int|null indicates that the cache could reuse a stale response while it revalidates it to a cache.
52+
*/
53+
public int|null $staleWhileRevalidating = null,
54+
55+
/**
56+
* @var int|null indicates that the cache can reuse a stale response when an upstream server generates an error, or
57+
* when the error is generated locally
58+
*/
59+
public int|null $staleIfError = null,
60+
) {
61+
}
62+
63+
public function with(
64+
int|null|false $maxAge = false,
65+
int|null|false $sMaxAge = false,
66+
bool|null $noCache = null,
67+
bool|null $mustRevalidate = null,
68+
bool|null $proxyRevalidate = null,
69+
bool|null $noStore = null,
70+
bool|null $public = null,
71+
bool|null $immutable = null,
72+
int|null|false $staleWhileRevalidating = false,
73+
int|null|false $staleIfError = false,
74+
): self {
75+
return new self(
76+
maxAge: $this->withInt($this->maxAge, $maxAge),
77+
sMaxAge: $this->withInt($this->sMaxAge, $sMaxAge),
78+
noCache: $this->withBool($this->noCache, $noCache),
79+
mustRevalidate: $this->withBool($this->mustRevalidate, $mustRevalidate),
80+
proxyRevalidate: $this->withBool($this->proxyRevalidate, $proxyRevalidate),
81+
noStore: $this->withBool($this->noStore, $noStore),
82+
public: $this->withBool($this->public, $public),
83+
immutable: $this->withBool($this->immutable, $immutable),
84+
staleWhileRevalidating: $this->withInt($this->staleWhileRevalidating, $staleWhileRevalidating),
85+
staleIfError: $this->withInt($this->staleIfError, $staleIfError),
86+
);
87+
}
88+
89+
private function withInt(int|null $original, int|null|false $var): int|null
90+
{
91+
return $var === false ? $original : $var;
92+
}
93+
94+
private function withBool(bool $original, bool|null $var): bool
95+
{
96+
return $var ?? $original;
97+
}
98+
99+
public function render(): string
100+
{
101+
$header = [
102+
$this->public ? 'public' : 'private',
103+
...$this->header($this->maxAge, "max-age=$this->maxAge"),
104+
...$this->header($this->sMaxAge, "s-maxage=$this->sMaxAge"),
105+
...$this->header($this->noCache, "no-cache"),
106+
...$this->header($this->mustRevalidate, "must-revalidate"),
107+
...$this->header($this->proxyRevalidate, "proxy-revalidate"),
108+
...$this->header($this->noStore, "no-store"),
109+
...$this->header($this->immutable, "immutable"),
110+
...$this->header($this->staleWhileRevalidating, "stale-while-revalidate=$this->staleWhileRevalidating"),
111+
...$this->header($this->staleIfError, "stale-if-error=$this->staleIfError"),
112+
];
113+
114+
return implode(' ', $header);
115+
}
116+
117+
private function header($prop, $value): array
118+
{
119+
return $prop ? [$value] : [];
120+
}
121+
}

src/Cache/MaxAge.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
/**
8+
* Sets the cache max age (or s-maxage if shared is true)
9+
*/
10+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
11+
readonly class MaxAge extends AbstractCache
12+
{
13+
public function __construct(public int $age, public bool $shared = false)
14+
{
15+
}
16+
17+
public function tokenize(Tokenizer $tokenizer): Tokenizer
18+
{
19+
$previousAge = $this->shared ? $tokenizer->sMaxAge : $tokenizer->maxAge;
20+
21+
// keep the shortest age
22+
if ($this->age > $previousAge && $previousAge !== null) {
23+
return $tokenizer;
24+
}
25+
26+
// if age = 0, this is the same as no-store
27+
if ($this->age === 0) {
28+
return (new NeverCache())->tokenize($tokenizer);
29+
}
30+
31+
// if we were previously not storing the page, bail
32+
if ($tokenizer->noStore) {
33+
return $tokenizer;
34+
}
35+
36+
// ensure immutable is unset
37+
return $this->shared ? $tokenizer->with(sMaxAge: $this->age, immutable: false) : $tokenizer->with(
38+
maxAge: $this->age,
39+
immutable: false
40+
);
41+
}
42+
}

src/Cache/NeverCache.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
8+
readonly class NeverCache extends AbstractCache
9+
{
10+
public function tokenize(Tokenizer $tokenizer): Tokenizer
11+
{
12+
// this trumps everything and ensures the page is never cached!
13+
return $tokenizer->with(
14+
maxAge: null,
15+
sMaxAge: null,
16+
noCache: false,
17+
mustRevalidate: false,
18+
proxyRevalidate: false,
19+
noStore: true,
20+
public: false,
21+
immutable: false,
22+
staleWhileRevalidating: null,
23+
staleIfError: null
24+
);
25+
}
26+
}

src/Cache/NeverChanges.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
/**
8+
* Marks the component as immutable
9+
*/
10+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
11+
readonly class NeverChanges extends AbstractCache
12+
{
13+
private const YEAR = 604800;
14+
15+
public function tokenize(Tokenizer $tokenizer): Tokenizer
16+
{
17+
// there is a component that requires a shorter cache, that wins
18+
if($tokenizer->maxAge < self::YEAR) {
19+
return $tokenizer;
20+
}
21+
22+
return $tokenizer->with(
23+
maxAge: self::YEAR,
24+
mustRevalidate: false,
25+
proxyRevalidate: false,
26+
immutable: true,
27+
staleWhileRevalidating: null,
28+
staleIfError: null
29+
);
30+
}
31+
}

src/Cache/Revalidate.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace Bottledcode\SwytchFramework\Cache;
4+
5+
use Bottledcode\SwytchFramework\Cache\Control\Tokenizer;
6+
7+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
8+
readonly class Revalidate extends AbstractCache
9+
{
10+
public function __construct(
11+
public RevalidationEnum $when = RevalidationEnum::EveryRequest,
12+
public int|null $staleSeconds = null
13+
) {
14+
}
15+
16+
public function tokenize(Tokenizer $tokenizer): Tokenizer
17+
{
18+
if ($tokenizer->noStore) {
19+
return $tokenizer;
20+
}
21+
22+
// automatically remove any immutability
23+
if ($tokenizer->immutable) {
24+
$tokenizer = $tokenizer->with(maxAge: null, immutable: false);
25+
}
26+
27+
switch ($this->when) {
28+
case RevalidationEnum::EveryRequest:
29+
return $tokenizer->with(
30+
noCache: true,
31+
mustRevalidate: false,
32+
proxyRevalidate: false,
33+
staleWhileRevalidating: null,
34+
staleIfError: null,
35+
);
36+
case RevalidationEnum::WhenStale:
37+
// we should revalidate on every request
38+
if ($tokenizer->noCache) {
39+
return $tokenizer;
40+
}
41+
return $tokenizer->with(mustRevalidate: true, staleWhileRevalidating: null, staleIfError: null);
42+
case RevalidationEnum::WhenStaleProxies:
43+
if ($tokenizer->noCache) {
44+
return $tokenizer;
45+
}
46+
return $tokenizer->with(proxyRevalidate: true);
47+
case RevalidationEnum::AfterStale:
48+
if ($tokenizer->noCache || $tokenizer->mustRevalidate || $tokenizer->proxyRevalidate) {
49+
return $tokenizer;
50+
}
51+
return $tokenizer->with(staleWhileRevalidating: $this->staleSeconds);
52+
case RevalidationEnum::AfterError:
53+
if ($tokenizer->noCache || $tokenizer->mustRevalidate || $tokenizer->proxyRevalidate) {
54+
return $tokenizer;
55+
}
56+
return $tokenizer->with(staleIfError: $this->staleSeconds);
57+
default:
58+
throw new \LogicException('Unknown enum type!');
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)