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/CHANGELOG.md b/CHANGELOG.md
index 8f56754..aa86d7c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## Unreleased
+
+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`.
+- Streams fallback reads `$http_response_header` in the `file_get_contents`
+ 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
First public Packagist release. Composer name is `mapsight/embed` (MIT).
diff --git a/README.md b/README.md
index 8514ab2..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 `
`. 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 ──────────────┐ │
│ │ │ │
+│ │ │ │
│ │ ← empty on miss │ │
│ │
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 !== '') {
@@ -212,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 @@
+ */
+ public const DEFAULT_SHARE_PARAMS = ['feature', 'module'];
+
+ private readonly SsrTransport $transport;
+
+ private readonly SsrCircuitBreaker $breaker;
+
+ private readonly LoggerInterface $logger;
+
+ /**
+ * @param list
$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 $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
+ */
+ 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 $urls
+ * @return array
+ */
+ 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
+ */
+ 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/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 @@
+ $headers
+ */
+final class SsrHttpRequest
+{
+ /**
+ * @param array $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 @@
+|null $urls
- * @return list deleted sidecar cache keys (empty when sidecar is unset or fail-open)
- */
- public function afterFeatureSourcePublish(?array $urls = null): array
- {
- $deleted = $this->purgeSidecar($urls);
- $this->resultCache?->flush();
-
- return $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|null $urls
- * @return list
+ * 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 $urls
*/
- private function purgeSidecar(?array $urls): array
+ public function purge(array $urls = []): PurgeResult
{
- if ($this->ssrUrl === null || $this->ssrUrl === '') {
- 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,
- );
- } catch (\Throwable $error) {
- error_log('mapsight ssr purge failed: ' . $error->getMessage());
-
- return [];
- }
+ 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 @@
- $payload
- *
- * @return list
- *
- * @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 @@
+ $payload
- * @param array $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/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 @@
+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;
if (!str_ends_with($opening, '>')) {
- 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 +92,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 +115,20 @@ 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');
+ }
+
+ 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 @@
+ */
+ private array $queue = [];
+
+ /** @var list */
+ public array $requests = [];
+
+ public function queue(SsrHttpResponse|\Throwable $next): void
+ {
+ $this->queue[] = $next;
+ }
+
+ /**
+ * @param array $state
+ * @param array|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
new file mode 100644
index 0000000..fdc40ea
--- /dev/null
+++ b/tests/EmbedRequestTest.php
@@ -0,0 +1,100 @@
+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_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: 'simpleMap',
+ 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: '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(
+ 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/NativeSsrTransportTest.php b/tests/NativeSsrTransportTest.php
new file mode 100644
index 0000000..65db590
--- /dev/null
+++ b/tests/NativeSsrTransportTest.php
@@ -0,0 +1,161 @@
+ ['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);
+ }
+
+ 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;
+ }
+
+ private static function stopServer(): void
+ {
+ if (self::$process !== false) {
+ proc_terminate(self::$process);
+ proc_close(self::$process);
+ self::$process = false;
+ }
+ }
+}
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
'',
$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([
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());
+ }
+}
diff --git a/tests/RendererTest.php b/tests/RendererTest.php
index 7386b7f..7c7e714 100644
--- a/tests/RendererTest.php
+++ b/tests/RendererTest.php
@@ -6,13 +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\SsrDocument;
-use OpenMapsight\Embed\SsrTransport;
+use OpenMapsight\Embed\SsrClient;
+use OpenMapsight\Embed\SsrClientError;
+use OpenMapsight\Embed\SsrOutcome;
+use OpenMapsight\Embed\SsrUnavailable;
+use OpenMapsight\Embed\Testing\FakeSsrTransport;
use PHPUnit\Framework\TestCase;
final class RendererTest extends TestCase
@@ -20,186 +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('', $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('', $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('');
- }
- };
-
- $html = (new Renderer($transport))->render(new EmbedRequest(
- preset: 'infosite',
+ $transport = new FakeSsrTransport();
+ $transport->queueV1(
+ '',
+ ['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(
- '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('', $html);
- $this->assertStringContainsString('', $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('', $result->html);
+ $this->assertStringContainsString('', $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('');
- }
- };
-
- (new Renderer($transport))->render(new EmbedRequest(
- preset: 'infosite',
- containerId: 'mapsight-embed-timeouts',
- config: ['imagesUrl' => '/mapsight/plan/img/'],
+ $transport = new FakeSsrTransport();
+ $transport->queueV1('');
+ $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
@@ -207,72 +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 \RuntimeException('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('', $first);
- $this->assertStringContainsString('', $second);
- $this->assertStringContainsString('', $skipped);
+ $this->assertCount(2, $transport->requests);
+ $this->assertSame(SsrOutcome::SkippedBreaker, $skipped->ssr);
+ $this->assertStringContainsString('', $skipped->html);
$clock->now = 1010.0;
$afterCooldown = $renderer->render($request);
- $this->assertSame(3, $transport->calls);
- $this->assertStringContainsString('', $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;
-
- public function postJson(
- string $url,
- array $payload,
- float $timeoutSeconds,
- array $headers = [],
- float $connectTimeoutSeconds = 0.1,
- ): SsrDocument {
- $this->calls++;
-
- return new SsrDocument('');
- }
- };
+ $transport = new FakeSsrTransport();
+ $transport->queueV1('', ['app' => ['n' => 1]]);
+ $transport->queueV1('', ['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',
@@ -281,21 +191,25 @@ public function postJson(
$first = $renderer->render($request);
$second = $renderer->render($request);
- $this->assertSame(1, $transport->calls);
- $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',
@@ -303,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',
@@ -311,146 +225,98 @@ 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|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('');
- }
- };
-
- (new Renderer($transport))->render(new EmbedRequest(
- preset: 'stadtplan',
+ $transport = new FakeSsrTransport();
+ $transport->queueV1('');
+ $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',
));
- $this->assertSame(
- '/map/?module=baustellen-verkehr',
- $transport->payload['options']['requestUrl'] ?? null,
- );
+ $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_cache_key_includes_request_url(): void
+ public function test_utm_and_click_ids_do_not_change_the_cache_key(): 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));
+ $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 class implements SsrTransport {
- /** @var array|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('');
- }
- };
-
- (new Renderer($transport))->render(new EmbedRequest(
- preset: 'infosite',
+ $transport = new FakeSsrTransport();
+ $transport->queueV1('');
+ $this->renderer($transport)->render(new EmbedRequest(
+ preset: 'simpleMap',
containerId: 'mapsight-embed-origin',
- config: ['imagesUrl' => '/mapsight/plan/img/'],
- ssrUrl: 'http://ssr:4123',
+ config: [],
requestUrl: '/map?feature=poi-1',
pageOrigin: 'https://www.example.com',
ogImage: 'https://www.example.com/plan/img/og-default.png',
));
- $this->assertSame(
- 'https://www.example.com',
- $transport->payload['options']['pageOrigin'] ?? null,
- );
+ $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(
'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,
+ $payload['options']['ogImage'] ?? null,
);
+ $this->assertSame('/map?feature=poi-1', $payload['options']['requestUrl'] ?? null);
}
- public function test_render_document_exposes_page_meta_when_feature_or_module_is_on_request_url(): void
+ public function test_render_exposes_page_meta_when_share_param_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(
- '',
- $this->meta,
- );
- }
- };
-
- $withFeature = (new Renderer($transport))->renderDocument(new EmbedRequest(
- preset: 'infosite',
+ $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('', [], $meta);
+ $transport->queueV1('', [], $meta);
+ $transport->queueV1('', [], $meta);
+ $renderer = $this->renderer($transport);
+
+ $withFeature = $renderer->render(new EmbedRequest(
+ preset: 'simpleMap',
containerId: 'mapsight-embed-meta',
- config: ['imagesUrl' => '/mapsight/plan/img/'],
- ssrUrl: 'http://ssr:4123',
+ config: [],
requestUrl: '/map?feature=poi-1',
pageOrigin: 'https://www.example.com',
));
- $withModule = (new Renderer($transport))->renderDocument(new EmbedRequest(
- preset: 'stadtplan',
+ $withModule = $renderer->render(new EmbedRequest(
+ preset: 'simpleMap',
containerId: 'mapsight-embed-meta',
- config: ['imagesUrl' => '/mapsight/plan/img/'],
- ssrUrl: 'http://ssr:4123',
- requestUrl: '/plan/?module=parken',
+ config: [],
+ requestUrl: '/plan/?module=parking',
pageOrigin: 'https://www.example.com',
));
- $withoutShareable = (new Renderer($transport))->renderDocument(new EmbedRequest(
- preset: 'infosite',
+ $withoutShareable = $renderer->render(new EmbedRequest(
+ preset: 'simpleMap',
containerId: 'mapsight-embed-meta',
- config: ['imagesUrl' => '/mapsight/plan/img/'],
- ssrUrl: 'http://ssr:4123',
+ config: [],
requestUrl: '/map',
pageOrigin: 'https://www.example.com',
));
@@ -462,24 +328,79 @@ public function postJson(
$this->assertStringContainsString('data-dehydrated-state=', $withoutShareable->html);
}
- private static function samplePageMeta(): PlacePageMeta
+ public function test_open_circuit_still_serves_warm_cache(): void
{
- 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',
- ],
+ $transport = new FakeSsrTransport();
+ $transport->queueV1('', ['n' => 1]);
+ $cache = new ArraySsrResultCache();
+ $breaker = new ProcessSsrCircuitBreaker(1, 60.0);
+ $renderer = $this->renderer($transport, $cache, $breaker);
+ $request = new EmbedRequest(
+ preset: 'simpleMap',
+ containerId: 'mapsight-embed-warm',
+ config: [],
);
+
+ $warm = $renderer->render($request);
+ $breaker->recordFailure();
+ $this->assertFalse($breaker->allow());
+ $served = $renderer->render($request);
+
+ $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 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,
+ breaker: new ProcessSsrCircuitBreaker(2, 10.0),
+ );
+ $request = new EmbedRequest(
+ preset: 'simpleMap',
+ containerId: 'mapsight-embed-client-error',
+ config: [],
+ );
+
+ $renderer->render($request);
+ $renderer->render($request);
+ $third = $renderer->render($request);
+
+ $this->assertCount(3, $transport->requests);
+ $this->assertSame(SsrOutcome::SkippedError, $third->ssr);
+ }
+
+ public function test_script_nonce_and_json_encoded_urls(): void
+ {
+ $result = (new Renderer())->render(new EmbedRequest(
+ preset: 'simpleMap',
+ containerId: 'mapsight-embed-nonce',
+ config: [],
+ assetBase: '/x',
+ scriptNonce: 'abc-1',
+ ));
+
+ $this->assertStringContainsString('', $result->bootScriptHtml);
+ }
+
+ private function renderer(
+ FakeSsrTransport $transport,
+ ?ArraySsrResultCache $cache = null,
+ ?ProcessSsrCircuitBreaker $breaker = null,
+ ): Renderer {
+ return new Renderer(new SsrClient(
+ ssrUrl: 'http://ssr:4123',
+ transport: $transport,
+ resultCache: $cache,
+ breaker: $breaker,
+ ));
}
}
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 @@
+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('', ['n' => 1]);
+ $transport->queueV1('', ['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',
+ '',
+ ));
+ $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('');
+ $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(''), 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(''), 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 56fc71d..429ebe8 100644
--- a/tests/SsrPublishTest.php
+++ b/tests/SsrPublishTest.php
@@ -5,98 +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}> */
- 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(''));
- $hook = new SsrPublish('http://ssr:4123', $cache, $transport);
+ $cache->set('k1', new SsrDocument(''), 60);
+ $hook = new SsrPublish($this->client($transport, $cache));
- $deleted = $hook->afterFeatureSourcePublish([
- 'https://example.test/schools.geojson',
- ]);
+ $result = $hook->purge(['https://example.test/schools.geojson']);
- $this->assertSame(['doc::https://example.test/schools.geojson'], $deleted);
- $this->assertSame(1, $transport->calls);
- $this->assertSame('http://ssr:4123/purge', $transport->requests[0]['url'] ?? null);
+ $this->assertTrue($result->sidecarPurged);
+ $this->assertSame(['doc::https://example.test/schools.geojson'], $result->deletedKeys);
+ $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|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(''));
+ $cache->set('k1', new SsrDocument(''), 60);
- $deleted = (new SsrPublish('http://ssr:4123', $cache, $transport))
- ->afterFeatureSourcePublish();
+ $result = (new SsrPublish($this->client($transport, $cache)))->purge();
- $this->assertSame(['doc::all'], $deleted);
- $this->assertSame([], $transport->payload);
+ $this->assertTrue($result->sidecarPurged);
+ $this->assertSame(['doc::all'], $result->deletedKeys);
+ $this->assertSame('{}', $transport->requests[0]->body);
$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(
- 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(''));
+ $cache->set('k1', new SsrDocument(''), 60);
- $deleted = (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->assertSame([], $deleted);
- $this->assertNull($cache->get('k1'));
+ $this->assertFalse($result->sidecarPurged);
+ $this->assertSame([], $result->deletedKeys);
+ $this->assertNotNull($cache->get('k1'));
+ }
+
+ public function test_blank_urls_are_not_a_purge_all(): void
+ {
+ $transport = new FakeSsrTransport();
+ $cache = new ArraySsrResultCache();
+ $cache->set('k1', new SsrDocument(''), 60);
+
+ try {
+ (new SsrPublish($this->client($transport, $cache)))->purge(['']);
+ $this->fail('expected InvalidArgumentException');
+ } catch (\InvalidArgumentException $e) {
+ $this->assertStringContainsString('only empty urls', $e->getMessage());
+ }
+
+ $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 c0c3f85..cef673d 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;
@@ -15,7 +16,6 @@ public function test_injects_json_state_into_dehydrated_attribute(): void
'v' => 1,
'html' => '',
'state' => ['app' => ['title' => 'ok & "x"']],
- 'meta' => ['preset' => 'infosite'],
], JSON_THROW_ON_ERROR));
$this->assertStringContainsString('id="mapsight-embed-1"', $html);
@@ -26,6 +26,15 @@ public function test_injects_json_state_into_dehydrated_attribute(): void
$this->assertStringNotContainsString('