getConfig(); return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== ''; } public function getHealthEndpoint(): string { return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? ''; } /** * The /__stats sibling of the health endpoint (cumulative hit/miss/ * error counters, in the node since release 20261109-211544). Derived * from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly. */ public function getStatsEndpoint(): string { $explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? ''; if ($explicit !== '') { return $explicit; } $health = $this->getHealthEndpoint(); return $health === '' ? '' : str_replace('/__health', '/__stats', $health); } /** * Comma-separated recipient list for vitec:ssr-health transition and * failover alerts (SSR_ALERT_EMAIL); empty string = alerting off. */ public function getAlertEmail(): string { return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? ''); } /** * Consecutive DOWN samples after which vitec:ssr-health fails over to * the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute * cron cadence). Never below 1. */ public function getFailoverAfter(): int { return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2)); } /** * POST /__warm on the node (contract 0066-FE), derived from * SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly. */ public function getWarmEndpoint(): string { $explicit = $this->getConfig()['SSR_WARM_ENDPOINT'] ?? ''; if ($explicit !== '') { return $explicit; } $purge = $this->getConfig()['SSR_PURGE_ENDPOINT'] ?? ''; return $purge === '' ? '' : str_replace('/__purge', '/__warm', $purge); } /** * Starts a background warm run on the node: no paths (or '*') = every * page of sitemap.xml, otherwise exactly the given paths. The node * answers at once and renders in the background; progress shows up as * "warming" in /__health and /__stats. Not written to the purge log - * a warm run evicts nothing, and the log feeds the purge statistics. * * @param string[] $paths * @return array{success:bool, httpCode:int, message:string, data:array} */ public function warm(array $paths = []): array { if (!$this->isConfigured()) { return ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'data' => []]; } $paths = $this->normalize($paths); $payload = ($paths === [] || $paths === ['*']) ? ['source' => 'sitemap'] : ['paths' => $paths]; try { $response = $this->requestFactory->request($this->getWarmEndpoint(), 'POST', [ 'headers' => [ 'X-Purge-Secret' => $this->getConfig()['SSR_PURGE_SECRET'], 'Content-Type' => 'application/json', ], 'body' => json_encode($payload, JSON_THROW_ON_ERROR), // The node reads the sitemap (index + sub-sitemaps) before // it answers; the pages themselves are not awaited. 'timeout' => 30, 'connect_timeout' => 3, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = (string)$response->getBody(); $data = json_decode($body, true); $data = is_array($data) ? $data : []; if ($code === 200 && !empty($data['started'])) { $message = sprintf( 'Warm run started: %d pages queued (source: %s, %d at a time)', (int)($data['queued'] ?? 0), (string)($data['source'] ?? '?'), (int)($data['concurrency'] ?? 0) ); return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data]; } if ($code === 200 && !empty($data['running'])) { $w = (array)($data['warming'] ?? []); $message = sprintf( 'A warm run is already active: %d of %d pages done', (int)($w['done'] ?? 0) + (int)($w['skipped'] ?? 0) + (int)($w['failed'] ?? 0), (int)($w['queued'] ?? 0) ); return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data]; } $message = 'HTTP ' . $code . ' - ' . (string)($data['error'] ?? substr($body, 0, 200)); return ['success' => false, 'httpCode' => $code, 'message' => $message, 'data' => $data]; } catch (\Throwable $e) { return ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'data' => []]; } } /** * Normalizes, dedupes and sends the paths; logs the outcome. * Never throws - editors' saves must not fail on a purge problem. * * @param string[] $paths page paths, or ['*'] for everything * @return array{success:bool, httpCode:int, message:string, paths:string[]} */ public function purge(array $paths, string $trigger): array { $paths = $this->normalize($paths); if ($paths === []) { return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []]; } if (!$this->isConfigured()) { $result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths]; $this->log($trigger, $result, 0); return $result; } $c = $this->getConfig(); $start = microtime(true); try { $response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [ 'headers' => [ 'X-Purge-Secret' => $c['SSR_PURGE_SECRET'], 'Content-Type' => 'application/json', ], 'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR), 'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = substr((string)$response->getBody(), 0, 200); $result = [ 'success' => $code === 200, 'httpCode' => $code, 'message' => $body, 'paths' => $paths, ]; } catch (\Throwable $e) { $result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths]; } $this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000)); return $result; } public function purgeQuietly(array $paths, string $trigger): void { try { $this->purge($paths, $trigger); } catch (\Throwable) { // Logged inside purge(); never disturb the caller. } } /** * Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query * stripped, no trailing slash (root stays '/'), '*' wins over all. * * @param string[] $paths * @return string[] */ public function normalize(array $paths): array { $out = []; foreach ($paths as $p) { $p = trim((string)$p); if ($p === '') { continue; } if ($p === '*') { return ['*']; } $p = parse_url($p, PHP_URL_PATH) ?: '/'; if ($p !== '/' ) { $p = '/' . trim($p, '/'); } $out[$p] = true; } return array_keys($out); } private function getConfig(): array { if ($this->config !== null) { return $this->config; } $this->config = []; $file = self::getConfigPath(); if (is_readable($file)) { foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) { continue; } [$k, $v] = explode('=', $line, 2); $this->config[trim($k)] = trim($v); } } return $this->config; } private function log(string $trigger, array $result, int $durationMs): void { try { $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert( 'tx_vitec_ssrpurgelog', [ 'crdate' => time(), 'trigger_source' => substr($trigger, 0, 100), 'paths' => implode(', ', $result['paths']), 'success' => $result['success'] ? 1 : 0, 'http_code' => $result['httpCode'], 'message' => substr($result['message'], 0, 255), 'duration_ms' => $durationMs, ] ); } catch (\Throwable) { // Table missing (before schema update) must not break anything. } } }