npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cursory-js

v2.1.0

Published

Mouse Trajectory Factory. Generate 100% human-realistic mouse trajectories with timings. A TypeScript port of Vinyzu's cursory.

Readme

Mouse Trajectory Factory. Generate 100% human-realistic mouse trajectories with timings.

A TypeScript port of Cursory by Vinyzu, with the same algorithms, the same recorded-trajectory database, and the same licence. A given seed produces the same trajectory here as it does in Python — see Parity with the Python original.

Ten trajectories at the default 60 Hz. Hollow circles start, filled circles finish, and each dot is one sample — so dots bunch up where the cursor slowed down and spread out mid-flight.


Install

npm install cursory-js

Node 18 or newer. TypeScript types are included.


Usage

import { generateTrajectory } from 'cursory-js';

const { points, timings } = generateTrajectory([120, 80], [940, 560]);

for (const [index, point] of points.entries()) {
  const pause = timings[index] - (timings[index - 1] ?? 0);
  console.log(`wait ${pause}ms, then move to ${point[0]}, ${point[1]}`);
}

points[i] is the position to move to, and timings[i] is how many milliseconds after the start of the movement to be there. Both arrays are the same length, timings starts at 0 and never goes backwards, and the first and last points are exactly the start and end you asked for.

require('cursory-js') works too.

Driving a real cursor

Dispatch each move without awaiting it, and pace the loop yourself:

import { generateTrajectory } from 'cursory-js';

const { points, timings } = generateTrajectory(from, to, { frequency: 60 });
const cdp = await page.context().newCDPSession(page);
const start = performance.now();

for (let i = 0; i < points.length; i += 1) {
  // Not awaited: see below.
  void cdp
    .send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: points[i][0], y: points[i][1] })
    .catch(() => {});

  const next = timings[i + 1];
  if (next !== undefined) {
    await sleep(next - (performance.now() - start));
  }
}

Do not await page.mouse.move() per sample. Awaiting waits for the browser to acknowledge the event, and it acknowledges on its rendering cadence. Measured against headless Chromium 140: a median of 16.67 ms per awaited move, which is exactly one 60 Hz frame. At 60 Hz that is the entire budget between samples, so 44% of moves overrun their deadline and the trajectory plays back stretched — losing the timing that made it look human in the first place. Awaiting raw CDP costs the same 16.6 ms, so "drop to CDP" is not the fix; not awaiting is.

Dispatched without awaiting, the same trajectories play back within 0.05% of their intended duration. The browser coalesces a few events when they arrive faster than it renders, which is what a real mouse does too.

examples/playwright.mjs is a complete, runnable version, and it reports its own drift so you can measure your setup rather than trust these numbers.

Reproducible trajectories

const first = generateTrajectory([0, 0], [500, 300], { seed: 42 });
const second = generateTrajectory([0, 0], [500, 300], { seed: 42 });
// first and second are identical

Without a seed, each call draws fresh OS entropy.


API

generateTrajectory(targetStart, targetEnd, options?)

| Parameter | Type | Description | |---|---|---| | targetStart | [number, number] | Where the cursor starts. | | targetEnd | [number, number] | Where the cursor ends up. |

| Option | Type | Default | Description | |---|---|---|---| | frequency | number | 60 | Samples per second. | | frequencyRandomizer | number | 1 | Largest jitter, in milliseconds, applied to each sample time. | | seed | number \| bigint | — | Seed for a reproducible trajectory. Seeds above Number.MAX_SAFE_INTEGER must be a bigint. | | directness | number | 0.65 | Relative preference for shorter, straighter paths, from 0 to 1. |

Returns { points: [number, number][], timings: number[] }.

Throws a RangeError if frequency is not positive, frequencyRandomizer is negative, or directness is outside 0 to 1. A zero-length movement returns a single point at timings 0 without consuming any randomness.

directness moves the whole distribution rather than filtering it: at 1 the straight, efficient recordings are preferred, at 0 the wandering ones, and either extreme stays reachable at any setting.

Lower-level exports

findTrajectory, findNearestTrajectory, findClosestTrajectory, morphTrajectory, jitterTrajectory, knotTrajectory, generateMiddleBiasedPoint, LOADED_TRAJECTORIES, and defaultGenerator are exported as well — the equivalents of Python's cursory.trajectory_selection. They take a generator from defaultGenerator(seed) where the Python versions take an rng argument.


