Guides · WhiskerJS 0.1.0

Plain JavaScript

No framework: load WhiskerJS as ES modules or through a bundler, collect when the form is submitted, send the result with the form.

In plain words

You copy the WhiskerJS files next to your page, import them with one line, and when the visitor presses the submit button you ask your server for a one-time code, let WhiskerJS read the browser, and send its answer together with the form. Nothing else on the page changes.

Install: build from source

WhiskerJS is not on npm yet; the npm package is coming. Build it from source:

cd whiskerjs                 # the WhiskerJS source folder
pnpm install && pnpm build   # writes dist/: ES2020 modules and .d.ts files

Then pick one of two ways.

  • As plain files. Copy dist/*.js and dist/collectors/*.js to your static files, keeping the folder layout (for example to public/vendor/whiskerjs/). The .d.ts files are only for editors and type checkers; leave them out if you like. This is how the live demo runs it.
  • With a bundler. Run pnpm pack in the WhiskerJS folder and install the tarball in your project with npm install ../whiskerjs/whiskerjs-0.1.0.tgz. Your bundler then resolves import { load } from 'whiskerjs'.

There are no runtime dependencies.

The page, as ES modules

A complete sign-in page. It assumes the two endpoints described in the guides overview: GET /api/whisker-nonce and your existing POST /api/login.

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Sign in</title></head>
<body>
  <form id="login">
    <label>Email <input name="email" type="email" required></label>
    <label>Password <input name="password" type="password" required></label>
    <button type="submit">Sign in</button>
  </form>
  <output id="status"></output>

  <script type="module">
    import { load } from '/vendor/whiskerjs/index.js';

    const agentReady = load(); // cheap: no collection work happens yet
    const form = document.getElementById('login');
    const status = document.getElementById('status');

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      try {
        // 1. A fresh nonce and timestamp from your server.
        const binding = await (await fetch('/api/whisker-nonce')).json();
        // 2. Collect at the moment you need it. collect() never throws or rejects.
        const agent = await agentReady;
        const device = await agent.collect({ timeoutMs: 3000, binding });
        // 3. Send it with the request you already make.
        const response = await fetch('/api/login', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ email: form.email.value, password: form.password.value, device }),
        });
        status.textContent = response.ok ? 'Signed in' : `Refused (${response.status})`;
      } catch {
        status.textContent = 'Network error, please try again';
      }
    });
  </script>
</body>
</html>

What happens on submit:

  1. Nonce. The page asks your server for a fresh { nonce, timestamp }. The server stores both for this session; see the Node, Go, PHP or Python guide.
  2. Collect. collect({ timeoutMs: 3000, binding }) reads the 47 components, runs the integrity checks, derives the ids and adds result.binding with your nonce, your timestamp and a digest over the whole result. It never throws and never rejects; a component that fails reports its own status instead.
  3. Send. The result goes in the body of the request you were sending anyway, as the device field. There is no WhiskerJS endpoint and no extra request.

The try block is for the two fetch calls, which can fail like any request.

The same page with a bundler

With the tarball installed, only the import changes. The script moves to its own file:

import { load } from 'whiskerjs';

const agentReady = load(); // cheap: no collection work happens yet
const form = document.getElementById('login');
const status = document.getElementById('status');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    // 1. A fresh nonce and timestamp from your server.
    const binding = await (await fetch('/api/whisker-nonce')).json();
    // 2. Collect at the moment you need it. collect() never throws or rejects.
    const agent = await agentReady;
    const device = await agent.collect({ timeoutMs: 3000, binding });
    // 3. Send it with the request you already make.
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email: form.email.value, password: form.password.value, device }),
    });
    status.textContent = response.ok ? 'Signed in' : `Refused (${response.status})`;
  } catch {
    status.textContent = 'Network error, please try again';
  }
});
  

and the page loads it with <script type="module" src="/src/main.js"></script> (the path your bundler expects).

Things to know

  • Secure context. Serve the page over https, or from http://localhost while you develop. Hashing uses crypto.subtle, which browsers only offer there; without it visitorId and structuredId are null and large components are unavailable with ruleId no_digest.
  • A body element. Several probes add hidden elements to document.body for a moment and remove them before collect() resolves. A module script runs after the document is parsed, so the body exists; if you call collect() from a script in the <head> without type="module" or defer, wait for DOMContentLoaded.
  • Content Security Policy. WhiskerJS makes no network request and creates no worker, so it needs nothing beyond script-src for your own files. Its integrity checks add one about:blank iframe without a src for a moment.
  • When to collect. On the action you protect (sign-in, sign-up, checkout), not on every page view. The time it takes is in result.durationMs; the overall budget is timeoutMs.
  • What the result contains. See Getting started and Signals.

Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2) and headless Chromium 140 (Playwright 1.55.0) on 2026-10-26: the ES module page served by the Node server, and the bundler version built with Vite 8.3.5. In both, the server accepted the binding and refused the same result sent a second time.

Questions and answers

Do I need a bundler?

No. The dist folder is plain ES2020 modules with relative imports, so a browser can load index.js directly with a script of type module. A bundler only changes the import line.

Can I load it from a CDN?

No CDN serves WhiskerJS. Serve the files from your own origin, which also keeps the page free of third-party requests.

Why is the agent created before the click?

load() is cheap: it only picks the browser objects, the clock and Web Crypto, and does no collection work. Creating it early means the submit handler only waits for collect().

What if the nonce request fails?

Then fetch throws and the catch block reports a network error. collect() itself never throws, so the try block is only there for your own requests.