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
7 changes: 7 additions & 0 deletions .changeset/pow-multi-worker-solving.md
Original file line number Diff line number Diff line change
@@ -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. 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.
4 changes: 4 additions & 0 deletions dev/config/src/vite/vite.frontend.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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,
Expand Down
34 changes: 34 additions & 0 deletions packages/procaptcha-pow/src/custom.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions packages/procaptcha-pow/src/services/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,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,
Expand Down Expand Up @@ -293,7 +293,7 @@ export const Manager = (
error: challenge.error,
});
} else {
const solution = await solvePoW(
const solution = await solvePoWParallel(
challenge.challenge,
challenge.difficulty,
);
Expand Down
142 changes: 142 additions & 0 deletions packages/procaptcha-pow/src/services/multiThreadedSolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// 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";
// "?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
// space (see solvePoWOffset / divide-and-conquer below).
const POW_WORKER_COUNT = Number.parseInt(
process.env.PROSOPO_POW_WORKERS || "4",
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 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) {
return 1;
}

const hardwareLimit =
typeof navigator !== "undefined" &&
Number.isFinite(navigator.hardwareConcurrency) &&
navigator.hardwareConcurrency > 0
? Math.max(1, navigator.hardwareConcurrency - 1)
: MAX_POW_WORKERS;

return Math.min(Math.floor(POW_WORKER_COUNT), hardwareLimit, MAX_POW_WORKERS);
};

/**
* 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 PowWorkerConstructor();

/**
* 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<number> => {
const workerCount = getWorkerCount();

if (workerCount <= 1 || typeof Worker === "undefined") {
return Promise.resolve(solvePoW(data, difficulty));
}

return new Promise<number>((resolve) => {
let workers: Worker[] = [];
let settled = false;

const cleanup = (): void => {
for (const worker of workers) {
worker.terminate();
}
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();

worker.onmessage = (event: MessageEvent<PowWorkerResponse>): void => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(event.data);
};

worker.onerror = (): void => {
fallbackToSync();
};

const request: PowWorkerRequest = {
data,
difficulty,
start: i,
step: workerCount,
};
worker.postMessage(request);

workers.push(worker);
}
} catch {
fallbackToSync();
}
});
};
46 changes: 46 additions & 0 deletions packages/procaptcha-pow/src/services/powWorker.ts
Original file line number Diff line number Diff line change
@@ -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<PowWorkerRequest>) => void) | null;
}

const ctx = self as unknown as PowWorkerGlobalScope;

ctx.onmessage = (event: MessageEvent<PowWorkerRequest>): void => {
const { data, difficulty, start, step } = event.data;
const nonce = solvePoWOffset(data, difficulty, start, step);
ctx.postMessage(nonce);
};
93 changes: 74 additions & 19 deletions packages/util/src/solverService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,41 +101,44 @@ export const hashMeetsDifficulty = (
difficulty: number,
): boolean => hashMeetsMask(hash, difficultyMask(difficulty));

export const solvePoW = async (
data: string,
difficulty: number,
): Promise<number> => {
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<number> => {
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;
}

Expand All @@ -148,6 +151,58 @@ export const solvePoW = async (
}
};

/**
* 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.
*
* Unlike `solvePoW`, this is synchronous and never yields to the event loop: it
* is intended to run inside a web worker, where blocking the (worker) thread
* does not affect the page's UI responsiveness.
*
* @param data - the challenge data appended to the nonce before hashing
* @param difficulty - the PoW difficulty (see `targetForDifficulty`)
* @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 => {
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(
`solvePoWOffset: step must be a positive integer, got ${step}`,
);
}
if (!Number.isFinite(difficulty) || difficulty < 0) {
throw new Error(
`solvePoWOffset: difficulty must be a non-negative number, got ${difficulty}`,
);
}

const mask = difficultyMask(difficulty);
const messageFor = nonceMessageWriter(data);
let nonce = start;

while (true) {
if (hashMeetsMask(sha256(messageFor(nonce)), mask)) {
return nonce;
}

nonce += step;
}
};

/**
* Usage:
* await newMacrotask();
Expand Down
Loading
Loading