diff --git a/core/modules/big_pipe/README.md b/core/modules/big_pipe/README.md new file mode 100644 index 0000000..62b9a5e --- /dev/null +++ b/core/modules/big_pipe/README.md @@ -0,0 +1,107 @@ +# Installation + +Install like any other Drupal module. + + +# Recommendations + +It is strongly recommended to also enable the Dynamic Page Cache module that is included with Drupal 8 core. + + +# Relation to Page Cache & Dynamic Page Cache modules in Drupal 8 core + +- Page Cache (`page_cache`): no relation to BigPipe. +- Dynamic Page Cache (`dynamic_page_cache`): if a page is cached in the Dynamic Page Cache, BigPipe is able to send the main content much faster. It contains exactly the things that BigPipe still needs to do + + +# Documentation + +- During rendering, the personalized parts are turned into placeholders. +- By default, we use the Single Flush strategy for replacing the placeholders. i.e. we don't send a response until we've replaced them all. +- BigPipe introduces a new strategy, that allows us to flush the initial page first, and then _stream_ the replacements for the placeholders. +- This results in hugely improved front-end/perceived performance (watch the 40-second on the project page). + +There is no detailed documentation about BigPipe yet, but all of the following documentation is relevant, because it covers the principles/architecture that the BigPipe module is built upon. + +- +- +- +- +- + + + +--- + + + +# Environment requirements + +- BigPipe uses streaming, this means any proxy in between should not buffer the response: the origin needs to stream directly to the end user. +- Hence the web server and any proxies should not buffer the response, or otherwise the end result is still a single flush, which means worse performance again. +- BigPipe responses contain the header `Surrogate-Control: no-store, content="BigPipe/1.0"`. For more information about this header, see . + +Note that this version number (`BigPipe/1.0`) is not expected to increase, since all that is necessary for a proxy to support BigPipe, is the absence of buffering. No additional proxy requirements are expected to ever be added. + + +## Apache + +When using Apache, there is nothing to do: no buffering by default. + + +## FastCGI + +When using FastCGI, you must disable its buffering. + +- When using Apache+FastCGI, [set `FcgidOutputBufferSize` to `0`](https://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html#fcgidoutputbuffersize): +``` + + FcgidOutputBufferSize 0 + +``` +- When using Nginx+FastCGI, [set `fastcgi_buffering` to `off`](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffering). + + +## IIS + +When using IIS, you must [disable its buffering](https://support.microsoft.com/en-us/kb/2321250). + +## Varnish + +When using Varnish, the following VCL disables buffering only for BigPipe responses: + +``` +vcl_backend_response { + if (beresp.Surrogate-Control ~ "BigPipe/1.0") { + set beresp.do_stream = true; + set beresp.ttl = 0s; + } +} +``` + +and for Varnish <4: + +``` +vcl_fetch { + if (beresp.Surrogate-Control ~ "BigPipe/1.0") { + set beresp.do_stream = true; + set beresp.ttl = 0; + } +} +``` + +Note that the `big_pipe_nojs` cookie does *not* break caching. Varnish should let that cookie pass through. + + +## Nginx + +When using Nginx, the BigPipe module already sends a `X-Accel-Buffering: no` header for BigPipe responses, which disables buffering. + +Alternatively, it is possible to [disable proxy buffering explicitly](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering). + + +## Other web servers and (reverse) proxies + +Other web servers and (reverse) proxies, including CDNs, need to be configured in a similar way. + +Buffering will nullify the improved front-end performance. This means that users accessing the site via a ISP-installed proxy will not benefit. But the site won't break either. diff --git a/core/modules/big_pipe/big_pipe.info.yml b/core/modules/big_pipe/big_pipe.info.yml new file mode 100644 index 0000000..d523d2f --- /dev/null +++ b/core/modules/big_pipe/big_pipe.info.yml @@ -0,0 +1,6 @@ +name: BigPipe +type: module +description: 'Sends pages in a way that allows browsers to show them much faster. Uses the BigPipe technique.' +package: 'Performance and scalability' +version: VERSION +core: 8.x diff --git a/core/modules/big_pipe/big_pipe.libraries.yml b/core/modules/big_pipe/big_pipe.libraries.yml new file mode 100644 index 0000000..88ecff6 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.libraries.yml @@ -0,0 +1,11 @@ +big_pipe: + version: VERSION + js: + js/big_pipe.js: {} + drupalSettings: + bigPipePlaceholders: [] + dependencies: + - core/jquery + - core/drupal + - core/drupal.ajax + - core/drupalSettings diff --git a/core/modules/big_pipe/big_pipe.module b/core/modules/big_pipe/big_pipe.module new file mode 100644 index 0000000..4cacef4 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.module @@ -0,0 +1,41 @@ +hasSession($request); + $page['#cache']['contexts'][] = 'session.exists'; + // Only do the no-JS detection while we don't know if there's no JS support: + // avoid endless redirect loops. + $has_big_pipe_nojs_cookie = $request->cookies->has(BigPipeStrategy::NOJS_COOKIE); + $page['#cache']['contexts'][] = 'cookies:' . BigPipeStrategy::NOJS_COOKIE; + if ($session_exists && !$has_big_pipe_nojs_cookie) { + $page['#attached']['html_head'][] = [ + [ + // Redirect through a 'Refresh' meta tag if JavaScript is disabled. + '#tag' => 'meta', + '#noscript' => TRUE, + '#attributes' => [ + 'http-equiv' => 'Refresh', + 'content' => '0; URL=' . Url::fromRoute('big_pipe.nojs', [], ['query' => \Drupal::service('redirect.destination')->getAsArray()])->toString(), + ], + ], + 'big_pipe_detect_nojs', + ]; + } +} diff --git a/core/modules/big_pipe/big_pipe.routing.yml b/core/modules/big_pipe/big_pipe.routing.yml new file mode 100644 index 0000000..c7981dc --- /dev/null +++ b/core/modules/big_pipe/big_pipe.routing.yml @@ -0,0 +1,9 @@ +big_pipe.nojs: + path: '/big_pipe/no-js' + defaults: + _controller: '\Drupal\big_pipe\Controller\BigPipeController:setNoJsCookie' + _title: 'BigPipe no-JS check' + options: + no_cache: TRUE + requirements: + _access: 'TRUE' diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml new file mode 100644 index 0000000..cf5ca80 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.services.yml @@ -0,0 +1,27 @@ +services: + html_response.big_pipe_subscriber: + class: Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + tags: + - { name: event_subscriber } + arguments: ['@big_pipe'] + placeholder_strategy.big_pipe: + class: Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + arguments: ['@session_configuration', '@request_stack'] + tags: + - { name: placeholder_strategy, priority: 0 } + big_pipe: + class: Drupal\big_pipe\Render\BigPipe + arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher'] + html_response.attachments_processor.big_pipe: + public: false + class: \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor + decorates: html_response.attachments_processor + decoration_inner_name: html_response.attachments_processor.original + arguments: ['@html_response.attachments_processor.original', '@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer', '@module_handler'] + + # @todo Move this into Drupal 8 core. + cache_context.session.exists: + class: Drupal\big_pipe\Cache\Context\SessionExistsCacheContext + arguments: ['@session_configuration', '@request_stack'] + tags: + - { name: cache.context} diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js new file mode 100644 index 0000000..e21df03 --- /dev/null +++ b/core/modules/big_pipe/js/big_pipe.js @@ -0,0 +1,92 @@ +/** + * @file + * Provides Ajax page updating via BigPipe. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Execute Ajax commands included in the script tag. + * + * @param {number} index + * Current index. + * @param {HTMLScriptElement} placeholder + * Script tag created by bigPipe. + */ + function bigPipeProcessPlaceholder(index, placeholder) { + var placeholderName = this.getAttribute('data-big-pipe-placeholder'); + var content = this.textContent.trim(); + // Ignore any placeholders that are not in the known placeholder list. + // This is used to avoid someone trying to XSS the site via the + // placeholdering mechanism.; + if (typeof drupalSettings.bigPipePlaceholders[placeholderName] !== 'undefined') { + // If we try to parse the content too early textContent will be empty, + // making JSON.parse fail. Remove once so that it can be processed again + // later. + if (content === '') { + $(this).removeOnce('big-pipe'); + } + else { + var response = JSON.parse(content); + // Use a dummy url. + var ajaxObject = Drupal.ajax({url: 'big-pipe/placeholder.json'}); + ajaxObject.success(response); + } + } + } + + /** + * + * @param {HTMLDocument} context + * Main + * + * @return {bool} + * Returns true when processing has been finished and a stop tag has been + * found. + */ + function bigPipeProcessContainer(context) { + // Make sure we have bigPipe related scripts before processing further. + if (!context.querySelector('script[data-big-pipe-event="start"]')) { + return false; + } + + $(context).find('script[data-drupal-ajax-processor="big_pipe"]').once('big-pipe') + .each(bigPipeProcessPlaceholder); + + // If we see a stop element always clear the timeout. + if (context.querySelector('script[data-big-pipe-event="stop"]')) { + if (timeoutID) { + clearTimeout(timeoutID); + } + return true; + } + + return false; + } + + function bigPipeProcess() { + timeoutID = setTimeout(function () { + if (!bigPipeProcessContainer(document)) { + bigPipeProcess(); + } + }, interval); + } + + var interval = 200; + // The internal ID to contain the watcher service. + var timeoutID; + + bigPipeProcess(); + + // If something goes wrong, make sure everything is cleaned up and has had a + // chance to be processed with everything loaded. + $(window).on('load', function () { + if (timeoutID) { + clearTimeout(timeoutID); + } + bigPipeProcessContainer(document); + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/big_pipe/src/Cache/Context/SessionExistsCacheContext.php b/core/modules/big_pipe/src/Cache/Context/SessionExistsCacheContext.php new file mode 100644 index 0000000..0d87b64 --- /dev/null +++ b/core/modules/big_pipe/src/Cache/Context/SessionExistsCacheContext.php @@ -0,0 +1,70 @@ +sessionConfiguration = $session_configuration; + $this->requestStack = $request_stack; + } + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t('Session exists'); + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return $this->sessionConfiguration->hasSession($this->requestStack->getCurrentRequest()) ? '1' : '0'; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata() { + return new CacheableMetadata(); + } + +} diff --git a/core/modules/big_pipe/src/Controller/BigPipeController.php b/core/modules/big_pipe/src/Controller/BigPipeController.php new file mode 100644 index 0000000..c471806 --- /dev/null +++ b/core/modules/big_pipe/src/Controller/BigPipeController.php @@ -0,0 +1,62 @@ +cookies->has(BigPipeStrategy::NOJS_COOKIE) || $request->getSession() === NULL) { + throw new AccessDeniedHttpException(); + } + + if (!$request->query->has('destination')) { + throw new HttpException(500, 'The original location is missing.'); + } + + $response = new LocalRedirectResponse($request->query->get('destination')); + $response->headers->setCookie(new Cookie(BigPipeStrategy::NOJS_COOKIE, TRUE)); + return $response; + } + +} diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php new file mode 100644 index 0000000..9170fe2 --- /dev/null +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -0,0 +1,162 @@ +bigPipe = $big_pipe; + } + + /** + * Adds markers to the response necessary for the BigPipe render strategy. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespondEarly(FilterResponseEvent $event) { + // It does not make sense to have BigPipe responses for subrequests. BigPipe + // is never useful internally in Drupal, only externally towards end users. + $response = $event->getResponse(); + $is_eligible = $event->isMasterRequest() && $response instanceof HtmlResponse; + $event->getRequest()->attributes->set(self::ATTRIBUTE_ELIGIBLE, $is_eligible); + if (!$is_eligible) { + return; + } + + // Wrap the scripts_bottom placeholder with a marker before and after, + // because \Drupal\big_pipe\Render\BigPipe needs to be able to extract that + // markup if there are no-JS BigPipe placeholders. + // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody() + $attachments = $response->getAttachments(); + if (isset($attachments['html_response_attachment_placeholders']['scripts_bottom'])) { + $scripts_bottom_placeholder = $attachments['html_response_attachment_placeholders']['scripts_bottom']; + $content = $response->getContent(); + $content = str_replace($scripts_bottom_placeholder, '' . $scripts_bottom_placeholder . '', $content); + $response->setContent($content); + } + } + + /** + * Transforms a HtmlResponse to a BigPipeResponse. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + // Early return if this response was already found to not be eligible. + // @see onRespondEarly() + if (!$event->getRequest()->attributes->get(self::ATTRIBUTE_ELIGIBLE)) { + return; + } + + $response = $event->getResponse(); + $attachments = $response->getAttachments(); + + // If there are no no-JS BigPipe placeholders, unwrap the scripts_bottom + // markup. + // @see onRespondEarly() + // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody() + if (empty($attachments['big_pipe_nojs_placeholders'])) { + $content = $response->getContent(); + $content = str_replace('', '', $content); + $response->setContent($content); + } + + // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders, + // there isn't anything dynamic in this response, and we can return early: + // there is no point in sending this response using BigPipe. + if (empty($attachments['big_pipe_placeholders']) && empty($attachments['big_pipe_nojs_placeholders'])) { + return; + } + + // Create a new BigPipeResponse. + $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'); + + $event->setResponse($big_pipe_response); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run after HtmlResponsePlaceholderStrategySubscriber (priority 5), i.e. + // after BigPipeStrategy has been applied, but before normal (priority 0) + // response subscribers have been applied, because by then it'll be too late + // to transform it into a BigPipeResponse. + $events[KernelEvents::RESPONSE][] = ['onRespondEarly', 3]; + + // Run as the last possible 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..abe8133 --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -0,0 +1,427 @@ +renderer = $renderer; + $this->session = $session; + $this->requestStack = $request_stack; + $this->httpKernel = $http_kernel; + $this->eventDispatcher = $event_dispatcher; + } + + /** + * {@inheritdoc} + */ + public function sendContent($content, array $attachments) { + // 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'] : []; + + // BigPipe sends responses using "Transfer-Encoding: chunked". To avoid + // sending already-sent assets, it is necessary to track cumulative assets + // from all previously rendered/sent chunks. + // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.41 + $cumulative_assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]); + $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $attachments['drupalSettings']['ajaxPageState']['libraries'])); + + // 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(); + + list($pre_body, $post_body) = explode('', $content, 2); + $this->sendPreBody($pre_body, $nojs_placeholders, $cumulative_assets); + $this->sendPlaceholders($placeholders, $this->getPlaceholderOrder($pre_body), $cumulative_assets); + $this->sendPostBody($post_body); + + // Close the session again. + $this->session->save(); + + return $this; + } + + /** + * Sends everything until just before . + * + * @param string $pre_body + * The HTML response's content until the closing tag. + * @param array $no_js_placeholders + * The no-JS BigPipe placeholders. + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering no-JS + * BigPipe 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- + // part of the page immediately. + if (empty($no_js_placeholders)) { + print $pre_body; + flush(); + return; + } + + // 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; + + $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. + if ($cumulative_assets_initial != $cumulative_assets) { + // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent + // before the HTML they're associated with. + // @see \Drupal\Core\Render\HtmlResponseSubscriber + // @see template_preprocess_html() + $js_bottom_placeholder = ''; + + $html_response = new HtmlResponse(); + $html_response->setContent([ + '#markup' => Markup::create($js_bottom_placeholder), + '#attached' => [ + 'drupalSettings' => $cumulative_assets->getSettings(), + 'library' => $cumulative_assets->getAlreadyLoadedLibraries(), + 'html_response_attachment_placeholders' => [ + 'scripts_bottom' => $js_bottom_placeholder, + ], + ], + ]); + $html_response->getCacheableMetadata()->setCacheMaxAge(0); + + // Push a fake request with the asset libraries loaded so far and dispatch + // KernelEvents::RESPONSE event. This results in the attachments for the + // HTML response being processed by HtmlResponseAttachmentsProcessor and + // hence the HTML to load the bottom JavaScript can be rendered. + $fake_request = $this->requestStack->getMasterRequest()->duplicate(); + $this->requestStack->push($fake_request); + $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $html_response); + $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event); + $this->requestStack->pop(); + $scripts_bottom = $event->getResponse()->getContent(); + } + + print $scripts_bottom; + flush(); + } + + /** + * Sends no-JS BigPipe placeholders' replacements as embedded HTML responses. + * + * @param string $html + * HTML markup. + * @param array $no_js_placeholders + * Associative array; the no-JS BigPipe placeholders. Keys are the BigPipe + * selectors. + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering no-JS + * BigPipe placeholders. + */ + protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) { + $fragments = explode('
', $fragment, 2); + $placeholder = $t[0]; + if (!isset($no_js_placeholders[$placeholder])) { + continue; + } + + $token = Crypt::randomBytesBase64(55); + + // Render the placeholder, but include the cumulative settings assets, so + // we can calculate the overall settings for the entire page. + $placeholder_plus_cumulative_settings = [ + 'placeholder' => $no_js_placeholders[$placeholder], + 'cumulative_settings_' . $token => [ + '#attached' => [ + 'drupalSettings' => $cumulative_assets->getSettings(), + ], + ], + ]; + $elements = $this->renderPlaceholder($placeholder, $placeholder_plus_cumulative_settings); + + // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent + // before the HTML they're associated with. In other words: ensure the + // critical assets for this placeholder's markup are loaded first. + // @see \Drupal\Core\Render\HtmlResponseSubscriber + // @see template_preprocess_html() + $css_placeholder = ''; + $js_placeholder = ''; + $elements['#markup'] = Markup::create($css_placeholder . $js_placeholder . (string) $elements['#markup']); + $elements['#attached']['html_response_attachment_placeholders']['styles'] = $css_placeholder; + $elements['#attached']['html_response_attachment_placeholders']['scripts'] = $js_placeholder; + + $html_response = new HtmlResponse(); + $html_response->setContent($elements); + $html_response->getCacheableMetadata()->setCacheMaxAge(0); + + // Push a fake request with the asset libraries loaded so far and dispatch + // KernelEvents::RESPONSE event. This results in the attachments for the + // HTML response being processed by HtmlResponseAttachmentsProcessor and + // hence: + // - the HTML to load the CSS can be rendered. + // - the HTML to load the JS (at the top) can be rendered. + $fake_request = $this->requestStack->getMasterRequest()->duplicate(); + $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())] + $cumulative_assets->getSettings()['ajaxPageState']); + $this->requestStack->push($fake_request); + $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $html_response); + $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event); + $html_response = $event->getResponse(); + $this->requestStack->pop(); + + // Send this embedded HTML response. + print $html_response->getContent(); + print $t[1]; + flush(); + + // Another placeholder was rendered and sent, track the set of asset + // libraries sent so far. Any new settings also need to be tracked, so + // they can be sent in ::sendPreBody(). + // @todo What if drupalSettings already was printed in the HTML ? That case is not yet handled. In that case, no-JS BigPipe would cause broken (incomplete) drupalSettings… This would not matter if it were only used if JS is not enabled, but that's not the only use case. However, this + $final_settings = $html_response->getAttachments()['drupalSettings']; + $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $final_settings['ajaxPageState']['libraries'])); + $cumulative_assets->setSettings($final_settings); + } + } + + /** + * Sends BigPipe placeholders' replacements as embedded AJAX responses. + * + * @param array $placeholders + * Associative array; the BigPipe placeholders. Keys are the BigPipe + * selectors. + * @param array $placeholder_order + * Indexed array; the order in which the BigPipe placeholders must be sent. + * Values are the BigPipe selectors. (These values correspond to keys in + * $placeholders.) + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering BigPipe + * placeholders. + */ + protected function sendPlaceholders(array $placeholders, array $placeholder_order, AttachedAssetsInterface $cumulative_assets) { + // Return early if there are no BigPipe placeholders to send. + if (empty($placeholders)) { + return; + } + + // Send a container and the start signal. + print "\n"; + print '' . "\n"; + flush(); + + // A BigPipe response consists of a HTML response plus multiple embedded + // AJAX responses. To process the attachments of those AJAX responses, we + // need a fake request that is identical to the master request, but with + // one change: it must have the right Accept header, otherwise the work- + // around for a bug in IE9 will cause not JSON, but