diff --git a/packages/vitec/Classes/Command/SsrWarmCommand.php b/packages/vitec/Classes/Command/SsrWarmCommand.php
new file mode 100644
index 0000000..c53b786
--- /dev/null
+++ b/packages/vitec/Classes/Command/SsrWarmCommand.php
@@ -0,0 +1,36 @@
+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'] ? 'OK ' : 'FAILED ') . $result['message']);
+ return $result['success'] ? Command::SUCCESS : Command::FAILURE;
+ }
+}
diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php
index b10a7b5..485ba42 100644
--- a/packages/vitec/Classes/Controller/Backend/SsrController.php
+++ b/packages/vitec/Classes/Controller/Backend/SsrController.php
@@ -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).
diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php
index c26d643..b198583 100644
--- a/packages/vitec/Classes/Service/SsrPurgeService.php
+++ b/packages/vitec/Classes/Service/SsrPurgeService.php
@@ -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.
diff --git a/packages/vitec/Classes/UserFunc/ProductFeatureJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductFeatureJsonRenderer.php
index 73fe9b2..a79016c 100644
--- a/packages/vitec/Classes/UserFunc/ProductFeatureJsonRenderer.php
+++ b/packages/vitec/Classes/UserFunc/ProductFeatureJsonRenderer.php
@@ -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 '';
}
}
}
diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php
index debfeed..807da14 100644
--- a/packages/vitec/Configuration/Backend/Modules.php
+++ b/packages/vitec/Configuration/Backend/Modules.php
@@ -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'],
+ ],
],
],
];
diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
index b454fb2..e7b68ce 100755
--- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
+++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
@@ -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
}
}
}
diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
index 30afc17..8655773 100644
--- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html
+++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
@@ -32,6 +32,33 @@
+
+
Cache warming
+
+
+
+ running
+ {health.warming.done} of {health.warming.queued} pages rendered
+ ({health.warming.skipped} still fresh, {health.warming.failed} failed)
+ Reload this module to follow the progress.
+
+
+
+
+ Last run: {health.warming.done} of {health.warming.queued} pages rendered
+ ({health.warming.skipped} still fresh,
+
+ {health.warming.failed} failed
+ 0 failed
+ )
+ aborted by restart
+
+ no run since the server started
+
+
+
+
+
Purge configuration
@@ -60,6 +87,14 @@
— needed after menu/footer changes while auto-purge is off
+
+