Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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

28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
168 changes: 126 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 │ │
│ │ │ │ │
Expand Down Expand Up @@ -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
│ │
Expand All @@ -68,97 +76,171 @@ 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). `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,
));
```

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`.

---

`preset` becomes `/assets/{preset}.js` next to `embed.js` under `assetBase`.
`containerClassName` is optional; if you pass it, the empty mount and the
sidecar request both get that class.
## 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`. |

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.
`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)
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 key is `SsrCacheKey`: config +
locale + deviceClass + assetVersion + requestUrl + contract `v`.
`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 to
clear all) and `flush()`es the PHP fragment cache. 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
(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) {
// POST failed — PHP cache was not flushed
}
```

Do not use a feature-source revision env var as the bust protocol.
Expand All @@ -170,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
```
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -46,6 +49,8 @@
"sort-packages": true
},
"scripts": {
"test": "phpunit"
"test": "phpunit",
"phpstan": "phpstan analyse",
"validate": "composer validate --strict"
}
}
Loading
Loading