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 / class-scan.php

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

453 lines 15.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 * Start a scan. Returns the scan id to poll, or a WP_Error.
107 *
108 * `$fresh` forces a new run; without it the engine may hand back a
109 * recent cached report for the same URL, which is what you want for a
110 * first look and not what you want after a change.
111 *
112 * @return array{scan_id:string,report_url:string,cached:bool}|\WP_Error
113 */
114 public static function start( string $url = '', bool $fresh = false ) {
115 $url = '' !== $url ? $url : home_url( '/' );
116
117 // The engine probes this URL from the outside, so a host it cannot
118 // reach produces a confusing failure deep in the scan rather than
119 // here. Catch the obvious cases up front.
120 if ( ! wp_http_validate_url( $url ) ) {
121 return new \WP_Error(
122 'xspeed_scan_bad_url',
123 __( 'That does not look like a public URL the scanner can reach.', 'xspeed' ),
124 array( 'status' => 400 )
125 );
126 }
127
128 // Claim the slot BEFORE the 20s remote call, or a second overlapping
129 // request starts its own scan while this one is still in flight.
130 if ( ! self::claim() ) {
131 $p = self::pending();
132 return new \WP_Error(
133 'xspeed_scan_in_progress',
134 __( 'A scan is already running for this site.', 'xspeed' ),
135 array(
136 'status' => 409,
137 'scan_id' => is_array( $p ) ? ( $p['scan_id'] ?? '' ) : '',
138 )
139 );
140 }
141
142 $res = wp_remote_post(
143 self::base() . '/api/scan',
144 array(
145 'timeout' => 20,
146 'headers' => array( 'Content-Type' => 'application/json' ),
147 'body' => wp_json_encode(
148 array(
149 'url' => $url,
150 'fresh' => $fresh,
151 // Sent ahead of engine support: an unknown field is
152 // ignored today and becomes meaningful the moment
153 // the flag lands, with no plugin release needed.
154 'visibility' => 'private' === self::visibility() ? 'unlisted' : 'listed',
155 )
156 ),
157 )
158 );
159
160 $body = self::decode( $res );
161 if ( is_wp_error( $body ) ) {
162 self::release();
163 return $body;
164 }
165
166 $code = (int) wp_remote_retrieve_response_code( $res );
167 if ( 429 === $code ) {
168 self::release();
169 return new \WP_Error(
170 'xspeed_scan_rate_limited',
171 isset( $body['message'] ) && is_string( $body['message'] )
172 ? $body['message']
173 : __( 'The scanner is rate limited right now. Try again in a few minutes.', 'xspeed' ),
174 array( 'status' => 429 )
175 );
176 }
177
178 $scan_id = isset( $body['scanId'] ) && is_string( $body['scanId'] ) ? $body['scanId'] : '';
179 if ( $code >= 400 || '' === $scan_id || ! self::valid_id( $scan_id ) ) {
180 self::release();
181 return new \WP_Error(
182 'xspeed_scan_failed',
183 isset( $body['message'] ) && is_string( $body['message'] )
184 ? $body['message']
185 : __( 'The scan could not be started.', 'xspeed' ),
186 array( 'status' => 502 )
187 );
188 }
189
190 $report_url = isset( $body['reportUrl'] ) && is_string( $body['reportUrl'] )
191 ? esc_url_raw( $body['reportUrl'] )
192 : self::report_url( $scan_id );
193
194 update_option(
195 self::PENDING_OPTION,
196 array(
197 'scan_id' => $scan_id,
198 'report_url' => $report_url,
199 'started' => time(),
200 ),
201 false
202 );
203
204 return array(
205 'scan_id' => $scan_id,
206 'report_url' => $report_url,
207 'cached' => ! empty( $body['cached'] ),
208 );
209 }
210
211 /**
212 * Poll one scan. A still-running scan reports its current step; a
213 * finished one is normalised, stored, and returned.
214 *
215 * @return array<string,mixed>|\WP_Error
216 */
217 public static function poll( string $scan_id ) {
218 if ( ! self::valid_id( $scan_id ) ) {
219 return new \WP_Error(
220 'xspeed_scan_bad_id',
221 __( 'That is not a valid scan id.', 'xspeed' ),
222 array( 'status' => 400 )
223 );
224 }
225
226 $res = wp_remote_get(
227 self::base() . '/api/scan/' . rawurlencode( $scan_id ),
228 array( 'timeout' => 20 )
229 );
230 $body = self::decode( $res );
231 if ( is_wp_error( $body ) ) {
232 return $body;
233 }
234
235 $status = isset( $body['status'] ) && is_string( $body['status'] ) ? $body['status'] : '';
236
237 if ( 'running' === $status ) {
238 return array(
239 'status' => 'running',
240 'scan_id' => $scan_id,
241 'step' => isset( $body['step'] ) && is_string( $body['step'] ) ? $body['step'] : '',
242 );
243 }
244
245 if ( 'complete' !== $status ) {
246 delete_option( self::PENDING_OPTION );
247 return new \WP_Error(
248 'xspeed_scan_failed',
249 isset( $body['error'] ) && is_string( $body['error'] )
250 ? $body['error']
251 : __( 'The scan did not complete.', 'xspeed' ),
252 array( 'status' => 502 )
253 );
254 }
255
256 $result = self::normalise( $body, self::visibility() );
257 update_option( self::RESULT_OPTION, $result, false );
258 delete_option( self::PENDING_OPTION );
259
260 return $result;
261 }
262
263 /**
264 * Claim the right to start a scan, atomically.
265 *
266 * `pending()` then `update_option()` is a read-then-write, and the write
267 * only happens AFTER a 20-second remote call -- so two overlapping
268 * requests both saw no marker, both spent a run against the engine's rate
269 * limit, and the second write buried the first scan's id so its result was
270 * never polled or stored.
271 *
272 * `add_option()` is the lock: it fails if the row already exists, and it
273 * is a single INSERT rather than a check followed by a write. The claim is
274 * placed BEFORE the remote call and released if that call fails, so a
275 * failed start does not leave the feature wedged until PENDING_MAX_AGE.
276 *
277 * @return bool True when this caller owns the scan slot.
278 */
279 private static function claim(): bool {
280 // Sweep a stale marker first, so an abandoned claim cannot block
281 // scanning for longer than PENDING_MAX_AGE.
282 self::pending();
283
284 return add_option(
285 self::PENDING_OPTION,
286 array(
287 'scan_id' => '',
288 'report_url' => '',
289 'started' => time(),
290 ),
291 '',
292 false
293 );
294 }
295
296 /** Release a claim taken by claim() -- used when the start fails. */
297 private static function release(): void {
298 delete_option( self::PENDING_OPTION );
299 }
300
301 /** The in-flight scan, or null. Stale markers are swept, not returned. */
302 public static function pending(): ?array {
303 $p = get_option( self::PENDING_OPTION );
304 if ( ! is_array( $p ) || empty( $p['scan_id'] ) ) {
305 return null;
306 }
307 if ( time() - (int) ( $p['started'] ?? 0 ) > self::PENDING_MAX_AGE ) {
308 delete_option( self::PENDING_OPTION );
309 return null;
310 }
311 return $p;
312 }
313
314 /** The last completed report, or null. */
315 public static function latest(): ?array {
316 $r = get_option( self::RESULT_OPTION );
317 return is_array( $r ) && isset( $r['score'] ) ? $r : null;
318 }
319
320 public static function report_url( string $scan_id ): string {
321 return self::base() . '/scan/r/' . $scan_id;
322 }
323
324 private static function valid_id( string $id ): bool {
325 return 1 === preg_match( self::ID_PATTERN, $id );
326 }
327
328 /**
329 * Reduce the engine's full report to what the dashboard stores.
330 *
331 * The whole audit stays one link away on the report page; keeping a
332 * copy of all 20 checks in an option would bloat it for no gain. What
333 * survives is the headline grade, the four dimensions, and the failing
334 * checks ranked by how many points fixing each recovers — the last is
335 * the part PSI cannot give us, so it is the part worth keeping.
336 */
337 private static function normalise( array $body, ?string $visibility = null ): array {
338 $dims = array();
339 if ( isset( $body['dimensions'] ) && is_array( $body['dimensions'] ) ) {
340 foreach ( $body['dimensions'] as $key => $d ) {
341 if ( ! is_array( $d ) || empty( $d['applicable'] ) ) {
342 continue;
343 }
344 $dims[ (string) $key ] = array(
345 'score' => self::num( $d['score'] ?? null ),
346 'earned' => self::num( $d['earned'] ?? null ),
347 'weight' => self::num( $d['weight'] ?? null ),
348 );
349 }
350 }
351
352 // Failing and partial checks, biggest recoverable gap first — the
353 // "do this next and gain N points" list.
354 $fixes = array();
355 if ( isset( $body['checks'] ) && is_array( $body['checks'] ) ) {
356 foreach ( $body['checks'] as $c ) {
357 if ( ! is_array( $c ) ) {
358 continue;
359 }
360 $status = isset( $c['status'] ) ? (string) $c['status'] : '';
361 if ( 'fail' !== $status && 'partial' !== $status ) {
362 continue;
363 }
364 $weight = (float) self::num( $c['weight'] ?? 0 );
365 $earned = (float) self::num( $c['earned'] ?? 0 );
366 $fixes[] = array(
367 'id' => isset( $c['id'] ) ? (string) $c['id'] : '',
368 'name' => isset( $c['name'] ) ? (string) $c['name'] : '',
369 'status' => $status,
370 'evidence' => isset( $c['evidence'] ) ? (string) $c['evidence'] : '',
371 'remediation' => isset( $c['remediation'] ) ? (string) $c['remediation'] : '',
372 'recoverable' => round( max( 0, $weight - $earned ), 1 ),
373 );
374 }
375 usort(
376 $fixes,
377 static fn( array $a, array $b ): int => $b['recoverable'] <=> $a['recoverable']
378 );
379 $fixes = array_slice( $fixes, 0, 8 );
380 }
381
382 $measured = isset( $body['measured'] ) && is_array( $body['measured'] ) ? $body['measured'] : array();
383
384 return array(
385 'scan_id' => isset( $body['scanId'] ) ? (string) $body['scanId'] : '',
386 'ts' => time(),
387 'url' => isset( $body['url'] ) ? esc_url_raw( (string) $body['url'] ) : '',
388 // `overallScore`, not `score` — the engine's own field name.
389 'score' => self::num( $body['overallScore'] ?? null ),
390 'grade' => isset( $body['grade'] ) ? (string) $body['grade'] : '',
391 'level' => self::num( $body['level'] ?? null ),
392 'level_name' => isset( $body['levelName'] ) ? (string) $body['levelName'] : '',
393 // A partial scan graded less than the full rubric; saying so is
394 // the difference between a low score and an incomplete one.
395 'partial' => ! empty( $body['partial'] ),
396 'dimensions' => $dims,
397 'measured' => array(
398 // Lighthouse is reported alongside, never AS, the score.
399 'lighthouse' => self::num( $measured['lighthouse'] ?? null ),
400 'lighthouse_desktop' => self::num( $measured['lighthouseDesktop'] ?? null ),
401 'ttfb_ms' => self::num( $measured['ttfbMs'] ?? null ),
402 'lcp_ms' => self::num( $measured['lcpMs'] ?? null ),
403 'cls' => self::num( $measured['cls'] ?? null ),
404 'tbt_ms' => self::num( $measured['tbtMs'] ?? null ),
405 'cache_hit' => isset( $measured['cacheHit'] ) ? (bool) $measured['cacheHit'] : null,
406 ),
407 'fixes' => $fixes,
408 'report_url' => isset( $body['reportUrl'] )
409 ? esc_url_raw( (string) $body['reportUrl'] )
410 : self::report_url( isset( $body['scanId'] ) ? (string) $body['scanId'] : '' ),
411 // Passed in, not resolved here: shaping a payload must not depend
412 // on Hub state, or the transform cannot be reasoned about (or
413 // tested) without a database behind it.
414 'visibility' => $visibility ?? '',
415 );
416 }
417
418 /**
419 * Numbers from an external service, kept nullable.
420 *
421 * (int) null is 0, and a missing measurement rendered as zero is the
422 * one coercion this feature must not make — "not measured" and "scored
423 * nothing" are different news.
424 */
425 private static function num( $v ) {
426 return is_numeric( $v ) ? ( is_float( $v + 0 ) && (float) $v !== floor( (float) $v ) ? round( (float) $v, 3 ) : (int) $v ) : null;
427 }
428
429 /** Shared transport handling: network error, then JSON shape. */
430 private static function decode( $res ) {
431 if ( is_wp_error( $res ) ) {
432 return new \WP_Error(
433 'xspeed_scan_unreachable',
434 sprintf(
435 /* translators: %s: transport error message. */
436 __( 'Could not reach the scanner: %s', 'xspeed' ),
437 $res->get_error_message()
438 ),
439 array( 'status' => 502 )
440 );
441 }
442 $body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
443 if ( ! is_array( $body ) ) {
444 return new \WP_Error(
445 'xspeed_scan_bad_response',
446 __( 'The scanner returned an unreadable response.', 'xspeed' ),
447 array( 'status' => 502 )
448 );
449 }
450 return $body;
451 }
452 }
453