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

@dropinnodex/client

v0.11.0

Published

Isomorphic, zero-dependency client SDK for dropin-activity — feeds, activities, reactions, and follows over fetch. Browser or Node.

Readme

@dropinnodex/client

Isomorphic, zero-dependency client SDK for dropin-activity — a GetStream-shaped activity feed as a service. Runs in the browser or Node, over fetch.

npm i @dropinnodex/client

Usage

import { DropInClient } from '@dropinnodex/client'

const client = new DropInClient({
  apiKey: 'acme-public-key',
  // Called on init and again on a 401 — fetch a token from YOUR backend (@dropinnodex/server).
  tokenProvider: () => fetchTokenFromYourBackend(),
})

const feed = client.feed('user', 'user-123')
await feed.addActivity({ verb: 'post', object: 'hello world' })

const page = await feed.get({ limit: 20 })          // keyset pagination
const more = await feed.get({ next: page.next ?? undefined })

await feed.follow('user', 'user-456')
const reaction = await client.reactions.add('like', activityId)
const me = await client.users.me()

Live updates with head checks

Poll a cheap change signal instead of a full feed read. Useful for "keep this fresh" UI without the cost of re-reading the whole feed every few seconds.

// Get the opaque ID of the latest activity or notification
// Returns { latest: string | null } — null means "nothing new".
const { latest } = await client.feed('timeline', 'user-123').head()
const { latest: ntfLatest } = await client.notifications.head()

// Compare with the last value you acted on; a change means fetch the real feed.
// The string itself is never parsed; only compare for equality.

Runs only on Redis — no database hit. latest is an opaque token (happens to be the newest activity / notification id). Both flows benefit from this: React hooks can buffer the work behind a 5-second timer (visible tabs only) to achieve "~5s latency, near-zero idle cost" without the infrastructure of a realtime transport.

Pointing somewhere else

url defaults to https://api.getnodex.cloud. Override it for staging, a proxy, or local development:

const client = new DropInClient({
  apiKey: 'acme-public-key',
  url: 'http://localhost:3000',
  tokenProvider: () => fetchTokenFromYourBackend(),
})

No trailing slash.

Reactions

const reaction = await client.reactions.add('like', activityId)   // → Reaction { id, kind, ... }

// List who reacted, newest first (paginated). Optional `kind` filters server-side.
const page = await client.reactions.list(activityId, { limit: 20 })
page.results    // Reaction[]
page.next       // string | null — pass back as { next } for the next page
const likes = await client.reactions.list(activityId, { kind: 'like' })

// Canonical, GetStream-parity delete: by reaction id (from add()/list()).
await client.reactions.delete(reaction.id)

// Convenience: remove YOUR OWN reaction of a kind from an activity — no id needed.
await client.reactions.unreact(activityId, 'like')

The Reaction type is exported: import type { Reaction } from '@dropinnodex/client'.

reactions.delete(reactionId) is the GetStream-parity primitive. reactions.unreact(activityId, kind) is sugar for the common "un-react by kind" case and does not require holding onto the reaction id. Breaking rename: earlier previews had reactions.delete(kind, activityId) do what unreact now does — delete is now strictly delete-by-id.

Custom fields — nested under custom (differs from GetStream)

Your own activity fields live under a typed custom object, not spread onto the activity root:

const feed = client.feed<{ title: string; location: string }>('user', 'user-123')
await feed.addActivity({
  verb: 'attend', object: 'session:42',
  custom: { title: 'Played a match', location: 'Amsterdam Zuid' },
})

const [a] = (await feed.get()).results
a.verb            // 'attend'         — reserved field
a.custom.title    // 'Played a match' — YOUR field, typed

Coming from GetStream? GetStream spreads custom keys onto the activity root (activity.title). We deliberately nest them under activity.custom instead. Two reasons:

  • Typed. client.feed<TCustom>(…) makes activity.custom a real typed shape end to end (SSR, hooks, enrichment) — no any.
  • No collisions. A custom field named verb, id, or time can never clobber a reserved one; the reserved/custom boundary is unambiguous.

This is the one intentional divergence from GetStream's activity shape. Reach for activity.custom.yourField rather than activity.yourField.

The token is cached until a 401, then refetched once and the request replays — never per-request. Errors throw DropInApiError with { code, message, status, requestId, retryAfterSeconds?, fields?, url? } — branch on code, never on message. retryAfterSeconds is set on a RATE_LIMITED (429) and fields carries per-field detail on a VALIDATION_FAILED. Full table: Errors.

Upgrading from 0.8.x? Nothing here breaks: this SDK has always thrown DropInApiError, and message has always been the API's own text. 0.9.0 only added retryAfterSeconds, fields and url, plus an instanceof that holds across a dual ESM/CJS load. (The bare-Error and stringified-body behaviour was @dropinnodex/server before 0.11.0 — if you also use that one, read its README first.)

