PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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.3.0, at includes/modules/Preloader/PreloaderModule.php

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