diff --git a/.changeset/ssr-sidecar.md b/.changeset/ssr-sidecar.md
new file mode 100644
index 00000000..646e111a
--- /dev/null
+++ b/.changeset/ssr-sidecar.md
@@ -0,0 +1,5 @@
+---
+"@mapsight/ssr-sidecar": minor
+---
+
+Add a Node 24 HTML embed sidecar with `/health`, `/v1/render`, and `/purge`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 032ec5db..32e561ab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,6 +27,7 @@ jobs:
outputs:
starters_e2e: ${{ steps.paths.outputs.starters_e2e }}
starters_copy_out: ${{ steps.paths.outputs.starters_copy_out }}
+ ssr_sidecar: ${{ steps.paths.outputs.ssr_sidecar }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -44,11 +45,13 @@ jobs:
if [[ "$EVENT_NAME" != "pull_request" ]]; then
echo "starters_e2e=true" >> "$GITHUB_OUTPUT"
echo "starters_copy_out=true" >> "$GITHUB_OUTPUT"
+ echo "ssr_sidecar=true" >> "$GITHUB_OUTPUT"
exit 0
fi
starters_e2e=false
starters_copy_out=false
+ ssr_sidecar=false
while IFS= read -r file; do
case "$file" in
.github/workflows/ci.yml|.github/actions/*|.nvmrc|package.json|pnpm-workspace.yaml|turbo.json)
@@ -89,8 +92,13 @@ jobs:
fi
done < <(git diff --name-only "$PR_BASE_SHA...$PR_HEAD_SHA")
+ if git diff --name-only "$PR_BASE_SHA...$PR_HEAD_SHA" | grep -qE '^(packages/ssr-sidecar/|\.github/workflows/ci\.yml$)'; then
+ ssr_sidecar=true
+ fi
+
echo "starters_e2e=$starters_e2e" >> "$GITHUB_OUTPUT"
echo "starters_copy_out=$starters_copy_out" >> "$GITHUB_OUTPUT"
+ echo "ssr_sidecar=$ssr_sidecar" >> "$GITHUB_OUTPUT"
shell: bash
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -399,6 +407,71 @@ jobs:
run: pnpm run test:starters:copy-out
shell: bash
+ ssr-sidecar-image:
+ name: SSR sidecar image
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
+ needs: [classify-ci]
+ if: needs.classify-ci.outputs.ssr_sidecar == 'true'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ submodules: false
+
+ - uses: $/.github/actions/setup/
+ with:
+ sync-main-ref: false
+
+ - name: Build sidecar package
+ run: pnpm --filter @mapsight/ssr-sidecar build
+ shell: bash
+
+ - name: Build sidecar image
+ run: docker build --tag ssr-sidecar:test packages/ssr-sidecar
+ shell: bash
+
+ - name: Smoke image HTTP contract
+ run: |
+ cid="$(docker run -d --rm -p 127.0.0.1:4123:4123 ssr-sidecar:test)"
+ cleanup() { docker rm -f "$cid" >/dev/null 2>&1 || true; }
+ trap cleanup EXIT
+
+ ok=0
+ for _ in $(seq 1 30); do
+ if curl -sf http://127.0.0.1:4123/health | grep -qx ok; then
+ ok=1
+ break
+ fi
+ sleep 1
+ done
+ if [[ "$ok" != "1" ]]; then
+ echo "sidecar did not become healthy"
+ docker logs "$cid" || true
+ exit 1
+ fi
+
+ render="$(curl -sf -X POST http://127.0.0.1:4123/v1/render \
+ -H 'content-type: application/json' \
+ -d '{"preset":"test","options":{"containerId":"mapsight-embed-1"}}')"
+ echo "$render" | grep -q '"v":1'
+ echo "$render" | grep -q 'mapsight-embed-1'
+
+ purge="$(curl -sf -X POST http://127.0.0.1:4123/purge \
+ -H 'content-type: application/json' \
+ -d '{}')"
+ test "$purge" = "[]"
+
+ render_code="$(curl -s -o /dev/null -w '%{http_code}' \
+ -X POST http://127.0.0.1:4123/render)"
+ test "$render_code" = "404"
+
+ unknown_code="$(curl -s -o /dev/null -w '%{http_code}' \
+ http://127.0.0.1:4123/foo)"
+ test "$unknown_code" = "404"
+ shell: bash
+
lint-dependencies:
name: Lint deps/pkg
timeout-minutes: 5
@@ -476,6 +549,7 @@ jobs:
test,
starters-e2e,
starters-copy-out,
+ ssr-sidecar-image,
lint-dependencies,
no-private-leak,
zizmor,
@@ -504,6 +578,7 @@ jobs:
permissions:
contents: write
pull-requests: write
+ packages: write # GHCR push for @mapsight/ssr-sidecar:beta
id-token: write # Required for OIDC
steps:
- name: Checkout
@@ -568,3 +643,32 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.published-packages }}
+
+ - name: Publish ssr-sidecar image
+ if: steps.changesets.outputs.published == 'true'
+ run: |
+ node <<'NODE' > "$RUNNER_TEMP/ssr-sidecar-version"
+ const packages = JSON.parse(process.env.PUBLISHED_PACKAGES || "[]");
+ const sidecar = packages.find((pkg) => pkg.name === "@mapsight/ssr-sidecar");
+ if (sidecar) {
+ console.log(sidecar.version);
+ }
+ NODE
+ version="$(cat "$RUNNER_TEMP/ssr-sidecar-version")"
+ if [[ -z "$version" ]]; then
+ echo "ssr-sidecar was not in this release"
+ exit 0
+ fi
+
+ echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
+ image="ghcr.io/open-mapsight/ssr-sidecar"
+ docker build \
+ --tag "$image:beta" \
+ --tag "$image:$version" \
+ packages/ssr-sidecar
+ docker push "$image:beta"
+ docker push "$image:$version"
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.published-packages }}
diff --git a/README.md b/README.md
index 9693c359..6ea67647 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,10 @@ Mapsight is a framework for building web applications with OpenLayers and React.
📈 count-aggregator-ui
| README |
Count aggregator UI (React) Embeddable wizard, time-series charts, and export links. CMS app-shell embed via vite-count-aggregator-embed. |
+
+🛰️ ssr-sidecar
| README |
+HTML embed SSR sidecar (Node 24) Generic GET /health, POST /v1/render, and POST /purge process. Hosts pull ghcr.io/open-mapsight/ssr-sidecar and bind-mount their render.js. npm beta is for image builds and tests. |
+
diff --git a/docs/integration/SSR_HYDRATION.md b/docs/integration/SSR_HYDRATION.md
index 3e654c92..0a5e0e9a 100644
--- a/docs/integration/SSR_HYDRATION.md
+++ b/docs/integration/SSR_HYDRATION.md
@@ -69,6 +69,12 @@ Monorepo entry points today:
**Maintainer CMS path for this phase:** PHP → **Node LTS** sidecar (Decision 006 still
lists Bun/framework alternatives as open for other hosts).
+Published process: [`@mapsight/ssr-sidecar`](../../packages/ssr-sidecar/README.md)
+and `ghcr.io/open-mapsight/ssr-sidecar`. Public HTTP surface is `GET /health`,
+`POST /v1/render` (JSON `{ v: 1, html, state, pageMeta, meta }`), and
+`POST /purge`. There is no `POST /render`. Hosts pull the image and bind-mount
+their `render.js`; the image does not bake a host bundle.
+
---
## Sidecar integration recipe (PHP CMS)
@@ -78,7 +84,7 @@ When using a **PHP → Node/Bun sidecar** (see [CMS_PHP](CMS_PHP.md)), treat SSR
### Request (CMS → sidecar)
-POST JSON to an internal render endpoint (localhost or private network):
+POST JSON to the sidecar `POST /v1/render` endpoint (localhost or private network):
```json
{
diff --git a/packages/ssr-sidecar/.dockerignore b/packages/ssr-sidecar/.dockerignore
new file mode 100644
index 00000000..a5705e7c
--- /dev/null
+++ b/packages/ssr-sidecar/.dockerignore
@@ -0,0 +1,3 @@
+*
+!package.json
+!dist
diff --git a/packages/ssr-sidecar/.gitignore b/packages/ssr-sidecar/.gitignore
new file mode 100644
index 00000000..849ddff3
--- /dev/null
+++ b/packages/ssr-sidecar/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/packages/ssr-sidecar/Dockerfile b/packages/ssr-sidecar/Dockerfile
new file mode 100644
index 00000000..eb6a41ef
--- /dev/null
+++ b/packages/ssr-sidecar/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:24-bookworm-slim
+
+WORKDIR /app
+
+COPY package.json ./
+COPY dist ./dist
+
+USER node
+
+ENV HOME=/tmp
+ENV MAPSIGHT_SSR_HOST=0.0.0.0
+ENV MAPSIGHT_SSR_PORT=4123
+ENV MAPSIGHT_SSR_MODULE=/app/dist/render.js
+
+EXPOSE 4123
+
+HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
+ CMD ["node", "-e", "fetch('http://127.0.0.1:4123/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+
+CMD ["node", "dist/server.js"]
diff --git a/packages/ssr-sidecar/README.md b/packages/ssr-sidecar/README.md
new file mode 100644
index 00000000..0d5aa3d6
--- /dev/null
+++ b/packages/ssr-sidecar/README.md
@@ -0,0 +1,85 @@
+# @mapsight/ssr-sidecar
+
+Generic Node 24 HTTP process for Mapsight HTML embed SSR.
+
+Hosts pull `ghcr.io/open-mapsight/ssr-sidecar` and bind-mount their product
+`render.js`. The image ships only this process plus a contract stub — never a
+host bundle.
+
+npm `@mapsight/ssr-sidecar` is how the image is built and tested. Host stacks
+pull the image; they do not `pnpm add` this package.
+
+The current dist-tag is **`beta`**.
+
+## Public contract
+
+| Method | Path | Response |
+| ------ | ------------ | -------------------------------------------- |
+| `GET` | `/health` | `ok` |
+| `POST` | `/v1/render` | JSON `{ v: 1, html, state, pageMeta, meta }` |
+| `POST` | `/purge` | JSON `string[]` of deleted cache keys |
+
+There is no `POST /render`. Unknown routes return `404`.
+
+`POST /v1/render` accepts:
+
+```json
+{
+ "preset": "simpleMap",
+ "options": {
+ "containerId": "mapsight-embed-1"
+ }
+}
+```
+
+Errors are `{ v: 1, error: { code, message } }` with `VALIDATION`,
+`BODY_TOO_LARGE`, `RENDER_FAILED`, or `RENDER_TIMEOUT`.
+
+`POST /purge` accepts `{ "urls": ["https://…/file.geojson"] }` or `{}` / no
+`urls` to clear all.
+
+Keep the process off public ingress.
+
+## Image
+
+```bash
+docker pull ghcr.io/open-mapsight/ssr-sidecar:beta
+```
+
+Bind-mount the host `dist-ssr` and point at the product module:
+
+```yaml
+services:
+ ssr:
+ image: ghcr.io/open-mapsight/ssr-sidecar:beta
+ environment:
+ MAPSIGHT_SSR_MODULE: /host/render.js
+ volumes:
+ - ${LOCAL_SSR_MOUNT}:/host:ro
+ ports:
+ - "127.0.0.1:4123:4123"
+```
+
+The stub module at `/app/dist/render.js` is the default when nothing is mounted.
+
+## Environment
+
+| Variable | Role |
+| ----------------------------------------- | ------------------------------------------------------------------------------ |
+| `MAPSIGHT_SSR_HOST` | Bind address (default `0.0.0.0`) |
+| `MAPSIGHT_SSR_PORT` | Bind port (default `4123`) |
+| `MAPSIGHT_SSR_MODULE` | ESM module exporting `render()` (and optionally `renderEnvelope()`, `purge()`) |
+| `MAPSIGHT_SSR_AWAIT_TIMEOUT_MS` | Read by the product module, not this server |
+| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Installed via `http.setGlobalProxyFromEnv` |
+| `MAPSIGHT_SSR_HTTP_ORIGINS` | Comma hosts rewritten `https` → `http` for hairpin fetches |
+
+## Develop
+
+```bash
+pnpm --filter @mapsight/ssr-sidecar test
+pnpm --filter @mapsight/ssr-sidecar typecheck
+pnpm --filter @mapsight/ssr-sidecar build
+```
+
+Hydration contract:
+[SSR and state hydration](https://github.com/open-mapsight/mapsight/blob/main/docs/integration/SSR_HYDRATION.md).
diff --git a/packages/ssr-sidecar/eslint.config.mts b/packages/ssr-sidecar/eslint.config.mts
new file mode 100644
index 00000000..bab321c1
--- /dev/null
+++ b/packages/ssr-sidecar/eslint.config.mts
@@ -0,0 +1,7 @@
+import {defineConfig} from "eslint/config";
+
+import baseConfig, {
+ testFilesEslintConfig,
+} from "../../configs/eslint-config-base.mts";
+
+export default defineConfig([baseConfig, testFilesEslintConfig]);
diff --git a/packages/ssr-sidecar/package.json b/packages/ssr-sidecar/package.json
new file mode 100644
index 00000000..88f28fc3
--- /dev/null
+++ b/packages/ssr-sidecar/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@mapsight/ssr-sidecar",
+ "description": "Node 24 HTTP sidecar for Mapsight HTML embed SSR",
+ "version": "0.0.0",
+ "type": "module",
+ "bin": {
+ "mapsight-ssr-sidecar": "./dist/server.js"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:"
+ },
+ "engines": {
+ "node": "^24.15.0"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "tag": "beta"
+ },
+ "repository": {
+ "url": "https://github.com/open-mapsight/mapsight"
+ },
+ "scripts": {
+ "build": "tsc --project tsconfig.build.json",
+ "clean": "rm -rf dist",
+ "clean-build": "npm-run-all clean build",
+ "lint": "eslint",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ }
+}
diff --git a/packages/ssr-sidecar/src/index.ts b/packages/ssr-sidecar/src/index.ts
new file mode 100644
index 00000000..a9e6a2fc
--- /dev/null
+++ b/packages/ssr-sidecar/src/index.ts
@@ -0,0 +1,24 @@
+export {
+ defaultRenderModulePath,
+ startSsrSidecar,
+ type SsrSidecarListen,
+ type SsrSidecarOptions,
+} from "./server.ts";
+export {purge, render, renderEnvelope} from "./render.ts";
+export type {PlacePageMeta, SsrEnvelope, SsrRequestBody} from "./render.ts";
+export {
+ extractStateFromFragment,
+ normalizeRenderResult,
+ type SsrRenderResult,
+ type SsrV1Error,
+ type SsrV1ErrorCode,
+ type SsrV1Success,
+} from "./v1.ts";
+export {parsePurgeUrls, runPurge, type PurgeFn} from "./purge.ts";
+export {
+ httpOriginHostsFromEnv,
+ installEnvHttpProxyDispatcher,
+ installHttpsOriginRewrite,
+ proxyUrlFromEnv,
+ rewriteHttpsToHttpOrigin,
+} from "./proxy.ts";
diff --git a/packages/ssr-sidecar/src/proxy.test.ts b/packages/ssr-sidecar/src/proxy.test.ts
new file mode 100644
index 00000000..90f2d437
--- /dev/null
+++ b/packages/ssr-sidecar/src/proxy.test.ts
@@ -0,0 +1,68 @@
+import {describe, expect, it} from "vitest";
+
+import {
+ httpOriginHostsFromEnv,
+ installEnvHttpProxyDispatcher,
+ proxyUrlFromEnv,
+ rewriteHttpsToHttpOrigin,
+} from "./proxy.ts";
+
+describe("proxyUrlFromEnv", () => {
+ it("prefers HTTPS_PROXY then HTTP_PROXY", () => {
+ expect(
+ proxyUrlFromEnv({
+ HTTPS_PROXY: "http://proxy.example:8080/",
+ HTTP_PROXY: "http://other.example:8080/",
+ }),
+ ).toBe("http://proxy.example:8080/");
+ expect(
+ proxyUrlFromEnv({http_proxy: "http://legacy.example:8080/"}),
+ ).toBe("http://legacy.example:8080/");
+ expect(proxyUrlFromEnv({HTTP_PROXY: " "})).toBeUndefined();
+ expect(proxyUrlFromEnv({})).toBeUndefined();
+ });
+});
+
+describe("rewriteHttpsToHttpOrigin", () => {
+ it("only rewrites listed https hosts", () => {
+ const hosts = ["maps.example.test"];
+ expect(
+ rewriteHttpsToHttpOrigin(
+ "https://maps.example.test/geojson/sights.geojson",
+ hosts,
+ ),
+ ).toBe("http://maps.example.test/geojson/sights.geojson");
+ expect(
+ rewriteHttpsToHttpOrigin(
+ "https://other.example.test/a.geojson",
+ hosts,
+ ),
+ ).toBe("https://other.example.test/a.geojson");
+ expect(
+ rewriteHttpsToHttpOrigin(
+ "http://maps.example.test/geojson/sights.geojson",
+ hosts,
+ ),
+ ).toBe("http://maps.example.test/geojson/sights.geojson");
+ expect(rewriteHttpsToHttpOrigin("/geojson/a.geojson", hosts)).toBe(
+ "/geojson/a.geojson",
+ );
+ });
+});
+
+describe("httpOriginHostsFromEnv", () => {
+ it("splits MAPSIGHT_SSR_HTTP_ORIGINS", () => {
+ expect(httpOriginHostsFromEnv({})).toEqual([]);
+ expect(
+ httpOriginHostsFromEnv({
+ MAPSIGHT_SSR_HTTP_ORIGINS: "maps.example.test, Other.EXAMPLE",
+ }),
+ ).toEqual(["maps.example.test", "other.example"]);
+ });
+});
+
+describe("installEnvHttpProxyDispatcher", () => {
+ it("is a no-op without proxy env", () => {
+ expect(installEnvHttpProxyDispatcher({})).toBeUndefined();
+ });
+});
diff --git a/packages/ssr-sidecar/src/proxy.ts b/packages/ssr-sidecar/src/proxy.ts
new file mode 100644
index 00000000..946ef84a
--- /dev/null
+++ b/packages/ssr-sidecar/src/proxy.ts
@@ -0,0 +1,125 @@
+/**
+ * Node fetch ignores HTTP_PROXY unless a dispatcher is installed.
+ * `http.setGlobalProxyFromEnv` is Node's hook for that (sets the undici
+ * global dispatcher). Call before any outbound fetch, including the
+ * MAPSIGHT_SSR_MODULE import.
+ */
+import http from "node:http";
+
+type HttpWithEnvProxy = typeof http & {
+ setGlobalProxyFromEnv?: (env: NodeJS.ProcessEnv) => void;
+};
+
+export function proxyUrlFromEnv(
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+ const value =
+ env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed === "" ? undefined : trimmed;
+}
+
+export function httpOriginHostsFromEnv(
+ env: NodeJS.ProcessEnv = process.env,
+): string[] {
+ const raw = env.MAPSIGHT_SSR_HTTP_ORIGINS;
+ if (typeof raw !== "string" || raw.trim() === "") {
+ return [];
+ }
+ return raw
+ .split(",")
+ .map((host) => host.trim().toLowerCase())
+ .filter((host) => host !== "");
+}
+
+export function rewriteHttpsToHttpOrigin(
+ href: string,
+ httpHosts: string[],
+): string {
+ if (httpHosts.length === 0) {
+ return href;
+ }
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ return href;
+ }
+ if (url.protocol !== "https:") {
+ return href;
+ }
+ if (!httpHosts.includes(url.hostname.toLowerCase())) {
+ return href;
+ }
+ url.protocol = "http:";
+ return url.href;
+}
+
+export function installEnvHttpProxyDispatcher(
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+ const proxyUrl = proxyUrlFromEnv(env);
+ if (proxyUrl === undefined) {
+ return undefined;
+ }
+ const setGlobalProxyFromEnv = (http as HttpWithEnvProxy)
+ .setGlobalProxyFromEnv;
+ if (typeof setGlobalProxyFromEnv !== "function") {
+ throw new Error(
+ "HTTP_PROXY is set but this Node build has no http.setGlobalProxyFromEnv; use Node 24.19+ or node --use-env-proxy",
+ );
+ }
+ setGlobalProxyFromEnv({
+ HTTP_PROXY: env.HTTP_PROXY ?? env.http_proxy ?? proxyUrl,
+ HTTPS_PROXY: env.HTTPS_PROXY ?? env.https_proxy ?? proxyUrl,
+ NO_PROXY: env.NO_PROXY ?? env.no_proxy,
+ http_proxy: env.http_proxy ?? env.HTTP_PROXY ?? proxyUrl,
+ https_proxy: env.https_proxy ?? env.HTTPS_PROXY ?? proxyUrl,
+ no_proxy: env.no_proxy ?? env.NO_PROXY,
+ });
+ return proxyUrl;
+}
+
+export function installHttpsOriginRewrite(
+ env: NodeJS.ProcessEnv = process.env,
+): string[] {
+ const httpHosts = httpOriginHostsFromEnv(env);
+ if (httpHosts.length === 0) {
+ return [];
+ }
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = function mapsightSsrFetch(
+ input: Parameters[0],
+ init?: Parameters[1],
+ ): Promise {
+ const href = requestHref(input);
+ if (href === undefined) {
+ return originalFetch(input, init);
+ }
+ const rewritten = rewriteHttpsToHttpOrigin(href, httpHosts);
+ if (rewritten === href) {
+ return originalFetch(input, init);
+ }
+ if (typeof input === "string" || input instanceof URL) {
+ return originalFetch(rewritten, init);
+ }
+ return originalFetch(new Request(rewritten, input), init);
+ };
+ return httpHosts;
+}
+
+function requestHref(input: Parameters[0]): string | undefined {
+ if (typeof input === "string") {
+ return input;
+ }
+ if (input instanceof URL) {
+ return input.href;
+ }
+ if (typeof Request !== "undefined" && input instanceof Request) {
+ return input.url;
+ }
+ return undefined;
+}
diff --git a/packages/ssr-sidecar/src/purge.test.ts b/packages/ssr-sidecar/src/purge.test.ts
new file mode 100644
index 00000000..d73972d5
--- /dev/null
+++ b/packages/ssr-sidecar/src/purge.test.ts
@@ -0,0 +1,65 @@
+import {describe, expect, it} from "vitest";
+
+import {parsePurgeUrls, runPurge} from "./purge.ts";
+
+describe("parsePurgeUrls", () => {
+ it("treats empty body and missing urls as clear-all", () => {
+ expect(parsePurgeUrls("")).toBeUndefined();
+ expect(parsePurgeUrls("{}")).toBeUndefined();
+ expect(parsePurgeUrls(" ")).toBeUndefined();
+ expect(parsePurgeUrls('{"urls":[]}')).toEqual([]);
+ });
+
+ it("keeps absolute GeoJSON URLs and drops blanks", () => {
+ expect(
+ parsePurgeUrls(
+ JSON.stringify({
+ urls: [
+ "https://maps.example.test/geojson/schools.geojson",
+ " ",
+ "https://www.example.test/mapsight/pulp/result/parking.geojson",
+ ],
+ }),
+ ),
+ ).toEqual([
+ "https://maps.example.test/geojson/schools.geojson",
+ "https://www.example.test/mapsight/pulp/result/parking.geojson",
+ ]);
+ });
+
+ it("rejects invalid JSON and non-string urls", () => {
+ expect(() => parsePurgeUrls("{")).toThrow(
+ expect.objectContaining({statusCode: 400}),
+ );
+ expect(() => parsePurgeUrls("[]")).toThrow(
+ expect.objectContaining({statusCode: 400}),
+ );
+ expect(() =>
+ parsePurgeUrls('{"urls":"https://example.test/a.geojson"}'),
+ ).toThrow(expect.objectContaining({statusCode: 400}));
+ expect(() => parsePurgeUrls('{"urls":[1]}')).toThrow(
+ expect.objectContaining({statusCode: 400}),
+ );
+ });
+});
+
+describe("runPurge", () => {
+ it("omits urls for clear-all and passes listed URLs through", async () => {
+ const calls: Array = [];
+ const purge = (urls?: string[]) => {
+ calls.push(urls);
+ return urls ?? ["doc::all"];
+ };
+
+ expect(await runPurge(purge, undefined)).toEqual(["doc::all"]);
+ expect(await runPurge(purge, [])).toEqual(["doc::all"]);
+ expect(
+ await runPurge(purge, ["https://example.test/a.geojson"]),
+ ).toEqual(["https://example.test/a.geojson"]);
+ expect(calls).toEqual([
+ undefined,
+ undefined,
+ ["https://example.test/a.geojson"],
+ ]);
+ });
+});
diff --git a/packages/ssr-sidecar/src/purge.ts b/packages/ssr-sidecar/src/purge.ts
new file mode 100644
index 00000000..8004e809
--- /dev/null
+++ b/packages/ssr-sidecar/src/purge.ts
@@ -0,0 +1,66 @@
+/**
+ * POST /purge body → urls for the product module's purge(urls?).
+ * Omit urls or pass [] to clear the whole process cache.
+ */
+
+export type PurgeFn = (urls?: string[]) => string[] | Promise;
+
+export function parsePurgeUrls(raw: string): string[] | undefined {
+ const trimmed = raw.trim();
+ if (trimmed === "") {
+ return undefined;
+ }
+
+ let body: unknown;
+ try {
+ body = JSON.parse(trimmed);
+ } catch {
+ throw Object.assign(new Error("invalid JSON"), {
+ statusCode: 400,
+ ssrCode: "VALIDATION" as const,
+ });
+ }
+
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
+ throw Object.assign(new Error("purge body must be a JSON object"), {
+ statusCode: 400,
+ ssrCode: "VALIDATION" as const,
+ });
+ }
+
+ if (!("urls" in body) || body.urls === undefined) {
+ return undefined;
+ }
+
+ if (!Array.isArray(body.urls)) {
+ throw Object.assign(new Error("urls must be an array of strings"), {
+ statusCode: 400,
+ ssrCode: "VALIDATION" as const,
+ });
+ }
+
+ const urls: string[] = [];
+ for (const url of body.urls) {
+ if (typeof url !== "string") {
+ throw Object.assign(new Error("urls must be an array of strings"), {
+ statusCode: 400,
+ ssrCode: "VALIDATION" as const,
+ });
+ }
+ const value = url.trim();
+ if (value !== "") {
+ urls.push(value);
+ }
+ }
+ return urls;
+}
+
+export async function runPurge(
+ purge: PurgeFn,
+ urls: string[] | undefined,
+): Promise {
+ const deleted = await purge(
+ urls === undefined || urls.length === 0 ? undefined : urls,
+ );
+ return Array.isArray(deleted) ? deleted : [];
+}
diff --git a/packages/ssr-sidecar/src/render.test.ts b/packages/ssr-sidecar/src/render.test.ts
new file mode 100644
index 00000000..6a689476
--- /dev/null
+++ b/packages/ssr-sidecar/src/render.test.ts
@@ -0,0 +1,37 @@
+import {describe, expect, it} from "vitest";
+
+import {render, renderEnvelope} from "./render.ts";
+
+describe("render", () => {
+ it("requires containerId", () => {
+ expect(() => render({preset: "test"})).toThrow(
+ expect.objectContaining({
+ message: "options.containerId is required",
+ statusCode: 400,
+ }),
+ );
+ });
+
+ it("uses the generic embed class and stub state", () => {
+ const html = render({
+ preset: "infosite",
+ options: {containerId: "mapsight-embed-1"},
+ });
+
+ expect(html).toContain('id="mapsight-embed-1"');
+ expect(html).toContain('class="mapsight-embed"');
+ expect(html).toContain("data-dehydrated-state=");
+ expect(html).not.toContain("bs-mapsight-embed");
+ expect(html).toContain(""ssr":"stub"");
+ });
+});
+
+describe("renderEnvelope", () => {
+ it("returns the fragment with null pageMeta", async () => {
+ const envelope = await renderEnvelope({
+ options: {containerId: "mapsight-embed-1"},
+ });
+ expect(envelope.pageMeta).toBeNull();
+ expect(envelope.html).toContain('id="mapsight-embed-1"');
+ });
+});
diff --git a/packages/ssr-sidecar/src/render.ts b/packages/ssr-sidecar/src/render.ts
new file mode 100644
index 00000000..1e161378
--- /dev/null
+++ b/packages/ssr-sidecar/src/render.ts
@@ -0,0 +1,98 @@
+/**
+ * Default SSR render module (contract stub).
+ *
+ * Hosts mount their product bundle on /host and set
+ * MAPSIGHT_SSR_MODULE=/host/render.js — same Node 24 container.
+ *
+ * Expected POST JSON:
+ * { v?: 1, preset, options: { containerId, containerClassName?, … } }
+ *
+ * `render()` returns an HTML fragment with data-dehydrated-state.
+ * `renderEnvelope()` is the same fragment plus pageMeta (null in this stub).
+ * `/v1/render` also accepts `{ html, state }` from the module.
+ */
+
+export type SsrRequestBody = {
+ v?: 1;
+ preset?: string;
+ requestId?: string;
+ assetVersion?: string;
+ options?: {
+ containerId?: string;
+ containerClassName?: string;
+ dehydratedState?: unknown;
+ requestUrl?: string;
+ pageOrigin?: string;
+ ogImage?: string;
+ [key: string]: unknown;
+ };
+};
+
+export type PlacePageMeta = {
+ title: string;
+ description: string;
+ canonicalUrl: string;
+ og: {
+ title: string;
+ description: string;
+ url: string;
+ type: "place" | "website";
+ image: string;
+ };
+ jsonLd: Record;
+};
+
+export type SsrEnvelope = {
+ html: string;
+ pageMeta: PlacePageMeta | null;
+};
+
+export function render(body: SsrRequestBody): string {
+ const options = body?.options ?? {};
+ const containerId = options.containerId;
+ if (typeof containerId !== "string" || containerId === "") {
+ const err = new Error("options.containerId is required") as Error & {
+ statusCode?: number;
+ };
+ err.statusCode = 400;
+ throw err;
+ }
+
+ const className =
+ typeof options.containerClassName === "string" &&
+ options.containerClassName !== ""
+ ? options.containerClassName
+ : "mapsight-embed";
+
+ const dehydratedState =
+ options.dehydratedState && typeof options.dehydratedState === "object"
+ ? options.dehydratedState
+ : {
+ app: {
+ ssr: "stub",
+ preset: body?.preset ?? null,
+ },
+ };
+
+ const stateJson = JSON.stringify(dehydratedState);
+
+ return ``;
+}
+
+/** Same fragment as render(), plus pageMeta. Stub has no feature lookup. */
+export function renderEnvelope(body: SsrRequestBody): Promise {
+ return Promise.resolve({html: render(body), pageMeta: null});
+}
+
+/** Product module clears xhr-json documents. Stub has no process cache. */
+export function purge(_urls?: string[]): Promise {
+ return Promise.resolve([]);
+}
+
+function escapeAttr(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
diff --git a/packages/ssr-sidecar/src/server.test.ts b/packages/ssr-sidecar/src/server.test.ts
new file mode 100644
index 00000000..a4b3fe04
--- /dev/null
+++ b/packages/ssr-sidecar/src/server.test.ts
@@ -0,0 +1,88 @@
+import {fileURLToPath} from "node:url";
+
+import {afterAll, beforeAll, describe, expect, it} from "vitest";
+
+import {type SsrSidecarListen, startSsrSidecar} from "./server.ts";
+
+describe("ssr sidecar HTTP contract", () => {
+ let listen: SsrSidecarListen;
+ let base: string;
+
+ beforeAll(async () => {
+ listen = await startSsrSidecar({
+ host: "127.0.0.1",
+ port: 0,
+ modulePath: fileURLToPath(new URL("./render.ts", import.meta.url)),
+ });
+ base = `http://127.0.0.1:${listen.port}`;
+ });
+
+ afterAll(async () => {
+ await new Promise((resolve, reject) => {
+ listen.server.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ });
+
+ it("serves GET /health", async () => {
+ const response = await fetch(`${base}/health`);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("ok");
+ });
+
+ it("renders POST /v1/render", async () => {
+ const response = await fetch(`${base}/v1/render`, {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ preset: "test",
+ options: {containerId: "mapsight-embed-1"},
+ }),
+ });
+ expect(response.status).toBe(200);
+ const payload = (await response.json()) as {
+ v: number;
+ html: string;
+ state: unknown;
+ pageMeta: unknown;
+ };
+ expect(payload.v).toBe(1);
+ expect(payload.html).toContain('id="mapsight-embed-1"');
+ expect(payload.pageMeta).toBeNull();
+ expect(payload.state).toEqual({
+ app: {ssr: "stub", preset: "test"},
+ });
+ });
+
+ it("clears cache keys on POST /purge", async () => {
+ const response = await fetch(`${base}/purge`, {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: "{}",
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual([]);
+ });
+
+ it("does not expose POST /render", async () => {
+ const response = await fetch(`${base}/render`, {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ options: {containerId: "mapsight-embed-1"},
+ }),
+ });
+ expect(response.status).toBe(404);
+ expect(await response.text()).toBe("not found");
+ });
+
+ it("returns 404 for unknown routes", async () => {
+ const response = await fetch(`${base}/foo`);
+ expect(response.status).toBe(404);
+ });
+});
diff --git a/packages/ssr-sidecar/src/server.ts b/packages/ssr-sidecar/src/server.ts
new file mode 100644
index 00000000..7e72987c
--- /dev/null
+++ b/packages/ssr-sidecar/src/server.ts
@@ -0,0 +1,342 @@
+/**
+ * Generic Mapsight SSR sidecar — Node 24.
+ *
+ * Env:
+ * MAPSIGHT_SSR_HOST (default 0.0.0.0)
+ * MAPSIGHT_SSR_PORT (default 4123)
+ * MAPSIGHT_SSR_MODULE — ESM module exporting render(body)
+ * (sync string, Promise, or { html, state }; this server always awaits)
+ * and optionally renderEnvelope(body) → { html, pageMeta } and purge(urls?)
+ * MAPSIGHT_SSR_AWAIT_TIMEOUT_MS — read by the product module (not this server);
+ * keep the host HTTP timeout above that budget
+ * HTTP_PROXY / HTTPS_PROXY / NO_PROXY — http.setGlobalProxyFromEnv (all fetch)
+ * MAPSIGHT_SSR_HTTP_ORIGINS — comma hosts rewritten https→http (hairpin)
+ *
+ * Routes:
+ * GET /health
+ * POST /v1/render → JSON { v, html, state, pageMeta, meta }
+ * POST /purge → JSON string[] of deleted cache keys
+ */
+import http from "node:http";
+import type {IncomingMessage, ServerResponse} from "node:http";
+import path from "node:path";
+import {fileURLToPath, pathToFileURL} from "node:url";
+
+import {
+ installEnvHttpProxyDispatcher,
+ installHttpsOriginRewrite,
+} from "./proxy.ts";
+import {type PurgeFn, parsePurgeUrls, runPurge} from "./purge.ts";
+import type {SsrEnvelope, SsrRequestBody} from "./render.ts";
+import {
+ type SsrRenderResult,
+ type SsrV1Error,
+ type SsrV1ErrorCode,
+ type SsrV1Success,
+ normalizeRenderResult,
+} from "./v1.ts";
+
+export type SsrSidecarOptions = {
+ host?: string;
+ port?: number;
+ modulePath?: string;
+};
+
+export type SsrSidecarListen = {
+ server: http.Server;
+ host: string;
+ port: number;
+ modulePath: string;
+};
+
+type RenderFn = (
+ body: SsrRequestBody,
+) => SsrRenderResult | Promise;
+
+type RenderEnvelopeFn = (
+ body: SsrRequestBody,
+) => SsrEnvelope | Promise;
+
+const maxBodyBytes = 256 * 1024;
+
+export function defaultRenderModulePath(): string {
+ return fileURLToPath(new URL("./render.js", import.meta.url));
+}
+
+export async function startSsrSidecar(
+ options: SsrSidecarOptions = {},
+): Promise {
+ const host = options.host ?? process.env.MAPSIGHT_SSR_HOST ?? "0.0.0.0";
+ const port =
+ options.port ?? Number(process.env.MAPSIGHT_SSR_PORT ?? "4123");
+ const modulePath =
+ options.modulePath ??
+ process.env.MAPSIGHT_SSR_MODULE ??
+ defaultRenderModulePath();
+ const proxyUrl = installEnvHttpProxyDispatcher();
+ const httpOrigins = installHttpsOriginRewrite();
+
+ const loaded = (await import(pathToFileURL(modulePath).href)) as {
+ render?: RenderFn;
+ renderEnvelope?: RenderEnvelopeFn;
+ purge?: PurgeFn;
+ };
+ const loadedRender = loaded.render;
+ if (typeof loadedRender !== "function") {
+ throw new Error(
+ `MAPSIGHT_SSR_MODULE must export render(): ${modulePath}`,
+ );
+ }
+ const render: RenderFn = loadedRender;
+ const loadedEnvelope = loaded.renderEnvelope;
+ const renderEnvelope: RenderEnvelopeFn | undefined =
+ typeof loadedEnvelope === "function" ? loadedEnvelope : undefined;
+ if (renderEnvelope === undefined) {
+ console.warn(
+ "ssr: MAPSIGHT_SSR_MODULE has no renderEnvelope(); pageMeta stays null",
+ );
+ }
+ const loadedPurge = loaded.purge;
+ const purge: PurgeFn =
+ typeof loadedPurge === "function"
+ ? loadedPurge
+ : () => {
+ console.warn(
+ "ssr: MAPSIGHT_SSR_MODULE has no purge(); POST /purge is a no-op",
+ );
+ return [];
+ };
+
+ async function loadEnvelope(body: SsrRequestBody): Promise {
+ if (renderEnvelope !== undefined) {
+ const envelope = await renderEnvelope(body);
+ return {
+ html: envelope.html,
+ pageMeta: envelope.pageMeta ?? null,
+ };
+ }
+ const result = await render(body);
+ const html = typeof result === "string" ? result : result.html;
+ return {html, pageMeta: null};
+ }
+
+ async function handlePurge(
+ req: IncomingMessage,
+ res: ServerResponse,
+ ): Promise {
+ try {
+ const raw = await readBody(req, maxBodyBytes);
+ const urls = parsePurgeUrls(raw);
+ const deleted = await runPurge(purge, urls);
+ res.writeHead(200, {
+ "Content-Type": "application/json; charset=utf-8",
+ });
+ res.end(JSON.stringify(deleted));
+ } catch (error) {
+ const {status, code, message} = classifyError(error);
+ writeV1Error(res, status, code, message);
+ }
+ }
+
+ async function handleV1(
+ req: IncomingMessage,
+ res: ServerResponse,
+ ): Promise {
+ const started = performance.now();
+ const requestId = headerValue(req, "x-request-id");
+ const assetVersion = headerValue(req, "x-mapsight-asset-version");
+ try {
+ const raw = await readBody(req, maxBodyBytes);
+ let body: SsrRequestBody;
+ try {
+ body = JSON.parse(raw) as SsrRequestBody;
+ } catch {
+ writeV1Error(res, 400, "VALIDATION", "invalid JSON", requestId);
+ return;
+ }
+ if (requestId && body.requestId == null) {
+ body.requestId = requestId;
+ }
+ if (assetVersion && body.assetVersion == null) {
+ body.assetVersion = assetVersion;
+ }
+ const envelope = await loadEnvelope(body);
+ const {html, state} = normalizeRenderResult(envelope.html, body);
+ const payload: SsrV1Success = {
+ v: 1,
+ html,
+ state,
+ pageMeta: envelope.pageMeta,
+ meta: {
+ preset: body.preset,
+ renderMs: Math.round(performance.now() - started),
+ requestId: body.requestId ?? requestId,
+ assetVersion: body.assetVersion ?? assetVersion,
+ },
+ };
+ res.writeHead(200, {
+ "Content-Type": "application/json; charset=utf-8",
+ ...(payload.meta.requestId
+ ? {"X-Request-Id": payload.meta.requestId}
+ : {}),
+ });
+ res.end(JSON.stringify(payload));
+ } catch (error) {
+ const {status, code, message} = classifyError(error);
+ writeV1Error(res, status, code, message, requestId);
+ }
+ }
+
+ const server = http.createServer((req, res) => {
+ void handleRequest(req, res);
+ });
+
+ async function handleRequest(
+ req: IncomingMessage,
+ res: ServerResponse,
+ ): Promise {
+ const url = req.url ?? "";
+ if (req.method === "GET" && url === "/health") {
+ res.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
+ res.end("ok");
+ return;
+ }
+
+ if (req.method === "POST" && url === "/v1/render") {
+ await handleV1(req, res);
+ return;
+ }
+
+ if (req.method === "POST" && url === "/purge") {
+ await handlePurge(req, res);
+ return;
+ }
+
+ res.writeHead(404, {"Content-Type": "text/plain; charset=utf-8"});
+ res.end("not found");
+ }
+
+ await listen(server, port, host);
+ const address = server.address();
+ if (address === null || typeof address === "string") {
+ server.close();
+ throw new Error("ssr sidecar failed to bind a TCP port");
+ }
+
+ console.log(
+ `mapsight ssr listening on http://${host}:${address.port} module=${modulePath}` +
+ (proxyUrl === undefined ? "" : ` proxy=${proxyUrl}`) +
+ (httpOrigins.length === 0
+ ? ""
+ : ` httpOrigins=${httpOrigins.join(",")}`),
+ );
+
+ return {server, host, port: address.port, modulePath};
+}
+
+function writeV1Error(
+ res: ServerResponse,
+ status: number,
+ code: SsrV1ErrorCode,
+ message: string,
+ requestId?: string,
+): void {
+ const payload: SsrV1Error = {v: 1, error: {code, message}};
+ console.error("ssr v1:", code, message);
+ res.writeHead(status, {
+ "Content-Type": "application/json; charset=utf-8",
+ ...(requestId ? {"X-Request-Id": requestId} : {}),
+ });
+ res.end(JSON.stringify(payload));
+}
+
+function classifyError(error: unknown): {
+ status: number;
+ code: SsrV1ErrorCode;
+ message: string;
+} {
+ const status =
+ error && typeof error === "object" && "statusCode" in error
+ ? Number((error as {statusCode?: number}).statusCode) || 500
+ : 500;
+ const tagged =
+ error && typeof error === "object" && "ssrCode" in error
+ ? String((error as {ssrCode?: string}).ssrCode)
+ : "";
+ if (status === 413 || tagged === "BODY_TOO_LARGE") {
+ return {status: 413, code: "BODY_TOO_LARGE", message: "body too large"};
+ }
+ if (status === 400 || tagged === "VALIDATION") {
+ const message =
+ error instanceof Error ? error.message : "invalid render request";
+ return {status: 400, code: "VALIDATION", message};
+ }
+ if (tagged === "RENDER_TIMEOUT") {
+ return {
+ status: 504,
+ code: "RENDER_TIMEOUT",
+ message: "render timed out",
+ };
+ }
+ return {status: 500, code: "RENDER_FAILED", message: "render failed"};
+}
+
+function headerValue(req: IncomingMessage, name: string): string | undefined {
+ const raw = req.headers[name];
+ if (typeof raw === "string" && raw !== "") {
+ return raw;
+ }
+ if (Array.isArray(raw) && raw[0]) {
+ return raw[0];
+ }
+ return undefined;
+}
+
+function readBody(req: IncomingMessage, limit: number): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ req.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > limit) {
+ reject(
+ Object.assign(new Error("body too large"), {
+ statusCode: 413,
+ ssrCode: "BODY_TOO_LARGE",
+ }),
+ );
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ req.on("error", reject);
+ });
+}
+
+function listen(
+ server: http.Server,
+ port: number,
+ host: string,
+): Promise {
+ return new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(port, host, () => {
+ server.removeListener("error", reject);
+ resolve();
+ });
+ });
+}
+
+function isExecutedAsCli(): boolean {
+ const entry = process.argv[1];
+ if (entry === undefined) {
+ return false;
+ }
+ return import.meta.url === pathToFileURL(path.resolve(entry)).href;
+}
+
+if (isExecutedAsCli()) {
+ await startSsrSidecar();
+}
diff --git a/packages/ssr-sidecar/src/v1.test.ts b/packages/ssr-sidecar/src/v1.test.ts
new file mode 100644
index 00000000..966d58fe
--- /dev/null
+++ b/packages/ssr-sidecar/src/v1.test.ts
@@ -0,0 +1,43 @@
+import {describe, expect, it} from "vitest";
+
+import {extractStateFromFragment, normalizeRenderResult} from "./v1.ts";
+
+describe("extractStateFromFragment", () => {
+ it("reads apostrophes that wrapEmbedFragment encodes as '", () => {
+ const state = {app: {title: "O'Reilly place"}};
+ const encoded = JSON.stringify(state)
+ .replace(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/"/g, """)
+ .replace(/`;
+
+ expect(extractStateFromFragment(html)).toEqual(state);
+ });
+
+ it("reads single-quoted entity-encoded JSON", () => {
+ const attribution =
+ 'OpenStreetMap contributors.';
+ const state = {map: {layers: {street: {attribution}}}};
+ const encoded = JSON.stringify(state)
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(/`;
+
+ expect(extractStateFromFragment(html)).toEqual(state);
+ });
+});
+
+describe("normalizeRenderResult", () => {
+ it("uses fragment state instead of the sidecar stub", () => {
+ const state = {map: {show: true}, app: {title: "City map"}};
+ const encoded = JSON.stringify(state).replace(/"/g, """);
+ const html = ``;
+
+ expect(normalizeRenderResult(html, {preset: "simpleMap"})).toEqual({
+ html,
+ state,
+ });
+ });
+});
diff --git a/packages/ssr-sidecar/src/v1.ts b/packages/ssr-sidecar/src/v1.ts
new file mode 100644
index 00000000..3645c9a8
--- /dev/null
+++ b/packages/ssr-sidecar/src/v1.ts
@@ -0,0 +1,88 @@
+/**
+ * SSR API v1 types (erasable). POST /v1/render → { v, html, state, pageMeta, meta }.
+ */
+import type {PlacePageMeta, SsrRequestBody} from "./render.ts";
+
+export type SsrV1ErrorCode =
+ "VALIDATION" | "BODY_TOO_LARGE" | "RENDER_FAILED" | "RENDER_TIMEOUT";
+
+export type SsrV1Success = {
+ v: 1;
+ html: string;
+ state: unknown;
+ pageMeta: PlacePageMeta | null;
+ meta: {
+ preset?: string;
+ renderMs: number;
+ requestId?: string;
+ assetVersion?: string;
+ };
+};
+
+export type SsrV1Error = {
+ v: 1;
+ error: {
+ code: SsrV1ErrorCode;
+ message: string;
+ };
+};
+
+export type SsrRenderResult =
+ | string
+ | {
+ html: string;
+ state: unknown;
+ };
+
+export function normalizeRenderResult(
+ result: SsrRenderResult,
+ body: SsrRequestBody,
+): {html: string; state: unknown} {
+ if (result && typeof result === "object" && "html" in result) {
+ const html = result.html;
+ if (typeof html !== "string" || html === "") {
+ throw Object.assign(new Error("render() html missing"), {
+ statusCode: 500,
+ ssrCode: "RENDER_FAILED" as const,
+ });
+ }
+ return {html, state: result.state};
+ }
+ if (typeof result !== "string" || result === "") {
+ throw Object.assign(new Error("render() returned empty"), {
+ statusCode: 500,
+ ssrCode: "RENDER_FAILED" as const,
+ });
+ }
+ return {
+ html: result,
+ state: extractStateFromFragment(result) ?? {
+ app: {ssr: "fragment", preset: body.preset ?? null},
+ },
+ };
+}
+
+export function extractStateFromFragment(html: string): unknown {
+ const match = html.match(/data-dehydrated-state=(?:"([^"]*)"|'([^']*)')/);
+ if (!match) {
+ return null;
+ }
+ const raw = match[1] ?? match[2];
+ if (raw === undefined) {
+ return null;
+ }
+ try {
+ return JSON.parse(decodeHtmlAttr(raw));
+ } catch {
+ return null;
+ }
+}
+
+function decodeHtmlAttr(value: string): string {
+ return value
+ .replace(/"/g, '"')
+ .replace(/'|'/g, "'")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/&/g, "&");
+}
diff --git a/packages/ssr-sidecar/tsconfig.build.json b/packages/ssr-sidecar/tsconfig.build.json
new file mode 100644
index 00000000..014e12c5
--- /dev/null
+++ b/packages/ssr-sidecar/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["**/*.test.ts"],
+ "include": ["src/**/*"],
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ }
+}
diff --git a/packages/ssr-sidecar/tsconfig.json b/packages/ssr-sidecar/tsconfig.json
new file mode 100644
index 00000000..ec6598c7
--- /dev/null
+++ b/packages/ssr-sidecar/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../configs/tsconfig-base.json",
+ "include": ["src/**/*"],
+ "compilerOptions": {
+ "types": ["node"]
+ }
+}
diff --git a/packages/ssr-sidecar/vitest.config.ts b/packages/ssr-sidecar/vitest.config.ts
new file mode 100644
index 00000000..e825b86f
--- /dev/null
+++ b/packages/ssr-sidecar/vitest.config.ts
@@ -0,0 +1,8 @@
+import {defineConfig} from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5351dca6..48327a3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -609,6 +609,18 @@ importers:
specifier: ^9.0.0
version: 9.0.0
+ packages/ssr-sidecar:
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 24.13.3
+ typescript:
+ specifier: 'catalog:'
+ version: 6.0.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.11(@types/node@24.13.3)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(sass@1.103.1)(terser@5.51.2)(yaml@2.9.0))
+
packages/traffic-style:
dependencies:
'@fortawesome/free-solid-svg-icons':