PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / modules / Preloader / PreloaderModule.php

PreloaderModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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