diff --git a/assets/src/connection-status_controller.js b/assets/src/connection-status_controller.js index ac191b4..a6ee963 100644 --- a/assets/src/connection-status_controller.js +++ b/assets/src/connection-status_controller.js @@ -38,7 +38,7 @@ export default class extends Controller { }); } dispatchEvent = (name, payload) => { - this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + this.dispatch(name, { detail: payload }); } statusChanged = (data) => { diff --git a/assets/src/sync-broadcast_controller.js b/assets/src/sync-broadcast_controller.js index f8ea1a4..fd7600c 100644 --- a/assets/src/sync-broadcast_controller.js +++ b/assets/src/sync-broadcast_controller.js @@ -26,7 +26,7 @@ export default class extends Controller { } dispatchEvent = (name, payload) => { - this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + this.dispatch(name, { detail: payload }); } messageReceived = async (event) => { diff --git a/src/Command/ListCacheStrategiesCommand.php b/src/Command/ListCacheStrategiesCommand.php index 2bf853f..3001210 100644 --- a/src/Command/ListCacheStrategiesCommand.php +++ b/src/Command/ListCacheStrategiesCommand.php @@ -5,6 +5,8 @@ namespace SpomkyLabs\PwaBundle\Command; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -12,6 +14,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\Yaml\Yaml; +use function count; #[AsCommand(name: 'pwa:cache:list-strategies', description: 'List the available cache strategies',)] final class ListCacheStrategiesCommand extends Command @@ -32,18 +35,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->title('Cache Strategies'); $table = $io->createTable(); - $table->setHeaders(['Name', 'Strategy', 'URL pattern', 'Enabled', 'Workbox?', 'Options']); + $table->setHeaders( + ['Name', 'Strategy', 'URL pattern', 'Enabled', 'Workbox?', 'Plugins', 'Preload URLs', 'Options'] + ); foreach ($this->services as $service) { $strategies = $service->getCacheStrategies(); foreach ($strategies as $strategy) { - $table->addRow([ - $strategy->name, - $strategy->strategy, - $strategy->urlPattern, - $strategy->enabled ? 'Yes' : 'No', - $strategy->requireWorkbox ? 'Yes' : 'No', - Yaml::dump($strategy->options), - ]); + if ($strategy instanceof WorkboxCacheStrategy) { + $table->addRow([ + $strategy->name, + $strategy->strategy, + $strategy->matchCallback, + $strategy->enabled ? 'Yes' : 'No', + $strategy->requireWorkbox ? 'Yes' : 'No', + Yaml::dump(array_map(fn (CachePlugin $v): string => $v->name, $strategy->plugins)), + count($strategy->preloadUrls), + Yaml::dump($strategy->options), + ]); + } else { + $table->addRow([ + $strategy->name, + $strategy->strategy, + $strategy->matchCallback, + $strategy->enabled ? 'Yes' : 'No', + $strategy->requireWorkbox ? 'Yes' : 'No', + '', + '', + '', + ]); + } } } $table->render(); diff --git a/src/Dto/AssetCache.php b/src/Dto/AssetCache.php index 5d4eec9..8e54397 100644 --- a/src/Dto/AssetCache.php +++ b/src/Dto/AssetCache.php @@ -4,17 +4,9 @@ namespace SpomkyLabs\PwaBundle\Dto; -use Symfony\Component\Serializer\Attribute\SerializedName; - -final class AssetCache +final class AssetCache extends Cache { public bool $enabled = true; - #[SerializedName('cache_name')] - 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/BackgroundSync.php b/src/Dto/BackgroundSync.php index 2844f1c..0944a93 100644 --- a/src/Dto/BackgroundSync.php +++ b/src/Dto/BackgroundSync.php @@ -6,12 +6,13 @@ use Symfony\Component\Serializer\Attribute\SerializedName; -final class BackgroundSync +final class BackgroundSync extends Cache { #[SerializedName('queue_name')] public string $queueName; - public string $regex; + #[SerializedName('match_callback')] + public string $matchCallback; public string $method; diff --git a/src/Dto/Cache.php b/src/Dto/Cache.php new file mode 100644 index 0000000..fe0f509 --- /dev/null +++ b/src/Dto/Cache.php @@ -0,0 +1,35 @@ +maxAge === null) { + return null; + } + if (is_string($this->maxAge)) { + $now = new DateTimeImmutable(); + $future = $now->add(DateInterval::createFromDateString($this->maxAge)); + return abs($future->getTimestamp() - $now->getTimestamp()); + } + return $this->maxAge; + } +} diff --git a/src/Dto/FontCache.php b/src/Dto/FontCache.php index 101fc24..e1dceb5 100644 --- a/src/Dto/FontCache.php +++ b/src/Dto/FontCache.php @@ -6,19 +6,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName; -final class FontCache +final class FontCache extends Cache { public bool $enabled = true; - #[SerializedName('cache_name')] - public string $cacheName = 'fonts'; - #[SerializedName('regex')] public string $regex = '/\.(ttf|eot|otf|woff2)$/'; - - #[SerializedName('max_entries')] - public int $maxEntries = 60; - - #[SerializedName('max_age')] - public int $maxAge = 60; } diff --git a/src/Dto/GoogleFontCache.php b/src/Dto/GoogleFontCache.php index 760ea60..f452bd9 100644 --- a/src/Dto/GoogleFontCache.php +++ b/src/Dto/GoogleFontCache.php @@ -6,16 +6,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName; -final class GoogleFontCache +final class GoogleFontCache extends Cache { public bool $enabled; #[SerializedName('cache_prefix')] public null|string $cachePrefix = null; - - #[SerializedName('max_age')] - public null|int $maxAge = null; - - #[SerializedName('max_entries')] - public null|int $maxEntries = null; } diff --git a/src/Dto/ImageCache.php b/src/Dto/ImageCache.php index ad31ae6..11498b9 100644 --- a/src/Dto/ImageCache.php +++ b/src/Dto/ImageCache.php @@ -6,19 +6,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName; -final class ImageCache +final class ImageCache extends Cache { public bool $enabled = true; - #[SerializedName('cache_name')] - public string $cacheName = 'assets'; - #[SerializedName('regex')] public string $regex = '/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/'; - - #[SerializedName('max_entries')] - public int $maxEntries = 60; - - #[SerializedName('max_age')] - public int $maxAge = 60; } diff --git a/src/Dto/PageCache.php b/src/Dto/PageCache.php index 43270d7..3eb1245 100644 --- a/src/Dto/PageCache.php +++ b/src/Dto/PageCache.php @@ -6,21 +6,33 @@ use Symfony\Component\Serializer\Attribute\SerializedName; -final class PageCache +final class PageCache extends Cache { - #[SerializedName('cache_name')] - public string $cacheName; - - #[SerializedName('regex')] - public string $regex; + #[SerializedName('match_callback')] + public string $matchCallback; #[SerializedName('network_timeout')] public int $networkTimeout = 3; - public string $strategy = 'networkFirst'; + public string $strategy = 'NetworkFirst'; public bool $broadcast = false; + #[SerializedName('range_requests')] + public bool $rangeRequests = false; + + /** + * @var int[] + */ + #[SerializedName('cacheable_response_statuses')] + public array $cacheableResponseStatuses = [0, 200]; + + /** + * @var null|array + */ + #[SerializedName('cacheable_response_headers')] + public array $cacheableResponseHeaders = []; + /** * @var array */ @@ -30,5 +42,6 @@ final class PageCache /** * @var array */ + #[SerializedName('preload_urls')] public array $urls = []; } diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index 61e75d8..ee013d7 100644 --- a/src/Resources/config/definition/service_worker.php +++ b/src/Resources/config/definition/service_worker.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use SpomkyLabs\PwaBundle\Service\CacheStrategy; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; return static function (DefinitionConfigurator $definition): void { @@ -94,6 +95,23 @@ return $v; }) ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['page_caches'])) { + return $v; + } + + $v['page_caches'][] = [ + 'match_callback' => 'navigate', + 'preload_urls' => $v['warm_cache_urls'] ?? [], + 'cache_name' => $v['page_cache_name'] ?? 'pages', + 'network_timeout' => $v['network_timeout_seconds'] ?? 3, + ]; + + return $v; + }) + ->end() ->children() ->booleanNode('use_cdn') ->defaultFalse() @@ -106,7 +124,7 @@ ->defaultNull() ->info('The cache prefix for the Google fonts.') ->end() - ->integerNode('max_age') + ->scalarNode('max_age') ->defaultNull() ->info('The maximum age of the Google fonts cache (in seconds).') ->end() @@ -201,7 +219,7 @@ ->info('The maximum number of entries in the image cache.') ->example([50, 100, 200]) ->end() - ->integerNode('max_age') + ->scalarNode('max_age') ->defaultValue(60 * 60 * 24 * 365) ->info('The maximum number of seconds before the image cache is invalidated.') ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) @@ -257,32 +275,78 @@ ->treatTrueLike([]) ->arrayPrototype() ->children() - ->scalarNode('regex') + ->scalarNode('match_callback') ->isRequired() - ->info('The regex to match the URLs.') + ->info('The regex or callback function to match the URLs.') + ->example(['/^\/api\//', '({url}) url.pathname === "/api/"']) ->end() ->scalarNode('cache_name') - ->isRequired() ->info('The name of the page cache.') + ->example(['pages', 'api']) ->end() ->integerNode('network_timeout') ->defaultValue(3) ->info( - 'The network timeout in seconds before cache is called (for "networkFirst" strategy only).' + 'The network timeout in seconds before cache is called (for "NetworkFirst" strategy only).' ) ->example([1, 2, 5]) ->end() ->scalarNode('strategy') - ->defaultValue('networkFirst') + ->defaultValue('NetworkFirst') + ->info( + 'The caching strategy. Only "NetworkFirst", "CacheFirst" and "StaleWhileRevalidate" are supported.' + ) + ->example(['NetworkFirst', 'StaleWhileRevalidate', 'CacheFirst']) + ->validate() + ->ifNotInArray(CacheStrategy::STRATEGIES) + ->thenInvalid( + 'Invalid caching strategy "%s". Should be one of: ' . implode( + ', ', + CacheStrategy::STRATEGIES + ) + ) + ->end() + ->end() + ->scalarNode('max_entries') + ->defaultNull() ->info( - 'The caching strategy. Only "networkFirst" and "staleWhileRevalidate" are supported.' + 'The maximum number of entries in the cache (for "CacheFirst" and "NetworkFirst" strategy only).' + ) + ->end() + ->scalarNode('max_age') + ->defaultNull() + ->info( + 'The maximum number of seconds before the cache is invalidated (for "CacheFirst" and "NetWorkFirst" strategy only).' ) - ->example(['networkFirst', 'staleWhileRevalidate']) ->end() ->booleanNode('broadcast') ->defaultFalse() ->info( - 'Whether to broadcast the cache update events (for "staleWhileRevalidate" strategy only).' + 'Whether to broadcast the cache update events (for "StaleWhileRevalidate" strategy only).' + ) + ->end() + ->booleanNode('range_requests') + ->defaultFalse() + ->info( + 'Whether to support range requests (for "CacheFirst" strategy only).' + ) + ->end() + ->arrayNode('cacheable_response_headers') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->scalarPrototype()->end() + ->info( + 'The cacheable response headers. If set to ["X-Is-Cacheable" => "true"], only the response with the header "X-Is-Cacheable: true" will be cached.' + ) + ->end() + ->arrayNode('cacheable_response_statuses') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->integerPrototype()->end() + ->info( + 'The cacheable response statuses. if set to [200], only 200 status will be cached.' ) ->end() ->arrayNode('broadcast_headers') @@ -292,7 +356,7 @@ ->defaultValue(['Content-Length', 'ETag', 'Last-Modified']) ->scalarPrototype()->end() ->end() - ->arrayNode('urls') + ->arrayNode('preload_urls') ->treatNullLike([]) ->treatFalseLike([]) ->treatTrueLike([]) @@ -337,9 +401,9 @@ ->info('The name of the queue.') ->example(['api-requests', 'image-uploads']) ->end() - ->scalarNode('regex') + ->scalarNode('match_callback') ->isRequired() - ->info('The regex to match the URLs.') + ->info('The regex or callback function to match the URLs.') ->example(['/\/api\//']) ->end() ->scalarNode('method') diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 46f5903..cff0c8f 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -12,6 +12,7 @@ use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; use SpomkyLabs\PwaBundle\Service\ManifestBuilder; +use SpomkyLabs\PwaBundle\Service\MatchCallbackHandler\MatchCallbackHandler; use SpomkyLabs\PwaBundle\Service\Rule\ServiceWorkerRule; use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; @@ -116,4 +117,8 @@ ->tag('spomky_labs_pwa.cache_strategy') ; $container->load('SpomkyLabs\\PwaBundle\\Service\\Rule\\', '../../Service/Rule/*'); + $container->instanceof(MatchCallbackHandler::class) + ->tag('spomky_labs_pwa.match_callback_handler') + ; + $container->load('SpomkyLabs\\PwaBundle\\Service\\MatchCallbackHandler\\', '../../Service/MatchCallbackHandler/*'); }; diff --git a/src/Service/CacheStrategy.php b/src/Service/CacheStrategy.php index 924454c..d20a5f8 100644 --- a/src/Service/CacheStrategy.php +++ b/src/Service/CacheStrategy.php @@ -4,17 +4,17 @@ namespace SpomkyLabs\PwaBundle\Service; -final readonly class CacheStrategy +abstract readonly class CacheStrategy { - public const STRATEGY_CACHE_FIRST = 'cacheFirst'; + public const STRATEGY_CACHE_FIRST = 'CacheFirst'; - public const STRATEGY_CACHE_ONLY = 'cacheOnly'; + public const STRATEGY_CACHE_ONLY = 'CacheOnly'; - public const STRATEGY_NETWORK_FIRST = 'networkFirst'; + public const STRATEGY_NETWORK_FIRST = 'NetworkFirst'; - public const STRATEGY_NETWORK_ONLY = 'networkOnly'; + public const STRATEGY_NETWORK_ONLY = 'NetworkOnly'; - public const STRATEGY_STALE_WHILE_REVALIDATE = 'staleWhileRevalidate'; + public const STRATEGY_STALE_WHILE_REVALIDATE = 'StaleWhileRevalidate'; public const STRATEGIES = [ self::STRATEGY_CACHE_FIRST, @@ -26,28 +26,10 @@ public function __construct( public string $name, - public string $strategy, - public string $urlPattern, public bool $enabled, public bool $requireWorkbox, - /** - * @var array{maxTimeout?: int, maxAge?: int, maxEntries?: int, warmUrls?: string[], plugins?: string[]} - */ - public array $options = [] ) { } - /** - * @param array{maxTimeout?: int, maxAge?: int, maxEntries?: int, warmUrls?: string[], plugins?: string[]} $options - */ - public static function create( - string $name, - string $strategy, - string $urlPattern, - bool $enabled, - bool $requireWorkbox, - array $options = [], - ): self { - return new self($name, $strategy, $urlPattern, $enabled, $requireWorkbox, $options); - } + abstract public function render(string $cacheObjectName, int $jsonOptions = 0): string; } diff --git a/src/Service/MatchCallbackHandler/DestinationMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/DestinationMatchCallbackHandler.php new file mode 100644 index 0000000..f52304d --- /dev/null +++ b/src/Service/MatchCallbackHandler/DestinationMatchCallbackHandler.php @@ -0,0 +1,18 @@ + request.destination === '%s'", trim(mb_substr($matchCallback, 12))); + } +} diff --git a/src/Service/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php new file mode 100644 index 0000000..8752ac8 --- /dev/null +++ b/src/Service/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname === '%s'", trim(mb_substr($matchCallback, 9))); + } +} diff --git a/src/Service/MatchCallbackHandler/MatchCallbackHandler.php b/src/Service/MatchCallbackHandler/MatchCallbackHandler.php new file mode 100644 index 0000000..0f3e9a3 --- /dev/null +++ b/src/Service/MatchCallbackHandler/MatchCallbackHandler.php @@ -0,0 +1,12 @@ + request.mode === 'navigate'"; + } +} diff --git a/src/Service/MatchCallbackHandler/OriginMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/OriginMatchCallbackHandler.php new file mode 100644 index 0000000..526a021 --- /dev/null +++ b/src/Service/MatchCallbackHandler/OriginMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.origin === '%s'", trim(mb_substr($matchCallback, 7))); + } +} diff --git a/src/Service/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php new file mode 100644 index 0000000..457974c --- /dev/null +++ b/src/Service/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname.endsWith('%s')", trim(mb_substr($matchCallback, 9))); + } +} diff --git a/src/Service/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php new file mode 100644 index 0000000..374c438 --- /dev/null +++ b/src/Service/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname.startsWith('%s')", trim(mb_substr($matchCallback, 11))); + } +} diff --git a/src/Service/MatchCallbackHandler/RegexMatchCallbackHandler.php b/src/Service/MatchCallbackHandler/RegexMatchCallbackHandler.php new file mode 100644 index 0000000..5389684 --- /dev/null +++ b/src/Service/MatchCallbackHandler/RegexMatchCallbackHandler.php @@ -0,0 +1,18 @@ +options['forceSyncFallback'] === true ? 'true' : 'false'; + $broadcastChannel = $this->options['broadcastChannel']; + $maxRetentionTime = $this->options['maxRetentionTime']; + $queueName = $this->options['queueName']; + $broadcastChannelSection = ''; + if ($broadcastChannel !== null) { + $broadcastChannelSection = << { + try { + await queue.replayRequests(); + } catch (error) { + // Failed to replay one or more requests + } finally { + remainingRequests = await queue.getAll(); + const bc = new BroadcastChannel('{$broadcastChannel}'); + bc.postMessage({name: '{$queueName}', remaining: remainingRequests.length}); + bc.close(); + } +} +BROADCAST_CHANNEL; + } + + $declaration = <<options, $jsonOptions) + ); + } +} diff --git a/src/Service/Plugin/CachePlugin.php b/src/Service/Plugin/CachePlugin.php new file mode 100644 index 0000000..07cec7d --- /dev/null +++ b/src/Service/Plugin/CachePlugin.php @@ -0,0 +1,77 @@ + $options + */ + public function __construct( + public string $name, + public array $options = [] + ) { + } + + abstract public function render(int $jsonOptions = 0): string; + + public static function createExpirationPlugin(null|int $maxEntries, null|string|int $maxAgeSeconds): static + { + return new ExpirationPlugin( + 'ExpirationPlugin', + [ + 'maxEntries' => $maxEntries, + 'maxAgeSeconds' => $maxAgeSeconds, + ] + ); + } + + public static function createBroadcastUpdatePlugin(array $headersToCheck = []): static + { + $headersToCheck = $headersToCheck === [] ? ['Content-Type', 'ETag', 'Last-Modified'] : $headersToCheck; + + return new BroadcastUpdatePlugin( + 'BroadcastUpdatePlugin', + [ + 'headersToCheck' => $headersToCheck, + ] + ); + } + + public static function createCacheableResponsePlugin(array $statuses = [0, 200], array $headers = []): static + { + $options = array_filter([ + 'statuses' => $statuses, + 'headers' => $headers, + ], fn ($value) => $value !== []); + $options = $options === [] ? [ + 'statuses' => [0, 200], + ] : $options; + + return new CacheableResponsePlugin('CacheableResponsePlugin', $options); + } + + public static function createRangeRequestsPlugin(): static + { + return new RangeRequestsPlugin('RangeRequestsPlugin'); + } + + public static function createBackgroundSyncPlugin( + string $queueName, + int $maxRetentionTime, + bool $forceSyncFallback, + ?string $broadcastChannel + ): static { + return new BackgroundSyncPlugin( + 'BackgroundSyncPlugin', + [ + 'queueName' => $queueName, + 'maxRetentionTime' => $maxRetentionTime, + 'forceSyncFallback' => $forceSyncFallback, + 'broadcastChannel' => $broadcastChannel, + ] + ); + } +} diff --git a/src/Service/Plugin/CacheableResponsePlugin.php b/src/Service/Plugin/CacheableResponsePlugin.php new file mode 100644 index 0000000..933fb74 --- /dev/null +++ b/src/Service/Plugin/CacheableResponsePlugin.php @@ -0,0 +1,16 @@ +options, $jsonOptions) + ); + } +} diff --git a/src/Service/Plugin/ExpirationPlugin.php b/src/Service/Plugin/ExpirationPlugin.php new file mode 100644 index 0000000..40fd1bb --- /dev/null +++ b/src/Service/Plugin/ExpirationPlugin.php @@ -0,0 +1,13 @@ +options, $jsonOptions)); + } +} diff --git a/src/Service/Plugin/RangeRequestsPlugin.php b/src/Service/Plugin/RangeRequestsPlugin.php new file mode 100644 index 0000000..9789f0a --- /dev/null +++ b/src/Service/Plugin/RangeRequestsPlugin.php @@ -0,0 +1,13 @@ + $cacheStrategies + */ + public function __construct( + #[TaggedIterator('spomky_labs_pwa.cache_strategy')] + private iterable $cacheStrategies, + #[Autowire('%kernel.debug%')] + bool $debug, + ) { + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; + if ($debug === true) { + $options |= JSON_PRETTY_PRINT; + } + $this->jsonOptions = $options; + } + + public function process(string $body): string + { + foreach ($this->cacheStrategies as $idCacheStrategy => $cacheStrategy) { + foreach ($cacheStrategy->getCacheStrategies() as $idStrategy => $strategy) { + if ($strategy->enabled === false) { + continue; + } + + $body .= PHP_EOL . PHP_EOL . trim($strategy->render( + sprintf('cacheStrategy_%d_%d', $idCacheStrategy, $idStrategy), + $this->jsonOptions + )); + } + } + + return $body; + } +} diff --git a/src/Service/Rule/AssetCache.php b/src/Service/Rule/AssetCache.php index a77bb3c..6d515ed 100644 --- a/src/Service/Rule/AssetCache.php +++ b/src/Service/Rule/AssetCache.php @@ -8,25 +8,22 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use function count; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -use const PHP_EOL; -final readonly class AssetCache implements ServiceWorkerRule, HasCacheStrategies +final readonly class AssetCache implements HasCacheStrategies { - /** - * @var array - */ - private array $jsonOptions; + private int $jsonOptions; private string $assetPublicPrefix; @@ -43,75 +40,33 @@ public function __construct( ) { $this->workbox = $serviceWorker->workbox; $this->assetPublicPrefix = rtrim($publicAssetsPathResolver->resolvePublicPath(''), '/'); - $options = [ - AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, - AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; if ($debug === true) { - $options[JsonEncode::OPTIONS] |= JSON_PRETTY_PRINT; + $options |= JSON_PRETTY_PRINT; } $this->jsonOptions = $options; } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - if ($this->workbox->assetCache->enabled === false) { - return $body; - } - $assets = $this->getAssets(); - $assetUrls = $this->serializer->serialize($assets, 'json', $this->jsonOptions); - $assetUrlsLength = count($assets) * 2; - - $declaration = <<workbox->assetCache->cacheName}', - plugins: [ - new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), - new workbox.expiration.ExpirationPlugin({ - maxEntries: {$assetUrlsLength}, - maxAgeSeconds: {$this->workbox->assetCache->maxAge}, - }), - ], -}); -workbox.routing.registerRoute( - ({url}) => url.pathname.startsWith('{$this->assetPublicPrefix}'), - assetCacheStrategy -); -self.addEventListener('install', event => { - const done = {$assetUrls}.map( - path => - assetCacheStrategy.handleAll({ - event, - request: new Request(path), - })[1] - ); - - event.waitUntil(Promise.all(done)); -}); -ASSET_CACHE_RULE_STRATEGY; - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } - public function getCacheStrategies(): array { - $urls = json_decode($this->serializer->serialize($this->getAssets(), 'json', $this->jsonOptions), true); + $urls = json_decode($this->serializer->serialize($this->getAssets(), 'json', [ + JsonEncode::OPTIONS => $this->jsonOptions, + ]), true); return [ - CacheStrategy::create( + WorkboxCacheStrategy::create( $this->workbox->assetCache->cacheName, CacheStrategy::STRATEGY_CACHE_FIRST, - sprintf("'({url}) => url.pathname.startsWith('%s')'", $this->assetPublicPrefix), + sprintf("({url}) => url.pathname.startsWith('%s')", $this->assetPublicPrefix), $this->workbox->enabled && $this->workbox->assetCache->enabled, true, + null, [ - 'maxEntries' => count($this->getAssets()) * 2, - 'maxAge' => $this->workbox->assetCache->maxAge, - 'warmUrls' => $urls, + CachePlugin::createExpirationPlugin( + count($this->getAssets()) * 2, + $this->workbox->assetCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + ), ], + $urls ), ]; } diff --git a/src/Service/Rule/BackgroundSync.php b/src/Service/Rule/BackgroundSync.php index 2abaf1b..aea2fe7 100644 --- a/src/Service/Rule/BackgroundSync.php +++ b/src/Service/Rule/BackgroundSync.php @@ -8,81 +8,61 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; -use const PHP_EOL; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -final readonly class BackgroundSync implements ServiceWorkerRule, HasCacheStrategies +final readonly class BackgroundSync implements HasCacheStrategies { private Workbox $workbox; - public function __construct(ServiceWorker $serviceWorker) - { + public function __construct( + ServiceWorker $serviceWorker, + #[TaggedIterator('spomky_labs_pwa.match_callback_handler')] + private iterable $matchCallbackHandlers, + #[Autowire('%kernel.debug%')] + bool $debug, + ) { $this->workbox = $serviceWorker->workbox; } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - if ($this->workbox->backgroundSync === []) { - return $body; - } - - $declaration = ''; - foreach ($this->workbox->backgroundSync as $sync) { - $forceSyncFallback = $sync->forceSyncFallback === true ? 'true' : 'false'; - $broadcastChannel = ''; - if ($sync->broadcastChannel !== null) { - $broadcastChannel = << { - try { - await queue.replayRequests(); - } catch (error) { - // Failed to replay one or more requests - } finally { - remainingRequests = await queue.getAll(); - const bc = new BroadcastChannel('{$sync->broadcastChannel}'); - bc.postMessage({name: '{$sync->queueName}', remaining: remainingRequests.length}); - bc.close(); - } - } -BROADCAST_CHANNEL; - } - $declaration .= <<regex}'), - new workbox.strategies.NetworkOnly({plugins: [new workbox.backgroundSync.BackgroundSyncPlugin('{$sync->queueName}',{ - "maxRetentionTime": {$sync->maxRetentionTime}, - "forceSyncFallback": {$forceSyncFallback}{$broadcastChannel} -})] }), - '{$sync->method}' -); -BACKGROUND_SYNC_RULE_STRATEGY; - } - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } - + /** + * @return array + */ public function getCacheStrategies(): array { $strategies = []; foreach ($this->workbox->backgroundSync as $sync) { - $strategies[] = CacheStrategy::create( + $strategies[] = WorkboxCacheStrategy::create( 'BackgroundSync API', CacheStrategy::STRATEGY_NETWORK_ONLY, - $sync->regex, + $this->prepareMatchCallback($sync->matchCallback), $this->workbox->enabled, true, + null, [ - 'plugins' => [ - sprintf('backgroundSync: "%s"', $sync->queueName), - sprintf('broadcastChannel: "%s"', $sync->broadcastChannel ?? '---'), - ], + CachePlugin::createBackgroundSyncPlugin( + $sync->queueName, + $sync->maxRetentionTime, + $sync->forceSyncFallback, + $sync->broadcastChannel + ), ] ); } return $strategies; } + + private function prepareMatchCallback(string $matchCallback): string + { + foreach ($this->matchCallbackHandlers as $handler) { + if ($handler->supports($matchCallback)) { + return $handler->handle($matchCallback); + } + } + + return $matchCallback; + } } diff --git a/src/Service/Rule/FontCache.php b/src/Service/Rule/FontCache.php index 771349a..cf8f5da 100644 --- a/src/Service/Rule/FontCache.php +++ b/src/Service/Rule/FontCache.php @@ -8,23 +8,21 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; +use function count; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -use const PHP_EOL; -final readonly class FontCache implements ServiceWorkerRule, HasCacheStrategies +final readonly class FontCache implements HasCacheStrategies { - /** - * @var array - */ - private array $jsonOptions; + private int $jsonOptions; private Workbox $workbox; @@ -36,80 +34,48 @@ public function __construct( bool $debug, ) { $this->workbox = $serviceWorker->workbox; - $options = [ - AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, - AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; if ($debug === true) { - $options[JsonEncode::OPTIONS] |= JSON_PRETTY_PRINT; + $options |= JSON_PRETTY_PRINT; } $this->jsonOptions = $options; } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - if ($this->workbox->fontCache->enabled === false) { - return $body; - } - $fonts = []; - foreach ($this->assetMapper->allAssets() as $asset) { - if (preg_match($this->workbox->fontCache->regex, $asset->sourcePath) === 1) { - $fonts[] = $asset->publicPath; - } - } - $fontUrls = $this->serializer->serialize($fonts, 'json', $this->jsonOptions); - - $declaration = <<workbox->fontCache->cacheName}', - plugins: [ - new workbox.cacheableResponse.CacheableResponsePlugin({ - statuses: [0, 200], - }), - new workbox.expiration.ExpirationPlugin({ - maxAgeSeconds: {$this->workbox->fontCache->maxAge}, - maxEntries: {$this->workbox->fontCache->maxEntries}, - }), - ], -}); -workbox.routing.registerRoute( - ({request}) => request.destination === 'font', - fontCacheStrategy -); -self.addEventListener('install', event => { - const done = {$fontUrls}.map( - path => - fontCacheStrategy.handleAll({ - event, - request: new Request(path), - })[1] - ); - - event.waitUntil(Promise.all(done)); -}); -FONT_CACHE_RULE_STRATEGY; - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } - public function getCacheStrategies(): array { + $urls = json_decode($this->serializer->serialize($this->getFonts(), 'json', [ + JsonEncode::OPTIONS => $this->jsonOptions, + ]), true); + $maxEntries = count($urls) + ($this->workbox->fontCache->maxEntries ?? 60); + return [ - CacheStrategy::create( - $this->workbox->fontCache->cacheName, + WorkboxCacheStrategy::create( + $this->workbox->fontCache->cacheName ?? 'fonts', CacheStrategy::STRATEGY_CACHE_FIRST, - "'({request}) => request.destination === 'font'", + "({request}) => request.destination === 'font'", $this->workbox->enabled && $this->workbox->fontCache->enabled, true, + null, [ - 'maxEntries' => $this->workbox->fontCache->maxEntries, - 'maxAge' => $this->workbox->fontCache->maxAge, + CachePlugin::createExpirationPlugin( + $maxEntries, + $this->workbox->fontCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + ), + CachePlugin::createCacheableResponsePlugin(), ], + $urls ), ]; } + + private function getFonts(): array + { + $fonts = []; + foreach ($this->assetMapper->allAssets() as $asset) { + if (preg_match($this->workbox->fontCache->regex, $asset->sourcePath) === 1) { + $fonts[] = $asset->publicPath; + } + } + return $fonts; + } } diff --git a/src/Service/Rule/GoogleFontCache.php b/src/Service/Rule/GoogleFontCache.php index a5ccca1..8a57b95 100644 --- a/src/Service/Rule/GoogleFontCache.php +++ b/src/Service/Rule/GoogleFontCache.php @@ -8,79 +8,47 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; -use Symfony\Component\Serializer\SerializerInterface; -use function count; -use const JSON_PRETTY_PRINT; -use const JSON_THROW_ON_ERROR; -use const JSON_UNESCAPED_SLASHES; -use const JSON_UNESCAPED_UNICODE; -use const PHP_EOL; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; -final readonly class GoogleFontCache implements ServiceWorkerRule, HasCacheStrategies +final readonly class GoogleFontCache implements HasCacheStrategies { - /** - * @var array - */ - private array $jsonOptions; - private Workbox $workbox; public function __construct( ServiceWorker $serviceWorker, - private SerializerInterface $serializer, - #[Autowire('%kernel.debug%')] - bool $debug, ) { $this->workbox = $serviceWorker->workbox; - $options = [ - AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, - AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; - if ($debug === true) { - $options[JsonEncode::OPTIONS] |= JSON_PRETTY_PRINT; - } - $this->jsonOptions = $options; } - public function process(string $body): string + public function getCacheStrategies(): array { - if ($this->workbox->enabled === false) { - return $body; + $prefix = $this->workbox->googleFontCache->cachePrefix ?? ''; + if ($prefix !== '') { + $prefix .= '-'; } - if ($this->workbox->googleFontCache->enabled === false) { - return $body; - } - $options = [ - 'cachePrefix' => $this->workbox->googleFontCache->cachePrefix, - 'maxAge' => $this->workbox->googleFontCache->maxAge, - 'maxEntries' => $this->workbox->googleFontCache->maxEntries, - ]; - $options = array_filter($options, static fn (mixed $v): bool => ($v !== null && $v !== '')); - $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); - - $declaration = << url.origin === 'https://fonts.googleapis.com'", + $this->workbox->enabled && $this->workbox->googleFontCache->enabled, + true + ), + WorkboxCacheStrategy::create( + $prefix . 'google-fonts-webfonts', + CacheStrategy::STRATEGY_CACHE_FIRST, + "({url}) => url.origin === 'https://fonts.gstatic.com'", $this->workbox->enabled && $this->workbox->googleFontCache->enabled, - $this->workbox->googleFontCache->enabled, + true, + null, [ - 'maxAge' => $this->workbox->googleFontCache->maxAge, - 'maxEntries' => $this->workbox->googleFontCache->maxEntries, + CachePlugin::createCacheableResponsePlugin(), + CachePlugin::createExpirationPlugin( + $this->workbox->googleFontCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + $this->workbox->googleFontCache->maxEntries ?? 30 + ), ] ), ]; diff --git a/src/Service/Rule/ImageCache.php b/src/Service/Rule/ImageCache.php index 3182595..39c83ef 100644 --- a/src/Service/Rule/ImageCache.php +++ b/src/Service/Rule/ImageCache.php @@ -8,11 +8,12 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use const PHP_EOL; -final readonly class ImageCache implements ServiceWorkerRule, HasCacheStrategies +final readonly class ImageCache implements HasCacheStrategies { private string $assetPublicPrefix; @@ -27,48 +28,25 @@ public function __construct( $this->assetPublicPrefix = rtrim($publicAssetsPathResolver->resolvePublicPath(''), '/'); } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - if ($this->workbox->imageCache->enabled === false) { - return $body; - } - $declaration = << (request.destination === 'image' && !url.pathname.startsWith('{$this->assetPublicPrefix}')), - new workbox.strategies.CacheFirst({ - cacheName: '{$this->workbox->imageCache->cacheName}', - plugins: [ - new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), - new workbox.expiration.ExpirationPlugin({ - maxEntries: {$this->workbox->imageCache->maxEntries}, - maxAgeSeconds: {$this->workbox->imageCache->maxAge}, - }), - ], - }) -); -IMAGE_CACHE_RULE_STRATEGY; - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } - public function getCacheStrategies(): array { return [ - CacheStrategy::create( - $this->workbox->imageCache->cacheName, + WorkboxCacheStrategy::create( + $this->workbox->imageCache->cacheName ?? 'images', CacheStrategy::STRATEGY_CACHE_FIRST, sprintf( - "'({request, url}) => (request.destination === 'image' && !url.pathname.startsWith('%s'))'", + "({request, url}) => (request.destination === 'image' && !url.pathname.startsWith('%s'))", $this->assetPublicPrefix ), $this->workbox->enabled && $this->workbox->imageCache->enabled, true, + null, [ - 'maxEntries' => $this->workbox->imageCache->maxEntries, - 'maxAge' => $this->workbox->imageCache->maxAge, + CachePlugin::createCacheableResponsePlugin(), + CachePlugin::createExpirationPlugin( + $this->workbox->imageCache->maxEntries ?? 60, + $this->workbox->imageCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 7 + ), ] ), ]; diff --git a/src/Service/Rule/ManifestCache.php b/src/Service/Rule/ManifestCache.php index 47de81f..11623d9 100644 --- a/src/Service/Rule/ManifestCache.php +++ b/src/Service/Rule/ManifestCache.php @@ -8,10 +8,11 @@ use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\DependencyInjection\Attribute\Autowire; use const PHP_EOL; -final readonly class ManifestCache implements ServiceWorkerRule, HasCacheStrategies +final readonly class ManifestCache implements HasCacheStrategies { private string $manifestPublicUrl; @@ -50,7 +51,7 @@ public function process(string $body): string public function getCacheStrategies(): array { return [ - CacheStrategy::create( + WorkboxCacheStrategy::create( 'manifest', CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE, sprintf("({url}) => '%s' === url.pathname", $this->manifestPublicUrl), diff --git a/src/Service/Rule/PageCaches.php b/src/Service/Rule/PageCaches.php index fb8356e..44b190a 100644 --- a/src/Service/Rule/PageCaches.php +++ b/src/Service/Rule/PageCaches.php @@ -4,86 +4,85 @@ namespace SpomkyLabs\PwaBundle\Service\Rule; -use SpomkyLabs\PwaBundle\Dto\PageCache; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Dto\Workbox; use SpomkyLabs\PwaBundle\Service\CacheStrategy; use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\Service\MatchCallbackHandler\MatchCallbackHandler; +use SpomkyLabs\PwaBundle\Service\Plugin\CachePlugin; +use SpomkyLabs\PwaBundle\Service\WorkboxCacheStrategy; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -use const PHP_EOL; -final readonly class PageCaches implements ServiceWorkerRule, HasCacheStrategies +final readonly class PageCaches implements HasCacheStrategies { - /** - * @var array - */ - private array $jsonOptions; + private int $jsonOptions; private Workbox $workbox; + /** + * @param iterable $matchCallbackHandlers + */ public function __construct( ServiceWorker $serviceWorker, private SerializerInterface $serializer, + #[TaggedIterator('spomky_labs_pwa.match_callback_handler')] + private iterable $matchCallbackHandlers, #[Autowire('%kernel.debug%')] bool $debug, ) { $this->workbox = $serviceWorker->workbox; - $options = [ - AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, - AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; if ($debug === true) { - $options[JsonEncode::OPTIONS] |= JSON_PRETTY_PRINT; + $options |= JSON_PRETTY_PRINT; } $this->jsonOptions = $options; } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - - foreach (array_values($this->workbox->pageCaches) as $id => $pageCache) { - $body = $this->processPageCache($id, $pageCache, $body); - } - - return $body; - } - public function getCacheStrategies(): array { $strategies = []; - foreach ($this->workbox->pageCaches as $pageCache) { - $strategy = match ($pageCache->strategy) { - 'staleWhileRevalidate' => CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE, - default => CacheStrategy::STRATEGY_NETWORK_FIRST, - }; - $plugins = ['CacheableResponsePlugin']; - if ($pageCache->broadcast === true && $strategy === CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE) { - $plugins[] = 'BroadcastUpdatePlugin'; - } - $routes = $this->serializer->serialize($pageCache->urls, 'json', $this->jsonOptions); + foreach ($this->workbox->pageCaches as $id => $pageCache) { + $routes = $this->serializer->serialize($pageCache->urls, 'json', [ + JsonEncode::OPTIONS => $this->jsonOptions, + ]); $url = json_decode($routes, true, 512, JSON_THROW_ON_ERROR); + $cacheName = $pageCache->cacheName ?? sprintf('page-cache-%d', $id); + + $plugins = [ + CachePlugin::createCacheableResponsePlugin( + $pageCache->cacheableResponseStatuses, + $pageCache->cacheableResponseHeaders + ), + ]; + if ($pageCache->broadcast === true && $pageCache->strategy === CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE) { + $plugins[] = CachePlugin::createBroadcastUpdatePlugin($pageCache->broadcastHeaders); + } + if ($pageCache->rangeRequests === true && $pageCache->strategy !== CacheStrategy::STRATEGY_NETWORK_ONLY) { + $plugins[] = CachePlugin::createRangeRequestsPlugin(); + } + if ($pageCache->maxEntries !== null || $pageCache->maxAgeInSeconds() !== null) { + $plugins[] = CachePlugin::createExpirationPlugin($pageCache->maxEntries, $pageCache->maxAgeInSeconds()); + } + $strategies[] = - CacheStrategy::create( - $pageCache->cacheName, - $strategy, - $pageCache->regex, + WorkboxCacheStrategy::create( + $cacheName, + $pageCache->strategy, + $this->prepareMatchCallback($pageCache->matchCallback), $this->workbox->enabled, true, + null, + $plugins, + $url, [ - 'maxTimeout' => $pageCache->networkTimeout, - 'plugins' => $plugins, - 'warmUrls' => $url, + 'networkTimeoutSeconds' => $pageCache->networkTimeout, ] ); } @@ -91,65 +90,14 @@ public function getCacheStrategies(): array return $strategies; } - private function processPageCache(int $id, PageCache $pageCache, string $body): string + private function prepareMatchCallback(string $matchCallback): string { - $routes = $this->serializer->serialize($pageCache->urls, 'json', $this->jsonOptions); - $strategy = match ($pageCache->strategy) { - 'staleWhileRevalidate' => 'StaleWhileRevalidate', - default => 'NetworkFirst', - }; - $broadcastHeaders = json_encode( - $pageCache->broadcastHeaders === [] ? [ - 'Content-Type', - 'ETag', - 'Last-Modified', - ] : $pageCache->broadcastHeaders, - JSON_THROW_ON_ERROR, - 512 - ); - $broadcastUpdate = ($strategy === 'StaleWhileRevalidate' && $pageCache->broadcast === true) ? sprintf( - ',new workbox.broadcastUpdate.BroadcastUpdatePlugin({headersToCheck: %s})', - $broadcastHeaders - ) : ''; - - $declaration = <<networkTimeout}, - cacheName: '{$pageCache->cacheName}', - plugins: [new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}){$broadcastUpdate}], -}); -workbox.routing.registerRoute( - new RegExp('{$pageCache->regex}'), - pageCache{$id}Strategy -); -self.addEventListener('install', event => { - const done = {$routes}.map( - path => - pageCache{$id}Strategy.handleAll({ - event, - request: new Request(path), - })[1] - ); - event.waitUntil(Promise.all(done)); -}); -fetchAsync = async (url) => { - await fetch(url); -} -self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'PREFETCH') { - const urls = event.data.payload.urls || []; - const done = urls.map( - path => - pageCache{$id}Strategy.handleAll({ - event, - request: new Request(path), - })[1] - ); - event.waitUntil(Promise.all(done)); - } -}); -PAGE_CACHE_RULE_STRATEGY; + foreach ($this->matchCallbackHandlers as $handler) { + if ($handler->supports($matchCallback)) { + return $handler->handle($matchCallback); + } + } - return $body . PHP_EOL . PHP_EOL . trim($declaration); + return $matchCallback; } } diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 72541b3..bde453c 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -32,20 +32,26 @@ public function compile(): ?string if ($this->serviceWorkerEnabled === false) { return null; } - $serviceWorker = $this->serviceWorker; + $body = ''; + foreach ($this->serviceworkerRules as $rule) { + $body = $rule->process($body); + } - if (! str_starts_with($serviceWorker->src->src, '/')) { - $asset = $this->assetMapper->getAsset($serviceWorker->src->src); + return $body . $this->includeRootSW(); + } + + private function includeRootSW(): string + { + if ($this->serviceWorker->src->src === '') { + return ''; + } + if (! str_starts_with($this->serviceWorker->src->src, '/')) { + $asset = $this->assetMapper->getAsset($this->serviceWorker->src->src); assert($asset !== null, 'Unable to find service worker source asset'); $body = $asset->content ?? file_get_contents($asset->sourcePath); } else { - $body = file_get_contents($serviceWorker->src->src); + $body = file_get_contents($this->serviceWorker->src->src); } - assert(is_string($body), 'Unable to find service worker source content'); - foreach ($this->serviceworkerRules as $rule) { - $body = $rule->process($body); - } - - return $body; + return is_string($body) ? $body : ''; } } diff --git a/src/Service/WorkboxCacheStrategy.php b/src/Service/WorkboxCacheStrategy.php new file mode 100644 index 0000000..f45b5d5 --- /dev/null +++ b/src/Service/WorkboxCacheStrategy.php @@ -0,0 +1,111 @@ + $plugins + * @param array $preloadUrls + * @param array $options + */ + public function __construct( + string $name, + public string $strategy, + public string $matchCallback, + bool $enabled, + bool $requireWorkbox, + public null|string $method = null, + public array $plugins = [], + public array $preloadUrls = [], + public array $options = [], + ) { + parent::__construct($name, $enabled, $requireWorkbox); + } + + /** + * @param array $plugins + * @param array $preloadUrls + * @param array $options + */ + public static function create( + string $name, + string $strategy, + string $matchCallback, + bool $enabled, + bool $requireWorkbox, + null|string $method = null, + array $plugins = [], + array $preloadUrls = [], + array $options = [], + ): static { + return new static( + $name, + $strategy, + $matchCallback, + $enabled, + $requireWorkbox, + $method, + $plugins, + $preloadUrls, + $options + ); + } + + public function render(string $cacheObjectName, int $jsonOptions = 0): string + { + if ($this->enabled === false) { + return ''; + } + + $timeout = ''; + if (in_array( + $this->strategy, + [self::STRATEGY_NETWORK_FIRST, self::STRATEGY_NETWORK_ONLY], + true + ) && ($this->options['networkTimeoutSeconds'] ?? null) !== null) { + $timeout = "networkTimeoutSeconds: {$this->options['networkTimeoutSeconds']},"; + } + $cacheName = ''; + if ($this->strategy !== self::STRATEGY_NETWORK_ONLY) { + $cacheName = "cacheName: '{$cacheName}',"; + } + $plugins = sprintf('[%s]', implode(', ', array_map( + fn (CachePlugin $plugin) => $plugin->render($jsonOptions), + $this->plugins + ))); + + $declaration = <<strategy}({ + {$timeout}{$cacheName}plugins: {$plugins} +}); +workbox.routing.registerRoute( + {$this->matchCallback}, + {$cacheObjectName} +); +FONT_CACHE_RULE_STRATEGY; + + if ($this->preloadUrls !== []) { + $fontUrls = json_encode($this->preloadUrls, $jsonOptions); + $declaration .= << { + const done = {$fontUrls}.map( + path => + {$cacheObjectName}.handleAll({ + event, + request: new Request(path), + })[1] + ); + event.waitUntil(Promise.all(done)); +}); +ASSET_CACHE_RULE_PRELOAD; + } + + return trim($declaration); + } +}