diff --git a/README.md b/README.md index cf519488..34dce56e 100644 --- a/README.md +++ b/README.md @@ -217,5 +217,32 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl | `useAssetCdn` | `boolean` | Whether or not to enable the CDN for uploaded assets. | | `useArtifactCdn` | `boolean` | Whether or not to enable the CDN for build artifacts and asset bundles. | | `staticCacheDuration` | `int` | The default duration, in seconds, to statically cache requests. | + > [!TIP] > These options can also be set via environment overrides beginning with `CRAFT_CLOUD_`. + +### Static cache + +Static cache purge events can be configured through Yii’s dependency injection container in `config/app.php`: + +```php +use craft\cloud\events\PurgeEvent; +use craft\cloud\StaticCache; + +return [ + 'container' => [ + 'definitions' => [ + StaticCache::class => [ + 'class' => StaticCache::class, + 'on ' . StaticCache::EVENT_BEFORE_PURGE => static function(PurgeEvent $event): void { + if ($event->element?->uri === 'health-check') { + $event->isValid = false; + } + }, + ], + ], + ], +]; +``` + +When a saved element purge proceeds, its non-null site URL is included in tag-based gateway API requests as the optional `fetchUrls` field. URLs are deduplicated, and the gateway asynchronously fetches them after a successful purge to repopulate the cache. Drafts, revisions, deletions, and canceled purges do not enqueue URLs. diff --git a/src/Module.php b/src/Module.php index 47cb30dd..cf6b2435 100644 --- a/src/Module.php +++ b/src/Module.php @@ -55,7 +55,7 @@ class Module extends \yii\base\Module implements \yii\base\BootstrapInterface */ private const MAX_EXECUTION_SECONDS_CLI = 900 - 10; - private Config $_config; + private Config $config; public static function log(int $level, string $message, array $context = []): void { @@ -227,8 +227,8 @@ private function isRemoteCloudAsset(Asset $asset): bool public function getConfig(): Config { - if (isset($this->_config)) { - return $this->_config; + if (isset($this->config)) { + return $this->config; } $fileConfig = Craft::$app->getConfig()->getConfigFromFile($this->id); @@ -238,9 +238,9 @@ public function getConfig(): Config ? Craft::createObject(['class' => Config::class] + $fileConfig) : $fileConfig; - $this->_config = Craft::configure($config, App::envConfig(Config::class, 'CRAFT_CLOUD_')); + $this->config = Craft::configure($config, App::envConfig(Config::class, 'CRAFT_CLOUD_')); - return $this->_config; + return $this->config; } protected function bootstrapCloud(ConsoleApplication|WebApplication $app): void diff --git a/src/StaticCache.php b/src/StaticCache.php index 48af5002..8104e46b 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -4,6 +4,7 @@ use Craft; use craft\base\ElementInterface; +use craft\cloud\events\PurgeEvent; use craft\events\ElementEvent; use craft\events\InvalidateElementCachesEvent; use craft\events\RegisterCacheOptionsEvent; @@ -39,6 +40,11 @@ class StaticCache extends \yii\base\Component { public const CDN_PREFIX = 'cdn:'; + /** + * @event PurgeEvent The event that is triggered before static cache tags are purged. + */ + public const EVENT_BEFORE_PURGE = 'beforePurge'; + /** * Cloudflare's documented Cache-Tag limits. * @@ -50,12 +56,14 @@ class StaticCache extends \yii\base\Component private ?int $cacheDuration = null; private Collection $tags; private Collection $tagsToPurge; + private Collection $fetchUrls; private bool $collectingCacheInfo = false; public function init(): void { $this->tags = Collection::make(); $this->tagsToPurge = Collection::make(); + $this->fetchUrls = Collection::make(); } public function registerEventHandlers(): void @@ -105,10 +113,14 @@ public function registerEventHandlers(): void Craft::$app->onAfterRequest(function() { if ($this->tagsToPurge->isNotEmpty()) { try { - $this->purgeTags(...$this->tagsToPurge); + $this->sendTagPurgeRequest( + $this->tagsToPurge, + $this->fetchUrls, + ); } catch (\Throwable $e) { - // TODO: log exception once output payload isn't a concern - Module::error('Failed to purge tags after request'); + Module::error('Failed to purge tags after request', [ + 'exceptionMessage' => $e->getMessage(), + ]); } } }); @@ -158,14 +170,23 @@ private function handleInvalidateElementCaches(InvalidateElementCachesEvent $eve { $tags = Collection::make($event->tags)->map(fn(string $tag) => StaticCacheTag::create($tag)->minify(true)); - $skip = $tags->contains(function(StaticCacheTag $tag) { - return preg_match('/element::craft\\\\elements\\\\\S+::(drafts|revisions)/', $tag->originalValue); - }); + // InvalidateElementCachesEvent::$element was added in Craft 4.10/5.2: + // https://github.com/craftcms/cms/pull/14950 + if (property_exists($event, 'element')) { + $element = $event->element; + $skip = $element && ElementHelper::isDraftOrRevision($element); + } else { + $element = null; + $skip = $tags->contains(function(StaticCacheTag $tag) { + return preg_match('/element::craft\\\\elements\\\\\S+::(drafts|revisions)/', $tag->originalValue); + }); + } if ($skip) { return; } + $tags = $this->beforePurge($element, ...$tags); $this->tagsToPurge->push(...$tags); } @@ -180,7 +201,15 @@ private function handleRegisterCacheOptions(RegisterCacheOptionsEvent $event): v private function handleSaveElement(ElementEvent $event): void { - $this->purgeElementUri($event->element); + if (!$this->purgeElementUri($event->element)) { + return; + } + + $url = $event->element->getUrl(); + + if ($url !== null) { + $this->fetchUrls->push($url); + } } private function handleDeleteElement(ElementEvent $event): void @@ -190,26 +219,46 @@ private function handleDeleteElement(ElementEvent $event): void public function purgeAll(): void { - $this->purgeGateway(); + $this->purgeOrigin(); $this->purgeCdn(); } + public function purgeOrigin(): void + { + $tags = $this->beforePurge( + null, + Module::getInstance()->getConfig()->environmentId, + ); + $this->tagsToPurge->push(...$tags); + } + + /** + * @deprecated in 3.5.0. Use [[purgeOrigin()]] instead. + */ public function purgeGateway(): void { - $this->tagsToPurge->push(Module::getInstance()->getConfig()->environmentId); + Craft::$app->getDeprecator()->log( + __METHOD__, + '`purgeGateway()` has been deprecated. Use `purgeOrigin()` instead.', + ); + $this->purgeOrigin(); } public function purgeCdn(): void { - $this->tagsToPurge->push(self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId); + $tags = $this->beforePurge( + null, + self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId, + ); + $this->tagsToPurge->push(...$tags); } - private function purgeElementUri(ElementInterface $element): void + private function purgeElementUri(ElementInterface $element): bool { $uri = $element->uri ?? null; if (ElementHelper::isDraftOrRevision($element) || !$uri) { - return; + return false; } $uri = $element->getIsHomepage() @@ -217,7 +266,10 @@ private function purgeElementUri(ElementInterface $element): void : Path::new($uri)->withLeadingSlash()->withoutTrailingSlash(); $environmentId = Module::getInstance()->getConfig()->environmentId; - $this->tagsToPurge->prepend("$environmentId:$uri"); + $tags = $this->beforePurge($element, "$environmentId:$uri"); + $this->tagsToPurge = $tags->concat($this->tagsToPurge); + + return $tags->isNotEmpty(); } private function addCacheHeadersToWebResponse(): void @@ -245,12 +297,27 @@ public function purgeTags(string|StaticCacheTag ...$tags): void { $tags = Collection::make($tags); $response = Craft::$app->getResponse(); + + if ($response instanceof \craft\web\Response) { + $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); + $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); + } + + $tags = $this->beforePurge(null, ...$tags); + $this->sendTagPurgeRequest($tags); + } + + private function sendTagPurgeRequest(Collection $tags, ?Collection $fetchUrls = null): void + { + $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; + $sendApiRequest = !$isWebResponse || $fetchUrls?->isNotEmpty(); // Add any existing tags from the response headers if ($isWebResponse) { - $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); + $headerTags = $this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value); $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); + $tags->push(...$this->beforePurge(null, ...$headerTags)); } $tags = $this->normalizeCacheTags(...$tags); @@ -259,7 +326,7 @@ public function purgeTags(string|StaticCacheTag ...$tags): void return; } - if ($isWebResponse) { + if (!$sendApiRequest) { $tags = $this->truncateCacheTagsForHeader($tags); Module::info('Purging tags', [ @@ -276,14 +343,59 @@ public function purgeTags(string|StaticCacheTag ...$tags): void Module::info('Purging tags', [ 'tags' => $tags, + 'fetchUrls' => $fetchUrls, ]); - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - // Mapping to string because: https://github.com/laravel/framework/pull/54630 - RequestOptions::JSON => [ - 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), - ], + $payload = [ + 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), + ]; + + if ($fetchUrls?->isNotEmpty()) { + $payload['fetchUrls'] = $fetchUrls + ->unique() + ->values() + ->all(); + } + + try { + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => $payload, + RequestOptions::TIMEOUT => 3, + ]); + } catch (\Throwable $e) { + if ($isWebResponse) { + $this->setCacheTagHeader( + HeaderEnum::CACHE_PURGE_TAG->value, + $this->truncateCacheTagsForHeader($tags), + ); + } + + throw $e; + } + } + + private function beforePurge( + ?ElementInterface $element, + string|StaticCacheTag ...$tags, + ): Collection { + $tags = $this->normalizeCacheTags(...$tags); + + if ($tags->isEmpty()) { + return $tags; + } + + $event = new PurgeEvent([ + 'tags' => $tags->values()->all(), + 'element' => $element, ]); + + $this->trigger(self::EVENT_BEFORE_PURGE, $event); + + if (!$event->isValid) { + return Collection::make(); + } + + return Collection::make($event->tags); } public function purgeUrlPrefixes(string ...$urlPrefixes): void diff --git a/src/cli/controllers/UpController.php b/src/cli/controllers/UpController.php index e5b68767..10c45c7d 100644 --- a/src/cli/controllers/UpController.php +++ b/src/cli/controllers/UpController.php @@ -28,7 +28,7 @@ public function actionIndex(): int if (Craft::$app->getIsInstalled()) { $this->mustRun('/up'); - Module::getInstance()->getStaticCache()->purgeGateway(); + Module::getInstance()->getStaticCache()->purgeOrigin(); } $event = new CancelableEvent(); diff --git a/src/events/PurgeEvent.php b/src/events/PurgeEvent.php new file mode 100644 index 00000000..981d1b2e --- /dev/null +++ b/src/events/PurgeEvent.php @@ -0,0 +1,20 @@ +previousModule = Module::getInstance(); - Module::setInstance(new Module('cloud')); + $module = new Module('cloud'); + Module::setInstance($module); Craft::$app->getRequest()->setIsCpRequest(false); Craft::$app->getResponse()->clear(); Craft::$app->getResponse()->setStatusCode(200); - $this->environmentId = Module::getInstance()->getConfig()->environmentId; - Module::getInstance()->getConfig()->environmentId = '123-environment-id'; + $this->environmentId = $module->getConfig()->environmentId; + $module->getConfig()->environmentId = '123-environment-id'; + $module->getConfig()->signingKey = 'test-signing-key'; + $module->set('requestSigner', new class(function(RequestInterface $request) { + $this->gatewayRequest = $request; + + if ($this->gatewayException) { + throw $this->gatewayException; + } + }) extends RequestSigner { + public function __construct(private readonly \Closure $capture) + { + parent::__construct('test-signing-key'); + } + + public function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack + { + return new HandlerStack(function(RequestInterface $request) { + ($this->capture)($request); + + return Create::promiseFor(new Response(204)); + }); + } + }); $this->requestMethod = $_SERVER['REQUEST_METHOD'] ?? null; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -184,6 +218,167 @@ public function testPurgeTagsKeepExistingHeaderTags(): void ); } + public function testBeforePurgeEventCanCancelPurge(): void + { + $staticCache = new StaticCache(); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $this->assertContainsOnlyInstancesOf(StaticCacheTag::class, $event->tags); + $event->isValid = false; + }); + + $staticCache->purgeTags('first'); + + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testDraftCacheInvalidationDoesNotPurge(): void + { + $staticCache = new StaticCache(); + $method = new ReflectionMethod($staticCache, 'handleInvalidateElementCaches'); + $method->setAccessible(true); + $eventConfig = property_exists(InvalidateElementCachesEvent::class, 'element') + ? [ + 'element' => new Entry(['draftId' => 1]), + 'tags' => ['element::craft\\elements\\Entry::1'], + ] + : [ + 'tags' => ['element::craft\\elements\\Entry::drafts'], + ]; + $method->invoke($staticCache, new InvalidateElementCachesEvent($eventConfig)); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); + } + + public function testFailedFetchRequestFallsBackToPurgeHeader(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + $this->gatewayException = new \RuntimeException(); + + $this->saveElement($staticCache, $element); + + try { + $this->sendPendingPurgeTags($staticCache); + } catch (\RuntimeException) { + } + + $this->assertSame( + '123-environment-id:/news', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventCanCancelExistingHeaderTags(): void + { + $staticCache = new StaticCache(); + Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'first'); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = false; + }); + + $staticCache->purgeTags(); + + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventCanReplaceTags(): void + { + $staticCache = new StaticCache(); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->tags = [StaticCacheTag::create('second')]; + }); + + $staticCache->purgeTags('first'); + + $this->assertSame( + 'second', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventReceivesElement(): void + { + $staticCache = new StaticCache(); + $element = new Entry(['uri' => 'news']); + $purgeEvent = null; + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) use (&$purgeEvent) { + $purgeEvent = $event; + }); + + $this->saveElement($staticCache, $element); + + $this->assertSame($element, $purgeEvent->element); + $this->assertSame('123-environment-id:/news', $purgeEvent->tags[0]->getValue()); + } + + public function testCancelledElementPurgeDoesNotQueueFetch(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = false; + }); + + $this->saveElement($staticCache, $element); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); + $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + } + + public function testDeletedElementPurgeDoesNotQueueFetch(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + + $this->deleteElement($staticCache, $element); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isNotEmpty()); + $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + } + + public function testSavedElementPurgeRequestIncludesFetchUrls(): void + { + $staticCache = new StaticCache(); + Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'cancelled'); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = $event->element !== null; + }); + $englishElement = new FetchableElement(['uri' => 'news']); + $englishElement->fetchUrl = 'https://example.com/news'; + $frenchElement = clone $englishElement; + $frenchElement->fetchUrl = 'https://example.com/fr/nouvelles'; + $urlLessElement = clone $englishElement; + $urlLessElement->fetchUrl = null; + + $this->saveElement($staticCache, $englishElement); + $this->saveElement($staticCache, $englishElement); + $this->saveElement($staticCache, $frenchElement); + $this->saveElement($staticCache, $urlLessElement); + $this->sendPendingPurgeTags($staticCache); + + $payload = json_decode( + (string) $this->gatewayRequest?->getBody(), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $this->assertSame(['123-environment-id:/news'], $payload['tags']); + $this->assertSame([ + 'https://example.com/news', + 'https://example.com/fr/nouvelles', + ], $payload['fetchUrls']); + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + public function testExistingCacheTagHeaderIsSplit(): void { $staticCache = new StaticCache(); @@ -235,6 +430,39 @@ private function addCacheHeadersToWebResponse(StaticCache $staticCache): void $method->invoke($staticCache); } + private function saveElement(StaticCache $staticCache, Entry $element): void + { + $method = new ReflectionMethod($staticCache, 'handleSaveElement'); + $method->setAccessible(true); + $method->invoke($staticCache, new ElementEvent(['element' => $element])); + } + + private function deleteElement(StaticCache $staticCache, Entry $element): void + { + $method = new ReflectionMethod($staticCache, 'handleDeleteElement'); + $method->setAccessible(true); + $method->invoke($staticCache, new ElementEvent(['element' => $element])); + } + + private function sendPendingPurgeTags(StaticCache $staticCache): void + { + $method = new ReflectionMethod($staticCache, 'sendTagPurgeRequest'); + $method->setAccessible(true); + $method->invoke( + $staticCache, + $this->collectionProperty($staticCache, 'tagsToPurge'), + $this->collectionProperty($staticCache, 'fetchUrls'), + ); + } + + private function collectionProperty(StaticCache $staticCache, string $name): Collection + { + $property = new ReflectionProperty($staticCache, $name); + $property->setAccessible(true); + + return $property->getValue($staticCache); + } + private function setTags(StaticCache $staticCache, Collection $tags): void { $property = new ReflectionProperty($staticCache, 'tags'); @@ -242,3 +470,13 @@ private function setTags(StaticCache $staticCache, Collection $tags): void $property->setValue($staticCache, $tags); } } + +class FetchableElement extends Entry +{ + public ?string $fetchUrl = null; + + public function getUrl(): ?string + { + return $this->fetchUrl; + } +}