diff --git a/.eslintrc b/.eslintrc
index cceeffc6b..6b468741f 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -9,6 +9,6 @@
"es6": true
},
"parserOptions": {
- "ecmaVersion": 8
+ "ecmaVersion": 2018
}
}
diff --git a/.jshintignore b/.jshintignore
index f34ecdf82..032baa156 100644
--- a/.jshintignore
+++ b/.jshintignore
@@ -1,3 +1,4 @@
wp-includes/js/service-worker.js
-wp-includes/js/service-worker-error-response-handling.js
+wp-includes/js/service-worker-navigation-routing.js
wp-includes/js/service-worker-precaching.js
+wp-includes/js/service-worker-stream-combiner.js
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
index 22a0eb829..e41fc2b7e 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -24,6 +24,13 @@
bin/*
+
+
+
+
+
+
+
diff --git a/amp/class-amp-service-worker.php b/amp/class-amp-service-worker.php
index 4862fc4c7..40ce1f49e 100644
--- a/amp/class-amp-service-worker.php
+++ b/amp/class-amp-service-worker.php
@@ -110,7 +110,7 @@ public function add_image_runtime_caching( $service_workers ) {
'/wp-content/.*\.(?:png|gif|jpg|jpeg|svg|webp)(\?.*)?$',
array(
'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST,
- 'cacheName' => 'images',
+ 'cacheName' => 'images', // @todo This needs to get the proper prefix in JS.
'plugins' => array(
'cacheableResponse' => array(
'statuses' => array( 0, 200 ),
diff --git a/pwa.php b/pwa.php
index ca9b11077..e74aa8dd6 100644
--- a/pwa.php
+++ b/pwa.php
@@ -52,7 +52,7 @@
/** WP_Service_Worker_Component Implementation Classes */
require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-configuration-component.php';
-require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-error-response-component.php';
+require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-navigation-routing-component.php';
require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-precaching-routes-component.php';
require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-precaching-routes.php';
require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-caching-routes-component.php';
diff --git a/readme.md b/readme.md
index ebca9621b..2b844dfdf 100644
--- a/readme.md
+++ b/readme.md
@@ -4,15 +4,15 @@
![Banner](wp-assets/banner-1544x500.png)
WordPress feature plugin to bring Progressive Web App (PWA) capabilities to Core
-**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [google](https://profiles.wordpress.org/google), [automattic](https://profiles.wordpress.org/automattic)
-**Tags:** [pwa](https://wordpress.org/plugins/tags/pwa), [progressive web apps](https://wordpress.org/plugins/tags/progressive-web-apps), [service workers](https://wordpress.org/plugins/tags/service-workers), [web app manifest](https://wordpress.org/plugins/tags/web-app-manifest), [https](https://wordpress.org/plugins/tags/https)
-**Requires at least:** 4.9
-**Tested up to:** 4.9
-**Stable tag:** 0.1.0
-**License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html)
-**Requires PHP:** 5.2
-
-[![Build Status](https://travis-ci.org/xwp/pwa-wp.svg?branch=master)](https://travis-ci.org/xwp/pwa-wp) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com)
+**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [google](https://profiles.wordpress.org/google), [automattic](https://profiles.wordpress.org/automattic)
+**Tags:** [pwa](https://wordpress.org/plugins/tags/pwa), [progressive web apps](https://wordpress.org/plugins/tags/progressive-web-apps), [service workers](https://wordpress.org/plugins/tags/service-workers), [web app manifest](https://wordpress.org/plugins/tags/web-app-manifest), [https](https://wordpress.org/plugins/tags/https)
+**Requires at least:** 4.9
+**Tested up to:** 4.9
+**Stable tag:** 0.1.0
+**License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html)
+**Requires PHP:** 5.2
+
+[![Build Status](https://travis-ci.org/xwp/pwa-wp.svg?branch=master)](https://travis-ci.org/xwp/pwa-wp) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com)
## Description ##
diff --git a/wp-includes/class-wp-service-workers.php b/wp-includes/class-wp-service-workers.php
index 2893d4856..850c8cf08 100644
--- a/wp-includes/class-wp-service-workers.php
+++ b/wp-includes/class-wp-service-workers.php
@@ -57,10 +57,10 @@ class WP_Service_Workers implements WP_Service_Worker_Registry_Aware {
*/
public function __construct() {
$components = array(
- 'configuration' => new WP_Service_Worker_Configuration_Component(),
- 'error_response' => new WP_Service_Worker_Error_Response_Component(),
- 'precaching_routes' => new WP_Service_Worker_Precaching_Routes_Component(),
- 'caching_routes' => new WP_Service_Worker_Caching_Routes_Component(),
+ 'configuration' => new WP_Service_Worker_Configuration_Component(),
+ 'navigation_routing' => new WP_Service_Worker_Navigation_Routing_Component(),
+ 'precaching_routes' => new WP_Service_Worker_Precaching_Routes_Component(),
+ 'caching_routes' => new WP_Service_Worker_Caching_Routes_Component(),
);
$this->scripts = new WP_Service_Worker_Scripts( $components );
diff --git a/wp-includes/components/class-wp-service-worker-error-response-component.php b/wp-includes/components/class-wp-service-worker-error-response-component.php
deleted file mode 100644
index f728960c6..000000000
--- a/wp-includes/components/class-wp-service-worker-error-response-component.php
+++ /dev/null
@@ -1,156 +0,0 @@
-Version );
- if ( $template !== $stylesheet ) {
- $revision .= sprintf( ';%s-v%s', $stylesheet, wp_get_theme( $stylesheet )->Version );
- }
-
- // Ensure the user-specific offline/500 pages are precached, and thet they update when user logs out or switches to another user.
- $revision .= sprintf( ';user-%d', get_current_user_id() );
-
- if ( ! is_admin() ) {
- $offline_error_template_file = pwa_locate_template( array( 'offline.php', 'error.php' ) );
- $offline_error_precache_entry = array(
- 'url' => add_query_arg( 'wp_error_template', 'offline', home_url( '/' ) ),
- 'revision' => $revision . ';' . md5( $offline_error_template_file . file_get_contents( $offline_error_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
- );
- $server_error_template_file = pwa_locate_template( array( '500.php', 'error.php' ) );
- $server_error_precache_entry = array(
- 'url' => add_query_arg( 'wp_error_template', '500', home_url( '/' ) ),
- 'revision' => $revision . ';' . md5( $server_error_template_file . file_get_contents( $server_error_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
- );
-
- /**
- * Filters what is precached to serve as the offline error response on the frontend.
- *
- * The URL returned in this array will be precached by the service worker and served as the response when
- * the client is offline or their connection fails. To prevent this behavior, this value can be filtered
- * to return false. When a theme or plugin makes a change to the response, the revision value in the array
- * must be incremented to ensure the URL is re-fetched to store in the precache.
- *
- * @since 0.2
- *
- * @param array|false $entry {
- * Offline error precache entry.
- *
- * @type string $url URL to page that shows the offline error template.
- * @type string $revision Revision for the template. This defaults to the template and stylesheet names, with their respective theme versions.
- * }
- */
- $offline_error_precache_entry = apply_filters( 'wp_offline_error_precache_entry', $offline_error_precache_entry );
-
- /**
- * Filters what is precached to serve as the internal server error response on the frontend.
- *
- * The URL returned in this array will be precached by the service worker and served as the response when
- * the server returns a 500 internal server error . To prevent this behavior, this value can be filtered
- * to return false. When a theme or plugin makes a change to the response, the revision value in the array
- * must be incremented to ensure the URL is re-fetched to store in the precache.
- *
- * @since 0.2
- *
- * @param array $entry {
- * Server error precache entry.
- *
- * @type string $url URL to page that shows the server error template.
- * @type string $revision Revision for the template. This defaults to the template and stylesheet names, with their respective theme versions.
- * }
- */
- $server_error_precache_entry = apply_filters( 'wp_server_error_precache_entry', $server_error_precache_entry );
-
- } else {
- $offline_error_precache_entry = array(
- 'url' => add_query_arg( 'code', 'offline', admin_url( 'admin-ajax.php?action=wp_error_template' ) ), // Upon core merge, this would use admin_url( 'error.php' ).
- 'revision' => PWA_VERSION, // Upon core merge, this should be the core version.
- );
- $server_error_precache_entry = array(
- 'url' => add_query_arg( 'code', '500', admin_url( 'admin-ajax.php?action=wp_error_template' ) ), // Upon core merge, this would use admin_url( 'error.php' ).
- 'revision' => PWA_VERSION, // Upon core merge, this should be the core version.
- );
- }
-
- $scripts->register(
- 'wp-error-response',
- array(
- 'src' => array( $this, 'get_script' ),
- 'deps' => array( 'wp-base-config' ),
- )
- );
-
- if ( $offline_error_precache_entry ) {
- $scripts->precaching_routes()->register( $offline_error_precache_entry['url'], isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null );
- }
- if ( $server_error_precache_entry ) {
- $scripts->precaching_routes()->register( $server_error_precache_entry['url'], isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null );
- }
-
- $blacklist_patterns = array();
- if ( ! is_admin() ) {
- $blacklist_patterns[] = '^' . preg_quote( untrailingslashit( wp_parse_url( admin_url(), PHP_URL_PATH ) ), '/' ) . '($|\?.*|/.*)';
- }
-
- $this->replacements = array(
- 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null,
- 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null,
- 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ),
- );
- }
-
- /**
- * Gets the priority this component should be hooked into the service worker action with.
- *
- * @since 0.2
- *
- * @return int Hook priority. A higher number means a lower priority.
- */
- public function get_priority() {
- return -99999;
- }
-
- /**
- * Get script for handling of error responses when the user is offline or when there is an internal server error.
- *
- * @return string Script.
- */
- public function get_script() {
- $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-error-response-handling.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
- $script = preg_replace( '#/\*\s*global.+?\*/#', '', $script );
-
- return str_replace(
- array_keys( $this->replacements ),
- array_values( $this->replacements ),
- $script
- );
- }
-}
diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php
new file mode 100644
index 000000000..4238ee1fe
--- /dev/null
+++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php
@@ -0,0 +1,485 @@
+', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) );
+ if ( 'header' === $stream_fragment ) {
+ if ( ! $loading_content ) {
+ $loading_content = esc_html__( 'Loading…', 'pwa' );
+ }
+ echo $loading_content; // WPCS: XSS OK.
+ }
+ echo '';
+
+ if ( $is_header ) {
+ $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ); // phpcs:ignore
+ $vars = array(
+ 'STREAM_COMBINE_INVOKE_SCRIPT_ID' => wp_json_encode( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ),
+ 'STREAM_COMBINE_DEFINE_SCRIPT_ID' => wp_json_encode( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ),
+ 'STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID' => wp_json_encode( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ),
+ );
+ $script = str_replace(
+ array_keys( $vars ),
+ array_values( $vars ),
+ $script
+ );
+
+ printf(
+ '',
+ esc_attr( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ),
+ $script
+ ); // WPCS: XSS OK.
+ }
+
+ // Short-circuit the response when requesting the header since there is nothing left to stream.
+ if ( $is_header ) {
+ exit;
+ }
+
+ // Handle serving the body. Normally it is output-buffered here. A plugin can disable the default output buffering to handle it later.
+ $is_header_buffered = (
+ false !== has_action( 'template_redirect', 'WP_Service_Worker_Navigation_Routing_Component::start_output_buffering_stream_fragment' )
+ &&
+ ob_get_level() > 0
+ );
+ if ( $is_header_buffered ) {
+ $header_html = ob_get_clean();
+ $libxml_use_errors = libxml_use_internal_errors( true );
+ $header_html = preg_replace( '##s', '', $header_html ); // Some libxml versions croak at noscript in head.
+ $dom = new DOMDocument();
+ $result = $dom->loadHTML( $header_html );
+ libxml_clear_errors();
+ libxml_use_internal_errors( $libxml_use_errors );
+ if ( ! $result ) {
+ wp_die( esc_html__( 'Failed to turn header into document.', 'pwa' ) );
+ }
+ $response = self::get_header_combine_invoke_script( $dom, true );
+ echo $response; // WPCS: XSS OK.
+ }
+ }
+
+ /**
+ * Get script for adding to the beginning of the body fragment to combine it with the header.
+ *
+ * @since 0.2
+ *
+ * @param DOMDocument $dom Document.
+ * @param bool $serialized Whether to return the script as HTML (true) or a DOM element (false).
+ * @return DOMElement|string DOM element or HTML string for script element containing header data.
+ */
+ public static function get_header_combine_invoke_script( $dom, $serialized = true ) {
+ $data = array(
+ // @todo Add html_attributes?
+ 'head_nodes' => array(),
+ 'body_attributes' => array(),
+ );
+ $head = $dom->getElementsByTagName( 'head' )->item( 0 );
+ if ( $head ) {
+ foreach ( $head->childNodes as $node ) {
+ if ( $node instanceof DOMElement ) {
+ if ( 'noscript' === $node->nodeName ) {
+ continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated.
+ }
+ $element = array(
+ $node->nodeName,
+ null,
+ );
+ if ( $node->hasAttributes() ) {
+ $element[1] = array();
+ foreach ( $node->attributes as $attribute ) {
+ $element[1][ $attribute->nodeName ] = $attribute->nodeValue;
+ }
+ }
+ if ( $node->firstChild instanceof DOMText ) {
+ $element[] = $node->firstChild->nodeValue;
+ }
+ $data['head_nodes'][] = $element;
+ } elseif ( $node instanceof DOMComment ) {
+ $data['head_nodes'][] = array(
+ '#comment',
+ $node->nodeValue,
+ );
+ }
+ }
+ }
+
+ // @todo Also obtain classes used in nav menus to then synchronize?
+ $body = $dom->getElementsByTagName( 'body' )->item( 0 );
+ if ( $body ) {
+ foreach ( $body->attributes as $attribute ) {
+ $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue;
+ }
+ }
+
+ if ( $serialized ) {
+ return sprintf(
+ '',
+ esc_attr( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ),
+ wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core.
+ );
+ } else {
+ $script = $dom->createElement( 'script' );
+ $script->setAttribute( 'id', self::STREAM_COMBINE_INVOKE_SCRIPT_ID );
+ $script->appendChild(
+ $dom->createTextNode(
+ sprintf( 'wpStreamCombine( %s )', wp_json_encode( $data, JSON_PRETTY_PRINT ) ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core.
+ )
+ );
+ return $script;
+ }
+ }
+
+ /**
+ * Adds the component functionality to the service worker.
+ *
+ * @since 0.2
+ *
+ * @param WP_Service_Worker_Scripts $scripts Instance to register service worker behavior with.
+ */
+ public function serve( WP_Service_Worker_Scripts $scripts ) {
+ $template = get_template();
+ $stylesheet = get_stylesheet();
+
+ $should_stream_response = ! is_admin() && current_theme_supports( self::STREAM_THEME_SUPPORT );
+ $stream_combiner_revision = '';
+ if ( $should_stream_response ) {
+ $stream_combiner_revision = md5( file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ }
+
+ $revision = sprintf( '%s-v%s', $template, wp_get_theme( $template )->Version );
+ if ( $template !== $stylesheet ) {
+ $revision .= sprintf( ';%s-v%s', $stylesheet, wp_get_theme( $stylesheet )->Version );
+ }
+
+ // Ensure the user-specific offline/500 pages are precached, and thet they update when user logs out or switches to another user.
+ $revision .= sprintf( ';user-%d', get_current_user_id() );
+
+ if ( ! is_admin() ) {
+ $offline_error_template_file = pwa_locate_template( array( 'offline.php', 'error.php' ) );
+ $offline_error_precache_entry = array(
+ 'url' => add_query_arg( 'wp_error_template', 'offline', home_url( '/' ) ),
+ 'revision' => $revision . ';' . md5( $offline_error_template_file . file_get_contents( $offline_error_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ );
+ $server_error_template_file = pwa_locate_template( array( '500.php', 'error.php' ) );
+ $server_error_precache_entry = array(
+ 'url' => add_query_arg( 'wp_error_template', '500', home_url( '/' ) ),
+ 'revision' => $revision . ';' . md5( $server_error_template_file . file_get_contents( $server_error_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ );
+
+ /**
+ * Filters what is precached to serve as the offline error response on the frontend.
+ *
+ * The URL returned in this array will be precached by the service worker and served as the response when
+ * the client is offline or their connection fails. To prevent this behavior, this value can be filtered
+ * to return false. When a theme or plugin makes a change to the response, the revision value in the array
+ * must be incremented to ensure the URL is re-fetched to store in the precache.
+ *
+ * @since 0.2
+ *
+ * @param array|false $entry {
+ * Offline error precache entry.
+ *
+ * @type string $url URL to page that shows the offline error template.
+ * @type string $revision Revision for the template. This defaults to the template and stylesheet names, with their respective theme versions.
+ * }
+ */
+ $offline_error_precache_entry = apply_filters( 'wp_offline_error_precache_entry', $offline_error_precache_entry );
+
+ /**
+ * Filters what is precached to serve as the internal server error response on the frontend.
+ *
+ * The URL returned in this array will be precached by the service worker and served as the response when
+ * the server returns a 500 internal server error . To prevent this behavior, this value can be filtered
+ * to return false. When a theme or plugin makes a change to the response, the revision value in the array
+ * must be incremented to ensure the URL is re-fetched to store in the precache.
+ *
+ * @since 0.2
+ *
+ * @param array $entry {
+ * Server error precache entry.
+ *
+ * @type string $url URL to page that shows the server error template.
+ * @type string $revision Revision for the template. This defaults to the template and stylesheet names, with their respective theme versions.
+ * }
+ */
+ $server_error_precache_entry = apply_filters( 'wp_server_error_precache_entry', $server_error_precache_entry );
+
+ } else {
+ $offline_error_precache_entry = array(
+ 'url' => add_query_arg( 'code', 'offline', admin_url( 'admin-ajax.php?action=wp_error_template' ) ), // Upon core merge, this would use admin_url( 'error.php' ).
+ 'revision' => PWA_VERSION, // Upon core merge, this should be the core version.
+ );
+ $server_error_precache_entry = array(
+ 'url' => add_query_arg( 'code', '500', admin_url( 'admin-ajax.php?action=wp_error_template' ) ), // Upon core merge, this would use admin_url( 'error.php' ).
+ 'revision' => PWA_VERSION, // Upon core merge, this should be the core version.
+ );
+ }
+
+ $scripts->register(
+ 'wp-navigation-routing',
+ array(
+ 'src' => array( $this, 'get_script' ),
+ 'deps' => array( 'wp-base-config' ),
+ )
+ );
+
+ if ( $offline_error_precache_entry ) {
+ $scripts->precaching_routes()->register( $offline_error_precache_entry['url'], isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null );
+ if ( $should_stream_response ) {
+ $scripts->precaching_routes()->register(
+ add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ),
+ ( isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : '' ) . $stream_combiner_revision
+ );
+ }
+ }
+ if ( $server_error_precache_entry ) {
+ $scripts->precaching_routes()->register( $server_error_precache_entry['url'], isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null );
+ if ( $should_stream_response ) {
+ $scripts->precaching_routes()->register(
+ add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ),
+ ( isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : '' ) . $stream_combiner_revision
+ );
+ }
+ }
+
+ // Streaming.
+ $streaming_header_precache_entry = null;
+ if ( $should_stream_response ) {
+ $header_template_file = locate_template( array( 'header.php' ) );
+ $streaming_header_precache_entry = array(
+ 'url' => add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'header', home_url( '/' ) ),
+ 'revision' => $revision . ';' . md5( $header_template_file . file_get_contents( $header_template_file ) ) . $stream_combiner_revision, // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ );
+
+ /**
+ * Filters what is precached to serve as the streaming header.
+ *
+ * @since 0.2
+ *
+ * @param array|false $entry {
+ * Offline error precache entry.
+ *
+ * @type string $url URL to streaming header fragment.
+ * @type string $revision Revision for the entry. Care must be taken to keep this updated based on the content that is output before the stream boundary.
+ * }
+ */
+ $streaming_header_precache_entry = apply_filters( 'wp_streaming_header_precache_entry', $streaming_header_precache_entry );
+
+ if ( $streaming_header_precache_entry ) {
+ $scripts->precaching_routes()->register( $streaming_header_precache_entry['url'], isset( $streaming_header_precache_entry['revision'] ) ? $streaming_header_precache_entry['revision'] : null );
+
+ add_filter( 'wp_service_worker_navigation_preload', '__return_false' ); // Navigation preload and streaming don't mix!
+ }
+ }
+
+ $this->replacements = array(
+ 'ERROR_OFFLINE_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? $offline_error_precache_entry['url'] : null ),
+ 'ERROR_OFFLINE_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ) : null ),
+ 'ERROR_500_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? $server_error_precache_entry['url'] : null ),
+ 'ERROR_500_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ) : null ),
+ 'STREAM_HEADER_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $streaming_header_precache_entry['url'] ) ? $streaming_header_precache_entry['url'] : null ),
+ 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $this->get_blacklist_patterns() ),
+ 'SHOULD_STREAM_RESPONSE' => wp_service_worker_json_encode( $should_stream_response ),
+ 'STREAM_HEADER_FRAGMENT_QUERY_VAR' => wp_service_worker_json_encode( self::STREAM_FRAGMENT_QUERY_VAR ),
+ );
+ }
+
+ /**
+ * Get blacklist patterns for routes to exclude from navigation route handling.
+ *
+ * @since 0.2
+ * @todo This list should probably be filterable.
+ *
+ * @return array Route regular expressions.
+ */
+ public function get_blacklist_patterns() {
+ $blacklist_patterns = array();
+
+ // Exclude admin URLs.
+ $blacklist_patterns[] = '^' . preg_quote( untrailingslashit( wp_parse_url( admin_url(), PHP_URL_PATH ) ), '/' ) . '($|\?.*|/.*)';
+
+ // Exclude REST API.
+ $blacklist_patterns[] = '^' . preg_quote( wp_parse_url( get_rest_url(), PHP_URL_PATH ), '/' ) . '.*';
+
+ // Exclude PHP files (e.g. wp-login.php).
+ $blacklist_patterns[] = '[^\?]*.\.php($|\?.*)';
+
+ // Exclude service worker and stream fragment requests (to ease debugging).
+ $blacklist_patterns[] = '.*\?(.*&)?(' . join( '|', array( self::STREAM_FRAGMENT_QUERY_VAR, WP_Service_Workers::QUERY_VAR ) ) . ')=';
+
+ // Exclude feed requests.
+ $blacklist_patterns[] = '[^\?]*\/feed\/(\w+\/)?$';
+
+ return $blacklist_patterns;
+ }
+
+ /**
+ * Gets the priority this component should be hooked into the service worker action with.
+ *
+ * @since 0.2
+ *
+ * @return int Hook priority. A higher number means a lower priority.
+ */
+ public function get_priority() {
+ return -99999;
+ }
+
+ /**
+ * Get script for routing navigation requests.
+ *
+ * @return string Script.
+ */
+ public function get_script() {
+ $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-navigation-routing.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $script = preg_replace( '#/\*\s*global.+?\*/#', '', $script );
+
+ return str_replace(
+ array_keys( $this->replacements ),
+ array_values( $this->replacements ),
+ $script
+ );
+ }
+}
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index d89a1d04a..422bd4c4f 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -16,9 +16,13 @@
add_action( 'wp_ajax_wp_service_worker', 'wp_ajax_wp_service_worker' );
add_action( 'wp_ajax_nopriv_wp_service_worker', 'wp_ajax_wp_service_worker' );
add_action( 'parse_query', 'wp_hide_admin_bar_offline' );
+add_filter( 'old_slug_redirect_url', 'wp_service_worker_fragment_redirect_old_slug_to_new_url' );
add_action( 'wp_head', 'wp_add_error_template_no_robots' );
+add_filter( 'pre_get_document_title', 'WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header' );
add_action( 'error_head', 'wp_add_error_template_no_robots' );
add_action( 'wp_default_service_workers', 'wp_default_service_workers' );
add_action( 'admin_init', 'wp_disable_script_concatenation' );
+
+add_action( 'template_redirect', 'WP_Service_Worker_Navigation_Routing_Component::start_output_buffering_stream_fragment', PHP_INT_MAX );
diff --git a/wp-includes/integrations/class-wp-service-worker-fonts-integration.php b/wp-includes/integrations/class-wp-service-worker-fonts-integration.php
index dd85d7908..92d23f572 100644
--- a/wp-includes/integrations/class-wp-service-worker-fonts-integration.php
+++ b/wp-includes/integrations/class-wp-service-worker-fonts-integration.php
@@ -24,7 +24,7 @@ public function register( WP_Service_Worker_Scripts $scripts ) {
'^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/(.*)',
array(
'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST,
- 'cacheName' => 'googleapis',
+ 'cacheName' => 'googleapis', // @todo This needs to get the proper prefix in JS.
'plugins' => array(
'cacheableResponse' => array(
'statuses' => array( 0, 200 ),
diff --git a/wp-includes/js/service-worker-error-response-handling.js b/wp-includes/js/service-worker-error-response-handling.js
deleted file mode 100644
index 4c4478eb7..000000000
--- a/wp-includes/js/service-worker-error-response-handling.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, BLACKLIST_PATTERNS */
-
-wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute(
- async function ( { event } ) {
- const { url } = event.request;
-
- const handleResponse = ( response ) => {
- if ( response.status < 500 ) {
- return response;
- }
- const channel = new BroadcastChannel( 'wordpress-server-errors' );
-
- // Wait for client to request the error message.
- channel.onmessage = ( event ) => {
- if ( event.data && event.data.clientUrl && url === event.data.clientUrl ) {
- response.text().then( ( text ) => {
- channel.postMessage({
- requestUrl: url,
- bodyText: text,
- status: response.status,
- statusText: response.statusText
- });
- channel.close();
- } );
- }
- };
-
- // Close the channel if client did not request the message within 30 seconds.
- setTimeout( () => {
- channel.close();
- }, 30 * 1000 );
-
- return caches.match( ERROR_500_URL );
- };
-
- const sendOfflineResponse = () => {
- return caches.match( ERROR_OFFLINE_URL );
- };
-
- /*
- * If navigation preload is enabled, use the preload request instead of doing another fetch.
- * This prevents requests from being duplicated. See .
- */
- if ( event.preloadResponse ) {
- try {
- const response = await event.preloadResponse;
- if ( response ) {
- return handleResponse( response );
- }
- } catch ( error ) {
- return sendOfflineResponse();
- }
- }
-
- return fetch( event.request )
- .then( handleResponse )
- .catch( sendOfflineResponse );
- },
- {
- blacklist: BLACKLIST_PATTERNS.map( ( pattern ) => new RegExp( pattern ) )
- }
-) );
diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js
new file mode 100644
index 000000000..1b4f844ef
--- /dev/null
+++ b/wp-includes/js/service-worker-navigation-routing.js
@@ -0,0 +1,121 @@
+/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, SHOULD_STREAM_RESPONSE, STREAM_HEADER_FRAGMENT_URL, ERROR_500_BODY_FRAGMENT_URL, ERROR_OFFLINE_BODY_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */
+
+const isStreamingResponses = SHOULD_STREAM_RESPONSE && wp.serviceWorker.streams.isSupported();
+
+wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute(
+ async function ( { event } ) {
+ const { url } = event.request;
+
+ let responsePreloaded = false;
+
+ const canStreamResponse = () => {
+ return isStreamingResponses && ! responsePreloaded;
+ };
+
+ const handleResponse = ( response ) => {
+ if ( response.status < 500 ) {
+ if ( response.redirected ) {
+ const redirectedUrl = new URL( response.url );
+ redirectedUrl.searchParams.delete( STREAM_HEADER_FRAGMENT_QUERY_VAR );
+ const script = `
+
+ `;
+ return response.text().then( ( body ) => {
+ return new Response( script + body );
+ } );
+ } else {
+ return response;
+ }
+ }
+ const channel = new BroadcastChannel( 'wordpress-server-errors' );
+
+ // Wait for client to request the error message.
+ channel.onmessage = ( event ) => {
+ if ( event.data && event.data.clientUrl && url === event.data.clientUrl ) {
+ response.text().then( ( text ) => {
+ channel.postMessage({
+ requestUrl: url,
+ bodyText: text,
+ status: response.status,
+ statusText: response.statusText
+ });
+ channel.close();
+ } );
+ }
+ };
+
+ // Close the channel if client did not request the message within 30 seconds.
+ setTimeout( () => {
+ channel.close();
+ }, 30 * 1000 );
+
+ return caches.match( canStreamResponse() ? ERROR_500_BODY_FRAGMENT_URL : ERROR_500_URL );
+ };
+
+ const sendOfflineResponse = () => {
+ return caches.match( canStreamResponse() ? ERROR_OFFLINE_BODY_FRAGMENT_URL : ERROR_OFFLINE_URL );
+ };
+
+ /*
+ * If navigation preload is enabled, use the preload request instead of doing another fetch.
+ * This prevents requests from being duplicated. See .
+ */
+ if ( event.preloadResponse ) {
+ try {
+ const response = await event.preloadResponse;
+ if ( response ) {
+ responsePreloaded = true;
+ return handleResponse( response );
+ }
+ } catch ( error ) {
+ responsePreloaded = true;
+ return sendOfflineResponse();
+ }
+ }
+
+ if ( canStreamResponse() ) {
+ const streamHeaderFragmentURL = STREAM_HEADER_FRAGMENT_URL;
+ const precacheStrategy = wp.serviceWorker.strategies.cacheFirst({
+ cacheName: wp.serviceWorker.core.cacheNames.precache,
+ });
+
+ const url = new URL( event.request.url );
+ url.searchParams.append( STREAM_HEADER_FRAGMENT_QUERY_VAR, 'body' );
+ const init = {
+ mode: 'same-origin'
+ };
+ const copiedProps = [
+ 'method',
+ 'headers',
+ 'credentials',
+ 'cache',
+ 'redirect',
+ 'referrer',
+ 'integrity',
+ ];
+ for ( const initProp of copiedProps ) {
+ init[ initProp ] = event.request[ initProp ];
+ }
+ const request = new Request( url.toString(), init );
+
+ const stream = wp.serviceWorker.streams.concatenateToResponse([
+ precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode.
+ fetch( request )
+ .then( handleResponse )
+ .catch( sendOfflineResponse ),
+ ]);
+
+ return stream.response;
+ } else {
+ return fetch( event.request )
+ .then( handleResponse )
+ .catch( sendOfflineResponse );
+ }
+ },
+ {
+ blacklist: BLACKLIST_PATTERNS.map( ( pattern ) => new RegExp( pattern ) )
+ }
+) );
diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js
new file mode 100644
index 000000000..f95472640
--- /dev/null
+++ b/wp-includes/js/service-worker-stream-combiner.js
@@ -0,0 +1,141 @@
+/*global STREAM_COMBINE_INVOKE_SCRIPT_ID, STREAM_COMBINE_DEFINE_SCRIPT_ID, STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID */
+
+"use strict";
+/* This JS file will be added as an inline script in a stream header fragment response. */
+/* This file currently uses JS features which are compatible with Chrome 40 (Googlebot). */
+
+/**
+ * Apply the stream body data to the stream header.
+ *
+ * @param {Array} data - Data collected from the DOMDocument prior to the stream boundary.
+ * @param {Array} data.head_nodes - Nodes in HEAD.
+ * @param {Object} data.body_attributes - Attributes on body.
+ */
+function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */
+ const processedHeadNodeData = new WeakSet();
+ const processedHeadElements = new WeakSet();
+
+ /**
+ * Determine if a given element matches the nodeData coming from the body fragment.
+ *
+ * Returns true if the element name is the same and its attributes are equal. Node textContent is not matched.
+ *
+ * @param {Element} element - Element in head to compare.
+ * @param {Object} newElementData - Head node data to compare with.
+ * @returns {boolean} Matching.
+ */
+ const isElementMatchingData = ( element, newElementData ) => {
+ if ( element.nodeName.toLowerCase() !== newElementData[0] ) {
+ return false;
+ }
+
+ const elementAttributes = Array.from( element.attributes )
+ .map( ( attribute ) => {
+ return attribute.nodeName + '=' + attribute.nodeValue;
+ } )
+ .sort().join( ';' );
+
+ const dataAttributes = Object.entries( newElementData[ 1 ] || {} )
+ .map( ( [ name, value ] ) => {
+ return name + '=' + value;
+ } )
+ .sort().join( ';' );
+
+ return elementAttributes === dataAttributes;
+ };
+
+ // Find identical nodes and update text content of any elements with matching attributes.
+ data.head_nodes
+ .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] )
+ .forEach( ( headNodeData ) => {
+ const headChildElement = Array.from( document.head.getElementsByTagName( headNodeData[ 0 ] ) ).find( ( element ) => {
+ return ! processedHeadElements.has( element ) && isElementMatchingData( element, headNodeData )
+ } );
+ if ( ! headChildElement ) {
+ return;
+ }
+
+ // Update node text if different.
+ if ( 'undefined' !== typeof headNodeData[ 2 ] && headChildElement.firstChild && headChildElement.firstChild.nodeType === 3 && headNodeData[ 2 ] !== headChildElement.firstChild.nodeValue ) {
+ headChildElement.firstChild.nodeValue = headNodeData[ 2 ];
+ }
+
+ processedHeadElements.add( headChildElement );
+ processedHeadNodeData.add( headNodeData );
+ } );
+
+ // Now create elements for each of the remaining.
+ data.head_nodes
+ .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] && ! processedHeadNodeData.has( headNodeData ) )
+ .forEach( ( headNodeData ) => {
+ const element = document.createElement( headNodeData[ 0 ] );
+ for ( const [ name, value ] of Object.entries( headNodeData[ 1 ] || {} ) ) {
+ element.setAttribute( name, value );
+ }
+ document.head.appendChild( element );
+ processedHeadElements.add( element );
+ } );
+
+ // Remove links and meta which are probably all stale in the header.
+ const preservedLinkRels = new Set( [
+ 'EditURI',
+ 'apple-touch-icon-precomposed',
+ 'dns-prefetch',
+ 'https://api.w.org/',
+ 'icon',
+ 'pingback',
+ 'preconnect',
+ 'preload',
+ 'profile',
+ 'stylesheet',
+ 'wlwmanifest'
+ ] );
+ Array.from( document.head.querySelectorAll( 'link[rel]' ) ).forEach( ( link ) => {
+ if ( ! processedHeadElements.has( link ) && ! preservedLinkRels.has( link.rel ) ) {
+ link.remove();
+ }
+ } );
+ const preservedMeta = new Set( [
+ 'viewport',
+ 'generator',
+ 'msapplication-TileImage',
+ ] );
+ Array.from( document.head.querySelectorAll( 'meta[name],meta[property]' ) ).forEach( ( meta ) => {
+ if ( ! processedHeadElements.has( meta ) && ! preservedMeta.has( meta.getAttribute( 'name' ) || meta.getAttribute( 'property' ) ) ) {
+ meta.remove();
+ }
+ } );
+
+ // Replace comments.
+ const pendingCommentNodes = data.head_nodes.filter( ( headNodeData ) => '#comment' === headNodeData[ 0 ] );
+ for ( const node of document.head.childNodes ) {
+ if ( 0 === pendingCommentNodes.length ) {
+ break;
+ }
+ if ( 8 === node.nodeType ) {
+ node.nodeValue = pendingCommentNodes.shift()[ 1 ];
+ }
+ }
+ // Add remaining comments that didn't match up.
+ pendingCommentNodes.forEach( ( headNodeData ) => {
+ document.head.appendChild( document.createTextNode( headNodeData[ 1 ] ) );
+ } );
+
+ // Populate body attributes.
+ for ( const key in data.body_attributes ) {
+ document.body.setAttribute( key, data.body_attributes[ key ] );
+ }
+
+ /* Purge all traces of the stream combination logic to ensure the AMP validator doesn't complain at runtime. */
+ const removedElements = [
+ STREAM_COMBINE_INVOKE_SCRIPT_ID,
+ STREAM_COMBINE_DEFINE_SCRIPT_ID,
+ STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID
+ ];
+ for ( const elementId of removedElements ) {
+ const element = document.getElementById( elementId );
+ if ( element ) {
+ element.parentNode.removeChild( element );
+ }
+ }
+}
diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php
index 4a0f231f3..f6a80468e 100644
--- a/wp-includes/service-workers.php
+++ b/wp-includes/service-workers.php
@@ -318,3 +318,19 @@ function wp_disable_script_concatenation() {
$concatenate_scripts = rest_sanitize_boolean( $_GET['wp_concatenate_scripts'] ); // WPCS: csrf ok, override ok.
}
}
+
+/**
+ * Preserve stream fragment query param on canonical redirects.
+ *
+ * @since 0.2
+ *
+ * @param string $link New URL of the post.
+ * @return string URL to be redirected.
+ */
+function wp_service_worker_fragment_redirect_old_slug_to_new_url( $link ) {
+ $fragment = WP_Service_Worker_Navigation_Routing_Component::get_stream_fragment_query_var();
+ if ( $fragment ) {
+ $link = add_query_arg( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR, $fragment, $link );
+ }
+ return $link;
+}