Client-side cipher solvers

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.

API

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.

Build pipeline

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:

Eleventy's passthrough copies dist/js_site/assets/js, so nothing on the page side has to know about the build path.

Verifying bundle size

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).

Porting a new algorithm

The Hill port is the reference. Template:

  1. 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 };
    }
    
  2. Register in algorithms/index.js:

    import { solve<Cipher> } from './<cipher>.js';
    export const ALGORITHMS = { hill: solveHill, <cipher>: solve<Cipher> };
    
  3. Add the cipher id to CLIENT_SOLVERS in index.js so hasClientSolver() reports true.

  4. 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).

  5. 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)).

  6. 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.

Next ports in queue

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.

Out of scope