diff --git a/rector.php b/rector.php index 82ecc9f..9cc1e1b 100644 --- a/rector.php +++ b/rector.php @@ -3,26 +3,24 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Core\ValueObject\PhpVersion; use Rector\Doctrine\Set\DoctrineSetList; -use Rector\PHPUnit\Set\PHPUnitLevelSetList; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; -use Rector\Symfony\Set\SymfonyLevelSetList; use Rector\Symfony\Set\SymfonySetList; +use Rector\ValueObject\PhpVersion; return static function (RectorConfig $config): void { $config->sets([ SetList::DEAD_CODE, LevelSetList::UP_TO_PHP_82, - SymfonyLevelSetList::UP_TO_SYMFONY_63, + //SymfonySetList::SYMFONY_63, SymfonySetList::SYMFONY_CODE_QUALITY, SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION, DoctrineSetList::DOCTRINE_CODE_QUALITY, DoctrineSetList::DOCTRINE_ORM_214, DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES, - PHPUnitLevelSetList::UP_TO_PHPUNIT_100, + //PHPUnitSetList::PHPUNIT_100, PHPUnitSetList::PHPUNIT_CODE_QUALITY, PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, ]); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 3ddb1d4..c275015 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -453,10 +453,31 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->example('/sw.js') ->end() ->scalarNode('precaching_placeholder') - ->defaultValue('self.__WB_MANIFEST') + ->defaultValue('//PRECACHING_PLACEHOLDER') ->info('The placeholder for the precaching. Will be replaced by the assets and versions.') - ->example('self.__WB_MANIFEST') + ->example('//PRECACHING_PLACEHOLDER') ->end() + ->scalarNode('warm_cache_placeholder') + ->defaultValue('//WARM_CACHE_URLS_PLACEHOLDER') + ->info('The placeholder for the warm cache. Will be replaced by the URLs.') + ->example('//WARM_CACHE_URLS_PLACEHOLDER') + ->end() + ->scalarNode('offline_fallback_placeholder') + ->defaultValue('//OFFLINE_FALLBACK_PLACEHOLDER') + ->info('The placeholder for the offline fallback. Will be replaced by the URL.') + ->example('//OFFLINE_FALLBACK_PLACEHOLDER') + ->end() + ->scalarNode('widgets_placeholder') + ->defaultValue('//WIDGETS_PLACEHOLDER') + ->info('The placeholder for the widgets. Will be replaced by the widgets management events.') + ->example('//WIDGETS_PLACEHOLDER') + ->end() + ->append( + $this->getUrlNode( + 'offline_fallback', + 'The URL of the offline fallback. If not set, the offline fallback will be disabled.' + ) + ) ->scalarNode('scope') ->cannotBeEmpty() ->defaultValue('/') @@ -467,6 +488,34 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->defaultTrue() ->info('Whether the service worker should use the cache.') ->end() + ->arrayNode('warm_cache_urls') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->info('The URLs to warm the cache. The URLs shall be served by the application.') + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) + ->end() + ->children() + ->scalarNode('path') + ->isRequired() + ->info('The URL of the shortcut.') + ->example('app_homepage') + ->end() + ->arrayNode('params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action.') + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->end(); @@ -618,7 +667,7 @@ private function getUrlNode(string $name, string $info, null|array $examples = n ->children() ->scalarNode('path') ->isRequired() - ->info('The URL of the shortcut.') + ->info('The URL or route name.') ->example($examples ?? ['https://example.com', 'app_action_route', '/do/action']) ->end() ->arrayNode('params') diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php index 571cbe3..6e7dbdc 100644 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -39,17 +39,12 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); $container->setParameter('spomky_labs_pwa.manifest_public_url', $config['manifest_public_url']); $container->setParameter('spomky_labs_pwa.sw_public_url', $config['serviceworker']['dest'] ?? null); - $container->setParameter( - 'spomky_labs_pwa.serviceworker.precaching_placeholder', - $config['serviceworker']['precaching_placeholder'] ?? 'self.__WB_MANIFEST' - ); unset( $config['image_processor'], $config['web_client'], $config['path_type_reference'], $config['manifest_public_url'], - $config['serviceworker']['precaching_placeholder'], ); $container->setParameter('spomky_labs_pwa.config', $config); if (! in_array($container->getParameter('kernel.environment'), ['dev', 'test'], true)) { diff --git a/src/Dto/ServiceWorker.php b/src/Dto/ServiceWorker.php index e589a00..b81a5fd 100644 --- a/src/Dto/ServiceWorker.php +++ b/src/Dto/ServiceWorker.php @@ -16,4 +16,25 @@ final class ServiceWorker #[SerializedName('use_cache')] public null|bool $useCache = null; + + #[SerializedName('warm_cache_placeholder')] + public string $warmCachePlaceholder; + + #[SerializedName('precaching_placeholder')] + public string $precachingPlaceholder; + + #[SerializedName('offline_fallback_placeholder')] + public string $offlineFallbackPlaceholder; + + #[SerializedName('widgets_placeholder')] + public string $widgetsPlaceholder; + + #[SerializedName('offline_fallback')] + public null|Url $offlineFallback = null; + + #[SerializedName('warm_cache_urls')] + /** + * @var array + */ + public array $warmCacheUrls = []; } diff --git a/src/Resources/workbox.js b/src/Resources/workbox.js index 5b9f3e2..e154ffb 100644 --- a/src/Resources/workbox.js +++ b/src/Resources/workbox.js @@ -2,69 +2,28 @@ importScripts( 'https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js' ); -const { - pageCache, // Cache pages with a network-first strategy. - imageCache, // Cache images with a cache-first strategy. - staticResourceCache, // Cache CSS, JS, and Web Worker requests with a cache-first strategy for 1 year. - offlineFallback, // Serve an offline fallback page when the user is offline and try to revalidate the request when the user is online. - warmStrategyCache, // Warm the cache with URLs that are likely to be visited next or during offline navigation. -} = workbox.recipes; -const { CacheFirst } = workbox.strategies; -const { precacheAndRoute } = workbox.precaching; -const { registerRoute } = workbox.routing; -const { CacheableResponsePlugin } = workbox.cacheableResponse; -const { ExpirationPlugin } = workbox.expiration; - -const PAGE_CACHE_NAME = 'pages'; -const FONT_CACHE_NAME = 'fonts'; -const STATIC_CACHE_NAME = 'assets'; -const IMAGE_CACHE_NAME = 'images'; -const OFFLINE_URI = '/offline'; // URI of the offline fallback page. -const warmCacheUrls = [ // URLs to warm the cache with. - '/', -]; - // *** Recipes *** -// Cache pages with a network-first strategy. -pageCache({ - cacheName: PAGE_CACHE_NAME -}); -// Cache CSS, JS, and Web Worker requests with a cache-first strategy. -staticResourceCache({ - cacheName: STATIC_CACHE_NAME, -}); -// Cache images with a cache-first strategy. -imageCache({ - cacheName: IMAGE_CACHE_NAME, - maxEntries: 60, // Default 60 images - maxAgeSeconds: 60 * 60 * 24 * 30, // Default 30 days -}); -// Serve an offline fallback page when the user is offline and try to revalidate the request when the user is online. -offlineFallback({ - pageFallback: OFFLINE_URI, -}); +// You are free to change or remove any of these presets as you wish. +// See https://developer.chrome.com/docs/workbox/modules/workbox-recipes for more information. -// Cache the underlying font files with a cache-first strategy. -registerRoute( - ({request}) => request.destination === 'font', - new CacheFirst({ - cacheName: FONT_CACHE_NAME, - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - }), - new ExpirationPlugin({ - maxAgeSeconds: 60 * 60 * 24 * 365, - maxEntries: 30, - }), - ], - }), -); +const { + pageCache, + imageCache, + staticResourceCache, + googleFontsCache, +} = workbox.recipes; -// This directive will be compiled and populated with asset routes and revisions -// At the moment, only static assets served by Asset Mapper are listed. -precacheAndRoute(self.__WB_MANIFEST); +// => Cache pages with a network-first strategy. +pageCache(); +// => Cache CSS, JS, and Web Worker requests with a cache-first strategy. +staticResourceCache(); +// => Cache images with a cache-first strategy. +imageCache(); +// => Cache the underlying font files with a cache-first strategy. +googleFontsCache(); -// Warm the cache with URLs that are likely to be visited next or during offline navigation. -const strategy = new CacheFirst(); -warmStrategyCache({urls: warmCacheUrls, strategy}); +// *** Bundle rules *** +//PRECACHING_PLACEHOLDER +//WARM_CACHE_URLS_PLACEHOLDER +//OFFLINE_FALLBACK_PLACEHOLDER +//WIDGETS_PLACEHOLDER diff --git a/src/Service/ServiceWorkerBuilder.php b/src/Service/ServiceWorkerBuilder.php index ef3b0bb..47625c6 100644 --- a/src/Service/ServiceWorkerBuilder.php +++ b/src/Service/ServiceWorkerBuilder.php @@ -6,9 +6,10 @@ use SpomkyLabs\PwaBundle\Dto\Manifest; use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Serializer\Encoder\JsonEncode; use Symfony\Component\Serializer\SerializerInterface; use function assert; +use function count; use function is_string; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; @@ -23,8 +24,6 @@ public function __construct( private SerializerInterface $serializer, private Manifest $manifest, private AssetMapperInterface $assetMapper, - #[Autowire('%spomky_labs_pwa.serviceworker.precaching_placeholder%')] - private string $precachingPlaceholder, ) { $serviceWorkerPublicUrl = $manifest->serviceWorker?->dest; $this->serviceWorkerPublicUrl = $serviceWorkerPublicUrl === null ? null : '/' . trim( @@ -51,12 +50,20 @@ public function build(): ?string $body = file_get_contents($serviceWorkerSource); } assert(is_string($body), 'Unable to find service worker source content'); - return $this->processPrecachedAssets($body); + $body = $this->processPrecachedAssets($body); + $body = $this->processWarmCacheUrls($body); + $body = $this->processWidgets($body); + + return $this->processOfflineFallback($body); } private function processPrecachedAssets(string $body): string { - if (! str_contains($body, $this->precachingPlaceholder)) { + $config = $this->manifest->serviceWorker; + if ($config === null) { + return $body; + } + if (! str_contains($body, $config->precachingPlaceholder)) { return $body; } $result = []; @@ -66,8 +73,172 @@ private function processPrecachedAssets(string $body): string 'revision' => $asset->digest, ]; } - return str_replace($this->precachingPlaceholder, $this->serializer->serialize($result, 'json', [ - 'json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]), $body); + $assets = $this->serializer->serialize($result, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + $precacheAndRouteDeclaration = str_contains( + $body, + 'precacheAndRoute' + ) ? '' : 'const { precacheAndRoute } = workbox.precaching;'; + + $declaration = <<precachingPlaceholder, trim($declaration), $body); + } + + private function processWarmCacheUrls(string $body): string + { + $config = $this->manifest->serviceWorker; + if ($config === null) { + return $body; + } + if (! str_contains($body, $config->warmCachePlaceholder)) { + return $body; + } + $urls = $config->warmCacheUrls; + if (count($urls) === 0) { + return $body; + } + + $routes = $this->serializer->serialize($urls, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $cacheFirstStrategyDeclaration = str_contains( + $body, + 'CacheFirst' + ) ? '' : 'const { CacheFirst } = workbox.strategies;'; + $warmStrategyCacheMethod = str_contains( + $body, + 'warmStrategyCache' + ) ? '' : 'const { warmStrategyCache } = workbox.recipes;'; + + $declaration = <<warmCachePlaceholder, trim($declaration), $body); + } + + private function processOfflineFallback(string $body): string + { + $config = $this->manifest->serviceWorker; + if ($config === null) { + return $body; + } + if (! str_contains($body, $config->offlineFallbackPlaceholder) || $config->offlineFallback === null) { + return $body; + } + + $url = $this->serializer->serialize($config->offlineFallback, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $offlineFallbackMethod = str_contains( + $body, + 'offlineFallback' + ) ? '' : 'const { offlineFallback } = workbox.recipes;'; + + $declaration = <<offlineFallbackPlaceholder, trim($declaration), $body); + } + + private function processWidgets(string $body): string + { + $config = $this->manifest->serviceWorker; + if ($config === null) { + return $body; + } + if (! str_contains($body, $config->widgetsPlaceholder)) { + return $body; + } + $tags = []; + foreach ($this->manifest->widgets as $widget) { + if ($widget->tag !== null) { + $tags[] = $widget->tag; + } + } + if (count($tags) === 0) { + return $body; + } + $data = $this->serializer->serialize($tags, 'json', [ + JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $declaration = << { + event.waitUntil(renderWidget(event.widget)); +}); +async function renderWidget(widget) { + const templateUrl = widget.definition.msAcTemplate; + const dataUrl = widget.definition.data; + const template = await (await fetch(templateUrl)).text(); + const data = await (await fetch(dataUrl)).text(); + await self.widgets.updateByTag(widget.definition.tag, {template, data}); +} + +self.addEventListener("widgetinstall", event => { + event.waitUntil(onWidgetInstall(event.widget)); +}); +async function onWidgetInstall(widget) { + const tags = await self.registration.periodicSync.getTags(); + if (!tags.includes(widget.definition.tag)) { + await self.registration.periodicSync.register(widget.definition.tag, { + minInterval: widget.definition.update + }); + } + await updateWidget(widget); +} + +self.addEventListener("widgetuninstall", event => { + event.waitUntil(onWidgetUninstall(event.widget)); +}); + +async function onWidgetUninstall(widget) { + if (widget.instances.length === 1 && "update" in widget.definition) { + await self.registration.periodicSync.unregister(widget.definition.tag); + } +} +self.addEventListener("periodicsync", async event => { + const widget = await self.widgets.getByTag(event.tag); + if (widget && "update" in widget.definition) { + event.waitUntil(renderWidget(widget)); + } +}); + +self.addEventListener("activate", event => { + event.waitUntil(updateWidgets()); +}); + +async function updateWidgets() { + const tags = {$data}; + if(!self.widgets || tags.length === 0) return; + for (const tag of tags) { + const widget = await self.widgets.getByTag(tag); + if (!widget) { + continue; + } + const template = await (await fetch(widget.definition.msAcTemplate)).text(); + const data = await (await fetch(widget.definition.data)).text(); + await self.widgets.updateByTag(widget.definition.tag, {template, data}); + } +} +OFFLINE_FALLBACK_STRATEGY; + + return str_replace($config->widgetsPlaceholder, trim($declaration), $body); } } diff --git a/src/Subscriber/AssetsCompileEventListener.php b/src/Subscriber/AssetsCompileEventListener.php index f05dfae..f442ac3 100644 --- a/src/Subscriber/AssetsCompileEventListener.php +++ b/src/Subscriber/AssetsCompileEventListener.php @@ -9,6 +9,7 @@ use Symfony\Component\AssetMapper\Path\PublicAssetsFilesystemInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\Serializer\Encoder\JsonEncode; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use const JSON_PRETTY_PRINT; @@ -37,7 +38,7 @@ public function __invoke(PreAssetsCompileEvent $event): void $data = $this->serializer->serialize($this->manifest, 'json', [ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - 'json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]); $this->assetsFilesystem->write($this->manifestPublicUrl, $data); } diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php index 3f6b830..e0469cb 100644 --- a/src/Subscriber/PwaDevServerSubscriber.php +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -13,6 +13,7 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\Profiler\Profiler; +use Symfony\Component\Serializer\Encoder\JsonEncode; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use const JSON_PRETTY_PRINT; @@ -83,7 +84,7 @@ private function serveManifest(RequestEvent $event): void $body = $this->serializer->serialize($this->manifest, 'json', [ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - 'json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]); $response = new Response($body, Response::HTTP_OK, [ 'Cache-Control' => 'public, max-age=604800, immutable',