PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Mcp/Mcp_Hub.php +155 -14 1.2.4 → 1.3.7 View file →
@@ -195,9 +195,9 @@
195 195 return array(
196 196 'attached' => $state['attached'],
197 197 'account_email' => $state['account_email'],
198 198 'attached_at' => $state['attached_at'],
199 - 'site_url' => home_url( '/' ),
199 + 'site_url' => Mcp_Pairing::absolute( home_url( '/' ) ),
200 200 // Method 1 paste-in credential — the existing per-site token.
201 201 // Empty until generate_token() (or a per-site Connect) mints one.
202 202 'site_token' => Mcp_Pairing::site_token(),
203 203 'hub_url' => self::hub_url(),
@@ -388,8 +388,17 @@
388 388 if ( '' === Mcp_Pairing::site_token() ) {
389 389 Mcp_Pairing::connect( false );
390 390 }
391 391
392 + /*
393 + * The Hub checks the token it is about to receive by calling this
394 + * site with it. If its earlier checks with an old token locked it out,
395 + * that check gets a 429 and the Hub keeps the old token, so the site
396 + * could never be connected again. An admin started this attach; let
397 + * every client try again.
398 + */
399 + Mcp_Rate_Limiter::reset_all();
400 +
392 401 return array(
393 402 'site_url' => self::site_url_canonical(),
394 403 'site_token' => Mcp_Pairing::site_token(),
395 404 'user_id' => (int) $uid,
@@ -443,9 +452,14 @@
443 452 return;
444 453 }
445 454
446 455 $uid = get_current_user_id();
456 + if ( empty( $body['attached'] ) && $uid && ! empty( self::state( $uid )['attached'] ) && self::keep_link_after_resync() ) {
457 + return;
458 + }
447 459 if ( ! empty( $body['attached'] ) ) {
460 + // Any sync in flight has landed; the next mismatch deserves a new one.
461 + delete_transient( self::RESYNC_SENT );
448 462 // The Hub says attached — mark THIS admin connected if not already.
449 463 $state = self::state( $uid );
450 464 if ( empty( $state['attached'] ) ) {
451 465 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
@@ -461,8 +475,93 @@
461 475 }
462 476 }
463 477
464 478 /**
479 + * When the last token sync was sent (unix time), kept for 30 minutes so
480 + * reconcile sends at most one sync per window.
481 + */
482 + private const RESYNC_SENT = 'xspeed_hub_resync_sent';
483 +
484 + /** How long the Hub gets to check a synced token before its silence counts. */
485 + private const RESYNC_GRACE = 2 * MINUTE_IN_SECONDS;
486 +
487 + /**
488 + * Send this site's current token to the Hub.
489 + *
490 + * The Hub keeps a copy of the token and presents it on every call. Rotate,
491 + * a Connect after Disconnect, or a reinstall replace the token here but
492 + * not there, and every Hub check then failed with "token invalid" until
493 + * the site was attached again.
494 + *
495 + * The Hub answers straight away (202) and checks the token afterwards by
496 + * calling this site with it. It stores the token only if this site
497 + * accepts it as the pairing token, so waiting here never holds a PHP
498 + * worker the Hub's check needs.
499 + *
500 + * Only for a site that was attached: an unattached site makes no call.
501 + *
502 + * @return int The Hub's HTTP status, or 0 when no call was made or it failed.
503 + */
504 + public static function push_token_to_hub(): int {
505 + $token = Mcp_Pairing::site_token();
506 + if ( '' === $token || ! self::site_attached() ) {
507 + return 0;
508 + }
509 + // The Hub checks this token by calling back with it. Its failures with
510 + // the old token must not lock that check out (see verify_attach_nonce()).
511 + Mcp_Rate_Limiter::reset_all();
512 + $resp = wp_remote_post(
513 + self::hub_url() . '/api/site/token',
514 + array(
515 + 'timeout' => 5,
516 + 'headers' => array(
517 + 'Content-Type' => 'application/json',
518 + 'X-XSpeed-Site-Token' => $token,
519 + ),
520 + 'body' => wp_json_encode( array( 'site_url' => self::site_url_canonical() ) ),
521 + )
522 + );
523 + return is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp );
524 + }
525 +
526 + /** Mcp_Pairing::TOKEN_CHANGED_ACTION listener. */
527 + public static function on_token_changed(): void {
528 + $code = self::push_token_to_hub();
529 + if ( $code >= 200 && $code < 300 ) {
530 + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS );
531 + }
532 + delete_transient( 'xspeed_hub_reconcile' );
533 + }
534 +
535 + /**
536 + * The Hub no longer recognises this site's token, but this admin's link
537 + * says attached. Decide whether the link stands.
538 + *
539 + * Only a 404 means the Hub has dropped the site. A timeout, a 429 or a
540 + * 5xx says nothing about the link, and clearing it on those is how a
541 + * connected site used to lose its badge. The Hub checks a synced token
542 + * after it answers, so a sync sent in the last RESYNC_GRACE seconds is
543 + * still pending. One sent earlier that has not restored the link ends
544 + * it, so a site the Hub will not accept does not show Connected for ever.
545 + *
546 + * @return bool True to keep the link.
547 + */
548 + private static function keep_link_after_resync(): bool {
549 + $sent = get_transient( self::RESYNC_SENT );
550 + if ( false !== $sent ) {
551 + return time() - (int) $sent < self::RESYNC_GRACE;
552 + }
553 + $code = self::push_token_to_hub();
554 + if ( 404 === $code ) {
555 + return false;
556 + }
557 + if ( $code >= 200 && $code < 300 ) {
558 + set_transient( self::RESYNC_SENT, time(), 30 * MINUTE_IN_SECONDS );
559 + }
560 + return true;
561 + }
562 +
563 + /**
465 564 * One-click attach redirect (Method 2). The Hub logs the user in, approves,
466 565 * calls back to this site's /attach route to record the link, then bounces
467 566 * the browser to `return_url` so the user lands back in the plugin without
468 567 * navigating manually.
@@ -549,10 +648,11 @@
549 648
550 649 /**
551 650 * Disconnect the CURRENT admin from the hub: clear their per-user link.
552 651 * Other admins' connections are untouched. Does NOT rotate the site_token
553 - * (still used by the per-site connection); to fully cut off the hub the
554 - * user rotates the token, which the Hub's stored copy then fails on.
652 + * (still used by the per-site connection). Rotating no longer cuts the
653 + * Hub off either: the new token is sent to the Hub (push_token_to_hub()),
654 + * so removing the site from the Hub is what ends its access.
555 655 */
556 656 public static function disconnect(): array {
557 657 $user_id = get_current_user_id();
558 658 $state = $user_id ? self::state( $user_id ) : array();
@@ -624,12 +724,48 @@
624 724 *
625 725 * @return array<string,mixed>|\WP_Error
626 726 */
627 727 public static function gtmetrix_test() {
628 - return self::gtmetrix_request( 'POST', '/api/site/gtmetrix/test' );
728 + return self::hub_request( 'POST', '/api/site/gtmetrix/test' );
629 729 }
630 730
631 731 /**
732 + * Ask the Hub to run a PageSpeed Insights audit for this site.
733 + *
734 + * The PSI twin of gtmetrix_test(): the Hub holds a real Google API key, so
735 + * routing the audit through it is what makes a keyless site's test work —
736 + * an unkeyed call straight to Google shares one anonymous per-IP pool with
737 + * every other unkeyed caller and refuses with "Quota exceeded" under any
738 + * real load (issue #426).
739 + *
740 + * The Hub answers 202 with a run row and audits in the background; the
741 + * result arrives via psi_runs().
742 + *
743 + * @param string $strategy 'mobile', 'desktop' or 'both'.
744 + * @return array<string,mixed>|\WP_Error
745 + */
746 + public static function psi_test( string $strategy = 'mobile' ) {
747 + $strategy = in_array( $strategy, array( 'mobile', 'desktop', 'both' ), true ) ? $strategy : 'mobile';
748 + return self::hub_request( 'POST', '/api/site/psi/test', array( 'strategy' => $strategy ) );
749 + }
750 +
751 + /**
752 + * PSI runs for this site, finished ones copied into the local history.
753 + *
754 + * The polling half of psi_test() — that route answers before the audit
755 + * runs, so without this the plugin would never learn the score.
756 + *
757 + * @return array<string,mixed>|\WP_Error
758 + */
759 + public static function psi_runs() {
760 + $result = self::hub_request( 'GET', '/api/site/psi/runs' );
761 + if ( ! is_wp_error( $result ) ) {
762 + self::store_hub_results( $result );
763 + }
764 + return $result;
765 + }
766 +
767 + /**
632 768 * Recent Hub-run tests for this site, plus the remaining allowance.
633 769 *
634 770 * Polled while a run is in flight, and read once on load so the button can
635 771 * show the count before anyone presses anything.
@@ -636,9 +772,9 @@
636 772 *
637 773 * @return array<string,mixed>|\WP_Error
638 774 */
639 775 public static function gtmetrix_runs() {
640 - $result = self::gtmetrix_request( 'GET', '/api/site/gtmetrix/runs' );
776 + $result = self::hub_request( 'GET', '/api/site/gtmetrix/runs' );
641 777 if ( ! is_wp_error( $result ) ) {
642 778 self::store_hub_results( $result );
643 779 }
644 780 return $result;
@@ -682,15 +818,19 @@
682 818 if ( $ts <= 0 || '' === $remote_id || Score_Store::exists_remote( $remote_id ) ) {
683 819 continue;
684 820 }
685 821
822 + // The runs table is shared between providers on the Hub too — a
823 + // PSI run must not be recorded as a GTmetrix row.
824 + $provider = 'psi' === ( $run['provider'] ?? '' ) ? 'psi' : 'gtmetrix';
825 +
686 826 Score_Store::insert(
687 827 array(
688 828 'ok' => true,
689 - 'provider' => 'gtmetrix',
829 + 'provider' => $provider,
690 830 'ts' => $ts,
691 831 'url' => (string) ( $r['url'] ?? '' ),
692 - 'strategy' => (string) ( $r['strategy'] ?? 'desktop' ),
832 + 'strategy' => (string) ( $r['strategy'] ?? ( 'psi' === $provider ? 'mobile' : 'desktop' ) ),
693 833 'score' => $r['score'] ?? null,
694 834 'metrics' => array(
695 835 'lcp' => $r['lcp'] ?? null,
696 836 'fcp' => $r['fcp'] ?? null,
@@ -717,25 +857,26 @@
717 857 * failure into a stable error code — must behave identically for both. A
718 858 * divergence there would show up as the UI handling a quota error on one
719 859 * path and not the other.
720 860 *
721 - * @param string $method HTTP method.
722 - * @param string $path Path under the hub base URL.
861 + * @param string $method HTTP method.
862 + * @param string $path Path under the hub base URL.
863 + * @param array<string,mixed> $body Extra POST body fields beside site_url.
723 864 * @return array<string,mixed>|\WP_Error
724 865 */
725 - private static function gtmetrix_request( string $method, string $path ) {
866 + private static function hub_request( string $method, string $path, array $body = array() ) {
726 867 $token = Mcp_Pairing::site_token();
727 868 if ( '' === $token ) {
728 869 return new \WP_Error(
729 870 'not_connected',
730 - __( 'Connect this site to xSpeed Hub to run a free GTmetrix test.', 'xspeed' )
871 + __( 'Connect this site to xSpeed Hub to run a free speed test.', 'xspeed' )
731 872 );
732 873 }
733 874
734 875 $site_url = self::site_url_canonical();
735 876 $args = array(
736 - // A GTmetrix test takes a minute, but the Hub answers as soon as it
737 - // has ACCEPTED the job — this waits for that handshake only.
877 + // A test takes a minute, but the Hub answers as soon as it has
878 + // ACCEPTED the job — this waits for that handshake only.
738 879 'timeout' => 15,
739 880 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
740 881 );
741 882
@@ -740,9 +881,9 @@
740 881 );
741 882
742 883 if ( 'POST' === $method ) {
743 884 $args['headers']['Content-Type'] = 'application/json';
744 - $args['body'] = wp_json_encode( array( 'site_url' => $site_url ) );
885 + $args['body'] = wp_json_encode( array_merge( array( 'site_url' => $site_url ), $body ) );
745 886 $resp = wp_remote_post( self::hub_url() . $path, $args );
746 887 } else {
747 888 $resp = wp_remote_get(
748 889 add_query_arg( array( 'site_url' => rawurlencode( $site_url ) ), self::hub_url() . $path ),