Skip to content

Commit 49f0e3a

Browse files
committed
FEATURE: Separate caching and creation of cache headers and support ETag and If-None-Match header
- All responses with CacheControl s-max-age headers are now cached - An ETag is calculated from the md5 of the response body - Request that are sent with an If-None-Match header that contains the ETag is detected and the response from cache or not contains the same ETag a 304 status "not modified" is returned - The results get a CacheControl with a max-age of the cache-lifetime. When a response is sent from the cache the lifetime of the entry is subtracted from the original max-age
1 parent cd17341 commit 49f0e3a

5 files changed

Lines changed: 171 additions & 78 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
namespace Flowpack\FullPageCache\Http;
3+
4+
use Neos\Flow\Annotations as Flow;
5+
use Neos\Flow\Http\Component\ComponentContext;
6+
use Neos\Flow\Http\Component\ComponentInterface;
7+
use Flowpack\FullPageCache\Cache\MetadataAwareStringFrontend;
8+
use Flowpack\FullPageCache\Aspects\ContentCacheAspect;
9+
10+
/**
11+
* Cache control header component
12+
*/
13+
class CacheControlHeaderComponent implements ComponentInterface
14+
{
15+
/**
16+
* @Flow\Inject
17+
* @var MetadataAwareStringFrontend
18+
*/
19+
protected $contentCache;
20+
21+
22+
/**
23+
* @var boolean
24+
* @Flow\InjectConfiguration(path="enabled")
25+
*/
26+
protected $enabled;
27+
28+
/**
29+
* @Flow\Inject
30+
* @var ContentCacheAspect
31+
*/
32+
protected $contentCacheAspect;
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public function handle(ComponentContext $componentContext)
38+
{
39+
if (!$this->enabled) {
40+
return;
41+
}
42+
43+
$request = $componentContext->getHttpRequest();
44+
if (strtoupper($request->getMethod()) !== 'GET') {
45+
return;
46+
}
47+
48+
if (!empty($request->getUri()->getQuery())) {
49+
return;
50+
}
51+
52+
$response = $componentContext->getHttpResponse();
53+
54+
if ($response->hasHeader('X-From-FullPageCache')) {
55+
return;
56+
}
57+
58+
if ($this->contentCacheAspect->hasUncachedSegments())
59+
{
60+
return;
61+
}
62+
63+
if ($response->hasHeader('Set-Cookie')) {
64+
return;
65+
}
66+
67+
[$tags, $lifetime] = $this->getCacheTagsAndLifetime();
68+
69+
if ($tags) {
70+
$modifiedResponse = $response
71+
->withHeader('CacheControl', 's-maxage=' . ($lifetime ?? 86400))
72+
->withHeader('X-CacheTags', $tags);
73+
74+
$componentContext->replaceHttpResponse($modifiedResponse);
75+
}
76+
}
77+
78+
/**
79+
* Get cache tags and lifetime from the cache metadata that was extracted by the special cache frontend for content cache
80+
*
81+
* @return array with first "tags" and then "lifetime"
82+
*/
83+
protected function getCacheTagsAndLifetime(): array
84+
{
85+
$lifetime = null;
86+
$tags = [];
87+
$entriesMetadata = $this->contentCache->getAllMetadata();
88+
foreach ($entriesMetadata as $identifier => $metadata) {
89+
$entryTags = isset($metadata['tags']) ? $metadata['tags'] : [];
90+
$entryLifetime = isset($metadata['lifetime']) ? $metadata['lifetime'] : null;
91+
if ($entryLifetime !== null) {
92+
if ($lifetime === null) {
93+
$lifetime = $entryLifetime;
94+
} else {
95+
$lifetime = min($lifetime, $entryLifetime);
96+
}
97+
}
98+
$tags = array_unique(array_merge($tags, $entryTags));
99+
}
100+
101+
return [$tags, $lifetime];
102+
}
103+
}

Classes/Http/RequestInterceptorComponent.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22
namespace Flowpack\FullPageCache\Http;
33

4+
use GuzzleHttp\Psr7\Response;
45
use Neos\Flow\Annotations as Flow;
56
use Neos\Cache\Frontend\StringFrontend;
67
use Neos\Flow\Http\Component\ComponentChain;
@@ -65,8 +66,31 @@ public function handle(ComponentContext $componentContext)
6566

