From ade75735214a6cc17c44d2ebba014c478d49e68b Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 17 Mar 2024 21:38:33 +0100 Subject: [PATCH] Features/better cache mgmt (#135) Better Cache Strategy Support --- assets/src/connection-status_controller.js | 2 +- assets/src/sync-broadcast_controller.js | 2 +- phpstan-baseline.neon | 104 +++++++++--- src/CachingStrategy/AssetCache.php | 84 ++++++++++ src/CachingStrategy/BackgroundSync.php | 66 ++++++++ src/CachingStrategy/CacheStrategy.php | 35 ++++ src/CachingStrategy/FontCache.php | 82 +++++++++ src/CachingStrategy/GoogleFontCache.php | 54 ++++++ .../HasCacheStrategies.php | 2 +- src/CachingStrategy/ImageCache.php | 52 ++++++ .../ManifestCache.php | 30 +--- src/CachingStrategy/ResourceCaches.php | 104 ++++++++++++ src/CachingStrategy/WorkboxCacheStrategy.php | 111 +++++++++++++ src/Command/ListCacheStrategiesCommand.php | 40 +++-- src/Dto/AssetCache.php | 10 +- src/Dto/BackgroundSync.php | 5 +- src/Dto/Cache.php | 40 +++++ src/Dto/FontCache.php | 11 +- src/Dto/GoogleFontCache.php | 8 +- src/Dto/ImageCache.php | 11 +- src/Dto/PageCache.php | 27 ++- src/Dto/Workbox.php | 4 +- .../DestinationMatchCallbackHandler.php | 18 ++ .../ExactPathnameMatchCallbackHandler.php | 18 ++ .../MatchCallbackHandler.php | 12 ++ .../NavigationMatchCallbackHandler.php | 18 ++ .../OriginMatchCallbackHandler.php | 18 ++ .../PathnameEndsWithMatchCallbackHandler.php | 18 ++ ...PathnameStartsWithMatchCallbackHandler.php | 18 ++ .../RegexMatchCallbackHandler.php | 18 ++ .../RouteMatchCallbackHandler.php | 28 ++++ .../config/definition/service_worker.php | 97 +++++++++-- src/Resources/config/services.php | 14 +- src/Service/CacheStrategy.php | 53 ------ src/Service/Rule/AssetCache.php | 132 --------------- src/Service/Rule/BackgroundSync.php | 88 ---------- src/Service/Rule/FontCache.php | 115 ------------- src/Service/Rule/GoogleFontCache.php | 88 ---------- src/Service/Rule/ImageCache.php | 76 --------- src/Service/Rule/PageCaches.php | 155 ------------------ src/Service/Rule/ServiceWorkerRule.php | 10 -- src/Service/ServiceWorkerCompiler.php | 29 ++-- .../AppendCacheStrategies.php | 54 ++++++ .../Rule => ServiceWorkerRule}/ClearCache.php | 11 +- .../OfflineFallback.php | 11 +- src/ServiceWorkerRule/ServiceWorkerRule.php | 10 ++ .../SkipWaiting.php | 9 +- .../WindowsWidgets.php | 9 +- .../WorkboxImport.php | 11 +- src/WorkboxPlugin/BackgroundSyncPlugin.php | 59 +++++++ src/WorkboxPlugin/BroadcastUpdatePlugin.php | 28 ++++ src/WorkboxPlugin/CachePlugin.php | 19 +++ src/WorkboxPlugin/CacheableResponsePlugin.php | 33 ++++ src/WorkboxPlugin/ExpirationPlugin.php | 21 +++ src/WorkboxPlugin/RangeRequestsPlugin.php | 18 ++ tests/config.php | 8 +- 56 files changed, 1319 insertions(+), 889 deletions(-) create mode 100644 src/CachingStrategy/AssetCache.php create mode 100644 src/CachingStrategy/BackgroundSync.php create mode 100644 src/CachingStrategy/CacheStrategy.php create mode 100644 src/CachingStrategy/FontCache.php create mode 100644 src/CachingStrategy/GoogleFontCache.php rename src/{Service => CachingStrategy}/HasCacheStrategies.php (77%) create mode 100644 src/CachingStrategy/ImageCache.php rename src/{Service/Rule => CachingStrategy}/ManifestCache.php (52%) create mode 100644 src/CachingStrategy/ResourceCaches.php create mode 100644 src/CachingStrategy/WorkboxCacheStrategy.php create mode 100644 src/Dto/Cache.php create mode 100644 src/MatchCallbackHandler/DestinationMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/MatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/NavigationMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/OriginMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/RegexMatchCallbackHandler.php create mode 100644 src/MatchCallbackHandler/RouteMatchCallbackHandler.php delete mode 100644 src/Service/CacheStrategy.php delete mode 100644 src/Service/Rule/AssetCache.php delete mode 100644 src/Service/Rule/BackgroundSync.php delete mode 100644 src/Service/Rule/FontCache.php delete mode 100644 src/Service/Rule/GoogleFontCache.php delete mode 100644 src/Service/Rule/ImageCache.php delete mode 100644 src/Service/Rule/PageCaches.php delete mode 100644 src/Service/Rule/ServiceWorkerRule.php create mode 100644 src/ServiceWorkerRule/AppendCacheStrategies.php rename src/{Service/Rule => ServiceWorkerRule}/ClearCache.php (78%) rename src/{Service/Rule => ServiceWorkerRule}/OfflineFallback.php (90%) create mode 100644 src/ServiceWorkerRule/ServiceWorkerRule.php rename src/{Service/Rule => ServiceWorkerRule}/SkipWaiting.php (73%) rename src/{Service/Rule => ServiceWorkerRule}/WindowsWidgets.php (93%) rename src/{Service/Rule => ServiceWorkerRule}/WorkboxImport.php (78%) create mode 100644 src/WorkboxPlugin/BackgroundSyncPlugin.php create mode 100644 src/WorkboxPlugin/BroadcastUpdatePlugin.php create mode 100644 src/WorkboxPlugin/CachePlugin.php create mode 100644 src/WorkboxPlugin/CacheableResponsePlugin.php create mode 100644 src/WorkboxPlugin/ExpirationPlugin.php create mode 100644 src/WorkboxPlugin/RangeRequestsPlugin.php 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/phpstan-baseline.neon b/phpstan-baseline.neon index b0262d2..1df498e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,35 @@ parameters: ignoreErrors: + - + message: "#^Parameter \\#1 \\$name of static method SpomkyLabs\\\\PwaBundle\\\\CachingStrategy\\\\WorkboxCacheStrategy\\:\\:create\\(\\) expects string, string\\|null given\\.$#" + count: 1 + path: src/CachingStrategy/AssetCache.php + + - + message: "#^Parameter \\#8 \\$preloadUrls of static method SpomkyLabs\\\\PwaBundle\\\\CachingStrategy\\\\WorkboxCacheStrategy\\:\\:create\\(\\) expects array\\, mixed given\\.$#" + count: 1 + path: src/CachingStrategy/AssetCache.php + + - + message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" + count: 1 + path: src/CachingStrategy/FontCache.php + + - + message: "#^Parameter \\#8 \\$preloadUrls of static method SpomkyLabs\\\\PwaBundle\\\\CachingStrategy\\\\WorkboxCacheStrategy\\:\\:create\\(\\) expects array\\, mixed given\\.$#" + count: 1 + path: src/CachingStrategy/FontCache.php + + - + message: "#^Parameter \\#8 \\$preloadUrls of static method SpomkyLabs\\\\PwaBundle\\\\CachingStrategy\\\\WorkboxCacheStrategy\\:\\:create\\(\\) expects array\\, mixed given\\.$#" + count: 1 + path: src/CachingStrategy/ResourceCaches.php + + - + message: "#^Part \\$this\\-\\>options\\['networkTimeoutSeconds'\\] \\(mixed\\) of encapsed string cannot be cast to string\\.$#" + count: 1 + path: src/CachingStrategy/WorkboxCacheStrategy.php + - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" count: 1 @@ -91,22 +121,22 @@ parameters: path: src/Dto/BackgroundSync.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$maxRetentionTime\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$matchCallback\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/BackgroundSync.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$method\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$maxRetentionTime\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/BackgroundSync.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$queueName\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$method\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/BackgroundSync.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$regex\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$queueName\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/BackgroundSync.php @@ -176,12 +206,12 @@ parameters: path: src/Dto/Manifest.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache has an uninitialized property \\$cacheName\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache has an uninitialized property \\$matchCallback\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/PageCache.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache has an uninitialized property \\$regex\\. Give it default value or assign it in the constructor\\.$#" + message: "#^PHPDoc tag @var for property SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache\\:\\:\\$cacheableResponseHeaders with type array\\\\|null is not subtype of native type array\\.$#" count: 1 path: src/Dto/PageCache.php @@ -346,7 +376,7 @@ parameters: path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$pageCaches\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$resourceCaches\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php @@ -492,7 +522,7 @@ parameters: - message: "#^Anonymous function should return array but returns mixed\\.$#" - count: 6 + count: 8 path: src/Resources/config/definition/service_worker.php - @@ -500,6 +530,11 @@ parameters: count: 1 path: src/Resources/config/definition/service_worker.php + - + message: "#^Cannot access an offset on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + - message: "#^Cannot access offset 'asset_cache' on mixed\\.$#" count: 2 @@ -560,11 +595,31 @@ parameters: count: 1 path: src/Resources/config/definition/service_worker.php + - + message: "#^Cannot access offset 'network_timeout…' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'page_cache_name' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'resource_caches' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + - message: "#^Cannot access offset 'static_regex' on mixed\\.$#" count: 1 path: src/Resources/config/definition/service_worker.php + - + message: "#^Cannot access offset 'warm_cache_urls' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + - message: "#^Strict comparison using \\!\\=\\= between mixed and null will always evaluate to true\\.$#" count: 3 @@ -626,24 +681,14 @@ parameters: 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\\.$#" + message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, 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\\{maxAge\\: int\\|null, maxEntries\\: int\\|null\\} given\\.$#" - count: 1 - path: src/Service/Rule/GoogleFontCache.php + path: src/ServiceWorkerRule/AppendCacheStrategies.php - message: "#^Strict comparison using \\=\\=\\= between int\\<1, max\\> and 0 will always evaluate to false\\.$#" count: 1 - path: src/Service/Rule/OfflineFallback.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/PageCaches.php + path: src/ServiceWorkerRule/OfflineFallback.php - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\SpomkyLabsPwaBundle\\:\\:loadExtension\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" @@ -658,4 +703,19 @@ parameters: - message: "#^Property SpomkyLabs\\\\PwaBundle\\\\Subscriber\\\\PwaDevServerSubscriber\\:\\:\\$jsonOptions type has no value type specified in iterable type array\\.$#" count: 1 - path: src/Subscriber/PwaDevServerSubscriber.php \ No newline at end of file + path: src/Subscriber/PwaDevServerSubscriber.php + + - + message: "#^Part \\$broadcastChannel \\(mixed\\) of encapsed string cannot be cast to string\\.$#" + count: 1 + path: src/WorkboxPlugin/BackgroundSyncPlugin.php + + - + message: "#^Part \\$maxRetentionTime \\(mixed\\) of encapsed string cannot be cast to string\\.$#" + count: 1 + path: src/WorkboxPlugin/BackgroundSyncPlugin.php + + - + message: "#^Part \\$queueName \\(mixed\\) of encapsed string cannot be cast to string\\.$#" + count: 2 + path: src/WorkboxPlugin/BackgroundSyncPlugin.php \ No newline at end of file diff --git a/src/CachingStrategy/AssetCache.php b/src/CachingStrategy/AssetCache.php new file mode 100644 index 0000000..8cd8f5a --- /dev/null +++ b/src/CachingStrategy/AssetCache.php @@ -0,0 +1,84 @@ +workbox = $serviceWorker->workbox; + $this->assetPublicPrefix = rtrim($publicAssetsPathResolver->resolvePublicPath(''), '/'); + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; + if ($debug === true) { + $options |= JSON_PRETTY_PRINT; + } + $this->jsonOptions = $options; + } + + public function getCacheStrategies(): array + { + $urls = json_decode($this->serializer->serialize($this->getAssets(), 'json', [ + JsonEncode::OPTIONS => $this->jsonOptions, + ]), true); + return [ + WorkboxCacheStrategy::create( + $this->workbox->assetCache->cacheName, + CacheStrategy::STRATEGY_CACHE_FIRST, + sprintf("({url}) => url.pathname.startsWith('%s')", $this->assetPublicPrefix), + $this->workbox->enabled && $this->workbox->assetCache->enabled, + true, + null, + [ + ExpirationPlugin::create( + count($this->getAssets()) * 2, + $this->workbox->assetCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + ), + ], + $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/CachingStrategy/BackgroundSync.php b/src/CachingStrategy/BackgroundSync.php new file mode 100644 index 0000000..9316a8e --- /dev/null +++ b/src/CachingStrategy/BackgroundSync.php @@ -0,0 +1,66 @@ + $matchCallbackHandlers + */ + public function __construct( + ServiceWorker $serviceWorker, + #[TaggedIterator('spomky_labs_pwa.match_callback_handler')] + private iterable $matchCallbackHandlers, + ) { + $this->workbox = $serviceWorker->workbox; + } + + /** + * @return array + */ + public function getCacheStrategies(): array + { + $strategies = []; + foreach ($this->workbox->backgroundSync as $sync) { + $strategies[] = WorkboxCacheStrategy::create( + 'BackgroundSync API', + CacheStrategy::STRATEGY_NETWORK_ONLY, + $this->prepareMatchCallback($sync->matchCallback), + $this->workbox->enabled, + true, + null, + [ + BackgroundSyncPlugin::create( + $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/CachingStrategy/CacheStrategy.php b/src/CachingStrategy/CacheStrategy.php new file mode 100644 index 0000000..bc4f459 --- /dev/null +++ b/src/CachingStrategy/CacheStrategy.php @@ -0,0 +1,35 @@ +workbox = $serviceWorker->workbox; + $options = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; + if ($debug === true) { + $options |= JSON_PRETTY_PRINT; + } + $this->jsonOptions = $options; + } + + 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 [ + WorkboxCacheStrategy::create( + $this->workbox->fontCache->cacheName ?? 'fonts', + CacheStrategy::STRATEGY_CACHE_FIRST, + "({request}) => request.destination === 'font'", + $this->workbox->enabled && $this->workbox->fontCache->enabled, + true, + null, + [ + ExpirationPlugin::create( + $maxEntries, + $this->workbox->fontCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + ), + CacheableResponsePlugin::create(), + ], + $urls + ), + ]; + } + + /** + * @return array + */ + 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/CachingStrategy/GoogleFontCache.php b/src/CachingStrategy/GoogleFontCache.php new file mode 100644 index 0000000..12db19e --- /dev/null +++ b/src/CachingStrategy/GoogleFontCache.php @@ -0,0 +1,54 @@ +workbox = $serviceWorker->workbox; + } + + public function getCacheStrategies(): array + { + $prefix = $this->workbox->googleFontCache->cachePrefix ?? ''; + if ($prefix !== '') { + $prefix .= '-'; + } + + return [ + WorkboxCacheStrategy::create( + $prefix . 'google-fonts-stylesheets', + CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE, + "({url}) => 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, + true, + null, + [ + CacheableResponsePlugin::create(), + ExpirationPlugin::create( + $this->workbox->googleFontCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 365, + $this->workbox->googleFontCache->maxEntries ?? 30 + ), + ] + ), + ]; + } +} diff --git a/src/Service/HasCacheStrategies.php b/src/CachingStrategy/HasCacheStrategies.php similarity index 77% rename from src/Service/HasCacheStrategies.php rename to src/CachingStrategy/HasCacheStrategies.php index be5f89d..0444ec5 100644 --- a/src/Service/HasCacheStrategies.php +++ b/src/CachingStrategy/HasCacheStrategies.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SpomkyLabs\PwaBundle\Service; +namespace SpomkyLabs\PwaBundle\CachingStrategy; interface HasCacheStrategies { diff --git a/src/CachingStrategy/ImageCache.php b/src/CachingStrategy/ImageCache.php new file mode 100644 index 0000000..b5809e3 --- /dev/null +++ b/src/CachingStrategy/ImageCache.php @@ -0,0 +1,52 @@ +workbox = $serviceWorker->workbox; + $this->assetPublicPrefix = rtrim($publicAssetsPathResolver->resolvePublicPath(''), '/'); + } + + public function getCacheStrategies(): array + { + return [ + WorkboxCacheStrategy::create( + $this->workbox->imageCache->cacheName ?? 'images', + CacheStrategy::STRATEGY_CACHE_FIRST, + sprintf( + "({request, url}) => (request.destination === 'image' && !url.pathname.startsWith('%s'))", + $this->assetPublicPrefix + ), + $this->workbox->enabled && $this->workbox->imageCache->enabled, + true, + null, + [ + CacheableResponsePlugin::create(), + ExpirationPlugin::create( + $this->workbox->imageCache->maxEntries ?? 60, + $this->workbox->imageCache->maxAgeInSeconds() ?? 60 * 60 * 24 * 7 + ), + ] + ), + ]; + } +} diff --git a/src/Service/Rule/ManifestCache.php b/src/CachingStrategy/ManifestCache.php similarity index 52% rename from src/Service/Rule/ManifestCache.php rename to src/CachingStrategy/ManifestCache.php index 47de81f..3fa985a 100644 --- a/src/Service/Rule/ManifestCache.php +++ b/src/CachingStrategy/ManifestCache.php @@ -2,16 +2,13 @@ declare(strict_types=1); -namespace SpomkyLabs\PwaBundle\Service\Rule; +namespace SpomkyLabs\PwaBundle\CachingStrategy; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Dto\Workbox; -use SpomkyLabs\PwaBundle\Service\CacheStrategy; -use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; 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; @@ -26,31 +23,10 @@ public function __construct( $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); } - public function process(string $body): string - { - if ($this->workbox->enabled === false) { - return $body; - } - if ($this->workbox->cacheManifest === false) { - return $body; - } - - $declaration = << '{$this->manifestPublicUrl}' === url.pathname, - new workbox.strategies.StaleWhileRevalidate({ - cacheName: 'manifest' - }) -); -IMAGE_CACHE_RULE_STRATEGY; - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } - 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/CachingStrategy/ResourceCaches.php b/src/CachingStrategy/ResourceCaches.php new file mode 100644 index 0000000..6ccb90a --- /dev/null +++ b/src/CachingStrategy/ResourceCaches.php @@ -0,0 +1,104 @@ + $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 = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; + if ($debug === true) { + $options |= JSON_PRETTY_PRINT; + } + $this->jsonOptions = $options; + } + + public function getCacheStrategies(): array + { + $strategies = []; + foreach ($this->workbox->resourceCaches as $id => $resourceCache) { + $routes = $this->serializer->serialize($resourceCache->urls, 'json', [ + JsonEncode::OPTIONS => $this->jsonOptions, + ]); + $url = json_decode($routes, true, 512, JSON_THROW_ON_ERROR); + + $cacheName = $resourceCache->cacheName ?? sprintf('page-cache-%d', $id); + + $plugins = [ + CacheableResponsePlugin::create( + $resourceCache->cacheableResponseStatuses, + $resourceCache->cacheableResponseHeaders + ), + ]; + if ($resourceCache->broadcast === true && $resourceCache->strategy === CacheStrategy::STRATEGY_STALE_WHILE_REVALIDATE) { + $plugins[] = BroadcastUpdatePlugin::create($resourceCache->broadcastHeaders); + } + if ($resourceCache->rangeRequests === true && $resourceCache->strategy !== CacheStrategy::STRATEGY_NETWORK_ONLY) { + $plugins[] = RangeRequestsPlugin::create(); + } + if ($resourceCache->maxEntries !== null || $resourceCache->maxAgeInSeconds() !== null) { + $plugins[] = ExpirationPlugin::create($resourceCache->maxEntries, $resourceCache->maxAgeInSeconds()); + } + + $strategies[] = + WorkboxCacheStrategy::create( + $cacheName, + $resourceCache->strategy, + $this->prepareMatchCallback($resourceCache->matchCallback), + $this->workbox->enabled, + true, + null, + $plugins, + $url, + [ + 'networkTimeoutSeconds' => $resourceCache->networkTimeout, + ] + ); + } + + 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/CachingStrategy/WorkboxCacheStrategy.php b/src/CachingStrategy/WorkboxCacheStrategy.php new file mode 100644 index 0000000..892fb31 --- /dev/null +++ b/src/CachingStrategy/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 = sprintf("cacheName: '%s',", $this->name ?? $cacheObjectName); + } + $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); + } +} diff --git a/src/Command/ListCacheStrategiesCommand.php b/src/Command/ListCacheStrategiesCommand.php index 2bf853f..f20efd2 100644 --- a/src/Command/ListCacheStrategiesCommand.php +++ b/src/Command/ListCacheStrategiesCommand.php @@ -4,7 +4,9 @@ namespace SpomkyLabs\PwaBundle\Command; -use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\CachingStrategy\HasCacheStrategies; +use SpomkyLabs\PwaBundle\CachingStrategy\WorkboxCacheStrategy; +use SpomkyLabs\PwaBundle\WorkboxPlugin\CachePlugin; 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->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..f11b118 --- /dev/null +++ b/src/Dto/Cache.php @@ -0,0 +1,40 @@ +maxAge === null) { + return null; + } + if (is_string($this->maxAge)) { + $now = new DateTimeImmutable(); + $interval = DateInterval::createFromDateString($this->maxAge); + if ($interval === false) { + throw new InvalidArgumentException('Invalid max age'); + } + $future = $now->add($interval); + 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/Dto/Workbox.php b/src/Dto/Workbox.php index 0e25b10..be21a54 100644 --- a/src/Dto/Workbox.php +++ b/src/Dto/Workbox.php @@ -30,8 +30,8 @@ final class Workbox /** * @var array */ - #[SerializedName('page_caches')] - public array $pageCaches; + #[SerializedName('resource_caches')] + public array $resourceCaches; #[SerializedName('asset_cache')] public AssetCache $assetCache; diff --git a/src/MatchCallbackHandler/DestinationMatchCallbackHandler.php b/src/MatchCallbackHandler/DestinationMatchCallbackHandler.php new file mode 100644 index 0000000..f97c9e8 --- /dev/null +++ b/src/MatchCallbackHandler/DestinationMatchCallbackHandler.php @@ -0,0 +1,18 @@ + request.destination === '%s'", trim(mb_substr($matchCallback, 12))); + } +} diff --git a/src/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php b/src/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php new file mode 100644 index 0000000..ae09686 --- /dev/null +++ b/src/MatchCallbackHandler/ExactPathnameMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname === '%s'", trim(mb_substr($matchCallback, 9))); + } +} diff --git a/src/MatchCallbackHandler/MatchCallbackHandler.php b/src/MatchCallbackHandler/MatchCallbackHandler.php new file mode 100644 index 0000000..0006374 --- /dev/null +++ b/src/MatchCallbackHandler/MatchCallbackHandler.php @@ -0,0 +1,12 @@ + request.mode === 'navigate'"; + } +} diff --git a/src/MatchCallbackHandler/OriginMatchCallbackHandler.php b/src/MatchCallbackHandler/OriginMatchCallbackHandler.php new file mode 100644 index 0000000..e0f49bf --- /dev/null +++ b/src/MatchCallbackHandler/OriginMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.origin === '%s'", trim(mb_substr($matchCallback, 7))); + } +} diff --git a/src/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php b/src/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php new file mode 100644 index 0000000..3aa64ac --- /dev/null +++ b/src/MatchCallbackHandler/PathnameEndsWithMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname.endsWith('%s')", trim(mb_substr($matchCallback, 9))); + } +} diff --git a/src/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php b/src/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php new file mode 100644 index 0000000..9857edb --- /dev/null +++ b/src/MatchCallbackHandler/PathnameStartsWithMatchCallbackHandler.php @@ -0,0 +1,18 @@ + url.pathname.startsWith('%s')", trim(mb_substr($matchCallback, 11))); + } +} diff --git a/src/MatchCallbackHandler/RegexMatchCallbackHandler.php b/src/MatchCallbackHandler/RegexMatchCallbackHandler.php new file mode 100644 index 0000000..3dbb669 --- /dev/null +++ b/src/MatchCallbackHandler/RegexMatchCallbackHandler.php @@ -0,0 +1,18 @@ +router->generate($routeName); + + return sprintf("({url}) => url.pathname === '%s'", $route); + } +} diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index 61e75d8..9aca1ea 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\CachingStrategy\CacheStrategy; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; return static function (DefinitionConfigurator $definition): void { @@ -94,6 +95,22 @@ return $v; }) ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['resource_caches'])) { + return $v; + } + $v['resource_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 +123,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 +218,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]) @@ -251,38 +268,84 @@ ->end() ->end() ->end() - ->arrayNode('page_caches') + ->arrayNode('resource_caches') ->treatNullLike([]) ->treatFalseLike([]) ->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" and "NetworkOnly" strategies).' ) ->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 maximum number of entries in the cache (for "CacheFirst" and "NetworkFirst" strategy only).' + ) + ->end() + ->scalarNode('max_age') + ->defaultNull() ->info( - 'The caching strategy. Only "networkFirst" and "staleWhileRevalidate" are supported.' + '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 +355,7 @@ ->defaultValue(['Content-Length', 'ETag', 'Last-Modified']) ->scalarPrototype()->end() ->end() - ->arrayNode('urls') + ->arrayNode('preload_urls') ->treatNullLike([]) ->treatFalseLike([]) ->treatTrueLike([]) @@ -337,9 +400,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') @@ -389,7 +452,7 @@ ->setDeprecated( 'spomky-labs/phpwa', '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_caches[].cache_name" instead.' + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.resource_caches[].cache_name" instead.' ) ->end() ->scalarNode('asset_cache_name') @@ -481,7 +544,7 @@ ->setDeprecated( 'spomky-labs/phpwa', '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_caches[].network_timeout" instead.' + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.resource_caches[].network_timeout" instead.' ) ->end() ->arrayNode('warm_cache_urls') @@ -492,7 +555,7 @@ ->setDeprecated( 'spomky-labs/phpwa', '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_caches[].urls" instead.' + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.resource_caches[].urls" instead.' ) ->arrayPrototype() ->beforeNormalization() diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 46f5903..1374b9f 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Facebook\WebDriver\WebDriverDimension; +use SpomkyLabs\PwaBundle\CachingStrategy\HasCacheStrategies; use SpomkyLabs\PwaBundle\Command\CreateIconsCommand; use SpomkyLabs\PwaBundle\Command\CreateScreenshotCommand; use SpomkyLabs\PwaBundle\Command\ListCacheStrategiesCommand; @@ -10,11 +11,11 @@ use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; -use SpomkyLabs\PwaBundle\Service\HasCacheStrategies; +use SpomkyLabs\PwaBundle\MatchCallbackHandler\MatchCallbackHandler; use SpomkyLabs\PwaBundle\Service\ManifestBuilder; -use SpomkyLabs\PwaBundle\Service\Rule\ServiceWorkerRule; use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; +use SpomkyLabs\PwaBundle\ServiceWorkerRule\ServiceWorkerRule; use SpomkyLabs\PwaBundle\Subscriber\ManifestCompileEventListener; use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; use SpomkyLabs\PwaBundle\Subscriber\ServiceWorkerCompileEventListener; @@ -112,8 +113,15 @@ $container->instanceof(ServiceWorkerRule::class) ->tag('spomky_labs_pwa.service_worker_rule') ; + $container->load('SpomkyLabs\\PwaBundle\\ServiceWorkerRule\\', '../../ServiceWorkerRule/*'); + $container->instanceof(HasCacheStrategies::class) ->tag('spomky_labs_pwa.cache_strategy') ; - $container->load('SpomkyLabs\\PwaBundle\\Service\\Rule\\', '../../Service/Rule/*'); + $container->load('SpomkyLabs\\PwaBundle\\CachingStrategy\\', '../../CachingStrategy/*'); + + $container->instanceof(MatchCallbackHandler::class) + ->tag('spomky_labs_pwa.match_callback_handler') + ; + $container->load('SpomkyLabs\\PwaBundle\\MatchCallbackHandler\\', '../../MatchCallbackHandler/*'); }; diff --git a/src/Service/CacheStrategy.php b/src/Service/CacheStrategy.php deleted file mode 100644 index 924454c..0000000 --- a/src/Service/CacheStrategy.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - private array $jsonOptions; - - private string $assetPublicPrefix; - - private Workbox $workbox; - - public function __construct( - ServiceWorker $serviceWorker, - #[Autowire(service: 'asset_mapper.public_assets_path_resolver')] - PublicAssetsPathResolverInterface $publicAssetsPathResolver, - private AssetMapperInterface $assetMapper, - private SerializerInterface $serializer, - #[Autowire('%kernel.debug%')] - bool $debug, - ) { - $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, - ]; - if ($debug === true) { - $options[JsonEncode::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); - return [ - CacheStrategy::create( - $this->workbox->assetCache->cacheName, - CacheStrategy::STRATEGY_CACHE_FIRST, - sprintf("'({url}) => url.pathname.startsWith('%s')'", $this->assetPublicPrefix), - $this->workbox->enabled && $this->workbox->assetCache->enabled, - true, - [ - '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 deleted file mode 100644 index 2abaf1b..0000000 --- a/src/Service/Rule/BackgroundSync.php +++ /dev/null @@ -1,88 +0,0 @@ -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); - } - - public function getCacheStrategies(): array - { - $strategies = []; - foreach ($this->workbox->backgroundSync as $sync) { - $strategies[] = CacheStrategy::create( - 'BackgroundSync API', - CacheStrategy::STRATEGY_NETWORK_ONLY, - $sync->regex, - $this->workbox->enabled, - true, - [ - 'plugins' => [ - sprintf('backgroundSync: "%s"', $sync->queueName), - sprintf('broadcastChannel: "%s"', $sync->broadcastChannel ?? '---'), - ], - ] - ); - } - - return $strategies; - } -} diff --git a/src/Service/Rule/FontCache.php b/src/Service/Rule/FontCache.php deleted file mode 100644 index 771349a..0000000 --- a/src/Service/Rule/FontCache.php +++ /dev/null @@ -1,115 +0,0 @@ - - */ - private array $jsonOptions; - - private Workbox $workbox; - - public function __construct( - ServiceWorker $serviceWorker, - private AssetMapperInterface $assetMapper, - 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 - { - 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 - { - return [ - CacheStrategy::create( - $this->workbox->fontCache->cacheName, - CacheStrategy::STRATEGY_CACHE_FIRST, - "'({request}) => request.destination === 'font'", - $this->workbox->enabled && $this->workbox->fontCache->enabled, - true, - [ - 'maxEntries' => $this->workbox->fontCache->maxEntries, - 'maxAge' => $this->workbox->fontCache->maxAge, - ], - ), - ]; - } -} diff --git a/src/Service/Rule/GoogleFontCache.php b/src/Service/Rule/GoogleFontCache.php deleted file mode 100644 index a5ccca1..0000000 --- a/src/Service/Rule/GoogleFontCache.php +++ /dev/null @@ -1,88 +0,0 @@ - - */ - 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 - { - if ($this->workbox->enabled === false) { - return $body; - } - 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 = <<workbox->enabled && $this->workbox->googleFontCache->enabled, - $this->workbox->googleFontCache->enabled, - [ - 'maxAge' => $this->workbox->googleFontCache->maxAge, - 'maxEntries' => $this->workbox->googleFontCache->maxEntries, - ] - ), - ]; - } -} diff --git a/src/Service/Rule/ImageCache.php b/src/Service/Rule/ImageCache.php deleted file mode 100644 index 3182595..0000000 --- a/src/Service/Rule/ImageCache.php +++ /dev/null @@ -1,76 +0,0 @@ -workbox = $serviceWorker->workbox; - $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, - CacheStrategy::STRATEGY_CACHE_FIRST, - sprintf( - "'({request, url}) => (request.destination === 'image' && !url.pathname.startsWith('%s'))'", - $this->assetPublicPrefix - ), - $this->workbox->enabled && $this->workbox->imageCache->enabled, - true, - [ - 'maxEntries' => $this->workbox->imageCache->maxEntries, - 'maxAge' => $this->workbox->imageCache->maxAge, - ] - ), - ]; - } -} diff --git a/src/Service/Rule/PageCaches.php b/src/Service/Rule/PageCaches.php deleted file mode 100644 index fb8356e..0000000 --- a/src/Service/Rule/PageCaches.php +++ /dev/null @@ -1,155 +0,0 @@ - - */ - 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 - { - 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); - $url = json_decode($routes, true, 512, JSON_THROW_ON_ERROR); - $strategies[] = - CacheStrategy::create( - $pageCache->cacheName, - $strategy, - $pageCache->regex, - $this->workbox->enabled, - true, - [ - 'maxTimeout' => $pageCache->networkTimeout, - 'plugins' => $plugins, - 'warmUrls' => $url, - ] - ); - } - - return $strategies; - } - - private function processPageCache(int $id, PageCache $pageCache, string $body): 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; - - return $body . PHP_EOL . PHP_EOL . trim($declaration); - } -} diff --git a/src/Service/Rule/ServiceWorkerRule.php b/src/Service/Rule/ServiceWorkerRule.php deleted file mode 100644 index 76c1154..0000000 --- a/src/Service/Rule/ServiceWorkerRule.php +++ /dev/null @@ -1,10 +0,0 @@ -serviceWorkerEnabled === false) { return null; } - $serviceWorker = $this->serviceWorker; + $body = ''; - if (! str_starts_with($serviceWorker->src->src, '/')) { - $asset = $this->assetMapper->getAsset($serviceWorker->src->src); + foreach ($this->serviceworkerRules as $rule) { + $body .= $rule->process(); + } + + 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/ServiceWorkerRule/AppendCacheStrategies.php b/src/ServiceWorkerRule/AppendCacheStrategies.php new file mode 100644 index 0000000..01d621d --- /dev/null +++ b/src/ServiceWorkerRule/AppendCacheStrategies.php @@ -0,0 +1,54 @@ + $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 = ''; + foreach ($this->cacheStrategies as $idCacheStrategy => $cacheStrategy) { + foreach ($cacheStrategy->getCacheStrategies() as $idStrategy => $strategy) { + if ($strategy->enabled === false) { + continue; + } + + $body .= PHP_EOL . trim($strategy->render( + sprintf('cache_%d_%d', $idCacheStrategy, $idStrategy), + $this->jsonOptions + )); + } + } + + return $body; + } +} diff --git a/src/Service/Rule/ClearCache.php b/src/ServiceWorkerRule/ClearCache.php similarity index 78% rename from src/Service/Rule/ClearCache.php rename to src/ServiceWorkerRule/ClearCache.php index d41f25e..d11b0a0 100644 --- a/src/Service/Rule/ClearCache.php +++ b/src/ServiceWorkerRule/ClearCache.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace SpomkyLabs\PwaBundle\Service\Rule; +namespace SpomkyLabs\PwaBundle\ServiceWorkerRule; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Dto\Workbox; -use const PHP_EOL; final readonly class ClearCache implements ServiceWorkerRule { @@ -18,13 +17,13 @@ public function __construct( $this->workbox = $serviceWorker->workbox; } - public function process(string $body): string + public function process(): string { if ($this->workbox->enabled === false) { - return $body; + return ''; } if ($this->workbox->clearCache === false) { - return $body; + return ''; } $declaration = <<jsonOptions = $options; } - public function process(string $body): string + public function process(): string { if ($this->workbox->enabled === false || ! isset($this->workbox->offlineFallback)) { - return $body; + return ''; } $options = [ 'pageFallback' => $this->workbox->offlineFallback->pageFallback, @@ -56,7 +55,7 @@ public function process(string $body): string ]; $options = array_filter($options, static fn (mixed $v): bool => $v !== null); if (count($options) === 0) { - return $body; + return ''; } $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); @@ -65,6 +64,6 @@ public function process(string $body): string workbox.recipes.offlineFallback({$options}); OFFLINE_FALLBACK_STRATEGY; - return $body . PHP_EOL . PHP_EOL . trim($declaration); + return trim($declaration); } } diff --git a/src/ServiceWorkerRule/ServiceWorkerRule.php b/src/ServiceWorkerRule/ServiceWorkerRule.php new file mode 100644 index 0000000..64f0a83 --- /dev/null +++ b/src/ServiceWorkerRule/ServiceWorkerRule.php @@ -0,0 +1,10 @@ +serviceWorker->skipWaiting === false) { - return $body; + return ''; } $declaration = <<manifest->widgets as $widget) { @@ -30,7 +29,7 @@ public function process(string $body): string } } if (count($tags) === 0) { - return $body; + return ''; } $data = $this->serializer->serialize($tags, 'json', [ JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, @@ -96,6 +95,6 @@ public function process(string $body): string } OFFLINE_FALLBACK_STRATEGY; - return $body . PHP_EOL . PHP_EOL . trim($declaration); + return trim($declaration); } } diff --git a/src/Service/Rule/WorkboxImport.php b/src/ServiceWorkerRule/WorkboxImport.php similarity index 78% rename from src/Service/Rule/WorkboxImport.php rename to src/ServiceWorkerRule/WorkboxImport.php index d424c19..5ec58cf 100644 --- a/src/Service/Rule/WorkboxImport.php +++ b/src/ServiceWorkerRule/WorkboxImport.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace SpomkyLabs\PwaBundle\Service\Rule; +namespace SpomkyLabs\PwaBundle\ServiceWorkerRule; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Dto\Workbox; -use const PHP_EOL; final readonly class WorkboxImport implements ServiceWorkerRule { @@ -18,10 +17,10 @@ public function __construct( $this->workbox = $serviceWorker->workbox; } - public function process(string $body): string + public function process(): string { if ($this->workbox->enabled === false) { - return $body; + return ''; } if ($this->workbox->useCDN === true) { $declaration = <<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 = << $queueName, + 'maxRetentionTime' => $maxRetentionTime, + 'forceSyncFallback' => $forceSyncFallback, + 'broadcastChannel' => $broadcastChannel, + ] + ); + } +} diff --git a/src/WorkboxPlugin/BroadcastUpdatePlugin.php b/src/WorkboxPlugin/BroadcastUpdatePlugin.php new file mode 100644 index 0000000..c8e527b --- /dev/null +++ b/src/WorkboxPlugin/BroadcastUpdatePlugin.php @@ -0,0 +1,28 @@ +options, $jsonOptions) + ); + } + + /** + * @param array $headersToCheck + */ + public static function create(array $headersToCheck = []): static + { + $headersToCheck = $headersToCheck === [] ? ['Content-Type', 'ETag', 'Last-Modified'] : $headersToCheck; + + return new self('BroadcastUpdatePlugin', [ + 'headersToCheck' => $headersToCheck, + ]); + } +} diff --git a/src/WorkboxPlugin/CachePlugin.php b/src/WorkboxPlugin/CachePlugin.php new file mode 100644 index 0000000..cba3e23 --- /dev/null +++ b/src/WorkboxPlugin/CachePlugin.php @@ -0,0 +1,19 @@ + $options + */ + public function __construct( + public string $name, + public array $options = [] + ) { + } + + abstract public function render(int $jsonOptions = 0): string; +} diff --git a/src/WorkboxPlugin/CacheableResponsePlugin.php b/src/WorkboxPlugin/CacheableResponsePlugin.php new file mode 100644 index 0000000..d0b5096 --- /dev/null +++ b/src/WorkboxPlugin/CacheableResponsePlugin.php @@ -0,0 +1,33 @@ +options, $jsonOptions) + ); + } + + /** + * @param array $statuses + * @param array $headers + */ + public static function create(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 self('CacheableResponsePlugin', $options); + } +} diff --git a/src/WorkboxPlugin/ExpirationPlugin.php b/src/WorkboxPlugin/ExpirationPlugin.php new file mode 100644 index 0000000..d260098 --- /dev/null +++ b/src/WorkboxPlugin/ExpirationPlugin.php @@ -0,0 +1,21 @@ +options, $jsonOptions)); + } + + public static function create(null|int $maxEntries, null|string|int $maxAgeSeconds): static + { + return new self('ExpirationPlugin', [ + 'maxEntries' => $maxEntries, + 'maxAgeSeconds' => $maxAgeSeconds, + ]); + } +} diff --git a/src/WorkboxPlugin/RangeRequestsPlugin.php b/src/WorkboxPlugin/RangeRequestsPlugin.php new file mode 100644 index 0000000..42a0c40 --- /dev/null +++ b/src/WorkboxPlugin/RangeRequestsPlugin.php @@ -0,0 +1,18 @@ + '/', 'use_cache' => true, 'workbox' => [ - 'page_caches' => [ + 'resource_caches' => [ [ - 'regex' => '.*', - 'strategy' => 'staleWhileRevalidate', + 'match_callback' => 'regex:.*', + 'strategy' => 'StaleWhileRevalidate', 'cache_name' => 'page-cache', 'broadcast' => true, - 'urls' => ['privacy_policy', 'terms_of_service'], + 'preload_urls' => ['privacy_policy', 'terms_of_service'], ], ], 'offline_fallback' => [