Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 163c570

Browse files
feature #48128 [HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses (dunglas)
This PR was squashed before being merged into the 6.3 branch. Discussion ---------- [HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | yes <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | n/a <!-- prefix each issue number with "Fix #", no need to create an issue if none exists, explain below instead --> | License | MIT | Doc PR | todo This patch adds support for sending informational responses, including [Early Hints responses](https://developer.chrome.com/blog/early-hints/) if supported by the SAPI. It also allows sending other informational status codes such as 102 Processing. According to [Shopify](https://twitter.com/colinbendell/status/1539322190541295616) and [Cloudflare](http://blog.cloudflare.com/early-hints-performance), using Early Hints, the performance improvement to the Largest Contentful Paint can go from several hundred milliseconds, and up to a second faster. Usage: ```php <?php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\WebLink\Link; class HomepageController extends AbstractController { #[Route("/", name: "homepage")] public function index(): Response { $response = $this->sendEarlyHints([ (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'), (new Link(href: '/script.js'))->withAttribute('as', 'script'), ]); // Do something slow... return $this->render('homepage/index.html.twig', response: $response); } } ``` With this patch, HttpFoundation will leverage the `headers_send()` function provided by [FrankenPHP](https://frankenphp.dev). FrankenPHP is currently the only SAPI supporting Early Hints, but other SAPI such as mod_apache will probably implement this function at some point: php/php-src#7025 (comment) The low-level API is similar to the one provided by Go: golang/go#42597 The high-level API helper in `AbstractController` is similar to Node's one: nodejs/node#44180 Commits ------- 5be52b2 [HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses
2 parents da9e54a + 5be52b2 commit 163c570

File tree

12 files changed

+146
-13
lines changed

12 files changed

+146
-13
lines changed

UPGRADE-6.3.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ FrameworkBundle
5757

5858
* Deprecate the `notifier.logger_notification_listener` service, use the `notifier.notification_logger_listener` service instead
5959

60+
HttpFoundation
61+
--------------
62+
63+
* `Response::sendHeaders()` now takes an optional `$statusCode` parameter
64+
6065
HttpKernel
6166
----------
6267

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ CHANGELOG
1717
* Allow setting `debug.container.dump` to `false` to disable dumping the container to XML
1818
* Add `framework.http_cache.skip_response_headers` option
1919
* Display warmers duration on debug verbosity for `cache:clear` command
20+
* Add `AbstractController::sendEarlyHints()` to send HTTP Early Hints
2021

2122
6.2
2223
---

src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Bundle\FrameworkBundle\Controller;
1313

1414
use Psr\Container\ContainerInterface;
15+
use Psr\Link\EvolvableLinkInterface;
1516
use Psr\Link\LinkInterface;
1617
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
1718
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
@@ -42,6 +43,7 @@
4243
use Symfony\Component\Serializer\SerializerInterface;
4344
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
4445
use Symfony\Component\WebLink\GenericLinkProvider;
46+
use Symfony\Component\WebLink\HttpHeaderSerializer;
4547
use Symfony\Contracts\Service\Attribute\Required;
4648
use Symfony\Contracts\Service\ServiceSubscriberInterface;
4749
use Twig\Environment;
@@ -92,6 +94,7 @@ public static function getSubscribedServices(): array
9294
'security.token_storage' => '?'.TokenStorageInterface::class,
9395
'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class,
9496
'parameter_bag' => '?'.ContainerBagInterface::class,
97+
'web_link.http_header_serializer' => '?'.HttpHeaderSerializer::class,
9598
];
9699
}
97100

@@ -402,4 +405,30 @@ protected function addLink(Request $request, LinkInterface $link): void
402405

403406
$request->attributes->set('_links', $linkProvider->withLink($link));
404407
}
408+
409+
/**
410+
* @param LinkInterface[] $links
411+
*/
412+
protected function sendEarlyHints(iterable $links, Response $response = null): Response
413+
{
414+
if (!$this->container->has('web_link.http_header_serializer')) {
415+
throw new \LogicException('You cannot use the "sendEarlyHints" method if the WebLink component is not available. Try running "composer require symfony/web-link".');
416+
}
417+
418+
$response ??= new Response();
419+
420+
$populatedLinks = [];
421+
foreach ($links as $link) {
422+
if ($link instanceof EvolvableLinkInterface && !$link->getRels()) {
423+
$link = $link->withRel('preload');
424+
}
425+
426+
$populatedLinks[] = $link;
427+
}
428+
429+
$response->headers->set('Link', $this->container->get('web_link.http_header_serializer')->serialize($populatedLinks), false);
430+
$response->sendHeaders(103);
431+
432+
return $response;
433+
}
405434
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
15+
use Symfony\Component\WebLink\HttpHeaderSerializer;
1516

