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

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'vitec:ssr-warm',
description: 'Start a background warm run on the SSR node (all sitemap pages, or the given paths)',
)]
final class SsrWarmCommand extends Command
{
public function __construct(private readonly SsrPurgeService $purgeService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to warm; none = every page of sitemap.xml');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$result = $this->purgeService->warm((array)$input->getArgument('paths'));
$output->writeln(($result['success'] ? '<info>OK</info> ' : '<error>FAILED</error> ') . $result['message']);
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -102,6 +102,22 @@ final class SsrController
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* "Warm cache" button: asks the node to render every page of the
* sitemap in the background (0066-FE). Returns at once; progress is
* the "Cache warming" row of the status table.
*/
public function warmAction(ServerRequestInterface $request): ResponseInterface
{
$result = $this->purgeService->warm();
$this->flash(
$result['message'],
$result['success'] ? 'Cache warming' : 'Cache warming failed',
$result['success']
);
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
* the page tree context menu item (ssr-context-menu.js).
@@ -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.
@@ -21,6 +21,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* Extends ProductListJsonRenderer purely to reuse its serializeProduct()
* and image handling - same filters, same shape, one source of truth.
*
* "No product" returns an EMPTY STRING, never json_encode(null): headless
* only decodes strings that look like objects/arrays, so the text "null"
* went out as the string "null" and crashed the front end (ticket
* 0068-FE). The TypoScript sets ifEmptyReturnNull = 1 on this field,
* which turns the empty string into a real JSON null.
*/
final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
{
@@ -33,7 +39,7 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
$productUid = (int)($flexFormData['settings']['product'] ?? 0);
if ($productUid <= 0) {
return (string)json_encode(null);
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -47,12 +53,12 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
)
->executeQuery()->fetchAssociative();
if (!$product) {
return (string)json_encode(null);
return '';
}
return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
return (string)json_encode(null);
return '';
}
}
}