SSR switch: site setting vitec.ssr.enabled gates .htaccess proxy to SSR node

- Site set Vitecset: new bool setting vitec.ssr.enabled (default off,
  backend switch under Site Management > Settings > VITEC Settings)
- SsrFlagService derives var/vitec-ssr-on.flag from the setting;
  SyncSsrFlag middleware (frontend + backend) re-syncs per request
  because TYPO3 v14 fires no event on site settings save;
  vitec:ssr-flag CLI command for status/--sync
- public/.htaccess: flag-gated block proxies page requests [P] to
  https://vitec-ssr.evomedien.de (mod_proxy incl. https target verified
  on this host 2026-11-09), same exclusions as the SPA fallback block;
  completely inert while the flag file is absent
- Emergency off without TYPO3: delete var/vitec-ssr-on.flag
- Tested end to end: flag on -> pages proxied to VPS Caddy (502 while
  the SSR node is not deployed yet), /_json untouched; flag off ->
  SPA shell exactly as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-09 13:35:27 +01:00
1 parent bd7a145afa
commit 2c0da281bd
6 files changed
+242

No files matched your search

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Shows (and on --sync repairs) the SSR routing state: the Site Set
* switch "vitec.ssr.enabled" versus the flag file that .htaccess
* evaluates. The SyncSsrFlag middleware does the same sync on every
* request, so this command is mainly for diagnostics and scripting.
*
* vendor/bin/typo3 vitec:ssr-flag # status only
* vendor/bin/typo3 vitec:ssr-flag --sync # align flag with setting
*/
#[AsCommand(name: 'vitec:ssr-flag', description: 'Show/sync the SSR routing flag against the vitec.ssr.enabled site setting')]
final class SsrFlagCommand extends Command
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('sync', null, InputOption::VALUE_NONE, 'Create/remove the flag file so it matches the setting');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$setting = $this->ssrFlagService->isEnabledInSettings();
$flag = $this->ssrFlagService->isFlagSet();
$output->writeln('Setting vitec.ssr.enabled: ' . ($setting ? 'ON' : 'off'));
$output->writeln('Flag ' . $this->ssrFlagService->getFlagPath() . ': ' . ($flag ? 'EXISTS (SSR routing active)' : 'absent (SPA as before)'));
if ($setting === $flag) {
$output->writeln('<info>In sync.</info>');
return Command::SUCCESS;
}
if (!$input->getOption('sync')) {
$output->writeln('<comment>OUT OF SYNC - run with --sync (or just hit any page: the middleware repairs it).</comment>');
return Command::FAILURE;
}
$new = $this->ssrFlagService->sync();
$output->writeln('<info>Synced: flag is now ' . ($new ? 'SET - SSR routing active.' : 'removed - SPA as before.') . '</info>');
return Command::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Service\SsrFlagService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Self-healing sync of the SSR routing flag (see SsrFlagService).
*
* Registered for BOTH stacks: backend, so flipping the Site Set switch
* takes effect with the very next backend request (the module reload
* right after saving), and frontend, so /_json traffic repairs the flag
* even if nobody is logged into the backend. When flag and setting
* agree - the normal case - this costs a single file_exists() per
* request.
*/
final class SyncSsrFlag implements MiddlewareInterface
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->ssrFlagService->syncQuietly();
return $handler->handle($request);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Keeps the SSR routing flag file in sync with the site setting
* "vitec.ssr.enabled" (Site Set switch, editable in the backend under
* Site Management > 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"
);
} else {
@unlink($this->getFlagPath());
}
return $desired;
}
/**
* 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.
}
}
}
@@ -46,5 +46,23 @@ return [
'typo3/cms-frontend/site',
],
],
// Self-healing sync of the SSR routing flag file against the
// Site Set switch vitec.ssr.enabled (no core event fires on
// settings save, hence per-request sync; cheap file_exists when
// nothing changed). Also registered for the backend below.
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
],
'backend' => [
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
],
];
@@ -132,3 +132,10 @@ settings:
category: seo
type: string
default: ''
vitec.ssr.enabled:
label: 'SSR delivery (serve pages from the SSR node)'
description: 'ON: page requests are proxied by .htaccess to the SSR server (React Router SSR on the Solr VPS) - the flag file var/vitec-ssr-on.flag is kept in sync automatically within seconds. OFF: pages are served as today (SPA shell from _frontend/). JSON under /_json, /typo3, /api etc. are never affected. Safe rollback switch.'
category: VITEC
type: bool
default: false
+23
View File
@@ -345,6 +345,29 @@ AddDefaultCharset utf-8
RewriteCond %{QUERY_STRING} __typo3_install
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L]
# --- VITEC SSR routing (flag-gated) -----------------------------------
# Active ONLY while the flag file below exists. Mirrors the SPA
# fallback's exclusions but PROXIES page requests to the SSR node on
# the Solr VPS instead of serving the React shell (mod_proxy incl.
# https target verified on this host 2026-11-09, see Nextcloud
# vitec-solr/ssr/README-SSR-Setup.md). The flag is written/removed by
# EXT:vitec (Site Set switch "vitec.ssr.enabled" -> SsrFlagService,
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
# Emergency off without TYPO3: delete the flag file.
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L]
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteCond %{REQUEST_URI} !^/_json(/|$)
RewriteCond %{REQUEST_URI} !^/typo3(/|$)
RewriteCond %{REQUEST_URI} !^/download/
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/_frontend/
RewriteCond %{REQUEST_URI} !^/sitemap
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L]
# ----------------------------------------------------------------------
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
# would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/.