SSR control center: auto-purge webhook, dashboard tiles, backend module
Package 1: SsrPurgeService posts to the node /__purge (endpoint+secret in var/vitec-ssr-purge.env, outside git) and logs every attempt to tx_vitec_ssrpurgelog. DataHandler hooks queue per request and flush once: pages with nav-relevant field changes and all tx_news_/tx_vitec_ records purge everything, other page/content edits purge the hosting page path. Gated by the routing flag and the new Site Set switch vitec.ssr.autoPurge. Flipping the SSR switch ON purges everything once. CLI: vitec:ssr-purge, vitec:ssr-health (samples /__health into tx_vitec_ssrhealthlog, 14 days kept, scheduler-ready). Package 2: dashboard widgets vitecSsrStatus (live health, release, switch, last purge) and vitecSsrStats (purge counts, failures, uptime, response time). Package 3: backend module web_vitecssr with manual purge (paths or everything) and both protocols. Tested end to end against the running node (purge single/all 200, health sample logged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d52d49974f
commit
63f950f9db
14 files changed
+1003
No files matched your search
@@ -81,6 +81,10 @@ final class SsrFlagService
|
||||
. "flip the switch in Site Management > Settings instead.\n"
|
||||
. 'Last sync: ' . date('c') . "\n"
|
||||
);
|
||||
// Fresh ON: whatever the node cached while the switch was OFF
|
||||
// is stale by definition - drop its whole page cache once.
|
||||
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class)
|
||||
->purgeQuietly(['*'], 'switch-on');
|
||||
} else {
|
||||
@unlink($this->getFlagPath());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||
|
||||
/**
|
||||
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
|
||||
* keeps a log of every attempt in tx_vitec_ssrpurgelog.
|
||||
*
|
||||
* Endpoint + secret live OUTSIDE the repository in var/vitec-ssr-purge.env
|
||||
* (KEY=VALUE lines: SSR_PURGE_ENDPOINT, SSR_PURGE_SECRET, SSR_HEALTH_ENDPOINT)
|
||||
* - same pattern as the routing flag file, nothing secret in git.
|
||||
*/
|
||||
final class SsrPurgeService
|
||||
{
|
||||
private ?array $config = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestFactory $requestFactory,
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
public static function getConfigPath(): string
|
||||
{
|
||||
return Environment::getVarPath() . '/vitec-ssr-purge.env';
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$c = $this->getConfig();
|
||||
return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== '';
|
||||
}
|
||||
|
||||
public function getHealthEndpoint(): string
|
||||
{
|
||||
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user