6667
$entry = $this->cacheFrontend->get($entryIdentifier);
6768
if ($entry) {
68-
$response = parse_response($entry);
69-
$response = $response->withHeader('X-From-FullPageCache', $entryIdentifier);
69+
$cachedResponse = parse_response($entry);
70+
71+
$etag = $cachedResponse->getHeaderLine('ETag');
72+
$lifetime = (int)$cachedResponse->getHeaderLine('X-Storage-Lifetime');
73+
$timestamp = (int)$cachedResponse->getHeaderLine('X-Storage-Timestamp');
74+
$age = time() - $timestamp;
75+
76+
if ($age > $lifetime) {
77+
return;
78+
}
79+
80+
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
81+
if ($ifNoneMatch && $ifNoneMatch === $etag ) {
82+
$response = (new Response( 304))
83+
->withHeader('CacheControl', 'max-age=' . ($lifetime - $age))
84+
->withHeader('X-From-FullPageCache', $entryIdentifier);
85+
} else {
86+
$response = $cachedResponse
87+
->withoutHeader('X-Storage-Lifetime')
88+
->withoutHeader('X-Storage-Timestamp')
89+
->withoutHeader('CacheControl')
90+
->withHeader('CacheControl', 'max-age=' . ($lifetime - $age))
91+
->withHeader('X-From-FullPageCache', $entryIdentifier);
92+
}
93+
7094
$componentContext->replaceHttpResponse($response);
7195
$componentContext->setParameter(ComponentChain::class, 'cancel', true);
7296
}
Lines changed: 30 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<?php
22
namespace Flowpack\FullPageCache\Http;
33

4-
use Flowpack\FullPageCache\Aspects\ContentCacheAspect;
5-
use Flowpack\FullPageCache\Cache\MetadataAwareStringFrontend;
64
use Neos\Cache\Frontend\StringFrontend;
75
use Neos\Flow\Annotations as Flow;
86
use Neos\Flow\Http\Component\ComponentContext;
@@ -20,95 +18,57 @@ class RequestStorageComponent implements ComponentInterface
2018
*/
2119
protected $cacheFrontend;
2220

23-
/**
24-
* @Flow\Inject
25-
* @var MetadataAwareStringFrontend
26-
*/
27-
protected $contentCache;
28-
2921
/**
3022
* @var boolean
3123
* @Flow\InjectConfiguration(path="enabled")
3224
*/
3325
protected $enabled;
3426

