Skip to content

Repository files navigation

Build Staging Production Issues

simplelikes

A minimal, standalone likes counter API. Drop-in anonymous likes for any static site.

Features

  • 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 counts
  • GET /likes/types/:type — returns paginated slugs for a type
  • POST /likes/:slug — toggles like/unlike (dedup per visitor), accepts optional type in body
  • POST /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 in dist/simplelikes.js

Quick start

npm install
cp .env.example .env
npm run dev:setup     # create local D1 tables (required once)
npm run dev

The 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"}

API

GET /likes/:slug

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.

POST /likes/:slug

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"}

POST /likes/batch

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.

GET /likes/types

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}]}

GET /likes/types/:type

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}

Errors

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)

Caching

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) — see src/utils/cache.ts
  • CORS headers are applied after cache retrieval, not baked into cached responses — this guarantees every request gets Access-Control-Allow-Origin matching its own Origin header, regardless of cache state

Privacy

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.

Security

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

Deployment

Cloudflare Workers + D1 (recommended)

One-command setup

npm run setup

This auto-detects your D1 databases, generates wrangler.toml with real IDs, and applies the schema to remote databases.

Manual setup

  1. Install Wrangler CLI:

    npm install -g wrangler
  2. Log in to your Cloudflare account:

    wrangler login
  3. Create the D1 databases:

    wrangler d1 create simplelikes
    wrangler d1 create simplelikes-staging
  4. Run setup:

    npm run setup
  5. Deploy:

    npm run deploy

VPS / standalone (Node.js)

simplelikes can also run as a standalone Node.js server using better-sqlite3:

npm install
npm run serve

The server starts at http://localhost:3000 with an auto-created SQLite database at ./data/likes.db.

better-sqlite3 is 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

Configuration

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).

Process management

For production VPS deployments, use a process manager like pm2:

npm install -g pm2
pm2 start npm --name simplelikes -- run serve

Set environment variables in the process manager config (ecosystem.config.js for pm2) or via your systemd service file.

Configuration

Environment variables

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_ORIGINS defaults to the local Wrangler dev server. For deployed workers, override via wrangler.toml [vars] or Cloudflare dashboard. In CI, set the ALLOWED_ORIGINS GitHub Secret for the CORS integration test to pass.

Local configuration

Both wrangler.toml and .env are gitignored. Start from the examples:

cp .env.example .env
cp wrangler.toml.example wrangler.toml

The wrangler.toml uses __PLACEHOLDER__ variables:

  • __STAGING_DATABASE_ID__ / __PRODUCTION_DATABASE_ID__ — replaced by scripts/setup.sh
  • __INTEGRATION_TEST_SECRET__ / __ALLOWED_ORIGINS__ — replaced by CI (deploy.yml) or by scripts/setup.sh (reads from .env if 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.

Client-side usage

The web component source is in src/client/ (TypeScript). Build the bundle:

npm run build:client

This 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>

Custom text

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.

Global config

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.__simpleLikesApiUrl still works as a fallback but is deprecated.

Styling

.sl-btn.liked { color: #e74c3c; }

See examples/widget.html for a live demo.

Scripts

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)

Project structure

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

Contributing

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:coverage

License

MIT — William Brendaw, 2026.

About

A minimal, standalone likes counter API. Drop-in anonymous likes for any static site.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages