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
5 changes: 5 additions & 0 deletions .changeset/ssr-sidecar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mapsight/ssr-sidecar": minor
---

Add a Node 24 HTML embed sidecar with `/health`, `/v1/render`, and `/purge`.
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
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
Expand Down Expand Up @@ -476,6 +549,7 @@ jobs:
test,
starters-e2e,
starters-copy-out,
ssr-sidecar-image,
lint-dependencies,
no-private-leak,
zizmor,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ Mapsight is a framework for building web applications with OpenLayers and React.
<td><nobr>📈 <strong><code>count-aggregator-ui</code></strong></nobr><br><nobr><a href="https://www.npmjs.com/package/@mapsight/count-aggregator-ui"><img alt="NPM Version" src="https://img.shields.io/npm/v/%40mapsight%2Fcount-aggregator-ui?style=flat"></a> | <a href="packages/count-aggregator-ui/README.md">README</a></nobr></td>
<td><strong>Count aggregator UI (React)</strong><br>Embeddable wizard, time-series charts, and export links. CMS app-shell embed via <a href="packages/vite-count-aggregator-embed/README.md"><code>vite-count-aggregator-embed</code></a>.</td>
</tr>
<tr>
<td><nobr>🛰️ <strong><code>ssr-sidecar</code></strong></nobr><br><nobr><a href="https://www.npmjs.com/package/@mapsight/ssr-sidecar"><img alt="NPM Version" src="https://img.shields.io/npm/v/%40mapsight%2Fssr-sidecar?style=flat"></a> | <a href="packages/ssr-sidecar/README.md">README</a></nobr></td>
<td><strong>HTML embed SSR sidecar (Node 24)</strong><br>Generic <code>GET /health</code>, <code>POST /v1/render</code>, and <code>POST /purge</code> process. Hosts pull <code>ghcr.io/open-mapsight/ssr-sidecar</code> and bind-mount their <code>render.js</code>. npm <code>beta</code> is for image builds and tests.</td>
</tr>
</tbody>
</table>

Expand Down
8 changes: 7 additions & 1 deletion docs/integration/SSR_HYDRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
{
Expand Down
3 changes: 3 additions & 0 deletions packages/ssr-sidecar/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*
!package.json
!dist
1 change: 1 addition & 0 deletions packages/ssr-sidecar/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
20 changes: 20 additions & 0 deletions packages/ssr-sidecar/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
85 changes: 85 additions & 0 deletions packages/ssr-sidecar/README.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 7 additions & 0 deletions packages/ssr-sidecar/eslint.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {defineConfig} from "eslint/config";

import baseConfig, {
testFilesEslintConfig,
} from "../../configs/eslint-config-base.mts";

export default defineConfig([baseConfig, testFilesEslintConfig]);
42 changes: 42 additions & 0 deletions packages/ssr-sidecar/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 24 additions & 0 deletions packages/ssr-sidecar/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading