PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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.1.6, at includes/modules/Heartbeat/HeartbeatModule.php

263 lines 9.2 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',
39 'icon' => 'Activity',
40 'description' => 'Control the WordPress Heartbeat API per context.',
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',
65 'description' => 'Heartbeat behavior on /wp-admin/ screens (autosave, notifications).',
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',
73 'description' => 'Heartbeat in the post / block editor. Disable only if you do not need autosave or co-edit locks.',
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',
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.',
82 ),
83 'frequency' => array(
84 'type' => 'int',
85 'default' => 60,
86 'min' => 15,
87 'max' => 300,
88 'label' => 'Throttle Frequency',
89 'description' => 'Interval in seconds for contexts set to Throttle. 60 is a sane default; lower = faster sync but more requests.',
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 'synopsis' => array(
104 array(
105 'type' => 'positional',
106 'name' => 'action',
107 'options' => array( 'show', 'set' ),
108 'optional' => false,
109 ),
110 // Use `--for` (not `--context`): WP-CLI silently swallows a
111 // `--context` assoc arg before it reaches the command, so
112 // `set --context= --behavior=` always lost the context and
113 // errored "Nothing to set". `--for` reads naturally
114 // (set --for=editor --behavior=disable) and is collision-
115 // free. No `options` constraint either — an options list
116 // also dropped args from $assoc; we validate in
117 // cli_handler() instead. (FBS-82153 Bug 2.)
118 array(
119 'type' => 'assoc',
120 'name' => 'for',
121 'optional' => true,
122 ),
123 array(
124 'type' => 'assoc',
125 'name' => 'behavior',
126 'optional' => true,
127 ),
128 array(
129 'type' => 'assoc',
130 'name' => 'frequency',
131 'optional' => true,
132 ),
133 ),
134 ),
135 );
136 }
137
138 public function boot(): void {
139 // Context resolution depends on WHEN we can classify the request:
140 // - Frontend: known at `init` (is_admin() is reliable there), and we
141 // must act before wp_enqueue_scripts (priority 10) registers the
142 // heartbeat script.
143 // - Admin: dashboard-vs-editor needs get_current_screen(), which is
144 // NULL at `init` and only populated from the `current_screen`
145 // action onward. Resolving context at init always returned
146 // "dashboard" on editor screens, so behavior_editor was dead.
147 // (FBS-82153 Bug 1.) Hook admin on `current_screen` instead, which
148 // fires before admin_enqueue_scripts so we can still dequeue.
149 if ( is_admin() ) {
150 add_action( 'current_screen', array( $this, 'apply_settings' ) );
151 } else {
152 add_action( 'init', array( $this, 'apply_settings' ), 5 );
153 }
154 }
155
156 /**
157 * Hook handler — translates our settings into Heartbeat behavior. Runs
158 * on every request. The branches below are pure read + dispatch — no
159 * I/O so the cost is negligible even on cached requests.
160 */
161 public function apply_settings(): void {
162 $settings = $this->get_settings();
163 $context = $this->detect_context();
164 $behavior = $settings[ 'behavior_' . $context ] ?? self::BEHAVIOR_KEEP;
165
166 if ( self::BEHAVIOR_DISABLE === $behavior ) {
167 // Dequeue the heartbeat script entirely. wp_deregister_script
168 // runs on `init` priority 5 → before wp_default_scripts
169 // (priority 10) re-registers, so we hook the actual enqueue
170 // stage instead.
171 add_action( 'wp_enqueue_scripts', array( $this, 'dequeue_heartbeat' ), 1 );
172 add_action( 'admin_enqueue_scripts', array( $this, 'dequeue_heartbeat' ), 1 );
173 } elseif ( self::BEHAVIOR_THROTTLE === $behavior ) {
174 $interval = max( 15, min( 300, (int) ( $settings['frequency'] ?? 60 ) ) );
175 add_filter(
176 'heartbeat_settings',
177 static function ( $hb_settings ) use ( $interval ) {
178 $hb_settings['interval'] = $interval;
179 return $hb_settings;
180 }
181 );
182 }
183 // BEHAVIOR_KEEP → no filter, WP defaults apply.
184 }
185
186 /**
187 * Dequeue + deregister the heartbeat script. Safe to call multiple
188 * times — both wp functions are idempotent.
189 */
190 public function dequeue_heartbeat(): void {
191 wp_dequeue_script( 'heartbeat' );
192 wp_deregister_script( 'heartbeat' );
193 }
194
195 /**
196 * Classify the current request into dashboard / editor / frontend.
197 * Editor here means the block / classic post editor screens — they're
198 * the heaviest heartbeat consumer and worth their own bucket.
199 */
200 private function detect_context(): string {
201 if ( ! is_admin() ) {
202 return 'frontend';
203 }
204 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
205 if ( $screen && in_array( $screen->base, array( 'post', 'post-new' ), true ) ) {
206 return 'editor';
207 }
208 return 'dashboard';
209 }
210
211 // REST handlers — provided by Module base class
212 // (rest_get_settings / rest_update_settings).
213
214 public function cli_handler( array $args, array $assoc ): void {
215 $action = $args[0] ?? 'show';
216
217 if ( 'show' === $action ) {
218 $opts = $this->get_settings();
219 foreach ( $opts as $key => $value ) {
220 \WP_CLI::log( sprintf( '%-22s %s', $key, is_scalar( $value ) ? (string) $value : wp_json_encode( $value ) ) );
221 }
222 return;
223 }
224
225 if ( 'set' === $action ) {
226 $patch = array();
227 $behaviors = array( self::BEHAVIOR_KEEP, self::BEHAVIOR_THROTTLE, self::BEHAVIOR_DISABLE );
228
229 $has_context = isset( $assoc['for'] ) && '' !== $assoc['for'];
230 $has_behavior = isset( $assoc['behavior'] ) && '' !== $assoc['behavior'];
231
232 // --for and --behavior are a pair: both or neither.
233 if ( $has_context xor $has_behavior ) {
234 \WP_CLI::error( 'Pass --for and --behavior together.' );
235 return;
236 }
237 if ( $has_context && $has_behavior ) {
238 if ( ! in_array( $assoc['for'], self::CONTEXTS, true ) ) {
239 \WP_CLI::error( 'Invalid --for. Use one of: ' . implode( ', ', self::CONTEXTS ) . '.' );
240 return;
241 }
242 if ( ! in_array( $assoc['behavior'], $behaviors, true ) ) {
243 \WP_CLI::error( 'Invalid --behavior. Use one of: ' . implode( ', ', $behaviors ) . '.' );
244 return;
245 }
246 $patch[ 'behavior_' . $assoc['for'] ] = $assoc['behavior'];
247 }
248 if ( isset( $assoc['frequency'] ) ) {
249 $patch['frequency'] = (int) $assoc['frequency'];
250 }
251 if ( empty( $patch ) ) {
252 \WP_CLI::error( 'Nothing to set. Provide --for= --behavior= and/or --frequency=' );
253 return;
254 }
255 $this->update_settings( $patch );
256 \WP_CLI::success( 'Updated heartbeat settings.' );
257 return;
258 }
259
260 \WP_CLI::error( "Unknown action: $action" );
261 }
262 }
263