Skip to content
Draft
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# Craft Cloud Package

Run Craft CMS on Craft Cloud.

## Static cache

On Craft Cloud, the gateway and CDN cache site pages and purge them with Craft's own cache tags.

Cloud's middleware runs outside Craft's `SetHeaders` and Laravel's cookie middleware. For successful GET and HEAD site responses, it sets:

- `CDN-Cache-Control` from the response's `Cache-Control` (`s-maxage`, then `max-age` or `Expires`), so `{% expires %}`, `ResponseHeaders::setCache()` and Symfony's `setSharedMaxAge()` control the CDN. Responses without a lifetime are cached for a year, with an hour of `stale-while-revalidate`. Lifetimes are capped at the earliest expiry of the elements the page used. `private`, `no-cache` and `no-store` responses aren't cached, and an explicit `CDN-Cache-Control` header is left alone.
- `Cache-Tag` with the page's Craft cache tags, including the matched element's.

Responses aren't cached when the request has a session cookie or `Authorization` header, the response sets a cookie, or the request used a route token or site token.

Every `TagDependency::invalidate()` purges the pages with those tags, apart from draft and revision changes. This covers element saves, `ElementCaches::collectCacheTags()` with custom tags, and `craft:invalidate-tags`. Purges are sent once per request, command or queue job, as a queued `PurgeStaticCache` job.

The Caches utility's “Craft Cloud static cache” option (`craft:clear-caches:cloud-static-cache`) purges everything. `php craft cloud:purge` does the same, or purges specific Cloudflare cache tags (`--tag`) or URL prefixes (`--prefix`).
2 changes: 2 additions & 0 deletions src/CloudServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace craft\cloud;

use craft\cloud\staticcache\StaticCacheServiceProvider;
use craft\cloud\transforms\AssetTransforms;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
Expand All @@ -21,6 +22,7 @@ public function register(): void
return;
}

$this->app->register(StaticCacheServiceProvider::class);
$this->configureQueue();
$this->configureCache();
$this->configureLogging();
Expand Down
87 changes: 87 additions & 0 deletions src/staticcache/CacheResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace craft\cloud\staticcache;

use Closure;
use CraftCms\Cms\Element\ElementCaches;
use CraftCms\Cms\Http\Middleware\HandleTokenRequest;
use CraftCms\Cms\Http\Middleware\ResolveSite;
use CraftCms\Cms\Route\MatchedElement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\Response;

/**
* Sets `CDN-Cache-Control` and `Cache-Tag` on site responses.
*
* This runs outside Craft's `SetHeaders` middleware and Laravel's session and cookie middleware, so it sees the
* response's final `Cache-Control` header and cookies. The browser's `Cache-Control` header is left alone.
*/
class CacheResponse
{
public function __construct(
private readonly ElementCaches $elementCaches,
) {}

public function handle(Request $request, Closure $next): mixed
{
if (!$request->isMethodCacheable() || $request->isCpRequest()) {
return $next($request);
}

$this->elementCaches->startCollectingCacheInfo();

try {
$response = $next($request);
$element = MatchedElement::get();

if ($element) {
$this->elementCaches->collectCacheInfoForElement($element);
}
} finally {
[$dependency, $expiry] = $this->elementCaches->stopCollectingCacheInfo();
}

if (!$response instanceof Response) {
return $response;
}

if ($this->isPersonal($request, $response)) {
$response->headers->set('CDN-Cache-Control', 'no-store');

return $response;
}

if ($response->isOk() && $response->getContent() !== false) {
if (!$response->headers->has('CDN-Cache-Control')) {
$response->headers->set('CDN-Cache-Control', CdnCacheControl::for($response, $expiry));
}

if ($dependency !== null) {
$response->headers->set('Cache-Tag', StaticCache::header($dependency->tags));
}
}

return $response;
}

/**
* Whether the response may differ per visitor, or comes from a link that isn't public.
*
* Site routes start their session lazily, so the session and `XSRF-TOKEN` cookies are only sent when the page
* used the session. Core only marks preview token responses as uncacheable, so other token routes, and site
* tokens, which can serve disabled sites, are excluded here.
*/
private function isPersonal(Request $request, Response $response): bool
{
return (
$request->cookies->has((string) config('session.cookie'))
|| $request->headers->has('Authorization')
|| $response->headers->getCookies() !== []
|| Context::getHidden(HandleTokenRequest::HAD_TOKEN_KEY) === true
|| Context::getHidden(ResolveSite::HAD_SITE_TOKEN_KEY) === true
);
}
}
53 changes: 53 additions & 0 deletions src/staticcache/CdnCacheControl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace craft\cloud\staticcache;