How it works

  1. Find a close human trajectory. A database of thousands of trajectories recorded from real people, searched for the closest match to the requested movement, with some randomisation.
  2. Morph it. Scaled, rotated and translated onto the requested endpoints exactly, while keeping the shape of the original.
  3. Add noise. Jittered and knotted, so positions vary slightly and the path cannot be recognised by hashing it. The noise fades to nothing at both ends, so the cursor still leaves and arrives the way the recording did.
  4. Regenerate with timings. Resampled at the requested frequency using the recording's own interval pattern, with noise added to the sample times.
  5. Re-add noise. Jittered and knotted again, since resampling smooths the path out.
  6. Morph again. Put back onto the requested endpoints exactly.

[!WARNING] It is theoretically possible to detect trajectories generated by Cursory, but doing so would take an infeasible amount of compute. The greater risk is a bad trajectory, which the algorithms above are what mitigate.


Parity with the Python original

This is a port, not a rewrite, and it is tested against output recorded from Python Cursory 2.1.0 — see test/parity.test.ts and scripts/generate-parity-fixtures.py, which regenerates the fixtures.

Random numbers match bit for bit. Cursory's output is driven entirely by numpy.random.default_rng(seed), so this package reimplements the parts of numpy that reach the result: the PCG64 bit generator, SeedSequence, the ziggurat normal sampler with numpy's own constant tables, Lemire's bounded-integer method and its rejection loop, weighted choice, and pairwise summation. The test suite checks 12 seeds across every distribution, and hashes a 200,000-draw normal stream, against values numpy produced. A seed that drew even one different number would give a different trajectory, not a slightly different one, so this is the part that has to be exact.

Coordinates match to about 1 × 10⁻¹² px. Over 490 randomised movements — 85,526 coordinates — the largest difference in any one of them was 9.09e-13 px, and 84% were identical bit for bit. The residue is exp, atan2, sin and cos rounding differently in V8 than in the C library numpy and CPython call, which is also why the parity test compares coordinates to a tolerance rather than exactly. math.hypot is reproduced exactly, using CPython's own vector_norm, because the distance between the endpoints feeds everything downstream.

Those are measurements, not estimates, and npm test prints its own:

149 trajectories, 23538 coordinates: worst gap 9.09e-13 px, 82.9% bit-identical

The committed fixture holds 149 movements to keep the repository small. python scripts/generate-parity-fixtures.py --cases 500 regenerates it at any size, and the suite re-measures rather than repeating the figure above.

About 0.2% of movements pick a different recording. When two recordings score exactly the same, numpy's ordering comes from an unstable sort whose SIMD kernel is chosen at run time — so Python itself does not always agree with Python across CPUs. This port breaks those ties by index instead, which is deterministic everywhere. In 3,000 sampled queries, 5 disagreed with numpy, and every one of them was an exact tie; there were no disagreements without one. A tie means the recordings were equally good matches, so the result is equally valid, just not the same one.

The fixture generator excludes any movement that hit a tie anywhere — about 2% of them, since each movement runs 21 candidate queries — so the checked-in expectations never depend on the CPU that produced them.

Differences from the Python API

Behaviour is the same; the surface is TypeScript-idiomatic.

| Python | TypeScript | |---|---| | generate_trajectory(start, end, frequency, frequency_randomizer, seed, directness) | generateTrajectory(start, end, { frequency, frequencyRandomizer, seed, directness }) | | returns (points, timings) | returns { points, timings } | | rng keyword argument | a Generator from defaultGenerator(seed), passed positionally | | ValueError | RangeError |


Development

npm install
npm test          # unit, behaviour and parity tests
npm run coverage
npm run build
node examples/plotTrajectories.mjs   # writes examples/trajectories.svg

Regenerating the parity fixtures needs the Python original:

pip install "cursory==2.1.0" "numpy~=2.3"
python scripts/generate-parity-fixtures.py

Copyright and license

Cursory © Vinyzu. This port © Jake Writer. Both under GNU LGPL v3 or later.

All Cursory versions are retroactively dual-licensed under LGPLv3-or-later and GPLv3-or-later. Commercial use is allowed, but the source, licence and copyright have to be made available. No liability or warranty is provided.

NOTICE credits the recorded-trajectory data and the numpy and CPython algorithms this port reimplements.

Thanks to

Vinyzu, for Cursory. Pointergeist, for helping Vinyzu understand mouse trajectories better. sameelarif, for Scribe. Margit Antal, Norbert Fejer and Krisztian Buza, for SapiMouse. MIMIC-LOGICS, for Mouse-Synthesizer.


Disclaimer

This repository is provided for educational purposes only.

No warranties are provided regarding accuracy, completeness, or suitability for any purpose. Use at your own risk — the authors and maintainers assume no liability for any damages, legal issues, or warranty breaches resulting from use, modification, or distribution of this code.

Any misuse or legal violations are the sole responsibility of the user.