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

luchy

v0.2.0

Published

Luchy tracking - browser script + server-side tracker

Readme

Luchy Tracker

A lightweight, privacy-focused analytics tracker for web applications. Built for CDN deployment with automatic compression and versioning.

Features

  • 🚀 Lightweight: ~3KB minified, ~1.5KB compressed
  • 📊 Privacy-focused: No cookies, no personal data collection
  • 🔄 Auto-tracking: Pageviews, outbound links, hash routing
  • 📦 CDN-ready: Optimized for global distribution
  • 🗜️ Compressed: Gzip and Brotli compression included
  • 🏷️ Versioned: Commit-based versioning for safe deployments

Quick Start

Installation

npm install luchy

Basic Usage

<script
  src="https://cdn.luchy.app/luchy.min.js"
  data-api-key="your-api-key"
  data-auto-pageviews
></script>

Advanced Usage

<script
  src="https://cdn.luchy.app/luchy.min.js"
  data-api-key="your-api-key"
  data-endpoint="https://api.luchy.app/ingest"
  data-auto-pageviews
  data-auto-outbound
  data-hash-routing
  data-track-localhost
></script>

Data Attributes

| Attribute | Description | Required | | ---------------------- | --------------------------------------- | -------- | | data-api-key | Your Luchy API key | ✅ Yes | | data-endpoint | Custom API endpoint | ❌ No | | data-auto-pageviews | Enable automatic pageview tracking | ❌ No | | data-auto-outbound | Enable automatic outbound link tracking | ❌ No | | data-auto-events | Enable automatic data attribute event tracking | ❌ No | | data-hash-routing | Enable hash routing support | ❌ No | | data-track-localhost | Track localhost traffic | ❌ No |

Data Attribute Events

Track custom events without JavaScript by adding data-luchy-event to any HTML element. Clicks are detected automatically via event delegation.

<!-- Simple event -->
<button data-luchy-event="cta-click">Sign Up</button>

<!-- With a payload (data-luchy-payload-*) -->
<a data-luchy-event="post-click" data-luchy-payload-slug="hello-world" href="/blog/hello-world">
  Read Post
</a>

<!-- Multiple keys, dashes convert to underscores -->
<!-- data-luchy-payload-plan-tier becomes { plan_tier: "enterprise" } -->
<button
  data-luchy-event="purchase"
  data-luchy-payload-plan-tier="enterprise"
  data-luchy-payload-source="pricing-page"
>
  Buy Now
</button>

data-luchy-prop-* is the original spelling and keeps working — it is written into pages we do not control, so it is never going away. New markup should use data-luchy-payload-*, which matches the field name on the wire and in the dashboard.

When an element has data-luchy-event, it takes priority over outbound link tracking to avoid duplicate events. Disable with data-auto-events="false" on the script tag.

Manual Tracking

// Track a pageview
window.luchy.trackPageview();

// Track a custom event. The second argument is the event payload —
// it is sent as `payload` and shows up as the event's properties.
window.luchy.trackEvent('Button Click', {
  button: 'signup',
  page: 'home'
});

// Enable auto-tracking features
window.luchy.enableAutoPageviews();
window.luchy.enableAutoOutboundTracking();
window.luchy.enableHashRouting();

Server-Side Tracking

The browser script can only see what happens in a page. A server sees what the application actually did — an order placed, a payment recorded, a webhook processed — and those are usually the events worth having.

luchy/server carries no browser globals, so it runs in Node, Bun, Deno and Cloudflare Workers:

import { createServerTracker } from 'luchy/server';

const luchy = createServerTracker({
  apiKey: process.env.LUCHY_API_KEY!,
  // optional: point at a self-hosted dashboard
  endpoint: 'https://dash.luchy.app/api/ingest',
  onError: (error) => logger.warn('[luchy] event dropped', error)
});

await luchy.trackEvent({
  name: 'order:placed',
  pathname: '/checkout',
  type: 'server',
  payload: { plan: 'pro', amount: 29 }
});

Nothing here ever rejects — analytics must not break the request it is describing. Pass onError if you want failures in your logs.

Fire it after the response so it costs no latency. On Cloudflare Workers:

ctx.waitUntil(luchy.trackEvent({ name: 'order:placed', pathname: '/checkout' }));

API client (luchy/api)

createServerTracker covers the two calls most apps make. luchy/api is the whole API: a typed client generated from the dashboard's own OpenAPI document, with tracking and reading on it. It has no DOM globals and no node built-ins, so the same import works on a server and in a browser.

import { createLuchyClient } from 'luchy/api';

const luchy = createLuchyClient({
  apiKey: LUCHY_API_KEY,
  // optional: point at a self-hosted dashboard's API root
  endpoint: 'https://dash.luchy.app/api',
  onError: (error) => console.warn('[luchy] event dropped', error)
});

// Tracking never rejects, on either side of the wire.
await luchy.trackEvent({
  name: 'order:placed',
  pathname: '/checkout',
  payload: { plan: 'pro', amount: 29 }
});

// Reads do — a chart with silently missing numbers is worse than an error.
const { results } = await luchy.query({
  date_range: '30d',
  metrics: ['pageviews', 'visitors'],
  dimensions: ['event:page']
});

Failed reads throw LuchyApiError, which carries the HTTP status and the error body the API sent. For anything without a convenience method, luchy.api is the underlying openapi-fetch client, typed against every documented endpoint:

const { data, error } = await luchy.api.GET('/health');

The request and response types come from the document too, so they are worth importing rather than restating: EventInput, PageviewInput, IngestSuccess, QueryRequest, QueryResponse, plus the raw paths and components.

Where the types come from

