This is a normal Vue sign-in form. When it is submitted it fetches a one-time code from your server, lets WhiskerJS read the browser, and posts the result together with the email and password. WhiskerJS is created the first time it is needed and only ever runs in the browser.
Install: build from source
WhiskerJS is not on npm yet; the npm package is coming. Build it and install the tarball:
cd whiskerjs
pnpm install && pnpm build && pnpm pack # writes whiskerjs-0.1.0.tgz
cd ../my-vue-app
npm install ../whiskerjs/whiskerjs-0.1.0.tgz
The component
src/LoginForm.vue:
<script setup lang="ts">
import { ref } from 'vue';
import { load, type Agent } from 'whiskerjs';
const email = ref('');
const password = ref('');
const status = ref('');
const busy = ref(false);
// One agent per page, created on first use. load() is cheap and keeps no state.
let agent: Promise<Agent> | undefined;
async function onSubmit() {
busy.value = true;
try {
// A fresh nonce from your server, then collect. collect() never throws.
const binding = await (await fetch('/api/whisker-nonce')).json();
const device = await (await (agent ??= load())).collect({ timeoutMs: 3000, binding });
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value, device }),
});
status.value = response.ok ? 'Signed in' : `Refused (${response.status})`;
} catch {
status.value = 'Network error, please try again';
} finally {
busy.value = false;
}
}
</script>
<template>
<form @submit.prevent="onSubmit">
<label>Email <input v-model="email" name="email" type="email" required /></label>
<label>Password <input v-model="password" name="password" type="password" required /></label>
<button type="submit" :disabled="busy">Sign in</button>
<output id="status">{{ status }}</output>
</form>
</template>
Mounted as the app’s root component in src/main.ts:
import { createApp } from 'vue';
import LoginForm from './LoginForm.vue';
createApp(LoginForm).mount('#app');
How it works
@submit.preventstops the browser’s own form post, so the handler can add the device result first.- The agent is created by the first submit (
agent ??= load()) and reused after that.load()is cheap and keeps no state betweencollect()calls. - The binding.
/api/whisker-noncereturns{ nonce, timestamp };collect({ binding })echoes both inresult.bindingand adds a digest over the whole result. Your server checks it: Node, Go, PHP, Python. - Errors.
collect()never throws or rejects; thetryblock covers your twofetchcalls. busydisables the button while a submit is in flight, so a double click does not use two nonces.
Things to know
- Server rendering (Nuxt and similar). The component’s
script setupruns on the server when the page is rendered there. Calling WhiskerJS only from the submit handler (or fromonMounted) keeps it in the browser. - Secure context. Use https, or
localhostwith the Vite dev server. Without Web Crypto the ids arenull. - Send the result unchanged when the server verifies the binding: the digest covers every field.
Tested
Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2), Vue 3.5.44, Vite 8.3.5 with @vitejs/plugin-vue 6.0.10, and vue-tsc 3.3.13 on 2026-10-26: built with vue-tsc --noEmit && vite build, served by the Node server and submitted in headless Chromium 140 (Playwright 1.55.0). The server accepted the binding and refused the same result sent a second time.
Questions and answers
Should the agent be reactive state?
No. The agent is a plain object with one method; keeping it in a ref adds nothing. A plain variable in script setup holds it for the life of the component.
What about Nuxt?
Nuxt renders components on the server first. Keep load() and collect() inside event handlers or onMounted, which only run in the browser, as this component does. The component itself was tested with Vue and Vite only.
Do I need a plugin or a global property?
No. Import load where you need it. There is nothing to install into the Vue app.