use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\Response;

/**
* Derives `CDN-Cache-Control` from a response's `Cache-Control` header, so Craft's and Symfony's cache APIs
* (`{% expires %}`, `ResponseHeaders::setCache()`, `setSharedMaxAge()`) control the CDN too.
*
* Cloudflare reads `CDN-Cache-Control` before `Cache-Control` and ESI's `Surrogate-Control`, so the shared lifetime
* becomes its `max-age`. Responses without a lifetime of their own are cached for a year, with an hour of
* `stale-while-revalidate`, as tag purges keep them current. Lifetimes are capped at the earliest element expiry.
*/
class CdnCacheControl
{
private const int DEFAULT_MAX_AGE = 31_536_000;
private const int DEFAULT_STALE_WHILE_REVALIDATE = 3600;

public static function for(Response $response, ?int $expiry): string
{
// Symfony reports `no-cache, private` for responses without a policy. Adding `public` to a copy leaves only
// the directives the response set itself.
$headers = clone $response->headers;
$headers->addCacheControlDirective('public');

if (array_any(['no-store', 'no-cache', 'private'], $headers->hasCacheControlDirective(...))) {
return 'no-store';
}

$directives = new HeaderBag(['Cache-Control' => $headers->get('Cache-Control')]);
$directives->removeCacheControlDirective('s-maxage');
$maxAge = $response->getMaxAge();

if ($maxAge === null) {
$maxAge = self::DEFAULT_MAX_AGE;

if (!$directives->hasCacheControlDirective('stale-while-revalidate')) {
$directives->addCacheControlDirective(
'stale-while-revalidate',
(string) self::DEFAULT_STALE_WHILE_REVALIDATE,
);
}
}

$directives->addCacheControlDirective('max-age', (string) min(max(0, $maxAge), $expiry ?? PHP_INT_MAX));

return (string) $directives->get('Cache-Control');
}
}
34 changes: 34 additions & 0 deletions src/staticcache/PurgeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace craft\cloud\staticcache;

use Illuminate\Console\Command;

class PurgeCommand extends Command
{
#[\Override]
protected $signature = 'cloud:purge
{--tag=* : Purge pages, assets and transforms with these Cloudflare cache tags}
{--prefix=* : Purge URLs that start with these prefixes, such as example.com/blog}';

#[\Override]
protected $description = 'Purges Craft Cloud’s static cache. Purges everything unless tags or prefixes are given.';

public function handle(): int
{
$tags = (array) $this->option('tag');
$prefixes = (array) $this->option('prefix');

dispatch_sync(
$tags === [] && $prefixes === []
? PurgeStaticCache::everything()
: new PurgeStaticCache(array_values($tags), array_values($prefixes)),
);

$this->components->info('Purged the static cache.');

return self::SUCCESS;
}
}
57 changes: 57 additions & 0 deletions src/staticcache/PurgeStaticCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace craft\cloud\staticcache;

use craft\cloud\signing\RequestSigner;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

