PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / modules / Heartbeat / HeartbeatModule.php

HeartbeatModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.2, at includes/modules/Heartbeat/HeartbeatModule.php

264 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Heartbeat Control module.
4 *
5 * Controls the WordPress Heartbeat API per context (dashboard / editor /
6 * frontend) and tunes its interval. Disabling heartbeat on the frontend
7 * is one of the cheapest wins for sites that don't need polling there.
8 *
9 * Tier: Free (1:1 with LiteSpeed Cache).
10 * Roadmap: §4.3.
11 *
12 * @package XSpeed
13 */
14
15 declare(strict_types=1);
16
17 namespace XSpeed\Modules\Heartbeat;
18
19 defined( 'ABSPATH' ) || exit;
20
21 use XSpeed\Module;
22
23 final class HeartbeatModule extends Module {
24
25 public const SLUG = 'heartbeat';
26 public const TIER = self::TIER_FREE;
27 public const VERSION = '1.0.0';
28
29 // Per-context behavior values stored under `behavior_<context>`.
30 public const BEHAVIOR_KEEP = 'keep'; // leave alone
31 public const BEHAVIOR_THROTTLE = 'throttle'; // apply our `frequency`
32 public const BEHAVIOR_DISABLE = 'disable'; // turn off entirely
33
34 private const CONTEXTS = array( 'dashboard', 'editor', 'frontend' );
35
36 public function ui_metadata(): array {
37 return array(
38 'label' => __( 'Heartbeat', 'xspeed' ),
39 'icon' => 'Activity',
40 'description' => __( 'Control the WordPress Heartbeat API per context.', 'xspeed' ),
41 );
42 }
43
44 /**
45 * Default profile mirrors the IMPLEMENTATION recommendation:
46 * - frontend: off by default (most polling-heavy, rarely needed)
47 * - editor: throttled to 60s (was 15s)
48 * - dashboard: throttled to 60s (was 60s — left as is)
49 */
50 public function settings_schema(): array {
51 $behavior_options = array( self::BEHAVIOR_KEEP, self::BEHAVIOR_THROTTLE, self::BEHAVIOR_DISABLE );
52 $behavior_option_labels = array(
53 self::BEHAVIOR_KEEP => 'Keep',
54 self::BEHAVIOR_THROTTLE => 'Throttle',
55 self::BEHAVIOR_DISABLE => 'Disable',
56 );
57
58 return array(
59 'behavior_dashboard' => array(
60 'type' => 'enum',
61 'default' => self::BEHAVIOR_THROTTLE,
62 'options' => $behavior_options,
63 'option_labels' => $behavior_option_labels,
64 'label' => __( 'Dashboard', 'xspeed' ),
65 'description' => __( 'Heartbeat behavior on /wp-admin/ screens (autosave, notifications).', 'xspeed' ),
66 ),
67 'behavior_editor' => array(
68 'type' => 'enum',
69 'default' => self::BEHAVIOR_THROTTLE,
70 'options' => $behavior_options,
71 'option_labels' => $behavior_option_labels,
72 'label' => __( 'Editor', 'xspeed' ),
73 'description' => __( 'Heartbeat in the post / block editor. Disable only if you do not need autosave or co-edit locks.', 'xspeed' ),
74 ),
75 'behavior_frontend' => array(
76 'type' => 'enum',
77 'default' => self::BEHAVIOR_DISABLE,
78 'options' => $behavior_options,
79 'option_labels' => $behavior_option_labels,
80 'label' => __( 'Frontend', 'xspeed' ),
81 'description' => __( 'Controls the Heartbeat API on the public site. Only takes effect when a plugin or theme actually loads heartbeat on the frontend (e.g. WooCommerce cart fragments, membership/notification plugins) — a default WordPress site loads none there, so this has no visible effect on such sites. Recommended: Disable, to stop the admin-ajax polling those plugins add.', 'xspeed' ),
82 ),
83 'frequency' => array(
84 'type' => 'int',
85 'default' => 60,
86 'min' => 15,
87 'max' => 300,
88 'label' => __( 'Throttle Frequency', 'xspeed' ),
89 'description' => __( 'Interval in seconds for contexts set to Throttle. 60 is a sane default; lower = faster sync but more requests.', 'xspeed' ),
90 ),
91 );
92 }
93
94 // rest_routes — using the Module base-class default (GET + POST under
95 // /xspeed/v1/heartbeat/ wired to rest_get_settings + rest_update_settings).
96
97 public function cli_commands(): array {
98 return array(
99 array(
100 'name' => 'xspeed heartbeat',
101 'callback' => array( $this, 'cli_handler' ),
102 'shortdesc' => 'Inspect or modify xSpeed heartbeat settings.',
103 'ai_hint' => 'Inspect or change WordPress Heartbeat (admin-ajax polling) frequency. Use for high admin-ajax.php CPU load, or hosting warnings about too many background requests.',
104 'synopsis' => array(
105 array(
106 'type' => 'positional',
107 'name' => 'action',
108 'options' => array( 'show', 'set' ),
109 'optional' => false,
110 ),
111 // Use `--for` (not `--context`): WP-CLI silently swallows a
112 // `--context` assoc arg before it reaches the command, so
113 // `set --context= --behavior=` always lost the context and
114 // errored "Nothing to set". `--for` reads naturally
115 // (set --for=editor --behavior=disable) and is collision-
116 // free. No `options` constraint either — an options list
117 // also dropped args from $assoc; we validate in
118 // cli_handler() instead. (FBS-82153 Bug 2.)
119 array(
120 'type' => 'assoc',
121 'name' => 'for',
122 'optional' => true,
123 ),
124 array(
125 'type' => 'assoc',
126 'name' => 'behavior',
127 'optional' => true,
128 ),
129 array(
130 'type' => 'assoc',
131 'name' => 'frequency',
132 'optional' => true,
133 ),
134 ),
135 ),
136 );
137 }
138
139 public function boot(): void {
140 // Context resolution depends on WHEN we can classify the request:
141 // - Frontend: known at `init` (is_admin() is reliable there), and we
142 // must act before wp_enqueue_scripts (priority 10) registers the
143 // heartbeat script.
144 // - Admin: dashboard-vs-editor needs get_current_screen(), which is
145 // NULL at `init` and only populated from the `current_screen`
146 // action onward. Resolving context at init always returned
147 // "dashboard" on editor screens, so behavior_editor was dead.
148 // (FBS-82153 Bug 1.) Hook admin on `current_screen` instead, which
149 // fires before admin_enqueue_scripts so we can still dequeue.
150 if ( is_admin() ) {
151 add_action( 'current_screen', array( $this, 'apply_settings' ) );
152 } else {
153 add_action( 'init', array( $this, 'apply_settings' ), 5 );
154 }
155 }
156
157 /**
158 * Hook handler — translates our settings into Heartbeat behavior. Runs
159 * on every request. The branches below are pure read + dispatch — no
160 * I/O so the cost is negligible even on cached requests.
161 */
162 public function apply_settings(): void {
163 $settings = $this->get_settings();
164 $context = $this->detect_context();
165 $behavior = $settings[ 'behavior_' . $context ] ?? self::BEHAVIOR_KEEP;
166
167 if ( self::BEHAVIOR_DISABLE === $behavior ) {
168 // Dequeue the heartbeat script entirely. wp_deregister_script
169 // runs on `init` priority 5 → before wp_default_scripts
170 // (priority 10) re-registers, so we hook the actual enqueue
171 // stage instead.
172 add_action( 'wp_enqueue_scripts', array( $this, 'dequeue_heartbeat' ), 1 );
173 add_action( 'admin_enqueue_scripts', array( $this, 'dequeue_heartbeat' ), 1 );
174 } elseif ( self::BEHAVIOR_THROTTLE === $behavior ) {
175 $interval = max( 15, min( 300, (int) ( $settings['frequency'] ?? 60 ) ) );
176 add_filter(
177 'heartbeat_settings',
178 static function ( $hb_settings ) use ( $interval ) {
179 $hb_settings['interval'] = $interval;
180 return $hb_settings;
181 }
182 );
183 }
184 // BEHAVIOR_KEEP → no filter, WP defaults apply.
185 }
186
187 /**
188 * Dequeue + deregister the heartbeat script. Safe to call multiple
189 * times — both wp functions are idempotent.
190 */
191 public function dequeue_heartbeat(): void {
192 wp_dequeue_script( 'heartbeat' );
193 wp_deregister_script( 'heartbeat' );
194 }
195
196 /**
197 * Classify the current request into dashboard / editor / frontend.
198 * Editor here means the block / classic post editor screens — they're
199 * the heaviest heartbeat consumer and worth their own bucket.
200 */
201 private function detect_context(): string {
202 if ( ! is_admin() ) {
203 return 'frontend';
204 }
205 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
206 if ( $screen && in_array( $screen->base, array( 'post', 'post-new' ), true ) ) {
207 return 'editor';
208 }
209 return 'dashboard';
210 }
211
212 // REST handlers — provided by Module base class
213 // (rest_get_settings / rest_update_settings).
214
215 public function cli_handler( array $args, array $assoc ): void {
216 $action = $args[0] ?? 'show';
217
218 if ( 'show' === $action ) {
219 $opts = $this->get_settings();
220 foreach ( $opts as $key => $value ) {
221 \WP_CLI::log( sprintf( '%-22s %s', $key, is_scalar( $value ) ? (string) $value : wp_json_encode( $value ) ) );
222 }
223 return;
224 }
225
226 if ( 'set' === $action ) {
227 $patch = array();
228 $behaviors = array( self::BEHAVIOR_KEEP, self::BEHAVIOR_THROTTLE, self::BEHAVIOR_DISABLE );
229
230 $has_context = isset( $assoc['for'] ) && '' !== $assoc['for'];
231 $has_behavior = isset( $assoc['behavior'] ) && '' !== $assoc['behavior'];
232
233 // --for and --behavior are a pair: both or neither.
234 if ( $has_context xor $has_behavior ) {
235 \WP_CLI::error( 'Pass --for and --behavior together.' );
236 return;
237 }
238 if ( $has_context && $has_behavior ) {
239 if ( ! in_array( $assoc['for'], self::CONTEXTS, true ) ) {
240 \WP_CLI::error( 'Invalid --for. Use one of: ' . implode( ', ', self::CONTEXTS ) . '.' );
241 return;
242 }
243 if ( ! in_array( $assoc['behavior'], $behaviors, true ) ) {
244 \WP_CLI::error( 'Invalid --behavior. Use one of: ' . implode( ', ', $behaviors ) . '.' );
245 return;
246 }
247 $patch[ 'behavior_' . $assoc['for'] ] = $assoc['behavior'];
248 }
249 if ( isset( $assoc['frequency'] ) ) {
250 $patch['frequency'] = (int) $assoc['frequency'];
251 }
252 if ( empty( $patch ) ) {
253 \WP_CLI::error( 'Nothing to set. Provide --for= --behavior= and/or --frequency=' );
254 return;
255 }
256 $this->update_settings( $patch );
257 \WP_CLI::success( 'Updated heartbeat settings.' );
258 return;
259 }
260
261 \WP_CLI::error( "Unknown action: $action" );
262 }
263 }
264