8000 feature #59020 [AssetMapper] add support for assets pre-compression (… · symfony/symfony@12e4e53 · GitHub
[go: up one dir, main page]

Skip to content

Commit 12e4e53

Browse files
committed
feature #59020 [AssetMapper] add support for assets pre-compression (dunglas)
This PR was squashed before being merged into the 7.3 branch. Discussion ---------- [AssetMapper] add support for assets pre-compression | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | n/a | License | MIT Adds support for assets and files precompression using Zstandard, Brotli, and gzip (this will be part of [my talk at SymfonyCon next week](https://live.symfony.com/2024-vienna-con/schedule#http-compression-in-symfony-apps)). When calling `bin/console asset-map:compile` and if the new option `asset_mapper.precompress` is enabled, all files matching the configured extensions will be compressed in Brotli, Zstandard, and gzip (zopfli when available) at maximal compression (defaults to the list supported by Caddy and Cloudflare). The PHP extension is used if available, otherwise the Unix command is used as a fallback. The compressed files are created with the same name as the original asset but with the `.br`, `.zst`, or `.gz` extension appended. This allows native compatibility with web servers supporting precompression such as [Caddy and FrankenPHP](https://caddyserver.com/docs/caddyfile/directives/file_server#precompressed) or [NGINX with the Brotli module](https://github.com/google/ngx_brotli). Apache can also use these files but will require [some extra config](https://stackoverflow.com/questions/16883241/how-to-host-static-content-pre-compressed-in-apache). This PR also adds an `assets:compress` command that can be used to compress files not managed by AssetMapper (e.g. `robots.txt`). Finally, the new `asset_mapper.compressor` service can be used to precompress files uploaded by the user (among various other use cases). TODO: * [x] add tests Commits ------- 5fa3e92 [AssetMapper] add support for assets pre-compression
2 parents 3a804f5 + 5fa3e92 commit 12e4e53

24 files changed

+858
-2
lines changed

.github/workflows/unit-tests.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
mode: low-deps
3434
- php: '8.3'
3535
- php: '8.4'
36+
# brotli and zstd extensions are optional, when not present the commands will be used instead,
37+
# we must test both scenarios
38+
extensions: amqp,apcu,brotli,igbinary,intl,mbstring,memcached,redis,relay,zstd
3639
#mode: experimental
3740
fail-fast: false
3841

@@ -53,6 +56,12 @@ jobs:
5356
extensions: "${{ matrix.extensions || env.extensions }}"
5457
tools: flex
5558

59+
- name: Install optional commands
60+
if: matrix.php == '8.4'
61+
run: |
62+
sudo apt-get update
63+
sudo apt-get install zopfli
64+
5665
- name: Configure environment
5766
run: |
5867
git config --global user.email ""

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for assets pre-compression
8+
49
7.2
510
---
611

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Bundle\FullStack;
1818
use Symfony\Component\Asset\Package;
1919
use Symfony\Component\AssetMapper\AssetMapper;
20+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
2021
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
2122
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
2223
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
@@ -924,6 +925,29 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $
924925
->info('The directory to store JavaScript vendors.')
925926
->defaultValue('%kernel.project_dir%/assets/vendor')
926927
->end()
928+
->arrayNode('precompress')
929+
->info('Precompress assets with Brotli, Zstandard and gzip.')
930+
->canBeEnabled()
931+
->fixXmlConfig('format')
932+
->fixXmlConfig('extension')
933+
->children()
934+
->arrayNode('formats')
935+
->info('Array of formats to enable. "brotli", "zstandard" and "gzip" are supported. Defaults to all formats supported by the system. The entire list must be provided.')
936+
->prototype('scalar')->end()
937+
->performNoDeepMerging()
938+
->validate()
939+
->ifTrue(static fn (array $v) => array_diff($v, ['brotli', 'zstandard', 'gzip']))
940+
->thenInvalid('Unsupported format: "brotli", "zstandard" and "gzip" are supported.')
941+
->end()
942+
->end()
943+
->arrayNode('extensions')
944+
->info('Array of extensions to compress. The entire list must be provided, no merging occurs.')
945+
->prototype('scalar')->end()
946+
->performNoDeepMerging()
947+
->defaultValue(interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [])
948+
->end()
949+
->end()
950+
->end()
927951
->end()
928952
->end()
929953
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Symfony\Component\Asset\PackageInterface;
3333
use Symfony\Component\AssetMapper\AssetMapper;
3434
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
35+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
3536
use Symfony\ E377 Component\BrowserKit\AbstractBrowser;
3637
use Symfony\Component\Cache\Adapter\AdapterInterface;
3738
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -1372,6 +1373,26 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde
13721373
->replaceArgument(3, $config['importmap_polyfill'])
13731374
->replaceArgument(4, $config['importmap_script_attributes'])
13741375
;
1376+
1377+
if (interface_exists(CompressorInterface::class)) {
1378+
$compressors = [];
1379+
foreach ($config['precompress']['formats'] as $format) {
1380+
$compressors[$format] = new Reference("asset_mapper.compressor.$format");
1381+
}
1382+
1383+
$container->getDefinition('asset_mapper.compressor')->replaceArgument(0, $compressors ?: null);
1384+
1385+
if ($config['precompress']['enabled']) {
1386+
$container
1387+
->getDefinition('asset_mapper.local_public_assets_filesystem')
1388+
->addArgument(new Reference('asset_mapper.compressor'))
1389+
->addArgument($config['precompress']['extensions'])
1390+
;
1391+
}
1392+
} else {
1393+
$container->removeDefinition('asset_mapper.compressor');
1394+
$container->removeDefinition('asset_mapper.assets.command.compress');
1395+
}
13751396
}
13761397

13771398
/**

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\AssetMapper\AssetMapperInterface;
1818
use Symfony\Component\AssetMapper\AssetMapperRepository;
1919
use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand;
20+
use Symfony\Component\AssetMapper\Command\CompressAssetsCommand;
2021
use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand;
2122
use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand;
2223
use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand;
@@ -28,6 +29,11 @@
2829
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
2930
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
3031
use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler;
32+
use Symfony\Component\AssetMapper\Compressor\BrotliCompressor;
33+
use Symfony\Component\AssetMapper\Compressor\ChainCompressor;
34+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
35+
use Symfony\Component\AssetMapper\Compressor\GzipCompressor;
36+
use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor;
3137
use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory;
3238
use Symfony\Component\AssetMapper\Factory\MappedAssetFactory;
3339
use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor;
@@ -254,5 +260,20 @@
254260
->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class)
255261
->args([service('asset_mapper.importmap.update_checker')])
256262
->tag('console.command')
263+
264+
->set('asset_mapper.compressor.brotli', BrotliCompressor::class)
265+
->set('asset_mapper.compressor.zstandard', ZstandardCompressor::class)
266+
->set('asset_mapper.compressor.gzip', GzipCompressor::class)
267+
268+
->set('asset_mapper.compressor', ChainCompressor::class)
269+
->args([
270+
abstract_arg('compressor'),
271+
service('logger'),
272+
])
273+
->alias(CompressorInterface::class, 'asset_mapper.compressor')
274+
275+
->set('asset_mapper.assets.command.compress', CompressAssetsCommand::class)
276+
->args([service('asset_mapper.compressor')])
277+
->tag('console.command')
257278
;
258279
};

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
<xsd:element name="excluded-pattern" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
207207
<xsd:element name="extension" type="asset_mapper_extension" minOccurs="0" maxOccurs="unbounded" />
208208
<xsd:element name="importmap-script-attribute" type="asset_mapper_attribute" minOccurs="0" maxOccurs="unbounded" />
209+
<xsd:element name="precompress" type="asset_mapper_precompress" minOccurs="0" maxOccurs="1" />
209210
</xsd:sequence>
210211
<xsd:attribute name="enabled" type="xsd:boolean" />
211212
<xsd:attribute name="exclude-dotfiles" type="xsd:boolean" />
@@ -230,6 +231,16 @@
230231
<xsd:attribute name="key" type="xsd:string" use="required" />
231232
</xsd:complexType>
232233

234+
<xsd:complexType name="asset_mapper_precompress">
235+
<xsd:sequence>
236+
<xsd:element name="format" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
237+
<xsd:element name="extension" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
238+
</xsd:sequence>
239+
240+
<xsd:attribute name="enabled" type="xsd:boolean" />
241+
</xsd:complexType>
242+
243+
233244
<xsd:simpleType name="missing-import-mode">
234245
<xsd:restriction base="xsd:string">
235246
<xsd:enumeration value="strict" />

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PHPUnit\Framework\TestCase;
1616
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Configuration;
1717
use Symfony\Bundle\FullStack;
18+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
1819
use Symfony\Component\Cache\Adapter\DoctrineAdapter;
1920
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
2021
use Symfony\Component\Config\Definition\Processor;
@@ -141,6 +142,11 @@ public function testAssetMapperCanBeEnabled()
141142
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
142143
'importmap_script_attributes' => [],
143144
'exclude_dotfiles' => true,
145+
'precompress' => [
146+
'enabled' => false,
147+
'formats' => [],
148+
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
149+
],
144150
];
145151

146152
$this->assertEquals($defaultConfig, $config['asset_mapper']);
@@ -847,6 +853,11 @@ protected static function getBundleDefaultConfig()
847853
'vendor_dir' => '%kernel.project_dir%/assets/vendor',
848854
'importmap_script_attributes' => [],
849855
'exclude_dotfiles' => true,
856+
'precompress' => [
857+
'enabled' => false,
858+
'formats' => [],
859+
'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [],
860+
],
850861
],
851862
'cache' => [
852863
'pools' => [],

src/Symfony/Component/AssetMapper/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip
8+
49
7.2
510
---
611

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Command;
13+
14+
use Symfony\Component\AssetMapper\Compressor\CompressorInterface;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
22+
/**
23+
* Pre-compresses files to serve through a web server.
24+
*
25+
* @author Kévin Dunglas <kevin@dunglas.dev>
26+
*/
27+
#[AsCommand(name: 'assets:compress', description: 'Pre-compresses files to serve through a web server')]
28+
final class CompressAssetsCommand extends Command
29+
{
30+
public function __construct(
31+
private readonly CompressorInterface $compressor,
32+
) {
33+
parent::__construct();
34+
}
35+
36+
protected function configure(): void
37+
{
38+
$this
39+
->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The files to compress')
40+
->setHelp(<<<'EOT'
41+
The <info>%command.name%</info> command compresses the given file in Brotli, Zstandard and gzip formats.
42+
This is especially useful to serve pre-compressed files through a web server.
43+
44+
The existing file will be kept. The compressed files will be created in the same directory.
45+
The extension of the compression format will be appended to the original file name.
46+
EOT
47+
);
48+
}
49+
50+
protected function execute(InputInterface $input, OutputInterface $output): int
51+
{
52+
$io = new SymfonyStyle($input, $output);
53+
54+
$paths = $input->getArgument('paths');
55+
foreach ($paths as $path) {
56+
$this->compressor->compress($path);
57+
}
58+
59+
$io->success(\sprintf('File%s compressed successfully.', \count($paths) > 1 ? 's' : ''));
60+
61+
return Command::SUCCESS;
62+
}
63+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Compressor;
13+
14+
use Symfony\Component\Process\Process;
15+
16+
/**
17+
* Compresses a file using Brotli.
18+
*
19+
* @author Kévin Dunglas <kevin@dunglas.dev>
20+
*/
21+
final class BrotliCompressor implements SupportedCompressorInterface
22+
{
23+
use CompressorTrait;
24+
25+
private const WRAPPER = 'compress.brotli';
26+
private const COMMAND = 'brotli';
27+
private const PHP_EXTENSION = 'brotli';
28+
private const FILE_EXTENSION = 'br';
29+
30+
public function __construct(
31+
?string $executable = null,
32+
) {
33+
$this->executable = $executable;
34+
}
35+
36+
/**
37+
* @return resource
38+
*/
39+
private function createStreamContext()
40+
{
41+
return stream_context_create(['brotli' => ['level' => BROTLI_COMPRESS_LEVEL_MAX]]);
42+
}
43+
44+
private function compressWithBinary(string $path): void
45+
{
46+
(new Process([$this->executable, '--best', '--force', "--output=$path.".self::FILE_EXTENSION, '--', $path]))->mustRun();
47+
}
48+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Compressor;
13+
14+
use Psr\Log\LoggerInterface;
15+
16+
/**
17+
* Calls multiple compressors in a chain.
18+
*
19+
* @author Kévin Dunglas <kevin@dunglas.dev>
20+
*/
21+
final class ChainCompressor implements CompressorInterface
22+
{
23+
/**
24+
* @param CompressorInterface[] $compressors
25+
*/
26+
public function __construct(
27+
private ?array $compressors = null,
28+
private readonly ?LoggerInterface $logger = null,
29+
) {
30+
}
31+
32+
public function compress(string $path): void
33+
{
34+
if (null === $this->compressors) {
35+
$this->compressors = [];
36+
foreach ([new BrotliCompressor(), new ZstandardCompressor(), new GzipCompressor()] as $compressor) {
37+
$unsupportedReason = $compressor->getUnsupportedReason();
38+
if (null === $unsupportedReason) {
39+
$this->compressors[] = $compressor;
40+
} else {
41+
$this->logger?->warning($unsupportedReason);
42+
}
43+
}
44+
}
45+
46+
foreach ($this->compressors as $compressor) {
47+
$compressor->compress($path);
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)
0