Follow counts

const stats = await client.feed('user', 'user-123').followStats()
stats.follower_count    // how many feeds follow user:user-123
stats.following_count   // how many feeds user:user-123 follows (usually 0 for a `user` feed)

Two-call profile pattern. Follow edges go from timeline:<id> to user:<other> , so a profile screen needs both feed kinds to get "followers" and "following" right:

const followers = await client.feed('user', 'user-123').followStats()      // .follower_count
const following = await client.feed('timeline', 'user-123').followStats() // .following_count

followStats('user', uid) counts who follows this person; followStats('timeline', uid) counts who this person follows. Calling followStats on the wrong feed kind returns a real number, just not the one you want (e.g. user:<id>'s following_count is normally 0, since nothing follows from a user feed).

Both counts are denormalized: bumped in the same transaction as the follow/unfollow write — so on the server they're always exactly consistent with the edges — never computed with a realtime COUNT(*). Client-side they're display data: your cached copy is only as fresh as your last read, so refresh() after a follow to re-pull. Fine for a profile header; don't use them as a source of truth for access control.

Follow suggestions

Who a feed should follow — user: feeds it doesn't already follow, ranked by mutual overlap (feeds followed by feeds it follows — friends-of-friends), then topped up by global popularity for a cold-start feed with a thin graph.

const { results } = await client.feed('timeline', 'user-123').suggestions({ limit: 25 })
results  // [{ group: 'user', id: 'anna', mutuals: 3 }, …] — best first

mutuals is how many of the caller's follows also follow this suggestion; a popularity-fill suggestion has mutuals: 0. Call it on the timeline:<id> feed — following happens from the timeline feed, so that's the graph the 2-hop walks. This is a capped top-N, not a page: limit defaults to 25 (max 50), there's no cursor and no next. Reads are open within a tenant, so a server token can fetch suggestions for any feed.

Notifications

A flat notification feed, private to the caller. It fills when someone follows you or reacts to an activity you authored (GetStream's "flat notifications" — no aggregation, no grouping).

const page = await client.notifications.get({ limit: 20 })
page.results   // Notification[] — newest first
page.unseen    // count of not-yet-seen notifications
page.unread    // count of not-yet-read notifications
page.next      // keyset cursor, or null

await client.notifications.markSeen()          // no ids → mark ALL seen
await client.notifications.markSeen(['n1'])    // or specific ids
await client.notifications.markRead(['n1'])    // read is tracked independently of seen
await client.notifications.markRead()          // mark all read

Each Notification is { id, verb, actor, object, reaction_kind, created_at, seen_at, read_at, actor_user }. verb is 'follow' or 'react'; actor is the acting user ref (e.g. 'user:bob') enriched into actor_user ({ id, custom }) when it resolves to a known user; object is the followed feed ref (follow) or the reacted activity id (react).

Notes:

  • You are never notified about your own actions (following yourself, reacting to your own post) — the server skips self-actions.
  • seen and read are independent (marking read does not mark seen), matching GetStream. Counts are denormalized like follow counts — server-authoritative, moved only when a row actually changed. Re-read to refresh a cached copy.
  • Removing a reaction leaves the notification in place; a repeat follow collapses into the single existing "X follows you" row.

Keeping feed data fresh: objects and activity patches

See the Keeping feed data fresh guide for the full rule (custom if it's true forever, an object if it changes) and why delete-and-repost is the wrong tool. From this package:

// Objects are server-write-only — this client can read them, never write them.
const session = await client.objects.get('session', '1234')
session.custom   // whatever your backend put there

// Patch YOUR OWN activity's `custom` — a typo, a corrected caption. Every
// set/unset path starts with `custom.`; `unset` is applied after `set`.
await client.feed('user', 'alice').updateActivity(activityId, {
  set: { 'custom.text': 'Corrected caption' },
})

// refs is patchable too, as a top-level field — replaces wholesale, `[]` clears
// it. This is how an activity posted before objects existed adopts one.
await client.feed('user', 'alice').updateActivity(activityId, {
  refs: ['session:1234'],
})

A feed read's objects sidecar (keyed type:id, resolved from every activity's refs on the page) rides along on feed().get() — see @dropinnodex/react's README for rendering it with resolveRefs.

Cancellation

Every method takes an optional RequestOptions as its last argument:

const ctrl = new AbortController()
const page = client.timeline('alice').get({ limit: 20 }, { signal: ctrl.signal })
ctrl.abort() // the request is dropped

An aborted call rejects with what fetch throws — a DOMException whose name is AbortError, or the reason you passed to abort() — never a DropInApiError. So err instanceof DropInApiError still means "the API answered".

Aborting before the call reaches the network also skips tokenProvider, so a cancelled screen does not hit your token endpoint.

@dropinnodex/react wires this up for you: its reads are cancelled on unmount, its writes are not.

License

MIT