1617
return static function (ContainerConfigurator $container) {
1718
$container->services()
19+
20+
->set('web_link.http_header_serializer', HttpHeaderSerializer::class)
21+
->alias(HttpHeaderSerializer::class, 'web_link.http_header_serializer')
22+
1823
->set('web_link.add_link_header_listener', AddLinkHeaderListener::class)
24+
->args([
25+
service('web_link.http_header_serializer'),
26+
])
1927
->tag('kernel.event_subscriber')
2028
;
2129
};

src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Symfony\Component\Security\Core\User\InMemoryUser;
4646
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
4747
use Symfony\Component\Serializer\SerializerInterface;
48+
use Symfony\Component\WebLink\HttpHeaderSerializer;
4849
use Symfony\Component\WebLink\Link;
4950
use Twig\Environment;
5051

@@ -72,6 +73,7 @@ public function testSubscribedServices()
7273
'parameter_bag' => '?Symfony\\Component\\DependencyInjection\\ParameterBag\\ContainerBagInterface',
7374
'security.token_storage' => '?Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface',
7475
'security.csrf.token_manager' => '?Symfony\\Component\\Security\\Csrf\\CsrfTokenManagerInterface',
76+
'web_link.http_header_serializer' => '?Symfony\\Component\\WebLink\\HttpHeaderSerializer',
7577
];
7678

7779
$this->assertEquals($expectedServices, $subscribed, 'Subscribed core services in AbstractController have changed');
@@ -677,4 +679,20 @@ public function testAddLink()
677679
$this->assertContains($link1, $links);
678680
$this->assertContains($link2, $links);
679681
}
682+
683+
public function testSendEarlyHints()
684+
{
685+
$container = new Container();
686+
$container->set('web_link.http_header_serializer', new HttpHeaderSerializer());
687+
688+
$controller = $this->createController();
689+
$controller->setContainer($container);
690+
691+
$response = $controller->sendEarlyHints([
692+
(new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
693+
(new Link(href: '/script.js'))->withAttribute('as', 'script'),
694+
]);
695+
696+
$this->assertSame('</style.css>; rel="preload"; as="stylesheet",</script.js>; rel="preload"; as="script"', $response->headers->get('Link'));
697+
}
680698
}

src/Symfony/Bundle/FrameworkBundle/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"symfony/deprecation-contracts": "^2.5|^3",
2626
"symfony/error-handler": "^6.1",
2727
"symfony/event-dispatcher": "^5.4|^6.0",
28-
"symfony/http-foundation": "^6.2",
28+
"symfony/http-foundation": "^6.3",
2929
"symfony/http-kernel": "^6.3",
3030
"symfony/polyfill-mbstring": "~1.0",
3131
"symfony/filesystem": "^5.4|^6.0",

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `ParameterBag::getEnum()`
88
* Create migration for session table when pdo handler is used
99
* Add support for Relay PHP extension for Redis
10+
* The `Response::sendHeaders()` method now takes an optional HTTP status code as parameter, allowing to send informational responses such as Early Hints responses (103 status code)
1011

1112
6.2
1213
---

src/Symfony/Component/HttpFoundation/Response.php

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ class Response
211211
511 => 'Network Authentication Required', // RFC6585
212212
];
213213

