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
NPM Version | README Count aggregator UI (React)
Embeddable wizard, time-series charts, and export links. CMS app-shell embed via vite-count-aggregator-embed. + +🛰️ ssr-sidecar
NPM Version | 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':