core/modules/big_pipe/big_pipe.services.yml | 2 +- .../HtmlResponseBigPipeSubscriber.php | 42 +++---- core/modules/big_pipe/src/Render/BigPipe.php | 65 +++++++---- .../big_pipe/src/Render/BigPipeInterface.php | 14 ++- .../big_pipe/src/Render/BigPipeResponse.php | 122 ++++++++++++++++++++- 5 files changed, 193 insertions(+), 52 deletions(-) diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml index 5235037..7be76ad 100644 --- a/core/modules/big_pipe/big_pipe.services.yml +++ b/core/modules/big_pipe/big_pipe.services.yml @@ -3,7 +3,7 @@ services: class: Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber tags: - { name: event_subscriber } - arguments: ['@big_pipe'] + arguments: ['@big_pipe', '@session_configuration'] placeholder_strategy.big_pipe: class: Drupal\big_pipe\Render\Placeholder\BigPipeStrategy arguments: ['@session_configuration', '@request_stack', '@current_route_match'] diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php index 80d0cd6..3d30bd4 100644 --- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -5,6 +5,7 @@ use Drupal\Core\Render\HtmlResponse; use Drupal\big_pipe\Render\BigPipeInterface; use Drupal\big_pipe\Render\BigPipeResponse; +use Drupal\Core\Session\SessionConfigurationInterface; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -25,14 +26,24 @@ class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface { */ protected $bigPipe; + /** + * The session configuration. + * + * @var \Drupal\Core\Session\SessionConfigurationInterface + */ + protected $sessionConfiguration; + /** * Constructs a HtmlResponseBigPipeSubscriber object. * * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe * The BigPipe service. + * @param \Drupal\Core\Session\SessionConfigurationInterface $session_configuration + * The session configuration. */ - public function __construct(BigPipeInterface $big_pipe) { + public function __construct(BigPipeInterface $big_pipe, SessionConfigurationInterface $session_configuration) { $this->bigPipe = $big_pipe; + $this->sessionConfiguration = $session_configuration; } /** @@ -91,35 +102,8 @@ public function onRespond(FilterResponseEvent $event) { return; } - $big_pipe_response = new BigPipeResponse(); + $big_pipe_response = new BigPipeResponse($this->sessionConfiguration->hasSession($event->getRequest()), $response); $big_pipe_response->setBigPipeService($this->bigPipe); - - // Clone the HtmlResponse's data into the new BigPipeResponse. - $big_pipe_response->headers = clone $response->headers; - $big_pipe_response - ->setStatusCode($response->getStatusCode()) - ->setContent($response->getContent()) - ->setAttachments($attachments) - ->addCacheableDependency($response->getCacheableMetadata()); - - // A BigPipe response can never be cached, because it is intended for a - // single user. - // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 - $big_pipe_response->setPrivate(); - - // Inform surrogates how they should handle BigPipe responses: - // - "no-store" specifies that the response should not be stored in cache; - // it is only to be used for the original request - // - "content" identifies what processing surrogates should perform on the - // response before forwarding it. We send, "BigPipe/1.0", which surrogates - // should not process at all, and in fact, they should not even buffer it - // at all. - // @see http://www.w3.org/TR/edge-arch/ - $big_pipe_response->headers->set('Surrogate-Control', 'no-store, content="BigPipe/1.0"'); - - // Add header to support streaming on NGINX + php-fpm (nginx >= 1.5.6). - $big_pipe_response->headers->set('X-Accel-Buffering', 'no'); - $event->setResponse($big_pipe_response); } diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index 4418e20..169356f 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -109,9 +109,7 @@ public function __construct(RendererInterface $renderer, SessionInterface $sessi /** * {@inheritdoc} */ - public function sendContent($content, array $attachments) { - $has_session = \Drupal::service('session_configuration')->hasSession($this->requestStack->getMasterRequest()); - + public function sendContent($content, array $attachments, $request_has_session) { // First, gather the BigPipe placeholders that must be replaced. $placeholders = isset($attachments['big_pipe_placeholders']) ? $attachments['big_pipe_placeholders'] : []; $nojs_placeholders = isset($attachments['big_pipe_nojs_placeholders']) ? $attachments['big_pipe_nojs_placeholders'] : []; @@ -123,7 +121,7 @@ public function sendContent($content, array $attachments) { $cumulative_assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]); $cumulative_assets->setAlreadyLoadedLibraries($attachments['library']); - if ($has_session) { + if ($request_has_session) { // The content in the placeholders may depend on the session, and by the // time the response is sent (see index.php), the session is already // closed. Reopen it for the duration that we are rendering placeholders. @@ -131,16 +129,18 @@ public function sendContent($content, array $attachments) { } list($pre_body, $post_body) = explode('', $content, 2); - $page_cache_response = $this->sendPreBody($pre_body, $nojs_placeholders, $cumulative_assets); + $streamed_response = $this->sendPreBody($pre_body, $nojs_placeholders, $cumulative_assets); $this->sendPlaceholders($placeholders, $this->getPlaceholderOrder($pre_body), $cumulative_assets); - $page_cache_response .= $this->sendPostBody($post_body); + static::appendToResponse($streamed_response, $this->sendPostBody($post_body)); - if ($has_session) { + if ($request_has_session) { // Close the session again. $this->session->save(); } - return $this; + // Only return the streamed response when the request has no session: only + // sessionless responses can be cached in reverse proxies. + return $request_has_session ? FALSE : $streamed_response; } /** @@ -153,6 +153,10 @@ public function sendContent($content, array $attachments) { * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets * The cumulative assets sent so far; to be updated while rendering no-JS * BigPipe placeholders. + * + * @return \Drupal\Core\Render\HtmlResponse + * The full streamed HTML with the cacheability metadata and attachments for + * the placeholders. */ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) { // If there are no no-JS BigPipe placeholders, we can send the pre- @@ -160,19 +164,16 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss if (empty($no_js_placeholders)) { print $pre_body; flush(); - return $pre_body; + return new HtmlResponse($pre_body); } - - $sent_output = ''; - // Extract the scripts_bottom markup: the no-JS BigPipe placeholders that we // will render may attach additional asset libraries, and if so, it will be // necessary to re-render scripts_bottom. list($pre_scripts_bottom, $scripts_bottom, $post_scripts_bottom) = explode('', $pre_body, 3); $cumulative_assets_initial = clone $cumulative_assets; - $sent_output .= $this->sendNoJsPlaceholders($pre_scripts_bottom . $post_scripts_bottom, $no_js_placeholders, $cumulative_assets); + $streamed_response = $this->sendNoJsPlaceholders($pre_scripts_bottom . $post_scripts_bottom, $no_js_placeholders, $cumulative_assets); // If additional asset libraries or drupalSettings were attached by any of // the placeholders, then we need to re-render scripts_bottom. @@ -207,9 +208,9 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss print $scripts_bottom; flush(); - $sent_output .= $scripts_bottom; + static::appendToResponse($streamed_response, $scripts_bottom); - return $sent_output; + return $streamed_response; } /** @@ -224,6 +225,10 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss * The cumulative assets sent so far; to be updated while rendering no-JS * BigPipe placeholders. * + * @return \Drupal\Core\Render\HtmlResponse + * The full streamed HTML with the cacheability metadata and attachments for + * the placeholders. + * * @throws \Exception * If an exception is thrown during the rendering of a placeholder, it is * caught to allow the other placeholders to still be replaced. But when @@ -231,7 +236,7 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss * simplify debugging. */ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) { - $sent_output = ''; + $streamed_response = new HtmlResponse(); // Split the HTML on every no-JS placeholder string. $prepare_for_preg_split = function ($placeholder_string) { @@ -253,8 +258,8 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse // rest of the logic in the loop handles the placeholders. if (!isset($no_js_placeholders[$fragment])) { print $fragment; - $sent_output .= $fragment; flush(); + static::appendToResponse($streamed_response, $fragment); continue; } @@ -264,6 +269,7 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse if ($placeholder_occurrences[$fragment] > 1 && isset($multi_occurrence_placeholders_content[$fragment])) { print $multi_occurrence_placeholders_content[$fragment]; flush(); + static::appendToResponse($streamed_response, $multi_occurrence_placeholders_content[$fragment]); continue; } @@ -335,7 +341,7 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse // Send this embedded HTML response. print $html_response->getContent(); flush(); - $sent_output .= $html_response->getContent(); + static::appendToResponse($streamed_response, $html_response); // Another placeholder was rendered and sent, track the set of asset // libraries sent so far. Any new settings also need to be tracked, so @@ -351,7 +357,28 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse } } - return $sent_output; + return $streamed_response; + } + + /** + * Appends a chunk to a response, merges cacheability metadata & attachments. + * + * @param \Drupal\Core\Render\HtmlResponse $response + * The response to which to append. + * @param string|\Drupal\Core\Render\HtmlResponse $new_chunk + * The string or response to append. String if there's no cacheability + * metadata or attachments to merge. + */ + protected static function appendToResponse(HtmlResponse $response, $new_chunk) { + assert(is_string($new_chunk) || $new_chunk instanceof HtmlResponse); + if ($new_chunk instanceof HtmlResponse) { + $response->setContent($response->getContent() . $new_chunk->getContent()); + $response->addCacheableDependency($new_chunk->getCacheableMetadata()); + $response->addAttachments($new_chunk->getAttachments()); + } + else { + $response->setContent($response->getContent() . $new_chunk); + } } /** diff --git a/core/modules/big_pipe/src/Render/BigPipeInterface.php b/core/modules/big_pipe/src/Render/BigPipeInterface.php index 3e2156b..2542460 100644 --- a/core/modules/big_pipe/src/Render/BigPipeInterface.php +++ b/core/modules/big_pipe/src/Render/BigPipeInterface.php @@ -47,6 +47,12 @@ * This allows us to use both no-JS BigPipe and "classic" BigPipe in the same * response to maximize the amount of content we can send as early as possible. * + * Furthermore, requests without a session (i.e. requests that are not for + * authenticated users, nor for anonymous users with sessions), BigPipe is also + * supported, to make that first request (a Page Cache miss) faster, by + * streaming it. To avoid a potential no-JS redirect, no-session requests always + * use no-JS BigPipe. + * * Finally, a closer look at the implementation, and how it supports and reuses * existing Drupal concepts: * 1. BigPipe placeholders: 1 HtmlResponse + N embedded AjaxResponses. @@ -138,7 +144,13 @@ * The HTML response content to send. * @param array $attachments * The HTML response's attachments. + * @param bool $request_has_session + * Whether the current request has a session. + * + * @return FALSE|\Drupal\Core\Render\HtmlResponse + * If $has_session is TRUE, the full streamed HTML with the cacheability + * metadata and attachments for the placeholders, FALSE otherwise. */ - public function sendContent($content, array $attachments); + public function sendContent($content, array $attachments, $request_has_session); } diff --git a/core/modules/big_pipe/src/Render/BigPipeResponse.php b/core/modules/big_pipe/src/Render/BigPipeResponse.php index 555e7cb..3dba160 100644 --- a/core/modules/big_pipe/src/Render/BigPipeResponse.php +++ b/core/modules/big_pipe/src/Render/BigPipeResponse.php @@ -3,6 +3,7 @@ namespace Drupal\big_pipe\Render; use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\StreamedResponseInterface; /** * A response that is sent in chunks by the BigPipe service. @@ -11,11 +12,21 @@ * it makes the content inaccessible (hidden behind a callback), which means no * middlewares are able to modify the content anymore. * + * Also note that this response object is aware of whether the request has a + * session or not: + * - a no-session response can be cached by Page Cache and other reverse proxies + * - a session response (anonymous or authenticated) cannot be cached by Page + * Cache and other reverse proxies. + * For that reason, response headers differ based on this distinction, as does + * the work done in the BigPipe service. + * * @see \Drupal\big_pipe\Render\BigPipeInterface * * @todo Will become obsolete with https://www.drupal.org/node/2577631 */ -class BigPipeResponse extends HtmlResponse { +class BigPipeResponse extends HtmlResponse implements StreamedResponseInterface { + + protected $requestHasSession; /** * The BigPipe service. @@ -24,6 +35,85 @@ class BigPipeResponse extends HtmlResponse { */ protected $bigPipe; + /** + * The original HTML response. + * + * Still contains placeholders. Its cacheability metadata and attachments are + * for everything except the placeholders (since those are not yet rendered). + * + * @see \Drupal\Core\Render\StreamedResponseInterface + * @see ::getStreamedResponse() + * + * @var \Drupal\Core\Render\HtmlResponse + */ + protected $originalHtmlResponse; + + /** + * The final HTML response. + * + * Contains replaced placeholders. Its cacheability metadata and attachments + * are only for the placeholders. + * + * @see \Drupal\Core\Render\StreamedResponseInterface + * @see ::getStreamedResponse() + * + * @var \Drupal\Core\Render\HtmlResponse|false + */ + protected $finalHtmlResponse; + + /** + * Constructs a new BigPipeResponse. + * + * @param bool $request_has_session + * Whether the current request has a session. + * @param \Drupal\Core\Render\HtmlResponse $response + * The original HTML response. + */ + public function __construct($request_has_session, HtmlResponse $response) { + parent::__construct('', $response->getStatusCode(), []); + + $this->requestHasSession = $request_has_session; + $this->originalHtmlResponse = $response; + + $this->populateBasedOnOriginalHtmlResponse(); + } + + /** + * Populates this BigPipeResponse object based on the original HTML response. + */ + protected function populateBasedOnOriginalHtmlResponse() { + // Clone the HtmlResponse's data into the new BigPipeResponse. + $this->headers = clone $this->originalHtmlResponse->headers; + $this + ->setStatusCode($this->originalHtmlResponse->getStatusCode()) + ->setContent($this->originalHtmlResponse->getContent()) + ->setAttachments($this->originalHtmlResponse->getAttachments()) + ->addCacheableDependency($this->originalHtmlResponse->getCacheableMetadata()); + + // A BigPipe response can never be cached, because it is intended for a + // single user. + // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 + if ($this->requestHasSession) { + $this->setPrivate(); + } + + // Inform surrogates how they should handle BigPipe responses: + // - "no-store" specifies that the response should not be stored in cache; + // it is only to be used for the original request + // - "max-age=N" specifies that the response can be cached by surrogates for + // up to N seconds + // - "content" identifies what processing surrogates should perform on the + // response before forwarding it. We send, "BigPipe/1.0", which surrogates + // should not process at all, and in fact, they should not even buffer it + // at all. + // @see http://www.w3.org/TR/edge-arch/ + $control_directive = ($this->requestHasSession || !$this->originalHtmlResponse->isCacheable()) ? 'no-store' : ('max-age=' . $this->originalHtmlResponse->getMaxAge()); + $this->headers->set('Surrogate-Control', $control_directive . ', content="BigPipe/1.0"'); + + // Add header to support streaming on NGINX + php-fpm (nginx >= 1.5.6). + $this->headers->set('X-Accel-Buffering', 'no'); + } + /** * Sets the BigPipe service to use. * @@ -38,9 +128,37 @@ public function setBigPipeService(BigPipeInterface $big_pipe) { * {@inheritdoc} */ public function sendContent() { - $this->bigPipe->sendContent($this->content, $this->getAttachments()); + $current_content = $this->content; + $result = $this->bigPipe->sendContent($current_content, $this->getAttachments(), $this->requestHasSession); + + assert('$result === FALSE || $result instanceof \Drupal\Core\Render\HtmlResponse', 'The result of BigPipe::sendContent() is either FALSE (when using JS BigPipe placeholders) or a HtmlResponse (when using no-JS BigPipe placeholders).'); + $this->finalHtmlResponse = $result; return $this; } + /** + * {@inheritdoc} + */ + public function getStreamedResponse() { + // We can only return the streamed response if it was collected. And we only + // collect it in one case, see … + if ($this->finalHtmlResponse === FALSE) { + return FALSE; + } + + // Start with the original HTML response, so we have the appropriate meta- + // data like headers, HTTP version, and so on. + $streamed_response = $this->originalHtmlResponse; + + // Override content with final HTML content (with replaced placeholders). + $streamed_response->setContent($this->finalHtmlResponse->getContent()); + + // Add cacheability metadata and attachments for rendered placeholders. + $streamed_response->addCacheableDependency($this->finalHtmlResponse->getCacheableMetadata()); + $streamed_response->addAttachments($this->finalHtmlResponse->getAttachments()); + + return $streamed_response; + } + }