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:
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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,10 @@ return [
|
||||
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgeAction',
|
||||
'methods' => ['POST'],
|
||||
],
|
||||
'warm' => [
|
||||
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::warmAction',
|
||||
'methods' => ['POST'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -77,6 +77,9 @@ tt_content {
|
||||
fields {
|
||||
productfeature = USER
|
||||
productfeature.userFunc = Evomedien\Vitec\UserFunc\ProductFeatureJsonRenderer->render
|
||||
# No product selected/visible -> real JSON null, not
|
||||
# the string "null" (ticket 0068-FE).
|
||||
productfeature.ifEmptyReturnNull = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,33 @@
|
||||
</f:if>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cache warming</th>
|
||||
<td>
|
||||
<f:if condition="{health.warming.running}">
|
||||
<f:then>
|
||||
<span class="badge badge-info">running</span>
|
||||
{health.warming.done} of {health.warming.queued} pages rendered
|
||||
({health.warming.skipped} still fresh, {health.warming.failed} failed)
|
||||
<br><small>Reload this module to follow the progress.</small>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<f:if condition="{health.warming.startedAt}">
|
||||
<f:then>
|
||||
Last run: {health.warming.done} of {health.warming.queued} pages rendered
|
||||
({health.warming.skipped} still fresh,
|
||||
<f:if condition="{health.warming.failed}">
|
||||
<f:then><span class="badge badge-danger">{health.warming.failed} failed</span></f:then>
|
||||
<f:else>0 failed</f:else>
|
||||
</f:if>)
|
||||
<f:if condition="{health.warming.aborted}"> <span class="badge badge-warning">aborted by restart</span></f:if>
|
||||
</f:then>
|
||||
<f:else><span class="text-muted">no run since the server started</span></f:else>
|
||||
</f:if>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Purge configuration</th>
|
||||
<td>
|
||||
@@ -60,6 +87,14 @@
|
||||
<button type="submit" class="btn btn-warning">Purge everything (*)</button>
|
||||
<small class="text-muted"> — needed after menu/footer changes while auto-purge is off</small>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top:24px">Warm cache</h3>
|
||||
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post"
|
||||
onsubmit="return confirm('This renders every sitemap page (800+) in the background and sends several thousand requests to TYPO3 within a few minutes. Continue?');">
|
||||
<button type="submit" class="btn btn-success">Warm cache (all sitemap pages)</button>
|
||||
<small class="text-muted"> — pre-renders every page in the background, so no visitor has to
|
||||
wait for a first render. Useful after "Purge everything" or a deploy; takes a few minutes.</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user