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; +}