From e82e8ab47e228e9771bb7fe796689ac879f72be6 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Wed, 17 Jun 2026 00:56:58 +0100 Subject: [PATCH 1/6] feat(pow): solve PoW captchas across a pool of web workers Move PoW solving off the main thread into a pool of web workers that divide and conquer the nonce space: with N workers, worker i tries nonces i, i+N, i+2N, ... so every nonce is covered exactly once with no overlap. The first worker to find a hash meeting the difficulty wins and the rest are terminated. The worker count is a build-time env var (PROSOPO_POW_WORKERS, default 8). Falls back to the existing synchronous solver when web workers are unavailable (e.g. SSR) or a single worker is configured. - util: add solvePoWOffset(data, difficulty, start, step); solvePoW now delegates to it - procaptcha-pow: add powWorker (worker entry) and multiThreadedSolver (orchestrator); Manager awaits solvePoWParallel - config: expose PROSOPO_POW_WORKERS to the browser bundle --- dev/config/src/vite/vite.frontend.config.ts | 4 + .../procaptcha-pow/src/services/Manager.ts | 7 +- .../src/services/multiThreadedSolver.ts | 118 ++++++++++++++++++ .../procaptcha-pow/src/services/powWorker.ts | 46 +++++++ packages/util/src/solverService.ts | 27 +++- 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 packages/procaptcha-pow/src/services/multiThreadedSolver.ts create mode 100644 packages/procaptcha-pow/src/services/powWorker.ts diff --git a/dev/config/src/vite/vite.frontend.config.ts b/dev/config/src/vite/vite.frontend.config.ts index 3c03d533c2..189c9fc526 100644 --- a/dev/config/src/vite/vite.frontend.config.ts +++ b/dev/config/src/vite/vite.frontend.config.ts @@ -84,6 +84,10 @@ export default async function ( "process.env.PROSOPO_LOG_LEVEL": JSON.stringify( process.env.PROSOPO_LOG_LEVEL, ), + // number of web workers used to solve PoW captchas in parallel + "process.env.PROSOPO_POW_WORKERS": JSON.stringify( + process.env.PROSOPO_POW_WORKERS, + ), // only needed if bundling with a site key "process.env.PROSOPO_SITE_KEY": JSON.stringify( process.env.PROSOPO_SITE_KEY, diff --git a/packages/procaptcha-pow/src/services/Manager.ts b/packages/procaptcha-pow/src/services/Manager.ts index 6cb126b452..83e1f987c8 100644 --- a/packages/procaptcha-pow/src/services/Manager.ts +++ b/packages/procaptcha-pow/src/services/Manager.ts @@ -34,8 +34,8 @@ import { encodeProcaptchaOutput, } from "@prosopo/types"; import { embedData, sleep } from "@prosopo/util"; -import { solvePoW } from "@prosopo/util"; import { randomAsHex } from "@prosopo/util-crypto"; +import { solvePoWParallel } from "./multiThreadedSolver.js"; export const Manager = ( configInput: ProcaptchaClientConfigInput, @@ -229,7 +229,10 @@ export const Manager = ( }, }); } else { - const solution = solvePoW(challenge.challenge, challenge.difficulty); + const solution = await solvePoWParallel( + challenge.challenge, + challenge.difficulty, + ); // Create salt with encoded coordinates if coordinates are provided let salt: string | undefined; diff --git a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts new file mode 100644 index 0000000000..86ee09459b --- /dev/null +++ b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts @@ -0,0 +1,118 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { solvePoW } from "@prosopo/util"; +import type { PowWorkerRequest, PowWorkerResponse } from "./powWorker.js"; + +// Hardcoded, build-time substituted number of web workers used to solve a PoW +// challenge in parallel. Each worker explores a disjoint slice of the nonce +// space (see solvePoWOffset / divide-and-conquer below). +const POW_WORKER_COUNT = Number.parseInt( + process.env.PROSOPO_POW_WORKERS || "8", + 10, +); + +/** + * Number of workers to use, guaranteed to be a sane positive integer. + */ +const getWorkerCount = (): number => + Number.isFinite(POW_WORKER_COUNT) && POW_WORKER_COUNT > 0 + ? Math.floor(POW_WORKER_COUNT) + : 1; + +/** + * Spawn a single PoW web worker. + * + * Kept as a separate function so the orchestrator can be tested/mocked and so + * environments without web worker support (e.g. SSR) fail fast and fall back to + * the synchronous solver. + */ +const spawnWorker = (): Worker => + new Worker(new URL("./powWorker.js", import.meta.url), { type: "module" }); + +/** + * Solve a PoW challenge across a pool of web workers. + * + * The nonce space is divided and conquered: with `n` workers, worker `i` only + * tries nonces `i, i + n, i + 2n, ...`, so the workers collectively cover every + * nonce exactly once with no overlap. The first worker to find a valid nonce + * wins; all workers are then terminated and its nonce is returned. + * + * Falls back to the synchronous single-threaded solver when web workers are not + * available (e.g. server-side rendering) or only a single worker is configured. + */ +export const solvePoWParallel = ( + data: string, + difficulty: number, +): Promise => { + const workerCount = getWorkerCount(); + + if (workerCount <= 1 || typeof Worker === "undefined") { + return Promise.resolve(solvePoW(data, difficulty)); + } + + return new Promise((resolve, reject) => { + let workers: Worker[] = []; + let settled = false; + + const cleanup = (): void => { + for (const worker of workers) { + worker.terminate(); + } + workers = []; + }; + + try { + for (let i = 0; i < workerCount; i++) { + const worker = spawnWorker(); + + worker.onmessage = ( + event: MessageEvent, + ): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(event.data); + }; + + worker.onerror = (event: ErrorEvent): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject( + event.error instanceof Error + ? event.error + : new Error(event.message || "PoW worker error"), + ); + }; + + const request: PowWorkerRequest = { + data, + difficulty, + start: i, + step: workerCount, + }; + worker.postMessage(request); + + workers.push(worker); + } + } catch (error) { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +}; diff --git a/packages/procaptcha-pow/src/services/powWorker.ts b/packages/procaptcha-pow/src/services/powWorker.ts new file mode 100644 index 0000000000..7143fd120f --- /dev/null +++ b/packages/procaptcha-pow/src/services/powWorker.ts @@ -0,0 +1,46 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { solvePoWOffset } from "@prosopo/util"; + +/** + * Work assigned to a single PoW worker. The worker only tries the nonces + * `start, start + step, start + 2*step, ...` so that, across a pool of `step` + * workers (each with a distinct `start` in `0..step-1`), every nonce is covered + * exactly once with no overlap. + */ +export interface PowWorkerRequest { + data: string; + difficulty: number; + start: number; + step: number; +} + +/** + * The solved nonce, posted back to the main thread by whichever worker finds it + * first. + */ +export type PowWorkerResponse = number; + +interface PowWorkerGlobalScope { + postMessage(message: PowWorkerResponse): void; + onmessage: ((event: MessageEvent) => void) | null; +} + +const ctx = self as unknown as PowWorkerGlobalScope; + +ctx.onmessage = (event: MessageEvent): void => { + const { data, difficulty, start, step } = event.data; + const nonce = solvePoWOffset(data, difficulty, start, step); + ctx.postMessage(nonce); +}; diff --git a/packages/util/src/solverService.ts b/packages/util/src/solverService.ts index ed57061863..6a24f88c3f 100644 --- a/packages/util/src/solverService.ts +++ b/packages/util/src/solverService.ts @@ -13,8 +13,29 @@ // limitations under the License. import { sha256 } from "@noble/hashes/sha256"; -export const solvePoW = (data: string, difficulty: number): number => { - let nonce = 0; +export const solvePoW = (data: string, difficulty: number): number => + solvePoWOffset(data, difficulty, 0, 1); + +/** + * Solve a PoW challenge while only trying a subset of the nonce space. + * + * Starting from `start`, only nonces `start, start + step, start + 2*step, ...` + * are tried. This allows the work to be divided across multiple solvers (e.g. + * web workers): with `step` workers, worker `i` uses `start = i` so that every + * nonce is covered exactly once across the pool with no overlap. + * + * @param data - the challenge data appended to the nonce before hashing + * @param difficulty - the number of leading hex zeros the hash must have + * @param start - the first nonce to try (the worker's offset) + * @param step - the gap between consecutive nonces tried (the worker count) + */ +export const solvePoWOffset = ( + data: string, + difficulty: number, + start: number, + step: number, +): number => { + let nonce = start; const prefix = "0".repeat(difficulty); while (true) { @@ -25,7 +46,7 @@ export const solvePoW = (data: string, difficulty: number): number => { return nonce; } - nonce += 1; + nonce += step; } }; From de56b4c42dcb39f4b810cc7387d09094d987b950 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Tue, 23 Jun 2026 13:04:28 +0100 Subject: [PATCH 2/6] fix(pow): validate solver inputs and fall back to sync solver on worker failure Address review feedback: - solvePoWOffset now validates start/step/difficulty are sane integers instead of hanging or producing non-integer nonces. - solvePoWParallel falls back to the synchronous solver when a worker errors or worker construction throws, instead of failing the captcha. --- .../src/services/multiThreadedSolver.ts | 33 ++++++++++--------- packages/util/src/solverService.ts | 14 ++++++++ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts index 86ee09459b..bbaff88e82 100644 --- a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts +++ b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts @@ -61,7 +61,7 @@ export const solvePoWParallel = ( return Promise.resolve(solvePoW(data, difficulty)); } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let workers: Worker[] = []; let settled = false; @@ -72,6 +72,19 @@ export const solvePoWParallel = ( workers = []; }; + // A worker failing to load or execute (e.g. module workers blocked by + // CSP, or an environment where `Worker` exists but construction/exec + // fails) shouldn't fail the captcha. Fall back to the synchronous + // solver instead, as documented. + const fallbackToSync = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(solvePoW(data, difficulty)); + }; + try { for (let i = 0; i < workerCount; i++) { const worker = spawnWorker(); @@ -87,17 +100,8 @@ export const solvePoWParallel = ( resolve(event.data); }; - worker.onerror = (event: ErrorEvent): void => { - if (settled) { - return; - } - settled = true; - cleanup(); - reject( - event.error instanceof Error - ? event.error - : new Error(event.message || "PoW worker error"), - ); + worker.onerror = (): void => { + fallbackToSync(); }; const request: PowWorkerRequest = { @@ -110,9 +114,8 @@ export const solvePoWParallel = ( workers.push(worker); } - } catch (error) { - cleanup(); - reject(error instanceof Error ? error : new Error(String(error))); + } catch { + fallbackToSync(); } }); }; diff --git a/packages/util/src/solverService.ts b/packages/util/src/solverService.ts index 6a24f88c3f..1845829a11 100644 --- a/packages/util/src/solverService.ts +++ b/packages/util/src/solverService.ts @@ -35,6 +35,20 @@ export const solvePoWOffset = ( start: number, step: number, ): number => { + if (!Number.isInteger(start)) { + throw new Error(`solvePoWOffset: start must be an integer, got ${start}`); + } + if (!Number.isInteger(step) || step < 1) { + throw new Error( + `solvePoWOffset: step must be a positive integer, got ${step}`, + ); + } + if (!Number.isInteger(difficulty) || difficulty < 0) { + throw new Error( + `solvePoWOffset: difficulty must be a non-negative integer, got ${difficulty}`, + ); + } + let nonce = start; const prefix = "0".repeat(difficulty); From 8e118d602700f38a8ae81998a637edd6d1d5e233 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Wed, 24 Jun 2026 08:05:01 +0100 Subject: [PATCH 3/6] fix(pow): inline worker bundle, dedupe solver loop, add offset solver tests - Bundle the PoW web worker via Vite's "?worker&inline" (matching the cryptoWorker pattern) so it is inlined into the consuming bundle. The previous "new Worker(new URL(...))" referenced a module that the library build tree-shook away (it is only referenced at runtime), which broke the procaptcha-bundle build with an unresolved powWorker.js entry. Adds the matching "?worker&inline" type declaration. - Reuse a module-level TextEncoder and share the per-nonce hash/target check between solvePoW and solvePoWOffset instead of duplicating the loop. - Add unit tests for solvePoWOffset (round-trip, disjoint slices, parity with solvePoW, divide-and-conquer, and input validation). - Fix biome formatting. --- packages/procaptcha-pow/src/custom.d.ts | 34 ++++++++++ .../src/services/multiThreadedSolver.ts | 12 ++-- packages/util/src/solverService.ts | 22 ++++--- .../util/src/tests/solverService.unit.test.ts | 63 +++++++++++++++++++ 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 packages/procaptcha-pow/src/custom.d.ts diff --git a/packages/procaptcha-pow/src/custom.d.ts b/packages/procaptcha-pow/src/custom.d.ts new file mode 100644 index 0000000000..bdf9533ef8 --- /dev/null +++ b/packages/procaptcha-pow/src/custom.d.ts @@ -0,0 +1,34 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Vite-specific worker imports: "?worker&inline" bundles the worker and inlines +// it (as a blob) into the consuming bundle, so no separate chunk file needs to +// be resolved at build time. The default export is a Worker constructor. +declare module "*?worker&inline" { + const WorkerConstructor: { + new (): Worker; + new (options?: WorkerOptions): Worker; + }; + + export default WorkerConstructor; +} + +declare module "*?worker" { + const WorkerConstructor: { + new (): Worker; + new (options?: WorkerOptions): Worker; + }; + + export default WorkerConstructor; +} diff --git a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts index bbaff88e82..8f960ff08d 100644 --- a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts +++ b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts @@ -13,6 +13,11 @@ // limitations under the License. import { solvePoW } from "@prosopo/util"; import type { PowWorkerRequest, PowWorkerResponse } from "./powWorker.js"; +// "?worker&inline" makes Vite bundle the worker and inline it into the consuming +// bundle, so there is no separate chunk file to resolve at build time. This also +// keeps the worker module from being tree-shaken away (it is only referenced at +// runtime, never statically imported). +import PowWorkerConstructor from "./powWorker.js?worker&inline"; // Hardcoded, build-time substituted number of web workers used to solve a PoW // challenge in parallel. Each worker explores a disjoint slice of the nonce @@ -37,8 +42,7 @@ const getWorkerCount = (): number => * environments without web worker support (e.g. SSR) fail fast and fall back to * the synchronous solver. */ -const spawnWorker = (): Worker => - new Worker(new URL("./powWorker.js", import.meta.url), { type: "module" }); +const spawnWorker = (): Worker => new PowWorkerConstructor(); /** * Solve a PoW challenge across a pool of web workers. @@ -89,9 +93,7 @@ export const solvePoWParallel = ( for (let i = 0; i < workerCount; i++) { const worker = spawnWorker(); - worker.onmessage = ( - event: MessageEvent, - ): void => { + worker.onmessage = (event: MessageEvent): void => { if (settled) { return; } diff --git a/packages/util/src/solverService.ts b/packages/util/src/solverService.ts index b6358b178c..b1b8582e6e 100644 --- a/packages/util/src/solverService.ts +++ b/packages/util/src/solverService.ts @@ -46,6 +46,18 @@ export const hashMeetsDifficulty = ( difficulty: number, ): boolean => hashToBigInt(hash) < targetForDifficulty(difficulty); +// Reused across every nonce attempt — allocating a fresh TextEncoder in the +// tight solve loop is pure overhead. +const textEncoder = new TextEncoder(); + +// Core per-nonce check shared by solvePoW and solvePoWOffset: hash `nonce + data` +// and test it against the precomputed difficulty target. +const nonceMeetsTarget = ( + nonce: number, + data: string, + target: bigint, +): boolean => hashToBigInt(sha256(textEncoder.encode(nonce + data))) < target; + export const solvePoW = async ( data: string, difficulty: number, @@ -54,10 +66,7 @@ export const solvePoW = async ( let nonce = 0; while (true) { - const message = new TextEncoder().encode(nonce + data); - const hash = sha256(message); - - if (hashToBigInt(hash) < target) { + if (nonceMeetsTarget(nonce, data, target)) { return nonce; } @@ -111,10 +120,7 @@ export const solvePoWOffset = ( let nonce = start; while (true) { - const message = new TextEncoder().encode(nonce + data); - const hash = sha256(message); - - if (hashToBigInt(hash) < target) { + if (nonceMeetsTarget(nonce, data, target)) { return nonce; } diff --git a/packages/util/src/tests/solverService.unit.test.ts b/packages/util/src/tests/solverService.unit.test.ts index 6348605636..5599d3c597 100644 --- a/packages/util/src/tests/solverService.unit.test.ts +++ b/packages/util/src/tests/solverService.unit.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest"; import { hashMeetsDifficulty, solvePoW, + solvePoWOffset, targetForDifficulty, } from "../solverService.js"; @@ -102,3 +103,65 @@ describe("solvePoW", () => { expect(hashMeetsDifficulty(hash, difficulty)).toBe(true); }); }); + +describe("solvePoWOffset", () => { + const hashOfNonce = (nonce: number, data: string): Uint8Array => + sha256(new TextEncoder().encode(nonce + data)); + + it("returns a nonce whose hash meets the difficulty", () => { + const challenge = "offset-roundtrip"; + const difficulty = 2; + const nonce = solvePoWOffset(challenge, difficulty, 0, 1); + expect(hashMeetsDifficulty(hashOfNonce(nonce, challenge), difficulty)).toBe( + true, + ); + }); + + it("only searches its own (start, step) slice of the nonce space", () => { + const challenge = "offset-disjoint"; + const difficulty = 2; + const step = 4; + for (let start = 0; start < step; start++) { + const nonce = solvePoWOffset(challenge, difficulty, start, step); + // worker `start` must return a nonce congruent to `start` mod `step` + expect(nonce % step).toBe(start); + expect( + hashMeetsDifficulty(hashOfNonce(nonce, challenge), difficulty), + ).toBe(true); + } + }); + + it("with (start=0, step=1) finds the same first nonce as solvePoW", async () => { + const challenge = "offset-matches-solvePoW"; + const difficulty = 2; + const expected = await solvePoW(challenge, difficulty); + expect(solvePoWOffset(challenge, difficulty, 0, 1)).toBe(expected); + }); + + it("divide-and-conquer: the smallest winning nonce across a pool equals the single-threaded result", async () => { + const challenge = "offset-pool"; + const difficulty = 2; + const step = 8; + const expected = await solvePoW(challenge, difficulty); + const poolWinners: number[] = []; + for (let start = 0; start < step; start++) { + poolWinners.push(solvePoWOffset(challenge, difficulty, start, step)); + } + expect(Math.min(...poolWinners)).toBe(expected); + }); + + it("throws on a non-integer start", () => { + expect(() => solvePoWOffset("x", 2, 0.5, 1)).toThrow(/start/); + }); + + it("throws on a non-positive or non-integer step", () => { + expect(() => solvePoWOffset("x", 2, 0, 0)).toThrow(/step/); + expect(() => solvePoWOffset("x", 2, 0, -1)).toThrow(/step/); + expect(() => solvePoWOffset("x", 2, 0, 1.5)).toThrow(/step/); + }); + + it("throws on a negative or non-finite difficulty", () => { + expect(() => solvePoWOffset("x", -1, 0, 1)).toThrow(/difficulty/); + expect(() => solvePoWOffset("x", Number.NaN, 0, 1)).toThrow(/difficulty/); + }); +}); From 8adf2b5267f71ea3fcb6e29c6e48cfa06d3408c9 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Wed, 24 Jun 2026 08:11:58 +0100 Subject: [PATCH 4/6] chore: add changeset for multi-threaded PoW solving --- .changeset/pow-multi-worker-solving.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pow-multi-worker-solving.md diff --git a/.changeset/pow-multi-worker-solving.md b/.changeset/pow-multi-worker-solving.md new file mode 100644 index 0000000000..e959fef6dc --- /dev/null +++ b/.changeset/pow-multi-worker-solving.md @@ -0,0 +1,7 @@ +--- +"@prosopo/procaptcha-pow": minor +"@prosopo/util": minor +"@prosopo/config": patch +--- + +Solve PoW captchas across a pool of web workers. The nonce space is divided and conquered across the pool (worker `i` tries nonces `i, i+N, i+2N, …`), keeping the UI responsive on higher-difficulty challenges, with a synchronous fallback when web workers are unavailable. Adds `solvePoWOffset` to `@prosopo/util` and `solvePoWParallel` to `@prosopo/procaptcha-pow`. From f59b4102381772934869ebf45cab5324ec8ce84a Mon Sep 17 00:00:00 2001 From: George Oastler Date: Wed, 24 Jun 2026 08:23:48 +0100 Subject: [PATCH 5/6] fix(pow): cap worker count at hardwareConcurrency and a hard max Clamp the configured PROSOPO_POW_WORKERS to navigator.hardwareConcurrency (when available) and a hard ceiling of 16, so a misconfigured build can't spawn an unbounded number of workers. Also clarify the changeset wording (solvePoWParallel is internal, not a public export). --- .changeset/pow-multi-worker-solving.md | 2 +- .../src/services/multiThreadedSolver.ts | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.changeset/pow-multi-worker-solving.md b/.changeset/pow-multi-worker-solving.md index e959fef6dc..320271cd6d 100644 --- a/.changeset/pow-multi-worker-solving.md +++ b/.changeset/pow-multi-worker-solving.md @@ -4,4 +4,4 @@ "@prosopo/config": patch --- -Solve PoW captchas across a pool of web workers. The nonce space is divided and conquered across the pool (worker `i` tries nonces `i, i+N, i+2N, …`), keeping the UI responsive on higher-difficulty challenges, with a synchronous fallback when web workers are unavailable. Adds `solvePoWOffset` to `@prosopo/util` and `solvePoWParallel` to `@prosopo/procaptcha-pow`. +Solve PoW captchas across a pool of web workers. The nonce space is divided and conquered across the pool (worker `i` tries nonces `i, i+N, i+2N, …`), keeping the UI responsive on higher-difficulty challenges, with a synchronous fallback when web workers are unavailable. Adds the `solvePoWOffset` helper to `@prosopo/util`; `@prosopo/procaptcha-pow` now solves PoW challenges on a worker pool internally. diff --git a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts index 8f960ff08d..2fb2510809 100644 --- a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts +++ b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts @@ -27,13 +27,32 @@ const POW_WORKER_COUNT = Number.parseInt( 10, ); +// Hard ceiling on workers regardless of configuration, so a misconfigured build +// can't spawn an unbounded number of workers and freeze the page. +const MAX_POW_WORKERS = 16; + /** * Number of workers to use, guaranteed to be a sane positive integer. + * + * The configured count is clamped to a hard maximum and, where the browser + * exposes it, to `navigator.hardwareConcurrency` — spawning more workers than + * CPU cores does not speed up a CPU-bound PoW search and only risks freezing the + * page. */ -const getWorkerCount = (): number => - Number.isFinite(POW_WORKER_COUNT) && POW_WORKER_COUNT > 0 - ? Math.floor(POW_WORKER_COUNT) - : 1; +const getWorkerCount = (): number => { + if (!Number.isFinite(POW_WORKER_COUNT) || POW_WORKER_COUNT < 1) { + return 1; + } + + const hardwareLimit = + typeof navigator !== "undefined" && + Number.isFinite(navigator.hardwareConcurrency) && + navigator.hardwareConcurrency > 0 + ? navigator.hardwareConcurrency + : MAX_POW_WORKERS; + + return Math.min(Math.floor(POW_WORKER_COUNT), hardwareLimit, MAX_POW_WORKERS); +}; /** * Spawn a single PoW web worker. From 70351a316e0876ecf4243ea546f391f88d701791 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Mon, 26 Oct 2026 12:09:12 +0000 Subject: [PATCH 6/6] perf(pow): default to 4 workers, keep a core free, reuse the message buffer in solvePoWOffset Benchmarks showed 8+ workers on a 10-core machine were no faster than 4 and dropped frames, so the pool now defaults to 4 and is capped at hardwareConcurrency - 1. solvePoWOffset now shares solvePoW's buffer-rewriting message writer instead of encoding nonce + data per attempt, which makes each worker ~40% faster. --- .changeset/pow-multi-worker-solving.md | 2 +- .../src/services/multiThreadedSolver.ts | 10 +-- packages/util/src/solverService.ts | 63 +++++++++---------- .../util/src/tests/solverService.unit.test.ts | 16 ++++- 4 files changed, 49 insertions(+), 42 deletions(-) diff --git a/.changeset/pow-multi-worker-solving.md b/.changeset/pow-multi-worker-solving.md index 320271cd6d..3ae9bfdff6 100644 --- a/.changeset/pow-multi-worker-solving.md +++ b/.changeset/pow-multi-worker-solving.md @@ -4,4 +4,4 @@ "@prosopo/config": patch --- -Solve PoW captchas across a pool of web workers. The nonce space is divided and conquered across the pool (worker `i` tries nonces `i, i+N, i+2N, …`), keeping the UI responsive on higher-difficulty challenges, with a synchronous fallback when web workers are unavailable. Adds the `solvePoWOffset` helper to `@prosopo/util`; `@prosopo/procaptcha-pow` now solves PoW challenges on a worker pool internally. +Solve PoW captchas across a pool of web workers. The nonce space is divided and conquered across the pool (worker `i` tries nonces `i, i+N, i+2N, …`), keeping the UI responsive on higher-difficulty challenges, with a synchronous fallback when web workers are unavailable. The pool defaults to 4 workers (`PROSOPO_POW_WORKERS`) and never uses more than one fewer than the device's cores, so the page keeps a core free. Adds the `solvePoWOffset` helper to `@prosopo/util`; `@prosopo/procaptcha-pow` now solves PoW challenges on a worker pool internally. diff --git a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts index 2fb2510809..2445985a52 100644 --- a/packages/procaptcha-pow/src/services/multiThreadedSolver.ts +++ b/packages/procaptcha-pow/src/services/multiThreadedSolver.ts @@ -23,7 +23,7 @@ import PowWorkerConstructor from "./powWorker.js?worker&inline"; // challenge in parallel. Each worker explores a disjoint slice of the nonce // space (see solvePoWOffset / divide-and-conquer below). const POW_WORKER_COUNT = Number.parseInt( - process.env.PROSOPO_POW_WORKERS || "8", + process.env.PROSOPO_POW_WORKERS || "4", 10, ); @@ -35,9 +35,9 @@ const MAX_POW_WORKERS = 16; * Number of workers to use, guaranteed to be a sane positive integer. * * The configured count is clamped to a hard maximum and, where the browser - * exposes it, to `navigator.hardwareConcurrency` — spawning more workers than - * CPU cores does not speed up a CPU-bound PoW search and only risks freezing the - * page. + * exposes it, to one fewer than `navigator.hardwareConcurrency`. Occupying every + * core starves the main thread and drops frames, and spawning more workers than + * cores does not speed up a CPU-bound PoW search. */ const getWorkerCount = (): number => { if (!Number.isFinite(POW_WORKER_COUNT) || POW_WORKER_COUNT < 1) { @@ -48,7 +48,7 @@ const getWorkerCount = (): number => { typeof navigator !== "undefined" && Number.isFinite(navigator.hardwareConcurrency) && navigator.hardwareConcurrency > 0 - ? navigator.hardwareConcurrency + ? Math.max(1, navigator.hardwareConcurrency - 1) : MAX_POW_WORKERS; return Math.min(Math.floor(POW_WORKER_COUNT), hardwareLimit, MAX_POW_WORKERS); diff --git a/packages/util/src/solverService.ts b/packages/util/src/solverService.ts index 5f5020cc63..68a7094962 100644 --- a/packages/util/src/solverService.ts +++ b/packages/util/src/solverService.ts @@ -101,54 +101,44 @@ export const hashMeetsDifficulty = ( difficulty: number, ): boolean => hashMeetsMask(hash, difficultyMask(difficulty)); -// Reused across every nonce attempt — allocating a fresh TextEncoder in the -// tight solve loop is pure overhead. -const textEncoder = new TextEncoder(); - -// Per-nonce check for solvePoWOffset: hash `nonce + data` and test it against -// the precomputed difficulty mask. solvePoW rewrites a single buffer instead, -// which it can only do because it walks the nonces in order. -const nonceMeetsMask = ( - nonce: number, - data: string, - mask: DifficultyMask, -): boolean => hashMeetsMask(sha256(textEncoder.encode(nonce + data)), mask); - -export const solvePoW = async ( - data: string, - difficulty: number, -): Promise => { - const mask = difficultyMask(difficulty); - - // Encode the constant suffix once and reuse a single message buffer, only - // rewriting the nonce's decimal digits each iteration. The digit count only - // grows at powers of ten, where the buffer is reallocated. - const encoder = new TextEncoder(); - const dataBytes = encoder.encode(data); - - let nonce = 0; +// Builds the `nonce + data` message for each attempt. The constant suffix is +// encoded once into a single reused buffer and only the nonce's decimal digits +// are rewritten per call. Nonces must not decrease between calls: the buffer +// only grows, at powers of ten, when the nonce gains a digit. +const nonceMessageWriter = (data: string): ((nonce: number) => Uint8Array) => { + const dataBytes = new TextEncoder().encode(data); let digits = 1; + let rollover = 10; let message = new Uint8Array(digits + dataBytes.length); message.set(dataBytes, digits); - let rollover = 10; - while (true) { - if (nonce === rollover) { - // the decimal representation gained a digit; grow the buffer + return (nonce: number): Uint8Array => { + while (nonce >= rollover) { digits += 1; rollover *= 10; message = new Uint8Array(digits + dataBytes.length); message.set(dataBytes, digits); } - // write the nonce's decimal digits into the leading `digits` bytes let n = nonce; for (let i = digits - 1; i >= 0; i--) { message[i] = 48 + (n % 10); // 48 === '0'.charCodeAt(0) n = Math.floor(n / 10); } + return message; + }; +}; + +export const solvePoW = async ( + data: string, + difficulty: number, +): Promise => { + const mask = difficultyMask(difficulty); + const messageFor = nonceMessageWriter(data); + let nonce = 0; - if (hashMeetsMask(sha256(message), mask)) { + while (true) { + if (hashMeetsMask(sha256(messageFor(nonce)), mask)) { return nonce; } @@ -184,8 +174,10 @@ export const solvePoWOffset = ( start: number, step: number, ): number => { - if (!Number.isInteger(start)) { - throw new Error(`solvePoWOffset: start must be an integer, got ${start}`); + if (!Number.isInteger(start) || start < 0) { + throw new Error( + `solvePoWOffset: start must be a non-negative integer, got ${start}`, + ); } if (!Number.isInteger(step) || step < 1) { throw new Error( @@ -199,10 +191,11 @@ export const solvePoWOffset = ( } const mask = difficultyMask(difficulty); + const messageFor = nonceMessageWriter(data); let nonce = start; while (true) { - if (nonceMeetsMask(nonce, data, mask)) { + if (hashMeetsMask(sha256(messageFor(nonce)), mask)) { return nonce; } diff --git a/packages/util/src/tests/solverService.unit.test.ts b/packages/util/src/tests/solverService.unit.test.ts index 73d54c5b0c..eb7c403116 100644 --- a/packages/util/src/tests/solverService.unit.test.ts +++ b/packages/util/src/tests/solverService.unit.test.ts @@ -205,8 +205,22 @@ describe("solvePoWOffset", () => { expect(Math.min(...poolWinners)).toBe(expected); }); - it("throws on a non-integer start", () => { + it("returns the first nonce in its slice whose hash meets the difficulty, across digit rollovers", () => { + const challenge = "offset-rollover"; + const difficulty = 2.5; + const start = 3; + const step = 7; + let expected = start; + while (!hashMeetsDifficulty(hashOfNonce(expected, challenge), difficulty)) { + expected += step; + } + expect(expected).toBeGreaterThan(100); + expect(solvePoWOffset(challenge, difficulty, start, step)).toBe(expected); + }); + + it("throws on a negative or non-integer start", () => { expect(() => solvePoWOffset("x", 2, 0.5, 1)).toThrow(/start/); + expect(() => solvePoWOffset("x", 2, -1, 1)).toThrow(/start/); }); it("throws on a non-positive or non-integer step", () => {