35-
/**
36-
* @Flow\Inject
37-
* @var ContentCacheAspect
38-
*/
39-
protected $contentCacheAspect;
40-
4127
/**
4228
* @inheritDoc
4329
*/
4430
public function handle(ComponentContext $componentContext)
4531
{
46-
if (!$this->enabled) {
47-
return;
48-
}
49-
5032
$request = $componentContext->getHttpRequest();
51-
if (strtoupper($request->getMethod()) !== 'GET') {
52-
return;
53-
}
54-
55-
if (!empty($request->getUri()->getQuery())) {
56-
return;
57-
}
58-
5933
$response = $componentContext->getHttpResponse();
6034

6135
if ($response->hasHeader('X-From-FullPageCache')) {
6236
return;
6337
}
6438

65-
if ($this->contentCacheAspect->hasUncachedSegments())
66-
{
67-
return;
68-
}
69-
70-
if ($response->hasHeader('Set-Cookie')) {
71-
return;
72-
}
73-
74-
$entryIdentifier = md5((string)$request->getUri());
75-
76-
[$tags, $lifetime] = $this->getCacheTagsAndLifetime();
77-
78-
if (empty($tags)) {
79-
// For now do not cache something without tags (maybe it was not a Neos page)
80-
return;
81-
}
82-
83-
$modifiedResponse = $response->withHeader('X-Storage-Component', $entryIdentifier);
84-
$this->cacheFrontend->set($entryIdentifier, str($modifiedResponse), $tags, $lifetime);
85-
// TODO: because stream is copied ot the modifiedResponse we would get empty output on first request
86-
$response->getBody()->rewind();
87-
}
88-
89-
/**
90-
* Get cache tags and lifetime from the cache metadata that was extracted by the special cache frontend for content cache
91-
*
92-
* @return array with first "tags" and then "lifetime"
93-
*/
94-
protected function getCacheTagsAndLifetime(): array
95-
{
96-
$lifetime = null;
97-
$tags = [];
98-
$entriesMetadata = $this->contentCache->getAllMetadata();
99-
foreach ($entriesMetadata as $identifier => $metadata) {
100-
$entryTags = isset($metadata['tags']) ? $metadata['tags'] : [];
101-
$entryLifetime = isset($metadata['lifetime']) ? $metadata['lifetime'] : null;
102-
if ($entryLifetime !== null) {
103-
if ($lifetime === null) {
104-
$lifetime = $entryLifetime;
39+
if ($response->hasHeader('CacheControl')) {
40+
$cacheControl = $response->getHeaderLine('CacheControl');
41+
if ($cacheControl && strpos($cacheControl,'s-maxage=') === 0) {
42+
$lifetime = (int) substr($cacheControl, 10);
43+
$cacheTags = $response->getHeader('X-CacheTags') ;
44+
45+
$entryIdentifier = md5((string)$request->getUri());
46+
$etag = md5(str($response));
47+
48+
$modifiedResponse = $response
49+
->withoutHeader('X-CacheTags')
50+
->withoutHeader('CacheControl')
51+
->withAddedHeader('ETag', $etag)
52+
->withAddedHeader('CacheControl', 'max-age=' . $lifetime);
53+
54+
$modifiedResponseforStorage = $modifiedResponse
55+
->withHeader('X-Storage-Component', $entryIdentifier)
56+
->withHeader('X-Storage-Timestamp', time())
57+
->withHeader('X-Storage-Lifetime', $lifetime);
58+
59+
$this->cacheFrontend->set($entryIdentifier, str($modifiedResponseforStorage), $cacheTags, $lifetime);
60+
$response->getBody()->rewind();
61+
62+
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
63+
if ($ifNoneMatch && $ifNoneMatch === $etag ) {
64+
$notModifiedResponse = (new Response(304))
65+
->withAddedHeader('CacheControl', 'max-age=' . $lifetime)
66+
->withHeader('X-From-FullPageCache', $entryIdentifier);
67+
$componentContext->replaceHttpResponse($notModifiedResponse);
10568
} else {
106-
$lifetime = min($lifetime, $entryLifetime);
69+
$componentContext->replaceHttpResponse($modifiedResponse);
10770
}
10871
}
109-
$tags = array_unique(array_merge($tags, $entryTags));
11072
}
111-
112-
return [$tags, $lifetime];
11373
}
11474
}

Configuration/Objects.yaml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
Flowpack\FullPageCache\Http\RequestInterceptorComponent:
1+
Flowpack\FullPageCache\Http\CacheControlHeaderComponent:
22
properties:
3-
cacheFrontend:
3+
contentCache:
44
object:
55
factoryObjectName: Neos\Flow\Cache\CacheManager
66
factoryMethodName: getCache
77
arguments:
88
1:
9-
value: Flowpack_FullPageCache_Entries
9+
value: Neos_Fusion_Content
1010

11-
Flowpack\FullPageCache\Http\RequestStorageComponent:
11+
Flowpack\FullPageCache\Http\RequestInterceptorComponent:
1212
properties:
1313
cacheFrontend:
1414
object:
@@ -17,13 +17,16 @@ Flowpack\FullPageCache\Http\RequestStorageComponent:
1717
arguments:
1818
1:
1919
value: Flowpack_FullPageCache_Entries
20-
contentCache:
20+
21+
Flowpack\FullPageCache\Http\RequestStorageComponent:
22+
properties:
23+
cacheFrontend:
2124
object:
2225
factoryObjectName: Neos\Flow\Cache\CacheManager
2326
factoryMethodName: getCache
2427
arguments:
2528
1:
26-
value: Neos_Fusion_Content
29+
value: Flowpack_FullPageCache_Entries
2730

2831
Flowpack\FullPageCache\Aspects\ContentCacheAspect:
2932
properties:

Configuration/Settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Neos:
1010
requestInterceptor:
1111
position: 'start 999999'
1212
component: 'Flowpack\FullPageCache\Http\RequestInterceptorComponent'
13+
addCacheHeader:
14+
position: 'after setHeader'
15+
component: 'Flowpack\FullPageCache\Http\CacheControlHeaderComponent'
1316
'postprocess':
1417
chain:
1518
requestStorage:

0 commit comments

Comments
 (0)