' . __( 'Page caching loads your site faster for visitors, and allows your site to handle more traffic without overloading.', 'surge' ) . '
', 23 | 'actions' => '', 24 | 'test' => 'surge_cache', 25 | ); 26 | 27 | $installed = get_option( 'surge_installed', false ); 28 | 29 | $actions = sprintf( 30 | '', 31 | esc_url( admin_url( 'plugins.php' ) ), 32 | __( 'Manage your plugins', 'surge' ) 33 | ); 34 | 35 | if ( $installed === false || $installed > 1 ) { 36 | $result['status'] = 'critical'; 37 | $result['label'] = __( 'Page caching is not installed correctly', 'surge' ); 38 | $result['description'] = '' . __( 'Looks like the Surge page caching plugin is not installed correctly. Please try to deactivate and activate it again in the Plugins screen. If that does not help, please visit the WordPress.org support forums.', 'surge' ) . '
'; 39 | $result['actions'] = $actions; 40 | $result['badge']['color'] = 'red'; 41 | return $result; 42 | } 43 | 44 | if ( $installed === 0 ) { 45 | $result['status'] = 'critical'; 46 | $result['label'] = __( 'Page caching is being installed', 'surge' ); 47 | $result['description'] = '' . __( 'The Surge page caching plugin is being installed. This should only take a few seconds. If this message does not disappear, please try to deactivate and activate the plugin again from the Plugins screen. If that does not help, please visit the WordPress.org support forums.', 'surge' ) . '
'; 48 | $result['actions'] = $actions; 49 | $result['badge']['color'] = 'orange'; 50 | return $result; 51 | } 52 | 53 | if ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { 54 | $result['status'] = 'critical'; 55 | $result['label'] = __( 'Page caching is disabled in wp-config.php', 'surge' ); 56 | $result['description'] = '' . __( 'The Surge page caching plugin is installed, but caching is disabled because the WP_CACHE directive is not defined in wp-config.php. Please try to deactivate and activate the Surge plugin, or define WP_CACHE manually in wp-config.php', 'surge' ) . '
'; 57 | $result['actions'] = $actions; 58 | $result['badge']['color'] = 'red'; 59 | return $result; 60 | } 61 | 62 | if ( ! file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) ) { 63 | $result['status'] = 'critical'; 64 | $result['label'] = __( 'Page caching is not installed correctly', 'surge' ); 65 | $result['description'] = '' . __( 'Looks like the Surge page caching plugin is not installed correctly, advanced-cache.php is missing. Please try to deactivate and activate it again in the Plugins screen. If that does not help, please visit the WordPress.org support forums.', 'surge' ) . '
'; 66 | $result['actions'] = $actions; 67 | $result['badge']['color'] = 'red'; 68 | return $result; 69 | } 70 | 71 | $contents = file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ); 72 | if ( strpos( $contents, 'namespace Surge;' ) === false ) { 73 | $result['status'] = 'critical'; 74 | $result['label'] = __( 'Page caching is not installed correctly', 'surge' ); 75 | $result['description'] = '' . __( 'Looks like the Surge page caching plugin is not installed correctly, invalid advanced-cache.php contents. Please try to deactivate and activate it again in the Plugins screen. If that does not help, please visit the WordPress.org support forums.', 'surge' ) . '
'; 76 | $result['actions'] = $actions; 77 | $result['badge']['color'] = 'red'; 78 | return $result; 79 | } 80 | 81 | if ( ! is_writable( CACHE_DIR ) ) { 82 | $result['status'] = 'critical'; 83 | $result['label'] = __( 'Page caching directory is missing or not writable', 'surge' ); 84 | $result['description'] = '' . __( 'The Surge plugin is installed, but the cache directory is missing or not writable. Please check the wp-content/cache directory permissions in your hosting environment, then toggle the Surge plugin activation. Visit the WordPress.org support forums for help.', 'surge' ) . '
'; 85 | $result['actions'] = $actions; 86 | $result['badge']['color'] = 'red'; 87 | return $result; 88 | } 89 | 90 | return $result; 91 | } 92 | -------------------------------------------------------------------------------- /include/install.php: -------------------------------------------------------------------------------- 1 | get_id() ) ); 20 | return $title; 21 | }, 10, 2 ); 22 | 23 | // When a post is published, or unpublished, we need to invalidate various 24 | // different pages featuring that specific post type. 25 | add_action( 'transition_post_status', function( $status, $old_status, $post ) { 26 | if ( $status == $old_status ) { 27 | return; 28 | } 29 | 30 | // Only if the post type is public. 31 | $obj = get_post_type_object( $post->post_type ); 32 | if ( ! $obj || ! $obj->public ) { 33 | return; 34 | } 35 | 36 | $status = get_post_status_object( $status ); 37 | $old_status = get_post_status_object( $old_status ); 38 | 39 | // To or from a public post status. 40 | if ( ( $status && $status->public ) || ( $old_status && $old_status->public ) ) { 41 | expire( 'post_type:' . $post->post_type ); 42 | } 43 | }, 10, 3 ); 44 | 45 | // Filter WP_Query at the stage where the query was completed, the results have 46 | // been fetched and sorted, as well as accounted and offset for sticky posts. 47 | // Here we attempt to guess which posts appear on this requests and set flags 48 | // accordingly. We also attempt to set more generic flags based on the query. 49 | add_filter( 'the_posts', function( $posts, $query ) { 50 | $post_ids = wp_list_pluck( $posts, 'ID' ); 51 | $blog_id = get_current_blog_id(); 52 | 53 | foreach ( $post_ids as $id ) { 54 | flag( sprintf( 'post:%d:%d', $blog_id, $id ) ); 55 | } 56 | 57 | // Nothing else to do if it's a singular query. 58 | if ( $query->is_singular ) { 59 | return $posts; 60 | } 61 | 62 | // If it's a query for multiple posts, then flag it with the post types. 63 | // TODO: Add proper support for post_type => any 64 | $post_types = $query->get( 'post_type' ); 65 | if ( empty( $post_types ) ) { 66 | $post_types = [ 'post' ]; 67 | } elseif ( is_string( $post_types ) ) { 68 | $post_types = [ $post_types ]; 69 | } 70 | 71 | // Add flags for public post types. 72 | foreach ( $post_types as $post_type ) { 73 | $obj = get_post_type_object( $post_type ); 74 | if ( is_null( $obj ) || ! $obj->public ) { 75 | continue; 76 | } 77 | 78 | flag( 'post_type:' . $post_type ); 79 | } 80 | 81 | return $posts; 82 | }, 10, 2 ); 83 | 84 | // Flag feeds 85 | $flag_feed = function() { flag( 'feed:' . get_current_blog_id() ); }; 86 | add_action( 'do_feed_rdf', $flag_feed ); 87 | add_action( 'do_feed_rss', $flag_feed ); 88 | add_action( 'do_feed_rss2', $flag_feed ); 89 | add_action( 'do_feed_atom', $flag_feed ); 90 | 91 | // Expire flags when post cache is cleaned. 92 | add_action( 'clean_post_cache', function( $post_id, $post ) { 93 | if ( wp_is_post_revision( $post ) ) { 94 | return; 95 | } 96 | 97 | $blog_id = get_current_blog_id(); 98 | expire( sprintf( 'post:%d:%d', $blog_id, $post_id ) ); 99 | }, 10, 2 ); 100 | 101 | // Multisite network/blog flags. 102 | add_action( 'init', function() { 103 | if ( is_multisite() ) { 104 | flag( sprintf( 'network:%d:%d', get_current_network_id(), get_current_blog_id() ) ); 105 | } 106 | } ); 107 | 108 | // Last-minute expirations, save flags. 109 | add_action( 'shutdown', function() { 110 | $flush_actions = [ 111 | 'activate_plugin', 112 | 'deactivate_plugin', 113 | 'switch_theme', 114 | 'customize_save', 115 | 'update_option_permalink_structure', 116 | 'update_option_tag_base', 117 | 'update_option_category_base', 118 | 'update_option_WPLANG', 119 | 'update_option_blogname', 120 | 'update_option_blogdescription', 121 | 'update_option_blog_public', 122 | 'update_option_show_on_front', 123 | 'update_option_page_on_front', 124 | 'update_option_page_for_posts', 125 | 'update_option_posts_per_page', 126 | 'update_option_woocommerce_permalinks', 127 | ]; 128 | 129 | $flush_actions = apply_filters( 'surge_flush_actions', $flush_actions ); 130 | 131 | $ms_flush_actions = [ 132 | '_core_updated_successfully', 133 | 'automatic_updates_complete', 134 | ]; 135 | 136 | $expire_flag = is_multisite() 137 | ? sprintf( 'network:%d:%d', get_current_network_id(), get_current_blog_id() ) 138 | : '/'; 139 | 140 | foreach ( $flush_actions as $action ) { 141 | if ( did_action( $action ) ) { 142 | expire( $expire_flag ); 143 | break; 144 | } 145 | } 146 | 147 | // Multisite flush actions expire the entire network. 148 | foreach ( $ms_flush_actions as $action ) { 149 | if ( did_action( $action ) ) { 150 | expire( '/' ); 151 | break; 152 | } 153 | } 154 | 155 | $expire = expire(); 156 | if ( empty( $expire ) ) { 157 | return; 158 | } 159 | 160 | $flags = null; 161 | $path = CACHE_DIR . '/flags.json.php'; 162 | $exists = file_exists( $path ); 163 | $mode = $exists ? 'r+' : 'w+'; 164 | 165 | // Make sure cache dir exists. 166 | if ( ! $exists && ! wp_mkdir_p( CACHE_DIR ) ) { 167 | return; 168 | } 169 | 170 | $f = fopen( $path, $mode ); 171 | $length = filesize( $path ); 172 | 173 | flock( $f, LOCK_EX ); 174 | 175 | if ( $length ) { 176 | $flags = fread( $f, $length ); 177 | $flags = substr( $flags, strlen( '' ) ); 178 | $flags = json_decode( $flags, true ); 179 | } 180 | 181 | if ( ! $flags ) { 182 | $flags = []; 183 | } 184 | 185 | foreach ( $expire as $flag ) { 186 | $flags[ $flag ] = time(); 187 | } 188 | 189 | if ( ! wp_mkdir_p( CACHE_DIR ) ) { 190 | return $contents; 191 | } 192 | 193 | if ( $length ) { 194 | ftruncate( $f, 0 ); 195 | rewind( $f ); 196 | } 197 | 198 | fwrite( $f, '' . json_encode( $flags ) ); 199 | fclose( $f ); 200 | 201 | event( 'expire', [ 'flags' => $expire ] ); 202 | } ); 203 | 204 | $expire_feeds = function() { expire( 'feed:' . get_current_blog_id() ); }; 205 | add_action( 'update_option_rss_use_excerpt', $expire_feeds ); 206 | add_action( 'update_option_posts_per_rss', $expire_feeds ); 207 | -------------------------------------------------------------------------------- /include/serve.php: -------------------------------------------------------------------------------- 1 | ' ) ); 44 | $flags = json_decode( $flags, true ); 45 | } 46 | 47 | if ( $flags && ! empty( $meta['flags'] ) ) { 48 | foreach ( $flags as $flag => $timestamp ) { 49 | if ( $timestamp <= $meta['created'] ) { 50 | continue; 51 | } 52 | 53 | // Invalidate by path. 54 | if ( substr( $flag, 0, 1 ) == '/' ) { 55 | if ( substr( $meta['path'], 0, strlen( $flag ) ) === $flag ) { 56 | header( 'X-Cache: expired' ); 57 | fclose( $f ); 58 | return; 59 | } 60 | 61 | // This is a path flag, no futher comparison required. 62 | continue; 63 | } 64 | 65 | if ( in_array( $flag, $meta['flags'] ) ) { 66 | header( 'X-Cache: expired' ); 67 | fclose( $f ); 68 | return; 69 | } 70 | } 71 | } 72 | 73 | // Set the HTTP response code and send headers. 74 | http_response_code( $meta['code'] ); 75 | 76 | foreach ( $meta['headers'] as $name => $values ) { 77 | foreach( (array) $values as $value ) { 78 | header( "{$name}: {$value}", false ); 79 | } 80 | } 81 | 82 | header( 'X-Cache: hit' ); 83 | event( 'request', [ 'meta' => $meta ] ); 84 | fpassthru( $f ); // Pass the remaining bytes to the output. 85 | fclose( $f ); 86 | die(); 87 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Surge === 2 | Contributors: kovshenin 3 | Donate link: https://github.com/kovshenin/surge 4 | Tags: cache, performance, caching 5 | Requires at least: 5.7 6 | Tested up to: 6.6 7 | Requires PHP: 7.3 8 | Stable tag: 1.1.0 9 | License: GPLv3 or later 10 | License URI: https://www.gnu.org/licenses/gpl-3.0.en.html 11 | 12 | Surge is a very simple and fast page caching plugin for WordPress. 13 | 14 | == Description == 15 | 16 | Surge generates and serves static HTML files for your WordPress site, causing quicker requests, faster load times and a shorter time to first byte (TTFB). 17 | 18 | Surge does not require configuration, and has no options. It works out of the box on any well-configured hosting platform. Cached files are stored on disk, and automatically invalidated when your site is updated. 19 | 20 | In various load tests, Surge has shown to easily handle 1000-2500 requests per second at 100 concurrent, on a small single-core server with only 1 GB of RAM. That's over 70 times faster than a stock WordPress install. 21 | 22 | == Installation == 23 | 24 | Via the WordPress Dashboard: navigate to Plugins - Add New. In the search bar type "surge" and hit Enter. Find the Surge plugin in the search results, hit Install, then Activate. 25 | 26 | Manually: download the Surge plugin .zip file from WordPress.org. In your WordPress admin navigate to Plugins - Add New - Upload. Select the .zip file and hit Upload. Activate the plugin after upload is successful. 27 | 28 | Manually via FTP: download the Surge plugin .zip file from WordPress.org, extract the archive, make sure the directory is called "surge". Use your FTP/SFTP client to upload the "surge" directory to wp-content/plugins. Then activate the plugin in your WordPress admin from the Plugins section. 29 | 30 | Using WP-CLI: wp plugin install surge --activate 31 | 32 | == Frequently Asked Questions == 33 | 34 | = Where is the plugin configuration screen? = 35 | 36 | There isn't one. 37 | 38 | = How do I clear the cache? = 39 | 40 | Toggle the plugin activation or run `wp surge flush` using WP-CLI. 41 | 42 | = Is my cache working? = 43 | 44 | Visit the Site Health screen under Tools in your WordPress dashboard. Common caching errors, like installation problems, etc. will appear there. Otherwise, open your site in an Incognito window to see the cached version. You could also look for the "X-Cache" header in the server response. 45 | 46 | = Why am I getting cache misses? = 47 | 48 | Below are a few common reasons: 49 | 50 | * You are logged into your WordPress site 51 | * You have a unique cookie set in your browser 52 | * A unique query parameter will also cause a cache miss, except common marketing parameters, such as utm_campaign, etc. 53 | * Request methods outside of GET and HEAD are not cached 54 | 55 | = Can I exclude page X from being cached? = 56 | 57 | Of course. If you pass a "Cache-Control: no-cache" header (or max-age=0) the request will automatically be excluded from cache. Note that most WordPress plugins will already do this where necessary. 58 | 59 | = fpassthru() has been disabled for security reasons = 60 | 61 | It seems like your hosting provider disabled the fpassthru() function, likely by mistake. This is a requirement for Surge. Please get in touch with them and kindly ask them to enable it. 62 | 63 | = How can I support Surge? = 64 | 65 | If you like Surge, consider giving us a [star on GitHub](https://github.com/kovshenin/surge) and a review on WordPress.org. 66 | 67 | == Changelog == 68 | 69 | = 1.1.0 = 70 | * Improved Multisite compatibility 71 | * Fixed occasional stat() warnings in cleanup routines 72 | * Fixed expiration by path being too broad 73 | * Added a filter for flush actions 74 | * Feature: added a simple events system for s-maxage and stale-while-revalidate support 75 | 76 | = 1.0.5 = 77 | * Fix woocommerce_product_title compatibility 78 | * Honor DONOTCACHEPAGE constant 79 | * Use built-in is_ssl() WordPress function for better compatibility 80 | 81 | = 1.0.4 = 82 | * Add a WP-CLI command to invalidate/flush page cache 83 | * Fix redirect loop with Core's redirect_canonical for ignore_query_vars 84 | * Fix warnings for requests with empty headers 85 | * Fix warnings when cron cleanup attempts to read a file that no longer exists 86 | * Add a filter to disable writing to wp-config.php 87 | 88 | = 1.0.3 = 89 | * Invalidate cache when posts_per_page is changed 90 | * Fix redirect loop with unknown query vars caused by Core's redirect_canonical 91 | * Ignore X-Cache and X-Powered-By headers from cache metadata 92 | * Allow multiple headers with the same name 93 | 94 | = 1.0.2 = 95 | * Fix PHP notice in invalidation 96 | * Protect against race conditions when writing flags.json 97 | * Add support for more post statuses in transition_post_status invalidation 98 | 99 | = 1.0.1 = 100 | * Add support for custom user configuration 101 | * Various invalidation enhancements and fixes 102 | * Remove advanced-cache.php when plugin is deactivated 103 | * Add a note about fpassthru() in FAQ 104 | * Minor fix in Site Health screen tests 105 | 106 | = 1.0.0 = 107 | * Anonymize requests to favicon.ico and robots.txt 108 | * Improve cache expiration, add cache expiration by path 109 | 110 | = 0.1.0 = 111 | * Initial release 112 | -------------------------------------------------------------------------------- /surge.php: -------------------------------------------------------------------------------- 1 | 'Caching Test', 47 | 'test' => '\Surge\health_test', 48 | ]; 49 | 50 | return $tests; 51 | } ); 52 | 53 | // Support for 6.1+ cache headers check. 54 | add_filter( 'site_status_page_cache_supported_cache_headers', function( $headers ) { 55 | $headers['x-cache'] = static function( $value ) { 56 | return false !== strpos( strtolower( $value ), 'hit' ); 57 | }; 58 | return $headers; 59 | } ); 60 | 61 | // Schedule cron events. 62 | add_action( 'shutdown', function() { 63 | if ( ! wp_next_scheduled( 'surge_delete_expired' ) ) { 64 | wp_schedule_event( time(), 'hourly', 'surge_delete_expired' ); 65 | } 66 | } ); 67 | 68 | // Re-install on activation 69 | register_activation_hook( __FILE__, function() { 70 | delete_option( 'surge_installed' ); 71 | } ); 72 | 73 | // Remove advanced-cache.php on deactivation 74 | register_deactivation_hook( __FILE__, function() { 75 | delete_option( 'surge_installed' ); 76 | 77 | // Remove advanced-cache.php only if its ours. 78 | if ( file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) ) { 79 | $contents = file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ); 80 | if ( strpos( $contents, 'namespace Surge;' ) !== false ) { 81 | unlink( WP_CONTENT_DIR . '/advanced-cache.php' ); 82 | } 83 | } 84 | } ); 85 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 |