diff --git a/.dev-lib b/.dev-lib index f84bc03260d..54c1da14764 100644 --- a/.dev-lib +++ b/.dev-lib @@ -18,4 +18,9 @@ function after_wp_install { fi svn export -q "$gutenberg_plugin_svn_url" "$WP_CORE_DIR/src/wp-content/plugins/gutenberg" echo "done" + + echo -n "Installing PWA 0.2-alpha2..." + wget -O "$WP_CORE_DIR/src/wp-content/plugins/pwa.zip" https://github.com/xwp/pwa-wp/releases/download/0.2-alpha2/pwa.zip + unzip -d "$WP_CORE_DIR/src/wp-content/plugins/pwa/" "$WP_CORE_DIR/src/wp-content/plugins/pwa.zip" + echo "done" } diff --git a/.eslintrc b/.eslintrc index e696a0a16d6..d13eb9f42ce 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,7 +22,8 @@ "globals": { "wp": true, "window": true, - "document": true + "document": true, + "Set": true }, "settings": { "react": { diff --git a/amp.php b/amp.php index f6565399d12..ef6ea0d15cc 100644 --- a/amp.php +++ b/amp.php @@ -247,6 +247,7 @@ function amp_init() { AMP_HTTP::handle_xhr_request(); AMP_Theme_Support::init(); AMP_Validation_Manager::init(); + AMP_Service_Worker::init(); add_action( 'init', array( 'AMP_Post_Type_Support', 'add_post_type_support' ), 1000 ); // After post types have been defined. if ( defined( 'WP_CLI' ) && WP_CLI ) { diff --git a/assets/js/amp-block-validation.js b/assets/js/amp-block-validation.js index c5115266933..3271e595c0a 100644 --- a/assets/js/amp-block-validation.js +++ b/assets/js/amp-block-validation.js @@ -294,8 +294,7 @@ var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars } return ( - module.lastStates.validationErrors.length !== validationErrors.length - || + module.lastStates.validationErrors.length !== validationErrors.length || ( validationErrors && ! _.isEqual( module.lastStates.validationErrors, validationErrors ) ) ); }, diff --git a/assets/js/amp-service-worker-runtime-precaching.js b/assets/js/amp-service-worker-runtime-precaching.js new file mode 100644 index 00000000000..06f9a0cf9f8 --- /dev/null +++ b/assets/js/amp-service-worker-runtime-precaching.js @@ -0,0 +1,9 @@ +/* global URLS */ +// See AMP_Service_Workers::add_amp_runtime_caching() and . +{ + self.addEventListener( 'install', event => { + event.waitUntil( + caches.open( wp.serviceWorker.core.cacheNames.runtime ).then( cache => cache.addAll( URLS ) ) + ); + } ); +} diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index 59add784246..d1288b9a7d7 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -256,6 +256,11 @@ function is_amp_endpoint() { return false; } + // Always return false when requesting service worker. + if ( class_exists( 'WP_Service_Workers' ) && ! empty( $wp_query ) && $wp_query->get( WP_Service_Workers::QUERY_VAR ) ) { + return false; + } + $did_parse_query = did_action( 'parse_query' ); if ( ! $did_parse_query ) { diff --git a/includes/class-amp-autoloader.php b/includes/class-amp-autoloader.php index e91c15cf505..d9e8aa369a5 100644 --- a/includes/class-amp-autoloader.php +++ b/includes/class-amp-autoloader.php @@ -31,6 +31,7 @@ class AMP_Autoloader { private static $classmap = array( 'AMP_Editor_Blocks' => 'includes/admin/class-amp-editor-blocks', 'AMP_Theme_Support' => 'includes/class-amp-theme-support', + 'AMP_Service_Worker' => 'includes/class-amp-service-worker', 'AMP_HTTP' => 'includes/class-amp-http', 'AMP_Comment_Walker' => 'includes/class-amp-comment-walker', 'AMP_Template_Customizer' => 'includes/admin/class-amp-customizer', diff --git a/includes/class-amp-service-worker.php b/includes/class-amp-service-worker.php new file mode 100644 index 00000000000..73a2280eddb --- /dev/null +++ b/includes/class-amp-service-worker.php @@ -0,0 +1,337 @@ +. + */ + $enabled_options = array( + 'cdn_script_caching' => true, + 'image_caching' => false, + 'google_fonts_caching' => false, + ); + if ( is_array( $theme_support['service_worker'] ) ) { + $enabled_options = array_merge( + $enabled_options, + $theme_support['service_worker'] + ); + } + + if ( $enabled_options['cdn_script_caching'] ) { + add_action( 'wp_front_service_worker', array( __CLASS__, 'add_cdn_script_caching' ) ); + } + if ( $enabled_options['image_caching'] ) { + add_action( 'wp_front_service_worker', array( __CLASS__, 'add_image_caching' ) ); + } + if ( $enabled_options['google_fonts_caching'] ) { + add_action( 'wp_front_service_worker', array( __CLASS__, 'add_google_fonts_caching' ) ); + } + } + + /** + * Add query var for iframe service worker request. + * + * @param array $vars Query vars. + * @return array Amended query vars. + */ + public static function add_query_var( $vars ) { + $vars[] = self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR; + return $vars; + } + + /** + * Add runtime caching for scripts loaded from the AMP CDN with a stale-while-revalidate strategy. + * + * @link https://github.com/ampproject/amp-by-example/blob/4593af61609898043302a101826ddafe7206bfd9/boilerplate-generator/templates/files/serviceworkerJs.js + * + * @param WP_Service_Worker_Scripts $service_workers Service worker registry. + */ + public static function add_cdn_script_caching( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Please update to PWA v0.2. Expected argument to be WP_Service_Worker_Cache_Registry.', 'amp' ), '1.1' ); + return; + } + + // Add AMP scripts to runtime cache which will then get stale-while-revalidate strategy. + $service_workers->register( + 'amp-cdn-runtime-caching', + function() { + $urls = AMP_Service_Worker::get_precached_script_cdn_urls(); + if ( empty( $urls ) ) { + return ''; + } + + $js = file_get_contents( AMP__DIR__ . '/assets/js/amp-service-worker-runtime-precaching.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents + $js = preg_replace( '#/\*\s*global.+?\*/#', '', $js ); + $js = str_replace( + 'URLS', + wp_json_encode( $urls ), + $js + ); + return $js; + } + ); + + // Serve the AMP Runtime from cache and check for an updated version in the background. See . + $service_workers->caching_routes()->register( + '^https:\/\/cdn\.ampproject\.org\/.*', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_STALE_WHILE_REVALIDATE, + ) + ); + } + + /** + * Add runtime image caching from the origin with a cache-first strategy. + * + * @link https://github.com/ampproject/amp-by-example/blob/4593af61609898043302a101826ddafe7206bfd9/boilerplate-generator/templates/files/serviceworkerJs.js#L60-L74 + * + * @param WP_Service_Worker_Scripts $service_workers Service workers. + */ + public static function add_image_caching( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Please update to PWA v0.2. Expected argument to be WP_Service_Worker_Scripts.', 'amp' ), '1.1' ); + return; + } + + $service_workers->caching_routes()->register( + '^' . preg_quote( set_url_scheme( content_url( '/' ), 'https' ), '/' ) . '[^\?]+?\.(?:png|gif|jpg|jpeg|svg|webp)(\?.*)?$', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST, + 'cacheName' => 'images', + 'plugins' => array( + 'cacheableResponse' => array( + 'statuses' => array( 0, 200 ), + ), + 'expiration' => array( + 'maxEntries' => 60, + 'maxAgeSeconds' => MONTH_IN_SECONDS, + ), + ), + ) + ); + } + + /** + * Add runtime caching of Google Fonts with stale-while-revalidate strategy for stylesheets and cache-first strategy for webfont files. + * + * @link https://developers.google.com/web/tools/workbox/guides/common-recipes#google_fonts + * @link https://github.com/ampproject/amp-by-example/blob/4593af61609898043302a101826ddafe7206bfd9/boilerplate-generator/templates/files/serviceworkerJs.js#L76-L103 + * @link https://github.com/xwp/pwa-wp/blob/master/integrations/class-wp-service-worker-fonts-integration.php + * + * @param WP_Service_Worker_Scripts $service_workers Service workers. + */ + public static function add_google_fonts_caching( $service_workers ) { + if ( ! ( $service_workers instanceof WP_Service_Worker_Scripts ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Please update to PWA v0.2. Expected argument to be WP_Service_Worker_Scripts.', 'amp' ), '1.1' ); + return; + } + + // The PWA plugin also automatically adds runtime caching for Google Fonts when WP_SERVICE_WORKER_INTEGRATIONS_ENABLED is set. + if ( class_exists( 'WP_Service_Worker_Fonts_Integration' ) ) { + return; + } + + // Cache the Google Fonts stylesheets with a stale while revalidate strategy. + $service_workers->caching_routes()->register( + '^https:\/\/fonts\.googleapis\.com', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_STALE_WHILE_REVALIDATE, + 'cacheName' => 'google-fonts-stylesheets', + ) + ); + + // Cache the Google Fonts webfont files with a cache first strategy for 1 year. + $service_workers->caching_routes()->register( + '^https:\/\/fonts\.gstatic\.com', + array( + 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST, + 'cacheName' => 'google-fonts-webfonts', + 'plugins' => array( + 'cacheableResponse' => array( + 'statuses' => array( 0, 200 ), + ), + 'expiration' => array( + 'maxAgeSeconds' => YEAR_IN_SECONDS, + 'maxEntries' => 30, + ), + ), + ) + ); + } + + /** + * Register URLs that will be precached in the runtime cache. (Yes, this sounds somewhat strange.) + * + * Note that the PWA plugin handles the precaching of custom logo, custom header, + * and custom background. The PWA plugin also handles precaching & serving of the + * offline/500 error pages and enabling navigation preload. + * + * @link https://github.com/ampproject/amp-by-example/blob/4593af61609898043302a101826ddafe7206bfd9/boilerplate-generator/templates/files/serviceworkerJs.js#L9-L22 + * @see AMP_Service_Worker::add_cdn_script_caching() + * + * @return array Runtime pre-cached URLs. + */ + public static function get_precached_script_cdn_urls() { + + // List of AMP scripts that we know will be used in WordPress always. + $precached_handles = array( + 'amp-runtime', + 'amp-bind', // Used by comments. + 'amp-form', // Used by comments. + 'amp-install-serviceworker', + ); + + $theme_support = AMP_Theme_Support::get_theme_support_args(); + if ( ! empty( $theme_support['comments_live_list'] ) ) { + $precached_handles[] = 'amp-live-list'; + } + + if ( amp_get_analytics() ) { + $precached_handles[] = 'amp-analytics'; + } + + $urls = array(); + foreach ( $precached_handles as $handle ) { + if ( wp_script_is( $handle, 'registered' ) ) { + $urls[] = wp_scripts()->registered[ $handle ]->src; + } + } + + return $urls; + } + + /** + * Add hooks to install the service worker from AMP page. + */ + public static function add_install_hooks() { + if ( current_theme_supports( 'amp' ) && is_amp_endpoint() ) { + add_action( 'wp_footer', array( __CLASS__, 'install_service_worker' ) ); + + // Prevent validation error due to the script that installs the service worker on non-AMP pages. + $priority = has_action( 'wp_print_scripts', 'wp_print_service_workers' ); + if ( false !== $priority ) { + remove_action( 'wp_print_scripts', 'wp_print_service_workers', $priority ); + } + } + add_action( 'amp_post_template_footer', array( __CLASS__, 'install_service_worker' ) ); + } + + /** + * Install service worker(s). + * + * @since 1.1 + * @see wp_print_service_workers() + * @link https://github.com/xwp/pwa-wp + */ + public static function install_service_worker() { + if ( ! function_exists( 'wp_service_workers' ) || ! function_exists( 'wp_get_service_worker_url' ) ) { + return; + } + + $src = wp_get_service_worker_url( WP_Service_Workers::SCOPE_FRONT ); + $iframe_src = add_query_arg( + self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR, + WP_Service_Workers::SCOPE_FRONT, + home_url( '/', 'https' ) + ); + ?> + + + query_vars[ self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR ] ) ) { + return; + } + + $scope = intval( $GLOBALS['wp']->query_vars[ self::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR ] ); + if ( WP_Service_Workers::SCOPE_ADMIN !== $scope && WP_Service_Workers::SCOPE_FRONT !== $scope ) { + wp_die( + esc_html__( 'No service workers registered for the requested scope.', 'amp' ), + esc_html__( 'Service Worker Installation', 'amp' ), + array( 'response' => 404 ) + ); + } + + $front_scope = home_url( '/', 'relative' ); + + ?> + + + + + <?php esc_html_e( 'Service Worker Installation', 'amp' ); ?> + + + + navigator.serviceWorker.register( %s, %s );', + wp_json_encode( wp_get_service_worker_url( $scope ) ), + wp_json_encode( array( 'scope' => $front_scope ) ) + ); + ?> + + + - + diff --git a/tests/test-amp-helper-functions.php b/tests/test-amp-helper-functions.php index e959d62b3db..dbfcd927c1e 100644 --- a/tests/test-amp-helper-functions.php +++ b/tests/test-amp-helper-functions.php @@ -402,6 +402,13 @@ public function test_is_amp_endpoint_for_post_embeds_and_feeds() { $this->go_to( home_url( '?feed=rss' ) ); $this->assertFalse( is_amp_endpoint() ); + + if ( class_exists( 'WP_Service_Workers' ) && function_exists( 'pwa_add_error_template_query_var' ) ) { + $this->go_to( home_url( "?p=$post_id" ) ); + global $wp_query; + $wp_query->set( WP_Service_Workers::QUERY_VAR, WP_Service_Workers::SCOPE_FRONT ); + $this->assertFalse( is_amp_endpoint() ); + } } /** @@ -523,7 +530,7 @@ public function test_script_registering() { wp_print_scripts(); $output = ob_get_clean(); - $this->assertStringStartsWith( '', $output ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript + $this->assertContains( '', $output ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript $this->assertContains( '', $output ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript $this->assertContains( '', $output ); // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript diff --git a/tests/test-class-amp-service-worker.php b/tests/test-class-amp-service-worker.php new file mode 100644 index 00000000000..be7c6730bb0 --- /dev/null +++ b/tests/test-class-amp-service-worker.php @@ -0,0 +1,251 @@ +markTestIncomplete( 'PWA plugin not active.' ); + } + } + + /** + * Test default hooks in init. + * + * @covers \AMP_Service_Worker::init() + */ + public function test_default_init_hooks() { + remove_all_filters( 'query_vars' ); + remove_all_actions( 'parse_request' ); + remove_all_actions( 'wp' ); + remove_all_actions( 'wp_front_service_worker' ); + + AMP_Service_Worker::init(); + $this->assertSame( 10, has_filter( 'query_vars', array( 'AMP_Service_Worker', 'add_query_var' ) ) ); + $this->assertSame( 10, has_action( 'parse_request', array( 'AMP_Service_Worker', 'handle_service_worker_iframe_install' ) ) ); + $this->assertSame( 10, has_action( 'wp', array( 'AMP_Service_Worker', 'add_install_hooks' ) ) ); + + $this->assertSame( 10, has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_cdn_script_caching' ) ) ); + $this->assertFalse( has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_image_caching' ) ) ); + $this->assertFalse( has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_google_fonts_caching' ) ) ); + } + + /** + * Test unconditional hooks in init. + * + * @covers \AMP_Service_Worker::init() + */ + public function test_theme_support_hooks() { + remove_all_filters( 'query_vars' ); + remove_all_actions( 'parse_request' ); + remove_all_actions( 'wp' ); + remove_all_actions( 'wp_front_service_worker' ); + + add_theme_support( + 'amp', + array( + 'service_worker' => array( + 'cdn_script_caching' => false, + 'image_caching' => true, + 'google_fonts_caching' => true, + ), + ) + ); + + AMP_Service_Worker::init(); + $this->assertFalse( has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_cdn_script_caching' ) ) ); + $this->assertSame( 10, has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_image_caching' ) ) ); + $this->assertSame( 10, has_action( 'wp_front_service_worker', array( 'AMP_Service_Worker', 'add_google_fonts_caching' ) ) ); + } + + /** + * Test add_query_var(). + * + * @covers \AMP_Service_Worker::add_query_var() + */ + public function test_add_query_var() { + $query_vars = AMP_Service_Worker::add_query_var( array( 'foo' ) ); + $this->assertSame( 'foo', $query_vars[0] ); + $this->assertCount( 2, $query_vars ); + $this->assertInternalType( 'string', $query_vars[1] ); + } + + /** + * Test add_cdn_script_caching(). + * + * @covers \AMP_Service_Worker::add_cdn_script_caching() + */ + public function test_add_cdn_script_caching() { + AMP_Service_Worker::add_cdn_script_caching( wp_service_workers()->get_registry() ); + $this->assertArrayHasKey( 'amp-cdn-runtime-caching', wp_service_workers()->get_registry()->registered ); + } + + /** + * Test add_image_caching(). + * + * @covers \AMP_Service_Worker::add_image_caching() + */ + public function test_add_image_caching() { + $before = wp_service_workers()->get_registry()->caching_routes()->get_all(); + AMP_Service_Worker::add_image_caching( wp_service_workers()->get_registry() ); + $after = wp_service_workers()->get_registry()->caching_routes()->get_all(); + + $this->assertSame( count( $before ) + 1, count( $after ) ); + } + + /** + * Test add_google_fonts_caching(). + * + * @covers \AMP_Service_Worker::add_google_fonts_caching() + */ + public function test_add_google_fonts_caching() { + if ( class_exists( 'WP_Service_Worker_Fonts_Integration' ) ) { + $this->markTestSkipped( 'WP_Service_Worker_Fonts_Integration is present.' ); + } + $before = wp_service_workers()->get_registry()->caching_routes()->get_all(); + AMP_Service_Worker::add_google_fonts_caching( wp_service_workers()->get_registry() ); + $after = wp_service_workers()->get_registry()->caching_routes()->get_all(); + $this->assertSame( count( $before ) + 2, count( $after ) ); + } + + /** + * Test get_precached_script_cdn_urls(). + * + * @covers \AMP_Service_Worker::get_precached_script_cdn_urls() + */ + public function test_get_precached_script_cdn_urls() { + $urls = AMP_Service_Worker::get_precached_script_cdn_urls(); + + $this->assertArraySubset( + array( + wp_scripts()->registered['amp-runtime']->src, + wp_scripts()->registered['amp-bind']->src, + wp_scripts()->registered['amp-form']->src, + wp_scripts()->registered['amp-install-serviceworker']->src, + ), + $urls + ); + + // Comments. + $this->assertNotContains( + wp_scripts()->registered['amp-live-list']->src, + $urls + ); + add_theme_support( + 'amp', + array( + 'comments_live_list' => true, + ) + ); + $this->assertContains( + wp_scripts()->registered['amp-live-list']->src, + AMP_Service_Worker::get_precached_script_cdn_urls() + ); + + // Analytics. + $this->assertNotContains( + wp_scripts()->registered['amp-analytics']->src, + $urls + ); + add_filter( + 'amp_analytics_entries', + function () { + return array( + array( + 'type' => 'foo', + 'config' => '{}', + ), + ); + } + ); + $this->assertContains( + wp_scripts()->registered['amp-analytics']->src, + AMP_Service_Worker::get_precached_script_cdn_urls() + ); + } + + /** + * Test add_install_hooks(). + * + * @covers \AMP_Service_Worker::add_install_hooks() + */ + public function test_add_install_hooks() { + remove_all_actions( 'amp_post_template_footer' ); + remove_all_actions( 'wp_footer' ); + remove_theme_support( 'amp' ); + + $post_id = $this->factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + + AMP_Service_Worker::add_install_hooks(); + $this->assertSame( 10, has_action( 'amp_post_template_footer', array( 'AMP_Service_Worker', 'install_service_worker' ) ) ); + $this->assertFalse( has_action( 'wp_footer', array( 'AMP_Service_Worker', 'install_service_worker' ) ) ); + + add_theme_support( 'amp' ); + $this->assertTrue( is_amp_endpoint() ); + AMP_Service_Worker::add_install_hooks(); + $this->assertSame( 10, has_action( 'wp_footer', array( 'AMP_Service_Worker', 'install_service_worker' ) ) ); + $this->assertFalse( has_action( 'wp_print_scripts', array( 'AMP_Service_Worker', 'wp_print_service_workers' ) ) ); + } + + /** + * Test install_service_worker(). + * + * @covers \AMP_Service_Worker::install_service_worker() + */ + public function test_install_service_worker() { + ob_start(); + AMP_Service_Worker::install_service_worker(); + $output = ob_get_clean(); + + $this->assertContains( 'go_to( home_url() ); + + // Now try to go to the iframe endpoint. + ob_start(); + $exception = null; + try { + $this->go_to( add_query_arg( \AMP_Service_Worker::INSTALL_SERVICE_WORKER_IFRAME_QUERY_VAR, '1', home_url() ) ); + } catch ( Exception $e ) { + $exception = $e; + } + $this->assertInstanceOf( 'Exception', $exception ); + $this->assertEquals( 'exited', $exception->getMessage() ); + $output = ob_get_clean(); + $this->assertContains( '', '', - '', - '', + '', + '', '##s', '',