diff --git a/core/core.services.yml b/core/core.services.yml index ed1341c..d60f42c 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1503,6 +1503,20 @@ services: arguments: ['@controller_resolver', '@renderer'] tags: - { name: event_subscriber } + # Placeholder strategies for rendering placeholders. + html_response.placeholder_strategy_subscriber: + class: Drupal\Core\EventSubscriber\HtmlResponsePlaceholderStrategySubscriber + tags: + - { name: event_subscriber } + arguments: ['@placeholder_strategy_manager'] + placeholder_strategy_manager: + class: Drupal\Core\Render\Placeholder\PlaceholderStrategyManager + tags: + - { name: service_collector, tag: placeholder_strategy, call: addPlaceholderStrategy } + placeholder_strategy.single_flush: + class: Drupal\Core\Render\Placeholder\SingleFlushStrategy + tags: + - { name: placeholder_strategy, priority: -1000 } email.validator: class: Egulias\EmailValidator\EmailValidator diff --git a/core/includes/common.inc b/core/includes/common.inc index 19beefa..63372e3 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -630,7 +630,8 @@ function drupal_merge_attached(array $a, array $b) { */ function drupal_process_attached(array $elements) { // Asset attachments are handled by \Drupal\Core\Asset\AssetResolver. - foreach (array('library', 'drupalSettings') as $type) { + // @todo This needs to be configurable somehow. + foreach (array('library', 'drupalSettings', 'big_pipe_placeholders') as $type) { unset($elements['#attached'][$type]); } diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmlResponsePlaceholderStrategySubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmlResponsePlaceholderStrategySubscriber.php new file mode 100644 index 0000000..ca7f2ac --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/HtmlResponsePlaceholderStrategySubscriber.php @@ -0,0 +1,80 @@ +placeholderStrategyManager = $placeholder_strategy_manager; + } + + /** + * Processes placeholders for HTML responses. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + if (!$event->isMasterRequest()) { + return; + } + + $response = $event->getResponse(); + if (!$response instanceof HtmlResponse) { + return; + } + + $attachments = $response->getAttachments(); + if (empty($attachments['placeholders'])) { + return; + } + + $attachments['placeholders'] = $this->placeholderStrategyManager->processPlaceholders($attachments['placeholders']); + + $response->setAttachments($attachments); + $event->setResponse($response); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run shortly before HtmlResponseSubscriber. + $events[KernelEvents::RESPONSE][] = ['onRespond', 5]; + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Render/Placeholder/PlaceholderStrategyInterface.php b/core/lib/Drupal/Core/Render/Placeholder/PlaceholderStrategyInterface.php new file mode 100644 index 0000000..b6474d2 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Placeholder/PlaceholderStrategyInterface.php @@ -0,0 +1,31 @@ +placeholderStrategies[] = $strategy; + } + + /** + * {@inheritdoc} + */ + public function processPlaceholders(array $placeholders) { + if (empty($placeholders)) { + return []; + } + + // Assert that there is at least one strategy. + assert('!empty($this->placeholderStrategies)', 'At least one placeholder strategy must be present; by default the fallback strategy \Drupal\Core\Render\Placeholder\SingleFlushStrategy is always present.'); + + $new_placeholders = []; + + // Give each placeholder strategy a chance to replace all not-yet replaced + // placeholders. The order of placeholder strategies is well defined + // and this uses a variation of the "chain of responsibility" design pattern. + foreach ($this->placeholderStrategies as $strategy) { + $processed_placeholders = $strategy->processPlaceholders($placeholders); + assert('array_intersect_key($processed_placeholders, $placeholders) === $processed_placeholders', 'Processed placeholders must be a subset of all placeholders.'); + $placeholders = array_diff_key($placeholders, $processed_placeholders); + $new_placeholders += $processed_placeholders; + + if (empty($placeholders)) { + break; + } + } + + return $new_placeholders; + } + +} diff --git a/core/lib/Drupal/Core/Render/Placeholder/SingleFlushStrategy.php b/core/lib/Drupal/Core/Render/Placeholder/SingleFlushStrategy.php new file mode 100644 index 0000000..d9f8545 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Placeholder/SingleFlushStrategy.php @@ -0,0 +1,26 @@ +bigPipe = $big_pipe; + } + + /** + * Processes placeholders for HTML responses. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + if (!$event->isMasterRequest()) { + return; + } + + $response = $event->getResponse(); + if (!$response instanceof HtmlResponse) { + return; + } + + $attachments = $response->getAttachments(); + if (empty($attachments['big_pipe_placeholders'])) { + return; + } + + // Create a new Response. + $big_pipe_response = new BigPipeResponse(); + + // Clone the response. + $big_pipe_response->headers = clone $response->headers; + $big_pipe_response->setStatusCode($response->getStatusCode()); + $big_pipe_response->setContent($response->getContent()); + $big_pipe_response->addCacheableDependency($response->getCacheableMetadata()); + + // Inject the placeholders and service into the response. + $big_pipe_response->setBigPipePlaceholders($attachments['big_pipe_placeholders']); + $big_pipe_response->setBigPipeService($this->bigPipe); + unset($attachments['big_pipe_placeholders']); + + // Set the remaining attachments. + $big_pipe_response->setAttachments($attachments); + + // And set the new response. + $event->setResponse($big_pipe_response); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run as pretty much last subscriber. + $events[KernelEvents::RESPONSE][] = ['onRespond', -10000]; + return $events; + } + +} diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php new file mode 100644 index 0000000..09508cf --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -0,0 +1,130 @@ +renderer = $renderer; + $this->ajaxResponseAttachmentsProcessor = $ajax_response_attachments_processor; + } + + /** + * {@inheritdoc} + */ + public function sendContent($content, array $placeholders) { + // Split it up in various chunks. + $split = ''; + if (strpos($content, $split) === FALSE) { + $split = ''; + } + $page_parts = explode($split, $content); + + if (count($page_parts) !== 2) { + throw new \LogicException("You need to have only one body or one tag in your html.html.twig template file."); + } + + // Support streaming on NGINX + php-fpm (nginx >= 1.5.6). + header('X-Accel-Buffering: no'); + + print $page_parts[0]; + + // Print a container and the start signal. + print "\n"; + print '
' . "\n"; + print ' ' . "\n"; + + ob_end_flush(); + flush(); + + ksort($placeholders); + + foreach ($placeholders as $placeholder => $placeholder_elements) { + // Check if the placeholder is present at all. + if (strpos($content, $placeholder) === FALSE) { + continue; + } + + // Create elements to process in right format. + $elements = [ + '#markup' => $placeholder, + '#attached' => [ + 'placeholders' => [ + $placeholder => $placeholder_elements, + ], + ], + ]; + + $elements = $this->renderer->renderPlaceholder($placeholder, $elements); + + // Create a new AjaxResponse. + $response = new AjaxResponse(); + $response->addCommand(new ReplaceCommand(sprintf('[data-big-pipe-selector="%s"]', $placeholder), $elements['#markup'])); + $response->setAttachments($elements['#attached']); + + $this->ajaxResponseAttachmentsProcessor->processAttachments($response); + + // @todo Filter response. + $json = $response->getContent(); + + $output = << + $json + + +EOF; + print $output; + + flush(); + } + + // Send the stop signal. + print ' ' . "\n"; + print '
' . "\n"; + + // Now that we have processed all the placeholders, attach the behaviors + // on the page again. + print $behaviors; + + print $split; + print $page_parts[1]; + + return $this; + } +} diff --git a/core/modules/big_pipe/src/Render/BigPipeInterface.php b/core/modules/big_pipe/src/Render/BigPipeInterface.php new file mode 100644 index 0000000..221791d --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeInterface.php @@ -0,0 +1,29 @@ + tag + * and afterwards processes placeholders to send when they have been rendered. + * + * The output buffers are flushed in between. + * + * @param array $placeholders + * The placeholders to process. + * @param string $content + * The content to send. + */ + public function sendContent($content, array $placeholders); + +} diff --git a/core/modules/big_pipe/src/Render/BigPipeResponse.php b/core/modules/big_pipe/src/Render/BigPipeResponse.php new file mode 100644 index 0000000..168b0d1 --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeResponse.php @@ -0,0 +1,60 @@ +bigPipePlaceholders = $placeholders; + } + + /** + * Sets the big pipe service to use. + * + * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe + * The BigPipe service. + */ + public function setBigPipeService(BigPipeInterface $big_pipe) { + $this->bigPipe = $big_pipe; + } + + /** + * {@inheritdoc} + */ + public function sendContent() { + $this->bigPipe->sendContent($this->content, $this->bigPipePlaceholders); + + return $this; + } +} diff --git a/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php new file mode 100644 index 0000000..a956ff2 --- /dev/null +++ b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php @@ -0,0 +1,58 @@ + $placeholder_elements) { + $html_placeholder = Html::getId($placeholder . '-' . $token); + $return[$placeholder] = [ + '#markup' => '
', + // Big Pipe placeholders are not cacheable. + '#cache' => [ + 'max-age' => 0, + ], + '#attached' => [ + // Use the big_pipe library. + 'library' => [ + 'big_pipe/big_pipe', + ], + // Add the placeholder to a white list of JS processed placeholders. + 'drupalSettings' => [ + 'bigPipePlaceholders' => [ $html_placeholder => TRUE ], + ], + 'big_pipe_placeholders' => [ + $html_placeholder => $placeholder_elements, + ], + ], + ]; + } + + return $return; + } +} diff --git a/core/tests/Drupal/Tests/Core/Render/Placeholder/PlaceholderStrategyManagerTest.php b/core/tests/Drupal/Tests/Core/Render/Placeholder/PlaceholderStrategyManagerTest.php new file mode 100644 index 0000000..87c766a --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Render/Placeholder/PlaceholderStrategyManagerTest.php @@ -0,0 +1,169 @@ +addPlaceholderStrategy($strategy); + } + + $this->assertEquals($result, $placeholder_strategy_manager->processPlaceholders($placeholders)); + } + + /** + * Provides a list of render strategies, placeholders and results. + * + * @return array + */ + public function providerProcessPlaceholders() { + $data = []; + + // Empty placeholders. + $data['empty placeholders'] = [[], [], []]; + + // Placeholder removing strategy. + $placeholders = [ + 'remove-me' => ['#markup' => 'I-am-a-llama-that-will-be-removed-sad-face.'], + ]; + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn([]); + $dev_null_strategy = $prophecy->reveal(); + + $data['placeholder removing strategy'] = [[$dev_null_strategy], $placeholders, []]; + + // Fake Single Flush strategy. + $placeholders = [ + '67890' => ['#markup' => 'special-placeholder'], + ]; + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn($placeholders); + $single_flush_strategy = $prophecy->reveal(); + + $data['fake single flush strategy'] = [[$single_flush_strategy], $placeholders, $placeholders]; + + // Fake ESI strategy. + $placeholders = [ + '12345' => ['#markup' => 'special-placeholder-for-esi'], + ]; + $result = [ + '12345' => ['#markup' => ''], + ]; + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn($result); + $esi_strategy = $prophecy->reveal(); + + $data['fake esi strategy'] = [[$esi_strategy], $placeholders, $result]; + + // ESI + SingleFlush strategy (ESI replaces all). + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn($result); + $esi_strategy = $prophecy->reveal(); + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->shouldNotBeCalled(); + $prophecy->processPlaceholders($result)->shouldNotBeCalled(); + $prophecy->processPlaceholders([])->shouldNotBeCalled(); + $single_flush_strategy = $prophecy->reveal(); + + $data['fake esi and single_flush strategy - esi replaces all'] = [[$esi_strategy, $single_flush_strategy], $placeholders, $result]; + + // ESI + SingleFlush strategy (mixed). + $placeholders = [ + '12345' => ['#markup' => 'special-placeholder-for-ESI'], + '67890' => ['#markup' => 'special-placeholder'], + 'foo' => ['#markup' => 'bar'], + ]; + + $esi_result = [ + '12345' => ['#markup' => ''], + ]; + + $normal_result = [ + '67890' => ['#markup' => 'special-placeholder'], + 'foo' => ['#markup' => 'bar'], + ]; + + $result = $esi_result + $normal_result; + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn($esi_result); + $esi_strategy = $prophecy->reveal(); + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($normal_result)->willReturn($normal_result); + $single_flush_strategy = $prophecy->reveal(); + + $data['fake esi and single_flush strategy - mixed'] = [[$esi_strategy, $single_flush_strategy], $placeholders, $result]; + + return $data; + } + + /** + * @covers ::processPlaceholders + * + * @expectedException \AssertionError + * @expectedExceptionMessage At least one placeholder strategy must be present; by default the fallback strategy \Drupal\Core\Render\Placeholder\SingleFlushStrategy is always present. + */ + public function testProcessPlaceholdersNoStrategies() { + // Placeholders but no strategies defined. + $placeholders = [ + 'assert-me' => ['#markup' => 'I-am-a-llama-that-will-lead-to-an-assertion-by-the-placeholder-strategy-manager.'], + ]; + + $placeholder_strategy_manager = new PlaceholderStrategyManager(); + $placeholder_strategy_manager->processPlaceholders($placeholders); + } + + /** + * @covers ::processPlaceholders + * + * @expectedException \AssertionError + * @expectedExceptionMessage Processed placeholders must be a subset of all placeholders. + */ + public function testProcessPlaceholdersWithRoguePlaceholderStrategy() { + // Placeholders but no strategies defined. + $placeholders = [ + 'assert-me' => ['#markup' => 'llama'], + ]; + + $result = [ + 'assert-me' => ['#markup' => 'llama'], + 'new-placeholder' => ['#markup' => 'rogue llama'], + ]; + + $prophecy = $this->prophesize('\Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface'); + $prophecy->processPlaceholders($placeholders)->willReturn($result); + $rogue_strategy = $prophecy->reveal(); + + $placeholder_strategy_manager = new PlaceholderStrategyManager(); + $placeholder_strategy_manager->addPlaceholderStrategy($rogue_strategy); + $placeholder_strategy_manager->processPlaceholders($placeholders); + } + + +}