PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260917
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260917
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 releases
← All changes | src/includes/classes/utils-assets.inc.php +2302 -247 260909260917 View file →
@@ -31,10 +31,12 @@
31 31 protected static $static_asset_cache = array();
32 32 protected static $static_assets_location_cache = array();
33 33 protected static $static_assets_health_cache;
34 34 protected static $static_js_data_map_cache = array(); //260906.1530 Parsed shipped static JavaScript data maps, keyed by path.
35 + protected static $static_assets_rebuild_after_save = array(); //260911.1834 Relevant saved CSS/JS option changes queue enabled static types for an immediate post-save rebuild.
35 36 protected static $asset_http_health_cache;
36 37 protected static $page_asset_expectations = array();
38 + protected static $asset_health_force_full_probe = FALSE; //260910.0630 The Health panel can request a fresh trusted current-delivery probe without changing saved delivery settings.
37 39
38 40 /**
39 41 * Handles CSS compression.
40 42 *
@@ -107,8 +109,9 @@
107 109 /**
108 110 * Returns the selected URL used whenever frontend CSS/JavaScript needs dynamic generation.
109 111 *
110 112 * The s2Member Dynamic Loader remains the default. If its file is missing or a trusted browser probe has confirmed that it is unreachable, the normal WordPress loader is used temporarily without changing the saved preference.
113 + * In the current UI this established route is named the s2Member-Only Dynamic Loader and is served by s2member-o.php.
111 114 *
112 115 * @package s2Member\Utilities
113 116 * @since 260904.0221
114 117 *
@@ -189,11 +192,1174 @@
189 192 return self::$asset_http_health_cache;
190 193 }
191 194
192 195 /**
196 + * Returns true while a trusted Full WordPress fallback failure is still active.
197 + *
198 + * @package s2Member\Utilities
199 + * @since 260912.1959
200 + *
201 + * @return bool True when CSS or JS fallback health is currently failed.
202 + */
203 + protected static function asset_health_fallback_problem_active()
204 + {
205 + $health = self::asset_http_health_state();
206 + $failures = (is_array($health) && !empty($health['failures']) && is_array($health['failures'])) ? $health['failures'] : array();
207 + return !empty($health['fallback_problem_since']) && (!empty($failures['fallback:dynamic_css']) || !empty($failures['fallback:dynamic_js']));
208 + }
209 +
210 + /**
211 + * Adds or updates compact recent per-asset issue details for the Health panel.
212 + *
213 + * This diagnostic summary is intentionally kept with the trusted HTTP-health state instead of
214 + * the frontend load log. It remains available when css-js.log is disabled, and one keyed entry
215 + * per affected physical target prevents the state from growing with traffic.
216 + *
217 + * @package s2Member\Utilities
218 + * @since 260910.2346
219 + *
220 + * @param array $issues Existing recent issue map.
221 + * @param string $key Stable physical-target key.
222 + * @param string $result Issue result (`late` or `failed`).
223 + * @param string $label Human-readable physical asset/route label.
224 + * @param string $detail Concise failure/timing detail.
225 + * @param string $url Relevant asset URL.
226 + * @param string $delivery Delivery mode when known.
227 + * @return array Updated issue map.
228 + */
229 + protected static function add_asset_health_recent_issue($issues = array(), $key = '', $result = '', $label = '', $detail = '', $url = '', $delivery = '')
230 + {
231 + $issues = (is_array($issues)) ? $issues : array();
232 + $key = substr(sanitize_key((string)$key), 0, 120);
233 + $result = strtolower((string)$result);
234 + if($key === '' || !in_array($result, array('late', 'failed'), TRUE))
235 + return $issues;
236 +
237 + $previous = (!empty($issues[$key]) && is_array($issues[$key])) ? $issues[$key] : array();
238 + $issues[$key] = array(
239 + 'label' => substr(sanitize_text_field((string)$label), 0, 120),
240 + 'result' => $result,
241 + 'delivery' => substr(sanitize_text_field((string)$delivery), 0, 80),
242 + 'detail' => substr(sanitize_text_field((string)$detail), 0, 200),
243 + 'url' => esc_url_raw((string)$url),
244 + 'first_seen' => (!empty($previous['first_seen'])) ? (int)$previous['first_seen'] : time(),
245 + 'last_seen' => time(),
246 + 'count' => (!empty($previous['count'])) ? (int)$previous['count'] + 1 : 1,
247 + );
248 + return $issues;
249 + }
250 +
251 +
252 + /**
253 + * Returns the compact rolling CSS/JavaScript asset-load health log.
254 + *
255 + * The state keeps only the latest 10 individual asset loads, populated clock-minute
256 + * aggregates from the latest 10 minutes, and populated clock-aligned 10-minute aggregates
257 + * from the latest 6 hours since Health last became non-Green. Minute/block keys are their
258 + * clock-aligned ending timestamps. Buckets retain sum/count so an accepted Late report can
259 + * correct the earlier Okay rating exactly, even after that clock period ended. Empty periods
260 + * are never manufactured as health evidence.
261 + *
262 + * @package s2Member\Utilities
263 + * @since 260910.0630
264 + *
265 + * @return array Stored rolling asset-load health state.
266 + */
267 + protected static function asset_health_log_state()
268 + {
269 + //260910.2346 Keep the hot-path state small and self-describing; every retained collection has a fixed request/time horizon and the option does not autoload.
270 + $state = get_option('ws_plugin__s2member_assets_health_log', array());
271 + $state = (is_array($state)) ? $state : array();
272 + $state['last_10_asset_loads'] = (!empty($state['last_10_asset_loads']) && is_array($state['last_10_asset_loads'])) ? array_values($state['last_10_asset_loads']) : array();
273 + $state['last_10min_minutes'] = (!empty($state['last_10min_minutes']) && is_array($state['last_10min_minutes'])) ? $state['last_10min_minutes'] : array();
274 + $state['last_6hour_10min_blocks'] = (!empty($state['last_6hour_10min_blocks']) && is_array($state['last_6hour_10min_blocks'])) ? $state['last_6hour_10min_blocks'] : array();
275 + //260911.1806 Keep one compact historical issue outside the rolling score windows so Last issue remains useful after busy healthy traffic or natural recovery.
276 + $state['last_issue'] = (!empty($state['last_issue']) && is_array($state['last_issue'])) ? $state['last_issue'] : array();
277 + //260913.0041 Keep a bounded always-available troubleshooting summary independent of the rolling score and optional css-js.log.
278 + $state['latest_issues'] = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? array_slice($state['latest_issues'], 0, 10, TRUE) : array();
279 + $state['last_issue_cleared_at'] = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
280 + $state['latest_issues_cleared_at'] = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
281 + //260912.0258 Keep a small duplicate-processing safeguard inside the existing health log in case a queued event survives after its changes were already stored.
282 + $state['processed_event_times'] = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? array_slice(array_values($state['processed_event_times']), -100) : array();
283 + return $state;
284 + }
285 +
286 + /**
287 + * Returns the option-name prefix used by queued asset-health events.
288 + *
289 + * Frontend requests queue separate non-autoloaded events instead of rewriting the shared rolling
290 + * log. The Health Logkeeper later merges those events into the one persistent health-log option.
291 + *
292 + * @package s2Member\Utilities
293 + * @since 260912.0258
294 + *
295 + * @return string Event option prefix.
296 + */
297 + protected static function asset_health_event_option_prefix()
298 + {
299 + return 'ws_plugin__s2member_assets_health_event_';
300 + }
301 +
302 + /**
303 + * Queues one asset-health event without waiting for or rewriting the shared rolling log.
304 + *
305 + * The option suffix combines the event time with the queue time in microseconds. That meaningful
306 + * pair gives chronological ordering, practical uniqueness, and duplicate-processing identity.
307 + *
308 + * @package s2Member\Utilities
309 + * @since 260912.0258
310 + *
311 + * @param array $event Compact load/issue event.
312 + * @return string Queued event-times suffix, or an empty string on failure.
313 + */
314 + protected static function queue_asset_health_event($event = array())
315 + {
316 + $event = (is_array($event)) ? $event : array();
317 + $event_time = (!empty($event['event_time'])) ? max(1, (int)$event['event_time']) : time();
318 + $microtime = explode(' ', microtime(), 2);
319 + $queued_sec = (!empty($microtime[1])) ? max(1, (int)$microtime[1]) : time();
320 + $queued_usec = (!empty($microtime[0])) ? (int)substr($microtime[0], 2, 6) : 0;
321 + $prefix = self::asset_health_event_option_prefix();
322 + $event_time_order = str_pad((string)$event_time, 12, '0', STR_PAD_LEFT);
323 +
324 + //260912.0258 add_option() provides the atomic uniqueness check; an extraordinarily unlikely collision simply advances the queue time by one microsecond and retries.
325 + for($attempt = 0; $attempt < 3; $attempt++)
326 + {
327 + $queued_time_order = str_pad((string)$queued_sec, 12, '0', STR_PAD_LEFT).str_pad((string)$queued_usec, 6, '0', STR_PAD_LEFT);
328 + $event_times = $event_time_order.'_'.$queued_time_order;
329 + if(add_option($prefix.$event_times, $event, '', 'no'))
330 + {
331 + //260912.0258 Schedule the Health Logkeeper without making the visitor wait for health-log maintenance.
332 + if(!wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
333 + wp_schedule_single_event(time() + 10, 'ws_plugin__s2member_assets_health_logkeeper');
334 + return $event_times;
335 + }
336 + if(++$queued_usec > 999999)
337 + {
338 + $queued_usec = 0;
339 + $queued_sec++;
340 + }
341 + }
342 + return '';
343 + }
344 +
345 + /**
346 + * Acquires the Health Logkeeper lock without waiting.
347 + *
348 + * Only the Health Logkeeper writes the shared rolling health log. If another Logkeeper run is
349 + * active, this request exits immediately; frontend requests only queue events and never wait here.
350 + *
351 + * @package s2Member\Utilities
352 + * @since 260912.0258
353 + *
354 + * @return string Unique lock value, or an empty string when another Logkeeper run owns it.
355 + */
356 + protected static function health_logkeeper_lock_acquire()
357 + {
358 + global $wpdb;
359 +
360 + $option = 'ws_plugin__s2member_assets_health_logkeeper_lock';
361 + $lock = time().':'.sha1(microtime(TRUE)."\0".wp_rand());
362 + if(add_option($option, $lock, '', 'no'))
363 + return $lock;
364 +
365 + $current = (string)get_option($option, '');
366 + $parts = explode(':', $current, 2);
367 + $locked_at = (!empty($parts[0]) && is_numeric($parts[0])) ? (int)$parts[0] : 0;
368 + //260913.0454 Delete only the stale lock version we inspected; another Logkeeper may replace it before this request reaches the delete.
369 + if(!$locked_at || $locked_at < time() - 2 * MINUTE_IN_SECONDS)
370 + {
371 + $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($current)), array('%s', '%s'));
372 + if($deleted)
373 + {
374 + wp_cache_delete($option, 'options');
375 + if(add_option($option, $lock, '', 'no'))
376 + return $lock;
377 + }
378 + }
379 + return '';
380 + }
381 +
382 + /**
383 + * Releases the Health Logkeeper lock when this request still owns it.
384 + *
385 + * @package s2Member\Utilities
386 + * @since 260912.0258
387 + *
388 + * @param string $lock Unique lock value returned by health_logkeeper_lock_acquire().
389 + * @return null
390 + */
391 + protected static function health_logkeeper_lock_release($lock = '')
392 + {
393 + global $wpdb;
394 +
395 + $option = 'ws_plugin__s2member_assets_health_logkeeper_lock';
396 + $current = (string)get_option($option, '');
397 + if($lock !== '' && $current !== '' && hash_equals($current, (string)$lock))
398 + {
399 + //260913.0454 Release only the exact lock version owned by this request; an expired owner must never delete a newer Logkeeper's lock.
400 + $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($current)), array('%s', '%s'));
401 + if($deleted)
402 + wp_cache_delete($option, 'options');
403 + }
404 + return;
405 + }
406 +
407 + /**
408 + * Replaces Last issue only when the candidate issue is at least as recent as the current one.
409 + *
410 + * @package s2Member\Utilities
411 + * @since 260911.2325
412 + *
413 + * @param array $state Asset-health log state, passed by reference.
414 + * @param int $time Issue timestamp.
415 + * @param string $result Compact historical result key.
416 + * @param string $label Site-owner-friendly asset label.
417 + * @param string $detail Concise explanation.
418 + * @param string $load_id Optional related load ID.
419 + * @param int $page_id Optional WordPress post/page ID.
420 + * @param string $page_path Optional queryless frontend path.
421 + * @return bool True when Last issue was replaced.
422 + */
423 + protected static function set_asset_health_last_issue(&$state, $time = 0, $result = '', $label = '', $detail = '', $load_id = '', $page_id = 0, $page_path = '')
424 + {
425 + $time = max(1, (int)$time);
426 + $result = strtolower((string)$result);
427 + $current_time = (!empty($state['last_issue']['time'])) ? (int)$state['last_issue']['time'] : 0;
428 + $cleared_at = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
429 + if($result === '' || $current_time > $time || $cleared_at >= $time)
430 + return FALSE;
431 +
432 + //260911.2325 Delayed browser reports may arrive out of order; Last issue must follow event time, not whichever request happened to write last.
433 + $state['last_issue'] = array(
434 + 'time' => $time,
435 + 'result' => $result,
436 + 'label' => substr((string)$label, 0, 100),
437 + 'detail' => substr(wp_strip_all_tags((string)$detail), 0, 240),
438 + 'load_id' => (string)$load_id,
439 + 'page_id' => max(0, (int)$page_id),
440 + 'page_path' => substr((string)$page_path, 0, 240),
441 + );
442 + return TRUE;
443 + }
444 +
445 + /**
446 + * Adds one occurrence to the bounded persistent Asset Health Latest Issues summary.
447 + *
448 + * Distinct issues are grouped by asset/result/page so repeats do not crowd out other problems.
449 + * Each group keeps a total count and up to 10 recent occurrence times.
450 + *
451 + * @package s2Member\Utilities
452 + * @since 260913.0041
453 + *
454 + * @param array $state Asset-health log state, passed by reference.
455 + * @param int $time Original issue timestamp.
456 + * @param string $result Compact issue result.
457 + * @param array $issue Issue context.
458 + * @param array $page Page context with `page_id` and `page_path`.
459 + * @return bool True when the summary changed.
460 + */
461 + protected static function add_asset_health_latest_issue(&$state, $time = 0, $result = '', $issue = array(), $page = array())
462 + {
463 + $time = max(1, (int)$time);
464 + $result = strtolower((string)$result);
465 + $issue = (is_array($issue)) ? $issue : array();
466 + $page = (is_array($page)) ? $page : array();
467 + $cleared_at = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
468 + if($result === '' || $cleared_at >= $time)
469 + return FALSE;
470 +
471 + $asset = substr(sanitize_text_field((!empty($issue['asset'])) ? (string)$issue['asset'] : ''), 0, 120);
472 + $label = substr(sanitize_text_field((!empty($issue['label'])) ? (string)$issue['label'] : ''), 0, 120);
473 + $detail = substr(wp_strip_all_tags((!empty($issue['detail'])) ? (string)$issue['detail'] : ''), 0, 240);
474 + $delivery = substr(sanitize_text_field((!empty($issue['delivery'])) ? (string)$issue['delivery'] : ''), 0, 80);
475 + $page_id = (!empty($page['page_id'])) ? max(0, (int)$page['page_id']) : 0;
476 + $page_path = (!empty($page['page_path'])) ? substr((string)$page['page_path'], 0, 240) : '';
477 + if($label === '' && $detail === '' && $asset === '')
478 + return FALSE;
479 +
480 + $page_identity = ($page_id > 0) ? 'id:'.$page_id : 'path:'.$page_path;
481 + $key = sha1(($asset !== '' ? $asset : $label)."\0".$result."\0".$page_identity);
482 + $issues = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array();
483 + $previous = (!empty($issues[$key]) && is_array($issues[$key])) ? $issues[$key] : array();
484 + $times = (!empty($previous['times']) && is_array($previous['times'])) ? array_values($previous['times']) : array();
485 + array_unshift($times, $time);
486 + rsort($times, SORT_NUMERIC); //260913.0041 Delayed reports can arrive after newer issues; retain the 10 most recent occurrence times by event time, not processing order.
487 + $times = array_slice($times, 0, 10);
488 + $issues[$key] = array(
489 + 'asset' => $asset,
490 + 'label' => $label,
491 + 'result' => $result,
492 + 'delivery' => $delivery,
493 + 'detail' => $detail,
494 + 'page_id' => $page_id,
495 + 'page_path' => $page_path,
496 + 'first_seen' => (!empty($previous['first_seen'])) ? min((int)$previous['first_seen'], $time) : $time,
497 + 'last_seen' => (!empty($previous['last_seen'])) ? max((int)$previous['last_seen'], $time) : $time,
498 + 'count' => (!empty($previous['count'])) ? (int)$previous['count'] + 1 : 1,
499 + 'times' => $times,
500 + );
501 + uasort($issues, function($a, $b) {
502 + $a_time = (!empty($a['last_seen'])) ? (int)$a['last_seen'] : 0;
503 + $b_time = (!empty($b['last_seen'])) ? (int)$b['last_seen'] : 0;
504 + return ($a_time === $b_time) ? 0 : (($a_time > $b_time) ? -1 : 1);
505 + });
506 + $state['latest_issues'] = array_slice($issues, 0, 10, TRUE);
507 + return TRUE;
508 + }
509 +
510 + /**
511 + * Returns the numeric rating for one asset-load result.
512 + *
513 + * @package s2Member\Utilities
514 + * @since 260910.0630
515 + *
516 + * @param string $result Asset-load result: `okay`, `late`, `fallback`, or `failed`.
517 + * @return int Rating from 1 through 4, or zero when invalid.
518 + */
519 + protected static function asset_health_load_rating($result = '')
520 + {
521 + $ratings = array('okay' => 4, 'late' => 3, 'fallback' => 2, 'failed' => 1); //260910.2346 Persist full result words so the health log remains readable without an O/L/F/X legend; the numeric value is used only for scoring.
522 + $result = strtolower((string)$result);
523 + return isset($ratings[$result]) ? $ratings[$result] : 0;
524 + }
525 +
526 + /**
527 + * Returns the ending timestamp of the clock-aligned period containing a timestamp.
528 + *
529 + * A timestamp exactly on a boundary belongs to the period ending at that boundary. Thus a
530 + * 10-minute period ending 12:10:00 represents 12:00:01 through 12:10:00 at whole-second precision.
531 + *
532 + * @package s2Member\Utilities
533 + * @since 260910.2346
534 + *
535 + * @param int $time Timestamp.
536 + * @param int $seconds Period size in seconds.
537 + * @return int Clock-aligned period ending timestamp.
538 + */
539 + protected static function asset_health_period_end($time = 0, $seconds = 0)
540 + {
541 + $time = max(1, (int)$time);
542 + $seconds = max(1, (int)$seconds);
543 + return (int)(ceil($time / $seconds) * $seconds);
544 + }
545 +
546 + /**
547 + * Converts the final 1.00-4.00 health score to the site-owner status color.
548 + *
549 + * @package s2Member\Utilities
550 + * @since 260910.0630
551 + *
552 + * @param float|null $score Final health score, or NULL when there is no evidence yet.
553 + * @param string $latest_result Retained for call-site compatibility; the weighted score now determines status by itself.
554 + * @return string Status-light key.
555 + */
556 + protected static function asset_health_status_from_score($score = NULL, $latest_result = '')
557 + {
558 + if($score === NULL)
559 + return 'unknown';
560 + $score = (float)$score;
561 + //260913.0046 Let the weighted score decide Health consistently; recency already gives a new non-Okay result the strongest influence without an extra status override.
562 + //260912.2005 Exact half-point boundaries belong to the less-healthy band; use the documented two-decimal cutoffs so 2.50, for example, is Working, review suggested rather than Recent issue.
563 + if($score >= 3.51)
564 + return 'healthy';
565 + if($score >= 2.51)
566 + return 'delayed';
567 + if($score >= 1.51)
568 + return 'attention';
569 + return 'error';
570 + }
571 +
572 + /**
573 + * Recalculates request, time, and final health scores from the retained asset-load log.
574 + *
575 + * Newer asset loads have importance 10 down through 1. Each populated clock minute first
576 + * averages all asset-load ratings inside it, then receives importance 10 for the current minute
577 + * down through 1 nine minutes ago. Empty minutes are skipped instead of inventing evidence.
578 + *
579 + * @package s2Member\Utilities
580 + * @since 260910.0630
581 + *
582 + * @param array|null $state Optional already-loaded asset health log.
583 + * @return array Request/time/final scores and supporting counts.
584 + */
585 + protected static function asset_health_scores($state = NULL)
586 + {
587 + $state = (is_array($state)) ? $state : self::asset_health_log_state();
588 + $loads = (!empty($state['last_10_asset_loads']) && is_array($state['last_10_asset_loads'])) ? array_values($state['last_10_asset_loads']) : array();
589 + $loads = array_slice($loads, -10);
590 + $request_total = 0.0;
591 + $request_importance = 0;
592 + $importance = 10;
593 + //260910.0709 Newest asset load matters most (10) and the oldest retained load least (1); divide by total importance below so the result stays on the same 1.00-4.00 scale.
594 + for($i = count($loads) - 1; $i >= 0 && $importance >= 1; $i--, $importance--)
595 + {
596 + $rating = (!empty($loads[$i]['result'])) ? self::asset_health_load_rating($loads[$i]['result']) : 0;
597 + if(!$rating)
598 + continue;
599 + $request_total += $rating * $importance;
600 + $request_importance += $importance;
601 + }
602 + $request_score = ($request_importance) ? $request_total / $request_importance : NULL;
603 +
604 + $current_minute_end = self::asset_health_period_end(time(), MINUTE_IN_SECONDS);
605 + $time_total = 0.0;
606 + $time_importance = 0;
607 + $time_count = 0;
608 + //260910.0709 Time Health averages every load inside a populated clock minute before applying recency importance, so heavy traffic cannot dominate other minutes and mixed outcomes inside one minute are not discarded.
609 + foreach((!empty($state['last_10min_minutes']) && is_array($state['last_10min_minutes'])) ? $state['last_10min_minutes'] : array() as $minute_end => $bucket)
610 + {
611 + $minute_end = (int)$minute_end;
612 + $age = (int)(($current_minute_end - $minute_end) / MINUTE_IN_SECONDS);
613 + $bucket_count = (!empty($bucket['count'])) ? (int)$bucket['count'] : 0;
614 + $bucket_sum = (isset($bucket['sum'])) ? (float)$bucket['sum'] : 0.0;
615 + if($age < 0 || $age > 9 || $bucket_count < 1)
616 + continue;
617 + $rating = $bucket_sum / $bucket_count;
618 + if($rating < 1 || $rating > 4)
619 + continue;
620 + $importance = 10 - $age;
621 + $time_total += $rating * $importance;
622 + $time_importance += $importance;
623 + $time_count++;
624 + }
625 + $time_score = ($time_importance) ? $time_total / $time_importance : NULL;
626 + //260910.0709 Request history and clock-time history get equal final influence when both exist; neither perspective can silently dominate the other.
627 + if($request_score !== NULL && $time_score !== NULL)
628 + $score = ($request_score + $time_score) / 2;
629 + else if($request_score !== NULL)
630 + $score = $request_score;
631 + else if($time_score !== NULL)
632 + $score = $time_score;
633 + else
634 + $score = NULL;
635 +
636 + $latest_result = ($loads && !empty($loads[count($loads) - 1]['result'])) ? (string)$loads[count($loads) - 1]['result'] : '';
637 +
638 + return array(
639 + 'request_score' => $request_score,
640 + 'time_score' => $time_score,
641 + 'score' => $score,
642 + 'latest_result' => $latest_result,
643 + 'request_count' => count($loads),
644 + 'time_count' => $time_count,
645 + );
646 + }
647 +
648 + /**
649 + * Returns the equal-block average from populated clock-aligned 10-minute blocks in the last 6 hours.
650 + *
651 + * Each populated 10-minute block contributes one average regardless of traffic volume. Empty
652 + * blocks contribute nothing because absence of traffic is not health evidence.
653 + *
654 + * @package s2Member\Utilities
655 + * @since 260910.0630
656 + *
657 + * @param array $state Asset health log.
658 + * @return float|null Rolling six-hour average, or NULL without retained non-Green evidence.
659 + */
660 + protected static function asset_health_six_hour_average($state = array())
661 + {
662 + $blocks = (!empty($state['last_6hour_10min_blocks']) && is_array($state['last_6hour_10min_blocks'])) ? $state['last_6hour_10min_blocks'] : array();
663 + if(!$blocks)
664 + return NULL;
665 + $cutoff = time() - 6 * HOUR_IN_SECONDS;
666 + $total = 0.0;
667 + $count = 0;
668 + //260910.2346 The persistent-review calculation runs only when an admin request has already passed cheaper status/age checks; at most about 37 populated blocks can contribute.
669 + foreach($blocks as $block_end => $bucket)
670 + {
671 + $block_end = (int)$block_end;
672 + $bucket_count = (!empty($bucket['count'])) ? (int)$bucket['count'] : 0;
673 + $bucket_sum = (isset($bucket['sum'])) ? (float)$bucket['sum'] : 0.0;
674 + if($block_end <= $cutoff || $bucket_count < 1)
675 + continue;
676 + $rating = $bucket_sum / $bucket_count;
677 + if($rating < 1 || $rating > 4)
678 + continue;
679 + $total += $rating;
680 + $count++;
681 + }
682 + return ($count) ? $total / $count : NULL;
683 + }
684 +
685 + /**
686 + * Returns a signature for compact page-load metadata used by a later Late correction.
687 + *
688 + * @package s2Member\Utilities
689 + * @since 260910.2346
690 + *
691 + * @param array $load Asset-load metadata.
692 + * @return string Signature.
693 + */
694 + protected static function asset_health_load_signature($load = array())
695 + {
696 + $parts = array();
697 + foreach(array('load_id', 'load_time', 'orig_result', 'orig_6hour') as $key)
698 + $parts[$key] = isset($load[$key]) ? (string)$load[$key] : '';
699 + return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
700 + }
701 +
702 + /**
703 + * Returns the current frontend page context without retaining query-string data.
704 + *
705 + * @package s2Member\Utilities
706 + * @since 260913.0048
707 + *
708 + * @return array Compact page ID/path context.
709 + */
710 + protected static function current_asset_health_page_context()
711 + {
712 + $page_id = (function_exists('is_singular') && is_singular()) ? (int)get_queried_object_id() : 0;
713 + $request_uri = (!empty($_SERVER['REQUEST_URI'])) ? wp_unslash((string)$_SERVER['REQUEST_URI']) : '';
714 + $page_path = ($request_uri !== '') ? (string)c_ws_plugin__s2member_utils_urls::parse_url($request_uri, PHP_URL_PATH) : '';
715 + $page_path = substr('/'.ltrim($page_path, '/'), 0, 240);
716 + if($page_path === '/')
717 + $page_path = '/';
718 + return array('page_id' => max(0, $page_id), 'page_path' => $page_path);
719 + }
720 +
721 + /**
722 + * Signs page context separately from legacy load metadata so already-cached pages remain compatible.
723 + *
724 + * @package s2Member\Utilities
725 + * @since 260913.0048
726 + *
727 + * @param array $load Asset-load metadata.
728 + * @return string Signature.
729 + */
730 + protected static function asset_health_page_signature($load = array())
731 + {
732 + $parts = array();
733 + foreach(array('load_id', 'load_time', 'page_id', 'page_path') as $key)
734 + $parts[$key] = isset($load[$key]) ? (string)$load[$key] : '';
735 + return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
736 + }
737 +
738 + /**
739 + * Returns signed page context from browser-returned load metadata.
740 + *
741 + * @package s2Member\Utilities
742 + * @since 260913.0048
743 + *
744 + * @param array $load Browser-returned load metadata.
745 + * @return array Verified page context, or empty context for legacy/tampered metadata.
746 + */
747 + protected static function verified_asset_health_page_context($load = array())
748 + {
749 + $load = (is_array($load)) ? $load : array();
750 + if(empty($load['page_signature']) || empty($load['load_id']) || empty($load['load_time']))
751 + return array('page_id' => 0, 'page_path' => '');
752 + $signature = (string)$load['page_signature'];
753 + if(!hash_equals(self::asset_health_page_signature($load), $signature))
754 + return array('page_id' => 0, 'page_path' => '');
755 + $page_id = (!empty($load['page_id'])) ? max(0, (int)$load['page_id']) : 0;
756 + $page_path = (!empty($load['page_path'])) ? substr((string)$load['page_path'], 0, 240) : '';
757 + return array('page_id' => $page_id, 'page_path' => $page_path);
758 + }
759 +
760 + /**
761 + * Queues one frontend asset load or signed Late correction for later collection.
762 + *
763 + * A WordPress-rendered frontend page queues one page-level result using the worst required
764 + * asset outcome: Okay=4, Late=3, Fallback=2, Failed=1. A signed Late report later corrects
765 + * that original load instead of counting the same page twice.
766 + *
767 + * @package s2Member\Utilities
768 + * @since 260910.0630
769 + *
770 + * @param string $result Asset-load result: `okay`, `late`, `fallback`, or `failed`.
771 + * @param bool $reset_on_ok Reset prior active history when an explicit trusted recheck returns Okay.
772 + * @param array $load Optional signed original-load metadata for a Late correction.
773 + * @param array $issue Optional compact issue snapshot with `label` and `detail`.
774 + * @return array Compact metadata for the queued load; `scores` remains an empty compatibility field.
775 + */
776 + protected static function queue_asset_health_load($result = '', $reset_on_ok = FALSE, $load = array(), $issue = array())
777 + {
778 + $result = strtolower((string)$result);
779 + $rating = self::asset_health_load_rating($result);
780 + if(!$rating)
781 + return array('scores' => array(), 'load' => array());
782 +
783 + $now = time();
784 + $load = (is_array($load)) ? $load : array();
785 + $issue = (is_array($issue)) ? $issue : array();
786 + $is_late_correction = $result === 'late' && !empty($load['load_id']) && !empty($load['load_time']);
787 + if(!$is_late_correction)
788 + {
789 + $load = array(
790 + 'load_id' => sha1(microtime(TRUE)."\0".wp_rand()."\0".home_url('/')),
791 + 'load_time' => $now,
792 + 'orig_result' => $result,
793 + 'orig_6hour' => 0,
794 + );
795 + //260913.0048 Frontend page identity is compact diagnostic context; keep query strings out and do not attach admin/AJAX request paths to explicit trusted rechecks.
796 + if(!$reset_on_ok && !is_admin() && !(function_exists('wp_doing_ajax') && wp_doing_ajax()))
797 + $load = array_merge($load, self::current_asset_health_page_context());
798 + }
799 +
800 + $event_time = ($is_late_correction && !empty($load['load_time'])) ? (int)$load['load_time'] : $now;
801 + //260912.0258 Queue the event and return without reading, locking, or rewriting the shared rolling health log.
802 + self::queue_asset_health_event(array(
803 + 'type' => 'load',
804 + 'event_time' => $event_time,
805 + 'result' => $result,
806 + 'reset_on_ok' => (bool)$reset_on_ok,
807 + 'load' => $load,
808 + 'issue' => $issue,
809 + ));
810 +
811 + if(!$is_late_correction)
812 + {
813 + $load['signature'] = self::asset_health_load_signature($load);
814 + if(isset($load['page_id']) || isset($load['page_path']))
815 + $load['page_signature'] = self::asset_health_page_signature($load);
816 + }
817 + return array('scores' => array(), 'load' => $load);
818 + }
819 +
820 + /**
821 + * Queues a useful historical issue that did not itself degrade the page-level health score.
822 + *
823 + * @package s2Member\Utilities
824 + * @since 260911.1834
825 + *
826 + * @param string $result Compact historical result key.
827 + * @param string $label Site-owner-friendly asset label.
828 + * @param string $detail Concise explanation.
829 + * @param array $issue Optional asset/delivery context.
830 + * @param array $page Optional page context.
831 + * @param int $event_time Optional original issue timestamp.
832 + * @return null
833 + */
834 + protected static function queue_asset_health_issue_snapshot($result = '', $label = '', $detail = '', $issue = array(), $page = array(), $event_time = 0)
835 + {
836 + $result = strtolower((string)$result);
837 + if($result === '')
838 + return;
839 +
840 + $issue = (is_array($issue)) ? $issue : array();
841 + $issue = array_merge($issue, array('label' => (string)$label, 'detail' => (string)$detail));
842 + $page = (is_array($page)) ? $page : array();
843 + if(!$page && !is_admin() && !(function_exists('wp_doing_ajax') && wp_doing_ajax()))
844 + $page = self::current_asset_health_page_context();
845 + //260913.0048 Self-repair/fallback/trusted-failure snapshots may identify the frontend page where they were encountered, without retaining its query string.
846 + //260912.0258 Queue historical snapshots like scored loads so a simultaneous healthy page cannot erase them with stale state.
847 + self::queue_asset_health_event(array(
848 + 'type' => 'issue',
849 + 'event_time' => ($event_time > 0) ? (int)$event_time : time(),
850 + 'result' => $result,
851 + 'issue' => $issue,
852 + 'page' => $page,
853 + ));
854 + return;
855 + }
856 +
857 + /**
858 + * Applies one queued scored-load event to an already-loaded health-log state.
859 + *
860 + * @package s2Member\Utilities
861 + * @since 260911.2356
862 + *
863 + * @param array $state Rolling health-log state, passed by reference.
864 + * @param array $event Queued load event.
865 + * @return bool True when the event was valid and consumed.
866 + */
867 + protected static function apply_asset_health_load_event(&$state, $event = array())
868 + {
869 + $event = (is_array($event)) ? $event : array();
870 + $result = (!empty($event['result'])) ? strtolower((string)$event['result']) : '';
871 + $rating = self::asset_health_load_rating($result);
872 + if(!$rating)
873 + return FALSE;
874 +
875 + $now = time();
876 + $event_time = (!empty($event['event_time'])) ? max(1, (int)$event['event_time']) : $now;
877 + $reset_on_ok = !empty($event['reset_on_ok']);
878 + $load = (!empty($event['load']) && is_array($event['load'])) ? $event['load'] : array();
879 + $issue = (!empty($event['issue']) && is_array($event['issue'])) ? $event['issue'] : array();
880 + $issues = (!empty($issue['items']) && is_array($issue['items'])) ? array_values($issue['items']) : (($issue) ? array($issue) : array());
881 + $page = array('page_id' => 0, 'page_path' => '');
882 +
883 + if($reset_on_ok && $result === 'okay')
884 + {
885 + //260913.0041 A trusted successful recheck resets scoring only; durable issue summaries, clear watermarks, and duplicate-processing protection remain historical state.
886 + $last_issue = (!empty($state['last_issue']) && is_array($state['last_issue'])) ? $state['last_issue'] : array();
887 + $latest_issues = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array();
888 + $last_issue_cleared_at = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
889 + $latest_issues_cleared_at = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
890 + $processed_event_times = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? $state['processed_event_times'] : array();
891 + $state = array('last_10_asset_loads' => array(), 'last_10min_minutes' => array(), 'last_6hour_10min_blocks' => array(), 'last_issue' => $last_issue, 'latest_issues' => $latest_issues, 'last_issue_cleared_at' => $last_issue_cleared_at, 'latest_issues_cleared_at' => $latest_issues_cleared_at, 'processed_event_times' => $processed_event_times, 'status' => 'unknown', 'not_green_since' => 0, 'history_reset_at' => $event_time);
892 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
893 + }
894 +
895 + $late_before_reset = FALSE;
896 + $is_late_correction = $result === 'late' && !empty($load['load_id']) && !empty($load['load_time']) && !empty($load['orig_result']) && isset($load['orig_6hour']) && !empty($load['signature']);
897 + if($is_late_correction)
898 + {
899 + $page = self::verified_asset_health_page_context($load); //260913.0048 Browser-returned page context is useful only when its separate signature matches.
900 + $load['load_id'] = preg_replace('/[^a-f0-9]/', '', strtolower((string)$load['load_id']));
901 + $load['load_time'] = (int)$load['load_time'];
902 + $load['orig_result'] = strtolower((string)$load['orig_result']);
903 + $load['orig_6hour'] = !empty($load['orig_6hour']) ? 1 : 0;
904 + $signature = (string)$load['signature'];
905 + unset($load['signature']);
906 + $is_late_correction = strlen($load['load_id']) === 40 && $load['load_time'] > 0 && in_array($load['orig_result'], array('okay', 'fallback'), TRUE) && hash_equals(self::asset_health_load_signature($load), $signature);
907 + if(!$is_late_correction)
908 + $page = array('page_id' => 0, 'page_path' => '');
909 + if($is_late_correction && !empty($state['history_reset_at']) && $load['load_time'] < (int)$state['history_reset_at'])
910 + {
911 + $late_before_reset = TRUE;
912 + $is_late_correction = FALSE;
913 + }
914 + }
915 + else
916 + {
917 + $page['page_id'] = (!empty($load['page_id'])) ? max(0, (int)$load['page_id']) : 0;
918 + $page['page_path'] = (!empty($load['page_path'])) ? substr((string)$load['page_path'], 0, 240) : '';
919 + }
920 +
921 + if($late_before_reset)
922 + {
923 + foreach($issues as $late_issue)
924 + if(is_array($late_issue))
925 + {
926 + self::add_asset_health_latest_issue($state, $load['load_time'], 'late', $late_issue, $page);
927 + self::set_asset_health_last_issue($state, $load['load_time'], 'late', (!empty($late_issue['label'])) ? $late_issue['label'] : '', (!empty($late_issue['detail'])) ? $late_issue['detail'] : '', (!empty($load['load_id'])) ? $load['load_id'] : '', $page['page_id'], $page['page_path']);
928 + }
929 + return TRUE; //260911.2356 Old delayed reports remain useful history but never re-enter a newer scoring epoch.
930 + }
931 +
932 + $original_contributed_to_6hour = FALSE;
933 + if($is_late_correction)
934 + {
935 + $orig_rating = self::asset_health_load_rating($load['orig_result']);
936 + if($rating >= $orig_rating)
937 + return TRUE; // Fallback is already worse than Late.
938 + $late_key = 'ws_plugin__s2member_asset_load_late_'.$load['load_id'];
939 + if(get_transient($late_key))
940 + return TRUE;
941 +
942 + $found = FALSE;
943 + foreach($state['last_10_asset_loads'] as &$entry)
944 + if(!empty($entry['load_id']) && hash_equals((string)$entry['load_id'], $load['load_id']))
945 + {
946 + $original_contributed_to_6hour = !empty($entry['orig_6hour']);
947 + $entry['result'] = 'late';
948 + $found = TRUE;
949 + break;
950 + }
951 + unset($entry);
952 + //260912.0258 A queued Late correction may arrive after its original load was processed, so derive six-hour membership from retained server state.
953 + if(!$found && !empty($state['not_green_since']) && (int)$state['not_green_since'] <= $load['load_time'])
954 + $original_contributed_to_6hour = TRUE;
955 +
956 + $minute_end = self::asset_health_period_end($load['load_time'], MINUTE_IN_SECONDS);
957 + $delta = $rating - $orig_rating;
958 + if(isset($state['last_10min_minutes'][$minute_end]) && !empty($state['last_10min_minutes'][$minute_end]['count']))
959 + $state['last_10min_minutes'][$minute_end]['sum'] += $delta;
960 + set_transient($late_key, 1, HOUR_IN_SECONDS);
961 + }
962 + else
963 + {
964 + $load_id = (!empty($load['load_id'])) ? preg_replace('/[^a-f0-9]/', '', strtolower((string)$load['load_id'])) : '';
965 + $load_id = (strlen($load_id) === 40) ? $load_id : sha1(microtime(TRUE)."\0".wp_rand()."\0".home_url('/'));
966 + $load_time = (!empty($load['load_time'])) ? max(1, (int)$load['load_time']) : $event_time;
967 + $load = array('load_id' => $load_id, 'load_time' => $load_time, 'orig_result' => $result, 'orig_6hour' => 0, 'page_id' => $page['page_id'], 'page_path' => $page['page_path']);
968 + //260912.0551 The Health Logkeeper already processes queued loads in event-time/queue-time order; preserve that order so simultaneous same-second requests are not randomized by load ID.
969 + $state['last_10_asset_loads'][] = array('time' => $load_time, 'result' => $result, 'load_id' => $load_id, 'orig_6hour' => 0);
970 + $state['last_10_asset_loads'] = array_slice($state['last_10_asset_loads'], -10);
971 +
972 + $minute_end = self::asset_health_period_end($load_time, MINUTE_IN_SECONDS);
973 + if(empty($state['last_10min_minutes'][$minute_end]) || !is_array($state['last_10min_minutes'][$minute_end]))
974 + $state['last_10min_minutes'][$minute_end] = array('sum' => 0.0, 'count' => 0);
975 + $state['last_10min_minutes'][$minute_end]['sum'] += $rating;
976 + $state['last_10min_minutes'][$minute_end]['count']++;
977 + }
978 +
979 + $current_minute_end = self::asset_health_period_end($now, MINUTE_IN_SECONDS);
980 + foreach($state['last_10min_minutes'] as $minute_end => $bucket)
981 + if((int)$minute_end < $current_minute_end - 9 * MINUTE_IN_SECONDS || (int)$minute_end > $current_minute_end)
982 + unset($state['last_10min_minutes'][$minute_end]);
983 +
984 + $scores = self::asset_health_scores($state);
985 + $status = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
986 + $previous_status = (!empty($state['status'])) ? (string)$state['status'] : 'unknown';
987 + $state['status'] = $status;
988 +
989 + if($status === 'healthy')
990 + {
991 + $state['not_green_since'] = 0;
992 + $state['last_6hour_10min_blocks'] = array();
993 + //260912.1959 Rolling delivery may be Healthy while a trusted standby fallback is still unavailable; keep that combined-health notice dismissal until the fallback recovers.
994 + if(!self::asset_health_fallback_problem_active())
995 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
996 + }
997 + else
998 + {
999 + if(empty($state['not_green_since']))
1000 + $state['not_green_since'] = ($is_late_correction) ? $load['load_time'] : $event_time;
1001 + $block_time = ($is_late_correction) ? $load['load_time'] : $event_time;
1002 + $block_end = self::asset_health_period_end($block_time, 10 * MINUTE_IN_SECONDS);
1003 + if($is_late_correction && $original_contributed_to_6hour && isset($state['last_6hour_10min_blocks'][$block_end]) && !empty($state['last_6hour_10min_blocks'][$block_end]['count']))
1004 + $state['last_6hour_10min_blocks'][$block_end]['sum'] += $rating - self::asset_health_load_rating($load['orig_result']);
1005 + else if(!$is_late_correction || !$original_contributed_to_6hour)
1006 + {
1007 + if(empty($state['last_6hour_10min_blocks'][$block_end]) || !is_array($state['last_6hour_10min_blocks'][$block_end]))
1008 + $state['last_6hour_10min_blocks'][$block_end] = array('sum' => 0.0, 'count' => 0);
1009 + $state['last_6hour_10min_blocks'][$block_end]['sum'] += $rating;
1010 + $state['last_6hour_10min_blocks'][$block_end]['count']++;
1011 + if(!$is_late_correction)
1012 + foreach($state['last_10_asset_loads'] as &$entry)
1013 + if(!empty($entry['load_id']) && hash_equals((string)$entry['load_id'], (string)$load['load_id']))
1014 + {
1015 + $entry['orig_6hour'] = 1;
1016 + break;
1017 + }
1018 + unset($entry);
1019 + }
1020 + $cutoff = $now - 6 * HOUR_IN_SECONDS;
1021 + foreach($state['last_6hour_10min_blocks'] as $end => $bucket)
1022 + if((int)$end <= $cutoff)
1023 + unset($state['last_6hour_10min_blocks'][$end]);
1024 + }
1025 +
1026 + if($previous_status === 'healthy' && $status !== 'healthy')
1027 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
1028 + if($result !== 'okay')
1029 + {
1030 + $issue_time = ($is_late_correction && !empty($load['load_time'])) ? (int)$load['load_time'] : $event_time;
1031 + foreach($issues as $health_issue)
1032 + if(is_array($health_issue))
1033 + {
1034 + self::add_asset_health_latest_issue($state, $issue_time, $result, $health_issue, $page);
1035 + self::set_asset_health_last_issue($state, $issue_time, $result, (!empty($health_issue['label'])) ? $health_issue['label'] : '', (!empty($health_issue['detail'])) ? $health_issue['detail'] : '', (!empty($load['load_id'])) ? $load['load_id'] : '', $page['page_id'], $page['page_path']);
1036 + }
1037 + }
1038 + return TRUE;
1039 + }
1040 +
1041 + /**
1042 + * Synchronizes time-derived status fields after queued events are merged.
1043 + *
1044 + * @package s2Member\Utilities
1045 + * @since 260911.2356
1046 + *
1047 + * @param array $state Rolling health-log state, passed by reference.
1048 + * @return bool True when derived state changed.
1049 + */
1050 + protected static function sync_asset_health_derived_state(&$state)
1051 + {
1052 + $scores = self::asset_health_scores($state);
1053 + $overall = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
1054 + $changed = (!isset($state['status']) || (string)$state['status'] !== $overall);
1055 + $state['status'] = $overall;
1056 +
1057 + if($overall === 'healthy')
1058 + {
1059 + if(!empty($state['not_green_since']) || !empty($state['last_6hour_10min_blocks']))
1060 + {
1061 + $state['not_green_since'] = 0;
1062 + $state['last_6hour_10min_blocks'] = array();
1063 + $changed = TRUE;
1064 + //260912.1959 Do not clear a dismissed combined-health notice while the independent fallback problem is still active.
1065 + if(!self::asset_health_fallback_problem_active())
1066 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
1067 + }
1068 + }
1069 + else if($overall !== 'unknown' && empty($state['not_green_since']))
1070 + {
1071 + $state['not_green_since'] = time();
1072 + $changed = TRUE;
1073 + }
1074 + return $changed;
1075 + }
1076 +
1077 + /**
1078 + * Runs the Health Logkeeper, merging queued frontend asset-health events into the rolling log.
1079 + *
1080 + * The Logkeeper never waits for another run. Frontend requests only queue separate event options,
1081 + * so page delivery is never serialized behind health-log maintenance.
1082 + *
1083 + * @package s2Member\Utilities
1084 + * @since 260912.0258
1085 + *
1086 + * @attaches-to ``add_action('ws_plugin__s2member_assets_health_logkeeper');``
1087 + * @return int Number of queued events handled.
1088 + */
1089 + public static function run_health_logkeeper()
1090 + {
1091 + $lock = self::health_logkeeper_lock_acquire();
1092 + if($lock === '')
1093 + {
1094 + //260912.0258 Never wait for a live Logkeeper; leave a background retry so a one-off scheduling collision cannot strand queued events.
1095 + if(!wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
1096 + wp_schedule_single_event(time() + 30, 'ws_plugin__s2member_assets_health_logkeeper');
1097 + return 0;
1098 + }
1099 +
1100 + global $wpdb;
1101 + $prefix = self::asset_health_event_option_prefix();
1102 + $like = $wpdb->esc_like($prefix).'%';
1103 + //260912.0258 Fetch each queued option and its value in one indexed prefix query; the timestamp-based option names already provide chronological order.
1104 + $rows = $wpdb->get_results($wpdb->prepare("SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC LIMIT 100", $like));
1105 + $events = array();
1106 + foreach((array)$rows as $row)
1107 + {
1108 + $option_name = (!empty($row->option_name)) ? (string)$row->option_name : '';
1109 + $event_times = ($option_name !== '' && strpos($option_name, $prefix) === 0) ? substr($option_name, strlen($prefix)) : '';
1110 + $event = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(isset($row->option_value) ? $row->option_value : NULL);
1111 + if(!preg_match('/^\\d{12}_\\d{18}$/D', $event_times) || !is_array($event))
1112 + {
1113 + //260912.0258 Malformed telemetry is disposable; delete it instead of carrying unexpected data into the health log.
1114 + if($option_name !== '')
1115 + delete_option($option_name);
1116 + continue;
1117 + }
1118 + $events[] = array('option_name' => $option_name, 'event_times' => $event_times, 'event' => $event);
1119 + }
1120 +
1121 + $state = self::asset_health_log_state();
1122 + $processed = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? array_fill_keys($state['processed_event_times'], TRUE) : array();
1123 + $handled = 0;
1124 + $changed = FALSE;
1125 + foreach($events as $queued)
1126 + {
1127 + $event = $queued['event'];
1128 + $event_times = $queued['event_times'];
1129 + if(empty($processed[$event_times]))
1130 + {
1131 + $type = (!empty($event['type'])) ? strtolower((string)$event['type']) : '';
1132 + $valid = FALSE;
1133 + if($type === 'load')
1134 + $valid = self::apply_asset_health_load_event($state, $event);
1135 + else if($type === 'issue' && !empty($event['result']))
1136 + {
1137 + $issue = (!empty($event['issue']) && is_array($event['issue'])) ? $event['issue'] : array();
1138 + $page = (!empty($event['page']) && is_array($event['page'])) ? $event['page'] : array();
1139 + $issue_time = (!empty($event['event_time'])) ? (int)$event['event_time'] : time();
1140 + $changed = self::add_asset_health_latest_issue($state, $issue_time, (string)$event['result'], $issue, $page) || $changed;
1141 + $changed = self::set_asset_health_last_issue($state, $issue_time, (string)$event['result'], (!empty($issue['label'])) ? (string)$issue['label'] : '', (!empty($issue['detail'])) ? (string)$issue['detail'] : '', '', (!empty($page['page_id'])) ? (int)$page['page_id'] : 0, (!empty($page['page_path'])) ? (string)$page['page_path'] : '') || $changed;
1142 + $valid = TRUE;
1143 + }
1144 + if(!$valid)
1145 + {
1146 + delete_option($queued['option_name']);
1147 + continue;
1148 + }
1149 + if($type === 'load')
1150 + $changed = TRUE;
1151 + $state['processed_event_times'][] = $event_times;
1152 + $state['processed_event_times'] = array_slice(array_values(array_unique($state['processed_event_times'])), -100);
1153 + $processed[$event_times] = TRUE;
1154 + }
1155 + $handled++;
1156 + }
1157 + $changed = self::sync_asset_health_derived_state($state) || $changed;
1158 +
1159 + $stored = TRUE;
1160 + if($changed || $events)
1161 + {
1162 + $stored = update_option('ws_plugin__s2member_assets_health_log', $state, FALSE);
1163 + if(!$stored)
1164 + $stored = serialize(self::asset_health_log_state()) === serialize($state); //260912.0258 update_option() also returns false when the requested value is already stored; distinguish that harmless case from a failed write before deleting queue rows.
1165 + }
1166 + if($stored)
1167 + {
1168 + //260912.0258 Delete only after the merged state and processed event-times are stored; if interrupted first, the next Logkeeper run can safely deduplicate the retained queue rows.
1169 + foreach($events as $queued)
1170 + delete_option($queued['option_name']);
1171 + }
1172 +
1173 + if((!$stored || count((array)$rows) >= 100) && !wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
1174 + wp_schedule_single_event(time() + 5, 'ws_plugin__s2member_assets_health_logkeeper');
1175 + self::health_logkeeper_lock_release($lock);
1176 + return ($stored) ? $handled : 0;
1177 + }
1178 +
1179 + /**
1180 + * Clears one administrator-selected Asset Health troubleshooting summary without changing scoring.
1181 + *
1182 + * @package s2Member\Utilities
1183 + * @since 260913.0056
1184 + *
1185 + * @return null Exits through WordPress JSON helpers.
1186 + */
1187 + public static function ajax_clear_asset_health_details()
1188 + {
1189 + if(!current_user_can('create_users'))
1190 + wp_send_json_error(array('message' => 'You do not have permission to clear Asset Health details.'), 403);
1191 + check_ajax_referer('ws-plugin--s2member-clear-asset-health-details');
1192 + $scope = (!empty($_POST['scope'])) ? sanitize_key(wp_unslash($_POST['scope'])) : '';
1193 + if(!in_array($scope, array('last_issue', 'latest_issues'), TRUE))
1194 + wp_send_json_error(array('message' => 'Invalid Asset Health clear request.'), 400);
1195 +
1196 + $lock = self::health_logkeeper_lock_acquire();
1197 + if($lock === '')
1198 + wp_send_json_error(array('message' => 'Asset Health is updating. Please try again.'), 409);
1199 + $state = self::asset_health_log_state();
1200 + $now = time();
1201 + if($scope === 'last_issue')
1202 + {
1203 + $state['last_issue'] = array();
1204 + $state['last_issue_cleared_at'] = $now;
1205 + }
1206 + else
1207 + {
1208 + $state['latest_issues'] = array();
1209 + $state['latest_issues_cleared_at'] = $now;
1210 + }
1211 + $stored = update_option('ws_plugin__s2member_assets_health_log', $state, FALSE);
1212 + if(!$stored)
1213 + $stored = serialize(self::asset_health_log_state()) === serialize($state);
1214 + self::health_logkeeper_lock_release($lock);
1215 + if(!$stored)
1216 + wp_send_json_error(array('message' => 'Asset Health details could not be cleared.'), 500);
1217 + wp_send_json_success(array('message' => ($scope === 'last_issue') ? 'Last issue cleared.' : 'Latest Issues cleared.'));
1218 + }
1219 +
1220 + /**
1221 + * Returns the page-level Okay/Fallback result for the delivery routes selected by WordPress.
1222 + *
1223 + * A normal configured route is Okay. Compatibility-required Full WordPress Dynamic delivery is
1224 + * also Okay because it is the correct route for the current request/configuration. WordPress Dynamic
1225 + * is Fallback only when a requested route unexpectedly could not be used. Browser activation is
1226 + * checked separately by the frontend activation monitor.
1227 + *
1228 + * @package s2Member\Utilities
1229 + * @since 260910.0630
1230 + *
1231 + * @return string `okay` or `fallback`.
1232 + */
1233 + protected static function page_asset_health_load_result()
1234 + {
1235 + $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1236 + //260910.0818 A page is Fallback only when WordPress had to choose Full WordPress Dynamic instead of a requested static or selected s2member-o.php route; intentionally selected Full WordPress Dynamic is Okay.
1237 + foreach(self::$page_asset_expectations as $expectation)
1238 + {
1239 + $type = (!empty($expectation['type'])) ? (string)$expectation['type'] : '';
1240 + if(!in_array($type, array('css', 'js'), TRUE))
1241 + continue;
1242 + if(!empty($expectation['delivery']) && $expectation['delivery'] === 'dynamic-wordpress' && (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]) || $selected_s2o))
1243 + {
1244 + //260913.2001 Static delivery can be intentionally incompatible with current hooks/configuration; successful required Dynamic delivery is the correct route, not a degraded fallback.
1245 + if(!empty($expectation['dynamic_required']) && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1246 + continue;
1247 + return 'fallback';
1248 + }
1249 + }
1250 + return 'okay';
1251 + }
1252 +
1253 + /**
1254 + * Returns compact context for a non-Okay page-level delivery result.
1255 + *
1256 + * @package s2Member\Utilities
1257 + * @since 260911.1806
1258 + *
1259 + * @param string $result Page-level asset-load result.
1260 + * @return array Issue snapshot with `label` and `detail`.
1261 + */
1262 + protected static function page_asset_health_issue($result = '')
1263 + {
1264 + $result = strtolower((string)$result);
1265 + if($result !== 'fallback')
1266 + return array();
1267 +
1268 + $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1269 + foreach(self::$page_asset_expectations as $expectation)
1270 + {
1271 + $type = (!empty($expectation['type'])) ? (string)$expectation['type'] : '';
1272 + if(!in_array($type, array('css', 'js'), TRUE) || empty($expectation['delivery']) || $expectation['delivery'] !== 'dynamic-wordpress')
1273 + continue;
1274 + if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1275 + {
1276 + $asset_id = (!empty($expectation['asset_id'])) ? (string)$expectation['asset_id'] : '';
1277 + $health_id = ($asset_id !== '') ? self::asset_runtime_health_id($asset_id, $type, 'static') : '';
1278 + return array(
1279 + 'asset' => ($health_id !== '') ? $health_id : (string)$asset_id,
1280 + 'label' => ($health_id !== '') ? self::asset_runtime_health_label($health_id) : strtoupper($type).' delivery',
1281 + 'delivery' => 'Static → Full WordPress Dynamic fallback',
1282 + 'detail' => (!empty($expectation['issue_detail'])) ? (string)$expectation['issue_detail'] : 'Requested static delivery was unavailable, so Full WordPress Dynamic fallback was used.',
1283 + );
1284 + }
1285 + if($selected_s2o)
1286 + return array(
1287 + 'asset' => 'dynamic_'.$type,
1288 + 'label' => 'Dynamic '.(($type === 'js') ? 'JS' : 'CSS'),
1289 + 'delivery' => 's2Member-Only → Full WordPress Dynamic fallback',
1290 + 'detail' => 'The selected s2Member-Only Dynamic Loader was unavailable, so Full WordPress Dynamic fallback was used.',
1291 + );
1292 + }
1293 + return array();
1294 + }
1295 +
1296 + /**
1297 + * Returns Okay/Fallback/Failed for the site's currently required delivery using trusted failure state.
1298 + *
1299 + * @package s2Member\Utilities
1300 + * @since 260910.0630
1301 + *
1302 + * @param array $failures Trusted current probe failures.
1303 + * @return string `okay`, `fallback`, or `failed`.
1304 + */
1305 + protected static function asset_health_current_delivery_result($failures = array())
1306 + {
1307 + $failures = (is_array($failures)) ? $failures : array();
1308 + $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1309 + $local_health = self::static_assets_health(TRUE);
1310 + $location = self::static_assets_location(FALSE);
1311 + $overall_rating = 4;
1312 +
1313 + //260910.0818 Trusted current delivery takes the worse CSS/JS result: 4=preferred route works, 2=WordPress fallback works, 1=no usable route verifies; Late is browser timing evidence and is not manufactured here.
1314 + foreach(array('css', 'js') as $type)
1315 + {
1316 + $type_rating = 4;
1317 + if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1318 + {
1319 + $dynamic_requirement = self::static_type_dynamic_requirement($type);
1320 + if(!empty($dynamic_requirement['required']))
1321 + $type_rating = (!empty($failures['dynamic:dynamic_'.$type])) ? 1 : 4; //260913.2001 Compatibility-required Dynamic delivery is the intended route; only failure of that route degrades Health.
1322 + else
1323 + {
1324 + $fallback = !empty($local_health['location']);
1325 + foreach(self::static_asset_ids($type, 'all') as $id)
1326 + {
1327 + $state = self::static_asset_build($id);
1328 + $definition = self::static_asset_definition($id, FALSE);
1329 + $generation_failure = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
1330 + if(empty($definition['ok']) || ($generation_failure && $state <= 0) || isset($local_health[$id]))
1331 + $fallback = TRUE;
1332 + if($state > 0 && !empty($location['ok']))
1333 + {
1334 + $base = substr($id, 0, -strlen('.'.$type));
1335 + $url = $location['url'].'/'.$base.'-'.$state.'.'.$type;
1336 + if(!empty($failures['static:'.$id]) && !empty($failures['static:'.$id]['url']) && (string)$failures['static:'.$id]['url'] === $url)
1337 + $fallback = TRUE;
1338 + }
1339 + }
1340 + if($fallback)
1341 + $type_rating = (!empty($failures['fallback:dynamic_'.$type])) ? 1 : 2;
1342 + }
1343 + }
1344 + else if($selected_s2o)
1345 + {
1346 + $s2o_problem = !is_file(self::s2o_file_path()) || (!empty($failures['s2o']));
1347 + if($s2o_problem)
1348 + $type_rating = (!empty($failures['fallback:dynamic_'.$type])) ? 1 : 2;
1349 + }
1350 + else if(!empty($failures['dynamic:dynamic_'.$type]))
1351 + $type_rating = 1;
1352 +
1353 + $overall_rating = min($overall_rating, $type_rating);
1354 + }
1355 + return ($overall_rating <= 1) ? 'failed' : (($overall_rating === 2) ? 'fallback' : 'okay');
1356 + }
1357 +
1358 + /**
193 1359 * Returns recent low-trust runtime suspicions reported by real frontend pages.
194 1360 *
195 - * Reports are only hints. They never change delivery by themselves. A trusted administrator-browser probe must confirm the exact URL/marker before persistent fallback or a confirmed notice is used.
1361 + * Reports are only hints. They never change delivery by themselves. A trusted administrator-browser probe must confirm the exact asset response before persistent fallback or a confirmed notice is used.
196 1362 *
197 1363 * @package s2Member\Utilities
198 1364 * @since 260904.2255
199 1365 *
@@ -211,19 +1377,23 @@
211 1377
212 1378 /**
213 1379 * Returns the current public asset URLs that an administrator's browser should probe.
214 1380 *
215 - * Normal checks are deliberately cheap. Static files use HEAD and s2member-o.php has a special early health response that exits before loading WordPress. A real-page suspicion adds a one-time full marker check for the exact asset that page expected.
1381 + * Normal checks are deliberately cheap. Static files use HEAD and s2member-o.php has a special early health response that exits before loading WordPress. A real-page suspicion adds a one-time full activation-tag check for the exact asset that page expected.
1382 + * A full Health-panel/recheck probe additionally verifies activation tags for the configured dynamic route,
1383 + * the WordPress Dynamic fallback, and static files so it can produce a fresh Okay/Fallback/Failed asset-load result.
216 1384 *
217 1385 * @package s2Member\Utilities
218 1386 * @since 260904.2110
219 1387 *
1388 + * @param bool $full Include current-delivery/fallback activation-tag checks for a fresh asset-load health result.
220 1389 * @return array Health targets keyed by logical target ID.
221 1390 */
222 - protected static function asset_http_health_targets()
1391 + protected static function asset_http_health_targets($full = FALSE)
223 1392 {
224 1393 $targets = array();
225 - if((empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && is_file(self::s2o_file_path()))
1394 + $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1395 + if($selected_s2o && is_file(self::s2o_file_path()))
226 1396 $targets['s2o'] = array(
227 1397 'id' => 's2o',
228 1398 'url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
229 1399 'probe_url' => add_query_arg('s2member_health_check', '1', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']),
@@ -228,9 +1398,9 @@
228 1398 'url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
229 1399 'probe_url' => add_query_arg('s2member_health_check', '1', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']),
230 1400 'type' => 'health',
231 1401 'mode' => 's2o-health',
232 - 'label' => 's2Member Dynamic Loader',
1402 + 'label' => 's2Member-Only Dynamic Loader',
233 1403 'failure_id' => 's2o',
234 1404 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
235 1405 );
236 1406
@@ -237,8 +1407,12 @@
237 1407 $location = self::static_assets_location(FALSE);
238 1408 if(!empty($location['ok']))
239 1409 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
240 1410 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1411 + {
1412 + $dynamic_requirement = self::static_type_dynamic_requirement($type);
1413 + if(!empty($dynamic_requirement['required']))
1414 + continue; //260913.2001 Intentionally inactive static files are not trusted-probe targets while compatibility requires Dynamic delivery.
241 1415 foreach(self::static_asset_ids($type, 'all') as $id)
242 1416 {
243 1417 $build = self::static_asset_build($id);
244 1418 if($build <= 0)
@@ -245,28 +1419,109 @@
245 1419 continue;
246 1420 $base = substr($id, 0, -strlen('.'.$type));
247 1421 $url = $location['url'].'/'.$base.'-'.$build.'.'.$type;
248 1422 if(is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
249 - $targets['static:'.$id] = array(
1423 + {
1424 + $target = array(
250 1425 'id' => 'static:'.$id,
251 1426 'url' => $url,
252 1427 'probe_url' => $url,
253 1428 'type' => $type,
254 - 'mode' => 'head',
255 - 'label' => $id,
1429 + 'mode' => ($full) ? 'activation-tag' : 'head',
1430 + 'label' => self::asset_runtime_health_label(self::asset_runtime_health_id($id, $type, 'static')),
256 1431 'failure_id' => 'static:'.$id,
257 1432 'failure_url' => $url,
258 1433 );
1434 + if($full)
1435 + {
1436 + //260912.0522 Full probes validate the activation tag too; cheap background probes stay HEAD-only to avoid unnecessary body downloads.
1437 + $health_id = self::asset_runtime_health_id($id, $type, 'static');
1438 + $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
1439 + $target['activation_tags'] = array(self::activation_tag_snippet($health_id, $type, $tag_value));
1440 + }
1441 + $targets['static:'.$id] = $target;
1442 + }
259 1443 }
1444 + }
260 1445
1446 + if($full)
1447 + {
1448 + //260910.0818 A full trusted check includes the actual dynamic response and its available WordPress fallback so Fallback can be distinguished from Failed instead of assuming a fallback works.
1449 + foreach(array('css', 'js') as $type)
1450 + {
1451 + $health_id = 'dynamic_'.$type;
1452 + $wordpress_url = self::wordpress_dynamic_asset_url();
1453 + $wordpress_url = ($type === 'css')
1454 + ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $wordpress_url)
1455 + : add_query_arg(array('ws_plugin__s2member_js_w_globals' => '1', 'qcABC' => '1'), $wordpress_url);
1456 + $wordpress_tag_value = ($type === 'css') ? '2147483640' : 'dynamic-wordpress';
1457 + $wordpress_target = array(
1458 + 'url' => $wordpress_url,
1459 + 'probe_url' => $wordpress_url,
1460 + 'type' => $type,
1461 + 'mode' => 'activation-tag',
1462 + 'label' => 'WP Loader '.(($type === 'css') ? 'CSS' : 'JS'),
1463 + 'activation_tags' => array(self::activation_tag_snippet($health_id, $type, $wordpress_tag_value)),
1464 + );
1465 +
1466 + if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1467 + {
1468 + $dynamic_requirement = self::static_type_dynamic_requirement($type);
1469 + //260913.2001 Probe compatibility-required Full WordPress Dynamic as the active route; genuine static failures keep the existing fallback probe identity.
1470 + $target_id = (!empty($dynamic_requirement['required'])) ? 'active:'.$health_id : 'fallback:'.$health_id;
1471 + $wordpress_target['id'] = $target_id;
1472 + $wordpress_target['failure_id'] = (!empty($dynamic_requirement['required'])) ? 'dynamic:'.$health_id : $target_id;
1473 + $wordpress_target['failure_url'] = $wordpress_url;
1474 + $targets[$target_id] = $wordpress_target;
1475 + }
1476 + else if($selected_s2o)
1477 + {
1478 + if(is_file(self::s2o_file_path()))
1479 + {
1480 + $s2o_url = ($type === 'css')
1481 + ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
1482 + : add_query_arg(array('ws_plugin__s2member_js_w_globals' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
1483 + $s2o_tag_value = ($type === 'css') ? '2147483639' : 'dynamic-s2member-o';
1484 + $target_id = 'active:s2member-o:'.$health_id;
1485 + $targets[$target_id] = array(
1486 + 'id' => $target_id,
1487 + 'url' => $s2o_url,
1488 + 'probe_url' => $s2o_url,
1489 + 'type' => $type,
1490 + 'mode' => 'activation-tag',
1491 + 'label' => 's2Member-Only '.(($type === 'css') ? 'CSS' : 'JS'),
1492 + 'activation_tags' => array(self::activation_tag_snippet($health_id, $type, $s2o_tag_value)),
1493 + 'failure_id' => 's2o',
1494 + 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
1495 + );
1496 + }
1497 + $target_id = 'fallback:'.$health_id;
1498 + $wordpress_target['id'] = $target_id;
1499 + $wordpress_target['failure_id'] = $target_id;
1500 + $wordpress_target['failure_url'] = $wordpress_url;
1501 + $targets[$target_id] = $wordpress_target;
1502 + }
1503 + else
1504 + {
1505 + $target_id = 'active:'.$health_id;
1506 + $wordpress_target['id'] = $target_id;
1507 + $wordpress_target['failure_id'] = 'dynamic:'.$health_id;
1508 + $wordpress_target['failure_url'] = $wordpress_url;
1509 + $targets[$target_id] = $wordpress_target;
1510 + }
1511 + }
1512 + }
1513 +
1514 + //260912.0522 Real-page Late reports remain low-trust hints; add exact URL/activation-tag targets so the administrator-browser probe can confirm or reject them without changing delivery from the report alone.
261 1515 foreach(self::asset_runtime_suspicions() as $key => $suspicion)
262 1516 {
263 - if(!self::asset_runtime_expectation_is_current($suspicion))
1517 + //260912.0522 Ignore pre-rename in-flight suspicions instead of carrying a compatibility alias for this new Beta schema.
1518 + if(empty($suspicion['activation_tag']) || !self::asset_runtime_expectation_is_current($suspicion))
264 1519 continue;
265 1520 $id = 'runtime:'.$key;
266 1521 $failure_id = '';
267 1522 $failure_url = (string)$suspicion['url'];
268 - if($suspicion['delivery'] === 'dynamic-lightweight')
1523 + if($suspicion['delivery'] === 'dynamic-s2member-o')
269 1524 {
270 1525 $failure_id = 's2o';
271 1526 $failure_url = $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'];
272 1527 }
@@ -272,9 +1527,9 @@
272 1527 }
273 1528 else if($suspicion['delivery'] === 'static' && !empty($suspicion['asset_id']))
274 1529 $failure_id = 'static:'.$suspicion['asset_id'];
275 1530 else
276 - $failure_id = $id;
1531 + $failure_id = 'dynamic:'.(string)$suspicion['id'];
277 1532
278 1533 $targets[$id] = array(
279 1534 'id' => $id,
280 1535 'url' => (string)$suspicion['url'],
@@ -279,11 +1534,11 @@
279 1534 'id' => $id,
280 1535 'url' => (string)$suspicion['url'],
281 1536 'probe_url' => (string)$suspicion['url'],
282 1537 'type' => (string)$suspicion['type'],
283 - 'mode' => 'marker',
284 - 'label' => (string)$suspicion['id'],
285 - 'markers' => array((string)$suspicion['marker']),
1538 + 'mode' => 'activation-tag',
1539 + 'label' => self::asset_runtime_health_label((string)$suspicion['id']),
1540 + 'activation_tags' => array((string)$suspicion['activation_tag']),
286 1541 'failure_id' => $failure_id,
287 1542 'failure_url' => $failure_url,
288 1543 'suspicion_key' => $key,
289 1544 'suspicion' => $suspicion,
@@ -304,81 +1559,154 @@
304 1559 protected static function asset_http_health_target_hash($targets = array())
305 1560 {
306 1561 $hash = array();
307 1562 foreach((array)$targets as $id => $target)
308 - $hash[$id] = array((string)$target['url'], (string)$target['type'], (string)$target['mode'], (!empty($target['markers'])) ? array_values((array)$target['markers']) : array());
1563 + $hash[$id] = array((string)$target['url'], (string)$target['type'], (string)$target['mode'], (!empty($target['activation_tags'])) ? array_values((array)$target['activation_tags']) : array());
309 1564 return md5(serialize($hash));
310 1565 }
311 1566
312 1567 /**
313 - * Returns marker output appended to a generated static asset.
1568 + * Upgrades the frontend Asset Health format once per site.
314 1569 *
1570 + * Existing timestamped files remain available to already-cached HTML, while fresh pages
1571 + * regenerate active static assets with one activation tag per physical response.
1572 + *
315 1573 * @package s2Member\Utilities
1574 + * @since 260909.2015
1575 + *
1576 + * @return null
1577 + */
1578 + public static function maybe_upgrade_asset_health_format()
1579 + {
1580 + if((string)get_option('ws_plugin__s2member_asset_health_format_version', '') === '3')
1581 + return;
1582 +
1583 + //260912.0522 Reset the first-v260909 activation-tag/build and health-history state together so old component tags and pre-score load history cannot bleed into the physical-file scoring model.
1584 + self::reset_static_asset_builds();
1585 + delete_option('ws_plugin__s2member_asset_runtime_suspicions');
1586 + delete_option('ws_plugin__s2member_asset_http_health');
1587 + delete_option('ws_plugin__s2member_asset_attention_state');
1588 + delete_option('ws_plugin__s2member_assets_health_log');
1589 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
1590 + self::$asset_http_health_cache = NULL;
1591 + update_option('ws_plugin__s2member_asset_health_format_version', '3', FALSE);
1592 + return;
1593 + }
1594 +
1595 + /**
1596 + * Returns the runtime-health ID for one physical frontend asset response.
1597 + *
1598 + * @package s2Member\Utilities
1599 + * @since 260909.2015
1600 + *
1601 + * @param string $asset_id Static logical filename, or an empty string for dynamic delivery.
1602 + * @param string $type `css` or `js`.
1603 + * @param string $delivery Delivery mode.
1604 + * @return string Runtime-health ID.
1605 + */
1606 + protected static function asset_runtime_health_id($asset_id = '', $type = '', $delivery = '')
1607 + {
1608 + $type = strtolower((string)$type);
1609 + if(!in_array($type, array('css', 'js'), TRUE))
1610 + return '';
1611 + if($delivery === 'static')
1612 + {
1613 + $base = substr((string)$asset_id, 0, -strlen('.'.$type));
1614 + $base = str_replace('-', '_', strtolower($base));
1615 + return ($base) ? $base.'_'.$type : '';
1616 + }
1617 + return 'dynamic_'.$type;
1618 + }
1619 +
1620 + /**
1621 + * Returns a site-owner-friendly label for one runtime-health ID.
1622 + *
1623 + * @package s2Member\Utilities
1624 + * @since 260909.2015
1625 + *
1626 + * @param string $id Runtime-health ID.
1627 + * @return string Human-readable label.
1628 + */
1629 + protected static function asset_runtime_health_label($id = '')
1630 + {
1631 + $combined = !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']));
1632 + $labels = array(
1633 + 's2member_css' => ($combined) ? 'Combined CSS' : 'Framework CSS',
1634 + 's2member_pro_css' => 'Pro CSS',
1635 + 's2member_js' => ($combined) ? 'Combined JS' : 'Framework JS',
1636 + 's2member_pro_js' => 'Pro JS',
1637 + 'dynamic_css' => 'Dynamic CSS',
1638 + 'dynamic_js' => 'Dynamic JS',
1639 + );
1640 + return isset($labels[$id]) ? $labels[$id] : (string)$id;
1641 + }
1642 +
1643 + /**
1644 + * Returns the activation-tag snippet used to verify one physical frontend asset response.
1645 + *
1646 + * @package s2Member\Utilities
1647 + * @since 260909.2015
1648 + *
1649 + * @param string $id Runtime-health ID.
1650 + * @param string $type `css` or `js`.
1651 + * @param string $tag_value Value the activation tag is expected to expose.
1652 + * @return string Activation-tag source snippet.
1653 + */
1654 + protected static function activation_tag_snippet($id = '', $type = '', $tag_value = '')
1655 + {
1656 + if($type === 'css')
1657 + return '#ws-plugin--s2member-asset-health-'.str_replace('_', '-', (string)$id).'{z-index:'.(string)$tag_value.'!important}';
1658 + return 'ws_plugin__s2member_asset_health["'.(string)$id.'"]="'.(string)$tag_value.'"';
1659 + }
1660 +
1661 + /**
1662 + * Returns the activation-tag snippet appended to a generated static asset.
1663 + *
1664 + * @package s2Member\Utilities
316 1665 * @since 260904.2255
317 1666 *
318 1667 * @param string $id Stable generated asset identifier without extension.
319 1668 * @param string $type `css` or `js`.
320 1669 * @param int $build Generated build timestamp.
321 - * @return string Marker output.
1670 + * @return string Activation-tag snippet.
322 1671 */
323 - protected static function static_asset_marker_output($id = '', $type = '', $build = 0)
1672 + protected static function static_activation_tag_snippet($id = '', $type = '', $build = 0)
324 1673 {
325 - $components = array();
326 - if($id === 's2member-pro')
327 - $components[] = 'pro';
328 - else
329 - {
330 - $components[] = 'framework';
331 - if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])))
332 - $components[] = 'pro';
333 - }
334 - $token = 'static-'.(int)$build;
1674 + //260912.0522 Activation-tag identity follows the physical response, not Framework/Pro logical components, so a combined file produces one tag and one possible Late report.
1675 + $health_id = self::asset_runtime_health_id($id.'.'.$type, $type, 'static');
1676 + $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
1677 + $activation_tag = self::activation_tag_snippet($health_id, $type, $tag_value);
335 1678 if($type === 'css')
336 - {
337 - $markers = array();
338 - foreach($components as $component)
339 - $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}';
340 - return implode('', $markers);
341 - }
342 - $markers = ';window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};';
343 - foreach($components as $component)
344 - $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";';
345 - return $markers;
1679 + return $activation_tag;
1680 + return ';window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};window.'.$activation_tag.';';
346 1681 }
347 1682
348 1683 /**
349 - * Returns marker output appended to dynamically generated CSS or JavaScript.
1684 + * Returns the activation-tag snippet appended to dynamically generated CSS or JavaScript.
350 1685 *
351 1686 * @package s2Member\Utilities
352 1687 * @since 260904.2255
353 1688 *
354 1689 * @param string $type `css` or `js`.
355 - * @return string Marker output.
1690 + * @return string Activation-tag snippet.
356 1691 */
357 - public static function dynamic_asset_marker_output($type = '')
1692 + public static function dynamic_activation_tag_snippet($type = '')
358 1693 {
359 1694 $type = strtolower((string)$type);
360 1695 if(!in_array($type, array('css', 'js'), TRUE))
361 1696 return '';
362 - $token = (defined('_WS_PLUGIN__S2MEMBER_ONLY')) ? 'dynamic-lightweight' : 'dynamic-wordpress';
363 - $components = array('framework');
364 - if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))
365 - $components[] = 'pro';
1697 + $delivery = (defined('_WS_PLUGIN__S2MEMBER_ONLY')) ? 'dynamic-s2member-o' : 'dynamic-wordpress';
1698 + $health_id = self::asset_runtime_health_id('', $type, $delivery);
1699 + $tag_value = ($type === 'css') ? (($delivery === 'dynamic-s2member-o') ? '2147483639' : '2147483640') : $delivery;
1700 + $activation_tag = self::activation_tag_snippet($health_id, $type, $tag_value);
366 1701 if($type === 'css')
367 - {
368 - $markers = array();
369 - foreach($components as $component)
370 - $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}';
371 - return "\n".implode('', $markers)."\n";
372 - }
373 - $markers = "\n;window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};";
374 - foreach($components as $component)
375 - $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";';
376 - return $markers."\n";
1702 + return "\n".$activation_tag."\n";
1703 + return "\n;window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};window.".$activation_tag.";\n";
377 1704 }
378 1705
379 1706 /**
380 - * Registers the exact CSS/JavaScript markers expected on the current frontend page.
1707 + * Registers the exact CSS/JavaScript activation tags expected on the current frontend page.
1708 + * Each call represents one physical response, so combined Framework+Pro delivery registers one activation tag for that combined file instead of one tag per logical component.
381 1709 *
382 1710 * @package s2Member\Utilities
383 1711 * @since 260904.2255
384 1712 *
@@ -384,73 +1712,42 @@
384 1712 *
385 1713 * @param string $asset_id Logical static asset ID, or an empty string for dynamic delivery.
386 1714 * @param string $type `css` or `js`.
387 1715 * @param string $url Public URL emitted on this page.
388 - * @param string $delivery `static`, `dynamic-lightweight`, or `dynamic-wordpress`.
1716 + * @param string $delivery `static`, `dynamic-s2member-o`, or `dynamic-wordpress`.
389 1717 * @param int $build Static build timestamp, or zero for dynamic delivery.
1718 + * @param string $issue_detail Optional reason a preferred route fell back before this response was selected.
1719 + * @param bool $dynamic_required Whether Full WordPress Dynamic delivery is intentionally required for compatibility.
390 1720 * @return null
391 1721 */
392 - public static function register_page_asset_expectations($asset_id = '', $type = '', $url = '', $delivery = '', $build = 0)
1722 + public static function register_page_asset_expectations($asset_id = '', $type = '', $url = '', $delivery = '', $build = 0, $issue_detail = '', $dynamic_required = FALSE)
393 1723 {
394 1724 $type = strtolower((string)$type);
395 1725 $url = (string)$url;
396 - if(!in_array($type, array('css', 'js'), TRUE) || !$url)
1726 + if(!in_array($type, array('css', 'js'), TRUE) || !$url || !in_array($delivery, array('static', 'dynamic-s2member-o', 'dynamic-wordpress'), TRUE))
397 1727 return;
398 - $components = array();
1728 + $id = self::asset_runtime_health_id($asset_id, $type, $delivery);
1729 + if(!$id)
1730 + return;
399 1731 if($delivery === 'static')
400 - {
401 - if(strpos((string)$asset_id, 's2member-pro.') === 0)
402 - $components[] = 'pro';
403 - else
404 - {
405 - $components[] = 'framework';
406 - if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])))
407 - $components[] = 'pro';
408 - }
409 - $token = 'static-'.(int)$build;
410 - }
1732 + $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
411 1733 else
412 - {
413 - $components[] = 'framework';
414 - if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))
415 - $components[] = 'pro';
416 - $token = ($delivery === 'dynamic-lightweight') ? 'dynamic-lightweight' : 'dynamic-wordpress';
417 - }
418 - $recovery_url = '';
419 - if($delivery !== 'dynamic-wordpress')
420 - {
421 - if($type === 'css')
422 - $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
423 - else
424 - {
425 - $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1';
426 - $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
427 - }
428 - }
429 - foreach($components as $component)
430 - {
431 - $id = $component.'_'.$type;
432 - if($type === 'css')
433 - {
434 - $token = ($component === 'framework') ? '2147483641' : '2147483642';
435 - $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}';
436 - }
437 - else
438 - $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"';
439 - $expectation = array(
440 - 'id' => $id,
441 - 'asset_id' => (string)$asset_id,
442 - 'type' => $type,
443 - 'component' => $component,
444 - 'url' => $url,
445 - 'delivery' => $delivery,
446 - 'token' => $token,
447 - 'marker' => $marker,
448 - 'recovery_url' => $recovery_url,
449 - );
450 - $expectation['signature'] = self::asset_runtime_expectation_signature($expectation);
451 - self::$page_asset_expectations[$id] = $expectation;
452 - }
1734 + $tag_value = ($type === 'css') ? (($delivery === 'dynamic-s2member-o') ? '2147483639' : '2147483640') : $delivery;
1735 + $expectation = array(
1736 + 'id' => $id,
1737 + 'asset_id' => (string)$asset_id,
1738 + 'type' => $type,
1739 + 'url' => $url,
1740 + 'delivery' => $delivery,
1741 + 'tag_value' => $tag_value,
1742 + 'activation_tag' => self::activation_tag_snippet($id, $type, $tag_value),
1743 + //260911.1806 Server-side fallback context is not sent to the browser; it only supplies a useful Last issue snapshot for the page that selected fallback.
1744 + 'issue_detail' => substr(wp_strip_all_tags((string)$issue_detail), 0, 240),
1745 + //260913.2001 Server-only compatibility context keeps intentional Full WordPress Dynamic delivery Healthy without changing the compact browser expectation/signature.
1746 + 'dynamic_required' => (bool)$dynamic_required,
1747 + );
1748 + $expectation['signature'] = self::asset_runtime_expectation_signature($expectation);
1749 + self::$page_asset_expectations[$id] = $expectation;
453 1750 return;
454 1751 }
455 1752
456 1753 /**
@@ -455,10 +1752,11 @@
455 1752
456 1753 /**
457 1754 * Expands one compact browser runtime expectation into the full signed structure.
458 1755 *
459 - * Frontend pages only need a few fields to check markers and recover. Reconstruct the
1756 + * Frontend pages only need a few fields to check asset activation. Reconstruct the
460 1757 * descriptive fields here when a miss is actually reported, keeping healthy page source small.
1758 + * Recovery fields from the first v260909 monitor are intentionally no longer part of current expectations because Late results no longer trigger speculative fallback injection.
461 1759 *
462 1760 * @package s2Member\Utilities
463 1761 * @since 260905.0106
464 1762 *
@@ -472,40 +1770,21 @@
472 1770 $id = isset($compact[0]) ? (string)$compact[0] : '';
473 1771 $asset_id = isset($compact[1]) ? (string)$compact[1] : '';
474 1772 $url = isset($compact[2]) ? (string)$compact[2] : '';
475 1773 $delivery = isset($compact[3]) ? (string)$compact[3] : '';
476 - $token = isset($compact[4]) ? (string)$compact[4] : '';
1774 + $tag_value = isset($compact[4]) ? (string)$compact[4] : '';
477 1775 $signature = isset($compact[5]) ? (string)$compact[5] : '';
478 - if(!preg_match('/\A(framework|pro)_(css|js)\z/', $id, $match))
479 - return array();
480 - $component = $match[1];
481 - $type = $match[2];
482 - if($type === 'css')
483 - $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}';
484 - else
485 - $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"';
486 -
487 - $recovery_url = '';
488 - if($delivery !== 'dynamic-wordpress')
489 - {
490 - if($type === 'css')
491 - $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
492 - else
493 - {
494 - $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1';
495 - $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
496 - }
497 - }
1776 + if(!preg_match('/\A(?:s2member(?:_pro)?|dynamic)_(css|js)\z/', $id, $match))
1777 + return array(); //260912.0522 Cached pages using the first-v260909 component-level activation-tag IDs are intentionally stale after the Asset Health format upgrade.
1778 + $type = $match[1];
498 1779 return array(
499 1780 'id' => $id,
500 1781 'asset_id' => $asset_id,
501 1782 'type' => $type,
502 - 'component' => $component,
503 1783 'url' => $url,
504 1784 'delivery' => $delivery,
505 - 'token' => $token,
506 - 'marker' => $marker,
507 - 'recovery_url' => $recovery_url,
1785 + 'tag_value' => $tag_value,
1786 + 'activation_tag' => self::activation_tag_snippet($id, $type, $tag_value),
508 1787 'signature' => $signature,
509 1788 );
510 1789 }
511 1790
@@ -520,9 +1799,9 @@
520 1799 */
521 1800 protected static function asset_runtime_expectation_signature($expectation = array())
522 1801 {
523 1802 $parts = array();
524 - foreach(array('id', 'asset_id', 'type', 'component', 'url', 'delivery', 'token', 'marker', 'recovery_url') as $key)
1803 + foreach(array('id', 'asset_id', 'type', 'url', 'delivery', 'tag_value', 'activation_tag') as $key)
525 1804 $parts[$key] = isset($expectation[$key]) ? (string)$expectation[$key] : '';
526 1805 return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
527 1806 }
528 1807
@@ -549,9 +1828,9 @@
549 1828 $location = self::static_assets_location(FALSE);
550 1829 $base = substr($id, 0, -strlen('.'.$type));
551 1830 return !empty($location['ok']) && $build > 0 && (string)$expectation['url'] === $location['url'].'/'.$base.'-'.$build.'.'.$type;
552 1831 }
553 - if($expectation['delivery'] === 'dynamic-lightweight')
1832 + if($expectation['delivery'] === 'dynamic-s2member-o')
554 1833 return (empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && strpos((string)$expectation['url'], $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'].'?') === 0;
555 1834 if($expectation['delivery'] === 'dynamic-wordpress')
556 1835 return strpos((string)$expectation['url'], self::wordpress_dynamic_asset_url().'?') === 0;
557 1836 return FALSE;
@@ -927,9 +2206,10 @@
927 2206 $new = (array)$value;
928 2207
929 2208 //260907.2203 Keep a concise operational history of CSS/JS configuration changes when s2Member logging is enabled.
930 2209 $config_changes = array();
931 - foreach(array('dynamic_asset_loader', 'static_css', 'static_css_minify', 'static_js', 'static_js_text', 'static_js_minify', 'static_assets_combine') as $key)
2210 + //260912.0522 Include wait-time changes because they can explain a sudden change in Late asset loads even when delivery settings themselves did not change.
2211 + foreach(array('dynamic_asset_loader', 'static_css', 'static_css_minify', 'static_js', 'static_js_text', 'static_js_minify', 'static_assets_combine', 'asset_health_wait_seconds') as $key)
932 2212 if(serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL))
933 2213 $config_changes[$key] = array('old' => isset($old[$key]) ? $old[$key] : NULL, 'new' => isset($new[$key]) ? $new[$key] : NULL);
934 2214 if($config_changes)
935 2215 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS configuration changed', 'changes' => $config_changes));
@@ -935,9 +2215,10 @@
935 2215 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS configuration changed', 'changes' => $config_changes));
936 2216
937 2217 if((string)(isset($old['static_assets_combine']) ? $old['static_assets_combine'] : '0') !== (string)(isset($new['static_assets_combine']) ? $new['static_assets_combine'] : '0'))
938 2218 {
939 - //260903.1918 A combine-mode change changes what the s2member.* filenames represent; discard all build state so the new 2-file/4-file representation starts with fresh timestamps.
2219 + //260911.1834 A combine-mode change resets both representations; queue enabled CSS/JS for immediate rebuilding after the complete new option set has finished saving.
2220 + self::$static_assets_rebuild_after_save = array('css', 'js');
940 2221 self::reset_static_asset_builds();
941 2222 return;
942 2223 }
943 2224
@@ -960,9 +2241,17 @@
960 2241 $invalidate[] = $selector;
961 2242 break;
962 2243 }
963 2244 if($invalidate)
2245 + {
2246 + //260911.1834 Remember only the affected types until update_all_options has finished; rebuilding here could still see the request's old global option set.
2247 + foreach($invalidate as $_static_asset_selector)
2248 + foreach(array('css', 'js') as $_static_asset_type)
2249 + if(strpos((string)$_static_asset_selector, $_static_asset_type) !== FALSE)
2250 + self::$static_assets_rebuild_after_save[] = $_static_asset_type;
2251 + self::$static_assets_rebuild_after_save = array_values(array_unique(self::$static_assets_rebuild_after_save));
964 2252 self::invalidate_static_assets($invalidate);
2253 + }
965 2254 return;
966 2255 }
967 2256 if(in_array((string)$option, array('siteurl', 'home'), TRUE))
968 2257 self::invalidate_static_assets(array('css', 'js'));
@@ -971,8 +2260,89 @@
971 2260 return;
972 2261 }
973 2262
974 2263 /**
2264 + * Rebuilds enabled static asset types after s2Member finishes saving relevant options.
2265 + *
2266 + * @package s2Member\Utilities
2267 + * @since 260911.1834
2268 + *
2269 + * @param array $vars Variables passed by ws_plugin__s2member_after_update_all_options.
2270 + * @return null
2271 + */
2272 + public static function rebuild_static_assets_after_options_save($vars = array())
2273 + {
2274 + if(empty($vars['updated_all_options']) || !self::$static_assets_rebuild_after_save)
2275 + return;
2276 +
2277 + $types = array_values(array_unique(self::$static_assets_rebuild_after_save));
2278 + self::$static_assets_rebuild_after_save = array();
2279 + $options = (!empty($vars['options']) && is_array($vars['options'])) ? $vars['options'] : get_option('ws_plugin__s2member_options', array());
2280 + if(!is_array($options))
2281 + return;
2282 +
2283 + //260911.1834 Build from the complete newly saved option set; lazy frontend generation remains the recovery path for later upgrades, deletions, or transient failures.
2284 + $previous_options = $GLOBALS['WS_PLUGIN__']['s2member']['o'];
2285 + $GLOBALS['WS_PLUGIN__']['s2member']['o'] = $options;
2286 + $results = array();
2287 + foreach($types as $type)
2288 + if(in_array($type, array('css', 'js'), TRUE) && !empty($options['static_'.$type]))
2289 + $results[$type] = self::ensure_static_assets($type);
2290 + $GLOBALS['WS_PLUGIN__']['s2member']['o'] = $previous_options;
2291 +
2292 + if($results)
2293 + c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS rebuilt after option save', 'result' => 'completed', 'types' => array_keys($results)));
2294 + return;
2295 + }
2296 +
2297 + /**
2298 + * Rebuilds enabled static assets opportunistically on normal privileged administrator page-loads.
2299 + *
2300 + * Routine requests only perform a few build-state/filesystem checks. Actual generation runs only when an active asset is pending, has never been generated, or its current local file is missing.
2301 + *
2302 + * @package s2Member\Utilities
2303 + * @since 260911.1924
2304 + *
2305 + * @return null
2306 + */
2307 + public static function maybe_rebuild_static_assets_on_admin_request()
2308 + {
2309 + if(!is_admin() || !current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
2310 + return;
2311 + if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js']))
2312 + return;
2313 +
2314 + $location = self::static_assets_location(FALSE);
2315 + foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
2316 + {
2317 + if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2318 + continue;
2319 + $needs_rebuild = FALSE;
2320 + foreach(self::static_asset_ids($type, 'all') as $id)
2321 + {
2322 + $build = self::static_asset_build($id);
2323 + if($build <= 0)
2324 + {
2325 + $needs_rebuild = TRUE;
2326 + continue;
2327 + }
2328 + if(!empty($location['ok']))
2329 + {
2330 + $base = substr($id, 0, -strlen('.'.$type));
2331 + if(!is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
2332 + {
2333 + $needs_rebuild = TRUE;
2334 + }
2335 + }
2336 + }
2337 + //260911.2325 Generation/failure cooldown, missing-file repair locking, and repaired-issue history are centralized in ensure_static_asset(); avoid a second history write from the admin recovery wrapper.
2338 + if($needs_rebuild)
2339 + self::ensure_static_assets($type);
2340 + }
2341 + return;
2342 + }
2343 +
2344 + /**
975 2345 * Invalidates generated frontend assets when plugin activation/deactivation can change frontend integrations.
976 2346 *
977 2347 * @package s2Member\Utilities
978 2348 * @since 260903.0437
@@ -1029,8 +2399,34 @@
1029 2399 return;
1030 2400 }
1031 2401
1032 2402 /**
2403 + * Deletes a static-asset repair lock only when the stored value still belongs to the expected owner.
2404 + *
2405 + * @package s2Member\Utilities
2406 + * @since 260913.0704
2407 + *
2408 + * @param string $option Repair-lock option name.
2409 + * @param string $lock Expected lock-owner value.
2410 + * @return bool True when this exact lock was deleted.
2411 + */
2412 + protected static function static_asset_repair_lock_delete($option = '', $lock = '')
2413 + {
2414 + global $wpdb;
2415 +
2416 + $option = (string)$option;
2417 + $lock = (string)$lock;
2418 + if($option === '' || $lock === '')
2419 + return FALSE;
2420 +
2421 + //260913.0704 Delete only the lock version this request observed or acquired; another request may have replaced it in the meantime.
2422 + $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($lock)), array('%s', '%s'));
2423 + if($deleted)
2424 + wp_cache_delete($option, 'options');
2425 + return (bool)$deleted;
2426 + }
2427 +
2428 + /**
1033 2429 * Returns one current generated frontend asset URL, building it when stale/uninitialized.
1034 2430 *
1035 2431 * Active timestamped files are existence-checked before their URLs are emitted. A missing or
1036 2432 * trusted-browser-confirmed unreachable file returns a failure so callers can use dynamic delivery immediately.
@@ -1057,14 +2453,17 @@
1057 2453
1058 2454 //260903.0544 Normal requests only check whether current hooks/configuration permit static delivery; source assembly and filesystem work wait until a build is actually needed.
1059 2455 $compatibility = self::static_asset_definition($id, FALSE);
1060 2456 if(empty($compatibility['ok']))
1061 - return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => abs(self::static_asset_build($id)), 'error' => (string)$compatibility['error']);
2457 + return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => abs(self::static_asset_build($id)), 'error' => (string)$compatibility['error'], 'dynamic_required' => !empty($compatibility['dynamic_required']));
1062 2458
1063 2459 $state = self::static_asset_build($id);
1064 2460 $dirty = $state < 0;
1065 2461 $active_build = abs($state);
1066 2462 $base = substr($id, 0, -strlen('.'.$type));
2463 + $failure_key = 'ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id);
2464 + $repair_lock_key = '';
2465 + $repair_lock_value = '';
1067 2466 $data_map_signature = array('ok' => TRUE, 'signature' => '', 'error' => '');
1068 2467 $uses_data_map = $type === 'js' && self::static_js_text_delivery() === 'page';
1069 2468 if($uses_data_map)
1070 2469 {
@@ -1085,23 +2484,47 @@
1085 2484 if(empty($location['ok']))
1086 2485 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $location['error']);
1087 2486 $url = $location['url'].'/'.$base.'-'.$active_build.'.'.$type;
1088 2487 $path = $location['dir'].'/'.$base.'-'.$active_build.'.'.$type;
1089 - //260904.2110 A few local file checks are cheaper than sending a broken static URL. Missing or browser-confirmed unreachable files fall back to dynamic delivery immediately.
1090 2488 if(!is_file($path))
1091 - return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Expected static asset '.$id.' is missing.');
1092 - if(self::asset_http_target_failed('static:'.$id, $url))
1093 - return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Static asset '.$id.' could not be loaded from its public URL.');
1094 - return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $url, 'build' => $active_build, 'error' => '');
2489 + {
2490 + //260911.1806 A configured static file that vanished locally is not an admin preference: try one guarded synchronous repair, then let normal dynamic fallback handle this request if repair cannot complete.
2491 + if($failure = get_transient($failure_key))
2492 + return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure);
2493 + $repair_lock_key = 'ws_plugin__s2member_static_asset_repair_lock_'.str_replace('.', '_', $id);
2494 + $repair_lock_current = (string)get_option($repair_lock_key, '');
2495 + $repair_lock_parts = explode(':', $repair_lock_current, 2);
2496 + $repair_lock_time = (!empty($repair_lock_parts[0]) && is_numeric($repair_lock_parts[0])) ? (int)$repair_lock_parts[0] : 0;
2497 + //260913.0704 Preserve compatibility with older timestamp-only locks while making stale takeover conditional on the exact lock value this request inspected.
2498 + if($repair_lock_current !== '' && (!$repair_lock_time || $repair_lock_time < time() - 30))
2499 + self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_current);
2500 + $repair_lock_value = time().':'.sha1(microtime(TRUE)."\0".wp_rand());
2501 + if(!add_option($repair_lock_key, $repair_lock_value, '', 'no'))
2502 + return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Expected static asset '.$id.' is missing; another request is already rebuilding it.');
2503 + $dirty = TRUE;
2504 + }
2505 + else
2506 + {
2507 + //260904.2110 A local file check is cheaper than sending a broken static URL; browser-confirmed public-URL failures still fall back without rebuilding a valid local file.
2508 + if(self::asset_http_target_failed('static:'.$id, $url))
2509 + return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Static asset '.$id.' could not be loaded from its public URL.');
2510 + return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $url, 'build' => $active_build, 'error' => '');
2511 + }
1095 2512 }
1096 2513
1097 - $failure_key = 'ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id);
1098 2514 if(!$force && $dirty && ($failure = get_transient($failure_key)))
1099 2515 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure);
1100 2516
1101 2517 $definition = self::static_asset_definition($id, TRUE);
1102 2518 if(empty($definition['ok']))
2519 + {
2520 + if($repair_lock_key !== '')
2521 + {
2522 + set_transient($failure_key, (string)$definition['error'], 5 * MINUTE_IN_SECONDS);
2523 + self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
2524 + }
1103 2525 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$definition['error']);
2526 + }
1104 2527
1105 2528 $build = max(time(), $active_build + 1);
1106 2529 $result = self::build_static_asset($base, $build, $type, $definition['sources'], !empty($definition['minify']));
1107 2530 if(!empty($result['ok']))
@@ -1118,11 +2541,20 @@
1118 2541
1119 2542 //260905.0106 Prune only after the new timestamp is current so the previous generation is treated as stale instead of protected.
1120 2543 self::prune_static_asset_generations(dirname($result['path']), $result['path']);
1121 2544 delete_transient($failure_key);
2545 + if($repair_lock_key !== '')
2546 + {
2547 + //260911.1834 A missing active file that repaired successfully is still useful history, but it must not lower the health score because this request retained static delivery.
2548 + $health_id = self::asset_runtime_health_id($id, $type, 'static');
2549 + self::queue_asset_health_issue_snapshot('repaired', self::asset_runtime_health_label($health_id), 'Expected static asset '.$id.' was missing and was rebuilt automatically.');
2550 + self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
2551 + }
1122 2552 return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $result['url'], 'build' => $build, 'error' => '');
1123 2553 }
1124 2554 set_transient($failure_key, (string)$result['error'], 5 * MINUTE_IN_SECONDS);
2555 + if($repair_lock_key !== '')
2556 + self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
1125 2557
1126 2558 //260907.2203 Preserve failed generation details even when delivery later falls back or recovers automatically.
1127 2559 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1128 2560 'event' => 'Static CSS/JS asset generation failed', 'result' => 'failure', 'asset' => $id, 'attempted_build' => $build,
@@ -1157,9 +2589,9 @@
1157 2589 foreach(self::static_asset_ids($type, 'all') as $id)
1158 2590 {
1159 2591 $assets[$id] = self::ensure_static_asset($id, $force);
1160 2592 if(empty($assets[$id]['ok']))
1161 - return array('ok' => FALSE, 'assets' => $assets, 'error' => (string)$assets[$id]['error']);
2593 + return array('ok' => FALSE, 'assets' => $assets, 'error' => (string)$assets[$id]['error'], 'dynamic_required' => !empty($assets[$id]['dynamic_required']));
1162 2594 }
1163 2595 return array('ok' => (bool)$assets, 'assets' => $assets, 'error' => '');
1164 2596 }
1165 2597
@@ -1247,21 +2679,28 @@
1247 2679 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js']))
1248 2680 return self::$static_assets_health_cache = array();
1249 2681
1250 2682 $missing = array();
2683 + $checked_types = array();
2684 + foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
2685 + if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2686 + {
2687 + $dynamic_requirement = self::static_type_dynamic_requirement($type);
2688 + if(empty($dynamic_requirement['required']))
2689 + $checked_types[] = $type; //260913.2001 Only currently active static routes participate in local static-file health.
2690 + }
1251 2691 $location = self::static_assets_location(FALSE);
1252 - if(empty($location['ok']))
2692 + if($checked_types && empty($location['ok']))
1253 2693 $missing['location'] = $location['error'];
1254 - else
1255 - foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
1256 - if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1257 - foreach(self::static_asset_ids($type, 'all') as $id)
1258 - {
1259 - $build = self::static_asset_build($id);
1260 - $base = substr($id, 0, -strlen('.'.$type));
1261 - if($build > 0 && !is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
1262 - $missing[$id] = 'Expected static asset '.$id.' is missing.';
1263 - }
2694 + else if(!empty($location['ok']))
2695 + foreach($checked_types as $type)
2696 + foreach(self::static_asset_ids($type, 'all') as $id)
2697 + {
2698 + $build = self::static_asset_build($id);
2699 + $base = substr($id, 0, -strlen('.'.$type));
2700 + if($build > 0 && !is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
2701 + $missing[$id] = 'Expected static asset '.$id.' is missing.';
2702 + }
1264 2703
1265 2704 //260907.2203 Log only local-health transitions so recurring admin checks do not repeat the same event.
1266 2705 $previous = get_option('ws_plugin__s2member_static_asset_health', array());
1267 2706 $previous = is_array($previous) ? $previous : array();
@@ -1277,76 +2716,493 @@
1277 2716 return self::$static_assets_health_cache = $missing;
1278 2717 }
1279 2718
1280 2719 /**
1281 - * Displays a branded admin warning for missing or browser-confirmed unreachable frontend assets.
2720 + * Returns timing status for one physical frontend CSS/JavaScript response.
1282 2721 *
2722 + * A pending real-page activation delay is Late/Yellow until the trusted browser check decides
2723 + * whether the response is valid. Confirmed historical timing evidence remains in Health scoring
2724 + * and Latest Issues instead of making a recovered asset row look currently unhealthy.
2725 + *
1283 2726 * @package s2Member\Utilities
1284 - * @since 260903.0612
2727 + * @since 260909.2021
1285 2728 *
1286 - * @attaches-to ``add_action('admin_notices');``
1287 - * @return null
2729 + * @param string $id Runtime-health ID.
2730 + * @return array Status details.
1288 2731 */
1289 - public static function static_assets_admin_notice()
2732 + protected static function asset_runtime_health_event($id = '')
1290 2733 {
1291 - if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
1292 - return;
2734 + $id = (string)$id;
2735 + $latest = 0;
2736 + $pending = FALSE;
1293 2737
1294 - $messages = array();
1295 - $static_settings_url = add_query_arg('s2member-open-panel', 'frontend-static-assets', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-static-assets';
1296 - $dynamic_settings_url = add_query_arg('s2member-open-panel', 'dynamic-asset-loader', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-dynamic-asset-loader-section';
1297 - $health = self::static_assets_health();
1298 - if($health)
1299 - $messages[] = esc_html(implode(' ', $health)).' Pages that need the missing file are using dynamic delivery instead. <a href="'.esc_url($static_settings_url).'">Open Static CSS/JS Optimization and refresh the static assets.</a>';
2738 + //260913.0059 Current rows describe current known state only; a trusted-successful follow-up leaves the Late event in scoring/Latest Issues instead of holding this row Yellow for an hour.
2739 + foreach(self::asset_runtime_suspicions() as $suspicion)
2740 + if(!empty($suspicion['id']) && (string)$suspicion['id'] === $id)
2741 + {
2742 + $pending = TRUE;
2743 + $latest = max($latest, (int)$suspicion['reported']);
2744 + }
1300 2745
1301 - $using_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1302 - $s2o_missing = $using_s2o && !is_file(self::s2o_file_path());
2746 + if($pending)
2747 + return array(
2748 + 'status' => 'delayed',
2749 + 'label' => 'Late',
2750 + 'detail' => 'A frontend page could not confirm that this asset became active within the configured wait time. A trusted browser check will verify the asset response.',
2751 + 'reported' => $latest,
2752 + );
2753 + return array('status' => 'healthy', 'label' => 'Healthy', 'detail' => '', 'reported' => 0);
2754 + }
1303 2755
2756 + /**
2757 + * Returns consolidated site-owner health for active frontend CSS/JavaScript delivery.
2758 + *
2759 + * Current rows explain the actual configured/preferred route and any fallback. The
2760 + * headline color comes from the Okay/Late/Fallback/Failed asset-load score: the latest 10
2761 + * individual loads plus populated clock minutes from the latest 10 minutes, with newer evidence more important.
2762 + *
2763 + * @package s2Member\Utilities
2764 + * @since 260909.2021
2765 + *
2766 + * @param bool $force Recheck local static-file health and request a full trusted browser probe on this admin page.
2767 + * @return array Overall status, score details, rows, notice level/items, and notice signature.
2768 + */
2769 + public static function frontend_asset_health($force = FALSE)
2770 + {
2771 + if($force)
2772 + self::$asset_health_force_full_probe = TRUE; //260912.0522 Opening the Health panel asks the footer probe for full current-route activation checks, not only the cheap background reachability checks.
2773 +
2774 + //260912.0258 Run the Health Logkeeper before rendering admin health so queued frontend evidence is reflected without requiring another refresh.
2775 + self::run_health_logkeeper();
2776 + $rows = array();
2777 + $error_notice_items = array();
2778 + $attention_items = array();
2779 + //260910.0709 Rows describe the actual route in use; notice item lists are separate so Yellow/Orange status can remain informative without automatically becoming an admin-wide alarm.
2780 + $http_health = self::asset_http_health_state();
2781 + $failures = (is_array($http_health) && !empty($http_health['failures']) && is_array($http_health['failures'])) ? $http_health['failures'] : array();
2782 + $runtime_warnings = (is_array($http_health) && !empty($http_health['runtime_warnings']) && is_array($http_health['runtime_warnings'])) ? $http_health['runtime_warnings'] : array();
2783 + $local_health = self::static_assets_health($force);
2784 + $location = self::static_assets_location(FALSE);
2785 + $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
2786 + $dynamic_normal = array('css' => FALSE, 'js' => FALSE);
2787 + $wp_loader_active = array('css' => FALSE, 'js' => FALSE);
2788 + $wp_loader_fallback = array('css' => FALSE, 'js' => FALSE);
2789 + $wp_loader_required = array('css' => FALSE, 'js' => FALSE);
2790 +
2791 + foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
2792 + {
2793 + $static_requested = !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]);
2794 + if(!$static_requested)
2795 + {
2796 + $dynamic_normal[$type] = TRUE;
2797 + $using_wordpress = self::dynamic_asset_url(FALSE) === self::wordpress_dynamic_asset_url();
2798 + $wp_loader_active[$type] = $using_wordpress;
2799 + $wp_loader_fallback[$type] = $selected_s2o;
2800 + if($selected_s2o)
2801 + {
2802 + //260912.1956 Show the configured s2Member-Only route separately from its WordPress fallback so each route's current health is understandable at a glance.
2803 + $s2o_url = ($type === 'css')
2804 + ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
2805 + : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
2806 + $event = self::asset_runtime_health_event('dynamic_'.$type);
2807 + if($using_wordpress)
2808 + {
2809 + $status = 'error';
2810 + $status_label = 'Failed';
2811 + $detail = 'The selected s2Member-Only Dynamic Loader could not be used; Full WordPress Dynamic fallback is serving this asset.';
2812 + $attention_items['fallback:'.$type] = $type_label.' is using Full WordPress Dynamic Loader because the selected s2Member-Only Dynamic Loader could not be used.';
2813 + }
2814 + else
2815 + {
2816 + $status = $event['status'];
2817 + $status_label = $event['label'];
2818 + $detail = 's2Member-Only Dynamic Loader.'.(($event['detail']) ? ' '.$event['detail'] : '');
2819 + }
2820 + $rows[] = array('label' => 's2Member-Only '.$type_label, 'delivery' => 'Dynamic', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $s2o_url);
2821 + }
2822 + continue;
2823 + }
2824 +
2825 + $ids = self::static_asset_ids($type, 'all');
2826 + $dynamic_requirement = self::static_type_dynamic_requirement($type);
2827 + if(!empty($dynamic_requirement['required']))
2828 + {
2829 + //260913.2001 A compatibility-required Dynamic route is expected delivery, not an Orange fallback; Full WordPress remains mandatory so the triggering hooks/configuration are present.
2830 + $wp_loader_active[$type] = TRUE;
2831 + $wp_loader_fallback[$type] = FALSE;
2832 + $wp_loader_required[$type] = TRUE;
2833 + $delivery_url = self::wordpress_dynamic_asset_url();
2834 + $delivery_url = ($type === 'css') ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url) : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2835 + $failed = !empty($failures['dynamic:dynamic_'.$type]);
2836 + $status = ($failed) ? 'error' : 'healthy';
2837 + $status_label = ($failed) ? 'Delivery check failed' : 'Healthy';
2838 + //260913.2111 The requirement detail now carries concise, actionable guidance; do not append a generic compatibility sentence to every row.
2839 + $detail = (string)$dynamic_requirement['detail'];
2840 + if($failed)
2841 + {
2842 + $detail .= ' The required Full WordPress Dynamic Loader could not be loaded or confirmed active.';
2843 + $error_notice_items['delivery:'.$type] = $type_label.' requires Full WordPress Dynamic delivery, but that route could not be loaded or verified.';
2844 + }
2845 + $rows[] = array('label' => $type_label.' Delivery', 'delivery' => 'Dynamic required', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
2846 + continue;
2847 + }
2848 +
2849 + $fallback = FALSE;
2850 + $fallback_reasons = array();
2851 + $states = array();
2852 + $generation_failures = array();
2853 +
2854 + //260910.0630 A compatibility/build fallback applies to the whole asset type; stale failures for static files that are no longer being served must not masquerade as current delivery failures.
2855 + foreach($ids as $id)
2856 + {
2857 + $states[$id] = self::static_asset_build($id);
2858 + $generation_failures[$id] = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2859 + $definition = self::static_asset_definition($id, FALSE);
2860 + if(empty($definition['ok']))
2861 + {
2862 + $fallback = TRUE;
2863 + $fallback_reasons[] = (string)$definition['error'];
2864 + }
2865 + else if($generation_failures[$id] && $states[$id] <= 0)
2866 + {
2867 + $fallback = TRUE;
2868 + $fallback_reasons[] = $id.': '.(string)$generation_failures[$id];
2869 + }
2870 + }
2871 +
2872 + if(!$fallback)
2873 + foreach($ids as $id)
2874 + {
2875 + $state = $states[$id];
2876 + $build = abs($state);
2877 + if(!empty($local_health['location']))
2878 + {
2879 + $fallback = TRUE;
2880 + $fallback_reasons[] = (string)$local_health['location'];
2881 + continue;
2882 + }
2883 + if(isset($local_health[$id]))
2884 + {
2885 + $fallback = TRUE;
2886 + $fallback_reasons[] = (string)$local_health[$id];
2887 + continue;
2888 + }
2889 + if($state > 0)
2890 + {
2891 + $base = substr($id, 0, -strlen('.'.$type));
2892 + $url = (!empty($location['ok'])) ? $location['url'].'/'.$base.'-'.$build.'.'.$type : '';
2893 + if($url && self::asset_http_target_failed('static:'.$id, $url))
2894 + {
2895 + $fallback = TRUE;
2896 + $fallback_reasons[] = $id.' could not be loaded from its public URL.';
2897 + }
2898 + }
2899 + }
2900 +
2901 + if($fallback)
2902 + {
2903 + $wp_loader_active[$type] = TRUE;
2904 + $wp_loader_fallback[$type] = TRUE;
2905 + $status = 'attention';
2906 + $status_label = 'Using dynamic fallback';
2907 + $detail = 'Full WordPress Dynamic Loader is being used instead of the requested static '.$type_label.' delivery.';
2908 + if($fallback_reasons)
2909 + $detail .= ' '.implode(' ', array_unique($fallback_reasons));
2910 + $attention_items['fallback:'.$type] = 'Requested static '.$type_label.' delivery is unavailable; Full WordPress Dynamic Loader is being used instead.';
2911 + $delivery_url = self::wordpress_dynamic_asset_url();
2912 + $delivery_url = ($type === 'css') ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url) : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2913 + $event = self::asset_runtime_health_event('dynamic_'.$type);
2914 + if($event['detail'])
2915 + $detail .= ' '.$event['detail'];
2916 + if(!empty($failures['fallback:dynamic_'.$type]) || !empty($failures['dynamic:dynamic_'.$type]))
2917 + {
2918 + $status = 'error';
2919 + $status_label = 'Fallback check failed';
2920 + $detail = 'The requested static '.$type_label.' delivery is unavailable, and the Full WordPress Dynamic fallback could not be loaded or verified.';
2921 + $error_notice_items['delivery:'.$type] = $type_label.' preferred delivery is unavailable and the Full WordPress Dynamic fallback could not be loaded or verified.';
2922 + }
2923 + $rows[] = array('label' => $type_label.' Delivery', 'delivery' => 'Dynamic fallback', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
2924 + continue;
2925 + }
2926 +
2927 + $wp_loader_fallback[$type] = TRUE;
2928 + foreach($ids as $id)
2929 + {
2930 + $state = self::static_asset_build($id);
2931 + $build = abs($state);
2932 + $failure = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2933 + $health_id = self::asset_runtime_health_id($id, $type, 'static');
2934 + $event = self::asset_runtime_health_event($health_id);
2935 + $status = $event['status'];
2936 + $status_label = $event['label'];
2937 + if($state < 0)
2938 + {
2939 + $status = ($status === 'healthy') ? 'delayed' : $status;
2940 + $status_label = ($status === 'delayed' && $event['status'] === 'healthy') ? 'Pending rebuild' : $status_label;
2941 + $detail = 'Static file is pending rebuild; its previous timestamp remains only for already-cached HTML.';
2942 + }
2943 + else
2944 + $detail = ($build > 0) ? 'Static file is current (build '.date_i18n('Y-m-d H:i:s', $build).').' : 'Static file has not been created yet; s2Member will build it automatically.';
2945 + if($failure && $state > 0)
2946 + {
2947 + $status = ($status === 'healthy') ? 'delayed' : $status;
2948 + $status_label = ($status === 'delayed' && $event['status'] === 'healthy') ? 'Rebuild issue' : $status_label;
2949 + $detail .= ' A recent rebuild failed, but the previous valid static file remains active. '.(string)$failure;
2950 + }
2951 + if($event['detail'])
2952 + $detail .= ' '.$event['detail'];
2953 + $base = substr($id, 0, -strlen('.'.$type));
2954 + $url = ($build > 0 && !empty($location['ok'])) ? $location['url'].'/'.$base.'-'.$build.'.'.$type : '';
2955 + $rows[] = array('label' => self::asset_runtime_health_label($health_id), 'delivery' => 'Static', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $url);
2956 + }
2957 + }
2958 +
2959 + $s2o_missing = $selected_s2o && !is_file(self::s2o_file_path());
2960 + $s2o_failed = $selected_s2o && !$s2o_missing && self::asset_http_target_failed('s2o', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
2961 + $s2o_needed = $selected_s2o && ($dynamic_normal['css'] || $dynamic_normal['js']);
2962 + $s2o_problem = $s2o_missing || $s2o_failed;
2963 +
2964 + if($selected_s2o && $s2o_problem && $s2o_needed)
2965 + $attention_items['s2o'] = ($s2o_missing) ? 'The selected s2Member-Only Dynamic Loader file <code>s2member-o.php</code> is missing; Full WordPress Dynamic Loader is being used automatically.' : 'The selected s2Member-Only Dynamic Loader could not be reached; Full WordPress Dynamic Loader is being used automatically.';
2966 +
1304 2967 //260907.2203 Track missing/recovered loader transitions without logging every admin health check.
2968 + //260909.2021 Keep those transitions in css-js.log even when the loader is not currently needed by fully static delivery.
1305 2969 $s2o_missing_logged = (bool)get_option('ws_plugin__s2member_css_js_s2o_missing', FALSE);
1306 2970 if($s2o_missing && !$s2o_missing_logged)
1307 2971 {
1308 2972 update_option('ws_plugin__s2member_css_js_s2o_missing', 1, FALSE);
1309 - c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file missing', 'result' => 'failure', 'file' => self::s2o_file_path(), 'fallback' => 'WordPress Dynamic Loader'));
2973 + c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member-Only Dynamic Loader file missing', 'result' => 'failure', 'file' => self::s2o_file_path(), 'fallback' => 'Full WordPress Dynamic Loader'));
1310 2974 }
1311 - else if($using_s2o && !$s2o_missing && $s2o_missing_logged)
2975 + else if($selected_s2o && !$s2o_missing && $s2o_missing_logged)
1312 2976 {
1313 2977 delete_option('ws_plugin__s2member_css_js_s2o_missing');
1314 - c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file recovered', 'result' => 'recovered', 'file' => self::s2o_file_path()));
2978 + c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member-Only Dynamic Loader file recovered', 'result' => 'recovered', 'file' => self::s2o_file_path()));
1315 2979 }
1316 2980
1317 - if($s2o_missing)
1318 - $messages[] = 'The selected s2Member Dynamic Loader file <code>s2member-o.php</code> is missing. s2Member is using the WordPress Dynamic Loader instead. Restore the file or <a href="'.esc_url($dynamic_settings_url).'">choose the WordPress Dynamic Loader</a>.';
2981 + //260912.1956 Full WordPress is always either the configured dynamic route or the safety-net fallback, so keep its CSS/JS health visible even when static delivery is currently healthy.
2982 + $full_checked = (!empty($http_health['full_checked'])) ? (int)$http_health['full_checked'] : 0;
2983 + foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
2984 + {
2985 + $is_fallback = !empty($wp_loader_fallback[$type]);
2986 + $failure_id = ($is_fallback) ? 'fallback:dynamic_'.$type : 'dynamic:dynamic_'.$type;
2987 + $failed = !empty($failures[$failure_id]);
2988 + $delivery_url = self::wordpress_dynamic_asset_url();
2989 + $delivery_url = ($type === 'css')
2990 + ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url)
2991 + : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2992 + if($failed)
2993 + {
2994 + $status = 'error';
2995 + $status_label = 'Failed';
2996 + $detail = ($is_fallback) ? 'The Full WordPress Dynamic fallback could not be loaded or confirmed active.' : 'The Full WordPress Dynamic response could not be loaded or confirmed active.';
2997 + }
2998 + else if(!$full_checked)
2999 + {
3000 + $status = 'disabled';
3001 + $status_label = 'Not checked yet';
3002 + $detail = 'Recheck Asset Health to verify this delivery route.';
3003 + }
3004 + else if(!empty($wp_loader_active[$type]))
3005 + {
3006 + $event = self::asset_runtime_health_event('dynamic_'.$type);
3007 + $status = $event['status'];
3008 + $status_label = $event['label'];
3009 + if(!empty($wp_loader_required[$type]))
3010 + $detail = 'Full WordPress Dynamic Loader is the active compatibility-required route.';
3011 + else
3012 + $detail = ($is_fallback) ? 'Full WordPress Dynamic Loader is currently serving this asset as fallback.' : 'Full WordPress Dynamic Loader is the configured delivery route.';
3013 + if($event['detail'])
3014 + $detail .= ' '.$event['detail'];
3015 + }
3016 + else
3017 + {
3018 + $status = 'healthy';
3019 + $status_label = 'Healthy';
3020 + $detail = 'Full WordPress Dynamic fallback is available if the preferred delivery route cannot be used.';
3021 + }
3022 + $rows[] = array('label' => 'WP Loader '.$type_label, 'delivery' => ($is_fallback) ? 'Dynamic fallback' : 'Dynamic', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
3023 + }
1319 3024
1320 - $http_health = self::asset_http_health_state();
1321 - $has_confirmed_failure = (bool)($health || $s2o_missing);
1322 - if(is_array($http_health) && !empty($http_health['runtime_warnings']) && is_array($http_health['runtime_warnings']))
1323 - foreach($http_health['runtime_warnings'] as $warning)
1324 - if(!empty($warning['reported']) && (int)$warning['reported'] >= time() - HOUR_IN_SECONDS)
1325 - $messages[] = 'A real frontend page reported that <code>'.esc_html((string)$warning['id']).'</code> did not become active, even though a follow-up browser check could load the expected file and marker. This can indicate script/style optimization, execution order, a browser extension, or another runtime conflict. Delivery has not been changed automatically.';
1326 - if(is_array($http_health) && !empty($http_health['failures']) && is_array($http_health['failures']))
1327 - foreach($http_health['failures'] as $id => $failure)
1328 - {
1329 - $status = (!empty($failure['status'])) ? ' HTTP '.(int)$failure['status'].'.' : '';
1330 - if($id === 's2o' && $using_s2o && !$s2o_missing && !empty($failure['url']) && (string)$failure['url'] === (string)$GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
3025 + //260912.0258 Only the Health Logkeeper mutates the rolling health log; this view reads the merged state without another read/modify/write race.
3026 + $state = self::asset_health_log_state();
3027 + $scores = self::asset_health_scores($state);
3028 + $rolling_score = $scores['score'];
3029 + $current_delivery_result = self::asset_health_current_delivery_result($failures);
3030 + $standby_fallback_failures = array();
3031 + if($full_checked && $current_delivery_result === 'okay')
3032 + foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
3033 + if(!empty($wp_loader_fallback[$type]) && !empty($failures['fallback:dynamic_'.$type]))
1331 3034 {
1332 - $has_confirmed_failure = TRUE;
1333 - $messages[] = 'The selected s2Member Dynamic Loader could not be reached.'.$status.' s2Member is using the WordPress Dynamic Loader instead. <a href="'.esc_url($dynamic_settings_url).'">Review Dynamic CSS/JS Loader</a> or see <a href="https://s2member.com/kb-article/mod-security-odd-403-503-500-errors/">Mod Security (Odd 403, 503, 500 Errors)</a>.';
3035 + $standby_fallback_failures[$type] = TRUE;
3036 + $attention_items['standby-fallback:'.$type] = 'WP Loader '.$type_label.' fallback is unavailable while the preferred '.$type_label.' delivery is still working.';
1334 3037 }
1335 - else if(strpos((string)$id, 'static:') === 0 && !empty($failure['url']) && self::asset_http_target_failed($id, (string)$failure['url']))
1336 - {
1337 - $has_confirmed_failure = TRUE;
1338 - $messages[] = 'A generated static file could not be loaded from its public URL.'.$status.' Pages that need it are using dynamic delivery instead. <a href="'.esc_url($static_settings_url).'">Open Static CSS/JS Optimization</a>.';
1339 - }
1340 - else if(strpos((string)$id, 'runtime:') === 0 && !empty($http_health['checked']) && (int)$http_health['checked'] >= time() - HOUR_IN_SECONDS)
1341 - {
1342 - $has_confirmed_failure = TRUE;
1343 - $messages[] = 'A dynamically generated frontend asset could not be loaded or did not contain its expected completion marker.'.$status.' Review the browser console and your CSS/JavaScript optimization or security settings.';
1344 - }
3038 + //260912.1956 A broken standby fallback can never improve Health: average its failed score with the established rolling score only while preferred delivery itself still works.
3039 + if($standby_fallback_failures && $rolling_score !== NULL)
3040 + $scores['score'] = ($rolling_score + 1.0) / 2;
3041 + $overall = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
3042 + $recent_issues = (!empty($http_health['recent_issues']) && is_array($http_health['recent_issues'])) ? $http_health['recent_issues'] : array();
3043 + //260910.2350 Recent per-asset details explain a non-Green rolling score even when css-js.log is disabled; clear them only after the overall calculated Health is Green and no trusted/pending problem remains.
3044 + if($overall === 'healthy' && !$failures && !$runtime_warnings && !self::asset_runtime_suspicions() && $recent_issues)
3045 + {
3046 + unset($http_health['recent_issues']);
3047 + update_option('ws_plugin__s2member_asset_http_health', $http_health, FALSE);
3048 + self::$asset_http_health_cache = $http_health;
3049 + $recent_issues = array();
3050 + }
3051 + $labels = array(
3052 + 'unknown' => 'Not checked yet',
3053 + 'healthy' => 'Healthy',
3054 + 'delayed' => 'Recent issue',
3055 + 'attention' => 'Working, review suggested',
3056 + 'error' => 'Needs attention',
3057 + );
3058 + $summaries = array(
3059 + 'unknown' => 'No recent frontend asset-load health is available yet. This panel will run a trusted current-delivery check.',
3060 + 'healthy' => 'Recent frontend CSS/JavaScript asset loads are healthy.',
3061 + 'delayed' => 'Recent asset loads are mixed or include late activation, but they do not currently average into degraded delivery.',
3062 + 'attention' => 'Frontend asset delivery is working, but recent results or an unavailable fallback route suggest that the configuration should be reviewed.',
3063 + 'error' => 'Recent asset loads average into serious delivery failure. s2Member forms, buttons, behavior, or styling may currently be affected.',
3064 + );
3065 +
3066 + $not_green_since = (!empty($state['not_green_since'])) ? (int)$state['not_green_since'] : 0;
3067 + $fallback_problem_since = ($standby_fallback_failures && !empty($http_health['fallback_problem_since'])) ? (int)$http_health['fallback_problem_since'] : 0;
3068 + //260912.1956 Keep the existing rolling-health age, but let a continuously unavailable standby fallback start/extend the same non-Healthy review period without creating synthetic page-load events.
3069 + if($overall !== 'healthy' && $fallback_problem_since > 0 && ($not_green_since <= 0 || $fallback_problem_since < $not_green_since))
3070 + $not_green_since = $fallback_problem_since;
3071 + if($overall === 'healthy')
3072 + $not_green_since = 0;
3073 + $six_hour_average = NULL;
3074 + $notice_level = '';
3075 + $notice_items = array();
3076 + $notice_signature = '';
3077 +
3078 + if($overall === 'error' && $not_green_since > 0)
3079 + {
3080 + //260910.0709 Red is immediate because the recent score says delivery is failing badly enough to threaten frontend behavior; no persistence delay is added.
3081 + $notice_level = 'error';
3082 + $notice_items = ($error_notice_items) ? $error_notice_items : array('score' => 'Recent CSS/JavaScript asset loads show repeated delivery failures severe enough that frontend s2Member functionality may be affected.');
3083 + $notice_signature = 'error:'.$not_green_since;
3084 + }
3085 + else if($not_green_since > 0 && $not_green_since <= time() - 6 * HOUR_IN_SECONDS)
3086 + {
3087 + //260912.1956 Reuse the established six-hour review logic; when the standby fallback itself has stayed unavailable for the full period, average its failed score into the retained delivery history just as Current Health does.
3088 + $six_hour_average = self::asset_health_six_hour_average($state);
3089 + if($standby_fallback_failures && $fallback_problem_since > 0 && $fallback_problem_since <= time() - 6 * HOUR_IN_SECONDS)
3090 + {
3091 + if($six_hour_average === NULL)
3092 + $six_hour_average = $rolling_score;
3093 + if($six_hour_average !== NULL)
3094 + $six_hour_average = ($six_hour_average + 1.0) / 2;
1345 3095 }
3096 + if($six_hour_average !== NULL && $six_hour_average <= 2.5)
3097 + {
3098 + $notice_level = 'attention';
3099 + //260911.1705 Keep admin-facing health wording understandable without requiring familiarity with the internal Green/Yellow/Orange/Red state model.
3100 + $notice_items = ($attention_items) ? $attention_items : array('score' => 'CSS/JavaScript asset-load health has remained substantially degraded across the latest six hours without returning to normal.');
3101 + $notice_signature = 'attention:'.$not_green_since;
3102 + }
3103 + }
1346 3104
1347 - if($messages)
1348 - c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member Frontend Asset Notice', implode('<br /><br />', $messages), $has_confirmed_failure);
3105 + //260910.0709 Dismissal is scoped to severity + one continuous non-Green period; changing Orange-review severity to Red surfaces again, while Green clears the old dismissal before another period can begin.
3106 + $dismissed = (string)get_option('ws_plugin__s2member_asset_notice_dismissed', '');
3107 + $active_signatures = array_values(array_filter(array(($not_green_since > 0) ? 'error:'.$not_green_since : '', ($not_green_since > 0) ? 'attention:'.$not_green_since : '')));
3108 + if($dismissed !== '' && !in_array($dismissed, $active_signatures, TRUE))
3109 + delete_option('ws_plugin__s2member_asset_notice_dismissed');
3110 +
3111 + return array(
3112 + 'status' => $overall,
3113 + 'status_label' => $labels[$overall],
3114 + 'summary' => $summaries[$overall],
3115 + 'rows' => $rows,
3116 + 'score' => $scores['score'],
3117 + 'rolling_score' => $rolling_score,
3118 + 'standby_fallback_failures' => array_keys($standby_fallback_failures),
3119 + 'request_score' => $scores['request_score'],
3120 + 'time_score' => $scores['time_score'],
3121 + 'request_count' => $scores['request_count'],
3122 + 'time_count' => $scores['time_count'],
3123 + 'recent_issues' => $recent_issues,
3124 + 'latest_issues' => (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array(),
3125 + 'six_hour_average' => $six_hour_average,
3126 + 'not_green_since' => $not_green_since,
3127 + 'notice_level' => $notice_level,
3128 + 'notice_items' => $notice_items,
3129 + 'notice_signature' => $notice_signature,
3130 + );
3131 + }
3132 +
3133 + /**
3134 + * Dismisses the current frontend-asset notice for the current continuous non-Green health period.
3135 + *
3136 + * @package s2Member\Utilities
3137 + * @since 260909.2021
3138 + *
3139 + * @attaches-to ``add_action('admin_init');``
3140 + * @return null
3141 + */
3142 + public static function dismiss_static_assets_admin_notice()
3143 + {
3144 + if(!is_admin() || !current_user_can('create_users') || empty($_GET['s2member-dismiss-asset-health-notice']))
3145 + return;
3146 +
3147 + check_admin_referer('s2member-dismiss-asset-health-notice');
3148 + $health = self::frontend_asset_health(TRUE);
3149 + if(!empty($health['notice_signature']))
3150 + update_option('ws_plugin__s2member_asset_notice_dismissed', (string)$health['notice_signature'], FALSE);
3151 +
3152 + wp_safe_redirect(wp_get_referer() ? wp_get_referer() : admin_url());
3153 + exit;
3154 + }
3155 +
3156 + /**
3157 + * Displays one non-Green-period-scoped admin-wide notice for red failures or persistent degraded health.
3158 + *
3159 + * Missing or browser-confirmed unreachable frontend assets remain part of the Red diagnosis when usable delivery/fallback also fails; preferred-route failures with a working fallback are not treated as Red.
3160 + * Red means the recent weighted asset loads average into serious failure and is immediate.
3161 + * Orange remains non-alarming unless six hours have passed without Green and the equal-block
3162 + * rolling six-hour average remains below Yellow territory. Current Yellow may therefore still
3163 + * surface the calm review notice when the longer recent history remains substantially degraded.
3164 + *
3165 + * @package s2Member\Utilities
3166 + * @since 260903.0612
3167 + *
3168 + * @attaches-to ``add_action('admin_notices');``
3169 + * @return null
3170 + */
3171 + public static function static_assets_admin_notice()
3172 + {
3173 + if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
3174 + return;
3175 +
3176 + $health = self::frontend_asset_health();
3177 + if(empty($health['notice_items']) || empty($health['notice_signature']) || empty($health['notice_level']))
3178 + return;
3179 + $dismissed = (string)get_option('ws_plugin__s2member_asset_notice_dismissed', '');
3180 + if($dismissed === (string)$health['notice_signature'])
3181 + return;
3182 + //260911.0012 A dismissed Red problem also suppresses the later calmer Orange review notice in the same non-Green period. A dismissed Orange notice never suppresses a later Red escalation.
3183 + if($health['notice_level'] !== 'error' && !empty($health['not_green_since']) && $dismissed === 'error:'.(int)$health['not_green_since'])
3184 + return;
3185 +
3186 + //260911.1707 Give the specific asset-health reason a compact, visually distinct line without requiring familiarity with the internal health-state colors.
3187 + $items = array();
3188 + foreach($health['notice_items'] as $item)
3189 + $items[] = '&bull;&nbsp; <strong><em>'.$item.'</em></strong>';
3190 + $settings_url = add_query_arg('s2member-open-panel', 'frontend-static-assets', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-asset-health';
3191 + $dismiss_url = wp_nonce_url(add_query_arg('s2member-dismiss-asset-health-notice', '1', admin_url()), 's2member-dismiss-asset-health-notice');
3192 +
3193 + $_notice_items = '<span style="display:block; margin:.4em 0 .45em .65em;">'.implode('<br />', $items).'</span>';
3194 + if($health['notice_level'] === 'error')
3195 + {
3196 + $message = 'Recent frontend CSS/JavaScript asset loads average into serious delivery failure. This can affect s2Member forms, buttons, behavior, or styling.'.$_notice_items.'<a href="'.esc_url($settings_url).'">Open CSS/JS Asset Health</a> for the current delivery details and troubleshooting.';
3197 + c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member CSS/JS Asset Delivery Problem', $message, TRUE, $dismiss_url);
3198 + }
3199 + else
3200 + {
3201 + $message = 'Frontend CSS/JavaScript asset-load health has remained substantially degraded across the latest six hours without returning to normal. Delivery may currently be improving or may still be working through fallback. This is a suggestion to review the configuration, not an emergency.'.$_notice_items.'<a href="'.esc_url($settings_url).'">Open CSS/JS Asset Health</a> to review the current delivery details.';
3202 + c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member CSS/JS Asset Health: Review Suggested', $message, FALSE, $dismiss_url);
3203 + }
3204 + unset($_notice_items);
1349 3205 return;
1350 3206 }
1351 3207
1352 3208 /**
@@ -1351,10 +3207,11 @@
1351 3207
1352 3208 /**
1353 3209 * Prints an infrequent trusted browser-side reachability probe for active frontend assets.
1354 3210 *
1355 - * Healthy static files use HEAD. The lightweight loader uses its tiny pre-WordPress health mode.
1356 - * A frontend runtime suspicion forces one full cache-busted marker check for that exact URL.
3211 + * Healthy static files use HEAD.
3212 + * The s2Member-Only Dynamic Loader (s2member-o.php) uses its tiny pre-WordPress health mode.
3213 + * A frontend runtime suspicion forces one full cache-busted activation check for that exact URL.
1357 3214 *
1358 3215 * @package s2Member\Utilities
1359 3216 * @since 260904.2110
1360 3217 *
@@ -1365,18 +3222,24 @@
1365 3222 public static function asset_http_health_probe()
1366 3223 {
1367 3224 if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
1368 3225 return;
1369 - $targets = self::asset_http_health_targets();
3226 +
3227 + $health_log = self::asset_health_log_state();
3228 + $scores = self::asset_health_scores($health_log);
3229 + $health = self::asset_http_health_state();
3230 + $has_failures = is_array($health) && !empty($health['failures']);
3231 + $has_suspicions = (bool)self::asset_runtime_suspicions();
3232 + $current_status = (!empty($health_log['status'])) ? (string)$health_log['status'] : 'unknown';
3233 + $full = self::$asset_health_force_full_probe || !$scores['time_count'] || $has_failures || $has_suspicions || !in_array($current_status, array('unknown', 'healthy'), TRUE);
3234 + $targets = self::asset_http_health_targets($full);
1370 3235 if(!$targets)
1371 3236 return;
1372 3237
1373 3238 $target_hash = self::asset_http_health_target_hash($targets);
1374 - $health = self::asset_http_health_state();
1375 - $has_failures = is_array($health) && !empty($health['failures']);
1376 - $has_suspicions = (bool)self::asset_runtime_suspicions();
1377 - $interval = ($has_failures || $has_suspicions) ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS;
1378 - if(!$has_suspicions && is_array($health) && !empty($health['checked']) && !empty($health['target_hash']) && (string)$health['target_hash'] === $target_hash && (int)$health['checked'] >= time() - $interval)
3239 + $interval = ($full || $has_failures || $has_suspicions) ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS;
3240 + $auto_due = $has_suspicions || !is_array($health) || empty($health['checked']) || empty($health['target_hash']) || (string)$health['target_hash'] !== $target_hash || (int)$health['checked'] < time() - $interval;
3241 + if(!$auto_due && !self::$asset_health_force_full_probe)
1379 3242 return;
1380 3243
1381 3244 $config = array(
1382 3245 'targets' => array_values($targets),
@@ -1383,10 +3246,14 @@
1383 3246 'target_hash' => $target_hash,
1384 3247 'ajax_url' => admin_url('admin-ajax.php'),
1385 3248 'nonce' => wp_create_nonce('ws-plugin--s2member-asset-http-health'),
1386 3249 'reload_on_change' => is_admin(),
3250 + 'full' => (bool)$full,
3251 + 'auto_run' => (bool)$auto_due,
1387 3252 );
1388 - echo '<script type="text/javascript">(function(c){if(!window.fetch||!window.URL||!window.Promise)return;function u(t,i){var x=new URL(t.probe_url,window.location.href),n=Date.now().toString(36)+"-"+i+"-"+Math.random().toString(36).slice(2);x.searchParams.set("s2member_asset_health",n);if(t.mode==="s2o-health")x.searchParams.set("s2member_health_token",n);return{x:x.toString(),n:n}}function ct(r,t){var v=(r.headers.get("content-type")||"").toLowerCase();if(t.type==="css")return v.indexOf("text/css")!==-1;if(t.type==="js")return /(javascript|ecmascript)/.test(v);return v.indexOf("text/plain")!==-1}function f(t,m,i,body){var z=u(t,i);return fetch(z.x,{method:m,cache:"no-store",credentials:"same-origin",headers:{"Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"}}).then(function(r){var h=r.headers.get("x-s2member-health-token")||"",tm=r.headers.get("x-s2member-health-time")||"";if(!body)return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:"",token:z.n,health_token:h,health_time:tm};return r.text().then(function(x){return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:x,token:z.n,health_token:h,health_time:tm}})}).catch(function(){return{ok:false,status:0,content_type:"",text:"",token:z.n,health_token:"",health_time:""}})}function p(t,i){if(t.mode==="s2o-health")return f(t,"GET",i,true).then(function(r){r.ok=r.ok&&r.health_token===r.token&&r.health_time!==""&&r.text.indexOf("s2member-o-health:"+r.token+":"+r.health_time)===0;return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Health marker mismatch"}});if(t.mode==="marker")return f(t,"GET",i,true).then(function(r){if(r.ok&&t.markers)for(var j=0;j<t.markers.length;j++)if(r.text.indexOf(t.markers[j])===-1){r.ok=false;break}return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Expected marker missing"}});return f(t,"HEAD",i,false).then(function(r){if(r.ok)return{id:t.id,ok:true,status:r.status,content_type:r.content_type,detail:""};return f(t,"GET",i+"g",false).then(function(g){return{id:t.id,ok:g.ok,status:g.status,content_type:g.content_type,detail:g.ok?"":"Public URL check failed"}})})}Promise.all(c.targets.map(p)).then(function(results){var body="action="+encodeURIComponent("ws_plugin__s2member_asset_http_health")+"&_ajax_nonce="+encodeURIComponent(c.nonce)+"&target_hash="+encodeURIComponent(c.target_hash)+"&results="+encodeURIComponent(JSON.stringify(results));return fetch(c.ajax_url,{method:"POST",cache:"no-store",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8","Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"},body:body})}).then(function(r){return r.json()}).then(function(j){if(c.reload_on_change&&j&&j.success&&j.data&&j.data.reload)window.location.reload()}).catch(function(){})})('.wp_json_encode($config).');</script>' . "\n";
3253 +
3254 + //260910.0818 The same trusted probe can run automatically for stale/no-current evidence and on demand from the Health panel; only an explicit recheck is allowed to reset active score history after a clean Okay result.
3255 + echo '<script type="text/javascript">(function(c){if(!window.fetch||!window.URL||!window.Promise)return;var b=document.getElementById("ws-plugin--s2member-recheck-asset-health"),s=document.getElementById("ws-plugin--s2member-recheck-asset-health-status"),busy=false;function u(t,i){var x=new URL(t.probe_url,window.location.href),n=Date.now().toString(36)+"-"+i+"-"+Math.random().toString(36).slice(2);x.searchParams.set("s2member_asset_health",n);if(t.mode==="s2o-health")x.searchParams.set("s2member_health_token",n);return{x:x.toString(),n:n}}function ct(r,t){var v=(r.headers.get("content-type")||"").toLowerCase();if(t.type==="css")return v.indexOf("text/css")!==-1;if(t.type==="js")return /(javascript|ecmascript)/.test(v);return v.indexOf("text/plain")!==-1}function f(t,m,i,body){var z=u(t,i);return fetch(z.x,{method:m,cache:"no-store",credentials:"same-origin",headers:{"Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"}}).then(function(r){var h=r.headers.get("x-s2member-health-token")||"",tm=r.headers.get("x-s2member-health-time")||"";if(!body)return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:"",token:z.n,health_token:h,health_time:tm};return r.text().then(function(x){return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:x,token:z.n,health_token:h,health_time:tm}})}).catch(function(){return{ok:false,status:0,content_type:"",text:"",token:z.n,health_token:"",health_time:""}})}function p(t,i){if(t.mode==="s2o-health")return f(t,"GET",i,true).then(function(r){r.ok=r.ok&&r.health_token===r.token&&r.health_time!==""&&r.text.indexOf("s2member-o-health:"+r.token+":"+r.health_time)===0;return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Dynamic Loader health response could not be verified."}});if(t.mode==="activation-tag")return f(t,"GET",i,true).then(function(r){if(r.ok&&t.activation_tags)for(var j=0;j<t.activation_tags.length;j++)if(r.text.indexOf(t.activation_tags[j])===-1){r.ok=false;break}return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Expected asset could not be verified as active."}});return f(t,"HEAD",i,false).then(function(r){if(r.ok)return{id:t.id,ok:true,status:r.status,content_type:r.content_type,detail:""};return f(t,"GET",i+"g",false).then(function(g){return{id:t.id,ok:g.ok,status:g.status,content_type:g.content_type,detail:g.ok?"":"Public URL check failed"}})})}function run(reset,retried){if(busy)return;busy=true;if(reset&&b)b.disabled=true;if(reset&&s)s.textContent="Checking current asset delivery...";Promise.all(c.targets.map(p)).then(function(results){var body="action="+encodeURIComponent("ws_plugin__s2member_asset_http_health")+"&_ajax_nonce="+encodeURIComponent(c.nonce)+"&target_hash="+encodeURIComponent(c.target_hash)+"&full="+(c.full?"1":"0")+"&reset_health="+(reset?"1":"0")+"&results="+encodeURIComponent(JSON.stringify(results));return fetch(c.ajax_url,{method:"POST",cache:"no-store",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8","Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"},body:body})}).then(function(r){return r.json()}).then(function(j){busy=false;if(j&&j.success&&j.data&&j.data.stale){if(!retried&&j.data.targets&&j.data.target_hash){c.targets=j.data.targets;c.target_hash=j.data.target_hash;if(reset&&s)s.textContent=j.data.recheck_message||"Delivery changed. Rechecking...";return run(reset,true)}if(reset&&s)s.textContent="Delivery changed again. Refreshing...";window.location.reload();return}if(reset&&s)s.textContent=(j&&j.success&&j.data&&j.data.recheck_message)?j.data.recheck_message:"Check complete.";if(reset&&b)b.disabled=false;if((reset||c.reload_on_change)&&j&&j.success&&j.data&&j.data.reload)window.location.reload()}).catch(function(){busy=false;if(reset&&b)b.disabled=false;if(reset&&s)s.textContent="The check could not be completed."})}if(b)b.addEventListener("click",function(){run(true,false)},false);if(c.auto_run)run(false,false)})('.wp_json_encode($config).');</script>' . "\n";
1389 3256 return;
1390 3257 }
1391 3258
1392 3259 /**
@@ -1402,12 +3269,14 @@
1402 3269 check_ajax_referer('ws-plugin--s2member-asset-http-health');
1403 3270 if(!current_user_can('create_users'))
1404 3271 wp_send_json_error(array('message' => 'You do not have permission to report s2Member asset health.'), 403);
1405 3272
1406 - $targets = self::asset_http_health_targets();
3273 + $full = !empty($_POST['full']);
3274 + $reset_health = $full && !empty($_POST['reset_health']);
3275 + $targets = self::asset_http_health_targets($full);
1407 3276 $target_hash = self::asset_http_health_target_hash($targets);
1408 3277 if(empty($_POST['target_hash']) || (string)wp_unslash($_POST['target_hash']) !== $target_hash)
1409 - wp_send_json_success(array('stale' => TRUE, 'reload' => FALSE));
3278 + wp_send_json_success(array('stale' => TRUE, 'reload' => FALSE, 'targets' => array_values($targets), 'target_hash' => $target_hash, 'recheck_message' => 'Delivery targets changed. Rechecking current routes...'));
1410 3279
1411 3280 $results = (!empty($_POST['results'])) ? json_decode(wp_unslash($_POST['results']), TRUE) : array();
1412 3281 $by_id = array();
1413 3282 if(is_array($results))
@@ -1417,15 +3286,18 @@
1417 3286
1418 3287 $old = self::asset_http_health_state();
1419 3288 $old_failures = (is_array($old) && !empty($old['failures']) && is_array($old['failures'])) ? $old['failures'] : array();
1420 3289 $runtime_warnings = (is_array($old) && !empty($old['runtime_warnings']) && is_array($old['runtime_warnings'])) ? $old['runtime_warnings'] : array();
3290 + $recent_issues = (is_array($old) && !empty($old['recent_issues']) && is_array($old['recent_issues'])) ? $old['recent_issues'] : array(); //260910.2346 Preserve compact per-asset troubleshooting context across probes until the calculated overall Health returns Green.
1421 3291 foreach($runtime_warnings as $key => $warning)
1422 3292 if(empty($warning['reported']) || (int)$warning['reported'] < time() - HOUR_IN_SECONDS)
1423 3293 unset($runtime_warnings[$key]);
1424 -
1425 3294 $old_runtime_warnings = $runtime_warnings; //260907.2203 Preserve prior warning state so only new trusted transitions are logged.
3295 + $old_health_log = self::asset_health_log_state();
3296 + $old_health_status = (!empty($old_health_log['status'])) ? (string)$old_health_log['status'] : 'unknown';
1426 3297
1427 3298 $failures = array();
3299 + $failure_contexts = array();
1428 3300 $suspicions = self::asset_runtime_suspicions();
1429 3301
1430 3302 foreach($targets as $id => $target)
1431 3303 {
@@ -1439,18 +3311,40 @@
1439 3311 'status' => (!empty($result['status'])) ? (int)$result['status'] : 0,
1440 3312 'content_type' => (!empty($result['content_type'])) ? substr(sanitize_text_field((string)$result['content_type']), 0, 100) : '',
1441 3313 'detail' => (!empty($result['detail'])) ? substr(sanitize_text_field((string)$result['detail']), 0, 160) : '',
1442 3314 );
3315 + $recent_issues = self::add_asset_health_recent_issue($recent_issues, 'failure-'.$failure_id, 'failed', (string)$target['label'], (!empty($failures[$failure_id]['detail'])) ? (string)$failures[$failure_id]['detail'] : 'Trusted browser check failed.', (string)$failures[$failure_id]['url']);
3316 + if(!empty($target['suspicion']) && is_array($target['suspicion']))
3317 + $failure_contexts[$failure_id] = array(
3318 + 'event_time' => (!empty($target['suspicion']['event_time'])) ? (int)$target['suspicion']['event_time'] : time(),
3319 + 'page_id' => (!empty($target['suspicion']['page_id'])) ? (int)$target['suspicion']['page_id'] : 0,
3320 + 'page_path' => (!empty($target['suspicion']['page_path'])) ? (string)$target['suspicion']['page_path'] : '',
3321 + );
3322 + if(strpos($failure_id, 'fallback:') !== 0 && (!isset($old_failures[$failure_id]) || serialize($old_failures[$failure_id]) !== serialize($failures[$failure_id])))
3323 + {
3324 + $_failure_suspicion = (!empty($target['suspicion']) && is_array($target['suspicion'])) ? $target['suspicion'] : array();
3325 + $_failure_page = array('page_id' => (!empty($_failure_suspicion['page_id'])) ? (int)$_failure_suspicion['page_id'] : 0, 'page_path' => (!empty($_failure_suspicion['page_path'])) ? (string)$_failure_suspicion['page_path'] : '');
3326 + $_failure_event_time = (!empty($_failure_suspicion['event_time'])) ? (int)$_failure_suspicion['event_time'] : time();
3327 + self::queue_asset_health_issue_snapshot('failed', (string)$target['label'], (!empty($failures[$failure_id]['detail'])) ? (string)$failures[$failure_id]['detail'] : 'Trusted browser check failed.', array('asset' => $failure_id, 'delivery' => (!empty($_failure_suspicion['delivery'])) ? (string)$_failure_suspicion['delivery'] : ''), $_failure_page, $_failure_event_time);
3328 + }
1443 3329 }
1444 3330 else if(!empty($target['suspicion_key']) && !empty($target['suspicion']))
1445 3331 {
1446 3332 $key = (string)$target['suspicion_key'];
3333 + $previous_warning = (isset($runtime_warnings[$key]) && is_array($runtime_warnings[$key])) ? $runtime_warnings[$key] : array();
3334 + $previous_count = (!empty($previous_warning['count'])) ? max(1, (int)$previous_warning['count']) : (($previous_warning) ? 1 : 0);
1447 3335 $runtime_warnings[$key] = array(
1448 3336 'id' => (string)$target['suspicion']['id'],
1449 3337 'url' => (string)$target['suspicion']['url'],
1450 3338 'delivery' => (string)$target['suspicion']['delivery'],
3339 + 'event_time' => (!empty($target['suspicion']['event_time'])) ? (int)$target['suspicion']['event_time'] : time(),
3340 + 'page_id' => (!empty($target['suspicion']['page_id'])) ? (int)$target['suspicion']['page_id'] : 0,
3341 + 'page_path' => (!empty($target['suspicion']['page_path'])) ? (string)$target['suspicion']['page_path'] : '',
3342 + 'first_reported' => (!empty($previous_warning['first_reported'])) ? (int)$previous_warning['first_reported'] : ((!empty($previous_warning['reported'])) ? (int)$previous_warning['reported'] : time()),
1451 3343 'reported' => time(),
3344 + 'count' => $previous_count + 1,
1452 3345 );
3346 + $recent_issues = self::add_asset_health_recent_issue($recent_issues, 'late-'.(string)$target['suspicion']['id'], 'late', self::asset_runtime_health_label((string)$target['suspicion']['id']), 'A frontend page could not confirm this asset within the configured wait time, but the trusted follow-up check verified the expected asset response.', (string)$target['suspicion']['url'], (string)$target['suspicion']['delivery']);
1453 3347 }
1454 3348 if(!empty($target['suspicion_key']))
1455 3349 unset($suspicions[(string)$target['suspicion_key']]);
1456 3350 }
@@ -1457,14 +3351,18 @@
1457 3351
1458 3352 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
1459 3353
1460 3354 //260907.2203 Keep an operational history of newly confirmed failures, recoveries, and runtime warnings.
3355 + //260910.0818 Routine Okay/Fallback/Late/Failed asset loads stay only in the fixed-size non-autoloaded health log so operational history is not flooded by normal frontend traffic.
1461 3356 foreach($failures as $id => $failure)
1462 3357 if(!isset($old_failures[$id]) || serialize($old_failures[$id]) !== serialize($failure))
1463 - c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
3358 + {
3359 + $_failure_log_context = (!empty($failure_contexts[$id]) && is_array($failure_contexts[$id])) ? $failure_contexts[$id] : array();
3360 + c_ws_plugin__s2member_utils_logs::log_entry('css-js', array_merge(array(
1464 3361 'event' => 'CSS/JS delivery health failure', 'result' => 'failure', 'target' => $id, 'details' => $failure,
1465 - 'fallback' => ($id === 's2o') ? 'WordPress Dynamic Loader' : ((strpos((string)$id, 'static:') === 0) ? 'dynamic delivery' : 'none'),
1466 - ));
3362 + 'fallback' => ($id === 's2o' || strpos((string)$id, 'static:') === 0) ? 'Full WordPress Dynamic Loader' : 'none',
3363 + ), $_failure_log_context));
3364 + }
1467 3365 foreach($old_failures as $id => $failure)
1468 3366 if(!isset($failures[$id]))
1469 3367 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS delivery health recovered', 'result' => 'recovered', 'target' => $id, 'previous_details' => $failure));
1470 3368 foreach($runtime_warnings as $key => $warning)
@@ -1470,18 +3368,62 @@
1470 3368 foreach($runtime_warnings as $key => $warning)
1471 3369 if(!isset($old_runtime_warnings[$key]))
1472 3370 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS runtime warning confirmed', 'result' => 'warning', 'details' => $warning, 'delivery_changed' => FALSE));
1473 3371
1474 - $new_health = array('checked' => time(), 'target_hash' => $target_hash, 'failures' => $failures, 'runtime_warnings' => $runtime_warnings);
3372 + $full_checked = ($full) ? time() : ((!empty($old['full_checked'])) ? (int)$old['full_checked'] : 0);
3373 + $fallback_problem = !empty($failures['fallback:dynamic_css']) || !empty($failures['fallback:dynamic_js']);
3374 + $fallback_problem_since = 0;
3375 + if($full && $fallback_problem)
3376 + $fallback_problem_since = (!empty($old['fallback_problem_since'])) ? (int)$old['fallback_problem_since'] : time();
3377 + else if(!$full && !empty($old['fallback_problem_since']))
3378 + $fallback_problem_since = (int)$old['fallback_problem_since'];
3379 + //260912.1956 Preserve when the complete route set was last checked, and how long a WordPress fallback has stayed unavailable, without turning standby-route health into extra page-load records.
3380 + $new_health = array('checked' => time(), 'full_checked' => $full_checked, 'fallback_problem_since' => $fallback_problem_since, 'target_hash' => $target_hash, 'failures' => $failures, 'runtime_warnings' => $runtime_warnings, 'recent_issues' => $recent_issues);
1475 3381 update_option('ws_plugin__s2member_asset_http_health', $new_health, FALSE);
1476 3382 self::$asset_http_health_cache = $new_health;
1477 - wp_send_json_success(array('failures' => count($failures), 'runtime_warnings' => count($runtime_warnings), 'reload' => serialize($old_failures) !== serialize($failures)));
3383 +
3384 + $load_result = '';
3385 + $recheck_message = '';
3386 + $new_health_status = $old_health_status;
3387 + if($full)
3388 + {
3389 + //260910.0818 A full trusted probe contributes one Okay/Fallback/Failed asset load through the same scoring path as frontend evidence; only an explicit successful admin recheck may start a clean epoch.
3390 + $load_result = self::asset_health_current_delivery_result($failures);
3391 + $standby_fallback_failures = ($load_result === 'okay') ? array_intersect_key($failures, array('fallback:dynamic_css' => TRUE, 'fallback:dynamic_js' => TRUE)) : array();
3392 + $reset_on_ok = $reset_health && $load_result === 'okay' && !$standby_fallback_failures;
3393 + //260913.0102 Trusted failure transitions are queued separately as issue snapshots, so this scored recheck does not duplicate them in Latest Issues.
3394 + self::queue_asset_health_load($load_result, $reset_on_ok);
3395 + //260912.1956 A standby fallback outage is historical issue context, not an extra page-load; queue it once on the failure transition while Current Health applies the separate safety-net score.
3396 + foreach($standby_fallback_failures as $failure_id => $failure)
3397 + if(empty($old_failures[$failure_id]))
3398 + {
3399 + $type_label = (substr($failure_id, -3) === '_js') ? 'JS' : 'CSS';
3400 + self::queue_asset_health_issue_snapshot('fallback-unavailable', 'WP Loader '.$type_label, 'The Full WordPress Dynamic fallback could not be loaded or confirmed active while preferred '.$type_label.' delivery was still working.');
3401 + }
3402 + //260912.0258 Trusted administrator probes run the Health Logkeeper immediately so their response reflects the event just queued; frontend pages remain queue-only.
3403 + self::run_health_logkeeper();
3404 + $new_health_log = self::asset_health_log_state();
3405 + $new_health_status = (!empty($new_health_log['status'])) ? (string)$new_health_log['status'] : 'unknown';
3406 + if($reset_health)
3407 + $recheck_message = ($reset_on_ok) ? 'Current delivery and its fallback are healthy. Recent scoring history was reset.' : (($standby_fallback_failures) ? 'Current delivery is working, but a fallback route is unavailable. Recent health history was kept.' : (($load_result === 'fallback') ? 'Current delivery is working through fallback. Recent health history was kept.' : 'Current delivery still has a failure. Recent health history was kept.'));
3408 + }
3409 +
3410 + $health_changed = serialize($old_failures) !== serialize($failures) || serialize($old_runtime_warnings) !== serialize($runtime_warnings) || $old_health_status !== $new_health_status;
3411 + wp_send_json_success(array(
3412 + 'failures' => count($failures),
3413 + 'runtime_warnings' => count($runtime_warnings),
3414 + 'load_result' => $load_result,
3415 + 'recheck_message' => $recheck_message,
3416 + 'reload' => $health_changed || $reset_health,
3417 + ));
1478 3418 }
1479 3419
1480 3420 /**
1481 - * Prints the late real-page asset marker monitor.
3421 + * Prints the real-page asset activation monitor.
1482 3422 *
1483 - * A normal browser has already finished loading ordinary CSS/JavaScript by window.load, so a short extra grace period is enough to avoid racing normal delivery. Healthy pages make no request. A recoverable miss uses one WordPress fallback request that also carries compact signed diagnostic details.
3423 + * A short configurable wait after window.load avoids racing normal delivery; healthy pages make no runtime-suspicion request.
3424 + * An asset that cannot be confirmed active after that wait is a low-trust timing suspicion only.
3425 + * The real page reports it for a later trusted browser check; unlike the first v260909 monitor, a recoverable miss no longer injects a WordPress fallback request merely because an optimizer may have delayed execution.
1484 3426 *
1485 3427 * @package s2Member\Utilities
1486 3428 * @since 260904.2255
1487 3429 *
@@ -1492,25 +3434,30 @@
1492 3434 {
1493 3435 if(is_admin() || !self::$page_asset_expectations)
1494 3436 return;
1495 3437
3438 + //260912.0304 Queue one page-level asset load now, preserving compact fallback context before healthy traffic can push it out of the rolling score window.
3439 + $page_result = self::page_asset_health_load_result();
3440 + $load_record = self::queue_asset_health_load($page_result, FALSE, array(), self::page_asset_health_issue($page_result));
3441 + $load = (!empty($load_record['load']) && is_array($load_record['load'])) ? $load_record['load'] : array();
3442 +
1496 3443 $expectations = array();
1497 - $recovery = array('css' => '', 'js' => '');
1498 3444 foreach(self::$page_asset_expectations as $expectation)
1499 - {
1500 3445 $expectations[] = array(
1501 3446 (string)$expectation['id'],
1502 3447 (string)$expectation['asset_id'],
1503 3448 (string)$expectation['url'],
1504 3449 (string)$expectation['delivery'],
1505 - (string)$expectation['token'],
3450 + (string)$expectation['tag_value'],
1506 3451 (string)$expectation['signature'],
1507 3452 );
1508 - if(!empty($expectation['recovery_url']) && empty($recovery[(string)$expectation['type']]))
1509 - $recovery[(string)$expectation['type']] = (string)$expectation['recovery_url'];
1510 - }
1511 - $config = array('a' => admin_url('admin-ajax.php'), 'e' => $expectations, 'r' => $recovery, 'd' => 1000);
1512 - echo '<script type="text/javascript" id="ws-plugin--s2member-asset-runtime-monitor">(function(c){function t(e){return /_js$/.test(e[0])?"js":"css"}function p(e){return /^pro_/.test(e[0])?"pro":"framework"}function n(e){var i="ws-plugin--s2member-"+p(e)+"-css-health",o=document.getElementById(i);if(!o){o=document.createElement("span");o.id=i;o.style.cssText="position:absolute;left:-99999px;top:-99999px;width:1px;height:1px;visibility:hidden";(document.body||document.documentElement).appendChild(o)}return o}function ok(e){if(t(e)==="js")return !!(window.ws_plugin__s2member_asset_health&&window.ws_plugin__s2member_asset_health[p(e)+"_js"]===e[4]);return !window.getComputedStyle||String(getComputedStyle(n(e)).zIndex)===e[4]}function u(x,m){var q=Date.now().toString(36)+"-"+Math.random().toString(36).slice(2),j=JSON.stringify(m);if(!window.URL)return x+(x.indexOf("?")<0?"?":"&")+"s2member_asset_recovery="+encodeURIComponent(q)+"&s2member_asset_runtime_suspect="+encodeURIComponent(j);var o=new URL(x,location.href);o.searchParams.set("s2member_asset_recovery",q);o.searchParams.set("s2member_asset_runtime_suspect",j);return o.toString()}function report(m){if(!window.fetch||!m.length)return;fetch(c.a,{method:"POST",cache:"no-store",credentials:"same-origin",keepalive:true,headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:"action=ws_plugin__s2member_asset_runtime_suspect&missing="+encodeURIComponent(JSON.stringify(m))}).catch(function(){})}function recover(m){var ro=[],cm=m.filter(function(e){return t(e)==="css"}),ja=c.e.filter(function(e){return t(e)==="js"}),jm=m.filter(function(e){return t(e)==="js"});if(cm.length){if(c.r.css){var l=document.createElement("link");l.rel="stylesheet";l.href=u(c.r.css,cm);l.onerror=function(){report(cm)};document.head.appendChild(l)}else ro=ro.concat(cm)}if(jm.length){if(ja.length&&jm.length===ja.length&&c.r.js){var s=document.createElement("script");s.src=u(c.r.js,jm);s.async=false;s.onerror=function(){report(jm)};(document.body||document.documentElement).appendChild(s)}else ro=ro.concat(jm)}if(ro.length)report(ro)}function check(){var m=c.e.filter(function(e){return !ok(e)});if(m.length)recover(m)}c.e.filter(function(e){return t(e)==="css"}).forEach(n);function go(){setTimeout(check,c.d)}document.readyState==="complete"?go():addEventListener("load",go,false)})('.wp_json_encode($config).');</script>' . "\n";
3453 + //260912.0522 The wait setting changes only when an asset becomes a Late suspicion; it never delays loading and does not trigger speculative fallback injection.
3454 + $wait_seconds = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['asset_health_wait_seconds'])) ? (int)$GLOBALS['WS_PLUGIN__']['s2member']['o']['asset_health_wait_seconds'] : 3;
3455 + $wait_seconds = max(1, min(60, $wait_seconds));
3456 + $config = array('ajax_url' => admin_url('admin-ajax.php'), 'expectations' => $expectations, 'delay' => $wait_seconds * 1000, 'load' => $load);
3457 +
3458 + //260912.0522 A Late activation is diagnostic evidence, not proof that loading failed; report it without racing an optimizer with a second CSS/JS response.
3459 + echo '<script type="text/javascript" id="ws-plugin--s2member-asset-runtime-monitor">(function(c){function t(e){return /_js$/.test(e[0])?"js":"css"}function n(e){var i="ws-plugin--s2member-asset-health-"+e[0].replace(/_/g,"-"),o=document.getElementById(i);if(!o){o=document.createElement("span");o.id=i;o.style.cssText="position:absolute;left:-99999px;top:-99999px;width:1px;height:1px;visibility:hidden";(document.body||document.documentElement).appendChild(o)}return o}function ok(e){if(t(e)==="js")return !!(window.ws_plugin__s2member_asset_health&&window.ws_plugin__s2member_asset_health[e[0]]===e[4]);return !window.getComputedStyle||String(getComputedStyle(n(e)).zIndex)===e[4]}function report(m){if(!window.fetch||!m.length)return;fetch(c.ajax_url,{method:"POST",cache:"no-store",credentials:"same-origin",keepalive:true,headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:"action=ws_plugin__s2member_asset_runtime_suspect&missing="+encodeURIComponent(JSON.stringify(m))+"&load="+encodeURIComponent(JSON.stringify(c.load||{}))}).catch(function(){})}function check(){var m=c.expectations.filter(function(e){return !ok(e)});if(m.length)report(m)}c.expectations.filter(function(e){return t(e)==="css"}).forEach(n);function go(){setTimeout(check,c.delay)}document.readyState==="complete"?go():addEventListener("load",go,false)})('.wp_json_encode($config).');</script>' . "\n";
1513 3460 return;
1514 3461 }
1515 3462
1516 3463 /**
@@ -1515,17 +3462,19 @@
1515 3462
1516 3463 /**
1517 3464 * Records signed low-trust frontend runtime suspicions without changing delivery state.
1518 3465 *
1519 - * Reports are rate-limited and only force a later trusted administrator-browser confirmation. A recovery request and the standalone AJAX reporter share this validator so successful page-local fallback normally needs no separate reporting request.
3466 + * Reports are rate-limited and only force a later trusted administrator-browser confirmation.
3467 + * The standalone AJAX reporter is now the normal path; the recovery-query validator remains for compatibility with already-cached pages from the first v260909 monitor.
1520 3468 *
1521 3469 * @package s2Member\Utilities
1522 3470 * @since 260905.0009
1523 3471 *
1524 3472 * @param array $missing Missing runtime expectations.
3473 + * @param array $load Optional signed metadata for the page asset load being corrected to Late.
1525 3474 * @return int Number of newly recorded suspicions.
1526 3475 */
1527 - protected static function record_asset_runtime_suspicions($missing = array())
3476 + protected static function record_asset_runtime_suspicions($missing = array(), $load = array())
1528 3477 {
1529 3478 if(!is_array($missing) || !$missing)
1530 3479 return 0;
1531 3480 $missing = array_slice($missing, 0, 4);
@@ -1530,8 +3479,12 @@
1530 3479 return 0;
1531 3480 $missing = array_slice($missing, 0, 4);
1532 3481 $suspicions = self::asset_runtime_suspicions();
1533 3482 $recorded = 0;
3483 + $valid_late_page = FALSE;
3484 + $late_issues = array();
3485 + $page_context = self::verified_asset_health_page_context($load);
3486 + $event_time = (!empty($page_context['page_id']) || !empty($page_context['page_path'])) && !empty($load['load_time']) ? (int)$load['load_time'] : time();
1534 3487 foreach($missing as $expectation)
1535 3488 {
1536 3489 if(is_array($expectation) && isset($expectation[0]) && !isset($expectation['id']))
1537 3490 $expectation = self::expand_asset_runtime_expectation($expectation);
@@ -1540,9 +3493,16 @@
1540 3493 $signature = (string)$expectation['signature'];
1541 3494 unset($expectation['signature']);
1542 3495 if(!hash_equals(self::asset_runtime_expectation_signature($expectation), $signature) || !self::asset_runtime_expectation_is_current($expectation))
1543 3496 continue;
1544 - $key = md5((string)$expectation['id']."\0".(string)$expectation['url']."\0".(string)$expectation['marker']);
3497 + $valid_late_page = TRUE;
3498 + $late_issues[] = array(
3499 + 'asset' => (string)$expectation['id'],
3500 + 'label' => self::asset_runtime_health_label((string)$expectation['id']),
3501 + 'delivery' => (!empty($expectation['delivery'])) ? (string)$expectation['delivery'] : '',
3502 + 'detail' => 's2Member could not confirm that this asset became active within the configured wait time.',
3503 + );
3504 + $key = md5((string)$expectation['id']."\0".(string)$expectation['url']."\0".(string)$expectation['activation_tag']);
1545 3505 if(get_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key))
1546 3506 continue;
1547 3507
1548 3508 $first_report = empty($suspicions[$key]); //260907.2203 Avoid duplicating the same low-trust runtime suspicion in the log.
@@ -1548,16 +3508,20 @@
1548 3508 $first_report = empty($suspicions[$key]); //260907.2203 Avoid duplicating the same low-trust runtime suspicion in the log.
1549 3509
1550 3510 set_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key, 1, MINUTE_IN_SECONDS);
1551 3511 $expectation['reported'] = time();
3512 + $expectation['event_time'] = $event_time;
3513 + $expectation['page_id'] = (!empty($page_context['page_id'])) ? (int)$page_context['page_id'] : 0;
3514 + $expectation['page_path'] = (!empty($page_context['page_path'])) ? (string)$page_context['page_path'] : '';
1552 3515 $expectation['signature'] = $signature;
1553 3516 $suspicions[$key] = $expectation;
1554 3517
1555 - //260907.2203 Record the first frontend runtime suspicion for later troubleshooting and trusted confirmation.
3518 + //260913.0048 Record accepted signed page context with the operational issue so css-js.log can correlate intermittent failures without storing query strings.
1556 3519 if($first_report)
1557 3520 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1558 3521 'event' => 'Frontend CSS/JS runtime issue reported', 'result' => 'suspected', 'asset' => (string)$expectation['id'],
1559 - 'delivery' => (string)$expectation['delivery'], 'url' => (string)$expectation['url'], 'trusted_confirmation_pending' => TRUE,
3522 + 'delivery' => (string)$expectation['delivery'], 'url' => (string)$expectation['url'], 'event_time' => $event_time,
3523 + 'page_id' => (!empty($page_context['page_id'])) ? (int)$page_context['page_id'] : 0, 'page_path' => (!empty($page_context['page_path'])) ? (string)$page_context['page_path'] : '', 'trusted_confirmation_pending' => TRUE,
1560 3524 ));
1561 3525
1562 3526 $recorded++;
1563 3527 }
@@ -1562,8 +3526,15 @@
1562 3526 $recorded++;
1563 3527 }
1564 3528 if($recorded)
1565 3529 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
3530 + if($valid_late_page)
3531 + {
3532 + //260913.0048 One page still contributes one Late score, while all affected physical assets can share that signed page/time context in bounded Latest Issues.
3533 + //260910.2346 Current pages send signed load metadata so Late corrects the original page instead of becoming a second load. Cached first-v260909 pages have no load metadata, so only a newly accepted/rate-limited suspicion contributes standalone Late evidence.
3534 + if($load || $recorded)
3535 + self::queue_asset_health_load('late', FALSE, $load, array('items' => $late_issues));
3536 + }
1566 3537 return $recorded;
1567 3538 }
1568 3539
1569 3540 /**
@@ -1594,12 +3565,48 @@
1594 3565 */
1595 3566 public static function ajax_asset_runtime_suspicion()
1596 3567 {
1597 3568 $missing = (!empty($_POST['missing'])) ? json_decode(wp_unslash($_POST['missing']), TRUE) : array();
1598 - wp_send_json_success(array('recorded' => self::record_asset_runtime_suspicions($missing)));
3569 + $load = (!empty($_POST['load'])) ? json_decode(wp_unslash($_POST['load']), TRUE) : array();
3570 + wp_send_json_success(array('recorded' => self::record_asset_runtime_suspicions($missing, $load)));
1599 3571 }
1600 3572
1601 3573 /**
3574 + * Returns whether an enabled static asset type intentionally requires dynamic delivery for compatibility.
3575 + *
3576 + * A source/build/filesystem failure is not an intentional requirement and remains a real fallback condition.
3577 + *
3578 + * @package s2Member\Utilities
3579 + * @since 260913.2001
3580 + *
3581 + * @param string $type `css` or `js`.
3582 + * @return array Requirement state and site-owner detail.
3583 + */
3584 + protected static function static_type_dynamic_requirement($type = '')
3585 + {
3586 + $type = strtolower((string)$type);
3587 + if(!in_array($type, array('css', 'js'), TRUE))
3588 + return array('required' => FALSE, 'detail' => '');
3589 +
3590 + $required = FALSE;
3591 + $details = array();
3592 + foreach(self::static_asset_ids($type, 'all') as $id)
3593 + {
3594 + $definition = self::static_asset_definition($id, FALSE);
3595 + if(!empty($definition['ok']))
3596 + continue;
3597 + if(empty($definition['dynamic_required']))
3598 + return array('required' => FALSE, 'detail' => '');
3599 + $required = TRUE;
3600 + if(!empty($definition['health_detail']))
3601 + $details[] = (string)$definition['health_detail'];
3602 + else if(!empty($definition['error']))
3603 + $details[] = (string)$definition['error'];
3604 + }
3605 + return array('required' => $required, 'detail' => implode(' ', array_unique($details)));
3606 + }
3607 +
3608 + /**
1602 3609 * Returns the source definition for one currently enabled/compatible generated frontend asset file.
1603 3610 *
1604 3611 * @package s2Member\Utilities
1605 3612 * @since 260903.0525
@@ -1631,9 +3638,20 @@
1631 3638 $hook_dynamic = has_action('ws_plugin__s2member_during_css');
1632 3639 $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_css_required', $hook_dynamic, get_defined_vars());
1633 3640
1634 3641 if($framework_dynamic || $hook_dynamic)
1635 - return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current CSS hooks require legacy dynamic assets');
3642 + {
3643 + //260913.2001 Explain which compatibility condition makes Dynamic delivery intentional instead of collapsing all such cases into a generic fallback error.
3644 + $reasons = array();
3645 + if(has_action('ws_plugin__s2member_before_css'))
3646 + $reasons[] = 'the "ws_plugin__s2member_before_css" hook has a customization';
3647 + if(isset($GLOBALS['wp_filter']['all']))
3648 + $reasons[] = 'WordPress\'s global "all" hook is active';
3649 + if($hook_dynamic)
3650 + $reasons[] = (has_action('ws_plugin__s2member_during_css')) ? 'the "ws_plugin__s2member_during_css" hook has a customization that must remain dynamic' : 'the "ws_plugin__s2member_dynamic_css_required" filter requires dynamic CSS';
3651 + $error = 'Static CSS cannot be used with the current request/configuration because '.implode('; ', array_unique($reasons)).'. Full WordPress Dynamic Loader is required so the current hooks and customizations remain available.';
3652 + return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => $error, 'dynamic_required' => TRUE);
3653 + }
1636 3654 if(!$include_sources)
1637 3655 return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_css_minify']), 'error' => '');
1638 3656
1639 3657 $sources = ($pro_file) ? array() : array(array('file' => $c['dir'].'/src/includes/s2member.css', 'preserve_header' => TRUE));
@@ -1655,13 +3673,13 @@
1655 3673 $page_text = self::static_js_text_delivery() === 'page';
1656 3674 if($page_text && !self::static_js_page_text_supported())
1657 3675 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Loading JavaScript text with each WordPress page requires a current s2Member Pro version');
1658 3676
3677 + $js_api_constants_enabled = (bool)apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE);
1659 3678 if($page_text)
1660 3679 {
1661 3680 //260906.2049 Text and other page-specific values resolve in the normal HTML request, so they do not make the external JavaScript dynamic.
1662 - $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE)
1663 - || has_action('ws_plugin__s2member_before_js_w_globals') || isset($GLOBALS['wp_filter']['all']);
3681 + $framework_dynamic = $js_api_constants_enabled || has_action('ws_plugin__s2member_before_js_w_globals') || isset($GLOBALS['wp_filter']['all']);
1664 3682 }
1665 3683 else
1666 3684 {
1667 3685 $site_locale = (string)get_option('WPLANG');
@@ -1668,10 +3686,9 @@
1668 3686 if(!$site_locale && defined('WPLANG'))
1669 3687 $site_locale = (string)WPLANG;
1670 3688 $site_locale = ($site_locale) ? $site_locale : 'en_US';
1671 3689 $current_locale = (function_exists('determine_locale')) ? (string)determine_locale() : (string)get_locale();
1672 - $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE)
1673 - || has_action('ws_plugin__s2member_before_js_w_globals') || $current_locale !== $site_locale || has_filter('ws_plugin__s2member_files_dir')
3690 + $framework_dynamic = $js_api_constants_enabled || has_action('ws_plugin__s2member_before_js_w_globals') || $current_locale !== $site_locale || has_filter('ws_plugin__s2member_files_dir')
1674 3691 || has_filter('ws_plugin__s2member_min_password_length') || has_filter('ws_plugin__s2member_min_password_strength_code') || has_filter('ws_plugin__s2member_min_password_strength_score')
1675 3692 || isset($GLOBALS['wp_filter']['all']);
1676 3693 }
1677 3694
@@ -1680,9 +3697,47 @@
1680 3697 if($hook_dynamic && !$page_text && self::static_js_builtin_pro_callbacks_supported())
1681 3698 $hook_dynamic = FALSE; //260906.2049 Older Pro releases can still use static-file text when their JavaScript hook contains only known built-ins.
1682 3699
1683 3700 if($framework_dynamic || $hook_dynamic)
1684 - return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current JavaScript hooks/configuration require dynamic assets');
3701 + {
3702 + //260913.2111 Separate page-specific values that JavaScript Text Delivery can move into the page from requirements that still need Full WordPress Dynamic JS.
3703 + $text_delivery_reasons = array();
3704 + $other_reasons = array();
3705 + if($js_api_constants_enabled)
3706 + $other_reasons[] = 'full s2Member JavaScript API constants are enabled by the "ws_plugin__s2member_js_api_constants_enable" filter';
3707 + if(has_action('ws_plugin__s2member_before_js_w_globals'))
3708 + $other_reasons[] = 'the "ws_plugin__s2member_before_js_w_globals" hook has a customization';
3709 + if(!$page_text && isset($current_locale, $site_locale) && $current_locale !== $site_locale)
3710 + $text_delivery_reasons[] = 'the current page language differs from the site default ('.$current_locale.' vs '.$site_locale.')';
3711 + foreach(array(
3712 + 'ws_plugin__s2member_files_dir' => 'the s2Member files directory is generated dynamically',
3713 + 'ws_plugin__s2member_min_password_length' => 'the minimum password length is generated dynamically',
3714 + 'ws_plugin__s2member_min_password_strength_code' => 'the password-strength code is generated dynamically',
3715 + 'ws_plugin__s2member_min_password_strength_score' => 'the password-strength score is generated dynamically',
3716 + ) as $filter => $reason)
3717 + if(!$page_text && has_filter($filter))
3718 + $text_delivery_reasons[] = $reason.' (via hook '.$filter.')';
3719 + if(isset($GLOBALS['wp_filter']['all']))
3720 + $other_reasons[] = 'WordPress\'s global "all" hook is active';
3721 + if(has_filter('ws_plugin__s2member_pro_available_gateways'))
3722 + $other_reasons[] = 'the available Pro gateways are filtered dynamically by "ws_plugin__s2member_pro_available_gateways"';
3723 + if($hook_dynamic && has_action('ws_plugin__s2member_during_js_w_globals') && !self::static_js_builtin_pro_callbacks_supported())
3724 + $other_reasons[] = 'the "ws_plugin__s2member_during_js_w_globals" hook contains a custom, reordered, or unsupported callback';
3725 + if($hook_dynamic && !$text_delivery_reasons && !$other_reasons)
3726 + $other_reasons[] = 'the "ws_plugin__s2member_dynamic_js_required" filter explicitly requires Dynamic JS';
3727 +
3728 + $text_delivery_reasons = array_unique($text_delivery_reasons);
3729 + $other_reasons = array_unique($other_reasons);
3730 + $reasons = array_merge($text_delivery_reasons, $other_reasons);
3731 + $error = 'Static JavaScript requires Dynamic delivery because '.implode('; ', $reasons).'. Full WordPress Dynamic Loader is required so the current hooks, values, and customizations remain available.';
3732 + if($text_delivery_reasons && !$other_reasons)
3733 + $health_detail = ucfirst(implode('; ', $text_delivery_reasons)).'. To keep the external JavaScript static, set <a href="#ws-plugin--s2member-static-js-text-setting">JavaScript Text Delivery</a> to "Load JavaScript text with each WordPress page".';
3734 + else if($text_delivery_reasons && $other_reasons)
3735 + $health_detail = 'Some page-specific JavaScript values require Dynamic JS: '.implode('; ', $text_delivery_reasons).'. Setting <a href="#ws-plugin--s2member-static-js-text-setting">JavaScript Text Delivery</a> to "Load JavaScript text with each WordPress page" lets pages affected only by those values keep using Static JS. Pages where another detected requirement applies will still use Full WordPress Dynamic JS: '.implode('; ', $other_reasons).'.';
3736 + else
3737 + $health_detail = 'Static JavaScript requires Dynamic delivery because '.implode('; ', $other_reasons).'. Full WordPress Dynamic Loader is used so the required hooks and customizations remain available.';
3738 + return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => $error, 'health_detail' => $health_detail, 'dynamic_required' => TRUE);
3739 + }
1685 3740 if($page_text)
1686 3741 {
1687 3742 $data_map_signature = self::static_js_data_map_signature($id);
1688 3743 if(empty($data_map_signature['ok']))
@@ -1985,10 +4040,10 @@
1985 4040 catch(Exception $e)
1986 4041 {
1987 4042 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'JavaScript minification failed: '.$e->getMessage());
1988 4043 }
1989 - $marker = self::static_asset_marker_output($id, $type, $build);
1990 - $output = (($headers) ? implode("\n", $headers)."\n" : '').$body."\n".$marker."\n";
4044 + $activation_tag = self::static_activation_tag_snippet($id, $type, $build);
4045 + $output = (($headers) ? implode("\n", $headers)."\n" : '').$body."\n".$activation_tag."\n";
1991 4046 $tmp = $path.'.tmp-'.uniqid('', TRUE);
1992 4047 if(file_put_contents($tmp, $output, LOCK_EX) === FALSE || (!@rename($tmp, $path) && !is_file($path)))
1993 4048 {
1994 4049 @unlink($tmp);