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

@grahak/js

v0.0.7

Published

Grahak JS SDK — the render-agnostic core: api client + auth/session + conversation state.

Readme

@grahak/js

The JavaScript SDK for embedding the Grahak customer-conversation widget in your web app. Render-agnostic, zero runtime dependencies, ESM.

It loads the Grahak widget in an iframe on Grahak's origin and bridges your page ↔ the iframe over postMessage. Your page never holds Grahak tokens — the session is established and kept inside the iframe.

Using React? @grahak/react wraps all of this in a provider + hooks. Prefer it in React apps.

Contents

Install

npm install @grahak/js

How it works

Two pieces:

  1. Backend — an authed endpoint that signs a short-lived identity JWT for the logged-in user with your project's private key. This is the security boundary; the JWT is what proves who the user is.
  2. Frontend — a few lines that init() the widget with that JWT and mount it.

The widget runs in an iframe on Grahak's origin and runs the token exchange itself, so Grahak tokens never touch your page.

Backend — mint an identity JWT

1. Generate your keypair

You sign identity JWTs with an RS256 private key and upload only the public half to Grahak — the GitHub-SSH-keys model. A leak of what Grahak stores grants no signing power. Any RS256 keypair works; with openssl:

# Private key — backend secret, never sent to the browser (PKCS#8 PEM)
openssl genpkey -algorithm RSA -pkcs8 -out grahak_private.pem -pkeyopt rsa_keygen_bits:2048
# Public key — upload to Console → project → Identity keys (SPKI PEM)
openssl rsa -in grahak_private.pem -pubout -out grahak_public.pem

Paste grahak_public.pem into Console → project → Identity keys, and store the private key as a backend secret (e.g. GRAHAK_PRIVATE_KEY). You can register multiple public keys per project and rotate freely — old keys keep verifying until you revoke them.

2. The JWT claims

| Part | Field | Required | Notes | |------|-------|----------|-------| | header | alg | yes | must be RS256 | | header | typ | — | JWT (conventional) | | header | kid | optional | RFC 7638 JWK thumbprint of your public key. If set, Grahak goes straight to it; if omitted, it tries all the project's active keys. Omitting it is fine. | | claim | aud | yes | must equal your projectId (in Console: your project → Settings → Project ID) | | claim | sub | yes | your app's stable, immutable user id (the host externalId) — never an email | | claim | iat | yes | issued-at; the token must be < 10 min old (30s clock tolerance) | | claim | exp | yes | set short, e.g. now + 10 min | | claim | email | optional | display only | | claim | name | optional | display only | | claim | avatar_url | optional | display only — snake_case | | claim | metadata | optional | arbitrary JSON object, stored on the end user |

email/name/avatar_url/metadata refresh on every boot; sub is the fixed identity (Grahak upserts the end user by (projectId, sub)).

The JWT is single-use and short-lived — mint a fresh one per widget boot. See the frontend userJwt getter.

3. Mint it on your backend

Any RS256 JWT library works — the claim contract above is all that matters.

TypeScript / Node (jose):

// GET /api/me/grahak-identity  ->  { jwt }   (an authed route — behind your login)
import { SignJWT, importPKCS8 } from "jose";

const key = await importPKCS8(process.env.GRAHAK_PRIVATE_KEY, "RS256");
const jwt = await new SignJWT({
  name: user.name,
  email: user.email,
  avatar_url: user.avatarUrl, // snake_case
})
  .setProtectedHeader({ alg: "RS256", typ: "JWT" }) // kid optional
  .setSubject(user.id) // your stable user id → externalId
  .setAudience(process.env.GRAHAK_PROJECT_ID)
  .setIssuedAt()
  .setExpirationTime("10m")
  .sign(key);

Go (golang-jwt/jwt v5):

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

