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

v0.4.2

Published

Score a browser against itself: 66 checks over 21 fingerprint probes, cross-checked against what the TLS/HTTP layer actually saw. CLI + library for Playwright, Puppeteer and any browser with a debug port.

Readme

liarjs

Score a browser against itself. A browser controls its own JavaScript. It does not control the network it connects over. liarjs collects the JS fingerprint inside the browser, collects the TLS/HTTP/ASN view from the edge that served the request, and reports every place the two stories disagree.

npx liarjs                                 # your local Chrome
npx liarjs --headless --min-score 60       # CI gate
npx liarjs --cdp http://127.0.0.1:9222     # any browser with a debug port

Hosted version, no install: liarjs.dev.


Why

Fingerprint test pages tell you what you look like. They don't tell you whether you're coherent. A profile can present a pristine Chrome 150 in JavaScript and still negotiate a handshake no Chrome would send, from an IP whose timezone is twelve hours off. Patch one layer, the other gives you up.

liarjs is that second question, as a command you can run in CI:

  • 69 checks, 60 of which need nothing but the browser (--offline), 9 of which compare the JS story against what the wire actually carried.
  • 21 probes behind them: canvas, WebGL + WebGL2, WebGPU, audio, 194 fonts over three detection paths plus a fourth that measures widths instead of names, WebRTC, a cross-thread Web Worker comparison, [native code] verification of 27 APIs, and a census of which interfaces the build was compiled with.
  • Attaches to anything speaking CDP. Your Chrome, a Chromium fork you patched, a commercial anti-detect browser's debug port, a Playwright/Puppeteer page you already have.
  • Zero runtime dependencies. No puppeteer, no playwright, no ws, just Node ≥ 22 and its built-in WebSocket.
  • Machine-readable. --json to export, --post to ship results to your own backend, diff so a fingerprint regression shows up in a pull request instead of in your ban rate.

Example

  liarjs · browser consistency scan
  Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/…

   20 / 100  Unverified · driver unchecked
  fingerprint −20  ·  network −12  ·  automation −48

  ✗ navigator.webdriver −40
    webdriver=true, the automation flag is set.
    id: webdriver

  ✗ Worker ↔ main-thread consistency −20
    A Web Worker reported different values than the main thread for userAgent, canvasHash.
    Detectors re-read identity inside workers precisely because partial overrides only patch
    the main thread.
    id: worker-consistency

  ! IP timezone ↔ browser timezone −12
    IP resolves to America/Los_Angeles but the browser reports Asia/Shanghai (proxy/VPN or
    spoofed timezone).
    id: tz

  ! Driver challenge −8
    Not taken yet. Everything else here describes the machine; none of it proves a hand is on
    the pointer. The behavioural challenge on liarjs.dev answers that directly - passing
    it returns these 8 points and is the only route to the top tier. Taking it and moving too
    little costs nothing: the score just stays provisional.
    id: driver-unverified

  23 checks · 2 critical · 2 warnings · 18 clean

  The behavioural layer (is a hand driving this browser?) runs only on liarjs.dev.
  That is why this score is provisional: it says the environment is coherent, not that a person is here.
  edge: 203.0.113.7 · AS4058 CITIC Telecom International CPC · LAS · HTTP/2 · TLSv1.3

Only the failures print by default; add --all for the full list.

What it checks

The score starts at 100 and each failing check deducts its weight, split across three axes (fingerprint, network, automation). The rating word needs one more thing: the top label You didn't fool me is only reachable after a behavioural challenge says a hand is on the pointer, and that challenge runs on liarjs.dev, not here. A CLI run therefore always prints Unverified · driver unchecked, however clean the browser is.

The same reason costs points, not just the word: every scan carries one pending item, driver-unverified, a warn worth 8 on the automation axis. It is not evidence of anything - it is the question this package cannot ask. So a spotless browser scores 92 here, never 100, and --min-score 92 is the strictest gate that can pass. Only the challenge on liarjs.dev returns those 8 points.

JS layer (runs anywhere, including --offline)

| id | check | 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] (JS-level spoofing) | 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 | | font-metrics | the claimed OS's own font families have no metrics: named, but no font file behind the name | 20 | | font-generic | sans-serif, serif and monospace collapse onto one width | 16 | | gl-ext-formfactor | mobile-only compressed-texture extensions under a desktop renderer string | 16 | | gpu-triad | WebGL GPU ↔ WebGPU adapter.info identity | 22 | | worker-consistency | a Web Worker reports different identity than the main thread | 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 | | 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 | | os-fonts | installed fonts describe a different OS than the UA claims | 14 | | 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 | | langs-empty | navigator.languages is empty | 10 | | headless-devices | enumerateDevices() returned nothing at all, not even an audio output | 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 | | 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 | | window-screen | the window is larger than the screen it says it is on | 15 | | 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 | | font-methods | the measureText and layout font-detection paths disagree | 10 | | audio-params | DynamicsCompressor factory defaults are off spec | 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 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 |

Cross-layer (needs the network endpoint)

| id | check | 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 | | 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 |