apps/dash zod route schemas
  → bun run openapi:emit   (in apps/dash)
  → packages/tracker/openapi.json
  → bun run generate       (in packages/tracker)
  → src/api/schema.d.ts

openapi.json is checked in: it is the wire contract, so a change to the API's shape shows up as a reviewable diff in the commit that caused it, and the client can be regenerated without a dashboard running anywhere. schema.d.ts is generated too — never edit either by hand. Change the zod schemas in apps/dash, re-run both steps, commit the result.

React Router on Cloudflare (luchy/react-router)

A React Router app already tells you what it did — in the request. Every console mutation is a form POST whose intent field names it (rotate, invite-member, create-api-key, …), every JSON API mutation is discriminated by its method, and every auth verb has a path that names the operation. So the event does not need to be emitted by hand from a route module, where it is only tracked once somebody remembers to instrument it: the Worker derives it from the request it is already holding. One hook, zero per-route code, and a new intent is tracked the day it is written.

Two lines in the Worker's fetch:

import { createRequestTracker } from 'luchy/react-router';

const tracker = createRequestTracker({
  apiKey: LUCHY_API_KEY,
  enabled: env.APP_ENV === 'production'
});

export default {
  async fetch(request, env, ctx) {
    const finish = tracker.begin(request);
    const response = await requestHandler(request, loadContext);
    finish(response, ctx);
    return response;
  }
} satisfies ExportedHandler<Env>;

begin has to run before React Router gets the request: it is what clones the body, and once the handler has read it, it is gone. finish schedules everything else on ctx.waitUntil, so nothing about analytics is on the response's critical path, and nothing it does can throw into your handler.

Events come out named route:intentteam/:id:invite-member, applications:create — with ids collapsed to :id, React Router's single-fetch .data suffix stripped (so a hydrated and a non-hydrated submission report the same name), and / mapped to home. The payload always carries status.

| Option | Default | What it does | | --- | --- | --- | | apiKey | — | Luchy API key. The public ingest key is fine. | | endpoint | hosted API | API root, without a trailing slash. | | enabled | true | When false, everything is a no-op — no clone, no network. | | ignorePrefixes | ['/__manifest'] | Raw-pathname prefixes to drop. Yours are added to the default, not swapped for it. | | ignoreRouteSuffixes | [] | Normalized-route suffixes to drop, e.g. /user-keys/validate. | | ignoreEvents | [] | Fully-formed event names to drop, e.g. notifications:markRead. | | trackFailures | false | When true, 4xx/5xx are tracked too (tell them apart by status). | | methodSuffix | false | When true, an intent-less mutation is route:post / route:delete instead of bare route. | | payload | — | (request, response) => payload, merged over status. Runs inside waitUntil; a rejection costs the payload, not the event. | | onError | — | The only way to see failures. |

The pieces are exported on their own too — isMutatingMethod, carriesIntent, normalizeRoute, serverEventName — if you want the naming without the hook.

Development

Build Scripts

# Build the CDN bundle *and* the importable package entry points
npm run build

# Only the package entry points (dist/index.js, dist/server.js + types)
npm run build:lib

# Upload to R2 (requires Wrangler setup)
npm run upload

# Build and deploy everything
npm run deploy

File Sizes

  • Minified: 3.0 KB
  • Gzipped: 1.7 KB (43% smaller)
  • Brotli: 1.5 KB (50% smaller)

Generated Files

dist/script/
├── luchy.js          (unminified)
├── luchy.min.js      (minified)
├── luchy.js.gz       (gzipped)
├── luchy.min.js.gz   (gzipped)
├── luchy.js.br       (brotli)
└── luchy.min.js.br   (brotli)

CDN Deployment

R2 Upload Structure

The deploy script uploads files to two locations:

Root Level:

bucket/
├── luchy.js
├── luchy.min.js
├── luchy.js.gz
├── luchy.min.js.gz
├── luchy.js.br
└── luchy.min.js.br

Versioned Level:

bucket/v/{commit-hash}/
├── luchy.js
├── luchy.min.js
├── luchy.js.gz
├── luchy.min.js.gz
├── luchy.js.br
└── luchy.min.js.br

CDN Usage Examples

<!-- Latest version -->
<script src="https://cdn.luchy.app/luchy.min.js"></script>

<!-- Specific version -->
<script src="https://cdn.luchy.app/v/abc123/luchy.min.js"></script>

<!-- With compression (automatic) -->
<script src="https://cdn.luchy.app/luchy.min.js"></script>

Deploying

bun run deploy builds, uploads to R2, purges the CDN cache and then checks what the edge actually serves.

The purge is not optional. R2 has the new bytes the instant the upload finishes, but cdn.luchy.app is cached, so skipping it leaves every customer on the previous build while the deploy prints nothing but green. For that reason the script refuses to start when CLOUDFLARE_API_TOKEN is missing, rather than uploading and half-shipping.

The verification step fetches https://cdn.luchy.app/luchy.min.js with no cache-busting query string on purpose — a unique query string bypasses the edge cache and would pass even when real visitors are still getting the old script.

Root paths are purged; v/{commit-hash}/ is immutable and never needs it.

Environment Variables

  • R2_BUCKET: R2 bucket name (default: cdn-luchy-app)
  • CLOUDFLARE_API_TOKEN: required. Needs the Zone > Cache Purge permission on the luchy.app zone.
  • CLOUDFLARE_ACCOUNT_ID: defaults to the account that owns cdn-luchy-app. Pinned because wrangler r2 object put refuses to pick between accounts in a non-interactive shell.
  • CLOUDFLARE_ZONE_ID: defaults to the luchy.app zone.

License

MIT