claims := jwt.MapClaims{
    "sub":        user.ID,         // stable user id → externalId
    "aud":        grahakProjectID,
    "iat":        time.Now().Unix(),
    "exp":        time.Now().Add(10 * time.Minute).Unix(),
    "name":       user.Name,
    "email":      user.Email,
    "avatar_url": user.AvatarURL,  // snake_case
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// kid optional: tok.Header["kid"] = grahakKeyThumbprint
signed, err := tok.SignedString(privateKey) // *rsa.PrivateKey (parse the PEM)

Python (PyJWT):

# pip install "pyjwt[crypto]"
import time
import jwt  # PyJWT

with open("grahak_private.pem") as f:
    private_key = f.read()

now = int(time.time())
token = jwt.encode(
    {
        "sub": user.id,                 # stable user id → externalId
        "aud": GRAHAK_PROJECT_ID,
        "iat": now,
        "exp": now + 600,               # 10 min
        "name": user.name,
        "email": user.email,
        "avatar_url": user.avatar_url,  # snake_case
    },
    private_key,
    algorithm="RS256",
    # headers={"kid": GRAHAK_KEY_THUMBPRINT},  # optional
)
# PyJWT >= 2.0 returns a str

Frontend — embed the widget

Overlay mode (recommended)

One call. Grahak injects a floating launcher button (with an unread badge) and owns the chat panel — you don't wire up mounting, visibility, or polling:

import { init } from "@grahak/js";

const widget = init({
  projectId: GRAHAK_PROJECT_ID,
  // A GETTER, not a string: the identity JWT is single-use + short-lived, so the
  // SDK fetches a fresh one on every (re)connect (e.g. after a reload).
  userJwt: async () => {
    const res = await fetch("/api/me/grahak-identity");
    return (await res.json()).jwt;
  },
}).mountOverlay();

That's the whole integration. Keep the returned widget if you want to drive it:

widget.setTheme("dark");                       // match your app's theme, live
widget.on("unread", ({ count }) => { ... });   // render your own badge too, if you like
widget.open();  widget.close();  widget.toggle();

Options (all optional): mountOverlay({ position, launcher, accentColor, width, height, offset, zIndex }).

  • position"bottom-right" (default) or "bottom-left".

  • launcher: false — hide Grahak's button and drive it from your own trigger (widget.open()), while Grahak still owns the persistent panel.

  • launcher: myElement — hand Grahak your own button and skip the wiring. Grahak binds its click to toggle() and mirrors state onto the element so plain CSS drives the look: it sets data-grahak-open="true|false" (and aria-expanded) and data-grahak-unread="true|false" (true only while closed with something unread). The element stays where it is in your DOM.

    const myButton = document.querySelector("#support");
    init({ projectId, userJwt }).mountOverlay({ launcher: myButton });
    #support[data-grahak-open="true"]  .close { display: block }
    #support[data-grahak-unread="true"] .dot  { display: block }

Custom placement (attach mode)

Want the widget inside your own drawer, popover, or page section instead of the floating overlay? Use mount(container) and own the placement + show/hide yourself.

const widget = init({ projectId, userJwt });
widget.mount(containerEl);

openButton.onclick = () => { showContainer(); widget.open(); };
closeButton.onclick = () => { hideContainer(); widget.close(); };

It's fine to mount/unmount on toggle if you don't mind the widget starting fresh each time. But know the trade-off: an <iframe> fully reloads if it's unmounted or moved in the DOM. A reload throws away

  • the open conversation, scroll position, and any half-typed reply, and
  • the background polling that powers the unread badge (it can't update while the iframe isn't mounted).

So if you want that state to persist — and a badge that updates before the widget is ever opened — keep the iframe mounted once in a stable container and hide it (CSS) instead of unmounting, toggling only visibility. In a React SPA that's one mount in your app shell that survives client-side navigation.

⚠️ A common footgun: putting the iframe inside a Radix Popover/Dialog without forceMount unmounts it on every close — so state and the badge reset. Use forceMount + a CSS hide, or mount in your shell and toggle visibility.

mountOverlay() does all of this for you — reach for attach mode only when you genuinely need custom placement. In React, the @grahak/react <GrahakWidget> component wraps it.

API reference

const widget = init({ projectId, userJwt, theme? });

widget.mountOverlay(opts?); // Grahak-owned launcher + panel (recommended)
widget.mount(container);    // custom placement — see "Custom placement" above

widget.open();              // show the panel
widget.close();
widget.toggle();
widget.setTheme("dark");    // live theme change, no re-init
widget.destroy();           // tear down (on logout)

widget.isOpen;              // boolean
widget.isReady;             // handshake complete
widget.unreadCount;         // current unread count (also via the `unread` event)

const off = widget.on("unread", ({ count }) => {});  // also: ready | open | close | resize | error
off();                                                // unsubscribe

Listen for error to catch a failed identity exchange — if your userJwt getter throws/rejects or returns a stale or invalid token, the widget emits an error event instead of connecting.

Troubleshooting

| Symptom | Likely cause | Fix | |---------|--------------|-----| | Widget shows "We don't recognize who you are" | Identity exchange failed: the JWT is > 10 min old, already used (it's single-use), a static string that went stale, aud ≠ your projectId, or the public key isn't registered (or was revoked) on the project. | Pass userJwt as a getter, not a string. Confirm aud == projectId, the registered public key matches your private key, and iat is fresh (≤ 10 min, ~30s clock tolerance). | | Open thread / draft / unread badge resets every time the panel closes | The iframe is being unmounted on close (e.g. a Radix Popover/Dialog without forceMount). | forceMount + a CSS hide, or use overlay mode. (See the attach-mode footgun above.) | | One transient postMessage "target origin … does not match" on load | Benign load-timing race; the SDK ignores mismatched-origin inbound messages. | No action unless it's persistently noisy. |

Testing (dev mode)

You don't point the SDK anywhere special to test. Create a dev-mode project in the Console and use its projectId — the widget always loads from the hosted widget.grahak.dev, in dev and production alike.

widgetBaseUrl exists only for Grahak's own development and self-hosting; leave it unset.