From bd558a68cbb5aaad6b206babe6a4b0c402a7e61d Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Thu, 18 Jan 2024 11:33:34 +0100 Subject: [PATCH] Better SW (#36) --- src/DependencyInjection/Configuration.php | 19 ++++- .../SpomkyLabsPwaExtension.php | 6 ++ src/Dto/ServiceWorker.php | 4 +- src/Normalizer/ServiceWorkerNormalizer.php | 6 +- src/Resources/config/services.php | 4 + src/Resources/workbox.js | 5 ++ src/Service/ServiceWorkerBuilder.php | 73 +++++++++++++++++++ src/Subscriber/PwaDevServerSubscriber.php | 71 +++++++++++++----- .../ServiceWorkerCompileEventListener.php | 39 ++++++++++ 9 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 src/Service/ServiceWorkerBuilder.php create mode 100644 src/Subscriber/ServiceWorkerCompileEventListener.php diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2dfb2d4..6d179f1 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -476,12 +476,29 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->treatFalseLike([]) ->treatTrueLike([]) ->treatNullLike([]) + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'src' => $v, + ]) + ->end() ->children() ->scalarNode('src') ->isRequired() - ->info('The path to the service worker. Can be served by Asset Mapper.') + ->info('The path to the service worker source file. Can be served by Asset Mapper.') + ->example('script/sw.js') + ->end() + ->scalarNode('dest') + ->cannotBeEmpty() + ->defaultValue('/sw.js') + ->info('The public URL to the service worker.') ->example('/sw.js') ->end() + ->scalarNode('precaching_placeholder') + ->defaultValue('self.__WB_MANIFEST') + ->info('The placeholder for the precaching. Will be replaced by the assets and versions.') + ->example('self.__WB_MANIFEST') + ->end() ->scalarNode('scope') ->cannotBeEmpty() ->defaultValue('/') diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php index 403e267..571cbe3 100644 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -38,12 +38,18 @@ 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 335461e..e589a00 100644 --- a/src/Dto/ServiceWorker.php +++ b/src/Dto/ServiceWorker.php @@ -8,7 +8,9 @@ final class ServiceWorker { - public null|string $src = null; + public string $src; + + public string $dest; public null|string $scope = null; diff --git a/src/Normalizer/ServiceWorkerNormalizer.php b/src/Normalizer/ServiceWorkerNormalizer.php index 13d2e49..75d0639 100644 --- a/src/Normalizer/ServiceWorkerNormalizer.php +++ b/src/Normalizer/ServiceWorkerNormalizer.php @@ -20,11 +20,11 @@ public function normalize(mixed $object, string $format = null, array $context = { assert($object instanceof ServiceWorker); $url = null; - if (! str_starts_with($object->src, '/')) { - $url = $this->assetMapper->getAsset($object->src)?->publicPath; + if (! str_starts_with($object->dest, '/')) { + $url = $this->assetMapper->getAsset($object->dest)?->publicPath; } if ($url === null) { - $url = $object->src; + $url = $object->dest; } $result = [ diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index ee12194..2d6d52a 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -6,8 +6,10 @@ use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\Service\Builder; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; use SpomkyLabs\PwaBundle\Subscriber\AssetsCompileEventListener; use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; +use SpomkyLabs\PwaBundle\Subscriber\ServiceWorkerCompileEventListener; use SpomkyLabs\PwaBundle\Twig\PwaExtension; use SpomkyLabs\PwaBundle\Twig\PwaRuntime; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -52,6 +54,8 @@ } $container->set(AssetsCompileEventListener::class); + $container->set(ServiceWorkerCompileEventListener::class); + $container->set(ServiceWorkerBuilder::class); $container->set(PwaDevServerSubscriber::class) ->args([ diff --git a/src/Resources/workbox.js b/src/Resources/workbox.js index fbb9f6d..5b9f3e2 100644 --- a/src/Resources/workbox.js +++ b/src/Resources/workbox.js @@ -10,6 +10,7 @@ const { 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; @@ -60,6 +61,10 @@ registerRoute( }), ); +// 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); + // Warm the cache with URLs that are likely to be visited next or during offline navigation. const strategy = new CacheFirst(); warmStrategyCache({urls: warmCacheUrls, strategy}); diff --git a/src/Service/ServiceWorkerBuilder.php b/src/Service/ServiceWorkerBuilder.php new file mode 100644 index 0000000..ef3b0bb --- /dev/null +++ b/src/Service/ServiceWorkerBuilder.php @@ -0,0 +1,73 @@ +serviceWorker?->dest; + $this->serviceWorkerPublicUrl = $serviceWorkerPublicUrl === null ? null : '/' . trim( + $serviceWorkerPublicUrl, + '/' + ); + } + + public function build(): ?string + { + if ($this->serviceWorkerPublicUrl === null) { + return null; + } + $serviceWorkerSource = $this->manifest->serviceWorker?->src; + if ($serviceWorkerSource === null) { + return null; + } + + if (! str_starts_with($serviceWorkerSource, '/')) { + $asset = $this->assetMapper->getAsset($serviceWorkerSource); + assert($asset !== null, 'Unable to find service worker source asset'); + $body = $asset->content ?? file_get_contents($asset->sourcePath); + } else { + $body = file_get_contents($serviceWorkerSource); + } + assert(is_string($body), 'Unable to find service worker source content'); + return $this->processPrecachedAssets($body); + } + + private function processPrecachedAssets(string $body): string + { + if (! str_contains($body, $this->precachingPlaceholder)) { + return $body; + } + $result = []; + foreach ($this->assetMapper->allAssets() as $asset) { + $result[] = [ + 'url' => $asset->publicPath, + '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); + } +} diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php index 860bd6c..bfcb773 100644 --- a/src/Subscriber/PwaDevServerSubscriber.php +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -5,6 +5,7 @@ namespace SpomkyLabs\PwaBundle\Subscriber; use SpomkyLabs\PwaBundle\Dto\Manifest; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; @@ -23,7 +24,10 @@ { private string $manifestPublicUrl; + private null|string $serviceWorkerPublicUrl; + public function __construct( + private ServiceWorkerBuilder $serviceWorkerBuilder, private SerializerInterface $serializer, private Manifest $manifest, #[Autowire('%spomky_labs_pwa.manifest_public_url%')] @@ -31,6 +35,11 @@ public function __construct( private null|Profiler $profiler, ) { $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); + $serviceWorkerPublicUrl = $manifest->serviceWorker?->dest; + $this->serviceWorkerPublicUrl = $serviceWorkerPublicUrl === null ? null : '/' . trim( + $serviceWorkerPublicUrl, + '/' + ); } public function onKernelRequest(RequestEvent $event): void @@ -39,20 +48,43 @@ public function onKernelRequest(RequestEvent $event): void return; } - $pathInfo = $event->getRequest() - ->getPathInfo(); - if ($pathInfo !== $this->manifestPublicUrl) { - return; + switch ($event->getRequest()->getPathInfo()) { + case $this->manifestPublicUrl : + $this->serveManifest($event); + break; + case $this->serviceWorkerPublicUrl : + $this->serveServiceWorker($event); + break; } + } + public function onKernelResponse(ResponseEvent $event): void + { + $headers = $event->getResponse() +->headers; + if ($headers->has('X-Manifest-Dev') || $headers->has('X-SW-Dev')) { + $event->stopPropagation(); + } + } + + public static function getSubscribedEvents(): array + { + return [ + // priority higher than RouterListener + KernelEvents::REQUEST => [['onKernelRequest', 35]], + // Highest priority possible to bypass all other listeners + KernelEvents::RESPONSE => [['onKernelResponse', 2048]], + ]; + } + + private function serveManifest(RequestEvent $event): void + { + $this->profiler?->disable(); $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, ]); - - $this->profiler?->disable(); - $response = new Response($body, Response::HTTP_OK, [ 'Cache-Control' => 'public, max-age=604800, immutable', 'Content-Type' => 'application/manifest+json', @@ -64,20 +96,21 @@ public function onKernelRequest(RequestEvent $event): void $event->stopPropagation(); } - public function onKernelResponse(ResponseEvent $event): void + private function serveServiceWorker(RequestEvent $event): void { - if ($event->getResponse()->headers->get('X-Manifest-Dev')) { - $event->stopPropagation(); + $data = $this->serviceWorkerBuilder->build(); + if ($data === null) { + return; } - } + $this->profiler?->disable(); - public static function getSubscribedEvents(): array - { - return [ - // priority higher than RouterListener - KernelEvents::REQUEST => [['onKernelRequest', 35]], - // Highest priority possible to bypass all other listeners - KernelEvents::RESPONSE => [['onKernelResponse', 2048]], - ]; + $response = new Response($data, Response::HTTP_OK, [ + 'Content-Type' => 'application/manifest+json', + 'X-SW-Dev' => true, + 'Etag' => hash('xxh128', $data), + ]); + + $event->setResponse($response); + $event->stopPropagation(); } } diff --git a/src/Subscriber/ServiceWorkerCompileEventListener.php b/src/Subscriber/ServiceWorkerCompileEventListener.php new file mode 100644 index 0000000..5d5d92a --- /dev/null +++ b/src/Subscriber/ServiceWorkerCompileEventListener.php @@ -0,0 +1,39 @@ +serviceWorkerPublicUrl = $serviceWorkerPublicUrl === null ? null : '/' . trim( + $serviceWorkerPublicUrl, + '/' + ); + } + + public function __invoke(PreAssetsCompileEvent $event): void + { + $data = $this->serviceWorkerBuilder->build(); + if ($data === null || $this->serviceWorkerPublicUrl === null) { + return; + } + $this->assetsFilesystem->write($this->serviceWorkerPublicUrl, $data); + } +}