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

Published

52 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

52 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, where 100 means nothing contradicted anything else
verdict.label;   // 'Trustworthy' | 'Suspicious' | 'Likely spoofed / bot'
verdict.checks;  // [{ id, title, status: 'ok'|'warn'|'bad'|'info', detail, weight }]

The two halves

44 checks need only the browser. 8 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', 'tz', 'lang', 'webrtc-ip', 'http-proto', 'tls-ver', 'cf-bot']

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

JS-layer checks

| id | what it catches | max −pts | |---|---|---| | webdriver | navigator.webdriver is set | 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 | | 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-lie | identical canvas draws read back differently, or OffscreenCanvas disagrees | 18 | | webgl-lie | the same WebGL scene rendered twice reads back different pixels | 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.permissionpermissions.query() state | 15 | | tz-offset | Intl timezone implies a different offset than getTimezoneOffset() | 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 | | langs-empty | navigator.languages is empty | 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: the window reports no browser UI | 10 | | 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 | | audio-params | DynamicsCompressor factory defaults are off spec | 8 | | heap-os | jsHeapSizeLimit is the value another platform's V8 build reports | 8 | | voice-locale | speech-synthesis voice language ≠ locale (leaks host OS language) | 8 | | touch-pointer | maxTouchPoints contradicts (any-pointer: coarse) | 8 | | codecs | claims Chrome but cannot play H.264 (a plain Chromium build) | 6 | | 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 ≠ 24 | 6 | | lang-base | navigator.languages lacks a bare base tag (en) | 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 |

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-Platformnavigator.platform | 15 | | tz | IP-derived timezone ≠ browser timezone | 12 | | webrtc-ip | WebRTC-exposed public IP ≠ the IP the connection came from | 10 | | lang | Accept-Languagenavigator.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. Labels: ≥ 85 Trustworthy, ≥ 60 Suspicious, below that Likely spoofed / bot.

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