@dropinnodex/client
v0.11.0
Published
Isomorphic, zero-dependency client SDK for dropin-activity — feeds, activities, reactions, and follows over fetch. Browser or Node.
Maintainers
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/clientUsage
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, typedComing 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>(…)makesactivity.customa real typed shape end to end (SSR, hooks, enrichment) — noany. - No collisions. A custom field named
verb,id, ortimecan 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_countfollowStats('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 firstmutuals 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 readEach 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.
seenandreadare 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 droppedAn 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
