[Asset] Add support for preloading with links and HTTP/2 push

This commit is contained in:
Kévin Dunglas 2017-01-31 16:53:28 +01:00 committed by Fabien Potencier
parent 554b1a748f
commit 7bab21700d
12 changed files with 328 additions and 2 deletions

View File

@ -12,6 +12,7 @@
namespace Symfony\Bridge\Twig\Extension;
use Symfony\Component\Asset\Packages;
use Symfony\Component\Asset\Preload\PreloadManagerInterface;
/**
* Twig extension for the Symfony Asset component.
@ -21,10 +22,12 @@ use Symfony\Component\Asset\Packages;
class AssetExtension extends \Twig_Extension
{
private $packages;
private $preloadManager;
public function __construct(Packages $packages)
public function __construct(Packages $packages, PreloadManagerInterface $preloadManager = null)
{
$this->packages = $packages;
$this->preloadManager = $preloadManager;
}
/**
@ -35,6 +38,7 @@ class AssetExtension extends \Twig_Extension
return array(
new \Twig_SimpleFunction('asset', array($this, 'getAssetUrl')),
new \Twig_SimpleFunction('asset_version', array($this, 'getAssetVersion')),
new \Twig_SimpleFunction('preload', array($this, 'preload')),
);
}
@ -67,6 +71,26 @@ class AssetExtension extends \Twig_Extension
return $this->packages->getVersion($path, $packageName);
}
/**
* Preloads an asset.
*
* @param string $path A public path
* @param string $as A valid destination according to https://fetch.spec.whatwg.org/#concept-request-destination
* @param bool $nopush If this asset should not be pushed over HTTP/2
*
* @return string The path of the asset
*/
public function preload($path, $as = '', $nopush = false)
{
if (null === $this->preloadManager) {
throw new \RuntimeException('A preload manager must be configured to use the "preload" function.');
}
$this->preloadManager->addResource($path, $as, $nopush);
return $path;
}
/**
* Returns the name of the extension.
*

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bridge\Twig\Tests\Extension;
use Symfony\Bridge\Twig\Extension\AssetExtension;
use Symfony\Component\Asset\Packages;
use Symfony\Component\Asset\Preload\PreloadManager;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class AssetExtensionTest extends \PHPUnit_Framework_TestCase
{
public function testGetAndPreloadAssetUrl()
{
if (!class_exists(PreloadManager::class)) {
$this->markTestSkipped('Requires Asset 3.3+.');
}
$preloadManager = new PreloadManager();
$extension = new AssetExtension(new Packages(), $preloadManager);
$this->assertEquals('/foo.css', $extension->preload('/foo.css', 'style', true));
$this->assertEquals('</foo.css>; rel=preload; as=style; nopush', $preloadManager->buildLinkValue());
}
/**
* @expectedException \RuntimeException
*/
public function testNoConfiguredPreloadManager()
{
$extension = new AssetExtension(new Packages());
$extension->preload('/foo.css');
}
}

View File

@ -38,5 +38,13 @@
<service id="assets.empty_version_strategy" class="Symfony\Component\Asset\VersionStrategy\EmptyVersionStrategy" public="false" />
<service id="assets.preload_manager" class="Symfony\Component\Asset\Preload\PreloadManager" public="false" />
<service id="asset.preload_listener" class="Symfony\Component\Asset\EventListener\PreloadListener">
<argument type="service" id="assets.preload_manager" />
<tag name="kernel.event_subscriber" />
</service>
</services>
</container>

View File

