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

Source: https://whiskerjs.com/guides/javascript/ (last updated 2026-10-26)

> **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:

```sh
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](https://whiskerjs.com/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](https://whiskerjs.com/guides/#how-the-pieces-fit): `GET /api/whisker-nonce` and your existing `POST /api/login`.

```html
<!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](https://whiskerjs.com/guides/verify-node/), [Go](https://whiskerjs.com/guides/verify-go/), [PHP](https://whiskerjs.com/guides/verify-php/) or [Python](https://whiskerjs.com/guides/verify-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:

```js
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](https://whiskerjs.com/docs/getting-started/#4-read-the-result) and [Signals](https://whiskerjs.com/docs/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](https://whiskerjs.com/guides/verify-node/#a-complete-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.

## Related pages

- [Verify in Node](https://whiskerjs.com/guides/verify-node/): The server this page was tested against.
- [All integration guides](https://whiskerjs.com/guides/): The shared contract between page and server.
- [Getting started](https://whiskerjs.com/docs/getting-started/): Build the library from source.
- [How it works](https://whiskerjs.com/docs/how-it-works/): What happens inside collect().
- [Live demo](https://whiskerjs.com/demo/): The same dist files, running in your browser.