/**
* Purges Craft Cloud's static cache by tag or URL prefix, through the gateway API.
*
* The gateway tags every page with `{environmentId}:uri` and `{environmentId}:uri:{uri}`, and the CDN tags assets
* and transforms with `{environmentId}:cdn` and `{environmentId}:rasterize`.
*/
class PurgeStaticCache implements ShouldQueue
{
use Dispatchable;
use Queueable;

public int $tries = 3;

/** @var list<int> */
public array $backoff = [10, 60];

/**
* @param list<string> $tags
* @param list<string> $prefixes
*/
public function __construct(
public readonly array $tags = [],
public readonly array $prefixes = [],
) {}

public static function everything(): self
{
$environmentId = StaticCache::environmentId();

return new self(["{$environmentId}:uri", "{$environmentId}:cdn", "{$environmentId}:rasterize"]);
}

public function handle(RequestSigner $signer): void
{
$baseUrl = rtrim($_SERVER['CRAFT_CLOUD_GATEWAY_BASE_URL'] ?? 'https://gateway.craft.cloud', characters: '/');

Http::withRequestMiddleware($signer->sign(...))
->timeout(40)
->post("{$baseUrl}/api/" . rawurlencode(StaticCache::environmentId()) . '/cache/purge', array_filter([
'tags' => $this->tags,
'prefixes' => $this->prefixes,
]))
->throw();
}
}
104 changes: 104 additions & 0 deletions src/staticcache/StaticCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace craft\cloud\staticcache;

use CraftCms\DependencyAwareCache\Events\TagsInvalidated;
use Throwable;

/**
* Mirrors Craft's tag dependency cache in Craft Cloud's static cache.
*
* Pages are tagged with the Craft cache tags they depend on, and every `TagDependency::invalidate()` purges the
* pages with those tags. Purges are collected until the request, command or queue job is done, then sent as one
* queued {@see PurgeStaticCache} job.
*/
class StaticCache
{
private const int MAX_TAGS = 1000;
private const int MAX_TAG_BYTES = 16 * 1024;

/** @var array<string, true> */
private array $tags = [];

/**
* Cloudflare cache tags are shared by every environment in a zone, and limited to 16 KiB per response, so Craft
* tags are hashed and prefixed with the start of the environment ID.
*/
public static function tag(string $craftTag): string
{
return substr(self::environmentId(), offset: 0, length: 8) . dechex(crc32($craftTag));
}

/**
* Returns the `Cache-Tag` header for a page's Craft tags, within Cloudflare's limits of 1,000 tags and 16 KiB.
*
* @param list<string> $craftTags
*/
public static function header(array $craftTags): string
{
$tags = array_values(array_unique(array_map(self::tag(...), $craftTags)));
$header = implode(',', $tags);

if (count($tags) <= self::MAX_TAGS && strlen($header) <= self::MAX_TAG_BYTES) {
return $header;
}

// Pages that lose tags are purged with every invalidation instead.
$header = implode(',', [self::overflowTag(), ...array_slice($tags, offset: 0, length: self::MAX_TAGS - 1)]);

return strlen($header) <= self::MAX_TAG_BYTES
? $header
: substr(
$header,
offset: 0,
length: (int) strrpos(substr($header, offset: 0, length: self::MAX_TAG_BYTES + 1), needle: ','),
);
}

/**
* Added to pages that have more tags than fit in the `Cache-Tag` header, and purged with every invalidation.
*/
public static function overflowTag(): string
{
return self::environmentId() . ':overflow';
}

public static function environmentId(): string
{
return (string) ($_SERVER['CRAFT_CLOUD_ENVIRONMENT_ID'] ?? '');
}

public function handleTagsInvalidated(TagsInvalidated $event): void
{
$tags = (array) $event->tags;

// Core invalidates `element::{class}::*` for every draft autosave, which would purge every listing page.
if (preg_grep('/^element::.+::(drafts|revisions)$/', $tags)) {
return;
}

// Core's element index and search query caches, which pages never depend on, are invalidated on every save.
foreach (preg_grep('/^element-(index|search)-query::/', $tags, PREG_GREP_INVERT) as $tag) {
$this->tags[self::tag($tag)] = true;
}
}

public function flush(): void
{
if ($this->tags === []) {
return;
}

$tags = [...array_keys($this->tags), self::overflowTag()];
$this->tags = [];

try {
PurgeStaticCache::dispatch($tags);
} catch (Throwable $exception) {
// On Lambda, termination runs before the response is returned, so don't fail a request that succeeded.
report($exception);
}
}
}
Loading
Loading