proof-of-work reactions for your blogs ๐Ÿ‘ โค๏ธ ๐Ÿ‘€ ๐Ÿ˜ฎ ๐Ÿค” ๐Ÿš€ https://pow-reaction.pages.dev/
Find a file
2025-09-15 15:03:42 +02:00
.forgejo/workflows Remove --provenance flag in npm.yml 2025-09-15 15:03:42 +02:00
.github Update links from github.com to git.hloth.dev 2025-09-15 15:02:14 +02:00
.vscode Remove css bundling 2025-09-03 18:29:29 +02:00
docs Add gif demo 2025-08-31 22:38:42 +02:00
src Update links from github.com to git.hloth.dev 2025-09-15 15:02:14 +02:00
static Initial commit 2025-08-28 01:17:49 +02:00
.gitignore Initial commit 2025-08-28 01:17:49 +02:00
.npmrc Initial commit 2025-08-28 01:17:49 +02:00
.prettierignore Initial commit 2025-08-28 01:17:49 +02:00
.prettierrc Add UI 2025-08-28 12:53:43 +02:00
bun.lock Giving up on Tailwind and removing it completely 2025-09-10 14:50:02 +02:00
eslint.config.js Initial commit 2025-08-28 01:17:49 +02:00
jsr.json Update links from github.com to git.hloth.dev 2025-09-15 15:02:14 +02:00
LICENSE Add license 2025-08-31 16:04:44 +02:00
mod.ts Handle errors in worker 2025-09-09 13:08:34 +02:00
package.json Update links from github.com to git.hloth.dev 2025-09-15 15:02:14 +02:00
README.md Update links from github.com to git.hloth.dev 2025-09-15 15:02:14 +02:00
svelte.config.js Migrate demo to CF pages 2025-08-31 17:33:28 +02:00
tsconfig.json Migrate demo to CF pages 2025-08-31 17:33:28 +02:00
vite.config.ts Initial commit 2025-08-28 01:17:49 +02:00
wrangler.jsonc Migrate demo to CF pages 2025-08-31 17:33:28 +02:00

pow-reaction

License NPM Version NPM Unpacked Size

proof-of-work reactions for your Svelte blogs

Demo gif

demo: pow-reaction.pages.dev

How POW captcha works

  1. You generate a challenge which consists of a. difficulty, b. number of rounds
  2. You generate a unique random string of characters for each round called id
  3. User now has to find a hash so that hash(id + nonce) -> translated to binary (000111010101011) starts from difficulty number of consecutive zeroes by iterating nonce starting from 0 and until they find the hash
  4. They send their solutions (nonces) back with the challenge signed by you (to retrieve parameters for captcha and keep this lib stateless)
  5. All you have to do is verify their solutions by checking if hash(id + nonce) with their provided nonce -> translated to binary really starts from difficulty number of consecutive zeroes

Add progressively increasing difficulty with each subsequent request, and you get a pretty good stateless, privacy friendly rate limiter.

Not only this is a secure way of stopping flood but also a fair way for users to express their reaction. More reactions = more time to spend = those who appreciate the page's content more will send more reactions.

Couple of qurks:

  • Instead of setting difficulty to 100 and rounds to 1, set difficulty to 1 and rounds to 100
    • more rounds = more equal average time of solving the challenge
    • more rounds = real progress bar for user
    • more rounds = run several workers to seek solution in parallel
  • Instead of mining on single core, use WebWorkers
    • web workers run in a separate thread = no UI freezes
    • several web workers = several times faster to find all solutions
    • use navigator.hardwareConcurrency which is supported by every browser

Install

NPM:

bun add pow-reaction

JSR is blocked (see #1)

In your Svelte UI component (client-side):

<script>
	import z from 'zod';
	import ReactionButton from 'pow-reaction';
	import { invalidate } from '$app/navigation';

	let { data } = $props();

	const emoji = '๐Ÿ˜˜';

	async function onclick() {
		const req = await fetch('/api/reactions/challenge', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ reaction: emoji })
		});
		if (!req.ok) throw new Error('Failed to get challenge');
		return z.object({ challenge: z.string() }).parse(await req.json());
	}

	async function onreact({ challenge, solutions }) {
		const req = await fetch('/api/reactions', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ challenge, solutions, reaction: emoji })
		});
		if (!req.ok) throw new Error('Failed to add reaction');
		z.object({ success: z.literal(true) }).parse(await req.json());

		invalidate('post:reactions');
	}
