Skip to content
Open
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
61 changes: 61 additions & 0 deletions ACCEPTANCE_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,64 @@ docker compose -f deploy/compose.website.yml down --remove-orphans
The website includes `/<chart>/api/` pages that POST chart props to a render API.

This repo historically used `https://nivo-api.herokuapp.com/nivo` for that, but if you want those pages to work without relying on an external service, the render API needs to be self-hosted and proxied under the same origin (e.g. at `/nivo/`).

Bring up the website+api stack:

```bash
docker compose -f deploy/compose.website-api.yml up -d --build
```

Render flow (POST -> url -> GET svg):

```bash
cat > /tmp/nivo_bar_payload.json <<'JSON'
{
"width": 1200,
"height": 500,
"margin": { "top": 40, "right": 50, "bottom": 40, "left": 50 },
"data": "[\n {\n \"country\": \"AD\",\n \"hot dog\": 47,\n \"burger\": 27,\n \"sandwich\": 113,\n \"kebab\": 75,\n \"fries\": 59,\n \"donut\": 168\n }\n]",
"keys": ["hot dog", "burger", "sandwich", "kebab", "fries", "donut"],
"indexBy": "country",
"colors": { "scheme": "nivo" },
"colorBy": "id",
"borderRadius": 0,
"borderWidth": 0,
"borderColor": { "from": "color", "modifiers": [["darker", 1.6]] },
"padding": 0.2,
"innerPadding": 0,
"groupMode": "stacked",
"layout": "vertical",
"valueScale": { "type": "linear", "nice": true, "round": false },
"indexScale": { "type": "band", "round": false },
"axisTop": null,
"axisRight": null,
"axisBottom": { "legend": "country", "legendOffset": 36 },
"axisLeft": { "legend": "food", "legendOffset": -40 },
"enableGridX": false,
"enableGridY": true,
"enableLabel": true,
"enableTotals": false,
"totalsOffset": 10,
"labelSkipWidth": 12,
"labelSkipHeight": 12,
"labelTextColor": { "from": "color", "modifiers": [["darker", 1.6]] },
"labelPosition": "middle",
"labelOffset": 0
}
JSON

curl -sS -o /tmp/nivo_post.json -X POST http://localhost:8080/nivo/charts/bar \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data-binary @/tmp/nivo_bar_payload.json

cat /tmp/nivo_post.json

url="$(node -e 'const fs=require(\"fs\"); console.log(JSON.parse(fs.readFileSync(\"/tmp/nivo_post.json\",\"utf8\")).url)')"
curl -sS -I "$url" | tr -d '\r'
```

Bring it down:

```bash
docker compose -f deploy/compose.website-api.yml down --remove-orphans
```
12 changes: 12 additions & 0 deletions DEPLOY_DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This repo includes Dockerfiles and docker-compose examples to build and serve:

- the Nivo website (Gatsby static build)
- Storybook at `/storybook/`
- optionally, a self-hosted render API proxied under `/nivo/` for the `/<chart>/api/` pages

## Website + Storybook

Expand All @@ -16,6 +17,17 @@ Open:
- Website: `http://localhost:8080/`
- Storybook: `http://localhost:8080/storybook/`

## Website + Storybook + Render API

```bash
docker compose -f deploy/compose.website-api.yml up -d --build
```

This stack reverse-proxies the API under the same origin:

- `POST http://localhost:8080/nivo/charts/<type>`
- `GET http://localhost:8080/nivo/r/<id>`

## Notes

- Gatsby inlines `GATSBY_*` variables at build time.
Expand Down
56 changes: 56 additions & 0 deletions Dockerfile.nivo-api
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1.7
#
# Builds the HTTP rendering API in ./api (monorepo workspace) and serves it on :3030.
#
# Build:
# docker build -f Dockerfile.nivo-api -t nivo-api:dev .
#
# Run:
# docker run --rm -p 3030:3030 nivo-api:dev
#
FROM node:22-bookworm-slim AS builder

WORKDIR /app

# Avoid downloading large E2E/browser binaries during image build.
ENV CYPRESS_INSTALL_BINARY=0
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PUPPETEER_SKIP_DOWNLOAD=1
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1

ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.11.0 --activate

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY tsconfig*.json babel.config.js lerna.json eslint.config.mjs ./
COPY Makefile ./Makefile
COPY conf ./conf
COPY scripts ./scripts
COPY packages ./packages
COPY api ./api

RUN pnpm install --frozen-lockfile

# The API depends on workspace packages, so build them first.
RUN pnpm run pkgs:types:check && pnpm run pkgs:build

