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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

A self-hosted drop-in replacement for [CounterAPI](https://counterapi.com) built on [Cloudflare Workers](https://workers.cloudflare.com/) + [KV](https://developers.cloudflare.com/kv/). CounterAPI went down in May 2026 without notice; this gives you the same API surface on infrastructure you own.

![CounterAPI Worker README views](https://counter.avikalp.workers.dev/api/github.com/avikalpg/counterapi-worker/views/readme?format=svg&label=README%20views)

This README uses the Worker above to count its own views. Dogfooding matters: the Markdown badge is a normal request to the hosted Worker, not a static screenshot.

## Features

- **Same URL structure as CounterAPI** — swap one hostname and your existing JS keeps working
- **Same response shape** — `{ value, iconSvg }` with matching Ionicon SVGs (eye for views, outline/filled heart for likes)
- **Embeddable SVG badges** — add `?format=svg` for `<img>` tags and GitHub Markdown
- **2-minute write buffer** — accumulates view increments in memory, flushes to KV once per 2-minute window per key. Max ~720 KV writes/day regardless of traffic, well within the free tier limit (1,000/day)
- **Health endpoint** — `/health` for uptime monitoring
- **Export endpoint** — dump all counter data as JSON for backup
Expand All @@ -20,6 +25,11 @@ GET /api/{namespace}/views/{key}
```
Auto-increments on each request and returns `{ value, iconSvg }`.

For an embeddable SVG badge:
```md
![views](https://counter.YOUR_SUBDOMAIN.workers.dev/api/your-site.com/views/page-key?format=svg&label=views)
```

### Like counter (read)
```
GET /api/{namespace}/vote/{key}?readOnly=true
Expand Down Expand Up @@ -72,6 +82,11 @@ fetch(`https://counter.YOUR_SUBDOMAIN.workers.dev/api/your-site.com/views/page-k

That's it. The response shape is identical.

For GitHub READMEs or HTML image badges, use the SVG format:
```html
<img src="https://counter.YOUR_SUBDOMAIN.workers.dev/api/your-site.com/views/page-key?format=svg&label=views" alt="Views" />
```

If you were using the `c.js` embed script (`<div class="counterapi" ...>`), see the [counter.js](https://github.com/avikalpg/avikalpg.github.io/blob/master/counter.js) drop-in replacement in the companion site repo.

## Deploy your own
Expand Down
48 changes: 48 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,42 @@ const CORS_HEADERS = {
'Content-Type': 'application/json',
};

function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

function renderBadgeSvg({ type, value, label }) {
const displayLabel = label || (type === 'vote' ? 'likes' : 'views');
const displayValue = value.toLocaleString('en-US');
const labelWidth = Math.max(52, displayLabel.length * 7 + 24);
const valueWidth = Math.max(44, displayValue.length * 8 + 24);
const width = labelWidth + valueWidth;
const valueColor = type === 'vote' ? '#e11d48' : '#4f46e5';

return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="24" role="img" aria-label="${escapeXml(displayLabel)}: ${escapeXml(displayValue)}">
<title>${escapeXml(displayLabel)}: ${escapeXml(displayValue)}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".08"/>
<stop offset="1" stop-opacity=".08"/>
</linearGradient>
<clipPath id="r"><rect width="${width}" height="24" rx="4" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="${labelWidth}" height="24" fill="#0f172a"/>
<rect x="${labelWidth}" width="${valueWidth}" height="24" fill="${valueColor}"/>
<rect width="${width}" height="24" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="11" font-weight="600">
<text x="${labelWidth / 2}" y="16">${escapeXml(displayLabel)}</text>
<text x="${labelWidth + valueWidth / 2}" y="16">${escapeXml(displayValue)}</text>
</g>
</svg>`;
}

// ---------------------------------------------------------------------------
// Write buffer — persists for the lifetime of this isolate instance.
//
Expand Down Expand Up @@ -204,6 +240,18 @@ export default {
? (isReadOnly ? HEART_OUTLINE_SVG : HEART_FILLED_SVG)
: EYE_SVG;

if (url.searchParams.get('format') === 'svg') {
const badgeSvg = renderBadgeSvg({
type,
value,
label: url.searchParams.get('label'),
});
return new Response(
badgeSvg,
{ headers: { ...CORS_HEADERS, 'Content-Type': 'image/svg+xml; charset=utf-8' } }
);
}

return new Response(
JSON.stringify({ value, iconSvg }),
{ headers: CORS_HEADERS }
Expand Down
18 changes: 18 additions & 0 deletions test/counter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ describe('GET /api/:ns/views/:key', () => {
expect(r2.body.value).toBe(before.body.value);
expect(r3.body.value).toBe(before.body.value);
});

it('returns an embeddable SVG badge when format=svg', async () => {
const kv = createKV();
const env = makeEnv(kv);
const key = uid();

const res = await fetch(`/api/${NS}/views/${key}?format=svg&label=repo%20views`, { env });
const body = await res.text();

expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('image/svg+xml');
expect(body).toContain('<svg');
expect(body).toContain('repo views');
expect(body).toContain('1');

const current = await json(`/api/${NS}/views/${key}?readOnly=true`, { env });
expect(current.body.value).toBe(1);
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading