PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 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 All 33 releases
xspeed / includes / modules / Preloader / PreloaderModule.php

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

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