PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / class-scan.php

class-scan.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-scan.php

518 lines 18.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Scan — the xSpeed Scan engine, run from the plugin.
4 *
5 * A graded whole-site report: four weighted dimensions (speed, delivery,
6 * assets, stack) over ~20 checks, each with evidence and a suggested fix.
7 * It is NOT a PageSpeed score wearing a different name — a Lighthouse
8 * result is ONE check inside it (S1, 18 of 100 points), and the rubric
9 * also measures things PSI cannot see at all: whether the request was a
10 * cache HIT, whether a caching plugin is active, whether the site is
11 * AI-controllable. `Score` stays the home of raw provider scores; the two
12 * numbers are different scales and must never be presented as one.
13 *
14 * Outbound HTTP is opt-in and user-initiated: nothing here runs unless
15 * someone presses Scan or runs the command. No schedule, no background
16 * call — see readme.txt "External services".
17 *
18 * PRIVACY, and the reason this class takes a `visibility` at all: the scan
19 * engine publishes every report it stores to a per-host feed that anyone
20 * can enumerate by domain. A site attached to the Hub gets an unlisted
21 * report instead, which the engine now honours -- the rule is simply
22 * unattached => public, attached => private. `visibility()` decides which
23 * is asked for and `private_supported()` says whether the engine can
24 * deliver it, so the UI can promise privacy only when both are true.
25 *
26 * "Unlisted" is the exact promise: the report is kept off the public
27 * per-host feed, but its URL stays reachable to anyone holding it.
28 *
29 * @package XSpeed
30 */
31
32 declare(strict_types=1);
33
34 namespace XSpeed;
35
36 defined( 'ABSPATH' ) || exit;
37
38 final class Scan {
39
40 /** Where the engine lives. Overridable for development only. */
41 public const BASE = 'https://xspeedcache.com';
42
43 /** Last completed report, plus the in-flight scan id. Autoload off. */
44 public const RESULT_OPTION = 'xspeed_scan_result';
45 public const PENDING_OPTION = 'xspeed_scan_pending';
46
47 /**
48 * A scan takes 20-60s, so a pending marker older than this is a scan
49 * that died — otherwise a crashed run wedges the button forever.
50 */
51 public const PENDING_MAX_AGE = 900;
52
53 /** Scan ids are `<host-slug>-<10 hex>`; mirror the engine's own shape. */
54 private const ID_PATTERN = '/^[a-z0-9][a-z0-9-]{0,80}-[0-9a-f]{10}$/';
55
56 /** The engine origin, filterable so a dev site can point elsewhere. */
57 public static function base(): string {
58 $base = (string) apply_filters( 'xspeed_scan_base', self::BASE );
59 return rtrim( $base, '/' );
60 }
61
62 /**
63 * Would this site's report be public or private?
64 *
65 * Attachment to the Hub is the switch. Note this reports INTENT: until
66 * the engine supports unlisted scans, every report is in fact public,
67 * which is why `private_supported()` exists as a separate question and
68 * the UI must not promise privacy on the strength of this alone.
69 */
70 public static function visibility(): string {
71 return self::attached() ? 'private' : 'public';
72 }
73
74 /**
75 * Is this site attached to the Hub?
76 *
77 * Guarded rather than a hard dependency: the Hub client lives in the Mcp
78 * module, and a site with that module inactive is simply not attached —
79 * which is a "no", not a fatal.
80 */
81 public static function attached(): bool {
82 if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) {
83 return false;
84 }
85 return (bool) \XSpeed\Modules\Mcp\Mcp_Hub::site_attached();
86 }
87
88 /**
89 * Can a private report actually be produced yet?
90 *
91 * True: the engine honours `visibility` on /api/scan, and a report sent
92 * as `unlisted` is kept off the public per-host feed. Kept separate from
93 * `visibility()` so the UI still asks two questions -- "would this be
94 * private?" and "can that be delivered?" -- and so a site pointed at an
95 * older engine by `xspeed_scan_base` can filter it back to false rather
96 * than promising a privacy that build cannot honour.
97 *
98 * Note the scope of the promise: unlisted suppresses the LISTING, not
99 * the report URL, which stays reachable by anyone who has it.
100 */
101 public static function private_supported(): bool {
102 return (bool) apply_filters( 'xspeed_scan_private_supported', true );
103 }
104
105 /**
106 * Which Lighthouse runs a scan may spend. `both` is the engine's default:
107 * the desktop run graded as the headline, the mobile run graded alongside
108 * it. A single device costs half the engine's quota and becomes the
109 * headline. Anything else is sent as `both`.
110 */
111 const STRATEGIES = array( 'both', 'desktop', 'mobile' );
112
113 /** Coerce a caller's strategy to one the engine knows. */
114 public static function strategy( string $value ): string {
115 $value = strtolower( trim( $value ) );
116 return in_array( $value, self::STRATEGIES, true ) ? $value : 'both';
117 }
118
119 /**
120 * Start a scan. Returns the scan id to poll, or a WP_Error.
121 *
122 * `$fresh` forces a new run; without it the engine may hand back a
123 * recent cached report for the same URL, which is what you want for a
124 * first look and not what you want after a change.
125 *
126 * `$strategy` picks the Lighthouse runs (see STRATEGIES). The engine
127 * grades the desktop run as the headline and the mobile run beside it,
128 * so `both` is right for a report a person reads; a site that only
129 * wants the run Google ranks on asks for `mobile` and spends half.
130 *
131 * @return array{scan_id:string,report_url:string,cached:bool}|\WP_Error
132 */
133 public static function start( string $url = '', bool $fresh = false, string $strategy = 'both' ) {
134 $url = '' !== $url ? $url : home_url( '/' );
135
136 // The engine probes this URL from the outside, so a host it cannot
137 // reach produces a confusing failure deep in the scan rather than
138 // here. Catch the obvious cases up front.
139 if ( ! wp_http_validate_url( $url ) ) {
140 return new \WP_Error(
141 'xspeed_scan_bad_url',
142 __( 'That does not look like a public URL the scanner can reach.', 'xspeed' ),
143 array( 'status' => 400 )
144 );
145 }
146
147 // Claim the slot BEFORE the 20s remote call, or a second overlapping
148 // request starts its own scan while this one is still in flight.
149 if ( ! self::claim() ) {
150 $p = self::pending();
151 return new \WP_Error(
152 'xspeed_scan_in_progress',
153 __( 'A scan is already running for this site.', 'xspeed' ),
154 array(
155 'status' => 409,
156 'scan_id' => is_array( $p ) ? ( $p['scan_id'] ?? '' ) : '',
157 )
158 );
159 }
160
161 $res = wp_remote_post(
162 self::base() . '/api/scan',
163 array(
164 'timeout' => 20,
165 'headers' => array( 'Content-Type' => 'application/json' ),
166 'body' => wp_json_encode(
167 array(
168 'url' => $url,
169 'fresh' => $fresh,
170 'strategy' => self::strategy( $strategy ),
171 // Sent ahead of engine support: an unknown field is
172 // ignored today and becomes meaningful the moment
173 // the flag lands, with no plugin release needed.
174 'visibility' => 'private' === self::visibility() ? 'unlisted' : 'listed',
175 )
176 ),
177 )
178 );
179
180 $body = self::decode( $res );
181 if ( is_wp_error( $body ) ) {
182 self::release();
183 return $body;
184 }
185
186 $code = (int) wp_remote_retrieve_response_code( $res );
187 if ( 429 === $code ) {
188 self::release();
189 return new \WP_Error(
190 'xspeed_scan_rate_limited',
191 isset( $body['message'] ) && is_string( $body['message'] )
192 ? $body['message']
193 : __( 'The scanner is rate limited right now. Try again in a few minutes.', 'xspeed' ),
194 array( 'status' => 429 )
195 );
196 }
197
198 $scan_id = isset( $body['scanId'] ) && is_string( $body['scanId'] ) ? $body['scanId'] : '';
199 if ( $code >= 400 || '' === $scan_id || ! self::valid_id( $scan_id ) ) {
200 self::release();
201 return new \WP_Error(
202 'xspeed_scan_failed',
203 isset( $body['message'] ) && is_string( $body['message'] )
204 ? $body['message']
205 : __( 'The scan could not be started.', 'xspeed' ),
206 array( 'status' => 502 )
207 );
208 }
209
210 $report_url = isset( $body['reportUrl'] ) && is_string( $body['reportUrl'] )
211 ? esc_url_raw( $body['reportUrl'] )
212 : self::report_url( $scan_id );
213
214 update_option(
215 self::PENDING_OPTION,
216 array(
217 'scan_id' => $scan_id,
218 'report_url' => $report_url,
219 'started' => time(),
220 ),
221 false
222 );
223
224 return array(
225 'scan_id' => $scan_id,
226 'report_url' => $report_url,
227 'cached' => ! empty( $body['cached'] ),
228 );
229 }
230
231 /**
232 * Poll one scan. A still-running scan reports its current step; a
233 * finished one is normalised, stored, and returned.
234 *
235 * @return array<string,mixed>|\WP_Error
236 */
237 public static function poll( string $scan_id ) {
238 if ( ! self::valid_id( $scan_id ) ) {
239 return new \WP_Error(
240 'xspeed_scan_bad_id',
241 __( 'That is not a valid scan id.', 'xspeed' ),
242 array( 'status' => 400 )
243 );
244 }
245
246 $res = wp_remote_get(
247 self::base() . '/api/scan/' . rawurlencode( $scan_id ),
248 array( 'timeout' => 20 )
249 );
250 $body = self::decode( $res );
251 if ( is_wp_error( $body ) ) {
252 return $body;
253 }
254
255 $status = isset( $body['status'] ) && is_string( $body['status'] ) ? $body['status'] : '';
256
257 if ( 'running' === $status ) {
258 return array(
259 'status' => 'running',
260 'scan_id' => $scan_id,
261 'step' => isset( $body['step'] ) && is_string( $body['step'] ) ? $body['step'] : '',
262 );
263 }
264
265 if ( 'complete' !== $status ) {
266 delete_option( self::PENDING_OPTION );
267 return new \WP_Error(
268 'xspeed_scan_failed',
269 isset( $body['error'] ) && is_string( $body['error'] )
270 ? $body['error']
271 : __( 'The scan did not complete.', 'xspeed' ),
272 array( 'status' => 502 )
273 );
274 }
275
276 $result = self::normalise( $body, self::visibility() );
277 update_option( self::RESULT_OPTION, $result, false );
278 delete_option( self::PENDING_OPTION );
279
280 return $result;
281 }
282
283 /**
284 * Claim the right to start a scan, atomically.
285 *
286 * `pending()` then `update_option()` is a read-then-write, and the write
287 * only happens AFTER a 20-second remote call -- so two overlapping
288 * requests both saw no marker, both spent a run against the engine's rate
289 * limit, and the second write buried the first scan's id so its result was
290 * never polled or stored.
291 *
292 * `add_option()` is the lock: it fails if the row already exists, and it
293 * is a single INSERT rather than a check followed by a write. The claim is
294 * placed BEFORE the remote call and released if that call fails, so a
295 * failed start does not leave the feature wedged until PENDING_MAX_AGE.
296 *
297 * @return bool True when this caller owns the scan slot.
298 */
299 private static function claim(): bool {
300 // Sweep a stale marker first, so an abandoned claim cannot block
301 // scanning for longer than PENDING_MAX_AGE.
302 self::pending();
303
304 return add_option(
305 self::PENDING_OPTION,
306 array(
307 'scan_id' => '',
308 'report_url' => '',
309 'started' => time(),
310 ),
311 '',
312 false
313 );
314 }
315
316 /** Release a claim taken by claim() -- used when the start fails. */
317 private static function release(): void {
318 delete_option( self::PENDING_OPTION );
319 }
320
321 /** The in-flight scan, or null. Stale markers are swept, not returned. */
322 public static function pending(): ?array {
323 $p = get_option( self::PENDING_OPTION );
324 if ( ! is_array( $p ) || empty( $p['scan_id'] ) ) {
325 return null;
326 }
327 if ( time() - (int) ( $p['started'] ?? 0 ) > self::PENDING_MAX_AGE ) {
328 delete_option( self::PENDING_OPTION );
329 return null;
330 }
331 return $p;
332 }
333
334 /** The last completed report, or null. */
335 public static function latest(): ?array {
336 $r = get_option( self::RESULT_OPTION );
337 if ( ! is_array( $r ) || ! isset( $r['score'] ) ) {
338 return null;
339 }
340 return self::with_devices( $r );
341 }
342
343 /**
344 * Give a stored record the per-device Lighthouse shape every reader now
345 * expects, whenever it was written.
346 *
347 * A record written BEFORE the engine graded desktop as the headline has
348 * no `device` and its `lighthouse` is the mobile run — that was the only
349 * run PSI made by default. A record written after has `device` set and
350 * carries both devices explicitly. So `device` is what decides how to
351 * read `lighthouse`, and nothing else has to know the difference.
352 *
353 * Deliberately does NOT invent a missing device: a desktop-only scan
354 * never measured mobile, and showing the desktop number under a mobile
355 * label is exactly what this replaced.
356 */
357 private static function with_devices( array $r ): array {
358 $m = isset( $r['measured'] ) && is_array( $r['measured'] ) ? $r['measured'] : array();
359 if ( ! array_key_exists( 'lighthouse_mobile', $m ) ) {
360 $m['lighthouse_mobile'] = '' === (string) ( $r['device'] ?? '' )
361 ? ( $m['lighthouse'] ?? null ) // pre-change record: it was mobile
362 : null; // post-change: mobile simply wasn't run
363 }
364 if ( ! array_key_exists( 'lighthouse_desktop', $m ) ) {
365 $m['lighthouse_desktop'] = null;
366 }
367 $r['measured'] = $m;
368 $r['device'] = (string) ( $r['device'] ?? '' );
369 return $r;
370 }
371
372 public static function report_url( string $scan_id ): string {
373 return self::base() . '/scan/r/' . $scan_id;
374 }
375
376 private static function valid_id( string $id ): bool {
377 return 1 === preg_match( self::ID_PATTERN, $id );
378 }
379
380 /**
381 * Reduce the engine's full report to what the dashboard stores.
382 *
383 * The whole audit stays one link away on the report page; keeping a
384 * copy of all 20 checks in an option would bloat it for no gain. What
385 * survives is the headline grade, the four dimensions, and the failing
386 * checks ranked by how many points fixing each recovers — the last is
387 * the part PSI cannot give us, so it is the part worth keeping.
388 */
389 private static function normalise( array $body, ?string $visibility = null ): array {
390 $dims = array();
391 if ( isset( $body['dimensions'] ) && is_array( $body['dimensions'] ) ) {
392 foreach ( $body['dimensions'] as $key => $d ) {
393 if ( ! is_array( $d ) || empty( $d['applicable'] ) ) {
394 continue;
395 }
396 $dims[ (string) $key ] = array(
397 'score' => self::num( $d['score'] ?? null ),
398 'earned' => self::num( $d['earned'] ?? null ),
399 'weight' => self::num( $d['weight'] ?? null ),
400 );
401 }
402 }
403
404 // Failing and partial checks, biggest recoverable gap first — the
405 // "do this next and gain N points" list.
406 $fixes = array();
407 if ( isset( $body['checks'] ) && is_array( $body['checks'] ) ) {
408 foreach ( $body['checks'] as $c ) {
409 if ( ! is_array( $c ) ) {
410 continue;
411 }
412 $status = isset( $c['status'] ) ? (string) $c['status'] : '';
413 if ( 'fail' !== $status && 'partial' !== $status ) {
414 continue;
415 }
416 $weight = (float) self::num( $c['weight'] ?? 0 );
417 $earned = (float) self::num( $c['earned'] ?? 0 );
418 $fixes[] = array(
419 'id' => isset( $c['id'] ) ? (string) $c['id'] : '',
420 'name' => isset( $c['name'] ) ? (string) $c['name'] : '',
421 'status' => $status,
422 'evidence' => isset( $c['evidence'] ) ? (string) $c['evidence'] : '',
423 'remediation' => isset( $c['remediation'] ) ? (string) $c['remediation'] : '',
424 'recoverable' => round( max( 0, $weight - $earned ), 1 ),
425 );
426 }
427 usort(
428 $fixes,
429 static fn( array $a, array $b ): int => $b['recoverable'] <=> $a['recoverable']
430 );
431 $fixes = array_slice( $fixes, 0, 8 );
432 }
433
434 $measured = isset( $body['measured'] ) && is_array( $body['measured'] ) ? $body['measured'] : array();
435
436 return array(
437 'scan_id' => isset( $body['scanId'] ) ? (string) $body['scanId'] : '',
438 'ts' => time(),
439 'url' => isset( $body['url'] ) ? esc_url_raw( (string) $body['url'] ) : '',
440 // `overallScore`, not `score` — the engine's own field name.
441 'score' => self::num( $body['overallScore'] ?? null ),
442 'grade' => isset( $body['grade'] ) ? (string) $body['grade'] : '',
443 'level' => self::num( $body['level'] ?? null ),
444 'level_name' => isset( $body['levelName'] ) ? (string) $body['levelName'] : '',
445 // A partial scan graded less than the full rubric; saying so is
446 // the difference between a low score and an incomplete one.
447 'partial' => ! empty( $body['partial'] ),
448 'dimensions' => $dims,
449 // Which device the headline grade is for: `desktop` (the default
450 // `both` run grades desktop as the headline), `mobile`, or ''
451 // from an engine that predates the strategy option.
452 'device' => isset( $body['device'] ) ? (string) $body['device'] : '',
453 'measured' => array(
454 // Lighthouse is reported alongside, never AS, the score.
455 //
456 // `lighthouse` is the GRADED run's score, and which device
457 // that is changed when the engine started grading desktop as
458 // the headline. Read the two per-device fields instead; this
459 // one stays only so a stored record keeps its shape, and
460 // `latest()` back-fills it for records written before the
461 // change. Reading it AS the mobile score is the bug this
462 // comment exists to prevent (it printed desktop as mobile).
463 'lighthouse' => self::num( $measured['lighthouse'] ?? null ),
464 'lighthouse_desktop' => self::num( $measured['lighthouseDesktop'] ?? null ),
465 'lighthouse_mobile' => self::num( $measured['lighthouseMobile'] ?? null ),
466 'ttfb_ms' => self::num( $measured['ttfbMs'] ?? null ),
467 'lcp_ms' => self::num( $measured['lcpMs'] ?? null ),
468 'cls' => self::num( $measured['cls'] ?? null ),
469 'tbt_ms' => self::num( $measured['tbtMs'] ?? null ),
470 'cache_hit' => isset( $measured['cacheHit'] ) ? (bool) $measured['cacheHit'] : null,
471 ),
472 'fixes' => $fixes,
473 'report_url' => isset( $body['reportUrl'] )
474 ? esc_url_raw( (string) $body['reportUrl'] )
475 : self::report_url( isset( $body['scanId'] ) ? (string) $body['scanId'] : '' ),
476 // Passed in, not resolved here: shaping a payload must not depend
477 // on Hub state, or the transform cannot be reasoned about (or
478 // tested) without a database behind it.
479 'visibility' => $visibility ?? '',
480 );
481 }
482
483 /**
484 * Numbers from an external service, kept nullable.
485 *
486 * (int) null is 0, and a missing measurement rendered as zero is the
487 * one coercion this feature must not make — "not measured" and "scored
488 * nothing" are different news.
489 */
490 private static function num( $v ) {
491 return is_numeric( $v ) ? ( is_float( $v + 0 ) && (float) $v !== floor( (float) $v ) ? round( (float) $v, 3 ) : (int) $v ) : null;
492 }
493
494 /** Shared transport handling: network error, then JSON shape. */
495 private static function decode( $res ) {
496 if ( is_wp_error( $res ) ) {
497 return new \WP_Error(
498 'xspeed_scan_unreachable',
499 sprintf(
500 /* translators: %s: transport error message. */
501 __( 'Could not reach the scanner: %s', 'xspeed' ),
502 $res->get_error_message()
503 ),
504 array( 'status' => 502 )
505 );
506 }
507 $body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
508 if ( ! is_array( $body ) ) {
509 return new \WP_Error(
510 'xspeed_scan_bad_response',
511 __( 'The scanner returned an unreadable response.', 'xspeed' ),
512 array( 'status' => 502 )
513 );
514 }
515 return $body;
516 }
517 }
518