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

[HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses #48128

Merged
merged 1 commit into from Mar 13, 2023

Conversation

dunglas
Copy link
Member

@dunglas dunglas commented Nov 6, 2022

Q A
Branch? 6.3
Bug fix? no
New feature? yes
Deprecations? yes
Tickets n/a
License MIT
Doc PR todo

This patch adds support for sending informational responses, including Early Hints responses if supported by the SAPI. It also allows sending other informational status codes such as 102 Processing.

According to Shopify and Cloudflare, 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

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. 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

@carsonbot

This comment was marked as outdated.

@dunglas dunglas force-pushed the feat/103-status-code branch 2 times, most recently from aa3684a to 85bb5ca Compare November 6, 2022 21:04
@welcoMattic welcoMattic modified the milestones: 6.2, 6.3 Nov 7, 2022
@carsonbot
Copy link

Hey!

I think @pascalwoerde has recently worked with this code. Maybe they can help review this?

Cheers!

Carsonbot

@chalasr
Copy link
Member

chalasr commented Nov 8, 2022

As many Symfony devs don't know about the sendHeaders() method and concept, I think it would make sense to expose a more high-level API for this, e.g Response::sendInformationalResponse(STATUS) or even Response::sendEarlyHints(Link[] $links) or similar. WDYT?

Also, is it relevant to send preload link headers with both the informational and main response? (Yes, it is)

@derrabus
Copy link
Member

derrabus commented Nov 8, 2022

Is that deprecation necessary? Calling sendHeaders() without any arguments should remain a valid call imho.

@dunglas
Copy link
Member Author

dunglas commented Nov 12, 2022

@chalasr What would be the benefit of doing that? My proposal is very similar to how it's implemented in Go for instance. IMHO the current API is good enough and there is no need to make it more complex.

@derrabus the plan is to force passing null in Symfony 7 to be able to make this attribute optional again in Symfony 8. Is there another way to add an optional parameter without introducing a breaking change?

@derrabus
Copy link
Member

@derrabus the plan is to force passing null in Symfony 7 to be able to make this attribute optional again in Symfony 8.

And why don't we make it optional right from the start?

@chalasr
Copy link
Member

chalasr commented Nov 13, 2022

@chalasr What would be the benefit of doing that? My proposal is very similar to how it's implemented in Go for instance. IMHO the current API is good enough and there is no need to make it more complex.

It's not obvious that sendHeaders() is about sending a response, hence I think the proposed API is more complex. I think we'd better have something crystal clear than being consistent with Golang's low-level api IMHO

@dunglas
Copy link
Member Author

dunglas commented Nov 13, 2022

Informational responses are special: they can only be composed of headers, by the spec:

A 1xx response is terminated by the end of the header section; it cannot contain content or trailers.

https://httpwg.org/specs/rfc9110.html#status.1xx

So basically, sending 1XX responses mean sending headers ahead of time. Adding a new method would just add more confusion IMHO.

@chalasr
Copy link
Member

chalasr commented Nov 13, 2022

So basically basically, sending 1XX responses mean sending headers ahead of time

Yes, that's something I personally know but:

  • use cases for informational responses other than 103 are rare in the web area, I consider the concept to be quite new for Symfony at least.
  • nobody calls sendHeaders() currently
  • exposing this new use case via a status parameter in sendHeaders() does not feel future-proof. A dedicated API (maybe even experimental as this is not widely supported yet?) would provide us with more flexibility.

But maybe it's just me 🤷 hope others opinions will come.

Is there another way to add an optional parameter without introducing a breaking change?

A virtual parameter (@param) should be enough: no added noise for callers, only a deprecation notice for eventual children (triggered by DebugClassLoader)

@dunglas
Copy link
Member Author

dunglas commented Nov 14, 2022

@chalasr I hope that I've found an acceptable compromise:

  1. the low-level API (in HttpFoundation) stays as-is, and still allows to do advancing things such as sending WebDav's 102 Processing status code
  2. I added a higher-level, object-oriented helper in AbstractController dedicated to Early Hints:
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
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(Request $request): 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);
    }
}

So we have the best of both worlds. WDYT?

@chalasr
Copy link
Member

chalasr commented Nov 14, 2022

Looks great! Thanks

UPGRADE-6.3.md Outdated Show resolved Hide resolved
src/Symfony/Component/HttpFoundation/CHANGELOG.md Outdated Show resolved Hide resolved
src/Symfony/Component/HttpFoundation/Response.php Outdated Show resolved Hide resolved
chalasr
chalasr previously approved these changes Dec 3, 2022
Copy link
Member

@chalasr chalasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although AbstractController is not first-class citizen anymore, it's good for discovery and we could consider moving this method to the component later if needed.

@derrabus
Copy link
Member

derrabus commented Dec 9, 2022

$response = $this->sendEarlyHints([
    (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
    (new Link(href: '/script.js'))->withAttribute('as', 'script'),
]);

This sends the headers immediately to the client, right? How can I test a controller that makes use of that feature?

@dunglas
Copy link
Member Author

dunglas commented Dec 9, 2022

Indeed. You can test using FrakenPHP and curl --no-buffer.

@chalasr
Copy link
Member

chalasr commented Dec 9, 2022

It would be nice to make sure that functional testing utilities are ready for this, and to patch them otherwise. Maybe even setup FrankenPHP in one of our GHA workflows?

src/Symfony/Component/HttpFoundation/StreamedResponse.php Outdated Show resolved Hide resolved
/**
* Tracks headers already sent in informational responses
*/
private array $sentHeaders;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would exposing a getter improve testability of this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest keeping that entirely internal. WDYT?

src/Symfony/Component/HttpFoundation/Response.php Outdated Show resolved Hide resolved
src/Symfony/Component/HttpFoundation/Response.php Outdated Show resolved Hide resolved
@dunglas dunglas force-pushed the feat/103-status-code branch 2 times, most recently from 53d4fc0 to a631350 Compare March 11, 2023 11:27
@nicolas-grekas
Copy link
Member

Thank you @dunglas.

@nicolas-grekas nicolas-grekas merged commit 163c570 into symfony:6.3 Mar 13, 2023
2 checks passed
@dunglas dunglas deleted the feat/103-status-code branch March 13, 2023 10:29
Comment on lines 384 to +393
// cookies
foreach ($this->headers->getCookies() as $cookie) {
header('Set-Cookie: '.$cookie, false, $this->statusCode);
}

if ($informationalResponse) {
headers_send($statusCode);

return $this;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would not send the Cookies in the Informational Response.

As the session and so on could be changing and that handling already complex in the AbstractSessionListener to support all kind of Runtime (Swoole, Roadrunner, ... running in the CLI context) and so would not do any set-cookie in the early hint responses.

Suggested change
// cookies
foreach ($this->headers->getCookies() as $cookie) {
header('Set-Cookie: '.$cookie, false, $this->statusCode);
}
if ($informationalResponse) {
headers_send($statusCode);
return $this;
}
if ($informationalResponse) {
headers_send($statusCode);
return $this;
}
// cookies
foreach ($this->headers->getCookies() as $cookie) {
header('Set-Cookie: '.$cookie, false, $this->statusCode);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants