diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9c828a7..e89efcb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -655,6 +655,16 @@ parameters: count: 1 path: src/Resources/config/definition/web_client.php + - + message: "#^Parameter \\#6 \\$options of static method SpomkyLabs\\\\PwaBundle\\\\Service\\\\CacheStrategy\\:\\:create\\(\\) expects array\\{maxTimeout\\?\\: int, maxAge\\?\\: int, maxEntries\\?\\: int, warmUrls\\?\\: array\\, plugins\\?\\: array\\\\}, array\\{maxEntries\\: int\\<0, max\\>, maxAge\\: int, warmUrls\\: mixed\\} given\\.$#" + count: 1 + path: src/Service/Rule/AssetCache.php + + - + message: "#^Parameter \\#6 \\$options of static method SpomkyLabs\\\\PwaBundle\\\\Service\\\\CacheStrategy\\:\\:create\\(\\) expects array\\{maxTimeout\\?\\: int, maxAge\\?\\: int, maxEntries\\?\\: int, warmUrls\\?\\: array\\, plugins\\?\\: array\\\\}, array\\{maxTimeout\\: int, plugins\\: array\\{0\\: 'CacheableResponsePl…', 1\\?\\: 'BroadcastUpdatePlug…'\\}, warmUrls\\: mixed\\} given\\.$#" + count: 1 + path: src/Service/Rule/PageCache.php + - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\SpomkyLabsPwaBundle\\:\\:loadExtension\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Command/ListCacheStrategiesCommand.php b/src/Command/ListCacheStrategiesCommand.php index deff152..2bf853f 100644 --- a/src/Command/ListCacheStrategiesCommand.php +++ b/src/Command/ListCacheStrategiesCommand.php @@ -12,11 +12,13 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\Yaml\Yaml; -use function assert; #[AsCommand(name: 'pwa:cache:list-strategies', description: 'List the available cache strategies',)] final class ListCacheStrategiesCommand extends Command { + /** + * @param iterable $services + */ public function __construct( #[TaggedIterator('spomky_labs_pwa.cache_strategy')] private readonly iterable $services, @@ -32,7 +34,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $table = $io->createTable(); $table->setHeaders(['Name', 'Strategy', 'URL pattern', 'Enabled', 'Workbox?', 'Options']); foreach ($this->services as $service) { - assert($service instanceof HasCacheStrategies); $strategies = $service->getCacheStrategies(); foreach ($strategies as $strategy) { $table->addRow([ diff --git a/src/Dto/AssetCache.php b/src/Dto/AssetCache.php index bb1b9e7..5d4eec9 100644 --- a/src/Dto/AssetCache.php +++ b/src/Dto/AssetCache.php @@ -14,4 +14,7 @@ final class AssetCache public string $cacheName = 'assets'; public string $regex = '/\.(css|js|json|xml|txt|map|ico|png|jpe?g|gif|svg|webp|bmp)$/'; + + #[SerializedName('max_age')] + public int $maxAge = 60 * 60 * 24 * 365; } diff --git a/src/Dto/PageCache.php b/src/Dto/PageCache.php index f374b9a..63b501e 100644 --- a/src/Dto/PageCache.php +++ b/src/Dto/PageCache.php @@ -19,6 +19,16 @@ final class PageCache #[SerializedName('network_timeout')] public int $networkTimeout = 3; + public string $strategy = 'networkFirst'; + + public bool $broadcast = false; + + /** + * @var array + */ + #[SerializedName('broadcast_headers')] + public array $broadcastHeaders = ['Content-Type', 'ETag', 'Last-Modified']; + /** * @var array */ diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index cdcf490..92ff563 100644 --- a/src/Resources/config/definition/service_worker.php +++ b/src/Resources/config/definition/service_worker.php @@ -250,6 +250,11 @@ ->info('The regex to match the assets.') ->example('/\.(css|js|json|xml|txt|map|ico|png|jpe?g|gif|svg|webp|bmp)$/') ->end() + ->scalarNode('max_age') + ->defaultValue(60 * 60 * 24 * 365) + ->info('The maximum number of seconds before the asset cache is invalidated.') + ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->end() ->end() ->end() ->arrayNode('font_cache') @@ -290,6 +295,26 @@ ) ->example([1, 2, 5]) ->end() + ->scalarNode('strategy') + ->defaultValue('networkFirst') + ->info( + 'The caching strategy. Only "networkFirst" and "staleWhileRevalidate" are supported.' + ) + ->example(['networkFirst', 'staleWhileRevalidate']) + ->end() + ->booleanNode('broadcast') + ->defaultFalse() + ->info( + 'Whether to broadcast the cache update events. Only supported with "staleWhileRevalidate" strategy.' + ) + ->end() + ->arrayNode('broadcast_headers') + ->treatNullLike(['Content-Length', 'ETag', 'Last-Modified']) + ->treatFalseLike(['Content-Length', 'ETag', 'Last-Modified']) + ->treatTrueLike(['Content-Length', 'ETag', 'Last-Modified']) + ->defaultValue(['Content-Length', 'ETag', 'Last-Modified']) + ->scalarPrototype()->end() + ->end() ->arrayNode('urls') ->treatNullLike([]) ->treatFalseLike([]) diff --git a/src/Service/CacheStrategy.php b/src/Service/CacheStrategy.php index 294abf1..924454c 100644 --- a/src/Service/CacheStrategy.php +++ b/src/Service/CacheStrategy.php @@ -31,14 +31,14 @@ public function __construct( public bool $enabled, public bool $requireWorkbox, /** - * @var array{maxTimeout?: int, maxAge?: int, maxEntries?: int} + * @var array{maxTimeout?: int, maxAge?: int, maxEntries?: int, warmUrls?: string[], plugins?: string[]} */ public array $options = [] ) { } /** - * @param array{maxTimeout?: int, maxAge?: int, maxEntries?: int} $options + * @param array{maxTimeout?: int, maxAge?: int, maxEntries?: int, warmUrls?: string[], plugins?: string[]} $options */ public static function create( string $name, @@ -46,7 +46,7 @@ public static function create( string $urlPattern, bool $enabled, bool $requireWorkbox, - array $options = [] + array $options = [], ): self { return new self($name, $strategy, $urlPattern, $enabled, $requireWorkbox, $options); } diff --git a/src/Service/Rule/AssetCache.php b/src/Service/Rule/AssetCache.php index aa97f0c..a77bb3c 100644 --- a/src/Service/Rule/AssetCache.php +++ b/src/Service/Rule/AssetCache.php @@ -62,12 +62,7 @@ public function process(string $body): string if ($this->workbox->assetCache->enabled === false) { return $body; } - $assets = []; - foreach ($this->assetMapper->allAssets() as $asset) { - if (preg_match($this->workbox->assetCache->regex, $asset->sourcePath) === 1) { - $assets[] = $asset->publicPath; - } - } + $assets = $this->getAssets(); $assetUrls = $this->serializer->serialize($assets, 'json', $this->jsonOptions); $assetUrlsLength = count($assets) * 2; @@ -78,7 +73,7 @@ public function process(string $body): string new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), new workbox.expiration.ExpirationPlugin({ maxEntries: {$assetUrlsLength}, - maxAgeSeconds: 365 * 24 * 60 * 60, + maxAgeSeconds: {$this->workbox->assetCache->maxAge}, }), ], }); @@ -104,6 +99,7 @@ public function process(string $body): string public function getCacheStrategies(): array { + $urls = json_decode($this->serializer->serialize($this->getAssets(), 'json', $this->jsonOptions), true); return [ CacheStrategy::create( $this->workbox->assetCache->cacheName, @@ -112,10 +108,25 @@ public function getCacheStrategies(): array $this->workbox->enabled && $this->workbox->assetCache->enabled, true, [ - 'maxEntries' => -1, - 'maxAge' => 365 * 24 * 60 * 60, + 'maxEntries' => count($this->getAssets()) * 2, + 'maxAge' => $this->workbox->assetCache->maxAge, + 'warmUrls' => $urls, ], ), ]; } + + /** + * @return array + */ + private function getAssets(): array + { + $assets = []; + foreach ($this->assetMapper->allAssets() as $asset) { + if (preg_match($this->workbox->assetCache->regex, $asset->sourcePath) === 1) { + $assets[] = $asset->publicPath; + } + } + return $assets; + } } diff --git a/src/Service/Rule/BackgroundSync.php b/src/Service/Rule/BackgroundSync.php index 8a1155d..bee1ac4 100644 --- a/src/Service/Rule/BackgroundSync.php +++ b/src/Service/Rule/BackgroundSync.php @@ -69,7 +69,7 @@ public function getCacheStrategies(): array $strategies = []; foreach ($this->workbox->backgroundSync as $sync) { $strategies[] = CacheStrategy::create( - 'backgroundSync', + '---', CacheStrategy::STRATEGY_NETWORK_ONLY, $sync->regex, $this->workbox->enabled, @@ -78,6 +78,10 @@ public function getCacheStrategies(): array 'maxTimeout' => 0, 'maxAge' => 0, 'maxEntries' => 0, + 'plugins' => [ + sprintf('backgroundSync: "%s"', $sync->queueName), + sprintf('broadcastChannel: "%s"', $sync->broadcastChannel ?? '---'), + ], ] ); } diff --git a/src/Service/Rule/PageCache.php b/src/Service/Rule/PageCache.php index 6f615af..12a2ccc 100644 --- a/src/Service/Rule/PageCache.php +++ b/src/Service/Rule/PageCache.php @@ -54,12 +54,43 @@ public function process(string $body): string return $body; } $routes = $this->serializer->serialize($this->workbox->pageCache->urls, 'json', $this->jsonOptions); + $strategy = match ($this->workbox->pageCache->strategy) { + 'staleWhileRevalidate' => 'StaleWhileRevalidate', + default => 'NetworkFirst', + }; + $broadcastHeaders = json_encode( + $this->workbox->pageCache->broadcastHeaders === [] ? [ + 'Content-Type', + 'ETag', + 'Last-Modified', + ] : $this->workbox->pageCache->broadcastHeaders, + JSON_THROW_ON_ERROR, + 512 + ); + $broadcastUpdate = ($strategy === 'StaleWhileRevalidate' && $this->workbox->pageCache->broadcast === true) ? sprintf( + ',new workbox.broadcastUpdate.BroadcastUpdatePlugin({headersToCheck: %s})', + $broadcastHeaders + ) : ''; $declaration = <<workbox->pageCache->cacheName}', - networkTimeoutSeconds: {$this->workbox->pageCache->networkTimeout}, - warmCache: {$routes} +const pageCacheStrategy = new workbox.strategies.{$strategy}({ + networkTimeoutSeconds: {$this->workbox->pageCache->networkTimeout}, + cacheName: '{$this->workbox->pageCache->cacheName}', + plugins: [new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}){$broadcastUpdate}], +}); +workbox.routing.registerRoute( + ({request}) => request.mode === 'navigate', + pageCacheStrategy +); +self.addEventListener('install', event => { + const done = {$routes}.map( + path => + pageCacheStrategy.handleAll({ + event, + request: new Request(path), + })[1] + ); + event.waitUntil(Promise.all(done)); }); PAGE_CACHE_RULE_STRATEGY; @@ -68,15 +99,27 @@ public function process(string $body): string public function getCacheStrategies(): array { + $strategy = match ($this->workbox->pageCache->strategy) { + 'staleWhileRevalidate' => CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE, + default => CacheStrategy::STRATEGY_NETWORK_FIRST, + }; + $plugins = ['CacheableResponsePlugin']; + if ($this->workbox->pageCache->broadcast === true && $strategy === CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE) { + $plugins[] = 'BroadcastUpdatePlugin'; + } + $routes = $this->serializer->serialize($this->workbox->pageCache->urls, 'json', $this->jsonOptions); + $url = json_decode($routes, true, 512, JSON_THROW_ON_ERROR); return [ CacheStrategy::create( $this->workbox->pageCache->cacheName, - CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE, - "'({request}) => request.mode === 'navigate'", + $strategy, + "({request}) => request.mode === 'navigate'", $this->workbox->enabled && $this->workbox->pageCache->enabled, true, [ 'maxTimeout' => $this->workbox->pageCache->networkTimeout, + 'plugins' => $plugins, + 'warmUrls' => $url, ] ), ];