214+
/**
215+
* Tracks headers already sent in informational responses.
216+
*/
217+
private array $sentHeaders;
218+
214219
/**
215220
* @param int $status The HTTP status code (200 "OK" by default)
216221
*
@@ -326,30 +331,71 @@ public function prepare(Request $request): static
326331
/**
327332
* Sends HTTP headers.
328333
*
334+
* @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null
335+
*
329336
* @return $this
330337
*/
331-
public function sendHeaders(): static
338+
public function sendHeaders(/* int $statusCode = null */): static
332339
{
333340
// headers have already been sent by the developer
334341
if (headers_sent()) {
335342
return $this;
336343
}
337344

345+
$statusCode = \func_num_args() > 0 ? func_get_arg(0) : null;
346+
$informationalResponse = $statusCode >= 100 && $statusCode < 200;
347+
if ($informationalResponse && !\function_exists('headers_send')) {
348+
// skip informational responses if not supported by the SAPI
349+
return $this;
350+
}
351+
338352
// headers
339353
foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
340-
$replace = 0 === strcasecmp($name, 'Content-Type');
341-
foreach ($values as $value) {
354+
$newValues = $values;
355+
$replace = false;
356+
357+
// As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed
358+
if (103 === $statusCode) {
359+
$previousValues = $this->sentHeaders[$name] ?? null;
360+
if ($previousValues === $values) {
361+
// Header already sent in a previous response, it will be automatically copied in this response by PHP
362+
continue;
363+
}
364+
365+
$replace = 0 === strcasecmp($name, 'Content-Type');
366+
367+
if (null !== $previousValues && array_diff($previousValues, $values)) {
368+
header_remove($name);
369+
$previousValues = null;
370+
}
371+
372+
$newValues = null === $previousValues ? $values : array_diff($values, $previousValues);
373+
}
374+
375+
foreach ($newValues as $value) {
342376
header($name.': '.$value, $replace, $this->statusCode);
343377
}
378+
379+
if ($informationalResponse) {
380+
$this->sentHeaders[$name] = $values;
381+
}
344382
}
345383

346384
// cookies
347385
foreach ($this->headers->getCookies() as $cookie) {
348386
header('Set-Cookie: '.$cookie, false, $this->statusCode);
349387
}
350388

389+
if ($informationalResponse) {
390+
headers_send($statusCode);
391+
392+
return $this;
393+
}
394+
395+
$statusCode ??= $this->statusCode;
396+
351397
// status
352-
header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText), true, $this->statusCode);
398+
header(sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode);
353399

354400
return $this;
355401
}

src/Symfony/Component/HttpFoundation/StreamedResponse.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,22 @@ public function setCallback(callable $callback): static
5959
/**
6060
* This method only sends the headers once.
6161
*
62+
* @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null
63+
*
6264
* @return $this
6365
*/
64-
public function sendHeaders(): static
66+
public function sendHeaders(/* int $statusCode = null */): static
6567
{
6668
if ($this->headersSent) {
6769
return $this;
6870
}
6971

70-
$this->headersSent = true;
72+
$statusCode = \func_num_args() > 0 ? func_get_arg(0) : null;
73+
if ($statusCode < 100 || $statusCode >= 200) {
74+
$this->headersSent = true;
75+
}
7176

72-
return parent::sendHeaders();
77+
return parent::sendHeaders($statusCode);
7378
}
7479

7580
/**

src/Symfony/Component/HttpFoundation/Tests/ResponseTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ public function testSendHeaders()
4242
$this->assertSame($response, $headers);
4343
}
4444

45+
public function testSendInformationalResponse()
46+
{
47+
$response = new Response();
48+
$response->sendHeaders(103);
49+
50+
// Informational responses must not override the main status code
51+
$this->assertSame(200, $response->getStatusCode());
52+
53+
$response->sendHeaders();
54+
}
55+
4556
public function testSend()
4657
{
4758
$response = new Response();

src/Symfony/Component/HttpFoundation/Tests/StreamedResponseTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,15 @@ public function testSetNotModified()
124124
$string = ob_get_clean();
125125
$this->assertEmpty($string);
126126
}
127+
128+
public function testSendInformationalResponse()
129+
{
130+
$response = new StreamedResponse();
131+
$response->sendHeaders(103);
132+
133+
// Informational responses must not override the main status code
134+
$this->assertSame(200, $response->getStatusCode());
135+
136+
$response->sendHeaders();
137+
}
127138
}

src/Symfony/Component/WebLink/EventListener/AddLinkHeaderListener.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,9 @@ class_exists(HttpHeaderSerializer::class);
2929
*/
3030
class AddLinkHeaderListener implements EventSubscriberInterface
3131
{
32-
private HttpHeaderSerializer $serializer;
33-
34-
public function __construct()
35-
{
36-
$this->serializer = new HttpHeaderSerializer();
32+
public function __construct(
33+
private readonly HttpHeaderSerializer $serializer = new HttpHeaderSerializer(),
34+
) {
3735
}
3836

3937
public function onKernelResponse(ResponseEvent $event): void

0 commit comments

Comments
 (0)