With --offline these eight are skipped rather than failed, and the report says so explicitly.

Options

--cdp <endpoint>     attach to a running browser (http://127.0.0.1:9222 or ws://…)
--page <url>         run the probes on this page instead of about:blank
--endpoint <url>     network-layer endpoint (default https://liarjs.dev/api/net.json)
--offline            JS-layer checks only, no outbound request
--headless           launch headless (headless tells will be reported)
--json [file]        export the full result as JSON (stdout when no file given)
--post <url>         send the full result to your own backend (POST, JSON body)
--token <token>      auth token for --post, sent as X-Liarjs-Token
--min-score <n>      exit 1 when the score is below n
--all                also print the checks that passed
--timeout <ms>       per-command timeout, default 30000
--keep-open          leave the tab open after scanning

Launching uses the first Chrome/Chromium/Edge it finds; override with LIARJS_CHROME=/path/to/binary. Extra browser flags come from LIARJS_CHROME_ARGS; containers and CI generally need LIARJS_CHROME_ARGS="--no-sandbox --disable-dev-shm-usage".

Export mode and send mode

--json writes the result where you point it; --post ships the same object to an endpoint you own. They compose:

npx liarjs --json scan.json --post https://api.example.com/scans --token "$LIARJS_TOKEN"

The posted body is the full ScanResult: score, label, every check, the raw fingerprint and the raw edge view.

Run the probes on a real page

about:blank is not a secure context, so UA-CH, StorageManager and most Permissions names are unavailable there no matter which browser you point at. The report says so when it happens. For the complete picture, and whenever your endpoint has no CORS headers:

npx liarjs --page https://example.com

Point it at a browser you didn't launch

Anything that exposes a CDP endpoint works, including most anti-detect browsers, which expose a local debug port per profile:

# a profile started by an anti-detect browser's local API
npx liarjs --cdp http://127.0.0.1:54321

# a stealth-patched Chromium you built yourself
./chrome --remote-debugging-port=9222 &
npx liarjs --cdp http://127.0.0.1:9222

Use it as a library

Works with any object exposing evaluate(expression: string). Playwright and Puppeteer Pages both qualify:

import { checkPage } from 'liarjs';

const result = await checkPage(page);
expect(result.score).toBeGreaterThanOrEqual(85);

const critical = result.checks.filter((c) => c.status === 'bad');
expect(critical, JSON.stringify(critical, null, 2)).toHaveLength(0);

Or drive the browser yourself:

import { scan, postResult } from 'liarjs';

const result = await scan({ cdp: 'http://127.0.0.1:9222', endpoint: 'https://my-worker.example/api/net.json' });
console.log(result.score, result.checks.filter((c) => c.status !== 'ok').map((c) => c.id));
await postResult('https://api.example.com/scans', result, { token: process.env.LIARJS_TOKEN });

ScanResult is { score, label, checks[], client, server, meta }, where client is the raw fingerprint, server the raw edge view and meta.schema the payload version (currently 4).

In CI

- run: npx liarjs --headless --json scan.json --min-score 60
- run: npx liarjs diff baseline.json scan.json     # what changed since the last known-good run

diff prints only the checks whose status moved, so a rebase that silently breaks WebGPU↔WebGL alignment is one line in the log instead of a mystery ban three weeks later.

Ignore checks that can't hold in your environment (datacenter IPs will always trip tz) by filtering result.checks in the library API, or by comparing against a baseline instead of an absolute floor.

The network half, and your data

--offline makes no outbound request at all.

Otherwise the browser under test fetches https://liarjs.dev/api/net.json, a normal request answered with what Cloudflare saw about that request (IP, ASN, colo, HTTP protocol, TLS version and ClientHello shape, headers). liarjs.dev does not store scans. If you'd rather not talk to it, point --endpoint at your own deployment: the endpoint is a ~60-line Cloudflare Worker, and the site that hosts it is open in the repo.

Packages

| package | what it is | |---|---| | liarjs | this CLI + the driver/library API | | @liarjs/collect | the 19 browser-side probes, standalone, injectable, or embeddable on your own site | | @liarjs/checks | the 69 consistency rules as pure functions |

The hosted scan at liarjs.dev runs the exact same packages, so a score there and a score here mean the same thing.

Limitations

  • A score is not a ban prediction. It measures internal coherence. Real sites also weigh proxy reputation, account age and behaviour, none of which this can see.
  • Cloudflare does not expose a raw JA3/JA4 on non-Enterprise plans, so the TLS checks use ClientHello length + extension/cipher hashes, not a full fingerprint string.
  • Checks drift with Chrome. Plugin faces, UA-CH shapes and GPU expectations change; rules are versioned with the package and reviewed per Chrome major.
  • Headless is detected on purpose. --headless will lose points on a stock browser. That's the correct answer, not a bug.

Positioning

This is a measurement and diagnostics tool: understand what a modern detector can see, and check your own footprint. It doesn't ship evasions, profiles or proxies.

MIT © liarjs.dev. Field notes on how each of these checks works: liarjs.dev/blog