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

grovs

v2.1.0

Published

Grovs generates dynamic links, tracks attributions, and analyzes referrals, seamlessly directing users to your app or the app store for downloads.

Readme

Overview

Grovs is a JavaScript SDK for deep linking, attribution and in-app messaging on the web. It generates dynamic links, resolves deferred deep links, tracks analytics events, and displays messages from the Grovs console.

Works in bundled browser apps, SSR frameworks (Next.js, Nuxt), Electron, and from a plain <script> tag. Written in TypeScript, ships its own types, and has no runtime dependencies.

Installation

npm install grovs --save
import Grovs from "grovs";

Script tag

The IIFE build at dist/grovs.global.js exposes window.Grovs. Serve it from your own origin alongside your other assets:

<script src="/assets/grovs.global.js"></script>
<script>
  Grovs.configure({ apiKey: "your-api-key" });
</script>

Loading it from a public CDN works too, but pin an exact version and add a Subresource Integrity hash — a @2 range resolves to a different file on every patch release, which defeats SRI and leaves you executing whatever the CDN serves. The published release notes carry the integrity value for each version.

CommonJS

import is the primary path. From require, the facade is the default export and the named exports sit alongside it:

const { default: Grovs, GrovsError } = require("grovs");
await Grovs.configure({ apiKey: "your-api-key" });

TypeScript

Types ship with the package; no @types/grovs is needed.

Upgrading from v1

Your existing v1 code keeps working. Two accessors return corrected values — see MIGRATION.md before upgrading.

Configuration

Call configure() once, as early as you can. It resolves true when the SDK authenticated successfully.

await Grovs.configure({
  apiKey: "your-api-key",
  testEnvironment: false,
  onDeeplink: (payload) => {
    // The app was opened from a Grovs link.
    console.log("deep link payload", payload);
  },
  onError: (code, message) => {
    console.error("grovs error", code, message);
  },
});

| Option | Default | Purpose | |---|---|---| | apiKey | required | From the console at app.grovs.io | | testEnvironment | false | Routes to the test environment | | baseURL | Grovs cloud | Custom API domain for self-hosted backends | | autoTrackScreenViews | true | Fires screen views on SPA route changes | | cookieDomain | host-only | Set to span subdomains, e.g. .example.com | | requireConsent | false | Store and send nothing until grantConsent() | | appVersion | SDK version | Your app's version, reported with the device fingerprint | | debugLevel | "error" | "info", "warn" or "error" | | messagesTheme | — | Deprecated and ignored — see Messages inbox (removed) | | onDeeplink | — | Called with the payload when opened from a link | | onError | — | Called on every SDK failure |

Server-side rendering. Importing the SDK during SSR is safe. Calling configure() on the server is a no-op that logs once and reports through onError — call it on the client instead.

Linked domains

