Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/service worker #9

Merged
merged 3 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
* text=auto

/.github export-ignore
/doc export-ignore
/tests export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.php_cs.dist export-ignore
/.scrutinizer.yml export-ignore
/.travis.yml export-ignore
/CODE_OF_CONDUCT.md export-ignore
/README.md export-ignore
/ecs.php export-ignore
/infection.json.dist export-ignore
/link export-ignore
/Makefile export-ignore
/phpstan.neon export-ignore
/phpstan-baseline.neon export-ignore
/phpunit.xml.dist export-ignore
/rector export-ignore
62 changes: 56 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ This project follows the [semantic versioning](http://semver.org/) strictly.

# Documentation

## Manifest Configuration
A Progressive Web Application (PWA) is a web application that has a manifest and can be installed on a device.
The manifest is a JSON file that describes the application and its assets (icons, screenshots, etc.).

A Service Worker can be used to provide offline capabilities to the application or to provide push notifications.

## Manifest

### Manifest Configuration

The bundle is able to generate a manifest from a configuration file.
The manifest members defined in the [Web app manifests](https://developer.mozilla.org/en-US/docs/Web/Manifest) are supported
Expand Down Expand Up @@ -57,7 +64,7 @@ phpwa:
'application/json': ['.json']
```

## Manifest Generation
### Manifest Generation

When the configuration is set, you can generate the manifest with the following command:

Expand Down Expand Up @@ -98,7 +105,7 @@ For instance, if your application root URL is https://example.com/foo/bar, set t
}
```

## Manifest Icons
### Manifest Icons

The bundle is able to generate icons from a source image.
The icons must be square and the source image should be at best quality as possible.
Expand All @@ -109,7 +116,7 @@ Depending on your system, you may have to install one extension or the other.
```yaml
# config/packages/phpwa.yaml
phpwa:
icon_processor: 'pwa.icon_processor.gd' # or 'pwa.icon_processor.imagick'
image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick'
icons:
- src: "%kernel.project_dir%/assets/images/logo.png"
sizes: [48, 57, 60, 72, 76, 96, 114, 128, 144, 152, 180, 192, 256, 384, 512, 1024]
Expand All @@ -126,14 +133,15 @@ With the configuration above, the bundle will generate
* 16 icons from the `mask.png` image. The format will be `png` and the purpose will be `maskable`.
* And 1 icon from the `logo.svg` image. The format will be `svg` and the size will be `any`.

## Manifest Screenshots
### Manifest Screenshots

The bundle is able to generate screenshots from a source image.
Similar to icons, the source image should be at best quality as possible.

```yaml
# config/packages/phpwa.yaml
phpwa:
image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick'
screenshots:
- src: "%kernel.project_dir%/assets/screenshots/narrow.png"
label: "View of the application home page"
Expand All @@ -149,14 +157,15 @@ The bundle will automatically generate screenshots from the source images and ad
such as the `sizes` and the `form_factor` (`wide` or `narrow`).
The `format` parameter is optional. It indicates the format of the generated image. If not set, the format will be the same as the source image.

## Manifest Shortcuts
### Manifest Shortcuts

The `shortcuts` member may contain a list of icons.
The parameters are very similar to the `icons` member.

```yaml
# config/packages/phpwa.yaml
phpwa:
image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick'
shortcuts:
- name: "Shortcut 1"
short_name: "shortcut-1"
Expand All @@ -176,6 +185,47 @@ phpwa:
format: 'webp'
```

### Using the Manifest

The manifest can be used in your HTML pages with the following code in the `<head>` section.
In you customized the output filename or the public folder, please replace `pwa.json` with the path to your manifest file.

```html
<link rel="manifest" href="{{ asset('pwa.json') }}">
```

## Service Worker

The following command will generate a Service Worker in the `public` directory.

```bash
symfony console pwa:sw
```

You can change the output file name and the output folder with the following options:

* `--output` or `-o` to change the output file name (default: `sw.js`)
* `--public_folder` or `-p` to change the public folder (default: `%kernel.project_dir%/public`)

Next, you have to register the Service Worker in your HTML pages with the following code in the `<head>` section.
It can also be done in a JavaScript file such as `app.js`.
In you customized the output filename or the public folder, please replace `sw.js` with the path to your Service Worker file.

```html
<script>
if (navigator.serviceWorker) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", {scope: '/'});
})
}
</script>
```

### Service Worker Configuration

The Service Worker uses Workbox and comes with predefined configuration and recipes.
You are free to change the configuration and the recipes to fit your needs.
In particular, you can change the cache strategy, the cache expiration, the cache name, etc.

# Support

Expand Down
68 changes: 68 additions & 0 deletions link
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env php
<?php

if (!file_exists(__DIR__.'/vendor/autoload.php')) {
echo "Run `composer install` before you run the `link` script.\n";
exit(1);
}

require __DIR__.'/vendor/autoload.php';

use Symfony\Component\Filesystem\Filesystem;

/**
* Links dependencies to components to a local clone of the main spomky-labs/phpwa GitHub repository.
* Inspired by symfony/symfony and async-aws/aws
*/

$copy = false !== $k = array_search('--copy', $argv, true);
$copy && array_splice($argv, $k, 1);
$pathToProject = $argv[1] ?? getcwd();

if (!is_dir("$pathToProject/vendor/spomky-labs/phpwa")) {
echo 'Link (or copy) dependencies to components to a local clone of the spomky-labs/phpwa GitHub repository.'.PHP_EOL.PHP_EOL;
echo "Usage: $argv[0] /path/to/the/project".PHP_EOL;
echo ' Use `--copy` to copy dependencies instead of symlink'.PHP_EOL.PHP_EOL;
echo "The directory \"$pathToProject\" does not exist or the dependencies are not installed, did you forget to run \"composer install\" in your project?".PHP_EOL;
exit(1);
}

$packages = [];

$filesystem = new Filesystem();
$directories = glob(__DIR__.'/src/*', GLOB_ONLYDIR | GLOB_NOSORT);

foreach ($directories as $dir) {
if ($filesystem->exists($composer = "$dir/composer.json")) {
$packages[json_decode(file_get_contents($composer))->name] = $dir;
}
}

if (is_dir("$pathToProject/vendor/spomky-labs/phpwa")) {
if ($filesystem->exists($composer = "$pathToProject/vendor/spomky-labs/phpwa/composer.json")) {
$packages[json_decode(file_get_contents($composer))->name] = realpath(__DIR__);
}
}

foreach (glob("$pathToProject/vendor/spomky-labs/*", GLOB_ONLYDIR | GLOB_NOSORT) as $dir) {
$package = 'spomky-labs/'.basename($dir);
if (!$copy && is_link($dir)) {
echo "\"$package\" is already a symlink, skipping.".PHP_EOL;
continue;
}

if (!isset($packages[$package])) {
continue;
}

$packageDir = ('\\' === DIRECTORY_SEPARATOR || $copy) ? $packages[$package] : $filesystem->makePathRelative($packages[$package], dirname(realpath($dir)));
$filesystem->remove($dir);

if ($copy) {
$filesystem->mirror($packageDir, $dir);
echo "\"$package\" has been copied from \"$packages[$package]\".".PHP_EOL;
} else {
$filesystem->symlink(rtrim($packageDir, '/'), $dir);
echo "\"$package\" has been linked to \"$packages[$package]\".".PHP_EOL;
}
}
4 changes: 1 addition & 3 deletions src/Command/GenerateManifestCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,15 @@
{
private readonly MimeTypes $mime;

private readonly Filesystem $filesystem;

public function __construct(

Check failure on line 34 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Method SpomkyLabs\PwaBundle\Command\GenerateManifestCommand::__construct() has parameter $config with no value type specified in iterable type array.
private readonly null|ImageProcessor $imageProcessor,
#[Autowire('%spomky_labs_pwa.config%')]
private readonly array $config,
#[Autowire('%kernel.project_dir%')]
private readonly string $rootDir,
private readonly Filesystem $filesystem,
) {
$this->mime = MimeTypes::getDefault();
$this->filesystem = new Filesystem();
parent::__construct();
}

Expand All @@ -67,9 +65,9 @@
$manifest = array_filter($manifest, static fn ($value) => ($value !== null && $value !== []));

$publicUrl = $input->getOption('url_prefix');
$publicFolder = Path::canonicalize($input->getOption('public_folder'));

Check failure on line 68 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Parameter #1 $path of static method Symfony\Component\Filesystem\Path::canonicalize() expects string, mixed given.
$assetFolder = '/' . trim((string) $input->getOption('asset_folder'), '/');

Check failure on line 69 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Cannot cast mixed to string.
$outputFile = '/' . trim((string) $input->getOption('output'), '/');

Check failure on line 70 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Cannot cast mixed to string.

if (! $this->filesystem->exists($publicFolder)) {
$this->filesystem->mkdir($publicFolder);
Expand All @@ -79,7 +77,7 @@
if ($manifest === self::FAILURE) {
return self::FAILURE;
}
$manifest = $this->processScreenshots($io, $manifest, $publicUrl, $publicFolder, $assetFolder);

Check failure on line 80 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Parameter #2 $manifest of method SpomkyLabs\PwaBundle\Command\GenerateManifestCommand::processScreenshots() expects array, array|int<min, 0>|int<2, max> given.
if ($manifest === self::FAILURE) {
return self::FAILURE;
}
Expand Down Expand Up @@ -120,9 +118,9 @@
$hash = mb_substr(hash('sha256', $data), 0, 8);
file_put_contents($tempFilename, $data);
$mime = $this->mime->guessMimeType($tempFilename);
$extension = $this->mime->getExtensions($mime);

Check failure on line 121 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Parameter #1 $mimeType of method Symfony\Component\Mime\MimeTypes::getExtensions() expects string, string|null given.

if (empty($extension)) {

Check failure on line 123 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Construct empty() is not allowed. Use more strict comparison.
throw new RuntimeException(sprintf('Unable to guess the extension for the mime type "%s"', $mime));
}

Expand All @@ -133,7 +131,7 @@
file_put_contents($localFilename, $data);
$this->filesystem->remove($tempFilename);

return [

Check failure on line 134 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Method SpomkyLabs\PwaBundle\Command\GenerateManifestCommand::storeFile() should return array{src: string, type: string} but returns array{src: non-falsy-string, type: string|null}.
'src' => sprintf('%s%s', $publicUrl, $filename),
'type' => $mime,
];
Expand All @@ -151,10 +149,10 @@
?string $formFactor
): array {
if ($format !== null) {
$data = $this->imageProcessor->process($data, null, null, $format);

Check failure on line 152 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Cannot call method process() on SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor|null.
}

['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes($data);

Check failure on line 155 in src/Command/GenerateManifestCommand.php

View workflow job for this annotation

GitHub Actions / PHP 8.2 Test on ubuntu-latest

Cannot call method getSizes() on SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor|null.
$size = sprintf('%sx%s', $width, $height);
$formFactor ??= $width > $height ? 'wide' : 'narrow';

Expand Down
69 changes: 69 additions & 0 deletions src/Command/WorkboxInitCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpKernel\Config\FileLocator;
use function count;

#[AsCommand(name: 'pwa:sw', description: 'Initializes the Workbox-based Service Worker.',)]
class WorkboxInitCommand extends Command
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $rootDir,
private readonly Filesystem $filesystem,
private readonly FileLocator $fileLocator,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addOption(
'public_folder',
'p',
InputOption::VALUE_OPTIONAL,
'Public folder',
$this->rootDir . '/public'
)
->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Output file', 'sw.js')
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Workbox Service Worker');

$publicFolder = Path::canonicalize($input->getOption('public_folder'));
$outputFile = '/' . trim((string) $input->getOption('output'), '/');

if (! $this->filesystem->exists($publicFolder)) {
$this->filesystem->mkdir($publicFolder);
}

$resourcePath = $this->fileLocator->locate('@SpomkyLabsPwaBundle/Resources/workbox.js', null, false);
if (count($resourcePath) !== 1) {
$io->error('Unable to find the Workbox resource.');
return self::FAILURE;
}
$resourcePath = $resourcePath[0];
$this->filesystem->copy($resourcePath, $publicFolder . $outputFile);

$io->success('Workbox is ready to use!');

return self::SUCCESS;
}
}
2 changes: 2 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use SpomkyLabs\PwaBundle\Command\GenerateManifestCommand;
use SpomkyLabs\PwaBundle\Command\WorkboxInitCommand;
use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor;
use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
Expand All @@ -16,6 +17,7 @@
;

$container->set(GenerateManifestCommand::class);
$container->set(WorkboxInitCommand::class);

if (extension_loaded('imagick')) {
$container
Expand Down
65 changes: 65 additions & 0 deletions src/Resources/workbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 { 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,
});

// 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,
}),
],
}),
);

// Warm the cache with URLs that are likely to be visited next or during offline navigation.
const strategy = new CacheFirst();
warmStrategyCache({urls: warmCacheUrls, strategy});