PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Preloader / PreloaderModule.php

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

344 lines 11.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',
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 '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',
71 'description' => 'URLs warmed per cron tick. Higher = faster crawl, more load on the origin.',
72 'dependsOn' => array( 'field' => 'enabled' ),
73 ),
74 'sitemap_url' => array(
75 'type' => 'string',
76 'default' => '',
77 'label' => 'Sitemap URL (optional)',
78 'description' => 'Override the auto-detected WordPress core sitemap (/wp-sitemap.xml). Leave blank for default.',
79 'dependsOn' => array( 'field' => 'enabled' ),
80 ),
81 'warm_on_publish' => array(
82 'type' => 'bool',
83 'default' => true,
84 'label' => 'Warm new content immediately',
85 'description' => 'When a post or page is published, fetch it once so the first visitor sees a cache HIT, not a cold MISS.',
86 'dependsOn' => array( 'field' => 'enabled' ),
87 ),
88 'warm_on_comment' => array(
89 'type' => 'bool',
90 'default' => false,
91 'label' => 'Re-warm after comments',
92 'description' => 'Re-warm a page after a comment is posted (Cache purges the page on comment; this fetches it back into cache).',
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 add_action( Preloader::CRON_HOOK, array( Preloader::class, 'tick' ) );
145 add_action( 'xspeed_preloader_recurring', array( Preloader::class, 'recurring_kickoff' ) );
146
147 // Apply schedule changes immediately whenever this module's
148 // settings get written (the standard per-module option hook).
149 add_action( 'update_option_xspeed_module_preloader', array( $this, 'on_settings_change' ), 10, 2 );
150 add_action( 'add_option_xspeed_module_preloader', array( $this, 'on_settings_added' ), 10, 2 );
151
152 // Content warmer — auto-warm a single URL on post publish /
153 // comment so the first visitor after a publish/comment sees a
154 // HIT, not the cold MISS that Cache::purge_all just created.
155 $opts = Settings_Manager::get( self::SLUG );
156 if ( ! empty( $opts['warm_on_publish'] ) ) {
157 add_action( 'transition_post_status', array( $this, 'on_post_transition' ), 10, 3 );
158 }
159 if ( ! empty( $opts['warm_on_comment'] ) ) {
160 add_action( 'comment_post', array( $this, 'on_comment_post' ), 20, 2 );
161 }
162 }
163
164 /**
165 * Hook: a post transitioned to publish. Warm its permalink once on
166 * shutdown so the post-save request itself stays fast.
167 *
168 * @param string $new New post status.
169 * @param string $old Old post status.
170 * @param \WP_Post $post Post object.
171 */
172 public function on_post_transition( $new, $old, $post ): void {
173 if ( 'publish' !== $new || 'publish' === $old ) {
174 return;
175 }
176 // Only warm public post types so we don't crawl private CPTs.
177 $post_type_obj = get_post_type_object( $post->post_type );
178 if ( ! $post_type_obj || empty( $post_type_obj->public ) ) {
179 return;
180 }
181 $url = get_permalink( $post );
182 if ( ! $url ) {
183 return;
184 }
185 // Defer to shutdown so the user's "Publish" click returns fast.
186 // (Cache::purge_all has already fired by then on the save_post
187 // hook, so the warm fetch lands AFTER the purge.)
188 add_action(
189 'shutdown',
190 static function () use ( $url ) {
191 Preloader::warm_one( $url, 'post published' );
192 },
193 20
194 );
195 }
196
197 /**
198 * Hook: comment posted. Warm the post's permalink so the page is
199 * back in cache before the next visitor lands.
200 *
201 * @param int $comment_id The comment ID.
202 * @param int $approved 1, 0, or 'spam'.
203 */
204 public function on_comment_post( $comment_id, $approved ): void {
205 // Approved comments only — pending/spam don't show on the
206 // public page and shouldn't trigger a warm.
207 if ( 1 !== (int) $approved ) {
208 return;
209 }
210 $comment = get_comment( $comment_id );
211 if ( ! $comment || ! $comment->comment_post_ID ) {
212 return;
213 }
214 $url = get_permalink( (int) $comment->comment_post_ID );
215 if ( ! $url ) {
216 return;
217 }
218 add_action(
219 'shutdown',
220 static function () use ( $url ) {
221 Preloader::warm_one( $url, 'comment posted' );
222 },
223 20
224 );
225 }
226
227 public function deactivate(): void {
228 wp_clear_scheduled_hook( Preloader::CRON_HOOK );
229 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
230 }
231
232 public function on_settings_change( $old, $new ): void {
233 $enabled = is_array( $new ) && ! empty( $new['enabled'] );
234 $schedule = is_array( $new ) ? (string) ( $new['schedule'] ?? 'manual' ) : 'manual';
235 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
236 }
237
238 public function on_settings_added( $name, $value ): void {
239 $enabled = is_array( $value ) && ! empty( $value['enabled'] );
240 $schedule = is_array( $value ) ? (string) ( $value['schedule'] ?? 'manual' ) : 'manual';
241 Preloader::apply_schedule( $enabled ? $schedule : 'manual' );
242 }
243
244 public function rest_start( \WP_REST_Request $request ) {
245 $opts = Settings_Manager::get( self::SLUG );
246 if ( empty( $opts['enabled'] ) ) {
247 return new \WP_Error(
248 'xspeed_preloader_disabled',
249 __( 'Enable the preloader before starting a crawl.', 'xspeed' ),
250 array( 'status' => 409 )
251 );
252 }
253 return rest_ensure_response( Preloader::start() );
254 }
255
256 public function rest_stop( \WP_REST_Request $request ) {
257 return rest_ensure_response( Preloader::stop() );
258 }
259
260 public function rest_status( \WP_REST_Request $request ) {
261 return rest_ensure_response( Preloader::status() );
262 }
263
264 public function cli_handler( array $args, array $assoc ): void {
265 $action = $args[0] ?? 'status';
266
267 switch ( $action ) {
268 case 'start':
269 $opts = Settings_Manager::get( self::SLUG );
270 if ( empty( $opts['enabled'] ) ) {
271 \WP_CLI::error( 'Preloader is disabled. Enable it via wp xspeed preloader set --enabled=1 first.' );
272 return;
273 }
274 $state = Preloader::start();
275 // Don't print a green Success over a crawl that queued
276 // nothing — that exit-0 was the whole complaint in #142.
277 $sitemap_error = (string) ( $state['sitemap_error'] ?? '' );
278 if ( 0 === (int) $state['total'] ) {
279 \WP_CLI::error(
280 '' !== $sitemap_error
281 ? sprintf( 'Queued 0 URLs. %s', $sitemap_error )
282 : 'Queued 0 URLs — nothing to warm. Check the sitemap URL and the cache exclusion rules.'
283 );
284 return;
285 }
286 if ( 'fallback' === ( $state['source'] ?? '' ) ) {
287 \WP_CLI::warning( $sitemap_error );
288 \WP_CLI::success(
289 sprintf(
290 'Queued %d URL%s from the site content instead of the sitemap.',
291 $state['total'],
292 1 === $state['total'] ? '' : 's'
293 )
294 );
295 return;
296 }
297 \WP_CLI::success( sprintf( 'Queued %d URL%s.', $state['total'], 1 === $state['total'] ? '' : 's' ) );
298 return;
299
300 case 'stop':
301 Preloader::stop();
302 \WP_CLI::success( 'Preloader stopped.' );
303 return;
304
305 case 'status':
306 $state = Preloader::status();
307 \WP_CLI::log( 'Running : ' . ( $state['running'] ? 'yes' : 'no' ) );
308 \WP_CLI::log( 'Processed : ' . $state['processed'] . ' / ' . $state['total'] );
309 if ( $state['last_url'] ) {
310 \WP_CLI::log( 'Last URL : ' . $state['last_url'] );
311 }
312 if ( ! empty( $state['errors'] ) ) {
313 \WP_CLI::log( 'Errors : ' . count( $state['errors'] ) );
314 foreach ( array_slice( $state['errors'], -5 ) as $e ) {
315 // Tolerate a bare string as well as the {url, error}
316 // shape. A string entry fataled this command outright
317 // ("Cannot access offset of type string on string"),
318 // which also took MCP's get_preloader_status down with
319 // it — an agent asking why a preload failed got a type
320 // error instead of the reason. The writer is fixed, but
321 // `status` is a diagnostic: it should survive whatever
322 // it is handed rather than die reporting on it. (QA F1)
323 if ( is_array( $e ) ) {
324 $url = isset( $e['url'] ) ? (string) $e['url'] : '';
325 $msg = isset( $e['error'] ) ? (string) $e['error'] : '';
326 // The sitemap message already names the URL, so
327 // prefixing it would print the URL twice on one line.
328 $line = ( '' !== $url && false === strpos( $msg, $url ) )
329 ? $url . '' . $msg
330 : $msg;
331 } else {
332 $line = (string) $e;
333 }
334 \WP_CLI::log( ' ' . $line );
335 }
336 }
337 return;
338
339 default:
340 \WP_CLI::error( "Unknown action: $action" );
341 }
342 }
343 }
344