diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php index 80d0cd60e39ce2196d8b69981fb1862b541812ab..f8cef1077f10fa52a1866f7be24e507a547ab64f 100644 --- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -91,38 +91,24 @@ public function onRespond(FilterResponseEvent $event) { return; } - $big_pipe_response = new BigPipeResponse(); - $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'); - + $big_pipe_response = new BigPipeResponse($response); + $big_pipe_response->setBigPipeService($this->getBigPipeService($event)); $event->setResponse($big_pipe_response); } + /** + * Returns the BigPipe service to use to send the current response. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * A response event. + * + * @return \Drupal\big_pipe\Render\BigPipeInterface + * A BigPipe service. + */ + protected function getBigPipeService(FilterResponseEvent $event) { + return $this->bigPipe; + } + /** * {@inheritdoc} */ diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index 1894aa96aa6e56b84c8bb7579ba8bc9a0cb00954..9337234337f031f66364cd8a87455f4e2dd88721 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -106,10 +106,49 @@ public function __construct(RendererInterface $renderer, SessionInterface $sessi $this->configFactory = $config_factory; } + /** + * Performs tasks before sending content (and rendering placeholders). + */ + protected function performPreSendTasks() { + // 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. + $this->session->start(); + } + + /** + * Performs tasks after sending content (and rendering placeholders). + */ + protected function performPostSendTasks() { + // Close the session again. + $this->session->save(); + } + + /** + * Sends a chunk. + * + * @param string|\Drupal\Core\Render\HtmlResponse $chunk + * The string or response to append. String if there's no cacheability + * metadata or attachments to merge. + */ + protected function sendChunk($chunk) { + assert(is_string($chunk) || $chunk instanceof HtmlResponse); + if ($chunk instanceof HtmlResponse) { + print $chunk->getContent(); + } + else { + print $chunk; + } + flush(); + } + /** * {@inheritdoc} */ - public function sendContent($content, array $attachments) { + public function sendContent(BigPipeResponse $response) { + $content = $response->getContent(); + $attachments = $response->getAttachments(); + // 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'] : []; @@ -121,10 +160,7 @@ public function sendContent($content, array $attachments) { $cumulative_assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]); $cumulative_assets->setAlreadyLoadedLibraries($attachments['library']); - // 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. - $this->session->start(); + $this->performPreSendTasks(); // Find the closing tag and get the strings before and after. But be // careful to use the latest occurrence of the string "", to ensure @@ -137,10 +173,7 @@ public function sendContent($content, array $attachments) { $this->sendPlaceholders($placeholders, $this->getPlaceholderOrder($pre_body, $placeholders), $cumulative_assets); $this->sendPostBody($post_body); - // Close the session again. - $this->session->save(); - - return $this; + $this->performPostSendTasks(); } /** @@ -158,8 +191,7 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss // If there are no no-JS BigPipe placeholders, we can send the pre- // part of the page immediately. if (empty($no_js_placeholders)) { - print $pre_body; - flush(); + $this->sendChunk($pre_body); return; } @@ -202,8 +234,7 @@ protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAss $scripts_bottom = $html_response->getContent(); } - print $scripts_bottom; - flush(); + $this->sendChunk($scripts_bottom); } /** @@ -244,8 +275,7 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse // between placeholders and it must be printed & flushed immediately. The // rest of the logic in the loop handles the placeholders. if (!isset($no_js_placeholders[$fragment])) { - print $fragment; - flush(); + $this->sendChunk($fragment); continue; } @@ -253,8 +283,7 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse // this is the second occurrence, we can skip all calculations and just // send the same content. if ($placeholder_occurrences[$fragment] > 1 && isset($multi_occurrence_placeholders_content[$fragment])) { - print $multi_occurrence_placeholders_content[$fragment]; - flush(); + $this->sendChunk($multi_occurrence_placeholders_content[$fragment]); continue; } @@ -324,8 +353,7 @@ protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAsse // Send this embedded HTML response. - print $html_response->getContent(); - flush(); + $this->sendChunk($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 @@ -369,10 +397,7 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde } // Send the start signal. - print "\n"; - print static::START_SIGNAL; - print "\n"; - flush(); + $this->sendChunk("\n" . static::START_SIGNAL . "\n"); // A BigPipe response consists of a HTML response plus multiple embedded // AJAX responses. To process the attachments of those AJAX responses, we @@ -444,8 +469,7 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde $json EOF; - print $output; - flush(); + $this->sendChunk($output); // Another placeholder was rendered and sent, track the set of asset // libraries sent so far. Any new settings are already sent; we don't need @@ -456,10 +480,7 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde } // Send the stop signal. - print "\n"; - print static::STOP_SIGNAL; - print "\n"; - flush(); + $this->sendChunk("\n" . static::STOP_SIGNAL . "\n"); } /** @@ -479,8 +500,26 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde */ protected function filterEmbeddedResponse(Request $fake_request, Response $embedded_response) { assert('$embedded_response instanceof \Drupal\Core\Render\HtmlResponse || $embedded_response instanceof \Drupal\Core\Ajax\AjaxResponse'); - $this->requestStack->push($fake_request); - $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $embedded_response); + return $this->filterResponse($fake_request, HttpKernelInterface::SUB_REQUEST, $embedded_response); + } + + /** + * Filters the given response. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request for which a response is being sent. + * @param \Symfony\Component\HttpKernel\HttpKernelInterface::MASTER_REQUEST|\Symfony\Component\HttpKernel\HttpKernelInterface::SUB_REQUEST $request_type + * The request type. + * @param \Symfony\Component\HttpFoundation\Response $response + * The response to filter. + * + * @return \Symfony\Component\HttpFoundation\Response + * The filtered response. + */ + protected function filterResponse(Request $request, $request_type, Response $response) { + assert('$request_type === \Symfony\Component\HttpKernel\HttpKernelInterface::MASTER_REQUEST || $request_type === \Symfony\Component\HttpKernel\HttpKernelInterface::SUB_REQUEST'); + $this->requestStack->push($request); + $event = new FilterResponseEvent($this->httpKernel, $request, $request_type, $response); $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event); $filtered_response = $event->getResponse(); $this->requestStack->pop(); @@ -494,9 +533,7 @@ protected function filterEmbeddedResponse(Request $fake_request, Response $embed * The HTML response's content after the closing tag. */ protected function sendPostBody($post_body) { - print ''; - print $post_body; - flush(); + $this->sendChunk('' . $post_body); } /** diff --git a/core/modules/big_pipe/src/Render/BigPipeInterface.php b/core/modules/big_pipe/src/Render/BigPipeInterface.php index 6d0e5a7c01f97bd5f9e1ce098cec24f253cb3815..420b5db67ba55d65df2912829ca6d6516e4446c7 100644 --- a/core/modules/big_pipe/src/Render/BigPipeInterface.php +++ b/core/modules/big_pipe/src/Render/BigPipeInterface.php @@ -134,17 +134,14 @@ interface BigPipeInterface { /** * Sends an HTML response in chunks using the BigPipe technique. * - * @param string $content - * The HTML response content to send. - * @param array $attachments - * The HTML response's attachments. + * @param \Drupal\big_pipe\Render\BigPipeResponse $response + * The BigPipe response to send. * * @internal * This method should only be invoked by * \Drupal\big_pipe\Render\BigPipeResponse, which is itself an internal - * class. Furthermore, the signature of this method will change in - * https://www.drupal.org/node/2657684. + * class. */ - public function sendContent($content, array $attachments); + public function sendContent(BigPipeResponse $response); } diff --git a/core/modules/big_pipe/src/Render/BigPipeResponse.php b/core/modules/big_pipe/src/Render/BigPipeResponse.php index c6871d5e2752918b9dd9a9da012382ad2d38c505..f74cec9a822216005848327f79fb0c6300eb7b47 100644 --- a/core/modules/big_pipe/src/Render/BigPipeResponse.php +++ b/core/modules/big_pipe/src/Render/BigPipeResponse.php @@ -27,6 +27,74 @@ 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; + + /** + * Constructs a new BigPipeResponse. + * + * @param \Drupal\Core\Render\HtmlResponse $response + * The original HTML response. + */ + public function __construct(HtmlResponse $response) { + parent::__construct('', $response->getStatusCode(), []); + + $this->originalHtmlResponse = $response; + + $this->populateBasedOnOriginalHtmlResponse(); + } + + /** + * Returns the original HTML response. + * + * @return \Drupal\Core\Render\HtmlResponse + * The original HTML response. + */ + public function getOriginalHtmlResponse() { + return $this->originalHtmlResponse; + } + + /** + * 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 + $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 + // - "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/ + $this->headers->set('Surrogate-Control', 'no-store, 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. * @@ -41,7 +109,12 @@ public function setBigPipeService(BigPipeInterface $big_pipe) { * {@inheritdoc} */ public function sendContent() { - $this->bigPipe->sendContent($this->content, $this->getAttachments()); + $this->bigPipe->sendContent($this); + + // All BigPipe placeholders are processed, so update this response's + // attachments. + unset($this->attachments['big_pipe_placeholders']); + unset($this->attachments['big_pipe_nojs_placeholders']); return $this; } diff --git a/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php index 8fe3e97a8b6a26986f39cec4feef8b61a1e10519..7af53f0f518d64c5096b3e0081a8cf1a69ba6a35 100644 --- a/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php +++ b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php @@ -150,7 +150,7 @@ protected function doProcessPlaceholders(array $placeholders) { // @see \Drupal\Core\Access\RouteProcessorCsrf::renderPlaceholderCsrfToken() // @see \Drupal\Core\Form\FormBuilder::renderFormTokenPlaceholder() // @see \Drupal\Core\Form\FormBuilder::renderPlaceholderFormAction() - if ($placeholder[0] !== '<' || $placeholder !== Html::normalize($placeholder)) { + if (static::placeholderIsAttributeSafe($placeholder)) { $overridden_placeholders[$placeholder] = static::createBigPipeNoJsPlaceholder($placeholder, $placeholder_elements, TRUE); } else { @@ -169,6 +169,21 @@ protected function doProcessPlaceholders(array $placeholders) { return $overridden_placeholders; } + /** + * Determines whether the given placeholder is attribute-safe or not. + * + * @param string $placeholder + * A placeholder. + * + * @return bool + * Whether the placeholder is safe for use in a HTML attribute (in case it's + * a placeholder for a HTML attribute value or a subset of it). + */ + protected static function placeholderIsAttributeSafe($placeholder) { + assert('is_string($placeholder)'); + return $placeholder[0] !== '<' || $placeholder !== Html::normalize($placeholder); + } + /** * Creates a BigPipe JS placeholder. * diff --git a/core/modules/big_pipe/src/Tests/BigPipeTest.php b/core/modules/big_pipe/src/Tests/BigPipeTest.php index bb032e5d33b8222f8525c47d7668a0b552c25dd5..25a59b06cbd4fc8acf318795f9fd27ad811959a7 100644 --- a/core/modules/big_pipe/src/Tests/BigPipeTest.php +++ b/core/modules/big_pipe/src/Tests/BigPipeTest.php @@ -158,7 +158,9 @@ public function testBigPipe() { $this->drupalGet(Url::fromRoute('big_pipe_test')); $this->assertBigPipeResponseHeadersPresent(); + $this->assertNoCacheTag('cache_tag_set_in_lazy_builder'); + $this->setCsrfTokenSeedInTestEnvironment(); $cases = $this->getTestCases(); $this->assertBigPipeNoJsPlaceholders([ $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder => $cases['edge_case__invalid_html']->embeddedHtmlResponse, @@ -236,7 +238,9 @@ public function testBigPipeNoJs() { $this->drupalGet(Url::fromRoute('big_pipe_test')); $this->assertBigPipeResponseHeadersPresent(); + $this->assertNoCacheTag('cache_tag_set_in_lazy_builder'); + $this->setCsrfTokenSeedInTestEnvironment(); $cases = $this->getTestCases(); $this->assertBigPipeNoJsPlaceholders([ $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder => $cases['edge_case__invalid_html']->embeddedHtmlResponse, @@ -402,14 +406,18 @@ protected function assertBigPipePlaceholders(array $expected_big_pipe_placeholde } /** - * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[] + * Ensures CSRF tokens can be generated for the current user's session. */ - protected function getTestCases() { - // Ensure we can generate CSRF tokens for the current user's session. + protected function setCsrfTokenSeedInTestEnvironment() { $session_data = $this->container->get('session_handler.write_safe')->read($this->cookies[$this->getSessionName()]['value']); $csrf_token_seed = unserialize(explode('_sf2_meta|', $session_data)[1])['s']; $this->container->get('session_manager.metadata_bag')->setCsrfTokenSeed($csrf_token_seed); + } + /** + * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[] + */ + protected function getTestCases($has_session = TRUE) { return BigPipePlaceholderTestCases::cases($this->container, $this->rootUser); } diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php index eeb7b0dc78a25b339e7af0edc91a74996c62798d..22307708fdf2af6a363a6537a61e32a42ff1ba91 100644 --- a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php @@ -14,13 +14,19 @@ class BigPipeTestController { * @return array */ public function test() { + $has_session = \Drupal::service('session_configuration')->hasSession(\Drupal::requestStack()->getMasterRequest()); + $build = []; $cases = BigPipePlaceholderTestCases::cases(\Drupal::getContainer()); // 1. HTML placeholder: status messages. Drupal renders those automatically, // so all that we need to do in this controller is set a message. - drupal_set_message('Hello from BigPipe!'); + if ($has_session) { + // Only set a message if a session already exists, otherwise we always + // trigger a session, which means we can't test no-session requests. + drupal_set_message('Hello from BigPipe!'); + } $build['html'] = $cases['html']->renderArray; // 2. HTML attribute value placeholder: form action. @@ -98,7 +104,10 @@ public static function currentTime() { public static function helloOrYarhar() { return [ '#markup' => BigPipeMarkup::create('Yarhar llamas forever!'), - '#cache' => ['max-age' => 0], + '#cache' => [ + 'max-age' => 0, + 'tags' => ['cache_tag_set_in_lazy_builder'], + ], ]; } diff --git a/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php index 9123129298a2d14739068ecd50143e71ca8f7eb3..f7d137dff8f23ce3999c936ac6ea9bbddeb9dcde 100644 --- a/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php +++ b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php @@ -51,7 +51,7 @@ function nonHtmlResponseProvider() { * @dataProvider attachmentsProvider */ public function testHtmlResponse(array $attachments) { - $big_pipe_response = new BigPipeResponse('original'); + $big_pipe_response = new BigPipeResponse(new HtmlResponse('original')); $big_pipe_response->setAttachments($attachments); // This mock is the main expectation of this test: verify that the decorated