</script>

<!-- Optionally pass i18n={{ reactButton: {{ loading: 'Loading...', reactWith: 'React with', jsRequired: 'JavaScript is required in order to add reactions' }} }} to translate strings -->
<ReactionButton reaction={emoji} value={data.reactions[emoji]} {onclick} {onreact} />

In your server side initialize PowReaction class (lib/server/reactions.ts):

import { PowReaction } from 'pow-reaction';

// load from process.env or something, it should be 32 bytes long
const secret = new TextEncoder().encode('HESOYAM_HESOYAM_HESOYAM_HESOYAM!');

export const reaction = new PowReaction({
	// secret is used to cryptographically sign challenge
	secret,
	// reaction can be any string, emoji or enum value
	reaction: '๐Ÿ˜˜',
	difficulty: {
		// how many ms (1/1000 of a second) should be checked when generating a challenge
		windowMs: 1000 * 60,
		// min. starting difficulty, optional, defaults to 4
		minDifficulty: 4,
		// floor(challenges generated in last `windowMs` * `multiplier`) = number of leading zero bytes in the challenge
		multiplier: 1,
		//
		async getEntries({ ip, since }) {
			// return number of entries in your persistant storage
			// an IP address `ip` has been added to it
			// starting from `since` Date
		},
		async putEntry({ ip }) {
			// put an IP address `ip` to your persistant storage
			// and assign current date `new Date()` to the entry
		}
	},
	// how long should a signed challenge be valid
	// this is mainly to prevent bots from requesting a lot of challenges
	// in advance, easily solving, and then submitting in batch.
	// too small values will cause low-end devices not to be able
	// to submit solutions within this time frame
	// optional, defaults to 60000 (60 seconds)
	ttl: 1000 * 60,
	async isRedeemed(id) {
		// return whether the challenge id was submitted previously
	},
	async setRedeemed(id) {
		// put the successfully submitted challenge id
	}
});

In your server challenge generator API handler (POST /api/reactions/challenge):

import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';

export async function POST({ request }) {
	const body = await z
		.object({
			reaction: z.literal('๐Ÿ˜˜')
		})
		.safeParseAsync(await request.json());

	if (!body.success) {
		return json({ success: false }, { status: 400 });
	}

	// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
	const ip = '1.2.3.4';
	const challenge = await reaction.getChallenge({ ip });
	return json({ challenge });
}

In your server solution submitter API handler (POST /api/reactions):

import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';

export async function POST({ request }) {
	const body = await z
		.object({
			reaction: z.literal('๐Ÿ˜˜'),
			challenge: z.string().min(1),
			solutions: z.array(z.number().int().nonnegative())
		})
		.safeParseAsync(await request.json());

	if (!body.success) {
		return json({ success: false }, { status: 400 });
	}
	const { challenge, solutions } = body.data;

	// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
	const ip = '1.2.3.4';

	const success = await reaction.verifySolution({ challenge, solutions }, { ip });
	if (success) {
		// increase number of reactions by +1 in your database
	}
	return json({ success });
}

Important

Vite deps optimizer does not work with WebWorkers (see vitejs/vite#11672, vitejs/vite#15547, vitejs/vite#15618). You must add pow-reaction to optimizeDeps.exclude in your vite.config.ts in order for WebWorker to load:

optimizeDeps: {
	exclude: ['pow-reaction'];
}

Theme

CSS variable Description Default
--reaction-button-text-color Value text color rgba(0, 0, 0, 0.6), rgba(212, 212, 212, 0.6) when (prefers-color-scheme: dark)
--reaction-button-highlight-color Button highlight color (focus & circular progress bar) rgba(0, 0, 0, 0.1), rgba(161, 161, 161, 0.3) when (prefers-color-scheme: dark)

Demo

You can find example & demo source code in src/routes directory.

Demo works with Cloudflare Pages and Cloudflare KV for IP rate limiting.

Tested in Firefox 142, Chrome 139, Safari macOS 18.5, Safari iOS 18.0, Tor Browser 14.5.6

Credits

Thanks to Paul Miller for the amazing noble project!

And thanks to Pilcrow for the awesome Oslo project!

License

MIT

Donate

hloth.dev/donate