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

@liarjs/checks

v0.4.2

Published

66 browser-fingerprint consistency rules as pure functions: score a JS fingerprint against itself and against what the TLS/HTTP layer saw. Bot and anti-detect browser detection, no dependencies.

Readme

@liarjs/checks

69 browser-fingerprint consistency rules as pure functions. Give it a fingerprint (and, optionally, what the edge saw on the wire) and get back a 0-100 score plus every check that fired. No network, no state, no dependencies beyond the types.

npm i @liarjs/checks
import { collect } from '@liarjs/collect';
import { computeVerdict, emptyServerData } from '@liarjs/checks';

const client = await collect();

// JS layer only
const offline = computeVerdict(client);

// cross-layer: pass what the edge observed for the same request
const server = { ...emptyServerData(), ...(await fetch('/api/net.json').then((r) => r.json())) };
const verdict = computeVerdict(client, server);

verdict.score;   // 0..100; a provisional score caps at 92 (see the driver-unverified item)
verdict.label;   // "You didn't fool me" | 'Half a face' | 'Caught you' | 'Unverified \u00b7 driver unchecked'
verdict.stage;   // 'provisional' until a driver verdict is applied
verdict.driver;  // 'human' | 'synthetic' | 'no-signal' | null
verdict.axes;    // { fingerprint, network, automation } deductions; the three sum to 100 - score
verdict.checks;  // [{ id, title, status: 'ok'|'warn'|'bad'|'info', detail, weight, axis }]

The two halves

60 checks need only the browser. 9 compare the JS story against the network layer and are listed in EDGE_ONLY_CHECKS:

import { EDGE_ONLY_CHECKS } from '@liarjs/checks';
// ['ua-http-js', 'platform', 'uach-ua-string', 'tz', 'lang', 'webrtc-ip', 'http-proto', 'tls-ver', 'cf-bot']

Call computeVerdict(client) without a server argument and those nine are skipped, not failed, so you get an honest JS-layer score instead of nine false positives.

Two of the 60 never deduct: webgl-param-timing and math-libm are recorded as info because what they measure is worth keeping in the payload but does not convict on its own.

JS-layer checks

| id | what it catches | max −pts | |---|---|---| | webdriver | navigator.webdriver is set | 40 | | auto-residue | Selenium / chromedriver / Puppeteer artefacts left on the page | 40 | | native-integrity | core APIs are not genuine [native code]: the JS-mask vs engine-patch line | 35 | | headless-ua | HeadlessChrome token in the UA | 30 | | webdriver-missing | claims Chrome 89+ but navigator.webdriver does not exist: delete was run on it | 30 | | api-formfactor | the exposed Web API set is a desktop build's under a mobile claim, or the reverse | 22 | | gpu-triad | WebGL GPU ↔ WebGPU adapter.info identity | 22 | | worker-consistency | a Web Worker reports different UA / languages / hardware / timezone / GPU / canvas | 20 | | font-metrics | the claimed OS's own font families have no metrics: named, but no font file behind the name | 20 | | canvas-roundtrip | toDataURL and getImageData disagree pixel for pixel about one canvas: a noiser hooked the encode path and not the readback | 20 | | webgl-readback | the same idea one layer down: readPixels and the encode path disagree about one frame | 20 | | webgpu-arch | GPUAdapter.info.architecture names a different GPU family than the WebGL renderer string | 20 | | canvas-lie | identical canvas draws read back differently, or OffscreenCanvas disagrees | 18 | | webgl-lie | the same WebGL scene rendered twice reads back different pixels | 18 | | audio-lie | two identical OfflineAudioContext renders produce different samples | 18 | | font-metric-clone | a proprietary family and its free metric clone measure identically, so one file answers to both names | 18 | | gl-ext-formfactor | mobile-only compressed-texture extensions under a desktop renderer string | 16 | | font-generic | sans-serif, serif and monospace collapse onto one width | 16 | | window-screen | the window is larger than the screen it says it is on | 15 | | webgl-pair | WebGL and WebGL2 name different GPUs on one machine | 15 | | uach-ver | UA-CH fullVersionList ≠ the version in the UA string | 15 | | plugins-ver | plugin/mimeType face doesn't match the claimed Chrome version | 15 | | perm-notif | Notification.permission ≠ permissions.query() state | 15 | | tz-offset | Intl timezone implies a different offset than getTimezoneOffset() | 15 | | cpu-perf-tier | navigator.cpuPerformance on a Chrome that never shipped it, or a tier the core count contradicts | 15 | | cpu-arch | the sign of a computed NaN names an arm or x86 FPU other than the architecture UA-CH (or a phone UA) claims | 15 | | os-fonts | installed fonts describe a different OS than the UA claims | 14 | | api-mobile-missing | claims Android but exposes none of Android's own interfaces | 14 | | webgpu-features | WebGPU offers ASTC/ETC2 while the GPU is presented as desktop | 12 | | webrtc-candidates | ICE gathering finished with zero candidates, not even an mDNS host | 12 | | ua-mobile | mobile hints contradict the UA or maxTouchPoints | 12 | | domrect-lie | getBoundingClientRect unstable across reads | 12 | | chrome-object | the UA claims Chrome but window.chrome is missing | 12 | | window-noise | outer is inner plus a few pixels of noise: a UI height no real title bar has | 12 | | math-pow | Math.pow(1e-6, 1e308) does not underflow to 0: the engine's math library was replaced | 12 | | langs-empty | navigator.languages is empty | 10 | | headless-devices | enumerateDevices() returned nothing at all, not even an audio output | 10 | | gpu-age | GPU too old to be real for a current Chrome (MAX_TEXTURE_SIZE) | 10 | | webgpu-empty | WebGPU returned an adapter but adapter.info is blank | 10 | | headless-viewport | outerHeight === innerHeight in both directions: the window reports no browser UI at all (fullscreen, kiosk, old headless) | 15 | | headless-gpu | desktop Chrome rendering WebGL through a software rasteriser | 15 | | font-methods | the measureText and layout font paths disagree | 10 | | screen-extended | screen.isExtended is true on a device presenting as a phone | 10 | | api-notification | new Notification() constructs on an Android claim, where it must throw | 10 | | codecs | claims Chrome but cannot play H.264 (a plain Chromium build) | 10 | | audio-params | DynamicsCompressor factory defaults are off spec | 8 | | voice-locale | speech-synthesis voice language ≠ locale (leaks host OS language) | 8 | | heap-platform | the performance.memory heap ceiling is not the one the claimed OS reports | 8 | | touch-pointer | maxTouchPoints contradicts (any-pointer: coarse) | 8 | | arm-windows-gpu | Windows on an arm CPU with an Intel or AMD GPU, which no Windows on ARM device carries | 8 | | browser-age | Chrome several majors behind stable: a pinned build, not an updating user | 6 | | cjk-fonts | CJK fonts installed on a non-CJK locale (leaks host region) | 6 | | colordepth | screen.colorDepth is neither 24 (8 bpc) nor 30 (10-bit HDR) | 6 | | lang-base | Chromium only: a region-qualified tag whose bare primary subtag is missing (zh-CN without zh) | 6 | | tz-dst | the reported January/July offsets don't match the zone's DST rule | 6 | | storage-quota | StorageManager quota below 1 GB | 4 | | webrtc-mdns | host ICE candidates expose raw local addresses instead of .local | 4 | | conn-rtt | navigator.connection.rtt === 0 | 3 | | webgl-param-timing | how long an unmasked getParameter takes against a control read - recorded, not charged | 0 | | math-libm | which host math library the transcendental functions came from: glibc, libsystem_m or UCRT - recorded, not charged | 0 |