# Compile api/app.ts -> api/app.js
RUN pnpm -C api build


FROM node:22-bookworm-slim AS runtime

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3030

# Copy only what the compiled app needs at runtime.
COPY --from=builder /app/api /app/api
COPY --from=builder /app/packages /app/packages
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json

EXPOSE 3030
CMD ["node", "api/app.js"]

22 changes: 22 additions & 0 deletions deploy/compose.website-api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
services:
api:
build:
context: ..
dockerfile: Dockerfile.nivo-api
restart: unless-stopped
expose:
- "3030"

website:
build:
context: ..
dockerfile: Dockerfile.nivo-website
args:
# Include /nivo reverse-proxy to the api service.
NGINX_CONF: deploy/nginx.website-api.conf
GATSBY_NIVO_API_URL: /nivo
restart: unless-stopped
depends_on:
- api
ports:
- "8080:80"
80 changes: 80 additions & 0 deletions deploy/nginx.website-api.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

# The API clients can post sizable JSON payloads.
client_max_body_size 10m;

# Compression helps a lot for Gatsby's JS/CSS bundles.
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
application/javascript
application/json
image/svg+xml;

# These should update quickly when you redeploy.
location = /sw.js {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}

location = /manifest.webmanifest {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}

# Proxy the HTTP rendering API through this same origin to avoid CORS issues.
# Website pages call `${GATSBY_NIVO_API_URL}/charts/<type>` with a base like `/nivo`.
location /nivo/ {
proxy_http_version 1.1;
# Preserve the original Host header, including any non-standard port.
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;

# API is mounted at /nivo, and nginx will forward the original URI.
proxy_pass http://api:3030;
}

# Self-hosted Storybook (built from ./storybook) served at /storybook/.
# Storybook assets will be matched by the global hashed-asset cache rule below.
location /storybook/ {
add_header Cache-Control "public, max-age=1800" always;
try_files $uri $uri/ /storybook/index.html =404;
}

# Serve pre-built Gatsby pages as-is.
location / {
# Cache HTML for 30 minutes to reduce origin load.
# Tradeoff: users may see stale HTML briefly after a deploy.
add_header Cache-Control "public, max-age=1800" always;
try_files $uri $uri/ =404;
}

# Root-level and /static assets are content-hashed in their filenames: cache aggressively.
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp|avif|woff2?|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}

# Gatsby page-data isn't content-hashed; cache briefly to reduce chatter, but allow quick updates.
location ^~ /page-data/ {
add_header Cache-Control "public, max-age=300" always;
try_files $uri =404;
}

# Basic hardening headers (safe for static content).
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
3 changes: 2 additions & 1 deletion packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ forOwn(chartsMapping, ({ schema }, type: ChartType) => {
// @ts-expect-error missing type for req
const props = req.payload
const id = uuid.v4()
const url = `${req.protocol}://${req.get('host')}/r/${id}`
// When mounted behind a reverse proxy prefix (e.g. /nivo), include it in the URL.
const url = `${req.protocol}://${req.get('host')}${req.baseUrl}/r/${id}`

storage.set(id, {
type,
Expand Down
29 changes: 16 additions & 13 deletions packages/express/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,28 @@ export const validate = (

return (req: Request, res: Response, next: NextFunction) => {
let data = req.body
if (omit) {
// @ts-expect-error omitProps is not PropertyName[] for simplicity
data = omit(data, omitProps)
if (omitProps) {
data = omit(data, omitProps as any)
}

try {
// @ts-expect-error no type for req.payload
req.payload = schema.validate(data, {
abortEarly: true,
convert: true,
})
next()
} catch (err: any) {
const { value, error } = schema.validate(data, {
abortEarly: true,
convert: true,
// The website API pages send extra UI-only keys; accept and strip anything unknown.
allowUnknown: true,
stripUnknown: { objects: true },
})

if (error) {
return res.status(400).json({
// @ts-expect-error no type for err
errors: err.details.map(({ message, path }) => {
errors: error.details.map(({ message, path }) => {
return `${message}${path ? ` (${path})` : ''}`
}),
})
}

// @ts-expect-error no type for req.payload
req.payload = value
next()
}
}
2 changes: 2 additions & 0 deletions packages/static/src/mappings/bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const barMapping = {
valueScale: Joi.object()
.keys({
type: Joi.any().valid('linear'),
nice: Joi.boolean(),
round: Joi.boolean(),
reverse: Joi.boolean(),
})
.allow(null),
Expand Down
Loading