Skip to content
Merged
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 5 additions & 5 deletions src/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
152 changes: 132 additions & 20 deletions src/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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,
);
Comment thread
timkelty marked this conversation as resolved.
Comment thread
timkelty marked this conversation as resolved.
} 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(),
]);
}
}
});
Expand Down Expand Up @@ -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;
Comment thread
timkelty marked this conversation as resolved.
$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);
}

Expand All @@ -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);
Comment thread
timkelty marked this conversation as resolved.
}
}

private function handleDeleteElement(ElementEvent $event): void
Expand All @@ -190,34 +219,57 @@ 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()
? '/'
: 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
Expand Down Expand Up @@ -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);
Expand All @@ -259,7 +326,7 @@ public function purgeTags(string|StaticCacheTag ...$tags): void
return;
}

if ($isWebResponse) {
if (!$sendApiRequest) {
$tags = $this->truncateCacheTagsForHeader($tags);

Module::info('Purging tags', [
Expand All @@ -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);
Comment thread
timkelty marked this conversation as resolved.
}

public function purgeUrlPrefixes(string ...$urlPrefixes): void
Expand Down
2 changes: 1 addition & 1 deletion src/cli/controllers/UpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/events/PurgeEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace craft\cloud\events;

use craft\base\ElementInterface;
use craft\cloud\StaticCacheTag;
use craft\events\CancelableEvent;

class PurgeEvent extends CancelableEvent
{
/**
* @var StaticCacheTag[] The static cache tags being purged.
*/
public array $tags = [];

Comment thread
timkelty marked this conversation as resolved.
/**
* @var ElementInterface|null The element that caused the purge, if any.
*/
public ?ElementInterface $element = null;
}
Loading
Loading