A Web Worker harness that runs cipher solvers in the browser instead of round-tripping to a Cloudflare Worker. For sub-second to ~5 s solves (Hill, columnar, substitution) the local path is faster: no network latency, no rate limit, ciphertext never leaves the user's machine. Heavy simulated-annealing solvers (bifid, playfair, foursquare, trifid) stay on Cloudflare Workers — they need the multi-second CPU budget and SA's wall-clock unpredictability is a worse fit for an in-page worker.
import { solveClient, hasClientSolver, cancelClientSolve } from '../lib/solvers/index.js';
// True if a client-side algorithm is registered.
hasClientSolver('hill'); // -> true
// Spawn the singleton Web Worker on first call, reuse on subsequent calls.
const result = await solveClient('hill', ciphertext);
// → { ok: true, ms: 1234, candidates: [{ matrix, det, plaintext, score, wordHits }, ...] }
// Errors resolve with { ok: false, error, candidates: [], ms: 0 } — never throws.
// Optional progress callback for long solves.
await solveClient('hill', ct, {}, ({ candidatesSoFar, ms }) => updateSpinner(ms));
The worker is module-format (type: 'module') so esbuild's --splitting --format=esm chunks the heavy ~1 MB quadgram blob into its own hashed file. First call downloads it once; every subsequent solve in the session is free.
Add new algorithm modules under algorithms/ and register them in algorithms/index.js. The js:build:solvers npm script handles the rest:
esbuild src/js/lib/solvers/solver-worker.js \
--bundle --minify --splitting --format=esm \
--outdir=dist/js/solvers \
--entry-names=[name] \
--chunk-names=[name]-[hash]
Output:
dist/js/solvers/solver-worker.js (fixed name, ~5 KB) — entry, referenced by index.js as /assets/js/solvers/solver-worker.jsdist/js/solvers/solver-data-[hash].js (~1 MB raw / ~570 KB gz) — lazy-loaded chunk, immutable so the browser cache holds it foreverdist/js/solvers/chunk-[hash].js (~120 B) — esbuild splitting bookkeepingEleventy's passthrough copies dist/js → _site/assets/js, so nothing on the page side has to know about the build path.
After npm run js:build:
du -h dist/js/main.js dist/js/solvers/*
gzip -c dist/js/solvers/solver-data-*.js | wc -c
main.js should not change between two builds where only solver code was added. The solver-data chunk only loads when a user clicks an Auto Solve button — confirm by watching the Network tab on the Hill page (or any page that imports solveClient).
The Hill port is the reference. Template:
Copy algorithms/hill.js to algorithms/<cipher>.js, swap the algorithm body. Keep the public signature:
export async function solve<Cipher>(rawCt, options) {
await loadEnglishData(); // ensures the quadgram chunk is loaded
// ...algorithm...
return { ok: true, candidates: [...], ms };
}
options.cancel() at loop boundaries so long SA runs can be aborted.options.progress({ candidatesSoFar, ms }) periodically if the solve is multi-second.matrix, key, keyword, wordHits, score, etc.).Register in algorithms/index.js:
import { solve<Cipher> } from './<cipher>.js';
export const ALGORITHMS = { hill: solveHill, <cipher>: solve<Cipher> };
Add the cipher id to CLIENT_SOLVERS in index.js so hasClientSolver() reports true.
Remove the cipher's entry from CLOUD_SOLVER_ENDPOINTS in src/js/lib/cipher-id/classifier.js AND from CLOUD_SOLVERS in src/js/tools/multidecoder-next.js. Add a matching entry to multidecoder-next's CLIENT_SOLVERS map (mostly the title tooltip + min/max length).
Wire the standalone tool page if it has its own solver UI (mirror the pattern in src/js/tools/hill.js: swap the cloud-fetch call for solveClient(cipher, ct)).
Build + smoke test:
npm run js:build && npx eleventy # do NOT include i18n:bundle
Verify dist/js/solvers/solver-data-*.js size unchanged, main.js size unchanged, and a known ciphertext for the new cipher resolves correctly in the browser.
workers/cipher-solve-columnar/src/index.js): permutation hill-climb across key lengths 4–12 with 9k keyword wordlist Phase 0. ~2 hrs to port; same candidate shape as cloud ({ key, keyword, permutation, keyLength, plaintext, score, wordHits }).workers/cipher-solve-substitution/src/index.js): Phase 0 keyword sweep + SA hillclimb. Heavier than columnar; expect ~3 hrs. Pull the recent Phase 0 dedup fix (commit a9083956) before porting.Both ports are deferred — establish the harness with hill first, then revisit. See project_vps_solver_offload.md for the longer-term "Solve Harder" VPS tier that complements this work.
src/assets/vendor/multidecoder/. Don't double-port unless we consolidate the two solver paths.