@ -375,6 +375,12 @@ abstract class FrameworkExtensionTest extends TestCase
$this->assertEquals('assets.custom_version_strategy', (string) $defaultPackage->getArgument(1));
}
public function testAssetHasPreloadListener()
{
$container = $this->createContainerFromFile('assets');
$this->assertTrue($container->hasDefinition('asset.preload_listener'));
}
public function testTranslator()
{
$container = $this->createContainerFromFile('full');

View File

@ -60,6 +60,7 @@
"conflict": {
"phpdocumentor/reflection-docblock": "<3.0",
"phpdocumentor/type-resolver": "<0.2.0",
"symfony/asset": "<3.3",
"symfony/console": "<3.3"
},
"suggest": {

View File

@ -70,6 +70,7 @@
<service id="twig.extension.assets" class="Symfony\Bridge\Twig\Extension\AssetExtension" public="false">
<argument type="service" id="assets.packages" />
<argument type="service" id="assets.preload_manager" on-invalid="ignore" />
</service>
<service id="twig.extension.code" class="Symfony\Bridge\Twig\Extension\CodeExtension" public="false">

View File

@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Asset\EventListener;
use Symfony\Component\Asset\Preload\PreloadManager;
use Symfony\Component\Asset\Preload\PreloadManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Adds the preload Link HTTP header to the response.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class PreloadListener implements EventSubscriberInterface
{
private $preloadManager;
public function __construct(PreloadManagerInterface $preloadManager)
{
$this->preloadManager = $preloadManager;
}
public function onKernelResponse(FilterResponseEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}
if ($value = $this->preloadManager->buildLinkValue()) {
$event->getResponse()->headers->set('Link', $value, false);
// Free memory
$this->preloadManager->clear();
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return array(KernelEvents::RESPONSE => 'onKernelResponse');
}
}

View File

@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Asset\Preload;
/**
* Manages preload HTTP headers.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class PreloadManager implements PreloadManagerInterface
{
private $resources = array();
/**
* {@inheritdoc}
*/
public function addResource($uri, $as = '', $nopush = false)
{
$this->resources[$uri] = array('as' => $as, 'nopush' => $nopush);
}
/**
* {@inheritdoc}
*/
public function clear()
{
$this->resources = array();
}
/**
* {@inheritdoc}
*/
public function buildLinkValue()
{
if (!$this->resources) {
return;
}
$parts = array();
foreach ($this->resources as $uri => $options) {
$as = '' === $options['as'] ? '' : sprintf('; as=%s', $options['as']);
$nopush = $options['nopush'] ? '; nopush' : '';
$parts[] = sprintf('<%s>; rel=preload%s%s', $uri, $as, $nopush);
}
return implode(',', $parts);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Asset\Preload;
/**
* Manages resources to preload according to the W3C "Preload" specification.
*
* @see https://www.w3.org/TR/preload/
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface PreloadManagerInterface
{
/**
* Adds an element to the list of resources to preload.
*
* @param string $uri The resource URI
* @param string $as A valid destination according to https://fetch.spec.whatwg.org/#concept-request-destination
* @param bool $nopush If this asset should not be pushed over HTTP/2
*/
public function addResource($uri, $as = '', $nopush = false);
/**
* Clears the list of resources.
*/
public function clear();
/**
* Builds the value of the preload Link HTTP header.
*
* @return string|null
*/
public function buildLinkValue();
}

View File

@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Asset\Tests\EventListener;
use Symfony\Component\Asset\EventListener\PreloadListener;
use Symfony\Component\Asset\Preload\PreloadManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class PreloadListenerTest extends \PHPUnit_Framework_TestCase
{
public function testOnKernelResponse()
{
$manager = new PreloadManager();
$manager->addResource('/foo');
$subscriber = new PreloadListener($manager);
$response = new Response('', 200, array('Link' => '<https://demo.api-platform.com/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"'));
$event = $this->getMockBuilder(FilterResponseEvent::class)->disableOriginalConstructor()->getMock();
$event->method('isMasterRequest')->willReturn(true);
$event->method('getResponse')->willReturn($response);
$subscriber->onKernelResponse($event);
$this->assertInstanceOf(EventSubscriberInterface::class, $subscriber);
$expected = array(
'<https://demo.api-platform.com/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"',
'</foo>; rel=preload',
);
$this->assertEquals($expected, $response->headers->get('Link', null, false));
$this->assertNull($manager->buildLinkValue());
}
public function testSubscribedEvents()
{
$this->assertEquals(array(KernelEvents::RESPONSE => 'onKernelResponse'), PreloadListener::getSubscribedEvents());
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Asset\Preload;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class PreloadManagerTest extends \PHPUnit_Framework_TestCase
{
public function testManageResources()
{
$manager = new PreloadManager();
$this->assertInstanceOf(PreloadManagerInterface::class, $manager);
$manager->addResource('/foo/bar.js', 'script', false);
$manager->addResource('/foo/baz.css');
$manager->addResource('/foo/bat.png', 'image', true);
$this->assertEquals('</foo/bar.js>; rel=preload; as=script,</foo/baz.css>; rel=preload,</foo/bat.png>; rel=preload; as=image; nopush', $manager->buildLinkValue());
}
}

View File

@ -22,7 +22,8 @@
"symfony/http-foundation": ""
},
"require-dev": {
"symfony/http-foundation": "~2.8|~3.0"
"symfony/http-foundation": "~2.8|~3.0",
"symfony/http-kernel": "~2.8|~3.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Asset\\": "" },