Real JSON null for empty product feature; SSR warm-cache button and CLI

Ticket 0068-FE: ProductFeatureJsonRenderer returned json_encode(null), which headless passes on as the STRING null (it only decodes object/array-looking strings) and crashed the front end. It now returns an empty string and the TypoScript field sets ifEmptyReturnNull = 1, giving a real JSON null. No other renderer used the pattern. Ticket 0066-FE: SsrPurgeService::warm() posts to the node /__warm endpoint (derived from SSR_PURGE_ENDPOINT, same secret), exposed as a Warm cache button with confirmation in the VITEC SSR module, a Cache warming progress row fed by /__health, and the CLI vitec:ssr-warm [paths]. Warm runs are deliberately not written to the purge log. Verified: two-path run 2/2 done, affected page delivers productfeature null.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5.2 committed 2026-11-10 10:59:24 +01:00
1 parent cb53ff67ee
commit 8f8ae039d4
7 files changed
+176 -3

No files matched your search

@@ -76,6 +76,79 @@ final class SsrPurgeService
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.