A minimal, standalone likes counter API. Drop-in anonymous likes for any static site.
GET /likes/:slug— returns counts grouped by type (without?type=) or count for a specific type (with?type=)GET /likes/types— lists all types with aggregate slug and like countsGET /likes/types/:type— returns paginated slugs for a typePOST /likes/:slug— toggles like/unlike (dedup per visitor), accepts optionaltypein bodyPOST /likes/batch— returns counts for multiple slugs grouped by type- CORS whitelist — only your domain can call the API
- Rate limiting — per-IP (10 req/min) + global safeguard (500 GET/min, 50 POST/min)
- Slug validation — prevents path traversal and abuse
- Anonymous — no login, no user data stored (privacy policy)
- Cloudflare native — runs on Cloudflare Workers + D1, no external dependencies
- Client script included — drop-in
<simple-likes>custom element indist/simplelikes.js
npm install
cp .env.example .env
npm run dev:setup # create local D1 tables (required once)
npm run devThe dev server starts at http://localhost:8787. No Cloudflare account needed — a local SQLite database is created and the schema is applied by dev:setup.
curl http://localhost:8787/likes/hello-world
# {"slug":"hello-world","types":{}}
curl -X POST http://localhost:8787/likes/hello-world \
-H "X-Visitor-Id: visitor-1"
# {"slug":"hello-world","count":1,"liked":true,"type":"untyped"}
curl -X POST http://localhost:8787/likes/hello-world \
-H "X-Visitor-Id: visitor-1" \
-d '{"type":"artigos"}'
# {"slug":"hello-world","count":1,"liked":true,"type":"artigos"}
curl -X POST http://localhost:8787/likes/hello-world \
-H "X-Visitor-Id: visitor-1"
# {"slug":"hello-world","count":0,"liked":false,"type":"untyped"}Returns counts grouped by type. When ?type= is omitted, all types for the slug are returned. When a specific ?type= is provided, only that type's count is returned.
# Without type — all types grouped
curl http://localhost:8787/likes/hello-world
# {"slug":"hello-world","types":{"artigos":42,"notas":7}}
# With type — single type
curl "http://localhost:8787/likes/hello-world?type=artigos"
# {"slug":"hello-world","count":42,"type":"artigos"}?type=untyped and ?type= (empty) return 400 — use the no-parameter variant to access untyped data.
Toggles the like for a visitor. Requires X-Visitor-Id header (a hash of User-Agent + IP, generated client-side). If the visitor hasn't liked this slug yet, it increments the count. If they already liked it, it decrements and removes their visitor record.
Accepts an optional type field in the JSON body (defaults to "untyped"). The type is a content category like "artigos" or "notas" — "untyped" and "" are reserved.
curl -X POST http://localhost:8787/likes/hello-world \
-H "X-Visitor-Id: visitor-1" \
-H "Content-Type: application/json" \
-d '{"type":"artigos"}'
# {"slug":"hello-world","count":43,"liked":true,"type":"artigos"}
# Without type — defaults to "untyped"
curl -X POST http://localhost:8787/likes/hello-world \
-H "X-Visitor-Id: visitor-1" \
-H "Content-Type: application/json" \
-d '{}'
# {"slug":"hello-world","count":1,"liked":true,"type":"untyped"}Returns counts for multiple slugs grouped by type. A batch read operation — no likes are created, only fetched.
Use cases:
- Home page — show like counts for the latest 5 posts, 3 notes, and 5 curated links in one call
- Archive/category pages — load counts for all items in a listing without N individual requests
- Sidebar widgets — "most liked" or "trending" widgets that need counts for multiple slugs
Accepts an optional type field to filter results to a single type.
# All types
curl -X POST https://likes.yourdomain.com/likes/batch \
-H "Content-Type: application/json" \
-d '{"slugs":["hello-world","my-post","note-1"]}'
# {"types":{"artigos":{"hello-world":42,"my-post":7},"notas":{"note-1":3}}}
# Filter by type
curl -X POST https://likes.yourdomain.com/likes/batch \
-H "Content-Type: application/json" \
-d '{"slugs":["hello-world","my-post"],"type":"artigos"}'
# {"types":{"artigos":{"hello-world":42,"my-post":7}}}Design note: This endpoint uses POST instead of GET because it carries a JSON body with the list of slugs. GET with a request body has no defined semantics per RFC 9110 §9.3.1 and may be rejected by proxies/CDNs. The POST-for-read pattern is the industry standard for batch reads — the same approach used by Elasticsearch (POST /_search), GraphQL (POST /graphql), and OData (POST /$batch). Despite using POST, this is a read operation for rate limiting purposes.
Returns all types with aggregate slug and like counts.
curl http://localhost:8787/likes/types
# {"types":[{"type":"artigos","slug_count":5,"total_likes":42},{"type":"notas","slug_count":3,"total_likes":15}]}Returns paginated slugs for a specific type. /likes/types/untyped returns 400.
curl "http://localhost:8787/likes/types/artigos?limit=10&offset=0"
# {"type":"artigos","slugs":[{"slug":"hello-world","count":42}],"total":1}| Status | Reason |
|---|---|
| 400 | Invalid slug, missing X-Visitor-Id, invalid type, or reserved type "untyped" |
| 405 | Method not allowed |
| 429 | Rate limit exceeded (includes Retry-After header) |
Read responses are cached at the edge using the Cloudflare Cache API to reduce D1 reads and improve latency.
| Endpoint | Cache TTL | Cache key |
|---|---|---|
GET /likes/:slug |
60s | Request URL |
GET /likes/types |
60s | Request URL |
GET /likes/types/:type |
60s | Request URL |
POST /likes/batch |
30s | SHA-256 hash of sorted slugs + type |
- Only 200 OK responses are cached — errors and 4xx pass through
- On cache hit, the response is returned instantly without querying D1
- On cache miss, the response is stored and served with
Cache-Control: public, max-age=<TTL> - The cache is per-datacenter — each Cloudflare edge location maintains its own copy; the first request after a write from a new region may still see stale data for up to the TTL
- New endpoints only need to call
cache.wrap(request, ttl, fetchFn)— seesrc/utils/cache.ts - CORS headers are applied after cache retrieval, not baked into cached responses — this guarantees every request gets
Access-Control-Allow-Originmatching its ownOriginheader, regardless of cache state
simplelikes is anonymous by design: no IPs, User-Agents, cookies, or tracking data are stored. The only persisted data is the slug identifier and an opaque visitor hash.
See PRIVACY.md for the full privacy policy, data collection table, and a suggested disclosure snippet for site owners.
See SECURITY.md for the vulnerability disclosure policy.
simplelikes is designed with defense in depth:
| Layer | Mechanism |
|---|---|
| CORS | Only origins in ALLOWED_ORIGINS env var can call from a browser |
| Per-IP rate limit | 10 requests per minute per IP — primary defense against individual abuse |
| Global rate limit | 500 GET/min, 50 POST/min — secondary layer protecting D1 free tier quota from coordinated attacks |
| Slug validation | Regex-restricted: [a-z0-9/-], max 200 chars |
| Type validation | Alphanumeric + hyphens, max 50 chars, rejects reserved "untyped" |
| Visitor dedup | likes_visitors table prevents double-counting per slug + visitor |
| Security headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY |
npm run setupThis auto-detects your D1 databases, generates wrangler.toml with real IDs, and applies the schema to remote databases.
-
Install Wrangler CLI:
npm install -g wrangler
-
Log in to your Cloudflare account:
wrangler login
-
Create the D1 databases:
wrangler d1 create simplelikes wrangler d1 create simplelikes-staging
-
Run setup:
npm run setup
-
Deploy:
npm run deploy
simplelikes can also run as a standalone Node.js server using better-sqlite3:
npm install
npm run serveThe server starts at http://localhost:3000 with an auto-created SQLite database at ./data/likes.db.
better-sqlite3is an optional dependency — Cloudflare Workers deploys do not install it. Only install when self-hosting.
Unlike npm run dev (which loads .env automatically via Wrangler), npm run serve reads environment variables directly from the process. Set them in your shell or process manager:
PORT=3000 DB_PATH=./data/likes.db ALLOWED_ORIGINS=https://example.com npm run serve| Env var | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP server port |
DB_PATH |
./data/likes.db |
SQLite database file path |
ALLOWED_ORIGINS |
— | Comma-separated list of allowed CORS origins |
These can also be added to .env for local testing (see .env.example).
For production VPS deployments, use a process manager like pm2:
npm install -g pm2
pm2 start npm --name simplelikes -- run serveSet environment variables in the process manager config (ecosystem.config.js for pm2) or via your systemd service file.
| Env var | Default | Description |
|---|---|---|
ALLOWED_ORIGINS |
http://localhost:8787 |
Comma-separated list of allowed CORS origins |
INTEGRATION_TEST_SECRET |
— | Secret for X-Integration-Test header to bypass rate limits in integration tests |
ALLOWED_ORIGINSdefaults to the local Wrangler dev server. For deployed workers, override viawrangler.toml [vars]or Cloudflare dashboard. In CI, set theALLOWED_ORIGINSGitHub Secret for the CORS integration test to pass.
Both wrangler.toml and .env are gitignored. Start from the examples:
cp .env.example .env
cp wrangler.toml.example wrangler.tomlThe wrangler.toml uses __PLACEHOLDER__ variables:
__STAGING_DATABASE_ID__/__PRODUCTION_DATABASE_ID__— replaced byscripts/setup.sh__INTEGRATION_TEST_SECRET__/__ALLOWED_ORIGINS__— replaced by CI (deploy.yml) or byscripts/setup.sh(reads from.envif available)
For local dev, no Cloudflare account is required — a local SQLite database is used and __ALLOWED_ORIGINS__ defaults to http://localhost:8787.
For Cloudflare deployment, run npm run setup to auto-detect databases, generate wrangler.toml with real IDs, and apply the schema. If you have .env configured, setup.sh also populates __INTEGRATION_TEST_SECRET__ and __ALLOWED_ORIGINS__ from it.
The web component source is in src/client/ (TypeScript). Build the bundle:
npm run build:clientThis produces dist/simplelikes.js and examples/simplelikes.js — a single-file drop-in script.
<script src="dist/simplelikes.js"></script>
<script>
window.__simpleLikesConfig = {
apiUrl: "https://likes.yourdomain.com",
};
</script>
<simple-likes slug="hello-world"></simple-likes>
<simple-likes slug="my-post"></simple-likes>
<simple-likes slug="artigo-1" type="artigos"></simple-likes>Customize the button label with text (singular) and text-plural attributes:
<simple-likes slug="pt" text="coração" text-plural="corações"></simple-likes>
<simple-likes slug="clap" text="clap"></simple-likes>When text-plural is omitted, it defaults to text + "s". Singular form is used when count equals 1, plural otherwise.
All configuration is done via window.__simpleLikesConfig:
window.__simpleLikesConfig = {
apiUrl: "https://likes.yourdomain.com",
text: "star",
"text-plural": "stars",
};| Option | Type | Default | Description |
|---|---|---|---|---|
| apiUrl | string | "/likes" | Base URL for the likes API |
| type | string | "untyped" | Content category for segregation (e.g. "artigos", "notas") |
| text | string | "like" | Default singular label for all tags |
| text-plural | string | text + "s" | Default plural label for all tags |
Priority: inline attribute > global config > hardcoded default.
Legacy:
window.__simpleLikesApiUrlstill works as a fallback but is deprecated.
.sl-btn.liked { color: #e74c3c; }See examples/widget.html for a live demo.
| Script | Purpose |
|---|---|
npm run dev |
Start local dev server (loads .env automatically) |
npm run dev:stop |
Stop local dev server |
npm run dev:clean |
Remove .wrangler/ (local D1 data and caches) |
npm run dev:setup |
Apply schema to local D1 database (run after dev:clean or on first start) |
npm run dev:reload |
Reset local DB and restart dev server (clean + setup + dev) |
npm run setup |
Auto-detect D1 databases, generate .env, apply schema |
npm run db:migrate |
Apply schema to remote D1 databases |
npm run typecheck |
TypeScript type checking |
npm test |
Run unit tests |
npm run test:coverage |
Run unit tests with coverage report (threshold: 95%) |
npm run test:integration |
Run integration tests against staging (requires INTEGRATION_TEST_SECRET and EXPECTED_ORIGIN) |
npm run test:watch |
Run tests in watch mode |
npm run changelog |
Refresh CHANGELOG [Unreleased] section |
npm run release |
Cut a new release (tag, changelog, push) |
npm run deploy |
Deploy to Cloudflare Workers |
npm run deploy:staging |
Deploy to staging environment |
npm run deploy:production |
Deploy to production environment |
npm run serve |
Start standalone Node.js server (VPS / local SQLite) |
simplelikes/
├── src/
│ ├── index.ts Workers entry point (creates D1Storage, delegates to handleRequest)
│ ├── server.ts Node.js/VPS entry point (creates Sqlite3Storage, HTTP server)
│ ├── db/schema.sql D1 / SQLite schema
│ ├── storage/
│ │ ├── types.ts IStorage interface (getCount, increment, hasVisitor, batchGet)
│ │ ├── d1.ts D1Storage — D1Database adapter
│ │ └── sqlite.ts Sqlite3Storage — better-sqlite3 adapter (optional dep)
│ └── utils/
│ ├── cache.ts Cloudflare Cache API wrapper (60s GET, 30s batch)
│ ├── cors.ts CORS whitelist + security headers
│ ├── rate-limit.ts Per-IP + global rate limiting
│ └── validate.ts Slug validation
├── dist/
│ └── simplelikes.js Built client bundle
├── examples/
│ ├── widget.html Live demo
│ └── simplelikes.js Bundled client script (generated)
├── vitest.config.ts Vitest config (coverage, thresholds)
├── tests/
│ ├── unit/ Unit tests
│ │ ├── handler.test.ts Handler routing with mocked D1
│ │ ├── client/ Web component tests
│ │ │ ├── config.test.ts
│ │ │ ├── api.test.ts
│ │ │ └── component.test.ts
│ │ ├── utils/
│ │ │ ├── cache.test.ts Cache API wrap
│ │ │ ├── cors.test.ts CORS whitelist
│ │ │ ├── rate-limit.test.ts
│ │ │ └── validate.test.ts Slug validation
│ │ └── storage/
│ │ └── storage.test.ts D1 + SQLite storage
│ └── e2e/
│ └── integration.test.ts Integration against staging
├── .github/
│ ├── CODEOWNERS Required reviewer (@brendaw)
│ ├── FUNDING.yml Support links
│ ├── ISSUE_TEMPLATE/ Bug report + feature request templates
│ ├── pull_request_template.md
│ └── workflows/
│ ├── build.yml Push main/tag → Typecheck + tests → Trigger Deploy
│ ├── deploy.yml Deploy → Integration tests → (if tag) Release
│ └── release.yml GitHub Release (workflow_dispatch only)
├── scripts/
│ ├── import-prod-to-local.ts Import prod D1 dump to local SQLite
│ ├── json-to-sql.ts Convert wrangler JSON export to INSERTs
│ ├── migration-v1-to-v2.sql v1 → v2 schema migration (table recreation)
│ ├── schema-v1.sql Snapshot of v1 schema for migration testing
│ ├── release.sh Automated release flow
│ ├── changelog.sh CHANGELOG generation from conventional commits
│ └── setup.sh One-command setup script
├── .env.example Env vars template — copy to .env (gitignored)
├── wrangler.toml.example Wrangler config template — copy to wrangler.toml (gitignored)
├── PRIVACY.md Privacy policy and data collection disclosure
├── MAINTAINERS.md Maintenance policies
├── CONTRIBUTING.md Contribution guide
├── RELEASING.md Release process
├── CHANGELOG.md Version history
└── AUTHORS.md Contributors list
See CONTRIBUTING.md for the development workflow — bug fixes, improvements, and new features are welcome.
Before opening a PR, run:
npm run typecheck && npm run test:coverageMIT — William Brendaw, 2026.