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
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Build context exclusion list — keep the image lean and avoid bundling
# anything the runtime doesn't need.

.git
.github
.vercel
.idea
.vscode
node_modules
tests
docs
examples
scripts
review.md
*.md
!README.md
Dockerfile
.dockerignore
.gitignore
.env
.env.local
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,56 @@ jobs:

- name: Run tests
run: npm test

# Validates the optional self-host path: image builds, container starts,
# HEALTHCHECK passes, /api/health responds, /api/divider returns SVG.
# Independent of the Node matrix above because the runtime inside the
# image is pinned by the Dockerfile (node:22-slim).
#
# No untrusted GitHub event data flows into any `run:` step — every
# shell variable below is initialized inside the script.
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
tags: profilekit:ci
load: true

- name: Run container and verify /api/health + /api/divider
run: |
set -euo pipefail
docker run -d --name pkit -p 3000:3000 profilekit:ci

# Wait up to ~30s for Docker's HEALTHCHECK (defined in the
# Dockerfile) to flip to "healthy".
for i in $(seq 1 30); do
status=$(docker inspect --format='{{.State.Health.Status}}' pkit 2>/dev/null || echo "unknown")
echo "attempt $i: health=$status"
if [ "$status" = "healthy" ]; then break; fi
sleep 1
done

