Commit 69c23474 authored by alexpott's avatar alexpott

Issue #2657684 by Wim Leers, Fabianx, xjm, effulgentsia: Refactor BigPipe...

Issue #2657684 by Wim Leers, Fabianx, xjm, effulgentsia: Refactor BigPipe internals to allow a contrib module to extend BigPipe with the ability to stream anonymous responses and prime Page Cache for subsequent visits
parent 6fa5085d
......@@ -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}
*/
......
......@@ -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 </body> tag and get the strings before and after. But be
// careful to use the latest occurrence of the string "</body>", 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-</body>
// 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
</script>
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 </body> tag.
*/
protected function sendPostBody($post_body) {
print '</body>';
print $post_body;
flush();
$this->sendChunk('</body>' . $post_body);
}
/**
......
......@@ -134,17 +134,14 @@
/**
* 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);
}
......@@ -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;
}
......
......@@ -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.
*
......
......@@ -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);
}
......
......@@ -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('<marquee>Yarhar llamas forever!</marquee>'),
'#cache' => ['max-age' => 0],
'#cache' => [
'max-age' => 0,
'tags' => ['cache_tag_set_in_lazy_builder'],
],
];
}
......
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment