PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / modules / Preloader / PreloaderModule.php

PreloaderModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.6, at includes/modules/Preloader/PreloaderModule.php

338 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache Preloader module.
4 *
5 * Owns the preloader's settings + the dashboard custom panel that pairs
6 * the schema controls with a Start/Stop/Status surface.
7 *
8 * Tier: Free (LiteSpeed parity — their crawler is free).
9 * Roadmap: §3 P3.1.
10 *
11 * @package XSpeed
12 */
13
14 declare(strict_types=1);
15
16 namespace XSpeed\Modules\Preloader;
17
18 defined( 'ABSPATH' ) || exit;
19
20 use XSpeed\Module;
21 use XSpeed\Preloader;
22 use XSpeed\Settings_Manager;
23
24 final class PreloaderModule extends Module {
25
26 public const SLUG = 'preloader';
27 public const TIER = self::TIER_FREE;
28 public const VERSION = '1.0.0';
29
30 public function ui_metadata(): array {
31 return array(
32 'label' => 'Preloader',
33 'tab_label' => 'Crawl Now', // its own tab on the Preloader page
34 'icon' => 'Wand2',
35 'description' => 'Crawl the sitemap to warm cache so visitors never hit a cold MISS.',
36 // Custom panel wraps the schema-driven settings with a
37 // Start/Stop control surface + a live status readout
38 // (queue depth, last URL, recent errors).
39 'custom_panel' => 'PreloaderHost',
40 );
41 }
42
43 public function settings_schema(): array {
44 return array(
45 'enabled' => array(
46 'type' => 'bool',
47 'default' => false,
48 'label' => 'Enable Preloader',
49 'description' => 'When on, xSpeed crawls the sitemap on the schedule below and warms the page cache.',
50 ),
51 'schedule' => array(
52 'type' => 'enum',
53 'default' => 'manual',
54 'options' => array( 'manual', 'hourly', 'daily', 'weekly' ),
55 'option_labels' => array(
56 'manual' => 'Manual',
57 'hourly' => 'Hourly',
58 'daily' => 'Daily',
59 'weekly' => 'Weekly',
60 ),
61 'label' => 'Schedule',
62 'description' => 'How often to start a fresh crawl. Manual means you trigger it from the dashboard.',
63 ),
64 'batch_size' => array(
65 'type' => 'int',
66 'default' => 5,
67 'min' => 1,
68 'max' => 50,
69 'label' => 'Batch Size',
70 'description' => 'URLs warmed per cron tick. Higher = faster crawl, more load on the origin.',
71 ),
72 'sitemap_url' => array(
73 'type' => 'string',
74 'default' => '',
75 'label' => 'Sitemap URL (optional)',
76 'description' => 'Override the auto-detected WordPress core sitemap (/wp-sitemap.xml). Leave blank for default.',
77 ),
78 'warm_on_publish' => array(
79 'type' => 'bool',
80 'default' => true,
81 'label' => 'Warm new content immediately',
82 'description' => 'When a post or page is published, fetch it once so the first visitor sees a cache HIT, not a cold MISS.',
83 ),
84 'warm_on_comment' => array(
85 'type' => 'bool',
86 'default' => false,
87 'label' => 'Re-warm after comments',
88 'description' => 'Re-warm a page after a comment is posted (Cache purges the page on comment; this fetches it back into cache).',
89 ),
90 );
91 }
92
93 public function rest_routes(): array {
94 // Module base gives us GET + POST for settings under
95 // /xspeed/v1/preloader/. We add Start/Stop/Status alongside.
96 $default = parent::rest_routes();
97 return array_merge(
98 $default,
99 array(
100 array(
101 'path' => '/start',
102 'methods' => 'POST',
103 'callback' => array( $this, 'rest_start' ),
104 ),
105 array(
106 'path' => '/stop',
107 'methods' => 'POST',
108 'callback' => array( $this, 'rest_stop' ),
109 ),
110 array(
111 'path' => '/status',
112 'methods' => 'GET',
113 'callback' => array( $this, 'rest_status' ),
114 ),
115 )
116 );
117 }
118
119 public function cli_commands(): array {
120 return array(
121 array(
122 'name' => 'xspeed preloader',
123 'callback' => array( $this, 'cli_handler' ),
124 'shortdesc' => 'Drive the cache preloader (start | stop | status).',
125 'synopsis' => array(
126 array(
127 'type' => 'positional',
128 'name' => 'action',
129 'options' => array( 'start', 'stop', 'status' ),
130 'optional' => false,
131 ),
132 ),
133 ),
134 );
135 }
136
137 public function boot(): void {
138 add_action( Preloader::CRON_HOOK, array( Preloader::class, 'tick' ) );
139 add_action( 'xspeed_preloader_recurring', array( Preloader::class, 'recurring_kickoff' ) );
140
141 // Apply schedule changes immediately whenever this module's
142 // settings get written (the standard per-module option hook).
143 add_action( 'update_option_xspeed_module_preloader', array( $this, 'on_settings_change' ), 10, 2 );
144 add_action( 'add_option_xspeed_module_preloader', array( $this, 'on_settings_added' ), 10, 2 );
145
146 // Content warmer — auto-warm a single URL on post publish /
147 // comment so the first visitor after a publish/comment sees a
148 // HIT, not the cold MISS that Cache::purge_all just created.
149 $opts = Settings_Manager::get( self::SLUG );
150 if ( ! empty( $opts['warm_on_publish'] ) ) {
151 add_action( 'transition_post_status', array( $this, 'on_post_transition' ), 10, 3 );
152 }
153 if ( ! empty( $opts['warm_on_comment'] ) ) {
154 add_action( 'comment_post', array( $this, 'on_comment_post' ), 20, 2 );
155 }
156 }
157
158 /**
159 * Hook: a post transitioned to publish. Warm its permalink once on
160 * shutdown so the post-save request itself stays fast.
161 *
162 * @param string $new New post status.
163 * @param string $old Old post status.
164 * @param \WP_Post $post Post object.
165 */
166 public function on_post_transition( $new, $old, $post ): void {
167 if ( 'publish' !== $new || 'publish' === $old ) {
168 return;
169 }
170 // Only warm public post types so we don't crawl private CPTs.
171 $post_type_obj = get_post_type_object( $post->post_type );
172 if ( ! $post_type_obj || empty( $post_type_obj->public ) ) {
173 return;
174 }
175 $url = get_permalink( $post );
176 if ( ! $url ) {
177 return;
178 }
179 // Defer to shutdown so the user's "Publish" click returns fast.
180 // (Cache::purge_all has already fired by then on the save_post
181 // hook, so the warm fetch lands AFTER the purge.)
182 add_action(
183 'shutdown',
184 static function () use ( $url ) {
185 Preloader::warm_one( $url, 'post published' );
186 },
187 20
188 );
189 }
190
191 /**
192 * Hook: comment posted. Warm the post's permalink so the page is
193 * back in cache before the next visitor lands.
194 *
195 * @param int $comment_id The comment ID.
196 * @param int $approved 1, 0, or 'spam'.
197 */
198 public function on_comment_post( $comment_id, $approved ): void {
199 // Approved comments only — pending/spam don't show on the
200 // public page and shouldn't trigger a warm.
201 if ( 1 !== (int) $approved ) {
202 return;
203 }
204 $comment = get_comment( $comment_id );
205 if ( ! $comment || ! $comment->comment_post_ID ) {
206 return;
207 }
208 $url = get_permalink( (int) $comment->comment_post_ID );
209 if ( ! $url ) {
210 return;
211 }
212 add_action(
213 'shutdown',
214 static function () use ( $url ) {
215 Preloader::warm_one( $url, 'comment posted' );
216 },
217 20
218 );
219 }
220
221 public function deactivate(): void {
222 wp_clear_scheduled_hook( Preloader::CRON_HOOK );
223 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
224 }
225
226 public function on_settings_change( $old, $new ): void {
227 $enabled = is_array( $new ) && ! empty( $new['enabled'] );
228 $schedule = is_array( $new ) ? (string) ( $new['schedule'] ?? 'manual' ) : 'manual';
229 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
230 }
231
232 public function on_settings_added( $name, $value ): void {
233 $enabled = is_array( $value ) && ! empty( $value['enabled'] );
234 $schedule = is_array( $value ) ? (string) ( $value['schedule'] ?? 'manual' ) : 'manual';
235 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
236 }
237
238 public function rest_start( \WP_REST_Request $request ) {
239 $opts = Settings_Manager::get( self::SLUG );
240 if ( empty( $opts['enabled'] ) ) {
241 return new \WP_Error(
242 'xspeed_preloader_disabled',
243 __( 'Enable the preloader before starting a crawl.', 'xspeed' ),
244 array( 'status' => 409 )
245 );
246 }
247 return rest_ensure_response( Preloader::start() );
248 }
249
250 public function rest_stop( \WP_REST_Request $request ) {
251 return rest_ensure_response( Preloader::stop() );
252 }
253
254 public function rest_status( \WP_REST_Request $request ) {
255 return rest_ensure_response( Preloader::status() );
256 }
257
258 public function cli_handler( array $args, array $assoc ): void {
259 $action = $args[0] ?? 'status';
260
261 switch ( $action ) {
262 case 'start':
263 $opts = Settings_Manager::get( self::SLUG );
264 if ( empty( $opts['enabled'] ) ) {
265 \WP_CLI::error( 'Preloader is disabled. Enable it via wp xspeed preloader set --enabled=1 first.' );
266 return;
267 }
268 $state = Preloader::start();
269 // Don't print a green Success over a crawl that queued
270 // nothing — that exit-0 was the whole complaint in #142.
271 $sitemap_error = (string) ( $state['sitemap_error'] ?? '' );
272 if ( 0 === (int) $state['total'] ) {
273 \WP_CLI::error(
274 '' !== $sitemap_error
275 ? sprintf( 'Queued 0 URLs. %s', $sitemap_error )
276 : 'Queued 0 URLs — nothing to warm. Check the sitemap URL and the cache exclusion rules.'
277 );
278 return;
279 }
280 if ( 'fallback' === ( $state['source'] ?? '' ) ) {
281 \WP_CLI::warning( $sitemap_error );
282 \WP_CLI::success(
283 sprintf(
284 'Queued %d URL%s from the site content instead of the sitemap.',
285 $state['total'],
286 1 === $state['total'] ? '' : 's'
287 )
288 );
289 return;
290 }
291 \WP_CLI::success( sprintf( 'Queued %d URL%s.', $state['total'], 1 === $state['total'] ? '' : 's' ) );
292 return;
293
294 case 'stop':
295 Preloader::stop();
296 \WP_CLI::success( 'Preloader stopped.' );
297 return;
298
299 case 'status':
300 $state = Preloader::status();
301 \WP_CLI::log( 'Running : ' . ( $state['running'] ? 'yes' : 'no' ) );
302 \WP_CLI::log( 'Processed : ' . $state['processed'] . ' / ' . $state['total'] );
303 if ( $state['last_url'] ) {
304 \WP_CLI::log( 'Last URL : ' . $state['last_url'] );
305 }
306 if ( ! empty( $state['errors'] ) ) {
307 \WP_CLI::log( 'Errors : ' . count( $state['errors'] ) );
308 foreach ( array_slice( $state['errors'], -5 ) as $e ) {
309 // Tolerate a bare string as well as the {url, error}
310 // shape. A string entry fataled this command outright
311 // ("Cannot access offset of type string on string"),
312 // which also took MCP's get_preloader_status down with
313 // it — an agent asking why a preload failed got a type
314 // error instead of the reason. The writer is fixed, but
315 // `status` is a diagnostic: it should survive whatever
316 // it is handed rather than die reporting on it. (QA F1)
317 if ( is_array( $e ) ) {
318 $url = isset( $e['url'] ) ? (string) $e['url'] : '';
319 $msg = isset( $e['error'] ) ? (string) $e['error'] : '';
320 // The sitemap message already names the URL, so
321 // prefixing it would print the URL twice on one line.
322 $line = ( '' !== $url && false === strpos( $msg, $url ) )
323 ? $url . '' . $msg
324 : $msg;
325 } else {
326 $line = (string) $e;
327 }
328 \WP_CLI::log( ' ' . $line );
329 }
330 }
331 return;
332
333 default:
334 \WP_CLI::error( "Unknown action: $action" );
335 }
336 }
337 }
338