From 8f8ae039d458ebf4556640a32abd53d3e874b7a5 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 10 Nov 2026 10:59:24 +0100 Subject: [PATCH] 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 --- .../vitec/Classes/Command/SsrWarmCommand.php | 36 +++++++++ .../Controller/Backend/SsrController.php | 16 ++++ .../vitec/Classes/Service/SsrPurgeService.php | 73 +++++++++++++++++++ .../UserFunc/ProductFeatureJsonRenderer.php | 12 ++- .../vitec/Configuration/Backend/Modules.php | 4 + .../Sets/Vitecset/setup.typoscript | 3 + .../Private/Templates/Ssr/Index.html | 35 +++++++++ 7 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 packages/vitec/Classes/Command/SsrWarmCommand.php 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 + +

Warm cache

+
+ + — 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. +