Settings). * * The flag file is what public/.htaccess checks per request (RewriteCond * ... -f): if it exists, page requests are proxied to the SSR node on the * Solr VPS; if not, the SPA fallback serves _frontend/index.html as * before. TYPO3 itself never routes differently - the flag only steers * Apache, which is why a plain file is used: zero runtime cost in the * hot path and trivially inspectable on the server. * * Saving site settings in the TYPO3 v14 backend fires NO event (core * SiteWriter::writeSettings dispatches nothing, checked 2026-11-09), so * the sync runs self-healingly from the SyncSsrFlag middleware on every * frontend and backend request - a mismatch costs one file operation * once, agreement costs a file_exists(). The vitec:ssr-flag CLI command * offers the same sync plus a status view. */ final class SsrFlagService { public function __construct(private readonly SiteFinder $siteFinder) { } public function getFlagPath(): string { return Environment::getVarPath() . '/vitec-ssr-on.flag'; } /** * True as soon as ANY site enables the setting (there is only the * "vitec" site; iterating keeps us independent of the identifier). */ public function isEnabledInSettings(): bool { try { foreach ($this->siteFinder->getAllSites() as $site) { if ((bool)$site->getSettings()->get('vitec.ssr.enabled', false)) { return true; } } } catch (SiteNotFoundException) { // No sites configured - treat as disabled. } return false; } public function isFlagSet(): bool { return is_file($this->getFlagPath()); } /** * Creates or removes the flag file so it matches the setting. * * @return bool|null The new state if something changed, null if the * flag already matched the setting. */ public function sync(): ?bool { $desired = $this->isEnabledInSettings(); if ($desired === $this->isFlagSet()) { return null; } if ($desired) { file_put_contents( $this->getFlagPath(), "SSR routing is ON (site setting vitec.ssr.enabled).\n" . "Managed by SsrFlagService - do not create/delete manually,\n" . "flip the switch in Site Management > Settings instead.\n" . 'Last sync: ' . date('c') . "\n" ); // Fresh ON: whatever the node cached while the switch was OFF // is stale by definition - drop its whole page cache once. \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class) ->purgeQuietly(['*'], 'switch-on'); } else { @unlink($this->getFlagPath()); } return $desired; } /** * Auto-failover (vitec:ssr-health): turns SSR delivery hard OFF. * * Removing only the flag file would not stick - the self-healing * SyncSsrFlag middleware recreates it from the still-true site setting * on the very next request. So the setting itself is flipped to false * in config/sites//settings.yaml (the file the backend * settings editor writes, flat dotted keys), caches are flushed so the * compiled site settings forget the old value, and only then the flag * is removed. Re-enabling is deliberately manual (backend switch or * settings.yaml + cache flush). */ public function disableSsr(): void { $changed = false; foreach ($this->siteFinder->getAllSites() as $site) { $file = Environment::getConfigPath() . '/sites/' . $site->getIdentifier() . '/settings.yaml'; if (!is_file($file)) { continue; } $yaml = (string)file_get_contents($file); $new = preg_replace('/^(vitec\.ssr\.enabled\s*:).*$/m', '$1 false', $yaml, 1, $count); if ($new !== null && $count > 0 && $new !== $yaml) { if (file_put_contents($file, $new) === false) { throw new \RuntimeException('Could not write ' . $file, 1762800001); } $changed = true; } } if (!$changed && $this->isEnabledInSettings()) { throw new \RuntimeException( 'vitec.ssr.enabled not found in any config/sites/*/settings.yaml - cannot disable it, ' . 'and the SyncSsrFlag middleware would recreate the flag from the setting', 1762800002 ); } if ($changed) { GeneralUtility::makeInstance(CacheManager::class)->flushCaches(); } @unlink($this->getFlagPath()); } /** * Sync variant for the request hot path: never lets an IO hiccup * (read-only FS, concurrent unlink ...) break request handling. */ public function syncQuietly(): void { try { $this->sync(); } catch (\Throwable) { // Intentionally silent - next request retries. } } }