PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 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 All 32 releases
xspeed / includes / modules / Preloader / PreloaderModule.php

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

392 lines 13.5 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', 'xspeed' ),
33 'tab_label' => __( 'Crawl Now', 'xspeed' ), // 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.', 'xspeed' ),
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 /**
44 * Surface a firewall refusing our warmer.
45 *
46 * A 403/406 on a warm used to reach the admin only as "HTTP 403" buried
47 * in the activity log, so a site whose every warm was refused looked
48 * simply idle — newly published posts were never warmed and nothing said
49 * why. The page is fine; the fix is a server rule, so the notice names
50 * the user-agent to allow. (#481)
51 */
52 public function ui_notices(): array {
53 $block = Preloader::firewall_block();
54 if ( null === $block ) {
55 return array();
56 }
57
58 return array(
59 array(
60 'tone' => 'warn',
61 'title' => __( 'Your server is blocking the xSpeed cache warmer', 'xspeed' ),
62 'body' => sprintf(
63 /* translators: 1: HTTP status code, 2: the user-agent xSpeed sends. */
64 __( 'A warm request was refused with HTTP %1$d, which is how a firewall answers when it judges a request by its user-agent. Pages are not being warmed, so the first visitor to each new page waits for an uncached render. Allow the user-agent "%2$s" in your server\'s bad-bot rules — on xCloud this is the 8G firewall — or change it with the xspeed_preloader_user_agent filter. This notice clears itself once a warm gets through.', 'xspeed' ),
65 (int) $block['code'],
66 (string) ( $block['user_agent'] ?? Preloader::user_agent() )
67 ),
68 ),
69 );
70 }
71
72 public function settings_schema(): array {
73 return array(
74 'enabled' => array(
75 'type' => 'bool',
76 'default' => false,
77 'label' => __( 'Enable Preloader', 'xspeed' ),
78 'description' => __( 'When on, xSpeed crawls the sitemap on the schedule below and warms the page cache.', 'xspeed' ),
79 ),
80 'schedule' => array(
81 'type' => 'enum',
82 'default' => 'manual',
83 'options' => array( 'manual', 'hourly', 'daily', 'weekly' ),
84 'option_labels' => array(
85 'manual' => 'Manual',
86 'hourly' => 'Hourly',
87 'daily' => 'Daily',
88 'weekly' => 'Weekly',
89 ),
90 'label' => __( 'Schedule', 'xspeed' ),
91 'description' => __( 'How often to start a fresh crawl. Manual means you trigger it from the dashboard.', 'xspeed' ),
92 'dependsOn' => array( 'field' => 'enabled' ),
93 ),
94 'batch_size' => array(
95 'type' => 'int',
96 'default' => 5,
97 'min' => 1,
98 'max' => 50,
99 'label' => __( 'Batch Size', 'xspeed' ),
100 'description' => __( 'URLs warmed per cron tick. Higher = faster crawl, more load on the origin.', 'xspeed' ),
101 'dependsOn' => array( 'field' => 'enabled' ),
102 ),
103 'sitemap_url' => array(
104 'type' => 'string',
105 'default' => '',
106 'label' => __( 'Sitemap URL (optional)', 'xspeed' ),
107 'description' => __( 'Override the auto-detected WordPress core sitemap (/wp-sitemap.xml). Leave blank for default.', 'xspeed' ),
108 'dependsOn' => array( 'field' => 'enabled' ),
109 ),
110 'warm_on_publish' => array(
111 'type' => 'bool',
112 'default' => true,
113 'label' => __( 'Warm new content immediately', 'xspeed' ),
114 'description' => __( 'When a post or page is published, fetch it once so the first visitor sees a cache HIT, not a cold MISS.', 'xspeed' ),
115 'dependsOn' => array( 'field' => 'enabled' ),
116 ),
117 'warm_on_comment' => array(
118 'type' => 'bool',
119 'default' => false,
120 'label' => __( 'Re-warm after comments', 'xspeed' ),
121 'description' => __( 'Re-warm a page after a comment is posted (Cache purges the page on comment; this fetches it back into cache).', 'xspeed' ),
122 'dependsOn' => array( 'field' => 'enabled' ),
123 ),
124 );
125 }
126
127 public function rest_routes(): array {
128 // Module base gives us GET + POST for settings under
129 // /xspeed/v1/preloader/. We add Start/Stop/Status alongside.
130 $default = parent::rest_routes();
131 return array_merge(
132 $default,
133 array(
134 array(
135 'path' => '/start',
136 'methods' => 'POST',
137 'callback' => array( $this, 'rest_start' ),
138 ),
139 array(
140 'path' => '/stop',
141 'methods' => 'POST',
142 'callback' => array( $this, 'rest_stop' ),
143 ),
144 array(
145 'path' => '/status',
146 'methods' => 'GET',
147 'callback' => array( $this, 'rest_status' ),
148 ),
149 )
150 );
151 }
152
153 public function cli_commands(): array {
154 return array(
155 array(
156 'name' => 'xspeed preloader',
157 'callback' => array( $this, 'cli_handler' ),
158 'shortdesc' => 'Drive the cache preloader (start | stop | status).',
159 'ai_hint' => 'Cache warming: crawl the site so visitors hit a warm cache instead of paying for the first render. Use after a full purge, or when the first visitor to each page reports a slow load.',
160 'synopsis' => array(
161 array(
162 'type' => 'positional',
163 'name' => 'action',
164 'options' => array( 'start', 'stop', 'status' ),
165 'optional' => false,
166 ),
167 ),
168 ),
169 );
170 }
171
172 public function boot(): void {
173 /*
174 * Deferred to `init`. This module reads its own settings to decide
175 * what to hook, and reading settings builds settings_schema(), whose
176 * labels are declared through __(). boot() runs on `plugins_loaded`,
177 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
178 * earliest safe moment to translate — so doing that here fires
179 * _load_textdomain_just_in_time on every request AND resolves the
180 * labels against a domain that is not loaded yet.
181 *
182 * Everything below hooks actions that fire after `init`, so running
183 * one hook later is equivalent.
184 */
185 add_action( 'init', array( $this, 'boot_on_init' ) );
186 }
187
188 /**
189 * The real boot body — see boot() for why it runs on `init`.
190 */
191 public function boot_on_init(): void {
192 add_action( Preloader::CRON_HOOK, array( Preloader::class, 'tick' ) );
193 add_action( 'xspeed_preloader_recurring', array( Preloader::class, 'recurring_kickoff' ) );
194
195 // Apply schedule changes immediately whenever this module's
196 // settings get written (the standard per-module option hook).
197 add_action( 'update_option_xspeed_module_preloader', array( $this, 'on_settings_change' ), 10, 2 );
198 add_action( 'add_option_xspeed_module_preloader', array( $this, 'on_settings_added' ), 10, 2 );
199
200 // Content warmer — auto-warm a single URL on post publish /
201 // comment so the first visitor after a publish/comment sees a
202 // HIT, not the cold MISS that Cache::purge_all just created.
203 $opts = Settings_Manager::get( self::SLUG );
204 if ( ! empty( $opts['warm_on_publish'] ) ) {
205 add_action( 'transition_post_status', array( $this, 'on_post_transition' ), 10, 3 );
206 }
207 if ( ! empty( $opts['warm_on_comment'] ) ) {
208 add_action( 'comment_post', array( $this, 'on_comment_post' ), 20, 2 );
209 }
210 }
211
212 /**
213 * Hook: a post transitioned to publish. Warm its permalink once on
214 * shutdown so the post-save request itself stays fast.
215 *
216 * @param string $new New post status.
217 * @param string $old Old post status.
218 * @param \WP_Post $post Post object.
219 */
220 public function on_post_transition( $new, $old, $post ): void {
221 if ( 'publish' !== $new || 'publish' === $old ) {
222 return;
223 }
224 // Only warm public post types so we don't crawl private CPTs.
225 $post_type_obj = get_post_type_object( $post->post_type );
226 if ( ! $post_type_obj || empty( $post_type_obj->public ) ) {
227 return;
228 }
229 $url = get_permalink( $post );
230 if ( ! $url ) {
231 return;
232 }
233 // Defer to shutdown so the user's "Publish" click returns fast.
234 // (Cache::purge_all has already fired by then on the save_post
235 // hook, so the warm fetch lands AFTER the purge.)
236 add_action(
237 'shutdown',
238 static function () use ( $url ) {
239 Preloader::warm_one( $url, 'post published' );
240 },
241 20
242 );
243 }
244
245 /**
246 * Hook: comment posted. Warm the post's permalink so the page is
247 * back in cache before the next visitor lands.
248 *
249 * @param int $comment_id The comment ID.
250 * @param int $approved 1, 0, or 'spam'.
251 */
252 public function on_comment_post( $comment_id, $approved ): void {
253 // Approved comments only — pending/spam don't show on the
254 // public page and shouldn't trigger a warm.
255 if ( 1 !== (int) $approved ) {
256 return;
257 }
258 $comment = get_comment( $comment_id );
259 if ( ! $comment || ! $comment->comment_post_ID ) {
260 return;
261 }
262 $url = get_permalink( (int) $comment->comment_post_ID );
263 if ( ! $url ) {
264 return;
265 }
266 add_action(
267 'shutdown',
268 static function () use ( $url ) {
269 Preloader::warm_one( $url, 'comment posted' );
270 },
271 20
272 );
273 }
274
275 public function deactivate(): void {
276 wp_clear_scheduled_hook( Preloader::CRON_HOOK );
277 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
278 }
279
280 public function on_settings_change( $old, $new ): void {
281 $enabled = is_array( $new ) && ! empty( $new['enabled'] );
282 $schedule = is_array( $new ) ? (string) ( $new['schedule'] ?? 'manual' ) : 'manual';
283 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
284 }
285
286 public function on_settings_added( $name, $value ): void {
287 $enabled = is_array( $value ) && ! empty( $value['enabled'] );
288 $schedule = is_array( $value ) ? (string) ( $value['schedule'] ?? 'manual' ) : 'manual';
289 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
290 }
291
292 public function rest_start( \WP_REST_Request $request ) {
293 $opts = Settings_Manager::get( self::SLUG );
294 if ( empty( $opts['enabled'] ) ) {
295 return new \WP_Error(
296 'xspeed_preloader_disabled',
297 __( 'Enable the preloader before starting a crawl.', 'xspeed' ),
298 array( 'status' => 409 )
299 );
300 }
301 return rest_ensure_response( Preloader::start() );
302 }
303
304 public function rest_stop( \WP_REST_Request $request ) {
305 return rest_ensure_response( Preloader::stop() );
306 }
307
308 public function rest_status( \WP_REST_Request $request ) {
309 return rest_ensure_response( Preloader::status() );
310 }
311
312 public function cli_handler( array $args, array $assoc ): void {
313 $action = $args[0] ?? 'status';
314
315 switch ( $action ) {
316 case 'start':
317 $opts = Settings_Manager::get( self::SLUG );
318 if ( empty( $opts['enabled'] ) ) {
319 \WP_CLI::error( 'Preloader is disabled. Enable it via wp xspeed preloader set --enabled=1 first.' );
320 return;
321 }
322 $state = Preloader::start();
323 // Don't print a green Success over a crawl that queued
324 // nothing — that exit-0 was the whole complaint in #142.
325 $sitemap_error = (string) ( $state['sitemap_error'] ?? '' );
326 if ( 0 === (int) $state['total'] ) {
327 \WP_CLI::error(
328 '' !== $sitemap_error
329 ? sprintf( 'Queued 0 URLs. %s', $sitemap_error )
330 : 'Queued 0 URLs — nothing to warm. Check the sitemap URL and the cache exclusion rules.'
331 );
332 return;
333 }
334 if ( 'fallback' === ( $state['source'] ?? '' ) ) {
335 \WP_CLI::warning( $sitemap_error );
336 \WP_CLI::success(
337 sprintf(
338 'Queued %d URL%s from the site content instead of the sitemap.',
339 $state['total'],
340 1 === $state['total'] ? '' : 's'
341 )
342 );
343 return;
344 }
345 \WP_CLI::success( sprintf( 'Queued %d URL%s.', $state['total'], 1 === $state['total'] ? '' : 's' ) );
346 return;
347
348 case 'stop':
349 Preloader::stop();
350 \WP_CLI::success( 'Preloader stopped.' );
351 return;
352
353 case 'status':
354 $state = Preloader::status();
355 \WP_CLI::log( 'Running : ' . ( $state['running'] ? 'yes' : 'no' ) );
356 \WP_CLI::log( 'Processed : ' . $state['processed'] . ' / ' . $state['total'] );
357 if ( $state['last_url'] ) {
358 \WP_CLI::log( 'Last URL : ' . $state['last_url'] );
359 }
360 if ( ! empty( $state['errors'] ) ) {
361 \WP_CLI::log( 'Errors : ' . count( $state['errors'] ) );
362 foreach ( array_slice( $state['errors'], -5 ) as $e ) {
363 // Tolerate a bare string as well as the {url, error}
364 // shape. A string entry fataled this command outright
365 // ("Cannot access offset of type string on string"),
366 // which also took MCP's get_preloader_status down with
367 // it — an agent asking why a preload failed got a type
368 // error instead of the reason. The writer is fixed, but
369 // `status` is a diagnostic: it should survive whatever
370 // it is handed rather than die reporting on it. (QA F1)
371 if ( is_array( $e ) ) {
372 $url = isset( $e['url'] ) ? (string) $e['url'] : '';
373 $msg = isset( $e['error'] ) ? (string) $e['error'] : '';
374 // The sitemap message already names the URL, so
375 // prefixing it would print the URL twice on one line.
376 $line = ( '' !== $url && false === strpos( $msg, $url ) )
377 ? $url . ' — ' . $msg
378 : $msg;
379 } else {
380 $line = (string) $e;
381 }
382 \WP_CLI::log( ' ' . $line );
383 }
384 }
385 return;
386
387 default:
388 \WP_CLI::error( "Unknown action: $action" );
389 }
390 }
391 }
392