The SDK sends its origin (https://app.example.com) as an identifier, and the backend compares it exactly against the linked domains configured for your project. A mismatch fails with "This Web app is not configured". If that happens, the error message contains the exact string the SDK sent — copy it into the console.

Deep links

Deep link handling needs no integration beyond onDeeplink. The SDK reads the link parameter from the URL, resolves the payload, and calls you back.

await Grovs.configure({
  apiKey: "your-api-key",
  onDeeplink: (payload) => router.push(payload.screen),
});

// Or read them later:
Grovs.lastReceivedPayload();
Grovs.allReceivedPayloadsSinceStartup();

The callback runs when a lookup returns a payload object; a missing payload does not trigger it. With requireConsent: true, the lookup and callback wait for grantConsent(), while the URL's link token is captured in memory first.

Refreshing with ?Grovs=... (or the legacy ?linksquared=...) still in the URL resolves that link again and delivers its payload to the new page again. The SDK consumes the stored token after a successful lookup but leaves the URL unchanged. If your app handles a link only once, remove its query parameter after handling it and guard any action that must not repeat. Without a URL token, the backend can still return a payload through deferred matching.

Generating links

const url = await Grovs.generateLink({
  title: "Link title",
  subtitle: "Link subtitle",
  imageURL: "https://example.com/image.jpg",
  data: { screen: "product", id: 42 },
  tags: ["launch"],
  showPreviewiOS: false,
  showPreviewAndroid: true,
  copyToClipboardiOS: false,
  copyToClipboardAndroid: true,
  customRedirects: {
    ios: { link: "https://apps.apple.com/...", openAppIfInstalled: true },
    android: { link: "https://play.google.com/..." },
    desktop: { link: "https://example.com" },
  },
  trackingCampaign: "BlackFriday2025",
  trackingSource: "instagram",
  trackingMedium: "social",
});

Resolves null on failure; the reason arrives through onError.

copyToClipboardiOS / copyToClipboardAndroid ask the Grovs-hosted preview page to copy the link to the clipboard, so a fresh mobile install can be matched back to it by the native SDK. Omit them and the link inherits the project default — passing false is an explicit override, not the same thing. They only take effect where the preview page is shown for that platform (showPreviewiOS / showPreviewAndroid), and there is no combined copyToClipboard shorthand; the toggles are per-platform by design. The web SDK never reads the clipboard itself: the copy happens on the preview page and the read-back happens in the iOS and Android SDKs after install.

Details for a link the SDK generated:

const details = await Grovs.linkDetails("abc123");

Identifying users

Grovs.setUserIdentifier("user-42");
Grovs.setUserAttributes({ plan: "pro", seats: 5 });

Grovs.userIdentifier;  // "user-42"
Grovs.userAttributes;  // { plan: "pro", seats: 5 }

Both are safe to call before configure() resolves. Whichever you set locally wins; the other is adopted from the server if it has one.

Grovs.isAuthenticated() reports whether the SDK has authenticated. It is false before configure() resolves, after reset(), and while a transient failure is being retried.

Tracking events

Grovs.track("purchase", { item: "sku-42", price: 19.99 }, ["checkout"]);
Grovs.trackScreenView("Checkout", { section: "payment" });

Custom events carry the most recently viewed screen as screen_name, so they can be segmented by screen. Screen names are capped at 255 characters.

Event names must be non-empty and must not be a reserved system name (install, reinstall, app_open, view, open, time_spent, reactivation, user_referred) or a message event (push_open, push_delivered, push_held, in_app_viewed, in_app_clicked, in_app_dismissed, in_app_answered, in_app_commented, in_app_held, journey_entered, journey_held, journey_path); the backend rejects them too. Use trackScreenView() for screen views.

Property values may be strings, numbers, booleans, null, or nested arrays and objects of those. Date, URL and BigInt are coerced to strings. Values that cannot be represented in JSON — NaN, Infinity, functions, symbols, circular references — are dropped for that key only; the rest are still sent. If the encoded properties exceed 8 KB (measured as UTF-8 bytes, as the backend measures them) they are all dropped and the event is still recorded. Tags are capped at 20 per event, each 255 characters.

Screen tracking

With autoTrackScreenViews enabled (the default), SPA route changes fire screen views automatically — pushState, replaceState, popstate and hashchange are all observed. The screen name resolves in priority order: screenNameProvider, then the alias map, then document.title, then the pathname.

Name resolution is deferred one animation frame, so document.title is the page the user is on rather than the one they left. If your framework commits its render later than that, name the screen explicitly:

Grovs.screenNameProvider = (url) => {
  if (url.pathname.startsWith("/legal")) return "suppress";
  if (url.pathname.startsWith("/product/")) return "Product";
  return "automatic";
};

URLs are high-cardinality, so collapse them with alias patterns — otherwise a catalogue of any size produces one dashboard row per id:

Grovs.setScreenAliases({
  "/product/:id": "Product",
  "/docs/*": "Documentation",
  "/checkout": "Checkout",
});

When several patterns match, the more specific one wins regardless of order: more literal text first, then :param over *. A bare * is a catch-all that every other pattern beats.

Global tags attach to every event until cleared:

Grovs.setGlobalTags(["beta"]);
Grovs.setGlobalTags(null);

Flushing

Events batch automatically and flush on tab close. To drain the queue before a hard navigation:

await Grovs.flush();

Messages inbox (removed)

The messages inbox and automatic message display were removed; in-app messages replace them. The methods remain as deprecated no-ops so existing pages keep working, and none of them makes a request:

| Method | Now | | --- | --- | | Grovs.showMessagesList() | does nothing | | Grovs.displayAutomaticMessages() | does nothing | | Grovs.getMessages(page) | resolves [] | | Grovs.numberOfUnreadMessages() | resolves 0 | | Grovs.markMessageAsRead(id) | resolves true |

The messagesTheme option is still accepted and ignored.

In-app HTML messages and CSP

An HTML in-app message runs in a sandboxed srcdoc iframe, which inherits your page's Content-Security-Policy. The SDK cannot override it, so the policy must allow what the message uses:

  • script-src must allow inline scripts: the message's bridge and its own scripts are inline. That means 'unsafe-inline' and no nonce, hash or 'strict-dynamic' in script-src (or in default-src when there is no script-src): browsers ignore 'unsafe-inline' when any of those is present. So a nonce-, hash- or 'strict-dynamic'-based policy cannot run HTML messages at all. When blocked, the message never starts; the SDK skips it and reports onError with inAppDisplayFailed (5) once, naming the CSP.
  • img-src, media-src and font-src: the hosts the message loads pictures, video and fonts from (your CDN, or https:). img-src also covers the pictures of the built-in templates.
  • style-src 'unsafe-inline' if the message uses <style> or style="".

The built-in templates need no script or style exception, so they work under any policy, including nonce-, hash- and 'strict-dynamic'-based ones.

Smart banner

On iPhone, iPad and Android browsers the SDK shows the banner configured in the Grovs dashboard (Link behaviour → Smart banners). Its button is a Grovs link: it opens your app when installed, and otherwise goes to the store and delivers the deep link after install. Desktop browsers never see it.

Tell the banner what the current page is about, so "Open in app" lands on the right screen. Call it on each page of a single-page app; calls made before configure() apply once it runs:

Grovs.smartBanner.setLinkData({ data: { product_id: "42" } });

The app receives your data plus page_path (the page's path, unless you pass your own). data is a flat object of at most 20 keys with string, number or boolean values; keys are at most 64 characters, string values at most 512, and the whole payload at most 4 KB. Data over a limit is not sent: the banner keeps its default link and the SDK logs a warning.

Pages that never call setLinkData() use the banner's default link, with no extra request. A tap before the page link arrives uses the default link. If the banner or link request fails, the SDK waits 60 seconds, or longer if the server's Retry-After asks, across page loads in the tab, before asking again; a banner already showing stays.

Grovs.smartBanner.hide();      // this page only; not a dismissal
Grovs.smartBanner.refresh();   // re-run banner selection
Grovs.configure({ apiKey: "…", smartBanner: false }); // turn it off

The dashboard sets two separate hide periods: after closing, the banner is hidden for dismiss_days; after opening (tapping the button), it's hidden for open_hide_days. Either can be 0, hiding the banner for that page view only.

The SDK follows route changes in single-page apps on its own, whatever autoTrackScreenViews is set to. A new page is a new path (see below); query-string and #anchor changes keep the link data you set. A query-string change on the same path is the same page, so if your product id lives in the query string (/product?id=42), call setLinkData() when it changes. Under requireConsent the banner waits for grantConsent(). Values that are undefined or null are left out, so { product_id: product?.id } is fine.

The v1 constructor (new Grovs(apiKey, …)) never shows the banner; use Grovs.configure() to get it.

If two copies of the SDK run on one page, only one banner is drawn. An older copy (released before the smart banner) that owns SPA route tracking can hide pushState route changes from the banner; full page loads and back/forward still work.

Hash-routed sites

When the URL hash starts with #/ or #!/ (shop.com/#/product/42), the hash is the page path: /product/42, without the hash's own ?query or #anchor. It is what URL patterns match, what page_path defaults to, and what counts as a new page. Other hashes (#reviews) are plain anchors and the path is location.pathname. An app mounted under a path (/app/#/product/42) matches on the hash route alone, so write patterns like /product/*, not /app/product/*.

Fixed headers and footers

A top banner pushes the page down; a bottom banner floats over it. Offset your own fixed elements with the two variables the SDK sets on <html>:

.site-header { top: var(--grovs-smart-banner-top, 0); }
.cookie-bar { bottom: var(--grovs-smart-banner-bottom, 0); }

Webviews

The banner also shows inside your own app's webview: pass smartBanner: false there. In Instagram and Facebook in-app browsers the button may not be able to open the app.

Purchases

await Grovs.logCustomPurchase({
  type: "buy",              // "buy" | "cancel" | "refund" | "refund_reversed"
  priceInCents: 1999,
  currency: "USD",
  productID: "com.acme.coins.100",
  startDate: new Date(),    // optional
  transactionID: "order-8842",  // optional, but pass yours — see below
});

The backend deduplicates purchases on the transaction id, so passing your own order or payment id makes the call safe to repeat: a retry after a timeout, or a double-submitted checkout, is counted once. Omit it and the SDK mints one per call, which covers its own network retries but cannot recognise the same purchase sent from a later page load.

Purchase events require a Grovs Enterprise backend (GROVS_EE=true). On a standard deployment the endpoint does not exist and this reports eventDispatchFailed naming the requirement rather than retrying.

Consent

Tracking is on by default. To gate it behind a cookie banner:

await Grovs.configure({ apiKey: "your-api-key", requireConsent: true });

// Nothing has been stored on the device or sent. Events tracked meanwhile are
// held in memory and delivered once consent arrives.
await Grovs.grantConsent();

// Clear identifiers, session and queued events:
Grovs.reset();

reset() returns the SDK to its pre-consent state and stops tracking. It does not re-authenticate on its own, which is the point: with requireConsent: true the visitor has withdrawn permission. To start again, call configure() (then grantConsent() if you require consent).

Delivery

Events are queued, persisted to localStorage, and sent in batches of 50. The first batch goes as soon as configure() has authenticated and resolved attribution, and it carries anything an earlier visit left undelivered, so a short visit does not depend on the tab-close flush. After that they batch every five seconds, immediately at 50 events, and again when the page is hidden.

Failed requests are attempted up to three times, with exponential backoff and full jitter. A Retry-After longer than a request will wait ends the retries and holds the scheduled batches for the interval the server named; an explicit flush() still sends.

flush() joins a delivery already in progress rather than starting a second one. If that delivery is a scheduled batch and the backend throttles it, flush() resolves with events still queued — it waits for the send in flight, not for the queue to empty. Call it again, or let the next scheduled batch carry them once the server's interval has passed. Events the backend rejects as permanently invalid are dropped rather than retried. The queue holds 1,000 events, or one million characters, and discards anything older than seven days.

What the SDK collects

configure() authenticates and, in the same request, reports a device fingerprint used to match deferred deep links: user agent, screen size, timezone, language, and the WebGL vendor and renderer strings. It also reads and writes a visitor identifier in a cookie and in localStorage. Tracked events carry the event name, your properties and tags, a session id, and the attribution path the visit arrived on.

On mobile browsers the smart banner fetches its configuration and, for the banner it shows, sends the page path and any link data you passed to setLinkData(). It stores dismissals in localStorage (grovs_smart_banner_dismissed) and caches its configuration and links in sessionStorage (grovs_sb:* keys), and tracks the smart_banner_view, smart_banner_click and smart_banner_dismiss events.

With the default requireConsent: false this happens as soon as you call configure(). If you need it gated behind a banner, use Consent — with requireConsent: true nothing is written to the device and no request leaves until grantConsent().

Errors

Every failure reaches onError with one of five codes, matching the iOS SDK. Code 5 is new in 2.1.0; an exhaustive switch needs a case for it.

| Code | Meaning | |---|---| | 1 authenticationFailed | Bad API key, unconfigured domain, or auth failure | | 2 networkRequestFailed | A request failed, or the SDK was called during SSR | | 3 eventDispatchFailed | Events were rejected; they stay queued unless permanently invalid | | 4 linkGenerationFailed | Link generation failed | | 5 inAppDisplayFailed | An in-app message could not be drawn, for example an HTML message blocked by the page's CSP |

import Grovs, { GrovsError } from "grovs";

await Grovs.configure({
  apiKey: "your-api-key",
  onError: (code, message) => {
    if (code === GrovsError.authenticationFailed) reportToSentry(message);
  },
});

Disabling the SDK

Grovs.setEnabled(false);
Grovs.setDebugLevel("info");

Known limitations

  • Safari identity. The visitor identifier is written to both a cookie and localStorage, because Safari's ITP clamps script-written cookies to 7 days regardless of the expiry requested. That covers visitors returning within the window, but ITP can still evict script-writable storage for genuinely dormant ones — so Safari install counts carry a small known over-count.
  • Delivery is at-least-once. The final batch is sent with keepalive when the page is hidden and stays stored until acknowledged, so a page that goes away before the answer sends it again from the next load. No batch is dropped before an acknowledgement. Events can still be discarded for the documented reasons below: seven days old, evicted by the queue caps, or never written because the browser refused storage. A re-send is byte-identical and carries the same event_id, so a backend that deduplicates on it counts the event once. Grovs cloud does. If you point baseURL at your own backend, deduplicating on event_id is your responsibility; without it, retries are counted twice.
  • One known exception to "byte-identical", across tabs. An event is attributed by the page that created it, and only that page. A second tab sharing the queue can send a copy of that event before its own page has settled its attribution, and the two copies then differ in the campaign they carry. Grovs cloud's event_id covers the resolved campaign, so those two are not recognised as the same event and one visit can be counted twice under two campaigns. It needs a second tab, opened on a campaign link, while the first tab's attribution is still resolving. Closing it properly needs per-tab queue ownership, which is not in this release.
  • configure() resolves false on a transient failure (no connection, timeout, 429, 5xx) while the SDK retries in the background, on online and at 30-second intervals, up to three times. Events tracked meanwhile queue.
  • Multiple tabs share one queue without atomic ownership. A tab opened mid-visit may send events the first tab also sends. Those copies are identical and event_id dedup collapses them, except in the attribution case above. Because the shared write is read-merge-write and localStorage has no compare-and-swap, two tabs writing in the same instant can still lose an event that neither has delivered.
  • Stored state is per project. The queue, session and counters are keyed by project (and separately for testEnvironment), so switching projects on one origin does not carry events across. The visitor identifier is shared.
  • Purchases require Enterprise, as above.
  • In-app purchase logging has no web equivalent; there is no StoreKit. logCustomPurchase covers every web payment flow.

Development

npm test              # typecheck, lint, unit tests, coverage gate
npm run verify        # the above plus build, size budget, export shapes, E2E
npm run demo          # QA harness at http://localhost:5174/demo/
npm run test:live     # every flow against a real backend (needs a key)
npm run test:safari   # native installed Safari on macOS, local test server

The demo runs stubbed by default — no backend needed — and can be switched to a real one. npm run test:live drives it through every flow against a live project: create a link, arrive through it, resolve the payload, deliver events and confirm the backend accepted them, identity, purchases. See demo/README.md.

The browser suite includes a complete consent journey, payload delivery on refresh, and responses arriving after reset. These use controlled HTTP responses; e2e/delivery.spec.ts separately checks real HTTP receipt. They run in Chromium, WebKit and Firefox with no retries. They start a fresh server on port 4175, separate from the demo on port 5174, so a running dev server cannot serve a stale SDK bundle. Live tests require http://localhost:4175 in the project's linked domains and CORS configuration. For concurrent local runs, set GROVS_E2E_PORT to a free port and give Playwright separate output/report directories (--output and PLAYWRIGHT_HTML_OUTPUT_DIR). Live projects must allow the overridden origin.

On a browser-test failure, test-results/ contains a screenshot and trace; playwright-report/ contains the HTML report. Open it with npx playwright show-report. CI uploads these folders on failure. An explicit npm run test:live builds the SDK and fails if GROVS_LIVE_API_KEY is missing; the normal verification suite needs no key.

npm run test:safari uses macOS's bundled safaridriver to run the built SDK in the installed Safari. Enable Safari → Settings → Developer → Allow remote automation first. It opens an isolated automation window; keep that window in front during the tab-close test. It needs no project key and sends all SDK traffic to a local HTTP server. Failed setup fails the command rather than silently skipping tests.

The native Safari suite checks consent, request headers and browser details, deep-link callbacks across refresh and delayed consent, automatic batching, identity recovery after cookie deletion, full storage loss, refused storage writes, retries, unchanged replay after reload, and server receipt after a real tab close. Storage deletion/refusal is injected: this tests recovery from eviction, not Safari's multi-day ITP eviction policy. The IP assertion checks the local connection address; production proxy IP forwarding must be verified against the deployed backend separately.

Further assistance

Documentation: https://docs.grovs.io/s/docs. Support: [email protected].