# Independent verification from the host — don't trust HEALTHCHECK
# alone in case the probe itself is misconfigured.
body=$(curl -sf http://localhost:3000/api/health)
echo "$body"
echo "$body" | grep -q '"ok": true'

# Real card endpoint — proves the route adapter is wired, not
# just /api/health.
ctype=$(curl -s -o /dev/null -w '%{content_type}' \
'http://localhost:3000/api/divider?style=line&width=400')
echo "divider ctype: $ctype"
echo "$ctype" | grep -q 'image/svg+xml'

docker logs pkit
docker rm -f pkit
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
#
# ProfileKit container image — optional self-hosted path.
#
# ProfileKit's primary deployment target is Vercel (api/[endpoint].js).
# This image is for anyone who wants to run ProfileKit on their own
# infrastructure instead. The Vercel path is unaffected.
#
# Zero npm dependencies (see package.json — no "dependencies" /
# "devDependencies" blocks), so there is no `npm install` step. The image
# is just a Node 22 runtime + the source. Builds in seconds.

FROM node:22-slim

# Pre-existing unprivileged user shipped by the node image.
WORKDIR /app

# Copy only what the runtime needs. Tests / docs / build helpers stay out
# of the image — see .dockerignore for the exclusion list.
COPY package.json server.js ./
COPY src/ ./src/
COPY api/ ./api/
COPY public/ ./public/

ENV NODE_ENV=production \
PORT=3000

EXPOSE 3000

# Liveness / readiness probe. Node's built-in fetch (>=22) keeps the image
# dependency-free — no curl / wget install needed.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

USER node

CMD ["node", "server.js"]
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ A community gallery for sharing single-card presets and adopting others' designs

## About this project

**Currently implemented.** 28 SVG card endpoints (`/api/*`), 17 built-in themes plus gist-hosted custom palettes via `theme_url=`, five bundled variable fonts, `/api/stack` composition with namespaced child IDs, a live playground at [profilekit.vercel.app](https://profilekit.vercel.app), and an MCP wrapper at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp). Zero runtime dependencies, 30-minute CDN cache, deployed on Vercel.
**Currently implemented.** 28 SVG card endpoints (`/api/*`), 17 built-in themes plus gist-hosted custom palettes via `theme_url=`, five bundled variable fonts, `/api/stack` composition with namespaced child IDs, a live playground at [profilekit.vercel.app](https://profilekit.vercel.app), and an MCP wrapper at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp). Two deployment paths: **Vercel functions** (primary, `api/[endpoint].js`) and an **optional self-hosted Docker** image (`Dockerfile` + `server.js`) running the same handlers. Zero runtime dependencies, 30-minute CDN cache on the hosted instance.

**Planned.** A single-card preset gallery at `/gallery` — adopt someone else's design URL as a starting point, then tweak parameters in the editor. Cross-agent preset compile (one preset → Claude Code, Cursor, Codex CLI configs).

**Design intent.** *No ranking, composable presentation.* Each card is a parameter-only URL — every visual property exposed as a query string so the same endpoint renders in a GitHub README, a dev.to bio, a Hashnode header, or a slide cover with no template forking. The gallery is for *adoption*, not voting: you start from someone else's preset and edit it; we do not show which preset is "most popular." Pure SVG with CSS / SMIL keeps animations alive inside GitHub's image proxy and removes the JavaScript attack surface.
**Design intent.** *No ranking, composable presentation.* Each card is a parameter-only URL — every visual property exposed as a query string so the same endpoint renders in a GitHub README, a dev.to bio, a Hashnode header, or a slide cover with no template forking. The gallery is for *adoption*, not voting: you start from someone else's preset and edit it; we do not show which preset is "most popular." Pure SVG with CSS / SMIL keeps animations alive inside GitHub's image proxy and removes the JavaScript attack surface. The self-hosted Docker path reuses the exact same handler files as the Vercel path via a thin `server.js` adapter — there is no "Docker-only" or "Vercel-only" code surface.

**Non-goals.** No ratings. No rankings. No leaderboards. No remix lineage / fork trees. No raster fallback for upload-only platforms (LinkedIn, Discord, X, Medium) — export to PNG yourself if you need one; we will not pretend the SVG works there. No tracking pixels, no per-view analytics.
**Non-goals.** No ratings. No rankings. No leaderboards. No remix lineage / fork trees. No raster fallback for upload-only platforms (LinkedIn, Discord, X, Medium) — export to PNG yourself if you need one; we will not pretend the SVG works there. No tracking pixels, no per-view analytics. **The self-hosted Docker mode does not replace the Vercel path** — it is purely additive for users who want to run ProfileKit on their own infrastructure. The hosted instance continues to be the default and is unaffected by any change to `server.js` or the Dockerfile.

**Redacted.** None.

Expand Down Expand Up @@ -663,13 +663,38 @@ A gallery of dimension presets for each context lives in [`examples/README.md`](

Copy any URL from the gallery, change the `name` / `subtitle` / `theme`, and drop it into the matching context.

## Self-Hosting
## Self-hosting

Two supported paths. Pick whichever fits your infrastructure — both run the same handler code from `src/endpoints/`.

### Path A — Vercel (default)

1. Fork this repo
2. Deploy to [Vercel](https://vercel.com/new)
3. Add environment variable: `GITHUB_TOKEN` — [create one here](https://github.com/settings/tokens) (no scopes needed for public data)
4. Done. Your endpoints are at `https://your-project.vercel.app/api/*`

### Path B — Docker (any container host)

The repo ships a `Dockerfile` and a `server.js` adapter that turns the same handler files into a plain Node 22 HTTP server. No package install step (zero runtime deps), so the image builds in seconds.

```bash
# Build once
docker build -t profilekit:local .

# Run a single replica
docker run --rm -p 3000:3000 \
-e GITHUB_TOKEN=ghp_... \
profilekit:local
# → http://localhost:3000/api/divider?style=wave
```

For a real deployment (multiple replicas behind a load balancer), see [`examples/self-host/`](examples/self-host/) — `docker compose up --build --scale web=3` brings up three app replicas behind nginx round-robin. Each response carries an `X-ProfileKit-Instance` header so you can verify the LB is rotating across replicas.

**Known limitation — token pool is per-process.** `src/common/github-token.js` keeps GitHub rate-limit state in process memory. With N replicas, each maintains its own pool independently, so a 429 on replica A doesn't tell replica B to skip the same token. For low-volume self-hosts it's invisible; for high-volume, either give each replica its own GitHub token (via `GITHUB_TOKENS=` comma list or `GITHUB_TOKEN_1..N` numbered form, see `.env.example`) or front the deployment with a shared rate-limit store (Redis) — out of scope for the bundled example.

The Docker path is purely additive — the Vercel path keeps working unchanged.

## Roadmap

- **Now** — 28 card endpoints, 17 themes, playground at [profilekit.vercel.app](https://profilekit.vercel.app), MCP server at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp), curated picks in the Templates tab.
Expand Down
71 changes: 71 additions & 0 deletions examples/self-host/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Self-hosting ProfileKit (Docker)

This is the optional self-hosted path. The primary deployment target is
still Vercel (`api/[endpoint].js`) — nothing here replaces that.

## Quick start

From this directory:

```bash
docker compose up --build --scale web=3
```

ProfileKit is now reachable at <http://localhost:8080>, with three app
replicas behind one nginx load balancer.

To prove the load balancer is round-robining:

```bash
for i in 1 2 3 4 5; do
curl -s -D - "http://localhost:8080/api/divider?style=wave" -o /dev/null \
| grep -i x-profilekit-instance
done
```

The `X-ProfileKit-Instance` header rotates across the three replica
container IDs.

## What's running

| Service | Role |
|---|---|
| `web` × 3 | App replicas. Each runs `node server.js` from the repo-root Dockerfile. 128 MB memory + 0.5 CPU limit each — mirrors the Vercel function budget. |
| `lb` | nginx round-robin load balancer. Port 8080 (host) → 80 (LB) → 3000 (each replica). |

## With GitHub-backed cards

`/api/stats`, `/api/languages`, `/api/pin`, `/api/reviews` need a GitHub
token. Set one before bringing the stack up:

```bash
export GITHUB_TOKEN=ghp_...
docker compose up --build --scale web=3
```

The other 24 cards (hero, divider, wave, terminal, etc.) work without a
token.

## Known limitation — token pool is per-process

`src/common/github-token.js` stores rate-limit state (which token is
cooled-down for how long) in process memory. With N replicas, each
replica maintains its own pool state. A token that gets a 429 on
replica A will keep being tried on replica B and C until each replica
independently observes its own 429.

For low-volume self-hosts that's invisible. For high-volume self-hosts
(many concurrent README embeds), point each replica at a separate
GitHub token via the `GITHUB_TOKENS=` or `GITHUB_TOKEN_1..N` form, or
front the deployment with a shared rate-limit store (Redis) — out of
scope for this example.

## Scaling further

```bash
docker compose up --build --scale web=10
```

nginx picks up the new replicas automatically because the upstream uses
Docker's embedded DNS with per-request re-resolution (see
`nginx/nginx.conf`).
62 changes: 62 additions & 0 deletions examples/self-host/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# docker-compose.yml — ProfileKit self-host example.
#
# Three app replicas behind one nginx load balancer. Each replica runs the
# same Dockerfile from the repo root; nginx round-robins across them and
# the X-ProfileKit-Instance response header reveals which replica answered.
#
# Run from this directory:
# docker compose up --build --scale web=3
# curl -s -D - http://localhost:8080/api/divider?style=wave | grep -i instance
# (repeat — X-ProfileKit-Instance rotates across the three replicas)
#
# Resource limits mirror the Vercel function budget (128 MB / 10 s) so
# behavior under load matches production.

services:
web:
build:
# Repo root — two directories up from this compose file.
context: ../..
dockerfile: Dockerfile
image: profilekit:local
expose:
- "3000" # internal only — never published; the LB is the entry point.
environment:
# GitHub-backed cards (stats/languages/pin/reviews) need a token; pure
# cards (hero/divider/wave/terminal/...) work without one. Optional.
GITHUB_TOKEN: ${GITHUB_TOKEN:-}
deploy:
replicas: 3
resources:
limits:
cpus: "0.50" # half a core per replica
memory: "128M" # matches vercel.json's 128 MB function budget
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
networks:
- profilekit-net
restart: unless-stopped

lb:
image: nginx:1.27-alpine
depends_on:
- web
ports:
- "8080:80" # single public entry point.
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- profilekit-net
restart: unless-stopped

networks:
profilekit-net:
driver: bridge
49 changes: 49 additions & 0 deletions examples/self-host/nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# nginx.conf — load balancer in front of the ProfileKit replicas.
#
# Docker Compose runs the app behind this on port 8080 (host) → 80 (this
# container) → 3000 (each web replica). nginx re-resolves the `web`
# service name on every request so it round-robins across whatever
# replicas are currently up, including after `docker compose scale`.

worker_processes auto;

events {
worker_connections 1024;
}

http {
access_log /dev/stdout;
error_log /dev/stderr warn;

# Docker's embedded DNS server. Resolving the service name `web`
# through it returns the A records of ALL replicas; combined with the
# variable form of proxy_pass below, nginx re-resolves and round-
# robins on every request rather than caching a single backend at
# boot.
resolver 127.0.0.11 valid=5s ipv6=off;

server {
listen 80;

# Surface that the request passed through the LB tier.
add_header X-LB nginx always;

location / {
# Variable form forces per-request DNS resolution → round-robin
# across live `web` replicas.
set $backend "http://web:3000";
proxy_pass $backend;

proxy_set_header Host $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_connect_timeout 3s;
# Slightly longer than the app's 10s maxDuration budget so
# nginx surfaces upstream timeouts cleanly rather than
# cutting the request off prematurely.
proxy_read_timeout 11s;
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"test": "node --test tests/*.test.js",
"check": "find api src scripts -name '*.js' -exec node --check {} +"
"check": "node --check server.js && find api src scripts -name '*.js' -exec node --check {} +"
},
"license": "MIT"
}
Loading
Loading