Cross-layer checks

| id | what it catches | max −pts | |---|---|---| | ua-http-js | User-Agent header ≠ navigator.userAgent | 25 | | cf-bot | the edge already classifies you as a known bot | 25 | | platform | Sec-CH-UA-Platform ≠ navigator.platform | 15 | | uach-ua-string | Sec-CH-UA-Platform ≠ the OS token in the UA string | 15 | | tz | IP-derived timezone ≠ browser timezone | 12 | | webrtc-ip | WebRTC-exposed public IP ≠ the IP the connection came from | 10 | | lang | Accept-Language ≠ navigator.languages[0] | 8 | | http-proto | modern Chrome that negotiated HTTP/1.1 | 6 | | tls-ver | modern Chrome that negotiated TLS < 1.3 | 6 |

Scoring

The score is 100 − the sum of the weights of the checks whose status is warn or bad; info and ok cost nothing.

The label is not a function of the score alone. computeVerdict always returns stage: 'provisional' and the label Unverified · driver unchecked, because a coherent environment is not proof that a person is driving the browser. Feed it a behavioural verdict with applyDriverVerdict(verdict, 'human' | 'synthetic' | 'no-signal') and the label resolves: human unlocks ≥ 85 You didn't fool me and ≥ 60 Half a face, synthetic is always Caught you (and caps the score at 39), no-signal costs nothing beyond that and stays provisional.

computeVerdict also always appends one pending item - driver-unverified, a warn worth 8 points on the automation axis - so a provisional score maxes out at 92 and a spotless browser cannot look finished before its driver is verified. applyDriverVerdict drops it and returns the 8 points for human, drops it before the 45-point deduction for synthetic, and leaves it in place for no-signal. Since this package ships no challenge, every score it produces on its own is capped at 92 - that is the honest number, not a missing feature. Producing that verdict needs a pointer-behaviour challenge, which this package does not ship.

Weights are deliberately blunt. A JS-level override of a native function (native-integrity, −35) is a categorically bigger tell than a 4-point storage-quota oddity, and the ordering matters more than the exact arithmetic.

Where ServerData comes from

Anything that can see the request before the browser does. On Cloudflare Workers that is request.cf plus headers: IP, ASN, colo, IP-derived timezone, HTTP protocol, TLS version/cipher, ClientHello length and extension/cipher hashes, Sec-CH-UA-*, Accept-Language, verified-bot category. The reference implementation is a ~60-line Worker in the repo; the hosted one is https://liarjs.dev/api/net.json.

Scoring server-side

The rules are pure and have no DOM dependency, so they run just as well on samples your own site collected with @liarjs/collect in send mode:

app.post('/fp', (req, res) => {
  const verdict = computeVerdict(req.body, { ...emptyServerData(), ip: req.ip, ipTimezone: geo(req.ip) });
  store(req.body.meta.fpHash, verdict.score, verdict.checks.filter((c) => c.status === 'bad'));
  res.sendStatus(200);
});

MIT © liarjs.dev