A Svelte sign-in form that, on submit, gets a one-time code from your server, lets WhiskerJS read the browser and posts the result with the email and password. WhiskerJS is created the first time someone submits and only 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-svelte-app
npm install ../whiskerjs/whiskerjs-0.1.0.tgz
The component
src/LoginForm.svelte:
<script lang="ts">
import { load, type Agent } from 'whiskerjs';
let email = $state('');
let password = $state('');
let status = $state('');
let busy = $state(false);
// One agent per page, created on first use. load() is cheap and keeps no state.
let agent: Promise<Agent> | undefined;
async function onsubmit(event: SubmitEvent) {
event.preventDefault();
busy = 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, password, device }),
});
status = response.ok ? 'Signed in' : `Refused (${response.status})`;
} catch {
status = 'Network error, please try again';
} finally {
busy = false;
}
}
</script>
<form {onsubmit}>
<label>Email <input bind:value={email} name="email" type="email" required /></label>
<label>Password <input bind:value={password} name="password" type="password" required /></label>
<button type="submit" disabled={busy}>Sign in</button>
<output id="status">{status}</output>
</form>
Mounted in src/main.ts:
import { mount } from 'svelte';
import LoginForm from './LoginForm.svelte';
mount(LoginForm, { target: document.getElementById('app')! });
How it works
onsubmitis the Svelte 5 event attribute;event.preventDefault()stops the browser’s own form post so the handler can add the device result.- Runes.
$stateholds the form fields, the status line and thebusyflag. The agent is a plain variable, created by the first submit (agent ??= load()). - The binding. The nonce endpoint returns
{ nonce, timestamp }, passed tocollect({ binding }). The result’sbindingcarries both and a digest over the whole result, which your server checks: Node, Go, PHP, Python. - Errors.
collect()never throws or rejects. Thetryblock covers thefetchcalls.
Things to know
- SvelteKit and server rendering. Component script code runs on the server during rendering; event handlers do not. Keep WhiskerJS calls in handlers, or in
onMountif you need to collect without a user action. - Secure context. https, or
localhostwith the Vite dev server. - Send the result unchanged if the server verifies the binding.
Tested
Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2), Svelte 5.57.2, Vite 8.3.5 with @sveltejs/vite-plugin-svelte 7.3.1, and svelte-check 4.7.7 on 2026-10-26: built with svelte-check && 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
Does this work in SvelteKit?
The component does: load() and collect() are only called from the submit handler, which runs in the browser. SvelteKit server endpoints can then host the nonce and verification routes. This guide was tested with Svelte and Vite only.
Why is the agent not $state?
Nothing in the markup depends on it. A plain variable is enough to keep the one agent for the life of the component.
Can I use a form action instead of fetch?
You can, as long as the device result reaches the server unchanged. A hidden input holding JSON.stringify(result) works; this guide shows fetch because that was tested.