From 0bd9c347a6e03e5eff331ef22a23270e406e5bf7 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Sat, 12 Sep 2026 15:53:48 +0200 Subject: [PATCH 1/9] Fix confirmed SSR bugs: header injection, cache-behind-breaker, and fail-open footguns. Validate tokens and preset identifiers, forward locale/deviceClass, serve warm cache when the breaker is open, and distinguish client errors from sidecar outages. --- CHANGELOG.md | 11 +++ README.md | 26 ++++-- src/Embed/EmbedRequest.php | 47 +++++++++- src/Embed/NativeSsrPurgeTransport.php | 6 +- src/Embed/NativeSsrTransport.php | 37 ++++---- src/Embed/Renderer.php | 92 +++++++++++--------- src/Embed/SsrClientError.php | 13 +++ src/Embed/SsrException.php | 12 +++ src/Embed/SsrPublish.php | 63 ++++++++------ src/Embed/SsrPurgeResult.php | 24 ++++++ src/Embed/SsrUnavailable.php | 13 +++ src/Embed/SsrV1Document.php | 22 +++-- tests/EmbedRequestTest.php | 75 ++++++++++++++++ tests/RendererTest.php | 120 +++++++++++++++++++++++++- tests/SsrPublishTest.php | 62 +++++++++++-- tests/SsrV1DocumentTest.php | 34 +++++++- 16 files changed, 541 insertions(+), 116 deletions(-) create mode 100644 src/Embed/SsrClientError.php create mode 100644 src/Embed/SsrException.php create mode 100644 src/Embed/SsrPurgeResult.php create mode 100644 src/Embed/SsrUnavailable.php create mode 100644 tests/EmbedRequestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f56754..2305977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Validate `preset` as a JS identifier and `requestId` / `assetVersion` as header-safe tokens. +- Forward `locale` and `deviceClass` to the sidecar in `options`. +- Look up the SSR result cache before consulting the circuit breaker. +- Trip the breaker only on `SsrUnavailable` (connect / timeout / 5xx), not client or parse errors. +- Reject SSR HTML that does not start with a real element (no comment injection). +- Accept only v1 JSON from the sidecar; strip a UTF-8 BOM. Remove deprecated `curl_close()`. +- Cache-bust `mapsight.css` with `?v=` like the module imports. +- `SsrPublish::afterFeatureSourcePublish()` returns `SsrPurgeResult`. Blank URL lists are an error, not a purge-all. A failed sidecar purge no longer flushes the PHP cache. + ## 0.3.0 — 2026-09-12 First public Packagist release. Composer name is `mapsight/embed` (MIT). diff --git a/README.md b/README.md index 8514ab2..1d19248 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,13 @@ echo $result->html; `?module=` *and* the sidecar returned meta. Apply it with your CMS title / canonical / OG / JSON-LD APIs. Do not inject head tags into the fragment. -`preset` becomes `/assets/{preset}.js` next to `embed.js` under `assetBase`. +`preset` becomes `/assets/{preset}.js` next to `embed.js` under `assetBase` +and is interpolated as a JS import binding, so it must be a JavaScript +identifier (not `my-map`, not a reserved word). `containerClassName` is optional; if you pass it, the empty mount and the sidecar request both get that class. +`locale` and `deviceClass` are forwarded in the sidecar `options` when set. +`assetVersion` cache-busts `mapsight.css` as well as the module imports. Pass `requestUrl` (path + search, typically `REQUEST_URI`) and, when that URL is path-only, `pageOrigin` so the sidecar can make absolute canonical / `og:url` @@ -131,8 +135,10 @@ Timeouts are split: `ssrConnectTimeoutSeconds` (default 0.1) and failures a process-local breaker skips Node for 15s. Pass an `SsrResultCache` (e.g. `ArraySsrResultCache`, or your Redis adapter) -to skip Node on a warm `{html,state}` hit. The key is `SsrCacheKey`: config + -locale + deviceClass + assetVersion + requestUrl + contract `v`. +to skip Node on a warm `{html,state}` hit. The cache is consulted before the +circuit breaker, so an open breaker still serves a warm fragment. The key is +`SsrCacheKey`: config + locale + deviceClass + assetVersion + requestUrl + +contract `v`. Those same locale / deviceClass values go to the sidecar. Wire and hydration details live in the Mapsight monorepo — do not fork them here: @@ -148,17 +154,23 @@ here: ## Publish / purge When a feature-source or GeoJSON file changes, call `SsrPublish` **before** the -next page render. It POSTs sidecar `/purge` (prefer absolute list URLs; omit to -clear all) and `flush()`es the PHP fragment cache. Purging Node only still -serves stale HTML from PHP. +next page render. It POSTs sidecar `/purge` (prefer absolute list URLs; omit or +pass `[]` to clear all) and `flush()`es the PHP fragment cache **only after a +successful sidecar purge** (or when no sidecar URL is configured). A list that +filters down to no URLs (e.g. `['']`) throws instead of purging everything. +The return value is `SsrPurgeResult` (`sidecarPurged`, `deletedKeys`). Purging +Node only still serves stale HTML from PHP. ```php -(new \OpenMapsight\Embed\SsrPublish( +$result = (new \OpenMapsight\Embed\SsrPublish( getenv('MAPSIGHT_SSR_URL') ?: null, $resultCache, // the SsrResultCache passed to Renderer, if any ))->afterFeatureSourcePublish([ 'https://www.example.com/geojson/places.geojson', ]); +if (!$result->sidecarPurged) { + // sidecar URL unset, or POST failed — PHP cache was not flushed on failure +} ``` Do not use a feature-source revision env var as the bust protocol. diff --git a/src/Embed/EmbedRequest.php b/src/Embed/EmbedRequest.php index 3431994..868e14e 100644 --- a/src/Embed/EmbedRequest.php +++ b/src/Embed/EmbedRequest.php @@ -9,6 +9,17 @@ */ final class EmbedRequest { + /** @var list */ + private const JS_RESERVED_WORDS = [ + 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', + 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', + 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', + 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', + 'package', 'private', 'protected', 'public', 'return', 'static', + 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', + 'void', 'while', 'with', 'yield', + ]; + /** * @param array $config Arguments for the preset factory (e.g. infosite({…})). */ @@ -29,9 +40,7 @@ public function __construct( public readonly ?string $pageOrigin = null, public readonly ?string $ogImage = null, ) { - if ($this->preset === '') { - throw new \InvalidArgumentException('preset must not be empty'); - } + self::assertJsIdentifier($this->preset, 'preset'); if ($this->containerId === '') { throw new \InvalidArgumentException('containerId must not be empty'); } @@ -41,6 +50,38 @@ public function __construct( if ($this->ssrConnectTimeoutSeconds > $this->ssrTimeoutSeconds) { throw new \InvalidArgumentException('ssrConnectTimeoutSeconds must not exceed ssrTimeoutSeconds'); } + self::assertHeaderToken($this->requestId, 'requestId'); + self::assertHeaderToken($this->assetVersion, 'assetVersion'); + } + + /** + * Preset is interpolated as a JS import binding and a `/assets/{preset}.js` + * file name. Hyphens and reserved words are a SyntaxError in the boot script. + */ + private static function assertJsIdentifier(string $value, string $field): void + { + if (preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $value) !== 1) { + throw new \InvalidArgumentException($field . ' must be a JavaScript identifier'); + } + if (in_array($value, self::JS_RESERVED_WORDS, true)) { + throw new \InvalidArgumentException($field . ' must not be a JavaScript reserved word'); + } + } + + /** + * Values that become HTTP headers (and query tokens). Reject CR/LF and + * anything outside a conservative token alphabet. + */ + private static function assertHeaderToken(?string $value, string $field): void + { + if ($value === null || $value === '') { + return; + } + if (preg_match('/^[A-Za-z0-9._-]{1,200}$/', $value) !== 1) { + throw new \InvalidArgumentException( + $field . ' must match [A-Za-z0-9._-]{1,200}', + ); + } } /** diff --git a/src/Embed/NativeSsrPurgeTransport.php b/src/Embed/NativeSsrPurgeTransport.php index cdc92df..1b6c391 100644 --- a/src/Embed/NativeSsrPurgeTransport.php +++ b/src/Embed/NativeSsrPurgeTransport.php @@ -32,7 +32,7 @@ private function postWithCurl( ): string { $handle = curl_init($url); if ($handle === false) { - throw new \RuntimeException('SSR purge request failed'); + throw new SsrUnavailable('SSR purge request failed'); } curl_setopt_array($handle, [ @@ -47,10 +47,10 @@ private function postWithCurl( $body = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); $error = curl_error($handle); - curl_close($handle); + unset($handle); if ($body === false) { - throw new \RuntimeException($error !== '' ? $error : 'SSR purge request failed'); + throw new SsrUnavailable($error !== '' ? $error : 'SSR purge request failed'); } if ($status === 204) { diff --git a/src/Embed/NativeSsrTransport.php b/src/Embed/NativeSsrTransport.php index 6b6c5c0..19c40f7 100644 --- a/src/Embed/NativeSsrTransport.php +++ b/src/Embed/NativeSsrTransport.php @@ -18,9 +18,13 @@ public function postJson( array $headers = [], float $connectTimeoutSeconds = 0.1, ): SsrDocument { - $json = json_encode($payload, JSON_THROW_ON_ERROR); + try { + $json = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new SsrClientError('SSR request body is not JSON', 0, $e); + } if (strlen($json) > self::MAX_BODY_BYTES) { - throw new \RuntimeException('SSR request body exceeds size cap'); + throw new SsrClientError('SSR request body exceeds size cap'); } if (function_exists('curl_init')) { @@ -47,7 +51,7 @@ private function postWithCurl( $handle = curl_init($url); if ($handle === false) { - throw new \RuntimeException('SSR request failed'); + throw new SsrUnavailable('SSR request failed'); } curl_setopt_array($handle, [ @@ -62,14 +66,17 @@ private function postWithCurl( $body = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); $error = curl_error($handle); - curl_close($handle); + unset($handle); if ($body === false) { - throw new \RuntimeException($error !== '' ? $error : 'SSR request failed'); + throw new SsrUnavailable($error !== '' ? $error : 'SSR request failed'); } + if ($status >= 400 && $status < 500) { + throw new SsrClientError('SSR HTTP status ' . $status); + } if ($status < 200 || $status >= 300) { - throw new \RuntimeException('SSR HTTP status ' . $status); + throw new SsrUnavailable('SSR HTTP status ' . $status); } return $body; @@ -100,7 +107,7 @@ private function postWithStreams(string $url, string $json, float $timeoutSecond $body = @file_get_contents($url, false, $context); if ($body === false) { - throw new \RuntimeException('SSR request failed'); + throw new SsrUnavailable('SSR request failed'); } $status = 0; @@ -110,8 +117,11 @@ private function postWithStreams(string $url, string $json, float $timeoutSecond $status = (int) $matches[1]; } + if ($status >= 400 && $status < 500) { + throw new SsrClientError('SSR HTTP status ' . $status); + } if ($status < 200 || $status >= 300) { - throw new \RuntimeException('SSR HTTP status ' . $status); + throw new SsrUnavailable('SSR HTTP status ' . $status); } return $body; @@ -119,15 +129,6 @@ private function postWithStreams(string $url, string $json, float $timeoutSecond private function finish(string $body): SsrDocument { - $trimmed = ltrim($body); - if (str_starts_with($trimmed, '{')) { - return SsrV1Document::fromResponse($body); - } - - if ($body === '' || !str_contains($body, 'data-dehydrated-state')) { - throw new \RuntimeException('SSR response missing dehydrated state'); - } - - return new SsrDocument($body, null); + return SsrV1Document::fromResponse($body); } } diff --git a/src/Embed/Renderer.php b/src/Embed/Renderer.php index 7a48636..e72a3dc 100644 --- a/src/Embed/Renderer.php +++ b/src/Embed/Renderer.php @@ -35,8 +35,8 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed $assetBase = rtrim($request->assetBase, '/'); $parts = [ sprintf( - '', - $this->escapeAttr($assetBase), + '', + $this->escapeAttr($this->assetUrl($assetBase, 'mapsight.css', $request->assetVersion)), ), ]; @@ -45,47 +45,47 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed $ssrSkipped = false; if ($request->ssrUrl !== null && $request->ssrUrl !== '') { - if (!$this->circuitBreaker->allow()) { + $cacheKey = SsrCacheKey::for($request); + $cached = $this->resultCache?->get($cacheKey); + if ($cached !== null && $cached->html !== '') { + $containerHtml = $cached->html; + $pageMeta = $this->pageMetaForRequest($request, $cached->pageMeta); + } elseif (!$this->circuitBreaker->allow()) { $ssrSkipped = true; } else { - $cacheKey = SsrCacheKey::for($request); - $cached = $this->resultCache?->get($cacheKey); - if ($cached !== null && $cached->html !== '') { - $containerHtml = $cached->html; - $pageMeta = $this->pageMetaForRequest($request, $cached->pageMeta); - } else { - try { - $transport = $this->ssrTransport ?? new NativeSsrTransport(); - $renderUrl = rtrim($request->ssrUrl, '/') . '/v1/render'; - $payload = [ - 'v' => 1, - 'preset' => $request->preset, - 'options' => $this->ssrOptions($request), - ]; - if ($request->requestId !== null && $request->requestId !== '') { - $payload['requestId'] = $request->requestId; - } - if ($request->assetVersion !== null && $request->assetVersion !== '') { - $payload['assetVersion'] = $request->assetVersion; - } - $document = $transport->postJson( - $renderUrl, - $payload, - $request->ssrTimeoutSeconds, - self::ssrHeaders($request), - $request->ssrConnectTimeoutSeconds, - ); - $this->circuitBreaker->recordSuccess(); - $containerHtml = $document->html; - $pageMeta = $this->pageMetaForRequest($request, $document->pageMeta); - $this->resultCache?->set( - $cacheKey, - new SsrDocument($containerHtml, $pageMeta), - ); - } catch (\Throwable) { - $this->circuitBreaker->recordFailure(); - $ssrSkipped = true; + try { + $transport = $this->ssrTransport ?? new NativeSsrTransport(); + $renderUrl = rtrim($request->ssrUrl, '/') . '/v1/render'; + $payload = [ + 'v' => 1, + 'preset' => $request->preset, + 'options' => $this->ssrOptions($request), + ]; + if ($request->requestId !== null && $request->requestId !== '') { + $payload['requestId'] = $request->requestId; } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $payload['assetVersion'] = $request->assetVersion; + } + $document = $transport->postJson( + $renderUrl, + $payload, + $request->ssrTimeoutSeconds, + self::ssrHeaders($request), + $request->ssrConnectTimeoutSeconds, + ); + $this->circuitBreaker->recordSuccess(); + $containerHtml = $document->html; + $pageMeta = $this->pageMetaForRequest($request, $document->pageMeta); + $this->resultCache?->set( + $cacheKey, + new SsrDocument($containerHtml, $pageMeta), + ); + } catch (SsrUnavailable) { + $this->circuitBreaker->recordFailure(); + $ssrSkipped = true; + } catch (\Throwable) { + $ssrSkipped = true; } } } @@ -128,6 +128,12 @@ private function ssrOptions(EmbedRequest $request): array if ($ogImage !== null) { $options['ogImage'] = $ogImage; } + if ($request->locale !== null && $request->locale !== '') { + $options['locale'] = $request->locale; + } + if ($request->deviceClass !== null && $request->deviceClass !== '') { + $options['deviceClass'] = $request->deviceClass; + } return $options; } @@ -182,8 +188,8 @@ private function bootScript(EmbedRequest $request, string $assetBase): string | JSON_HEX_QUOT, ); - $embedUrl = $this->moduleUrl($assetBase, 'embed.js', $request->assetVersion); - $presetUrl = $this->moduleUrl($assetBase, $preset . '.js', $request->assetVersion); + $embedUrl = $this->assetUrl($assetBase, 'embed.js', $request->assetVersion); + $presetUrl = $this->assetUrl($assetBase, $preset . '.js', $request->assetVersion); return << @@ -197,7 +203,7 @@ private function bootScript(EmbedRequest $request, string $assetBase): string HTML; } - private function moduleUrl(string $assetBase, string $file, ?string $assetVersion): string + private function assetUrl(string $assetBase, string $file, ?string $assetVersion): string { $url = $assetBase . '/assets/' . $file; if ($assetVersion !== null && $assetVersion !== '') { diff --git a/src/Embed/SsrClientError.php b/src/Embed/SsrClientError.php new file mode 100644 index 0000000..0a79edd --- /dev/null +++ b/src/Embed/SsrClientError.php @@ -0,0 +1,13 @@ +|null $urls - * @return list deleted sidecar cache keys (empty when sidecar is unset or fail-open) */ - public function afterFeatureSourcePublish(?array $urls = null): array + public function afterFeatureSourcePublish(?array $urls = null): SsrPurgeResult { - $deleted = $this->purgeSidecar($urls); + if ($this->ssrUrl === null || $this->ssrUrl === '') { + $this->resultCache?->flush(); + + return new SsrPurgeResult(false, []); + } + + $payload = $this->purgePayload($urls); + + try { + $transport = $this->transport ?? new NativeSsrPurgeTransport(); + $deleted = $transport->postPurge( + rtrim($this->ssrUrl, '/') . '/purge', + $payload, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + ); + } catch (\Throwable $error) { + error_log('mapsight ssr purge failed: ' . $error->getMessage()); + + return new SsrPurgeResult(false, []); + } + $this->resultCache?->flush(); - return $deleted; + return new SsrPurgeResult(true, $deleted); } public static function fromEnv(?SsrResultCache $resultCache = null): self @@ -44,32 +67,24 @@ public static function fromEnv(?SsrResultCache $resultCache = null): self /** * @param list|null $urls - * @return list + * @return array */ - private function purgeSidecar(?array $urls): array + private function purgePayload(?array $urls): array { - if ($this->ssrUrl === null || $this->ssrUrl === '') { + if ($urls === null || $urls === []) { return []; } - $payload = []; - if ($urls !== null && $urls !== []) { - $payload['urls'] = array_values(array_filter($urls, static fn (mixed $url): bool => is_string($url) && $url !== '')); - } - - try { - $transport = $this->transport ?? new NativeSsrPurgeTransport(); - - return $transport->postPurge( - rtrim($this->ssrUrl, '/') . '/purge', - $payload, - $this->timeoutSeconds, - $this->connectTimeoutSeconds, + $filtered = array_values(array_filter( + $urls, + static fn (mixed $url): bool => is_string($url) && $url !== '', + )); + if ($filtered === []) { + throw new \InvalidArgumentException( + 'afterFeatureSourcePublish received only empty urls; refusing to purge the whole sidecar cache', ); - } catch (\Throwable $error) { - error_log('mapsight ssr purge failed: ' . $error->getMessage()); - - return []; } + + return ['urls' => $filtered]; } } diff --git a/src/Embed/SsrPurgeResult.php b/src/Embed/SsrPurgeResult.php new file mode 100644 index 0000000..07571a1 --- /dev/null +++ b/src/Embed/SsrPurgeResult.php @@ -0,0 +1,24 @@ + $deletedKeys + */ + public function __construct( + public readonly bool $sidecarPurged, + public readonly array $deletedKeys = [], + ) { + } +} diff --git a/src/Embed/SsrUnavailable.php b/src/Embed/SsrUnavailable.php new file mode 100644 index 0000000..a07e78d --- /dev/null +++ b/src/Embed/SsrUnavailable.php @@ -0,0 +1,13 @@ +')) { - throw new \RuntimeException('SSR v1 html has no opening element'); + throw new SsrClientError('SSR v1 html has no opening element'); } $opening = substr($opening, 0, -1) . ' data-dehydrated-state="' . $escaped . '">'; @@ -71,8 +75,8 @@ public static function withDehydratedState(string $html, mixed $state): string /** First `>` that is not inside a quoted attribute (OSM attribution has raw `>`). */ private static function openingTagEnd(string $html): int { - if ($html === '' || $html[0] !== '<') { - throw new \RuntimeException('SSR v1 html has no opening element'); + if ($html === '' || preg_match('/^<[A-Za-z]/', $html) !== 1) { + throw new SsrClientError('SSR v1 html has no opening element'); } $quote = null; @@ -94,6 +98,6 @@ private static function openingTagEnd(string $html): int } } - throw new \RuntimeException('SSR v1 html has no opening element'); + throw new SsrClientError('SSR v1 html has no opening element'); } } diff --git a/tests/EmbedRequestTest.php b/tests/EmbedRequestTest.php new file mode 100644 index 0000000..bb5153e --- /dev/null +++ b/tests/EmbedRequestTest.php @@ -0,0 +1,75 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('preset must be a JavaScript identifier'); + + new EmbedRequest( + preset: 'my-map', + containerId: 'mapsight-embed-1', + config: [], + ); + } + + public function test_rejects_reserved_word_preset(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('preset must not be a JavaScript reserved word'); + + new EmbedRequest( + preset: 'default', + containerId: 'mapsight-embed-1', + config: [], + ); + } + + public function test_rejects_request_id_with_crlf(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requestId must match'); + + new EmbedRequest( + preset: 'infosite', + containerId: 'mapsight-embed-1', + config: [], + requestId: "abc\r\nX-Injected: yes", + ); + } + + public function test_rejects_asset_version_with_spaces(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('assetVersion must match'); + + new EmbedRequest( + preset: 'infosite', + containerId: 'mapsight-embed-1', + config: [], + assetVersion: 'assets 9', + ); + } + + public function test_accepts_header_safe_tokens(): void + { + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + requestId: 'req-1.2_3', + assetVersion: 'assets-9', + ); + + $this->assertSame('req-1.2_3', $request->requestId); + $this->assertSame('assets-9', $request->assetVersion); + } +} diff --git a/tests/RendererTest.php b/tests/RendererTest.php index 7386b7f..21d0162 100644 --- a/tests/RendererTest.php +++ b/tests/RendererTest.php @@ -11,8 +11,10 @@ use OpenMapsight\Embed\ProcessSsrCircuitBreaker; use OpenMapsight\Embed\Renderer; use OpenMapsight\Embed\SsrCacheKey; +use OpenMapsight\Embed\SsrClientError; use OpenMapsight\Embed\SsrDocument; use OpenMapsight\Embed\SsrTransport; +use OpenMapsight\Embed\SsrUnavailable; use PHPUnit\Framework\TestCase; final class RendererTest extends TestCase @@ -124,6 +126,10 @@ public function postJson( )); $this->assertStringContainsString('data-dehydrated-state=', $html); + $this->assertStringContainsString( + 'href="/mapsight/plan/assets/mapsight.css?v=assets-9"', + $html, + ); $this->assertStringContainsString( 'import {mountEmbed} from "/mapsight/plan/assets/embed.js?v=assets-9"', $html, @@ -218,7 +224,7 @@ public function postJson( float $connectTimeoutSeconds = 0.1, ): SsrDocument { $this->calls++; - throw new \RuntimeException('sidecar down'); + throw new SsrUnavailable('sidecar down'); } }; $renderer = new Renderer( @@ -252,6 +258,9 @@ public function test_cache_hit_skips_node_and_does_not_mark_skipped(): void $transport = new class implements SsrTransport { public int $calls = 0; + /** @var array|null */ + public ?array $payload = null; + public function postJson( string $url, array $payload, @@ -260,6 +269,7 @@ public function postJson( float $connectTimeoutSeconds = 0.1, ): SsrDocument { $this->calls++; + $this->payload = $payload; return new SsrDocument('
'); + } + }; + + (new Renderer($transport))->render(new EmbedRequest( + preset: 'infosite', + containerId: 'mapsight-embed-locale', + config: ['imagesUrl' => '/mapsight/plan/img/'], + ssrUrl: 'http://ssr:4123', + locale: 'de', + deviceClass: 'mobile', + )); + + $this->assertSame('de', $transport->payload['options']['locale'] ?? null); + $this->assertSame('mobile', $transport->payload['options']['deviceClass'] ?? null); + } + + public function test_open_circuit_still_serves_warm_cache(): void + { + $transport = new class implements SsrTransport { + public int $calls = 0; + + public function postJson( + string $url, + array $payload, + float $timeoutSeconds, + array $headers = [], + float $connectTimeoutSeconds = 0.1, + ): SsrDocument { + $this->calls++; + + return new SsrDocument('
'); + } + }; + $cache = new ArraySsrResultCache(); + $breaker = new ProcessSsrCircuitBreaker(1, 60.0); + $renderer = new Renderer($transport, $breaker, $cache); + $request = new EmbedRequest( + preset: 'infosite', + containerId: 'mapsight-embed-warm', + config: ['imagesUrl' => '/mapsight/plan/img/'], + ssrUrl: 'http://ssr:4123', + ); + + $warm = $renderer->render($request); + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + + $served = $renderer->render($request); + + $this->assertSame(1, $transport->calls); + $this->assertSame($warm, $served); + $this->assertStringNotContainsString('mapsight-ssr-skipped', $served); + $this->assertStringContainsString('data-dehydrated-state=', $served); + } + + public function test_client_errors_do_not_trip_the_breaker(): void + { + $transport = new class implements SsrTransport { + public int $calls = 0; + + public function postJson( + string $url, + array $payload, + float $timeoutSeconds, + array $headers = [], + float $connectTimeoutSeconds = 0.1, + ): SsrDocument { + $this->calls++; + throw new SsrClientError('SSR v1 error VALIDATION'); + } + }; + $renderer = new Renderer( + $transport, + new ProcessSsrCircuitBreaker(2, 10.0), + ); + $request = new EmbedRequest( + preset: 'infosite', + containerId: 'mapsight-embed-client-error', + config: ['imagesUrl' => '/mapsight/plan/img/'], + ssrUrl: 'http://ssr:4123', + ); + + $renderer->render($request); + $renderer->render($request); + $third = $renderer->render($request); + + $this->assertSame(3, $transport->calls); + $this->assertStringContainsString('', $third); + } + public function test_cache_key_includes_request_url(): void { $home = new EmbedRequest( diff --git a/tests/SsrPublishTest.php b/tests/SsrPublishTest.php index 56fc71d..b8af447 100644 --- a/tests/SsrPublishTest.php +++ b/tests/SsrPublishTest.php @@ -36,11 +36,12 @@ public function postPurge( $cache->set('k1', new SsrDocument('
')); $hook = new SsrPublish('http://ssr:4123', $cache, $transport); - $deleted = $hook->afterFeatureSourcePublish([ + $result = $hook->afterFeatureSourcePublish([ 'https://example.test/schools.geojson', ]); - $this->assertSame(['doc::https://example.test/schools.geojson'], $deleted); + $this->assertTrue($result->sidecarPurged); + $this->assertSame(['doc::https://example.test/schools.geojson'], $result->deletedKeys); $this->assertSame(1, $transport->calls); $this->assertSame('http://ssr:4123/purge', $transport->requests[0]['url'] ?? null); $this->assertSame( @@ -70,15 +71,16 @@ public function postPurge( $cache = new ArraySsrResultCache(); $cache->set('k1', new SsrDocument('
')); - $deleted = (new SsrPublish('http://ssr:4123', $cache, $transport)) + $result = (new SsrPublish('http://ssr:4123', $cache, $transport)) ->afterFeatureSourcePublish(); - $this->assertSame(['doc::all'], $deleted); + $this->assertTrue($result->sidecarPurged); + $this->assertSame(['doc::all'], $result->deletedKeys); $this->assertSame([], $transport->payload); $this->assertNull($cache->get('k1')); } - public function test_sidecar_failure_still_flushes_php(): void + public function test_sidecar_failure_does_not_flush_php(): void { $transport = new class implements SsrPurgeTransport { public function postPurge( @@ -93,10 +95,56 @@ public function postPurge( $cache = new ArraySsrResultCache(); $cache->set('k1', new SsrDocument('
')); - $deleted = (new SsrPublish('http://ssr:4123', $cache, $transport)) + $result = (new SsrPublish('http://ssr:4123', $cache, $transport)) ->afterFeatureSourcePublish(['https://example.test/a.geojson']); - $this->assertSame([], $deleted); + $this->assertFalse($result->sidecarPurged); + $this->assertSame([], $result->deletedKeys); + $this->assertNotNull($cache->get('k1')); + } + + public function test_missing_sidecar_url_still_flushes_php(): void + { + $cache = new ArraySsrResultCache(); + $cache->set('k1', new SsrDocument('
')); + + $result = (new SsrPublish(null, $cache))->afterFeatureSourcePublish([ + 'https://example.test/a.geojson', + ]); + + $this->assertFalse($result->sidecarPurged); + $this->assertSame([], $result->deletedKeys); $this->assertNull($cache->get('k1')); } + + public function test_blank_urls_are_not_a_purge_all(): void + { + $transport = new class implements SsrPurgeTransport { + public int $calls = 0; + + public function postPurge( + string $url, + array $payload, + float $timeoutSeconds, + float $connectTimeoutSeconds = 0.1, + ): array { + $this->calls++; + + return []; + } + }; + $cache = new ArraySsrResultCache(); + $cache->set('k1', new SsrDocument('
')); + + try { + (new SsrPublish('http://ssr:4123', $cache, $transport)) + ->afterFeatureSourcePublish(['']); + $this->fail('expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('only empty urls', $e->getMessage()); + } + + $this->assertSame(0, $transport->calls); + $this->assertNotNull($cache->get('k1')); + } } diff --git a/tests/SsrV1DocumentTest.php b/tests/SsrV1DocumentTest.php index c0c3f85..a28ee91 100644 --- a/tests/SsrV1DocumentTest.php +++ b/tests/SsrV1DocumentTest.php @@ -4,6 +4,7 @@ namespace OpenMapsight\Tests; +use OpenMapsight\Embed\SsrClientError; use OpenMapsight\Embed\SsrV1Document; use PHPUnit\Framework\TestCase; @@ -118,11 +119,42 @@ private function dehydratedState(string $html): array public function test_rejects_error_payload(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(SsrClientError::class); SsrV1Document::containerHtmlFromResponse(json_encode([ 'v' => 1, 'error' => ['code' => 'RENDER_FAILED', 'message' => 'render failed'], ], JSON_THROW_ON_ERROR)); } + + public function test_rejects_html_that_starts_with_a_comment(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 html has no opening element'); + + SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['a' => 1], + ], JSON_THROW_ON_ERROR)); + } + + public function test_accepts_utf8_bom_before_json(): void + { + $html = SsrV1Document::containerHtmlFromResponse("\xEF\xBB\xBF" . json_encode([ + 'v' => 1, + 'html' => '
', + 'state' => ['app' => ['ssr' => 'v1']], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); + } + + public function test_rejects_raw_html_body(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 response is not JSON'); + + SsrV1Document::fromResponse('
'); + } } From 8ef96bdf560c9671e86e56d2f3d7a54e5174879a Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Sat, 12 Sep 2026 20:01:01 +0200 Subject: [PATCH 2/9] Test process-local breaker half-open reset and immediate re-open. --- tests/ProcessSsrCircuitBreakerTest.php | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/ProcessSsrCircuitBreakerTest.php diff --git a/tests/ProcessSsrCircuitBreakerTest.php b/tests/ProcessSsrCircuitBreakerTest.php new file mode 100644 index 0000000..bf2e363 --- /dev/null +++ b/tests/ProcessSsrCircuitBreakerTest.php @@ -0,0 +1,45 @@ + $clock->now); + + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + + $clock->now = 10.0; + $this->assertTrue($breaker->allow()); + + $breaker->recordSuccess(); + $this->assertTrue($breaker->allow()); + $clock->now = 10.1; + $this->assertTrue($breaker->allow()); + } + + public function test_failure_after_cooldown_reopens_immediately(): void + { + $clock = new class { + public float $now = 0.0; + }; + $breaker = new ProcessSsrCircuitBreaker(1, 10.0, fn () => $clock->now); + + $breaker->recordFailure(); + $clock->now = 10.0; + $this->assertTrue($breaker->allow()); + + $breaker->recordFailure(); + $this->assertFalse($breaker->allow()); + } +} From 7dc99f50186edd9812eafea46b743e586c91032d Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Sat, 12 Sep 2026 20:01:05 +0200 Subject: [PATCH 3/9] Require absolute http(s) URLs on page meta and emit description tags. Relative or javascript: URLs must not land in canonical or Open Graph tags. --- src/Embed/PageMetaTags.php | 3 ++- src/Embed/PlacePageMeta.php | 29 +++++++++++++++++++++++------ tests/PageMetaTagsTest.php | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/Embed/PageMetaTags.php b/src/Embed/PageMetaTags.php index c7ef641..b1a8d42 100644 --- a/src/Embed/PageMetaTags.php +++ b/src/Embed/PageMetaTags.php @@ -50,7 +50,7 @@ public static function title(?PlacePageMeta $meta, string $fallback): string } /** - * Canonical, Open Graph, and JSON-LD tags. Caller still sets ``. + * Canonical, description, Open Graph, and JSON-LD tags. Caller still sets `<title>`. */ public static function html(?PlacePageMeta $meta): string { @@ -70,6 +70,7 @@ public static function html(?PlacePageMeta $meta): string return implode("\n", [ '<link rel="canonical" href="' . self::escape($fields['canonicalUrl']) . '">', + '<meta name="description" content="' . self::escape($fields['description']) . '">', '<meta property="og:title" content="' . self::escape($fields['ogTitle']) . '">', '<meta property="og:description" content="' . self::escape($fields['ogDescription']) . '">', '<meta property="og:url" content="' . self::escape($fields['ogUrl']) . '">', diff --git a/src/Embed/PlacePageMeta.php b/src/Embed/PlacePageMeta.php index 01b1c73..69ffdd8 100644 --- a/src/Embed/PlacePageMeta.php +++ b/src/Embed/PlacePageMeta.php @@ -5,10 +5,10 @@ namespace OpenMapsight\Embed; /** - * Selected-feature or Stadtplan-module document meta from sidecar `renderEnvelope()`. + * Selected-feature or topic document meta from sidecar `renderEnvelope()`. * * Fail-open: {@see tryFrom()} returns null when the payload is missing or incomplete. - * `canonicalUrl` / `og.url` are the page permalink, never `schema.url`. + * `canonicalUrl` / `og.url` / `og.image` must be absolute `http(s)` URLs. */ final class PlacePageMeta { @@ -32,7 +32,7 @@ public static function tryFrom(mixed $value): ?self $title = self::nonEmptyString($value['title'] ?? null); $description = self::nonEmptyString($value['description'] ?? null); - $canonicalUrl = self::nonEmptyString($value['canonicalUrl'] ?? null); + $canonicalUrl = self::httpUrl($value['canonicalUrl'] ?? null); $ogRaw = $value['og'] ?? null; $jsonLd = $value['jsonLd'] ?? null; if ( @@ -47,9 +47,9 @@ public static function tryFrom(mixed $value): ?self $ogTitle = self::nonEmptyString($ogRaw['title'] ?? null); $ogDescription = self::nonEmptyString($ogRaw['description'] ?? null); - $ogUrl = self::nonEmptyString($ogRaw['url'] ?? null); + $ogUrl = self::httpUrl($ogRaw['url'] ?? null); $ogType = self::nonEmptyString($ogRaw['type'] ?? null); - $ogImage = self::nonEmptyString($ogRaw['image'] ?? null); + $ogImage = self::httpUrl($ogRaw['image'] ?? null); if ( $ogTitle === null || $ogDescription === null @@ -61,12 +61,19 @@ public static function tryFrom(mixed $value): ?self return null; } + $jsonLdByKey = []; + foreach ($jsonLd as $key => $item) { + if (is_string($key)) { + $jsonLdByKey[$key] = $item; + } + } + return new self( $title, $description, $canonicalUrl, new PlacePageMetaOg($ogTitle, $ogDescription, $ogUrl, $ogType, $ogImage), - $jsonLd, + $jsonLdByKey, ); } @@ -79,4 +86,14 @@ private static function nonEmptyString(mixed $value): ?string return $trimmed !== '' ? $trimmed : null; } + + private static function httpUrl(mixed $value): ?string + { + $url = self::nonEmptyString($value); + if ($url === null || preg_match('#^https?://#i', $url) !== 1) { + return null; + } + + return $url; + } } diff --git a/tests/PageMetaTagsTest.php b/tests/PageMetaTagsTest.php index 9a2563c..7a6bad0 100644 --- a/tests/PageMetaTagsTest.php +++ b/tests/PageMetaTagsTest.php @@ -19,6 +19,7 @@ public function test_html_emits_canonical_og_and_json_ld(): void '<link rel="canonical" href="https://www.example.com/map?feature=poi-1">', $html, ); + $this->assertStringContainsString('name="description" content="An example place."', $html); $this->assertStringContainsString('property="og:title" content="Town Hall"', $html); $this->assertStringContainsString('property="og:type" content="place"', $html); $this->assertStringContainsString( @@ -39,6 +40,32 @@ public function test_try_from_fails_open_on_garbage(): void $this->assertNull(PlacePageMeta::tryFrom('Town Hall')); } + public function test_try_from_rejects_unknown_og_type_and_non_http_urls(): void + { + $base = [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'canonicalUrl' => 'https://www.example.com/map?feature=poi-1', + 'og' => [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'url' => 'https://www.example.com/map?feature=poi-1', + 'type' => 'article', + 'image' => 'https://www.example.com/plan/img/og-default.png', + ], + 'jsonLd' => ['@type' => 'Place'], + ]; + $this->assertNull(PlacePageMeta::tryFrom($base)); + + $base['og']['type'] = 'place'; + $base['canonicalUrl'] = '/map?feature=poi-1'; + $this->assertNull(PlacePageMeta::tryFrom($base)); + + $base['canonicalUrl'] = 'https://www.example.com/map?feature=poi-1'; + $base['og']['image'] = 'javascript:alert(1)'; + $this->assertNull(PlacePageMeta::tryFrom($base)); + } + private static function sample(): PlacePageMeta { return PlacePageMeta::tryFrom([ From 9c6bfa51d7b523de29f98cfeaba99ca201c368d1 Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:01:09 +0200 Subject: [PATCH 4/9] Require psr/log and psr/simple-cache; add PHPStan as a dev dependency. --- composer.json | 9 ++- composer.lock | 170 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index bc8042e..9c57a17 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,12 @@ "source": "https://github.com/open-mapsight/embed" }, "require": { - "php": "^8.2" + "php": "^8.2", + "psr/log": "^3", + "psr/simple-cache": "^3" }, "require-dev": { + "phpstan/phpstan": "^2", "phpunit/phpunit": "^11.0" }, "suggest": { @@ -46,6 +49,8 @@ "sort-packages": true }, "scripts": { - "test": "phpunit" + "test": "phpunit", + "phpstan": "phpstan analyse", + "validate": "composer validate --strict" } } diff --git a/composer.lock b/composer.lock index 7e92d85..7efade7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,110 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "490b0de0425551e6a2fd3fc1f0b65164", - "packages": [], + "content-hash": "8072631a108a8e88d5599e5a09951cc2", + "packages": [ + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + } + ], "packages-dev": [ { "name": "myclabs/deep-copy", @@ -242,6 +344,70 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.2.13", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-09-03T20:38:19+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.12", From ffbcd7c011d82994c99bfb9db0d508a4fce0cb92 Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:01:15 +0200 Subject: [PATCH 5/9] Split sidecar config into SsrClient and make EmbedRequest placement-only. The host builds one client (transport, cache, breaker, logger) and reuses it for render, purge, health, and warm. The transport returns raw HTTP so the client owns the v1 contract. --- src/Embed/ArraySsrResultCache.php | 35 +- src/Embed/EmbedRequest.php | 134 +--- src/Embed/NativeSsrPurgeTransport.php | 130 ---- src/Embed/NativeSsrTransport.php | 187 ++--- src/Embed/Psr16SsrResultCache.php | 34 + .../{SsrPurgeResult.php => PurgeResult.php} | 4 +- src/Embed/RenderedEmbed.php | 12 +- src/Embed/Renderer.php | 212 ++---- src/Embed/SsrCacheKey.php | 24 +- src/Embed/SsrClient.php | 456 +++++++++++++ src/Embed/SsrContract.php | 13 + src/Embed/SsrHttpRequest.php | 26 + src/Embed/SsrHttpResponse.php | 18 + src/Embed/SsrOutcome.php | 14 + src/Embed/SsrPublish.php | 75 +- src/Embed/SsrPurgeTransport.php | 22 - src/Embed/SsrRenderResult.php | 19 + src/Embed/SsrResultCache.php | 4 +- src/Embed/SsrTransport.php | 19 +- src/Embed/SsrV1Document.php | 55 +- src/Embed/Testing/FakeSsrTransport.php | 64 ++ tests/EmbedRequestTest.php | 29 +- tests/NativeSsrTransportTest.php | 141 ++++ tests/RendererTest.php | 645 ++++++------------ tests/SsrClientTest.php | 121 ++++ tests/SsrPublishTest.php | 130 ++-- tests/SsrV1DocumentTest.php | 80 ++- tests/fixtures/http-server.php | 82 +++ tests/fixtures/v1-render.json | 10 + 29 files changed, 1648 insertions(+), 1147 deletions(-) delete mode 100644 src/Embed/NativeSsrPurgeTransport.php create mode 100644 src/Embed/Psr16SsrResultCache.php rename src/Embed/{SsrPurgeResult.php => PurgeResult.php} (83%) create mode 100644 src/Embed/SsrClient.php create mode 100644 src/Embed/SsrContract.php create mode 100644 src/Embed/SsrHttpRequest.php create mode 100644 src/Embed/SsrHttpResponse.php create mode 100644 src/Embed/SsrOutcome.php delete mode 100644 src/Embed/SsrPurgeTransport.php create mode 100644 src/Embed/SsrRenderResult.php create mode 100644 src/Embed/Testing/FakeSsrTransport.php create mode 100644 tests/NativeSsrTransportTest.php create mode 100644 tests/SsrClientTest.php create mode 100644 tests/fixtures/http-server.php create mode 100644 tests/fixtures/v1-render.json diff --git a/src/Embed/ArraySsrResultCache.php b/src/Embed/ArraySsrResultCache.php index 044bd3b..cd25949 100644 --- a/src/Embed/ArraySsrResultCache.php +++ b/src/Embed/ArraySsrResultCache.php @@ -5,21 +5,46 @@ namespace OpenMapsight\Embed; /** - * In-process cache for tests and single-worker smoke. Not shared across FPM workers. + * In-process LRU cache (256 entries) for tests and single-worker smoke. + * Not shared across FPM workers. Expired entries are dropped on get/set. */ final class ArraySsrResultCache implements SsrResultCache { - /** @var array<string, SsrDocument> */ + public const MAX_ENTRIES = 256; + + /** + * @var array<string, array{document: SsrDocument, expiresAt: float}> + */ private array $items = []; public function get(string $key): ?SsrDocument { - return $this->items[$key] ?? null; + $item = $this->items[$key] ?? null; + if ($item === null) { + return null; + } + if ($item['expiresAt'] <= microtime(true)) { + unset($this->items[$key]); + + return null; + } + + unset($this->items[$key]); + $this->items[$key] = $item; + + return $item['document']; } - public function set(string $key, SsrDocument $document): void + public function set(string $key, SsrDocument $document, int $ttl): void { - $this->items[$key] = $document; + unset($this->items[$key]); + $this->items[$key] = [ + 'document' => $document, + 'expiresAt' => microtime(true) + $ttl, + ]; + while (count($this->items) > self::MAX_ENTRIES) { + array_shift($this->items); + } } public function flush(): void diff --git a/src/Embed/EmbedRequest.php b/src/Embed/EmbedRequest.php index 868e14e..60176fb 100644 --- a/src/Embed/EmbedRequest.php +++ b/src/Embed/EmbedRequest.php @@ -5,7 +5,10 @@ namespace OpenMapsight\Embed; /** - * One Mapsight placement: caller-owned embed config plus host asset/SSR knobs. + * One Mapsight placement. Sidecar reachability lives on {@see SsrClient}. + * + * `config` is opaque: the library never reads keys from it. Documented v1 + * option keys are written over it when building the sidecar payload. */ final class EmbedRequest { @@ -21,7 +24,7 @@ final class EmbedRequest ]; /** - * @param array<string, mixed> $config Arguments for the preset factory (e.g. infosite({…})). + * @param array<string, mixed> $config Arguments for the preset factory. */ public function __construct( public readonly string $preset, @@ -29,9 +32,6 @@ public function __construct( public readonly array $config, public readonly string $assetBase = '/mapsight/plan', public readonly string $containerClassName = '', - public readonly ?string $ssrUrl = null, - public readonly float $ssrTimeoutSeconds = 2.0, - public readonly float $ssrConnectTimeoutSeconds = 0.1, public readonly ?string $requestId = null, public readonly ?string $assetVersion = null, public readonly ?string $locale = null, @@ -39,16 +39,13 @@ public function __construct( public readonly ?string $requestUrl = null, public readonly ?string $pageOrigin = null, public readonly ?string $ogImage = null, + public readonly ?string $scriptNonce = null, ) { self::assertJsIdentifier($this->preset, 'preset'); - if ($this->containerId === '') { - throw new \InvalidArgumentException('containerId must not be empty'); - } - if ($this->ssrTimeoutSeconds <= 0 || $this->ssrConnectTimeoutSeconds <= 0) { - throw new \InvalidArgumentException('SSR timeouts must be positive'); - } - if ($this->ssrConnectTimeoutSeconds > $this->ssrTimeoutSeconds) { - throw new \InvalidArgumentException('ssrConnectTimeoutSeconds must not exceed ssrTimeoutSeconds'); + if (preg_match('/^[A-Za-z][A-Za-z0-9_:.-]*$/', $this->containerId) !== 1) { + throw new \InvalidArgumentException( + 'containerId must match [A-Za-z][A-Za-z0-9_:.-]*', + ); } self::assertHeaderToken($this->requestId, 'requestId'); self::assertHeaderToken($this->assetVersion, 'assetVersion'); @@ -83,115 +80,4 @@ private static function assertHeaderToken(?string $value, string $field): void ); } } - - /** - * Page URL Node SSR uses to select Stadtplan `?module=` and apply `?feature=`. - * Explicit field, then config.requestUrl, then the current SAPI request URI. - */ - public function resolvedRequestUrl(): ?string - { - if ($this->requestUrl !== null && $this->requestUrl !== '') { - return $this->requestUrl; - } - - $fromConfig = $this->config['requestUrl'] ?? null; - if (is_string($fromConfig) && $fromConfig !== '') { - return $fromConfig; - } - - $uri = $_SERVER['REQUEST_URI'] ?? null; - if (is_string($uri) && $uri !== '') { - return $uri; - } - - return null; - } - - /** - * Public page origin (`https://www.example.com`) so path-only - * `requestUrl` can become an absolute canonical / `og:url`. - */ - public function resolvedPageOrigin(): ?string - { - if ($this->pageOrigin !== null && $this->pageOrigin !== '') { - return rtrim($this->pageOrigin, '/'); - } - - $fromConfig = $this->config['pageOrigin'] ?? null; - if (is_string($fromConfig) && $fromConfig !== '') { - return rtrim($fromConfig, '/'); - } - - $fromEnv = getenv('MAPSIGHT_PAGE_ORIGIN'); - if (is_string($fromEnv) && $fromEnv !== '') { - return rtrim($fromEnv, '/'); - } - - $publicHost = getenv('PUBLIC_HOST'); - if (is_string($publicHost) && $publicHost !== '') { - return 'https://' . $publicHost; - } - - return null; - } - - /** - * Absolute or root-absolute static default OG card. Null lets the sidecar - * use `{pageOrigin}/plan/img/og-default.png`. - */ - public function resolvedOgImage(): ?string - { - if ($this->ogImage !== null && $this->ogImage !== '') { - return $this->ogImage; - } - - $fromConfig = $this->config['ogImage'] ?? null; - if (is_string($fromConfig) && $fromConfig !== '') { - return $fromConfig; - } - - $fromEnv = getenv('MAPSIGHT_OG_IMAGE'); - if (is_string($fromEnv) && $fromEnv !== '') { - return $fromEnv; - } - - return null; - } - - /** - * Head overrides apply only when `?feature=` is on the request URL - * (`REQUEST_URI`), not when something is selected only in the client. - */ - public function requestHasFeatureParam(): bool - { - return $this->requestHasQueryParam('feature'); - } - - /** - * Head overrides apply when `?feature=` or Stadtplan `?module=` is on - * the request URL, not when a topic is selected only in the client. - */ - public function requestHasPageMetaParam(): bool - { - return $this->requestHasQueryParam('feature') - || $this->requestHasQueryParam('module'); - } - - private function requestHasQueryParam(string $name): bool - { - $url = $this->resolvedRequestUrl(); - if ($url === null) { - return false; - } - - $query = parse_url($url, PHP_URL_QUERY); - if (!is_string($query) || $query === '') { - return false; - } - - parse_str($query, $params); - $value = $params[$name] ?? null; - - return is_string($value) && $value !== ''; - } } diff --git a/src/Embed/NativeSsrPurgeTransport.php b/src/Embed/NativeSsrPurgeTransport.php deleted file mode 100644 index 1b6c391..0000000 --- a/src/Embed/NativeSsrPurgeTransport.php +++ /dev/null @@ -1,130 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace OpenMapsight\Embed; - -/** - * POST {ssrUrl}/purge → deleted cache keys (JSON string[]). - */ -final class NativeSsrPurgeTransport implements SsrPurgeTransport -{ - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - - if (function_exists('curl_init')) { - return $this->decode($this->postWithCurl($url, $json, $timeoutSeconds, $connectTimeoutSeconds)); - } - - return $this->decode($this->postWithStreams($url, $json, $timeoutSeconds)); - } - - private function postWithCurl( - string $url, - string $json, - float $timeoutSeconds, - float $connectTimeoutSeconds, - ): string { - $handle = curl_init($url); - if ($handle === false) { - throw new SsrUnavailable('SSR purge request failed'); - } - - curl_setopt_array($handle, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $json, - CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round($connectTimeoutSeconds * 1000)), - CURLOPT_TIMEOUT_MS => max(1, (int) round($timeoutSeconds * 1000)), - ]); - - $body = curl_exec($handle); - $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); - $error = curl_error($handle); - unset($handle); - - if ($body === false) { - throw new SsrUnavailable($error !== '' ? $error : 'SSR purge request failed'); - } - - if ($status === 204) { - return '[]'; - } - - if ($status < 200 || $status >= 300) { - throw new \RuntimeException('SSR purge HTTP status ' . $status); - } - - return $body; - } - - private function postWithStreams(string $url, string $json, float $timeoutSeconds): string - { - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json\r\nAccept: application/json\r\nContent-Length: " . strlen($json) . "\r\n", - 'content' => $json, - 'timeout' => $timeoutSeconds, - 'ignore_errors' => true, - ], - ]); - - $body = @file_get_contents($url, false, $context); - if ($body === false) { - throw new \RuntimeException('SSR purge request failed'); - } - - $status = 0; - if (isset($http_response_header[0]) - && preg_match('/\s(\d{3})\s/', $http_response_header[0], $matches) === 1 - ) { - $status = (int) $matches[1]; - } - - if ($status === 204) { - return '[]'; - } - - if ($status < 200 || $status >= 300) { - throw new \RuntimeException('SSR purge HTTP status ' . $status); - } - - return $body; - } - - /** @return list<string> */ - private function decode(string $body): array - { - $trimmed = trim($body); - if ($trimmed === '') { - return []; - } - - try { - $data = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new \RuntimeException('SSR purge response is not JSON', 0, $e); - } - - if (!is_array($data) || !array_is_list($data)) { - throw new \RuntimeException('SSR purge response must be a JSON array'); - } - - $keys = []; - foreach ($data as $item) { - if (!is_string($item)) { - throw new \RuntimeException('SSR purge response must be a JSON string array'); - } - $keys[] = $item; - } - - return $keys; - } -} diff --git a/src/Embed/NativeSsrTransport.php b/src/Embed/NativeSsrTransport.php index 19c40f7..abf0ae1 100644 --- a/src/Embed/NativeSsrTransport.php +++ b/src/Embed/NativeSsrTransport.php @@ -5,130 +5,157 @@ namespace OpenMapsight\Embed; /** - * Loopback/compose SSR POST via cURL when available, else PHP streams. + * Loopback/compose HTTP via cURL when available, else PHP streams. + * + * Streams `timeout` is per read, not a total budget, so a slow-dripping + * sidecar can exceed {@see SsrHttpRequest::$timeoutSeconds}. Streams also + * ignore `connectTimeoutSeconds` (needs ext-curl). */ final class NativeSsrTransport implements SsrTransport { - private const MAX_BODY_BYTES = 262144; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - try { - $json = json_encode($payload, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new SsrClientError('SSR request body is not JSON', 0, $e); - } - if (strlen($json) > self::MAX_BODY_BYTES) { - throw new SsrClientError('SSR request body exceeds size cap'); - } - + public function send(SsrHttpRequest $request): SsrHttpResponse + { if (function_exists('curl_init')) { - return $this->finish($this->postWithCurl($url, $json, $timeoutSeconds, $connectTimeoutSeconds, $headers)); + return $this->withCurl($request); } - return $this->finish($this->postWithStreams($url, $json, $timeoutSeconds, $headers)); + return $this->withStreams($request); } - /** - * @param array<string, string> $headers - */ - private function postWithCurl( - string $url, - string $json, - float $timeoutSeconds, - float $connectTimeoutSeconds, - array $headers, - ): string { - $headerLines = ['Content-Type: application/json']; - foreach ($headers as $name => $value) { - $headerLines[] = $name . ': ' . $value; + private function withCurl(SsrHttpRequest $request): SsrHttpResponse + { + $handle = curl_init($request->url); + if ($handle === false) { + throw new SsrUnavailable('SSR request failed'); } - $handle = curl_init($url); - if ($handle === false) { + $method = $request->method; + if ($method === '') { throw new SsrUnavailable('SSR request failed'); } - curl_setopt_array($handle, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $json, + $headerLines = $this->headerLines($request); + $opts = [ + CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headerLines, CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round($connectTimeoutSeconds * 1000)), - CURLOPT_TIMEOUT_MS => max(1, (int) round($timeoutSeconds * 1000)), - ]); + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => 0, + CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round($request->connectTimeoutSeconds * 1000)), + CURLOPT_TIMEOUT_MS => max(1, (int) round($request->timeoutSeconds * 1000)), + ]; + if ($request->body !== null) { + $opts[CURLOPT_POSTFIELDS] = $request->body; + } + + curl_setopt_array($handle, $opts); $body = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($handle, CURLINFO_CONTENT_TYPE); $error = curl_error($handle); unset($handle); - if ($body === false) { + if (!is_string($body)) { throw new SsrUnavailable($error !== '' ? $error : 'SSR request failed'); } - if ($status >= 400 && $status < 500) { - throw new SsrClientError('SSR HTTP status ' . $status); - } - if ($status < 200 || $status >= 300) { - throw new SsrUnavailable('SSR HTTP status ' . $status); - } - - return $body; + return new SsrHttpResponse( + $status, + is_string($contentType) && $contentType !== '' ? $contentType : null, + $body, + ); } - /** - * @param array<string, string> $headers - */ - private function postWithStreams(string $url, string $json, float $timeoutSeconds, array $headers): string + private function withStreams(SsrHttpRequest $request): SsrHttpResponse { - $headerLines = [ - 'Content-Type: application/json', - 'Content-Length: ' . strlen($json), + $headerLines = $this->headerLines($request); + if ($request->body !== null) { + $headerLines[] = 'Content-Length: ' . strlen($request->body); + } + + $http = [ + 'method' => $request->method, + 'header' => implode("\r\n", $headerLines) . "\r\n", + 'timeout' => $request->timeoutSeconds, + 'ignore_errors' => true, + 'follow_location' => 0, ]; - foreach ($headers as $name => $value) { - $headerLines[] = $name . ': ' . $value; + if ($request->body !== null) { + $http['content'] = $request->body; } - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", $headerLines) . "\r\n", - 'content' => $json, - 'timeout' => $timeoutSeconds, - 'ignore_errors' => true, - ], - ]); - - $body = @file_get_contents($url, false, $context); + $body = @file_get_contents($request->url, false, stream_context_create(['http' => $http])); if ($body === false) { throw new SsrUnavailable('SSR request failed'); } $status = 0; - if (isset($http_response_header[0]) - && preg_match('/\s(\d{3})\s/', $http_response_header[0], $matches) === 1 + $contentType = null; + $responseHeaders = $this->lastResponseHeaders(); + if (isset($responseHeaders[0]) + && preg_match('/\s(\d{3})\s/', $responseHeaders[0], $matches) === 1 ) { $status = (int) $matches[1]; } + foreach ($responseHeaders as $line) { + if (stripos($line, 'Content-Type:') === 0) { + $contentType = trim(substr($line, strlen('Content-Type:'))); + break; + } + } + + return new SsrHttpResponse($status, $contentType, $body); + } - if ($status >= 400 && $status < 500) { - throw new SsrClientError('SSR HTTP status ' . $status); + /** + * @return list<string> + */ + private function headerLines(SsrHttpRequest $request): array + { + $lines = ['Expect:']; + $headers = $request->headers; + if ($request->body !== null && !isset($headers['Content-Type']) && !isset($headers['content-type'])) { + $headers['Content-Type'] = 'application/json'; } - if ($status < 200 || $status >= 300) { - throw new SsrUnavailable('SSR HTTP status ' . $status); + foreach ($headers as $name => $value) { + $lines[] = $name . ': ' . $value; } - return $body; + return $lines; } - private function finish(string $body): SsrDocument + /** @return list<string> */ + private function lastResponseHeaders(): array { - return SsrV1Document::fromResponse($body); + if (function_exists('http_get_last_response_headers')) { + $fetched = http_get_last_response_headers(); + if (!is_array($fetched)) { + return []; + } + $headers = []; + foreach ($fetched as $line) { + if (is_string($line)) { + $headers[] = $line; + } + } + + return $headers; + } + + $raw = $GLOBALS['http_response_header'] ?? []; + if (!is_array($raw)) { + return []; + } + + $headers = []; + foreach ($raw as $line) { + if (is_string($line)) { + $headers[] = $line; + } + } + + return $headers; } } diff --git a/src/Embed/Psr16SsrResultCache.php b/src/Embed/Psr16SsrResultCache.php new file mode 100644 index 0000000..f731462 --- /dev/null +++ b/src/Embed/Psr16SsrResultCache.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +use Psr\SimpleCache\CacheInterface; + +/** + * {@see SsrResultCache} over any PSR-16 implementation (Redis, APCu, filesystem). + */ +final class Psr16SsrResultCache implements SsrResultCache +{ + public function __construct(private readonly CacheInterface $cache) + { + } + + public function get(string $key): ?SsrDocument + { + $value = $this->cache->get($key); + + return $value instanceof SsrDocument ? $value : null; + } + + public function set(string $key, SsrDocument $document, int $ttl): void + { + $this->cache->set($key, $document, $ttl); + } + + public function flush(): void + { + $this->cache->clear(); + } +} diff --git a/src/Embed/SsrPurgeResult.php b/src/Embed/PurgeResult.php similarity index 83% rename from src/Embed/SsrPurgeResult.php rename to src/Embed/PurgeResult.php index 07571a1..679f96f 100644 --- a/src/Embed/SsrPurgeResult.php +++ b/src/Embed/PurgeResult.php @@ -5,13 +5,13 @@ namespace OpenMapsight\Embed; /** - * Outcome of {@see SsrPublish::afterFeatureSourcePublish()}. + * Outcome of {@see SsrClient::purge()}. * * `sidecarPurged` is true only after a successful sidecar POST. A failed * purge leaves the PHP fragment cache alone so the next render cannot * refill it from a still-stale sidecar. */ -final class SsrPurgeResult +final class PurgeResult { /** * @param list<string> $deletedKeys diff --git a/src/Embed/RenderedEmbed.php b/src/Embed/RenderedEmbed.php index 83d6f67..a36ecb8 100644 --- a/src/Embed/RenderedEmbed.php +++ b/src/Embed/RenderedEmbed.php @@ -5,16 +5,24 @@ namespace OpenMapsight\Embed; /** - * Full embed fragment (CSS + container + boot) plus optional document meta. + * Embed fragment parts plus optional document meta and SSR outcome. * * Apply {@see $pageMeta} with the CMS title / OG APIs (or {@see PageMetaTags}), - * then inject {@see $html} into the mid-page slot. Do not put head tags in $html. + * put {@see $stylesheetHtml} / {@see $preloadHtml} in `<head>` when you can, + * then inject {@see $html} (or the remaining parts) into the page slot. */ final class RenderedEmbed { public function __construct( public readonly string $html, public readonly ?PlacePageMeta $pageMeta = null, + public readonly SsrOutcome $ssr = SsrOutcome::Disabled, + public readonly ?string $ssrReason = null, + public readonly float $ssrDurationMs = 0.0, + public readonly string $stylesheetHtml = '', + public readonly string $preloadHtml = '', + public readonly string $containerHtml = '', + public readonly string $bootScriptHtml = '', ) { } } diff --git a/src/Embed/Renderer.php b/src/Embed/Renderer.php index e72a3dc..2635ad2 100644 --- a/src/Embed/Renderer.php +++ b/src/Embed/Renderer.php @@ -5,160 +5,73 @@ namespace OpenMapsight\Embed; /** - * Emits an embed fragment: CSS + mount container (+ optional SSR shell) + mountEmbed boot. + * Emits an embed fragment: CSS + modulepreload + mount container + mountEmbed boot. * - * The mount container is an empty element. Preset chrome and page wrappers - * are host-owned. Use {@see renderDocument()} when the page URL may have - * `?feature=` or `?module=` so the host can apply {@see PlacePageMeta} to - * the document head. {@see render()} is the HTML-only path (same fragment, - * meta discarded). + * The mount container is an empty element on a miss. Preset chrome and page + * wrappers are host-owned. {@see render()} is the only entry point. */ final class Renderer { - private readonly SsrCircuitBreaker $circuitBreaker; - - public function __construct( - private readonly ?SsrTransport $ssrTransport = null, - ?SsrCircuitBreaker $circuitBreaker = null, - private readonly ?SsrResultCache $resultCache = null, - ) { - $this->circuitBreaker = $circuitBreaker ?? new ProcessSsrCircuitBreaker(); - } - - public function render(EmbedRequest $request): string + public function __construct(private readonly ?SsrClient $ssr = null) { - return $this->renderDocument($request)->html; } - public function renderDocument(EmbedRequest $request): RenderedEmbed + public function render(EmbedRequest $request): RenderedEmbed { $assetBase = rtrim($request->assetBase, '/'); - $parts = [ - sprintf( - '<link rel="stylesheet" href="%s">', - $this->escapeAttr($this->assetUrl($assetBase, 'mapsight.css', $request->assetVersion)), - ), - ]; - - $containerHtml = null; + $stylesheet = sprintf( + '<link rel="stylesheet" href="%s">', + $this->escapeAttr($this->assetUrl($assetBase, 'mapsight.css', $request->assetVersion)), + ); + $embedUrl = $this->assetUrl($assetBase, 'embed.js', $request->assetVersion); + $presetUrl = $this->assetUrl($assetBase, $request->preset . '.js', $request->assetVersion); + $preload = sprintf( + '<link rel="modulepreload" href="%s">'."\n".'<link rel="modulepreload" href="%s">', + $this->escapeAttr($embedUrl), + $this->escapeAttr($presetUrl), + ); + $pageMeta = null; $ssrSkipped = false; + $outcome = SsrOutcome::Disabled; + $reason = null; + $durationMs = 0.0; - if ($request->ssrUrl !== null && $request->ssrUrl !== '') { - $cacheKey = SsrCacheKey::for($request); - $cached = $this->resultCache?->get($cacheKey); - if ($cached !== null && $cached->html !== '') { - $containerHtml = $cached->html; - $pageMeta = $this->pageMetaForRequest($request, $cached->pageMeta); - } elseif (!$this->circuitBreaker->allow()) { - $ssrSkipped = true; + if ($this->ssr === null) { + $containerHtml = $this->emptyContainer($request); + } else { + $resolved = $this->ssr->resolve($request); + $outcome = $resolved->outcome; + $reason = $resolved->reason; + $durationMs = $resolved->durationMs; + if ($resolved->document !== null && $resolved->document->html !== '') { + $containerHtml = trim($resolved->document->html); + $pageMeta = $resolved->document->pageMeta; } else { - try { - $transport = $this->ssrTransport ?? new NativeSsrTransport(); - $renderUrl = rtrim($request->ssrUrl, '/') . '/v1/render'; - $payload = [ - 'v' => 1, - 'preset' => $request->preset, - 'options' => $this->ssrOptions($request), - ]; - if ($request->requestId !== null && $request->requestId !== '') { - $payload['requestId'] = $request->requestId; - } - if ($request->assetVersion !== null && $request->assetVersion !== '') { - $payload['assetVersion'] = $request->assetVersion; - } - $document = $transport->postJson( - $renderUrl, - $payload, - $request->ssrTimeoutSeconds, - self::ssrHeaders($request), - $request->ssrConnectTimeoutSeconds, - ); - $this->circuitBreaker->recordSuccess(); - $containerHtml = $document->html; - $pageMeta = $this->pageMetaForRequest($request, $document->pageMeta); - $this->resultCache?->set( - $cacheKey, - new SsrDocument($containerHtml, $pageMeta), - ); - } catch (SsrUnavailable) { - $this->circuitBreaker->recordFailure(); - $ssrSkipped = true; - } catch (\Throwable) { - $ssrSkipped = true; - } + $ssrSkipped = $outcome !== SsrOutcome::Disabled; + $containerHtml = $this->emptyContainer($request); } } + $boot = $this->bootScript($request, $embedUrl, $presetUrl); + $parts = [$stylesheet, $preload]; if ($ssrSkipped) { $parts[] = '<!-- mapsight-ssr-skipped -->'; } - - if ($containerHtml === null) { - $parts[] = $this->emptyContainer($request); - } else { - $parts[] = trim($containerHtml); - } - - $parts[] = $this->bootScript($request, $assetBase); - - return new RenderedEmbed(implode("\n", $parts) . "\n", $pageMeta); - } - - /** - * @return array<string, mixed> - */ - private function ssrOptions(EmbedRequest $request): array - { - $options = array_merge($request->config, [ - 'containerId' => $request->containerId, - ]); - if ($request->containerClassName !== '') { - $options['containerClassName'] = $request->containerClassName; - } - $requestUrl = $request->resolvedRequestUrl(); - if ($requestUrl !== null) { - $options['requestUrl'] = $requestUrl; - } - $pageOrigin = $request->resolvedPageOrigin(); - if ($pageOrigin !== null) { - $options['pageOrigin'] = $pageOrigin; - } - $ogImage = $request->resolvedOgImage(); - if ($ogImage !== null) { - $options['ogImage'] = $ogImage; - } - if ($request->locale !== null && $request->locale !== '') { - $options['locale'] = $request->locale; - } - if ($request->deviceClass !== null && $request->deviceClass !== '') { - $options['deviceClass'] = $request->deviceClass; - } - - return $options; - } - - private function pageMetaForRequest(EmbedRequest $request, ?PlacePageMeta $pageMeta): ?PlacePageMeta - { - if (!$request->requestHasPageMetaParam()) { - return null; - } - - return $pageMeta; - } - - /** @return array<string, string> */ - private static function ssrHeaders(EmbedRequest $request): array - { - $headers = ['Accept' => 'application/json']; - if ($request->requestId !== null && $request->requestId !== '') { - $headers['X-Request-Id'] = $request->requestId; - } - if ($request->assetVersion !== null && $request->assetVersion !== '') { - $headers['X-Mapsight-Asset-Version'] = $request->assetVersion; - } - - return $headers; + $parts[] = $containerHtml; + $parts[] = $boot; + + return new RenderedEmbed( + implode("\n", $parts) . "\n", + $pageMeta, + $outcome, + $reason, + $durationMs, + $stylesheet, + $preload, + $containerHtml, + $boot, + ); } private function emptyContainer(EmbedRequest $request): string @@ -175,7 +88,7 @@ private function emptyContainer(EmbedRequest $request): string ); } - private function bootScript(EmbedRequest $request, string $assetBase): string + private function bootScript(EmbedRequest $request, string $embedUrl, string $presetUrl): string { $preset = $request->preset; $configJson = json_encode( @@ -187,16 +100,17 @@ private function bootScript(EmbedRequest $request, string $assetBase): string | JSON_HEX_APOS | JSON_HEX_QUOT, ); - - $embedUrl = $this->assetUrl($assetBase, 'embed.js', $request->assetVersion); - $presetUrl = $this->assetUrl($assetBase, $preset . '.js', $request->assetVersion); + $nonce = ''; + if ($request->scriptNonce !== null && $request->scriptNonce !== '') { + $nonce = ' nonce="' . $this->escapeAttr($request->scriptNonce) . '"'; + } return <<<HTML -<script type="module"> -import {mountEmbed} from "{$this->escapeJsDoubleQuoted($embedUrl)}"; -import {{$preset}} from "{$this->escapeJsDoubleQuoted($presetUrl)}"; +<script type="module"{$nonce}> +import {mountEmbed} from {$this->jsString($embedUrl)}; +import {{$preset}} from {$this->jsString($presetUrl)}; -mountEmbed("{$this->escapeJsDoubleQuoted($request->containerId)}", +mountEmbed({$this->jsString($request->containerId)}, {$preset}({$configJson}), ); </script> @@ -218,12 +132,16 @@ private function escapeAttr(string $value): string return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } - private function escapeJsDoubleQuoted(string $value): string + private function jsString(string $value): string { - return str_replace( - ['\\', '"', "\n", "\r"], - ['\\\\', '\\"', '\\n', '\\r'], + return json_encode( $value, + JSON_THROW_ON_ERROR + | JSON_UNESCAPED_SLASHES + | JSON_HEX_TAG + | JSON_HEX_AMP + | JSON_HEX_APOS + | JSON_HEX_QUOT, ); } } diff --git a/src/Embed/SsrCacheKey.php b/src/Embed/SsrCacheKey.php index f8f6dff..6baf228 100644 --- a/src/Embed/SsrCacheKey.php +++ b/src/Embed/SsrCacheKey.php @@ -6,16 +6,15 @@ /** * Stable key for a v1 SSR placement: config + locale/deviceClass + assetVersion - * + requestUrl + contract. requestUrl must be in the key so `?module=` / - * `?feature=` (and similar search) does not reuse another URL's HTML. - * pageOrigin / ogImage are in the key so cached pageMeta stays absolute. + * + normalised requestUrl + contract. pageOrigin / ogImage stay in the key + * so cached pageMeta stays absolute. */ final class SsrCacheKey { - public static function for(EmbedRequest $request): string + public static function for(EmbedRequest $request, ?string $requestUrl): string { $payload = [ - 'v' => 1, + 'v' => SsrContract::VERSION, 'preset' => $request->preset, 'containerId' => $request->containerId, 'containerClassName' => $request->containerClassName, @@ -23,9 +22,9 @@ public static function for(EmbedRequest $request): string 'assetVersion' => $request->assetVersion, 'locale' => $request->locale, 'deviceClass' => $request->deviceClass, - 'requestUrl' => $request->resolvedRequestUrl(), - 'pageOrigin' => $request->resolvedPageOrigin(), - 'ogImage' => $request->resolvedOgImage(), + 'requestUrl' => $requestUrl, + 'pageOrigin' => self::nonEmpty($request->pageOrigin), + 'ogImage' => self::nonEmpty($request->ogImage), ]; return hash( @@ -34,6 +33,15 @@ public static function for(EmbedRequest $request): string ); } + private static function nonEmpty(?string $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return $value; + } + private static function normalize(mixed $value): mixed { if (!is_array($value)) { diff --git a/src/Embed/SsrClient.php b/src/Embed/SsrClient.php new file mode 100644 index 0000000..bfd0b40 --- /dev/null +++ b/src/Embed/SsrClient.php @@ -0,0 +1,456 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +/** + * Configured sidecar client: render, purge, health, warm. Built once per process. + */ +final class SsrClient +{ + public const DEFAULT_CACHE_TTL = 3600; + + public const DEFAULT_MAX_RESPONSE_BYTES = 1048576; + + public const DEFAULT_MAX_STATE_BYTES = 262144; + + public const MAX_REQUEST_URL_BYTES = 2048; + + private const MAX_BODY_BYTES = 262144; + + /** @var list<string> */ + public const DEFAULT_SHARE_PARAMS = ['feature', 'module']; + + private readonly SsrTransport $transport; + + private readonly SsrCircuitBreaker $breaker; + + private readonly LoggerInterface $logger; + + /** + * @param list<string> $shareParams + */ + public function __construct( + private readonly string $ssrUrl, + private readonly float $timeoutSeconds = 2.0, + private readonly float $connectTimeoutSeconds = 0.1, + ?SsrTransport $transport = null, + private readonly ?SsrResultCache $resultCache = null, + private readonly int $cacheTtlSeconds = self::DEFAULT_CACHE_TTL, + ?SsrCircuitBreaker $breaker = null, + ?LoggerInterface $logger = null, + private readonly int $maxResponseBytes = self::DEFAULT_MAX_RESPONSE_BYTES, + private readonly int $maxStateBytes = self::DEFAULT_MAX_STATE_BYTES, + private readonly array $shareParams = self::DEFAULT_SHARE_PARAMS, + ) { + if ($this->ssrUrl === '') { + throw new \InvalidArgumentException('ssrUrl must not be empty'); + } + if ($this->timeoutSeconds <= 0 || $this->connectTimeoutSeconds <= 0) { + throw new \InvalidArgumentException('SSR timeouts must be positive'); + } + if ($this->connectTimeoutSeconds > $this->timeoutSeconds) { + throw new \InvalidArgumentException('connectTimeoutSeconds must not exceed timeoutSeconds'); + } + if ($this->cacheTtlSeconds < 1) { + throw new \InvalidArgumentException('cacheTtlSeconds must be >= 1'); + } + $this->transport = $transport ?? new NativeSsrTransport(); + $this->breaker = $breaker ?? new ProcessSsrCircuitBreaker(); + $this->logger = $logger ?? new NullLogger(); + } + + public function resolve(EmbedRequest $request, bool $force = false): SsrRenderResult + { + $started = microtime(true); + $requestUrl = $this->normalizeRequestUrl($request->requestUrl); + $cacheKey = SsrCacheKey::for($request, $requestUrl); + + if (!$force) { + $cached = $this->resultCache?->get($cacheKey); + if ($cached !== null && $cached->html !== '') { + $this->logger->debug('mapsight ssr cache hit', [ + 'request_id' => $request->requestId, + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + ]); + + return new SsrRenderResult( + SsrOutcome::Cached, + $this->withPageMetaGate($cached, $requestUrl), + null, + $this->elapsedMs($started), + ); + } + + if (!$this->breaker->allow()) { + $this->logger->warning('mapsight ssr skipped', [ + 'reason' => 'breaker_open', + 'status' => null, + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + 'elapsed_ms' => $this->elapsedMs($started), + 'request_id' => $request->requestId, + ]); + + return new SsrRenderResult( + SsrOutcome::SkippedBreaker, + null, + 'breaker_open', + $this->elapsedMs($started), + ); + } + } + + try { + $document = $this->fetchDocument($request, $requestUrl); + $this->breaker->recordSuccess(); + $this->resultCache?->set($cacheKey, $document, $this->cacheTtlSeconds); + + return new SsrRenderResult( + SsrOutcome::Rendered, + $this->withPageMetaGate($document, $requestUrl), + null, + $this->elapsedMs($started), + ); + } catch (SsrUnavailable $error) { + $this->noteBreakerFailure(); + $this->logSkip($request, $error, $started); + + return new SsrRenderResult( + SsrOutcome::SkippedError, + null, + $error->getMessage(), + $this->elapsedMs($started), + ); + } catch (\Throwable $error) { + $this->logSkip($request, $error, $started); + + return new SsrRenderResult( + SsrOutcome::SkippedError, + null, + $error->getMessage(), + $this->elapsedMs($started), + ); + } + } + + public function warm(EmbedRequest $request): SsrOutcome + { + return $this->resolve($request, true)->outcome; + } + + public function health(): bool + { + try { + $response = $this->transport->send(new SsrHttpRequest( + 'GET', + rtrim($this->ssrUrl, '/') . '/health', + ['Accept' => 'application/json, text/plain'], + null, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + + return $response->status >= 200 && $response->status < 300; + } catch (\Throwable) { + return false; + } + } + + /** + * @param list<string> $urls + */ + public function purge(array $urls = []): PurgeResult + { + $payload = $this->purgePayload($urls); + try { + $json = $payload === [] + ? '{}' + : json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $response = $this->transport->send(new SsrHttpRequest( + 'POST', + rtrim($this->ssrUrl, '/') . '/purge', + ['Accept' => 'application/json'], + $json, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + $deleted = $this->decodePurgeResponse($response); + } catch (\Throwable $error) { + $this->logger->warning('mapsight ssr purge failed', [ + 'reason' => $error->getMessage(), + 'url' => rtrim($this->ssrUrl, '/') . '/purge', + ]); + + return new PurgeResult(false, []); + } + + $this->resultCache?->flush(); + + return new PurgeResult(true, $deleted); + } + + public function normalizeRequestUrl(?string $url): ?string + { + if ($url === null || $url === '') { + return null; + } + + if (strlen($url) > self::MAX_REQUEST_URL_BYTES) { + $path = parse_url($url, PHP_URL_PATH); + + return is_string($path) && $path !== '' ? $path : '/'; + } + + $parts = parse_url($url); + if (!is_array($parts)) { + return null; + } + + $path = $parts['path'] ?? ''; + $kept = []; + $query = $parts['query'] ?? ''; + if ($query !== '') { + parse_str($query, $params); + foreach ($this->shareParams as $name) { + $value = $params[$name] ?? null; + if (is_string($value) && $value !== '') { + $kept[$name] = $value; + } + } + ksort($kept); + } + + $out = $path; + if ($kept !== []) { + $out .= '?' . http_build_query($kept); + } + + return $out !== '' ? $out : null; + } + + public function requestHasPageMetaParam(?string $normalizedRequestUrl): bool + { + if ($normalizedRequestUrl === null) { + return false; + } + $query = parse_url($normalizedRequestUrl, PHP_URL_QUERY); + if (!is_string($query) || $query === '') { + return false; + } + parse_str($query, $params); + foreach ($this->shareParams as $name) { + $value = $params[$name] ?? null; + if (is_string($value) && $value !== '') { + return true; + } + } + + return false; + } + + private function fetchDocument(EmbedRequest $request, ?string $requestUrl): SsrDocument + { + $payload = [ + 'v' => SsrContract::VERSION, + 'preset' => $request->preset, + 'options' => $this->ssrOptions($request, $requestUrl), + ]; + if ($request->requestId !== null && $request->requestId !== '') { + $payload['requestId'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $payload['assetVersion'] = $request->assetVersion; + } + + try { + $json = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new SsrClientError('SSR request body is not JSON', 0, $e); + } + if (strlen($json) > self::MAX_BODY_BYTES) { + throw new SsrClientError('SSR request body exceeds size cap'); + } + + $headers = ['Accept' => 'application/json']; + if ($request->requestId !== null && $request->requestId !== '') { + $headers['X-Request-Id'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $headers['X-Mapsight-Asset-Version'] = $request->assetVersion; + } + + $url = rtrim($this->ssrUrl, '/') . '/v1/render'; + $response = $this->transport->send(new SsrHttpRequest( + 'POST', + $url, + $headers, + $json, + $this->timeoutSeconds, + $this->connectTimeoutSeconds, + )); + + if (strlen($response->body) > $this->maxResponseBytes) { + throw new SsrClientError('SSR response exceeds size cap'); + } + if ($response->status >= 400 && $response->status < 500) { + throw new SsrClientError('SSR HTTP status ' . $response->status); + } + if ($response->status < 200 || $response->status >= 300) { + throw new SsrUnavailable('SSR HTTP status ' . $response->status); + } + if (!$this->isJsonContentType($response->contentType)) { + throw new SsrClientError('SSR response Content-Type must be application/json'); + } + + return SsrV1Document::fromResponse( + $response->body, + $request->containerId, + $this->maxStateBytes, + ); + } + + /** + * @return array<string, mixed> + */ + private function ssrOptions(EmbedRequest $request, ?string $requestUrl): array + { + $options = $request->config; + $options['containerId'] = $request->containerId; + if ($request->containerClassName !== '') { + $options['containerClassName'] = $request->containerClassName; + } + if ($requestUrl !== null) { + $options['requestUrl'] = $requestUrl; + } + if ($request->pageOrigin !== null && $request->pageOrigin !== '') { + $options['pageOrigin'] = rtrim($request->pageOrigin, '/'); + } + if ($request->ogImage !== null && $request->ogImage !== '') { + $options['ogImage'] = $request->ogImage; + } + if ($request->locale !== null && $request->locale !== '') { + $options['locale'] = $request->locale; + } + if ($request->deviceClass !== null && $request->deviceClass !== '') { + $options['deviceClass'] = $request->deviceClass; + } + + return $options; + } + + private function withPageMetaGate(SsrDocument $document, ?string $requestUrl): SsrDocument + { + if ($this->requestHasPageMetaParam($requestUrl)) { + return $document; + } + + return new SsrDocument($document->html, null); + } + + /** + * @param list<string> $urls + * @return array<string, mixed> + */ + private function purgePayload(array $urls): array + { + if ($urls === []) { + return []; + } + + $filtered = array_values(array_filter( + $urls, + static fn (string $url): bool => $url !== '', + )); + if ($filtered === []) { + throw new \InvalidArgumentException( + 'purge received only empty urls; refusing to purge the whole sidecar cache', + ); + } + + return ['urls' => $filtered]; + } + + /** + * @return list<string> + */ + private function decodePurgeResponse(SsrHttpResponse $response): array + { + if ($response->status === 204) { + return []; + } + if ($response->status < 200 || $response->status >= 300) { + throw new SsrUnavailable('SSR purge HTTP status ' . $response->status); + } + + $trimmed = trim($response->body); + if ($trimmed === '') { + return []; + } + + try { + $data = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new SsrClientError('SSR purge response is not JSON', 0, $e); + } + if (!is_array($data) || !array_is_list($data)) { + throw new SsrClientError('SSR purge response must be a JSON array'); + } + + $keys = []; + foreach ($data as $item) { + if (!is_string($item)) { + throw new SsrClientError('SSR purge response must be a JSON string array'); + } + $keys[] = $item; + } + + return $keys; + } + + private function isJsonContentType(?string $contentType): bool + { + if ($contentType === null || $contentType === '') { + return false; + } + + return str_starts_with(strtolower($contentType), 'application/json'); + } + + private function noteBreakerFailure(): void + { + $wasOpen = !$this->breaker->allow(); + $this->breaker->recordFailure(); + if (!$wasOpen && !$this->breaker->allow()) { + $this->logger->info('mapsight ssr breaker opened', []); + } + } + + private function logSkip(EmbedRequest $request, \Throwable $error, float $started): void + { + $this->logger->warning('mapsight ssr skipped', [ + 'reason' => $error->getMessage(), + 'status' => self::statusFromMessage($error->getMessage()), + 'url' => rtrim($this->ssrUrl, '/') . '/v1/render', + 'elapsed_ms' => $this->elapsedMs($started), + 'request_id' => $request->requestId, + ]); + } + + private static function statusFromMessage(string $message): ?int + { + if (preg_match('/SSR HTTP status (\d+)/', $message, $matches) === 1) { + return (int) $matches[1]; + } + + return null; + } + + private function elapsedMs(float $started): float + { + return round((microtime(true) - $started) * 1000, 3); + } +} diff --git a/src/Embed/SsrContract.php b/src/Embed/SsrContract.php new file mode 100644 index 0000000..6756344 --- /dev/null +++ b/src/Embed/SsrContract.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +/** + * Embed protocol version used in the sidecar payload, cache key, and parser. + */ +final class SsrContract +{ + public const VERSION = 1; +} diff --git a/src/Embed/SsrHttpRequest.php b/src/Embed/SsrHttpRequest.php new file mode 100644 index 0000000..e16e757 --- /dev/null +++ b/src/Embed/SsrHttpRequest.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +/** + * Raw HTTP call issued by {@see SsrClient}. + * + * @param array<string, string> $headers + */ +final class SsrHttpRequest +{ + /** + * @param array<string, string> $headers + */ + public function __construct( + public readonly string $method, + public readonly string $url, + public readonly array $headers = [], + public readonly ?string $body = null, + public readonly float $timeoutSeconds = 2.0, + public readonly float $connectTimeoutSeconds = 0.1, + ) { + } +} diff --git a/src/Embed/SsrHttpResponse.php b/src/Embed/SsrHttpResponse.php new file mode 100644 index 0000000..99582b1 --- /dev/null +++ b/src/Embed/SsrHttpResponse.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +/** + * Raw HTTP result. The client owns status classification and v1 parsing. + */ +final class SsrHttpResponse +{ + public function __construct( + public readonly int $status, + public readonly ?string $contentType, + public readonly string $body, + ) { + } +} diff --git a/src/Embed/SsrOutcome.php b/src/Embed/SsrOutcome.php new file mode 100644 index 0000000..9a2c8de --- /dev/null +++ b/src/Embed/SsrOutcome.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +enum SsrOutcome: string +{ + case Disabled = 'disabled'; + case Rendered = 'rendered'; + case Cached = 'cached'; + case SkippedBreaker = 'skipped_breaker'; + case SkippedError = 'skipped_error'; +} diff --git a/src/Embed/SsrPublish.php b/src/Embed/SsrPublish.php index 2464323..062aa30 100644 --- a/src/Embed/SsrPublish.php +++ b/src/Embed/SsrPublish.php @@ -5,17 +5,13 @@ namespace OpenMapsight\Embed; /** - * CMS / pulp publish hook: drop sidecar xhr-json documents, then flush PHP fragments. + * Publish hook: drop sidecar documents, then flush PHP fragments. */ final class SsrPublish { - public function __construct( - private readonly ?string $ssrUrl = null, - private readonly ?SsrResultCache $resultCache = null, - private readonly ?SsrPurgeTransport $transport = null, - private readonly float $timeoutSeconds = 2.0, - private readonly float $connectTimeoutSeconds = 0.1, - ) {} + public function __construct(private readonly SsrClient $ssr) + { + } /** * Call before the next page render. Prefer absolute GeoJSON URLs. @@ -24,67 +20,10 @@ public function __construct( * A list that filters down to no URLs (e.g. `['']`) is an error, not a * purge-all. A failed sidecar POST does not flush the PHP cache. * - * @param list<string>|null $urls + * @param list<string> $urls */ - public function afterFeatureSourcePublish(?array $urls = null): SsrPurgeResult + public function purge(array $urls = []): PurgeResult { - if ($this->ssrUrl === null || $this->ssrUrl === '') { - $this->resultCache?->flush(); - - return new SsrPurgeResult(false, []); - } - - $payload = $this->purgePayload($urls); - - try { - $transport = $this->transport ?? new NativeSsrPurgeTransport(); - $deleted = $transport->postPurge( - rtrim($this->ssrUrl, '/') . '/purge', - $payload, - $this->timeoutSeconds, - $this->connectTimeoutSeconds, - ); - } catch (\Throwable $error) { - error_log('mapsight ssr purge failed: ' . $error->getMessage()); - - return new SsrPurgeResult(false, []); - } - - $this->resultCache?->flush(); - - return new SsrPurgeResult(true, $deleted); - } - - public static function fromEnv(?SsrResultCache $resultCache = null): self - { - $ssrUrl = getenv('MAPSIGHT_SSR_URL'); - if (!is_string($ssrUrl) || $ssrUrl === '') { - $ssrUrl = null; - } - - return new self($ssrUrl, $resultCache); - } - - /** - * @param list<string>|null $urls - * @return array<string, mixed> - */ - private function purgePayload(?array $urls): array - { - if ($urls === null || $urls === []) { - return []; - } - - $filtered = array_values(array_filter( - $urls, - static fn (mixed $url): bool => is_string($url) && $url !== '', - )); - if ($filtered === []) { - throw new \InvalidArgumentException( - 'afterFeatureSourcePublish received only empty urls; refusing to purge the whole sidecar cache', - ); - } - - return ['urls' => $filtered]; + return $this->ssr->purge($urls); } } diff --git a/src/Embed/SsrPurgeTransport.php b/src/Embed/SsrPurgeTransport.php deleted file mode 100644 index 3446021..0000000 --- a/src/Embed/SsrPurgeTransport.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace OpenMapsight\Embed; - -interface SsrPurgeTransport -{ - /** - * @param array<string, mixed> $payload - * - * @return list<string> - * - * @throws \Throwable when the sidecar is unreachable or returns a non-success body - */ - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array; -} diff --git a/src/Embed/SsrRenderResult.php b/src/Embed/SsrRenderResult.php new file mode 100644 index 0000000..01a5d6f --- /dev/null +++ b/src/Embed/SsrRenderResult.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +/** + * Internal resolve() result: document plus why SSR was used or skipped. + */ +final class SsrRenderResult +{ + public function __construct( + public readonly SsrOutcome $outcome, + public readonly ?SsrDocument $document = null, + public readonly ?string $reason = null, + public readonly float $durationMs = 0.0, + ) { + } +} diff --git a/src/Embed/SsrResultCache.php b/src/Embed/SsrResultCache.php index 2a1bd25..57e9470 100644 --- a/src/Embed/SsrResultCache.php +++ b/src/Embed/SsrResultCache.php @@ -5,14 +5,14 @@ namespace OpenMapsight\Embed; /** - * Optional PHP-side store of a successful SSR document (Redis/APCu later). + * Optional PHP-side store of a successful SSR document. * Must keep html + pageMeta together so a cache hit can still set the head. */ interface SsrResultCache { public function get(string $key): ?SsrDocument; - public function set(string $key, SsrDocument $document): void; + public function set(string $key, SsrDocument $document, int $ttl): void; /** Drop every stored fragment so the next render cannot reuse pre-publish HTML. */ public function flush(): void; diff --git a/src/Embed/SsrTransport.php b/src/Embed/SsrTransport.php index 5838621..9ed05b3 100644 --- a/src/Embed/SsrTransport.php +++ b/src/Embed/SsrTransport.php @@ -4,19 +4,12 @@ namespace OpenMapsight\Embed; +/** + * Raw HTTP to the sidecar. One interface for render, purge, and health. + * + * @throws SsrUnavailable on connect errors, timeouts, and other transport failures + */ interface SsrTransport { - /** - * @param array<string, mixed> $payload - * @param array<string, string> $headers - * - * @throws \Throwable when the sidecar is unreachable or returns a non-success body - */ - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument; + public function send(SsrHttpRequest $request): SsrHttpResponse; } diff --git a/src/Embed/SsrV1Document.php b/src/Embed/SsrV1Document.php index 99c1fd8..36c0a91 100644 --- a/src/Embed/SsrV1Document.php +++ b/src/Embed/SsrV1Document.php @@ -9,8 +9,11 @@ */ final class SsrV1Document { - public static function fromResponse(string $body): SsrDocument - { + public static function fromResponse( + string $body, + string $containerId, + int $maxStateBytes = 262144, + ): SsrDocument { if (str_starts_with($body, "\xEF\xBB\xBF")) { $body = substr($body, 3); } @@ -21,12 +24,15 @@ public static function fromResponse(string $body): SsrDocument throw new SsrClientError('SSR v1 response is not JSON', 0, $e); } - if (!is_array($data) || ($data['v'] ?? null) !== 1) { - throw new SsrClientError('SSR v1 response missing v=1'); + if (!is_array($data) || ($data['v'] ?? null) !== SsrContract::VERSION) { + throw new SsrClientError('SSR v1 response missing v=' . SsrContract::VERSION); } if (isset($data['error'])) { - $code = is_array($data['error']) ? (string) ($data['error']['code'] ?? 'RENDER_FAILED') : 'RENDER_FAILED'; + $code = 'RENDER_FAILED'; + if (is_array($data['error']) && isset($data['error']['code']) && is_string($data['error']['code'])) { + $code = $data['error']['code']; + } throw new SsrClientError('SSR v1 error ' . $code); } @@ -40,26 +46,37 @@ public static function fromResponse(string $body): SsrDocument } return new SsrDocument( - self::withDehydratedState($html, $data['state']), + self::withDehydratedState($html, $data['state'], $containerId, $maxStateBytes), PlacePageMeta::tryFrom($data['pageMeta'] ?? null), ); } - public static function containerHtmlFromResponse(string $body): string - { - return self::fromResponse($body)->html; + public static function containerHtmlFromResponse( + string $body, + string $containerId = 'mapsight-embed-1', + int $maxStateBytes = 262144, + ): string { + return self::fromResponse($body, $containerId, $maxStateBytes)->html; } - public static function withDehydratedState(string $html, mixed $state): string - { + public static function withDehydratedState( + string $html, + mixed $state, + string $containerId, + int $maxStateBytes = 262144, + ): string { $json = json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + if (strlen($json) > $maxStateBytes) { + throw new SsrClientError('SSR v1 state exceeds size cap'); + } $escaped = htmlspecialchars($json, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $html = trim($html); $end = self::openingTagEnd($html); $opening = substr($html, 0, $end + 1); + self::assertContainerId($opening, $containerId); $opening = preg_replace( - '/\sdata-dehydrated-state=(?:"[^"]*"|\'[^\']*\')/', + '/\sdata-dehydrated-state=(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/', '', $opening, ) ?? $opening; @@ -100,4 +117,18 @@ private static function openingTagEnd(string $html): int throw new SsrClientError('SSR v1 html has no opening element'); } + + private static function assertContainerId(string $opening, string $containerId): void + { + if (preg_match('/\sid=(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/', $opening, $matches) !== 1) { + throw new SsrClientError('SSR v1 html root id does not match containerId'); + } + $quoted = $matches[1]; + $single = $matches[2] ?? ''; + $unquoted = $matches[3] ?? ''; + $id = $quoted !== '' ? $quoted : ($single !== '' ? $single : $unquoted); + if ($id !== $containerId) { + throw new SsrClientError('SSR v1 html root id does not match containerId'); + } + } } diff --git a/src/Embed/Testing/FakeSsrTransport.php b/src/Embed/Testing/FakeSsrTransport.php new file mode 100644 index 0000000..e8fdaf9 --- /dev/null +++ b/src/Embed/Testing/FakeSsrTransport.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed\Testing; + +use OpenMapsight\Embed\SsrContract; +use OpenMapsight\Embed\SsrHttpRequest; +use OpenMapsight\Embed\SsrHttpResponse; +use OpenMapsight\Embed\SsrTransport; + +/** + * Queued responses or exceptions for host and library tests. + */ +final class FakeSsrTransport implements SsrTransport +{ + /** @var list<SsrHttpResponse|\Throwable> */ + private array $queue = []; + + /** @var list<SsrHttpRequest> */ + public array $requests = []; + + public function queue(SsrHttpResponse|\Throwable $next): void + { + $this->queue[] = $next; + } + + /** + * @param array<string, mixed> $state + * @param array<string, mixed>|null $pageMeta + */ + public function queueV1( + string $html, + array $state = [], + ?array $pageMeta = null, + int $status = 200, + string $contentType = 'application/json', + ): void { + $this->queue(new SsrHttpResponse( + $status, + $contentType, + json_encode([ + 'v' => SsrContract::VERSION, + 'html' => $html, + 'state' => $state, + 'pageMeta' => $pageMeta, + ], JSON_THROW_ON_ERROR), + )); + } + + public function send(SsrHttpRequest $request): SsrHttpResponse + { + $this->requests[] = $request; + if ($this->queue === []) { + throw new \RuntimeException('FakeSsrTransport queue is empty'); + } + $next = array_shift($this->queue); + if ($next instanceof \Throwable) { + throw $next; + } + + return $next; + } +} diff --git a/tests/EmbedRequestTest.php b/tests/EmbedRequestTest.php index bb5153e..fdc40ea 100644 --- a/tests/EmbedRequestTest.php +++ b/tests/EmbedRequestTest.php @@ -33,13 +33,25 @@ public function test_rejects_reserved_word_preset(): void ); } + public function test_rejects_invalid_container_id(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('containerId must match'); + + new EmbedRequest( + preset: 'simpleMap', + containerId: '1bad', + config: [], + ); + } + public function test_rejects_request_id_with_crlf(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('requestId must match'); new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-1', config: [], requestId: "abc\r\nX-Injected: yes", @@ -52,13 +64,26 @@ public function test_rejects_asset_version_with_spaces(): void $this->expectExceptionMessage('assetVersion must match'); new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-1', config: [], assetVersion: 'assets 9', ); } + public function test_does_not_read_server_or_config_fallbacks(): void + { + $_SERVER['REQUEST_URI'] = '/from-sapi'; + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: ['requestUrl' => '/from-config'], + ); + + $this->assertNull($request->requestUrl); + unset($_SERVER['REQUEST_URI']); + } + public function test_accepts_header_safe_tokens(): void { $request = new EmbedRequest( diff --git a/tests/NativeSsrTransportTest.php b/tests/NativeSsrTransportTest.php new file mode 100644 index 0000000..efdbc07 --- /dev/null +++ b/tests/NativeSsrTransportTest.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Tests; + +use OpenMapsight\Embed\NativeSsrTransport; +use OpenMapsight\Embed\SsrHttpRequest; +use OpenMapsight\Embed\SsrUnavailable; +use PHPUnit\Framework\TestCase; + +final class NativeSsrTransportTest extends TestCase +{ + private static int $port = 0; + + /** @var resource|false */ + private static $process = false; + + public static function setUpBeforeClass(): void + { + self::$port = 18700 + random_int(0, 200); + $cmd = [ + PHP_BINARY, + '-S', + '127.0.0.1:' . self::$port, + __DIR__ . '/fixtures/http-server.php', + ]; + self::$process = proc_open( + $cmd, + [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ], + $pipes, + ); + if (self::$process === false) { + self::markTestSkipped('could not start php -S'); + } + + $ready = false; + for ($i = 0; $i < 50; $i++) { + $fp = @fsockopen('127.0.0.1', self::$port, $errno, $errstr, 0.1); + if (is_resource($fp)) { + fclose($fp); + $ready = true; + break; + } + usleep(50000); + } + if (!$ready) { + self::stopServer(); + self::markTestSkipped('php -S did not become ready'); + } + } + + public static function tearDownAfterClass(): void + { + self::stopServer(); + } + + public function test_posts_json_and_returns_status_and_content_type(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'POST', + $this->url('/v1/render'), + ['Accept' => 'application/json', 'X-Request-Id' => 'req-1'], + '{"v":1}', + 2.0, + 0.5, + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'application/json')); + $data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame(1, $data['v'] ?? null); + $this->assertSame('req-1', $data['state']['headers']['x-request-id'] ?? null); + } + + public function test_returns_4xx_and_5xx_without_throwing(): void + { + $transport = new NativeSsrTransport(); + $bad = $transport->send(new SsrHttpRequest('POST', $this->url('/v1/render?status=400'), [], '{}')); + $down = $transport->send(new SsrHttpRequest('POST', $this->url('/v1/render?status=503'), [], '{}')); + + $this->assertSame(400, $bad->status); + $this->assertSame(503, $down->status); + } + + public function test_health_get(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'GET', + $this->url('/health'), + )); + + $this->assertSame(200, $response->status); + $this->assertSame('ok', $response->body); + } + + public function test_timeout_throws_unavailable(): void + { + $this->expectException(SsrUnavailable::class); + (new NativeSsrTransport())->send(new SsrHttpRequest( + 'GET', + $this->url('/sleep'), + [], + null, + 0.05, + 0.05, + )); + } + + public function test_raw_html_content_type_is_preserved(): void + { + $response = (new NativeSsrTransport())->send(new SsrHttpRequest( + 'POST', + $this->url('/v1/render?raw=1'), + [], + '{}', + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'text/html')); + $this->assertStringContainsString('data-dehydrated-state', $response->body); + } + + private function url(string $path): string + { + return 'http://127.0.0.1:' . self::$port . $path; + } + + private static function stopServer(): void + { + if (self::$process !== false) { + proc_terminate(self::$process); + proc_close(self::$process); + self::$process = false; + } + } +} diff --git a/tests/RendererTest.php b/tests/RendererTest.php index 21d0162..7c7e714 100644 --- a/tests/RendererTest.php +++ b/tests/RendererTest.php @@ -6,15 +6,14 @@ use OpenMapsight\Embed\ArraySsrResultCache; use OpenMapsight\Embed\EmbedRequest; -use OpenMapsight\Embed\PlacePageMeta; -use OpenMapsight\Embed\PlacePageMetaOg; use OpenMapsight\Embed\ProcessSsrCircuitBreaker; use OpenMapsight\Embed\Renderer; use OpenMapsight\Embed\SsrCacheKey; +use OpenMapsight\Embed\SsrClient; use OpenMapsight\Embed\SsrClientError; -use OpenMapsight\Embed\SsrDocument; -use OpenMapsight\Embed\SsrTransport; +use OpenMapsight\Embed\SsrOutcome; use OpenMapsight\Embed\SsrUnavailable; +use OpenMapsight\Embed\Testing\FakeSsrTransport; use PHPUnit\Framework\TestCase; final class RendererTest extends TestCase @@ -22,190 +21,122 @@ final class RendererTest extends TestCase public function test_rejects_connect_timeout_longer_than_total(): void { $this->expectException(\InvalidArgumentException::class); - new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-bad-timeout', - config: [], - ssrTimeoutSeconds: 0.05, - ssrConnectTimeoutSeconds: 0.1, + new SsrClient( + ssrUrl: 'http://ssr:4123', + timeoutSeconds: 0.05, + connectTimeoutSeconds: 0.1, + transport: new FakeSsrTransport(), ); } - public function test_client_only_emits_css_container_and_mount_boot(): void + public function test_client_only_emits_css_preload_container_and_mount_boot(): void { - $html = (new Renderer())->render(new EmbedRequest( - preset: 'infosite', + $result = (new Renderer())->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-demo', config: [ 'imagesUrl' => '/mapsight/plan/img/', 'enableMap' => true, - 'enableList' => true, - 'enableTagSwitcher' => true, - 'startCoordinates' => [10.53, 52.27], - 'startZoom' => 12, 'view' => 'desktop', ], )); - $this->assertStringContainsString( - 'href="/mapsight/plan/assets/mapsight.css"', - $html, - ); - $this->assertStringContainsString( - 'id="mapsight-embed-demo"', - $html, - ); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js"', - $html, - ); - $this->assertStringContainsString( - 'import {infosite} from "/mapsight/plan/assets/infosite.js"', - $html, - ); - $this->assertStringContainsString('mountEmbed("mapsight-embed-demo"', $html); - $this->assertStringContainsString('infosite(', $html); - $this->assertStringContainsString('"enableList":true', $html); - $this->assertStringContainsString('"view":"desktop"', $html); + $html = $result->html; + $this->assertSame(SsrOutcome::Disabled, $result->ssr); + $this->assertStringContainsString('href="/mapsight/plan/assets/mapsight.css"', $html); + $this->assertStringContainsString('rel="modulepreload" href="/mapsight/plan/assets/embed.js"', $html); + $this->assertStringContainsString('rel="modulepreload" href="/mapsight/plan/assets/simpleMap.js"', $html); $this->assertStringContainsString('<div id="mapsight-embed-demo"></div>', $html); + $this->assertStringContainsString('import {mountEmbed} from "/mapsight/plan/assets/embed.js"', $html); + $this->assertStringContainsString('import {simpleMap} from "/mapsight/plan/assets/simpleMap.js"', $html); + $this->assertStringContainsString('mountEmbed("mapsight-embed-demo"', $html); + $this->assertStringContainsString('"enableMap":true', $html); $this->assertStringNotContainsString('data-dehydrated-state', $html); - $this->assertStringNotContainsString('ms3-', $html); - $this->assertStringNotContainsString('stadtplan', $html); $this->assertStringNotContainsString('mapsight-ssr-skipped', $html); + $this->assertStringNotContainsString('stadtplan', $html); + $this->assertSame($result->stylesheetHtml, $result->html === '' ? '' : trim(explode("\n", $html)[0])); } public function test_host_supplies_container_class_on_empty_mount(): void { $html = (new Renderer())->render(new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-demo', config: [], containerClassName: 'host-embed', - )); + ))->html; $this->assertStringContainsString('<div id="mapsight-embed-demo" class="host-embed"></div>', $html); - $this->assertStringNotContainsString('ms3-', $html); } public function test_ssr_success_injects_dehydrated_fragment_and_boot(): void { - $transport = new class implements SsrTransport { - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - TestCase::assertSame('http://ssr:4123/v1/render', $url); - TestCase::assertSame(2.0, $timeoutSeconds); - TestCase::assertSame(0.1, $connectTimeoutSeconds); - TestCase::assertSame(1, $payload['v'] ?? null); - TestCase::assertSame('req-1', $payload['requestId'] ?? null); - TestCase::assertSame('assets-9', $payload['assetVersion'] ?? null); - TestCase::assertSame('application/json', $headers['Accept'] ?? null); - TestCase::assertSame('req-1', $headers['X-Request-Id'] ?? null); - TestCase::assertSame('assets-9', $headers['X-Mapsight-Asset-Version'] ?? null); - TestCase::assertSame('infosite', $payload['preset'] ?? null); - TestCase::assertSame( - 'mapsight-embed-ssr', - $payload['options']['containerId'] ?? null, - ); - - return new SsrDocument('<div id="mapsight-embed-ssr" class="mapsight-embed" data-dehydrated-state="{"app":{"title":"ok"}}"></div>'); - } - }; - - $html = (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', + $transport = new FakeSsrTransport(); + $transport->queueV1( + '<div id="mapsight-embed-ssr" class="mapsight-embed"></div>', + ['app' => ['title' => 'ok']], + ); + $result = $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-ssr', config: ['imagesUrl' => '/mapsight/plan/img/', 'enableMap' => true], - ssrUrl: 'http://ssr:4123', requestId: 'req-1', assetVersion: 'assets-9', )); + $html = $result->html; + $this->assertSame(SsrOutcome::Rendered, $result->ssr); $this->assertStringContainsString('data-dehydrated-state=', $html); - $this->assertStringContainsString( - 'href="/mapsight/plan/assets/mapsight.css?v=assets-9"', - $html, - ); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js?v=assets-9"', - $html, - ); - $this->assertStringContainsString( - 'import {infosite} from "/mapsight/plan/assets/infosite.js?v=assets-9"', - $html, - ); + $this->assertStringContainsString('href="/mapsight/plan/assets/mapsight.css?v=assets-9"', $html); + $this->assertStringContainsString('import {mountEmbed} from "/mapsight/plan/assets/embed.js?v=assets-9"', $html); + $this->assertStringContainsString('import {simpleMap} from "/mapsight/plan/assets/simpleMap.js?v=assets-9"', $html); $this->assertStringContainsString('mountEmbed("mapsight-embed-ssr"', $html); $this->assertStringNotContainsString('mapsight-ssr-skipped', $html); $this->assertSame(1, substr_count($html, 'id="mapsight-embed-ssr"')); + + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('req-1', $payload['requestId'] ?? null); + $this->assertSame('assets-9', $payload['assetVersion'] ?? null); + $this->assertSame('req-1', $transport->requests[0]->headers['X-Request-Id'] ?? null); + $this->assertSame(2.0, $transport->requests[0]->timeoutSeconds); + $this->assertSame(0.1, $transport->requests[0]->connectTimeoutSeconds); } public function test_ssr_failure_falls_back_to_client_only(): void { - $transport = new class implements SsrTransport { - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - throw new \RuntimeException('connection refused'); - } - }; - - $html = (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('connection refused')); + $result = $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-fallback', config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', )); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $html); - $this->assertStringContainsString('<div id="mapsight-embed-fallback"></div>', $html); - $this->assertStringNotContainsString('data-dehydrated-state', $html); - $this->assertStringNotContainsString('ms3-', $html); - $this->assertStringContainsString( - 'import {mountEmbed} from "/mapsight/plan/assets/embed.js"', - $html, - ); - $this->assertStringContainsString('mountEmbed("mapsight-embed-fallback"', $html); + $this->assertSame(SsrOutcome::SkippedError, $result->ssr); + $this->assertSame('connection refused', $result->ssrReason); + $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $result->html); + $this->assertStringContainsString('<div id="mapsight-embed-fallback"></div>', $result->html); + $this->assertStringNotContainsString('data-dehydrated-state', $result->html); } public function test_passes_split_timeouts_to_transport(): void { - $transport = new class implements SsrTransport { - public float $timeout = 0; - public float $connect = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->timeout = $timeoutSeconds; - $this->connect = $connectTimeoutSeconds; - - return new SsrDocument('<div id="mapsight-embed-timeouts" class="mapsight-embed" data-dehydrated-state="{}"></div>'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-timeouts', - config: ['imagesUrl' => '/mapsight/plan/img/'], + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-timeouts"></div>'); + $client = new SsrClient( ssrUrl: 'http://ssr:4123', - ssrTimeoutSeconds: 3.0, - ssrConnectTimeoutSeconds: 0.05, + timeoutSeconds: 3.0, + connectTimeoutSeconds: 0.05, + transport: $transport, + ); + (new Renderer($client))->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-timeouts', + config: [], )); - $this->assertSame(3.0, $transport->timeout); - $this->assertSame(0.05, $transport->connect); + $this->assertSame(3.0, $transport->requests[0]->timeoutSeconds); + $this->assertSame(0.05, $transport->requests[0]->connectTimeoutSeconds); } public function test_open_circuit_skips_transport_until_cooldown(): void @@ -213,76 +144,45 @@ public function test_open_circuit_skips_transport_until_cooldown(): void $clock = new class { public float $now = 1000.0; }; - $transport = new class implements SsrTransport { - public int $calls = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - throw new SsrUnavailable('sidecar down'); - } - }; - $renderer = new Renderer( + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('sidecar down')); + $transport->queue(new SsrUnavailable('sidecar down')); + $transport->queue(new SsrUnavailable('sidecar down')); + $renderer = $this->renderer( $transport, - new ProcessSsrCircuitBreaker(2, 10.0, fn () => $clock->now), + breaker: new ProcessSsrCircuitBreaker(2, 10.0, fn () => $clock->now), ); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-breaker', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], ); - $first = $renderer->render($request); - $second = $renderer->render($request); + $renderer->render($request); + $renderer->render($request); $skipped = $renderer->render($request); - $this->assertSame(2, $transport->calls); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $first); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $second); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $skipped); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::SkippedBreaker, $skipped->ssr); + $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $skipped->html); $clock->now = 1010.0; $afterCooldown = $renderer->render($request); - $this->assertSame(3, $transport->calls); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $afterCooldown); + $this->assertCount(3, $transport->requests); + $this->assertSame(SsrOutcome::SkippedError, $afterCooldown->ssr); } public function test_cache_hit_skips_node_and_does_not_mark_skipped(): void { - $transport = new class implements SsrTransport { - public int $calls = 0; - - /** @var array<string, mixed>|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - $this->payload = $payload; - - return new SsrDocument('<div id="mapsight-embed-cache" class="mapsight-embed" data-dehydrated-state="{"app":{"n":' - . $this->calls - . '}}"></div>'); - } - }; + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-cache"></div>', ['app' => ['n' => 1]]); + $transport->queueV1('<div id="mapsight-embed-cache"></div>', ['app' => ['n' => 2]]); $cache = new ArraySsrResultCache(); - $renderer = new Renderer($transport, new ProcessSsrCircuitBreaker(), $cache); + $renderer = $this->renderer($transport, $cache); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-cache', - config: ['enableList' => true, 'imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: ['enableList' => true], assetVersion: 'assets-1', locale: 'de', deviceClass: 'desktop', @@ -291,23 +191,25 @@ public function postJson( $first = $renderer->render($request); $second = $renderer->render($request); - $this->assertSame(1, $transport->calls); - $this->assertSame('de', $transport->payload['options']['locale'] ?? null); - $this->assertSame('desktop', $transport->payload['options']['deviceClass'] ?? null); - $this->assertStringContainsString('data-dehydrated-state=', $first); - $this->assertSame($first, $second); - $this->assertStringNotContainsString('mapsight-ssr-skipped', $second); + $this->assertCount(1, $transport->requests); + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('de', $payload['options']['locale'] ?? null); + $this->assertSame('desktop', $payload['options']['deviceClass'] ?? null); + $this->assertSame(SsrOutcome::Rendered, $first->ssr); + $this->assertSame(SsrOutcome::Cached, $second->ssr); + $this->assertSame($first->containerHtml, $second->containerHtml); + $this->assertStringNotContainsString('mapsight-ssr-skipped', $second->html); $cache->flush(); $third = $renderer->render($request); - $this->assertSame(2, $transport->calls); - $this->assertStringContainsString('data-dehydrated-state=', $third); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::Rendered, $third->ssr); } public function test_cache_key_ignores_config_key_order(): void { $left = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-key', config: ['b' => 2, 'a' => 1], assetVersion: 'v1', @@ -315,7 +217,7 @@ public function test_cache_key_ignores_config_key_order(): void deviceClass: 'mobile', ); $right = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-key', config: ['a' => 1, 'b' => 2], assetVersion: 'v1', @@ -323,281 +225,182 @@ public function test_cache_key_ignores_config_key_order(): void deviceClass: 'mobile', ); - $this->assertSame(SsrCacheKey::for($left), SsrCacheKey::for($right)); + $this->assertSame(SsrCacheKey::for($left, null), SsrCacheKey::for($right, null)); } - public function test_ssr_payload_forwards_request_url(): void + public function test_ssr_payload_forwards_request_url_and_share_params(): void { - $transport = new class implements SsrTransport { - /** @var array<string, mixed>|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->payload = $payload; - - return new SsrDocument('<div id="mapsight-embed-url" class="mapsight-embed" data-dehydrated-state="{}"></div>'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'stadtplan', + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-url"></div>'); + $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', containerId: 'mapsight-embed-url', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/map/?module=baustellen-verkehr', + config: [], + requestUrl: '/map/?module=traffic&utm_source=x', + )); + + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('/map/?module=traffic', $payload['options']['requestUrl'] ?? null); + } + + public function test_utm_and_click_ids_do_not_change_the_cache_key(): void + { + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: new FakeSsrTransport()); + $plain = $client->normalizeRequestUrl('/map'); + $utm = $client->normalizeRequestUrl('/map?utm_source=x'); + $feature = $client->normalizeRequestUrl('/map?feature=1'); + $featureClick = $client->normalizeRequestUrl('/map?feature=1&fbclid=z'); + + $this->assertSame($plain, $utm); + $this->assertSame($feature, $featureClick); + $this->assertNotSame($plain, $feature); + } + + public function test_ssr_payload_forwards_page_origin_and_og_image(): void + { + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-origin"></div>'); + $this->renderer($transport)->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-origin', + config: [], + requestUrl: '/map?feature=poi-1', + pageOrigin: 'https://www.example.com', + ogImage: 'https://www.example.com/plan/img/og-default.png', )); + $payload = json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('https://www.example.com', $payload['options']['pageOrigin'] ?? null); $this->assertSame( - '/map/?module=baustellen-verkehr', - $transport->payload['options']['requestUrl'] ?? null, + 'https://www.example.com/plan/img/og-default.png', + $payload['options']['ogImage'] ?? null, ); + $this->assertSame('/map?feature=poi-1', $payload['options']['requestUrl'] ?? null); } - public function test_ssr_payload_forwards_locale_and_device_class(): void + public function test_render_exposes_page_meta_when_share_param_is_on_request_url(): void { - $transport = new class implements SsrTransport { - /** @var array<string, mixed>|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->payload = $payload; - - return new SsrDocument('<div id="mapsight-embed-locale" class="mapsight-embed" data-dehydrated-state="{}"></div>'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-locale', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - locale: 'de', - deviceClass: 'mobile', + $meta = [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'canonicalUrl' => 'https://www.example.com/map?feature=poi-1', + 'og' => [ + 'title' => 'Town Hall', + 'description' => 'An example place.', + 'url' => 'https://www.example.com/map?feature=poi-1', + 'type' => 'place', + 'image' => 'https://www.example.com/plan/img/og-default.png', + ], + 'jsonLd' => ['@type' => 'Place', 'name' => 'Town Hall'], + ]; + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-meta"></div>', [], $meta); + $transport->queueV1('<div id="mapsight-embed-meta"></div>', [], $meta); + $transport->queueV1('<div id="mapsight-embed-meta"></div>', [], $meta); + $renderer = $this->renderer($transport); + + $withFeature = $renderer->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-meta', + config: [], + requestUrl: '/map?feature=poi-1', + pageOrigin: 'https://www.example.com', + )); + $withModule = $renderer->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-meta', + config: [], + requestUrl: '/plan/?module=parking', + pageOrigin: 'https://www.example.com', + )); + $withoutShareable = $renderer->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-meta', + config: [], + requestUrl: '/map', + pageOrigin: 'https://www.example.com', )); - $this->assertSame('de', $transport->payload['options']['locale'] ?? null); - $this->assertSame('mobile', $transport->payload['options']['deviceClass'] ?? null); + $this->assertSame('Town Hall', $withFeature->pageMeta?->title); + $this->assertStringContainsString('data-dehydrated-state=', $withFeature->html); + $this->assertSame('Town Hall', $withModule->pageMeta?->title); + $this->assertNull($withoutShareable->pageMeta); + $this->assertStringContainsString('data-dehydrated-state=', $withoutShareable->html); } public function test_open_circuit_still_serves_warm_cache(): void { - $transport = new class implements SsrTransport { - public int $calls = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - - return new SsrDocument('<div id="mapsight-embed-warm" class="mapsight-embed" data-dehydrated-state="{"n":1}"></div>'); - } - }; + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-warm"></div>', ['n' => 1]); $cache = new ArraySsrResultCache(); $breaker = new ProcessSsrCircuitBreaker(1, 60.0); - $renderer = new Renderer($transport, $breaker, $cache); + $renderer = $this->renderer($transport, $cache, $breaker); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-warm', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], ); $warm = $renderer->render($request); $breaker->recordFailure(); $this->assertFalse($breaker->allow()); - $served = $renderer->render($request); - $this->assertSame(1, $transport->calls); - $this->assertSame($warm, $served); - $this->assertStringNotContainsString('mapsight-ssr-skipped', $served); - $this->assertStringContainsString('data-dehydrated-state=', $served); + $this->assertCount(1, $transport->requests); + $this->assertSame(SsrOutcome::Cached, $served->ssr); + $this->assertSame($warm->containerHtml, $served->containerHtml); + $this->assertStringNotContainsString('mapsight-ssr-skipped', $served->html); } public function test_client_errors_do_not_trip_the_breaker(): void { - $transport = new class implements SsrTransport { - public int $calls = 0; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->calls++; - throw new SsrClientError('SSR v1 error VALIDATION'); - } - }; - $renderer = new Renderer( + $transport = new FakeSsrTransport(); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $transport->queue(new SsrClientError('SSR v1 error VALIDATION')); + $renderer = $this->renderer( $transport, - new ProcessSsrCircuitBreaker(2, 10.0), + breaker: new ProcessSsrCircuitBreaker(2, 10.0), ); $request = new EmbedRequest( - preset: 'infosite', + preset: 'simpleMap', containerId: 'mapsight-embed-client-error', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', + config: [], ); $renderer->render($request); $renderer->render($request); $third = $renderer->render($request); - $this->assertSame(3, $transport->calls); - $this->assertStringContainsString('<!-- mapsight-ssr-skipped -->', $third); + $this->assertCount(3, $transport->requests); + $this->assertSame(SsrOutcome::SkippedError, $third->ssr); } - public function test_cache_key_includes_request_url(): void + public function test_script_nonce_and_json_encoded_urls(): void { - $home = new EmbedRequest( - preset: 'stadtplan', - containerId: 'mapsight-embed-key', - config: ['imagesUrl' => '/mapsight/plan/img/'], - requestUrl: '/map/', - ); - $verkehr = new EmbedRequest( - preset: 'stadtplan', - containerId: 'mapsight-embed-key', - config: ['imagesUrl' => '/mapsight/plan/img/'], - requestUrl: '/map/?module=baustellen-verkehr', - ); - - $this->assertNotSame(SsrCacheKey::for($home), SsrCacheKey::for($verkehr)); - } - - public function test_ssr_payload_forwards_page_origin_and_og_image(): void - { - $transport = new class implements SsrTransport { - /** @var array<string, mixed>|null */ - public ?array $payload = null; - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - $this->payload = $payload; - - return new SsrDocument('<div id="mapsight-embed-origin" class="mapsight-embed" data-dehydrated-state="{}"></div>'); - } - }; - - (new Renderer($transport))->render(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-origin', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/map?feature=poi-1', - pageOrigin: 'https://www.example.com', - ogImage: 'https://www.example.com/plan/img/og-default.png', + $result = (new Renderer())->render(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-nonce', + config: [], + assetBase: '/x</script><script>alert(1)</script>', + scriptNonce: 'abc-1', )); - $this->assertSame( - 'https://www.example.com', - $transport->payload['options']['pageOrigin'] ?? null, - ); - $this->assertSame( - 'https://www.example.com/plan/img/og-default.png', - $transport->payload['options']['ogImage'] ?? null, - ); - $this->assertSame( - '/map?feature=poi-1', - $transport->payload['options']['requestUrl'] ?? null, - ); + $this->assertStringContainsString('<script type="module" nonce="abc-1">', $result->html); + $this->assertStringContainsString('\u003C/script\u003E', $result->bootScriptHtml); + $this->assertStringNotContainsString('</script><script>alert(1)</script>', $result->bootScriptHtml); } - public function test_render_document_exposes_page_meta_when_feature_or_module_is_on_request_url(): void - { - $meta = self::samplePageMeta(); - $transport = new class($meta) implements SsrTransport { - public function __construct(private readonly PlacePageMeta $meta) - { - } - - public function postJson( - string $url, - array $payload, - float $timeoutSeconds, - array $headers = [], - float $connectTimeoutSeconds = 0.1, - ): SsrDocument { - return new SsrDocument( - '<div id="mapsight-embed-meta" class="mapsight-embed" data-dehydrated-state="{}"></div>', - $this->meta, - ); - } - }; - - $withFeature = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/map?feature=poi-1', - pageOrigin: 'https://www.example.com', - )); - $withModule = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'stadtplan', - containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], - ssrUrl: 'http://ssr:4123', - requestUrl: '/plan/?module=parken', - pageOrigin: 'https://www.example.com', - )); - $withoutShareable = (new Renderer($transport))->renderDocument(new EmbedRequest( - preset: 'infosite', - containerId: 'mapsight-embed-meta', - config: ['imagesUrl' => '/mapsight/plan/img/'], + private function renderer( + FakeSsrTransport $transport, + ?ArraySsrResultCache $cache = null, + ?ProcessSsrCircuitBreaker $breaker = null, + ): Renderer { + return new Renderer(new SsrClient( ssrUrl: 'http://ssr:4123', - requestUrl: '/map', - pageOrigin: 'https://www.example.com', + transport: $transport, + resultCache: $cache, + breaker: $breaker, )); - - $this->assertSame('Town Hall', $withFeature->pageMeta?->title); - $this->assertStringContainsString('data-dehydrated-state=', $withFeature->html); - $this->assertSame('Town Hall', $withModule->pageMeta?->title); - $this->assertNull($withoutShareable->pageMeta); - $this->assertStringContainsString('data-dehydrated-state=', $withoutShareable->html); - } - - private static function samplePageMeta(): PlacePageMeta - { - return new PlacePageMeta( - 'Town Hall', - 'An example place.', - 'https://www.example.com/map?feature=poi-1', - new PlacePageMetaOg( - 'Town Hall', - 'An example place.', - 'https://www.example.com/map?feature=poi-1', - 'place', - 'https://www.example.com/plan/img/og-default.png', - ), - [ - '@context' => 'https://schema.org', - '@type' => 'Place', - 'name' => 'Town Hall', - ], - ); } } diff --git a/tests/SsrClientTest.php b/tests/SsrClientTest.php new file mode 100644 index 0000000..923f2f3 --- /dev/null +++ b/tests/SsrClientTest.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Tests; + +use OpenMapsight\Embed\ArraySsrResultCache; +use OpenMapsight\Embed\EmbedRequest; +use OpenMapsight\Embed\SsrClient; +use OpenMapsight\Embed\SsrHttpResponse; +use OpenMapsight\Embed\SsrOutcome; +use OpenMapsight\Embed\Testing\FakeSsrTransport; +use PHPUnit\Framework\TestCase; + +final class SsrClientTest extends TestCase +{ + public function test_health_is_true_on_2xx(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse(200, 'text/plain', 'ok')); + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport); + + $this->assertTrue($client->health()); + $this->assertSame('GET', $transport->requests[0]->method); + $this->assertSame('http://ssr:4123/health', $transport->requests[0]->url); + } + + public function test_health_is_false_on_transport_error(): void + { + $transport = new FakeSsrTransport(); + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport); + + $this->assertFalse($client->health()); + } + + public function test_warm_forces_a_sidecar_call_and_stores_the_result(): void + { + $transport = new FakeSsrTransport(); + $transport->queueV1('<div id="mapsight-embed-warm"></div>', ['n' => 1]); + $transport->queueV1('<div id="mapsight-embed-warm"></div>', ['n' => 2]); + $cache = new ArraySsrResultCache(); + $client = new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + resultCache: $cache, + ); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-warm', + config: [], + ); + + $this->assertSame(SsrOutcome::Rendered, $client->resolve($request)->outcome); + $this->assertSame(SsrOutcome::Rendered, $client->warm($request)); + $this->assertCount(2, $transport->requests); + $this->assertSame(SsrOutcome::Cached, $client->resolve($request)->outcome); + $this->assertCount(2, $transport->requests); + } + + public function test_rejects_non_json_content_type(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse( + 200, + 'text/html', + '<div id="mapsight-embed-1" data-dehydrated-state="{}"></div>', + )); + $result = (new SsrClient(ssrUrl: 'http://ssr:4123', transport: $transport)) + ->resolve(new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + )); + + $this->assertSame(SsrOutcome::SkippedError, $result->outcome); + $this->assertStringContainsString('Content-Type', (string) $result->reason); + } + + public function test_four_xx_does_not_count_as_unavailable(): void + { + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse(400, 'application/json', '{"v":1,"error":{"code":"VALIDATION"}}')); + $transport->queueV1('<div id="mapsight-embed-1"></div>'); + $client = new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + breaker: new \OpenMapsight\Embed\ProcessSsrCircuitBreaker(1, 60.0), + ); + $request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: [], + ); + + $this->assertSame(SsrOutcome::SkippedError, $client->resolve($request)->outcome); + $this->assertSame(SsrOutcome::Rendered, $client->resolve($request)->outcome); + } + + public function test_normalizes_long_urls_to_path_only(): void + { + $client = new SsrClient(ssrUrl: 'http://ssr:4123', transport: new FakeSsrTransport()); + $url = '/map?' . str_repeat('x=1&', 600) . 'feature=1'; + + $this->assertSame('/map', $client->normalizeRequestUrl($url)); + } + + public function test_array_cache_expires_and_evicts(): void + { + $cache = new ArraySsrResultCache(); + $cache->set('old', new \OpenMapsight\Embed\SsrDocument('<div id="a"></div>'), 1); + $this->assertNotNull($cache->get('old')); + sleep(2); + $this->assertNull($cache->get('old')); + + for ($i = 0; $i < ArraySsrResultCache::MAX_ENTRIES + 2; $i++) { + $cache->set('k' . $i, new \OpenMapsight\Embed\SsrDocument('<div id="c"></div>'), 60); + } + $this->assertNull($cache->get('k0')); + $this->assertNotNull($cache->get('k' . (ArraySsrResultCache::MAX_ENTRIES + 1))); + } +} diff --git a/tests/SsrPublishTest.php b/tests/SsrPublishTest.php index b8af447..429ebe8 100644 --- a/tests/SsrPublishTest.php +++ b/tests/SsrPublishTest.php @@ -5,146 +5,94 @@ namespace OpenMapsight\Tests; use OpenMapsight\Embed\ArraySsrResultCache; +use OpenMapsight\Embed\SsrClient; use OpenMapsight\Embed\SsrDocument; +use OpenMapsight\Embed\SsrHttpResponse; use OpenMapsight\Embed\SsrPublish; -use OpenMapsight\Embed\SsrPurgeTransport; +use OpenMapsight\Embed\SsrUnavailable; +use OpenMapsight\Embed\Testing\FakeSsrTransport; use PHPUnit\Framework\TestCase; final class SsrPublishTest extends TestCase { public function test_purges_sidecar_then_flushes_php_fragments(): void { - $transport = new class implements SsrPurgeTransport { - public int $calls = 0; - - /** @var list<array{url: string, payload: array<string, mixed>}> */ - public array $requests = []; - - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $this->calls++; - $this->requests[] = ['url' => $url, 'payload' => $payload]; - - return ['doc::https://example.test/schools.geojson']; - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse( + 200, + 'application/json', + '["doc::https://example.test/schools.geojson"]', + )); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('<div data-dehydrated-state="{}"></div>')); - $hook = new SsrPublish('http://ssr:4123', $cache, $transport); + $cache->set('k1', new SsrDocument('<div data-dehydrated-state="{}"></div>'), 60); + $hook = new SsrPublish($this->client($transport, $cache)); - $result = $hook->afterFeatureSourcePublish([ - 'https://example.test/schools.geojson', - ]); + $result = $hook->purge(['https://example.test/schools.geojson']); $this->assertTrue($result->sidecarPurged); $this->assertSame(['doc::https://example.test/schools.geojson'], $result->deletedKeys); - $this->assertSame(1, $transport->calls); - $this->assertSame('http://ssr:4123/purge', $transport->requests[0]['url'] ?? null); + $this->assertCount(1, $transport->requests); + $this->assertSame('http://ssr:4123/purge', $transport->requests[0]->url); $this->assertSame( ['urls' => ['https://example.test/schools.geojson']], - $transport->requests[0]['payload'] ?? null, + json_decode((string) $transport->requests[0]->body, true, 512, JSON_THROW_ON_ERROR), ); $this->assertNull($cache->get('k1')); } public function test_omitted_urls_clears_sidecar_and_still_flushes_php(): void { - $transport = new class implements SsrPurgeTransport { - /** @var array<string, mixed>|null */ - public ?array $payload = null; - - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $this->payload = $payload; - - return ['doc::all']; - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrHttpResponse(200, 'application/json', '["doc::all"]')); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('<div></div>')); + $cache->set('k1', new SsrDocument('<div></div>'), 60); - $result = (new SsrPublish('http://ssr:4123', $cache, $transport)) - ->afterFeatureSourcePublish(); + $result = (new SsrPublish($this->client($transport, $cache)))->purge(); $this->assertTrue($result->sidecarPurged); $this->assertSame(['doc::all'], $result->deletedKeys); - $this->assertSame([], $transport->payload); + $this->assertSame('{}', $transport->requests[0]->body); $this->assertNull($cache->get('k1')); } public function test_sidecar_failure_does_not_flush_php(): void { - $transport = new class implements SsrPurgeTransport { - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - throw new \RuntimeException('connection refused'); - } - }; + $transport = new FakeSsrTransport(); + $transport->queue(new SsrUnavailable('connection refused')); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('<div></div>')); + $cache->set('k1', new SsrDocument('<div></div>'), 60); - $result = (new SsrPublish('http://ssr:4123', $cache, $transport)) - ->afterFeatureSourcePublish(['https://example.test/a.geojson']); + $result = (new SsrPublish($this->client($transport, $cache))) + ->purge(['https://example.test/a.geojson']); $this->assertFalse($result->sidecarPurged); $this->assertSame([], $result->deletedKeys); $this->assertNotNull($cache->get('k1')); } - public function test_missing_sidecar_url_still_flushes_php(): void - { - $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('<div></div>')); - - $result = (new SsrPublish(null, $cache))->afterFeatureSourcePublish([ - 'https://example.test/a.geojson', - ]); - - $this->assertFalse($result->sidecarPurged); - $this->assertSame([], $result->deletedKeys); - $this->assertNull($cache->get('k1')); - } - public function test_blank_urls_are_not_a_purge_all(): void { - $transport = new class implements SsrPurgeTransport { - public int $calls = 0; - - public function postPurge( - string $url, - array $payload, - float $timeoutSeconds, - float $connectTimeoutSeconds = 0.1, - ): array { - $this->calls++; - - return []; - } - }; + $transport = new FakeSsrTransport(); $cache = new ArraySsrResultCache(); - $cache->set('k1', new SsrDocument('<div></div>')); + $cache->set('k1', new SsrDocument('<div></div>'), 60); try { - (new SsrPublish('http://ssr:4123', $cache, $transport)) - ->afterFeatureSourcePublish(['']); + (new SsrPublish($this->client($transport, $cache)))->purge(['']); $this->fail('expected InvalidArgumentException'); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('only empty urls', $e->getMessage()); } - $this->assertSame(0, $transport->calls); + $this->assertSame([], $transport->requests); $this->assertNotNull($cache->get('k1')); } + + private function client(FakeSsrTransport $transport, ArraySsrResultCache $cache): SsrClient + { + return new SsrClient( + ssrUrl: 'http://ssr:4123', + transport: $transport, + resultCache: $cache, + ); + } } diff --git a/tests/SsrV1DocumentTest.php b/tests/SsrV1DocumentTest.php index a28ee91..cef673d 100644 --- a/tests/SsrV1DocumentTest.php +++ b/tests/SsrV1DocumentTest.php @@ -16,7 +16,6 @@ public function test_injects_json_state_into_dehydrated_attribute(): void 'v' => 1, 'html' => '<div id="mapsight-embed-1" class="mapsight-embed"></div>', 'state' => ['app' => ['title' => 'ok & "x"']], - 'meta' => ['preset' => 'infosite'], ], JSON_THROW_ON_ERROR)); $this->assertStringContainsString('id="mapsight-embed-1"', $html); @@ -27,6 +26,15 @@ public function test_injects_json_state_into_dehydrated_attribute(): void $this->assertStringNotContainsString('<script', $html); } + public function test_parses_checked_in_v1_fixture(): void + { + $body = (string) file_get_contents(__DIR__ . '/fixtures/v1-render.json'); + $document = SsrV1Document::fromResponse($body, 'mapsight-embed-1'); + + $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($document->html)); + $this->assertNull($document->pageMeta); + } + public function test_from_response_keeps_page_meta_and_fails_open_when_incomplete(): void { $document = SsrV1Document::fromResponse(json_encode([ @@ -46,7 +54,7 @@ public function test_from_response_keeps_page_meta_and_fails_open_when_incomplet ], 'jsonLd' => ['@type' => 'Place', 'name' => 'Town Hall'], ], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame('Town Hall', $document->pageMeta?->title); $this->assertSame( @@ -60,7 +68,7 @@ public function test_from_response_keeps_page_meta_and_fails_open_when_incomplet 'html' => '<div id="x" class="mapsight-embed"></div>', 'state' => ['app' => ['ssr' => 'v1']], 'pageMeta' => ['title' => 'incomplete'], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertNull($withoutMeta->pageMeta); } @@ -70,12 +78,24 @@ public function test_replaces_state_already_on_the_fragment(): void 'v' => 1, 'html' => '<div id="x" data-dehydrated-state="{"app":{"ssr":"stub"}}"></div>', 'state' => ['app' => ['ssr' => 'v1']], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); $this->assertStringNotContainsString('stub', $html); } + public function test_strips_unquoted_dehydrated_attribute(): void + { + $html = SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'html' => '<div id="x" data-dehydrated-state=stub></div>', + 'state' => ['app' => ['ssr' => 'v1']], + ], JSON_THROW_ON_ERROR), 'x'); + + $this->assertSame(1, substr_count($html, 'data-dehydrated-state=')); + $this->assertSame(['app' => ['ssr' => 'v1']], $this->dehydratedState($html)); + } + public function test_replaces_node_state_when_json_attribute_contains_gt(): void { $attribution = '<a href="https://www.openstreetmap.org/copyright" rel="external" target="_blank">OpenStreetMap-Mitwirkende</a>.'; @@ -93,7 +113,7 @@ public function test_replaces_node_state_when_json_attribute_contains_gt(): void 'map' => ['layers' => ['street' => ['attribution' => $attribution]]], 'app' => ['ssr' => 'v1'], ], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'x'); $this->assertSame( [ @@ -106,17 +126,6 @@ public function test_replaces_node_state_when_json_attribute_contains_gt(): void $this->assertStringContainsString('<p>shell</p>', $html); } - /** @return array<string, mixed> */ - private function dehydratedState(string $html): array - { - $this->assertSame(1, preg_match('/data-dehydrated-state="([^"]*)"/', $html, $matches)); - $decoded = html_entity_decode($matches[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - $state = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); - $this->assertIsArray($state); - - return $state; - } - public function test_rejects_error_payload(): void { $this->expectException(SsrClientError::class); @@ -136,7 +145,19 @@ public function test_rejects_html_that_starts_with_a_comment(): void 'v' => 1, 'html' => '<!-- ssr --><div id="c"></div>', 'state' => ['a' => 1], - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR), 'c'); + } + + public function test_rejects_root_id_mismatch(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 html root id does not match containerId'); + + SsrV1Document::containerHtmlFromResponse(json_encode([ + 'v' => 1, + 'html' => '<div id="other"></div>', + 'state' => ['a' => 1], + ], JSON_THROW_ON_ERROR), 'mapsight-embed-1'); } public function test_accepts_utf8_bom_before_json(): void @@ -155,6 +176,29 @@ public function test_rejects_raw_html_body(): void $this->expectException(SsrClientError::class); $this->expectExceptionMessage('SSR v1 response is not JSON'); - SsrV1Document::fromResponse('<div id="c" data-dehydrated-state="{}"></div>'); + SsrV1Document::fromResponse('<div id="c" data-dehydrated-state="{}"></div>', 'c'); + } + + public function test_rejects_state_over_the_size_cap(): void + { + $this->expectException(SsrClientError::class); + $this->expectExceptionMessage('SSR v1 state exceeds size cap'); + + SsrV1Document::fromResponse(json_encode([ + 'v' => 1, + 'html' => '<div id="x"></div>', + 'state' => ['blob' => str_repeat('a', 200)], + ], JSON_THROW_ON_ERROR), 'x', 16); + } + + /** @return array<string, mixed> */ + private function dehydratedState(string $html): array + { + $this->assertSame(1, preg_match('/data-dehydrated-state="([^"]*)"/', $html, $matches)); + $decoded = html_entity_decode($matches[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $state = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($state); + + return $state; } } diff --git a/tests/fixtures/http-server.php b/tests/fixtures/http-server.php new file mode 100644 index 0000000..4d8067d --- /dev/null +++ b/tests/fixtures/http-server.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +$query = []; +parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY) ?? ''), $query); + +if ($path === '/health') { + http_response_code(200); + header('Content-Type: text/plain'); + echo 'ok'; + exit; +} + +if ($path === '/sleep') { + usleep(400000); + http_response_code(200); + header('Content-Type: application/json'); + echo '{"ok":true}'; + exit; +} + +if ($path === '/v1/render') { + $status = (string) ($query['status'] ?? '200'); + if ($status === '400') { + http_response_code(400); + header('Content-Type: application/json'); + echo '{"v":1,"error":{"code":"VALIDATION"}}'; + exit; + } + if ($status === '503') { + http_response_code(503); + header('Content-Type: text/plain'); + echo 'down'; + exit; + } + if (($query['raw'] ?? '') === '1') { + http_response_code(200); + header('Content-Type: text/html'); + echo '<div id="mapsight-embed-1" data-dehydrated-state="{}"></div>'; + exit; + } + if (($query['bom'] ?? '') === '1') { + http_response_code(200); + header('Content-Type: application/json'); + echo "\xEF\xBB\xBF" . '{"v":1,"html":"<div id=\"mapsight-embed-1\"></div>","state":{"bom":true}}'; + exit; + } + + http_response_code(200); + header('Content-Type: application/json'); + $payload = json_decode((string) file_get_contents('php://input'), true); + echo json_encode([ + 'v' => 1, + 'html' => '<div id="mapsight-embed-1" class="mapsight-embed"></div>', + 'state' => [ + 'echo' => $payload, + 'headers' => [ + 'x-request-id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, + 'x-mapsight-asset-version' => $_SERVER['HTTP_X_MAPSIGHT_ASSET_VERSION'] ?? null, + ], + ], + 'pageMeta' => null, + ], JSON_THROW_ON_ERROR); + exit; +} + +if ($path === '/purge') { + if (($query['status'] ?? '') === '204') { + http_response_code(204); + exit; + } + http_response_code(200); + header('Content-Type: application/json'); + echo '["doc::1"]'; + exit; +} + +http_response_code(404); +header('Content-Type: text/plain'); +echo 'not found'; diff --git a/tests/fixtures/v1-render.json b/tests/fixtures/v1-render.json new file mode 100644 index 0000000..e5e83ba --- /dev/null +++ b/tests/fixtures/v1-render.json @@ -0,0 +1,10 @@ +{ + "v": 1, + "html": "<div id=\"mapsight-embed-1\" class=\"mapsight-embed\"></div>", + "state": { + "app": { + "ssr": "v1" + } + }, + "pageMeta": null +} From 2612ce141d721c540b41d3fb76d7e562619bd950 Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:01:18 +0200 Subject: [PATCH 6/9] Add PHPStan, CI validation, deprecation failures, and a security policy. --- .gitattributes | 4 ++++ .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ SECURITY.md | 12 ++++++++++++ phpstan.neon | 4 ++++ phpunit.xml | 5 ++++- 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 SECURITY.md create mode 100644 phpstan.neon diff --git a/.gitattributes b/.gitattributes index 29e5d24..aec0fd5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,10 @@ # Exclude tests and development configuration from distributed archives tests/ export-ignore phpunit.xml export-ignore +phpstan.neon export-ignore .phpunit.cache/ export-ignore .github/ export-ignore .idea/ export-ignore +REVIEW.md export-ignore +DECISIONS.md export-ignore + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cadbb68..c14eb03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,36 @@ jobs: php-version: ${{ matrix.php }} extensions: mbstring, xml, curl + - name: Validate composer.json + run: composer validate --strict + - name: Install dependencies run: composer install --no-progress --prefer-dist + - name: PHPStan + run: composer phpstan + + - name: Run tests + run: composer test + + prefer-lowest: + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Tests (PHP 8.2, prefer-lowest) + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.2' + extensions: mbstring, xml, curl + + - name: Install lowest dependencies + run: composer update --no-progress --prefer-lowest --prefer-stable + - name: Run tests run: composer test diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..be4705d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| --- | --- | +| 0.4.x | yes | +| < 0.4 | no | + +## Reporting a vulnerability + +Email [mail@pje-web.de](mailto:mail@pje-web.de). Please do not open a public issue for a vulnerability you have not already disclosed there. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..776ccd8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: max + paths: + - src diff --git a/phpunit.xml b/phpunit.xml index 94d6eaa..f02c941 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,10 +3,13 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" - cacheDirectory=".phpunit.cache"> + cacheDirectory=".phpunit.cache" + failOnDeprecation="true" + displayDetailsOnTestsThatTriggerDeprecations="true"> <testsuites> <testsuite name="OpenMapsight Embed"> <directory>tests</directory> + <exclude>tests/fixtures</exclude> </testsuite> </testsuites> </phpunit> From e52b4221a75738fceedca555f110b1495221083e Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:01:21 +0200 Subject: [PATCH 7/9] Document the 0.4 host API, trust boundaries, and Server-Timing. --- CHANGELOG.md | 33 +++++++--- README.md | 172 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 147 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2305977..d094aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,31 @@ ## Unreleased -- Validate `preset` as a JS identifier and `requestId` / `assetVersion` as header-safe tokens. -- Forward `locale` and `deviceClass` to the sidecar in `options`. -- Look up the SSR result cache before consulting the circuit breaker. -- Trip the breaker only on `SsrUnavailable` (connect / timeout / 5xx), not client or parse errors. -- Reject SSR HTML that does not start with a real element (no comment injection). -- Accept only v1 JSON from the sidecar; strip a UTF-8 BOM. Remove deprecated `curl_close()`. -- Cache-bust `mapsight.css` with `?v=` like the module imports. -- `SsrPublish::afterFeatureSourcePublish()` returns `SsrPurgeResult`. Blank URL lists are an error, not a purge-all. A failed sidecar purge no longer flushes the PHP cache. +Breaking 0.4.0 reshape. Placement data and sidecar configuration are split: +`EmbedRequest` is a placement value object; `SsrClient` is built once and +shared by `Renderer` and `SsrPublish`. `render()` returns `RenderedEmbed` +(the HTML-only method is gone). `SsrPublish::purge()` replaces +`afterFeatureSourcePublish()` and returns `PurgeResult`. + +- No implicit `$_SERVER` / `getenv` / `config` key fallbacks. `config` is + opaque; the library still writes documented v1 option keys over it. +- Share params (`feature`, `module` by default) whitelist `requestUrl` for + the cache key, sidecar payload, and pageMeta gate. +- `psr/log` and `psr/simple-cache` are the only runtime dependencies. + Optional `Psr16SsrResultCache`. Cache `set()` takes a TTL (default 3600 s). +- `SsrTransport::send()` returns raw HTTP. The client owns the v1 contract. +- `RenderedEmbed` exposes `ssr` / `ssrReason` / `ssrDurationMs` and fragment + parts (`stylesheetHtml`, `preloadHtml`, `containerHtml`, `bootScriptHtml`). +- Validate `preset`, `containerId`, `requestId`, `assetVersion`. Forward + `locale` / `deviceClass`. Optional `scriptNonce`. JS strings use + `json_encode` + `JSON_HEX_*`. +- Cache before breaker. Breaker counts only connect / timeout / 5xx. +- v1 HTML must start with `<[A-Za-z]` and match `containerId`. JSON only, + BOM stripped, response and state size caps. `curl_close()` removed. +- CSS `?v=`, `modulepreload`, transport protocol / `Expect` / no-follow + hardening. `health()` and `warm()`. `Testing\FakeSsrTransport`. +- PHPStan at max, `composer validate --strict`, `--prefer-lowest` CI, + `failOnDeprecation`. `SECURITY.md`. ## 0.3.0 — 2026-09-12 diff --git a/README.md b/README.md index 1d19248..5545d89 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # mapsight/embed PHP adapter for the Mapsight **embed protocol**. It emits a fragment you splice -into a page you already own: stylesheet, mount container, optional SSR try, -`mountEmbed` boot. +into a page you already own: stylesheet, modulepreload, mount container, +optional SSR try, `mountEmbed` boot. + +Composer package: `mapsight/embed`. Source: `open-mapsight/embed` (GitHub org). +Those names differ on purpose — Packagist vendor vs GitHub org. Preset name and config are opaque. **You** own wrappers, first-paint chrome, and `<head>`. This package does not. ```bash -composer require mapsight/embed:^0.3 +composer require mapsight/embed:^0.4 ``` Requires PHP 8.2+. MIT. @@ -23,6 +26,7 @@ Requires PHP 8.2+. MIT. │ │ │ ┌────────────── this library ──────────────┐ │ │ │ <link mapsight.css> │ │ +│ │ <link rel=modulepreload> │ │ │ │ <div id="…"> ← empty on miss │ │ │ │ <script type=module> ← mountEmbed │ │ │ │ │ │ │ @@ -53,7 +57,11 @@ returns junk, the page still boots client-only. ``` request │ - ├─ no ssrUrl ──────────────────────────► empty <div id> + boot + ├─ no SsrClient ───────────────────────► empty <div id> + boot + │ + ├─ cache hit ──────────────────────────► cached fragment + │ + ├─ breaker open ───────────────────────► <!-- mapsight-ssr-skipped --> │ ├─ POST /v1/render │ │ @@ -68,54 +76,116 @@ returns junk, the page still boots client-only. ## Usage +Build `SsrClient` once (DI / container). `EmbedRequest` is one placement. + ```php use OpenMapsight\Embed\EmbedRequest; +use OpenMapsight\Embed\Psr16SsrResultCache; use OpenMapsight\Embed\Renderer; +use OpenMapsight\Embed\SsrClient; +use OpenMapsight\Embed\SsrPublish; + +$ssr = new SsrClient( + ssrUrl: getenv('MAPSIGHT_SSR_URL') ?: 'http://127.0.0.1:4123', + resultCache: new Psr16SsrResultCache($psr16), // Redis / APCu / filesystem + logger: $logger, // optional PSR-3 +); +$renderer = new Renderer($ssr); // omit $ssr for client-only -$result = (new Renderer())->renderDocument(new EmbedRequest( +$result = $renderer->render(new EmbedRequest( preset: 'simpleMap', containerId: 'mapsight-embed-1', config: [ // opaque options for the preset factory your host build exports ], assetBase: '/mapsight/plan', - ssrUrl: getenv('MAPSIGHT_SSR_URL') ?: null, - requestUrl: $_SERVER['REQUEST_URI'] ?? null, + requestUrl: $request->getRequestUri(), // host-owned, explicit pageOrigin: 'https://www.example.com', ogImage: 'https://www.example.com/plan/img/og-default.png', + requestId: $request->headers->get('X-Request-Id'), + locale: 'de', + deviceClass: 'desktop', )); // Host <head> APIs, or: +// echo $result->stylesheetHtml; +// echo $result->preloadHtml; // echo \OpenMapsight\Embed\PageMetaTags::html($result->pageMeta); echo $result->html; ``` -`$result->html` is a **fragment**, not a document. Wrap it however you like. -`$result->pageMeta` is set only when the request URL has `?feature=` or -`?module=` *and* the sidecar returned meta. Apply it with your CMS title / -canonical / OG / JSON-LD APIs. Do not inject head tags into the fragment. +`$result->html` is a **fragment**, not a document. The same content is also +split into `stylesheetHtml`, `preloadHtml`, `containerHtml`, and +`bootScriptHtml` so a CMS head-injection API can take the links. +`$result->pageMeta` is set only when the normalised request URL has a share +parameter (`?feature=` / `?module=` by default) *and* the sidecar returned +meta. `$result->ssr` is `disabled` / `rendered` / `cached` / +`skipped_breaker` / `skipped_error`. `preset` becomes `/assets/{preset}.js` next to `embed.js` under `assetBase` and is interpolated as a JS import binding, so it must be a JavaScript -identifier (not `my-map`, not a reserved word). -`containerClassName` is optional; if you pass it, the empty mount and the -sidecar request both get that class. -`locale` and `deviceClass` are forwarded in the sidecar `options` when set. -`assetVersion` cache-busts `mapsight.css` as well as the module imports. +identifier (not `my-map`, not a reserved word). `containerId` must match +`^[A-Za-z][A-Za-z0-9_:.-]*$` and stay stable across renders or the cache +will miss. `containerClassName` is optional. `locale` and `deviceClass` are +forwarded in sidecar `options` when set. `assetVersion` cache-busts CSS and +both modules. `scriptNonce` is emitted on the inline module script. + +Pass `requestUrl` (path + search) and, when that URL is path-only, +`pageOrigin` so the sidecar can make absolute canonical / `og:url` / +default `og:image`. The client keeps only configured share parameters on +that URL (default `feature`, `module`) before it reaches the cache key, the +sidecar, or the pageMeta gate. + +### Server-Timing + +```php +header(sprintf( + 'Server-Timing: mapsight-ssr;dur=%.1f;desc=%s', + $result->ssrDurationMs, + $result->ssr->value, +)); +``` -Pass `requestUrl` (path + search, typically `REQUEST_URI`) and, when that URL -is path-only, `pageOrigin` so the sidecar can make absolute canonical / `og:url` -/ default `og:image`. Share search (`?feature=`, `?module=`) must be on that -URL so one placement’s HTML is not reused for another. +Suggested series if you already scrape metrics: +`mapsight_ssr_requests_total{outcome}`, `mapsight_ssr_duration_seconds`, +`mapsight_ssr_cache_hits_total`, `mapsight_ssr_breaker_open`. + +--- + +## Trust boundaries + +| Input | Trust | Notes | +| --- | --- | --- | +| Host fields on `EmbedRequest` / `SsrClient` | Trusted | You constructed them. | +| Sidecar JSON | Private network, mostly trusted | Parsed; HTML must start with a real element whose `id` matches `containerId`. Size-capped. | +| `requestUrl` | Untrusted | Normalised to path + share-param whitelist, capped at 2048 bytes. Never read from `$_SERVER`. | + +`requestId` and `assetVersion` must match `[A-Za-z0-9._-]{1,200}` because they +become HTTP headers. Validate or regenerate inbound `X-Request-Id` in the host +before passing it in. --- ## Sidecar This library POSTs to `{ssrUrl}/v1/render` and expects JSON -`{ v: 1, html, state, pageMeta }`. `state` is HTML-escaped onto -`data-dehydrated-state`. Optional `requestId` / `assetVersion` become -`X-Request-Id` / `X-Mapsight-Asset-Version`. +`{ v: 1, html, state, pageMeta }` with `Content-Type: application/json`. +`state` is HTML-escaped onto `data-dehydrated-state`. Optional `requestId` / +`assetVersion` become `X-Request-Id` / `X-Mapsight-Asset-Version`. + +### v1 request + +| Field | Role | +| --- | --- | +| `v` | Contract version (`1`) | +| `preset` | JS identifier / `/assets/{preset}.js` | +| `requestId` / `assetVersion` | Optional, also sent as headers | +| `options.containerId` | Required by the sidecar | +| `options.containerClassName` | Optional | +| `options.requestUrl` | Normalised path + share params | +| `options.pageOrigin` / `options.ogImage` | Absolute / root-absolute | +| `options.locale` / `options.deviceClass` | Optional, forwarded when set | +| `options.*` | Host `config` keys, overwritten by the documented keys above | The process is generic and stays **off public ingress**. Hosts pull [`ghcr.io/open-mapsight/ssr-sidecar`](https://github.com/open-mapsight/mapsight/tree/main/packages/ssr-sidecar) @@ -123,53 +193,53 @@ and bind-mount their own `render.js`. The image does not contain a host bundle. | Method | Path | Role | | --- | --- | --- | -| `GET` | `/health` | Liveness | +| `GET` | `/health` | Liveness (`SsrClient::health()`) | | `POST` | `/v1/render` | One placement → `{ html, state, pageMeta }` | -| `POST` | `/purge` | Drop sidecar caches (see publish below) | +| `POST` | `/purge` | Drop sidecar caches | There is no `POST /render`. -Timeouts are split: `ssrConnectTimeoutSeconds` (default 0.1) and -`ssrTimeoutSeconds` (default 2.0 total). Keep the total ≥ the sidecar’s +Timeouts are split: `connectTimeoutSeconds` (default 0.1) and +`timeoutSeconds` (default 2.0 total). Keep the total ≥ the sidecar’s `MAPSIGHT_SSR_AWAIT_TIMEOUT_MS` when your module awaits GeoJSON. After 5 -failures a process-local breaker skips Node for 15s. +connect / timeout / 5xx failures a process-local breaker skips Node for 15s. +4xx, encode errors, size caps, and v1 parse errors are logged and fail open +without opening the breaker. + +Pass an `SsrResultCache` (e.g. `Psr16SsrResultCache`, or `ArraySsrResultCache` +in tests — 256-entry LRU) to skip Node on a warm `{html,state}` hit. The +cache is consulted before the circuit breaker. Entries expire (`ttl`, default +3600 s). The key is `SsrCacheKey`: config + locale + deviceClass + +assetVersion + normalised requestUrl + contract `v`. -Pass an `SsrResultCache` (e.g. `ArraySsrResultCache`, or your Redis adapter) -to skip Node on a warm `{html,state}` hit. The cache is consulted before the -circuit breaker, so an open breaker still serves a warm fragment. The key is -`SsrCacheKey`: config + locale + deviceClass + assetVersion + requestUrl + -contract `v`. Those same locale / deviceClass values go to the sidecar. +`SsrClient::warm($request)` forces a sidecar call and stores the result +(publish then warm). Wire and hydration details live in the Mapsight monorepo — do not fork them here: -- [SSR and state hydration](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/SSR_HYDRATION.md) — `data-dehydrated-state`, fail-open, size bounds -- [`@mapsight/ssr-sidecar`](https://github.com/open-mapsight/mapsight/blob/main/packages/ssr-sidecar/README.md) — image, env, `/v1/render` / `/purge` -- [CMS PHP embed](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/CMS_PHP.md) — snippet pattern this library automates -- [Decision 006](https://github.com/open-mapsight/mapsight/blob/main/docs/architecture/decisions/006-ssr-state-hydration-goal.md) — why PHP → Node sidecar -- [Privacy: SSR sidecar](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/PRIVACY_DATA_FLOWS.md#ssr-sidecar-optional) — keep the POST inside your network +- [SSR and state hydration](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/SSR_HYDRATION.md) +- [`@mapsight/ssr-sidecar`](https://github.com/open-mapsight/mapsight/blob/main/packages/ssr-sidecar/README.md) +- [CMS PHP embed](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/CMS_PHP.md) +- [Decision 006](https://github.com/open-mapsight/mapsight/blob/main/docs/architecture/decisions/006-ssr-state-hydration-goal.md) +- [Privacy: SSR sidecar](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/PRIVACY_DATA_FLOWS.md#ssr-sidecar-optional) --- ## Publish / purge -When a feature-source or GeoJSON file changes, call `SsrPublish` **before** the -next page render. It POSTs sidecar `/purge` (prefer absolute list URLs; omit or -pass `[]` to clear all) and `flush()`es the PHP fragment cache **only after a -successful sidecar purge** (or when no sidecar URL is configured). A list that -filters down to no URLs (e.g. `['']`) throws instead of purging everything. -The return value is `SsrPurgeResult` (`sidecarPurged`, `deletedKeys`). Purging -Node only still serves stale HTML from PHP. +When a feature-source or GeoJSON file changes, call `SsrPublish` **before** +the next page render. It POSTs sidecar `/purge` (prefer absolute list URLs; +omit or pass `[]` to clear all) and `flush()`es the PHP fragment cache **only +after a successful sidecar purge**. A list that filters down to no URLs +(e.g. `['']`) throws instead of purging everything. ```php -$result = (new \OpenMapsight\Embed\SsrPublish( - getenv('MAPSIGHT_SSR_URL') ?: null, - $resultCache, // the SsrResultCache passed to Renderer, if any -))->afterFeatureSourcePublish([ +$result = (new SsrPublish($ssr))->purge([ 'https://www.example.com/geojson/places.geojson', ]); if (!$result->sidecarPurged) { - // sidecar URL unset, or POST failed — PHP cache was not flushed on failure + // POST failed — PHP cache was not flushed } ``` @@ -182,4 +252,6 @@ Do not use a feature-source revision env var as the bust protocol. ```bash composer install composer test +composer phpstan +composer validate --strict ``` From 94d73a30385f689d83fae06c469ccca8ae01319a Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:29:21 +0200 Subject: [PATCH 8/9] Read stream response headers in the file_get_contents caller. $GLOBALS['http_response_header'] is empty on PHP 8.2/8.3, so the no-curl fallback treated successful sidecar replies as transport failures. --- CHANGELOG.md | 2 ++ src/Embed/NativeSsrTransport.php | 57 ++++++++++++++++---------------- tests/NativeSsrTransportTest.php | 20 +++++++++++ 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d094aeb..7cb404d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ shared by `Renderer` and `SsrPublish`. `render()` returns `RenderedEmbed` hardening. `health()` and `warm()`. `Testing\FakeSsrTransport`. - PHPStan at max, `composer validate --strict`, `--prefer-lowest` CI, `failOnDeprecation`. `SECURITY.md`. +- Streams fallback reads `$http_response_header` in the `file_get_contents` + caller (PHP < 8.4). `$GLOBALS['http_response_header']` is empty there. ## 0.3.0 — 2026-09-12 diff --git a/src/Embed/NativeSsrTransport.php b/src/Embed/NativeSsrTransport.php index abf0ae1..96ad7ce 100644 --- a/src/Embed/NativeSsrTransport.php +++ b/src/Embed/NativeSsrTransport.php @@ -91,22 +91,15 @@ private function withStreams(SsrHttpRequest $request): SsrHttpResponse throw new SsrUnavailable('SSR request failed'); } - $status = 0; - $contentType = null; - $responseHeaders = $this->lastResponseHeaders(); - if (isset($responseHeaders[0]) - && preg_match('/\s(\d{3})\s/', $responseHeaders[0], $matches) === 1 - ) { - $status = (int) $matches[1]; - } - foreach ($responseHeaders as $line) { - if (stripos($line, 'Content-Type:') === 0) { - $contentType = trim(substr($line, strlen('Content-Type:'))); - break; - } + // file_get_contents() writes $http_response_header in this scope + // (PHP < 8.4). That is not $GLOBALS['http_response_header']. + if (function_exists('http_get_last_response_headers')) { + $responseHeaders = $this->stringLines(http_get_last_response_headers()); + } else { + $responseHeaders = $this->stringLines($http_response_header); } - return new SsrHttpResponse($status, $contentType, $body); + return $this->responseFromHeaderLines($responseHeaders, $body); } /** @@ -126,25 +119,31 @@ private function headerLines(SsrHttpRequest $request): array return $lines; } - /** @return list<string> */ - private function lastResponseHeaders(): array + /** + * @param list<string> $headers + */ + private function responseFromHeaderLines(array $headers, string $body): SsrHttpResponse { - if (function_exists('http_get_last_response_headers')) { - $fetched = http_get_last_response_headers(); - if (!is_array($fetched)) { - return []; - } - $headers = []; - foreach ($fetched as $line) { - if (is_string($line)) { - $headers[] = $line; - } + $status = 0; + $contentType = null; + if (isset($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches) === 1) { + $status = (int) $matches[1]; + } + foreach ($headers as $line) { + if (stripos($line, 'Content-Type:') === 0) { + $contentType = trim(substr($line, strlen('Content-Type:'))); + break; } - - return $headers; } - $raw = $GLOBALS['http_response_header'] ?? []; + return new SsrHttpResponse($status, $contentType, $body); + } + + /** + * @return list<string> + */ + private function stringLines(mixed $raw): array + { if (!is_array($raw)) { return []; } diff --git a/tests/NativeSsrTransportTest.php b/tests/NativeSsrTransportTest.php index efdbc07..65db590 100644 --- a/tests/NativeSsrTransportTest.php +++ b/tests/NativeSsrTransportTest.php @@ -125,6 +125,26 @@ public function test_raw_html_content_type_is_preserved(): void $this->assertStringContainsString('data-dehydrated-state', $response->body); } + public function test_streams_fallback_reads_status_and_content_type(): void + { + $transport = new NativeSsrTransport(); + $withStreams = new \ReflectionMethod($transport, 'withStreams'); + $response = $withStreams->invoke($transport, new SsrHttpRequest( + 'POST', + $this->url('/v1/render'), + ['Accept' => 'application/json', 'X-Request-Id' => 'req-streams'], + '{"v":1}', + 2.0, + 0.5, + )); + + $this->assertSame(200, $response->status); + $this->assertNotFalse(stripos((string) $response->contentType, 'application/json')); + $data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame(1, $data['v'] ?? null); + $this->assertSame('req-streams', $data['state']['headers']['x-request-id'] ?? null); + } + private function url(string $path): string { return 'http://127.0.0.1:' . self::$port . $path; From 8cf37b8870f4f9dbf338c74e3ed231d2337fa0d3 Mon Sep 17 00:00:00 2001 From: Paul Golmann <mail@pje-web.de> Date: Sat, 12 Sep 2026 20:32:36 +0200 Subject: [PATCH 9/9] Keep the deprecated http_response_header name off the PHP 8.5 class load. PHP 8.5 emits a deprecation when that identifier is compiled, even if the <8.4 branch never runs. Load it from a sidecar class instead. --- CHANGELOG.md | 3 ++- src/Embed/LegacyHttpStreamFetch.php | 27 +++++++++++++++++++++++++++ src/Embed/NativeSsrTransport.php | 25 ++++++++++++++----------- 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 src/Embed/LegacyHttpStreamFetch.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb404d..aa86d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ shared by `Renderer` and `SsrPublish`. `render()` returns `RenderedEmbed` - PHPStan at max, `composer validate --strict`, `--prefer-lowest` CI, `failOnDeprecation`. `SECURITY.md`. - Streams fallback reads `$http_response_header` in the `file_get_contents` - caller (PHP < 8.4). `$GLOBALS['http_response_header']` is empty there. + caller (PHP < 8.4). That identifier lives in a class loaded only then, so + PHP 8.5 does not compile the deprecation. `$GLOBALS` is empty there. ## 0.3.0 — 2026-09-12 diff --git a/src/Embed/LegacyHttpStreamFetch.php b/src/Embed/LegacyHttpStreamFetch.php new file mode 100644 index 0000000..0a50f41 --- /dev/null +++ b/src/Embed/LegacyHttpStreamFetch.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace OpenMapsight\Embed; + +/** + * PHP < 8.4 streams only. Loaded only when + * {@see http_get_last_response_headers()} is missing, so PHP 8.5 never + * compiles the deprecated `$http_response_header` identifier. + */ +final class LegacyHttpStreamFetch +{ + /** + * @param array<string, mixed> $http + * @return array{0: string, 1: list<string>} + */ + public static function get(string $url, array $http): array + { + $body = @file_get_contents($url, false, stream_context_create(['http' => $http])); + if ($body === false) { + throw new SsrUnavailable('SSR request failed'); + } + + return [$body, $http_response_header]; + } +} diff --git a/src/Embed/NativeSsrTransport.php b/src/Embed/NativeSsrTransport.php index 96ad7ce..b8730c2 100644 --- a/src/Embed/NativeSsrTransport.php +++ b/src/Embed/NativeSsrTransport.php @@ -86,20 +86,23 @@ private function withStreams(SsrHttpRequest $request): SsrHttpResponse $http['content'] = $request->body; } - $body = @file_get_contents($request->url, false, stream_context_create(['http' => $http])); - if ($body === false) { - throw new SsrUnavailable('SSR request failed'); - } - - // file_get_contents() writes $http_response_header in this scope - // (PHP < 8.4). That is not $GLOBALS['http_response_header']. + // PHP 8.5 deprecates the $http_response_header identifier itself. + // Keep that name out of this file; PHP < 8.4 loads a sidecar class. if (function_exists('http_get_last_response_headers')) { - $responseHeaders = $this->stringLines(http_get_last_response_headers()); - } else { - $responseHeaders = $this->stringLines($http_response_header); + $body = @file_get_contents($request->url, false, stream_context_create(['http' => $http])); + if ($body === false) { + throw new SsrUnavailable('SSR request failed'); + } + + return $this->responseFromHeaderLines( + $this->stringLines(http_get_last_response_headers()), + $body, + ); } - return $this->responseFromHeaderLines($responseHeaders, $body); + [$body, $headers] = LegacyHttpStreamFetch::get($request->url, $http); + + return $this->responseFromHeaderLines($this->stringLines($headers), $body); } /**