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.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 1.2.1 All 27 releases
xspeed / includes / class-scan.php

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