@nostr-wot/data
v0.4.3
Published
Pure-function Nostr data layer: profiles, notes, threads, engagement, NIP-65 outbox. Optional SWR cache + React hooks.
Maintainers
Readme
@nostr-wot/data
Pure-function Nostr data layer. Profiles, notes, threads, follows, follower lists, engagement (reactions / reposts / zaps), NIP-65 outbox-model relay discovery, and a streaming subscription coalescer. Optional SWR cache + React hooks ship as separate entrypoints.
Three entrypoints
| Import path | What's in it | Depends on |
|---|---|---|
| @nostr-wot/data | Pure fetchers, parsers, outbox helper, getPool(), sharedCoalescer | nostr-tools (peer) |
| @nostr-wot/data/cache | SWR cache wrapping the fetchers; observable primitive; localStorage persistence | + the above |
| @nostr-wot/data/react | useProfile, useNote, useThread, <NostrDataProvider>, plus the shared session context (<NostrSessionProvider>, useSession, useKEKSigner, …) | + react (peer) |
Use only what you need.
Install
npm i @nostr-wot/data nostr-toolsVanilla fetchers (no cache, no React)
import {
fetchProfile,
fetchNote,
fetchNotesByAuthor,
fetchThread,
fetchFollows,
fetchEngagement,
fetchRelayList,
relaysForAuthor,
setDefaultRelays,
setProfileAggregators,
} from "@nostr-wot/data";
setDefaultRelays(["wss://relay.damus.io", "wss://nos.lol"]);
setProfileAggregators(["wss://purplepag.es"]);
const profile = await fetchProfile("hex-pubkey");
// → { displayName, name, picture, banner, about, nip05, lud16, fetchedAt }
const note = await fetchNote("hex-event-id");
const notes = await fetchNotesByAuthor("hex-pubkey", { limit: 50 });
const replies = await fetchThread("hex-event-id");
const follows = await fetchFollows("hex-pubkey");
// → { event, pubkeys, fetchedAt }
const engagement = await fetchEngagement(["id1", "id2"]);
// → Map<id, { reactionCount, repostCount, zapTotalSats }>
// Outbox: union of defaults + the author's NIP-65 write relays
const relays = await relaysForAuthor("hex-pubkey");
const relayList = await fetchRelayList("hex-pubkey");
// → { read: string[], write: string[], event } | nullStreaming variants
streamProfile yields entry updates as relay events arrive (instead of resolving on EOSE).
import { streamProfile } from "@nostr-wot/data";
for await (const entry of streamProfile("hex-pubkey")) {
// each yield is an updated ProfileEntry — useful for showing partial results
}Subscription coalescer
sharedCoalescer merges concurrent reads from across your app — DM cache, profile cache, follower lists, etc. — into a single REQ per relay-set within a 50ms window. When the last consumer unsubscribes, the underlying subscription tears down.
import { sharedCoalescer } from "@nostr-wot/data";
// Live subscription
const stop = sharedCoalescer.enqueue({
filters: [{ kinds: [1], authors: ["hex"], limit: 50 }],
relays: ["wss://relay.damus.io"],
onEvent: (e) => { /* ... */ },
onEose: (relay) => { /* ... */ },
});
// One-shot
const events = await sharedCoalescer.querySync(
[{ kinds: [10002], authors: ["hex"], limit: 1 }],
{ relays, timeoutMs: 5000 },
);SWR cache layer
import {
getProfile,
getNote,
getThread,
getFollows,
getRelayList,
fetchEngagementBatch,
configurePersistence,
createKeyedObservable,
} from "@nostr-wot/data/cache";
configurePersistence({ namespace: "myapp", ttlMs: 24 * 3600_000 });
const profile = await getProfile("hex-pubkey");
// Cold-loads from localStorage if cached, refreshes in the background
// from relays as newer kind-0 events arrive.The cache exposes its own primitive — createKeyedObservable<K, V>() — used internally by every cached fetcher and by @nostr-wot/dm/cache. Useful if you're building your own SWR-style stream:
const obs = createKeyedObservable<string, MyValue>();
obs.set("key", { ... });
obs.subscribe("key", (v) => { /* re-render */ });
const slot = obs.get("key");React hooks
import {
NostrDataProvider,
useProfile,
useNote,
useThread,
useEngagement,
useFollows,
useRelayList,
} from "@nostr-wot/data/react";
<NostrDataProvider
relays={["wss://relay.damus.io", "wss://nos.lol"]}
profileAggregators={["wss://purplepag.es"]}
persistence={{ namespace: "myapp", ttlMs: 86400_000 }}
>
<App />
</NostrDataProvider>;
function ProfileCard({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey); // SWR
if (!profile) return <Skeleton />;
return <h1>{profile.displayName ?? profile.name}</h1>;
}<NostrDataProvider> configures defaults (relays, aggregators, persistence) on mount. If you're using @nostr-wot/wot or nostr-wot-sdk, prefer <NostrSdkProvider> instead — it wraps <NostrDataProvider> and adds opt-in WoT context.
Session context
@nostr-wot/data/react also hosts the shared session context — the single mount point for the active NostrSigner and the user's pubkey. It lives here (not in @nostr-wot/ui) so non-UI packages — DM hooks, blossom uploads, wallet/zap hooks — can read the signer from context without depending on the React UI package.
import {
NostrSessionProvider,
useSession,
useSigner,
usePubkey,
useLogin,
useLogout,
useKEKSigner,
} from "@nostr-wot/data/react";
<NostrSessionProvider
initialSigner={someSigner /* optional — e.g. constructed at boot */}
onChange={({ signer, pubkey }) => /* mirror to your state */}
onLogout={async () => { /* clear app caches */ }}
>
<App />
</NostrSessionProvider>;| Hook | Returns |
|---|---|
| useSession() | { signer, pubkey, isLoading, error, setSigner, logout } |
| useSigner() | The active NostrSigner or null |
| usePubkey() | The active hex pubkey or null |
| useLogin() | (signer) => Promise<void> callback — set the active signer |
| useLogout() | () => Promise<void> callback — drops the signer + runs onLogout |
| useKEKSigner() | A narrow KEKSigner (NIP-44 encrypt + decrypt + pubkey) when the active signer supports NIP-44, else null |
useKEKSigner exists because some operations — DM cache-key derivation, wallet local-store encryption — need NIP-44 specifically and must skip signers that don't expose it (e.g. NIP-46 bunkers with restricted perms). The hook returns null instead of throwing, so consumers can branch on capability:
const kek = useKEKSigner();
if (!kek) return <p>Your signer doesn't support NIP-44 — encrypted-at-rest cache disabled.</p>;
const cacheKey = await getOrCreateCacheKey(kek.pubkey, kek);@nostr-wot/ui mounts and consumes this same context for its login flows, and re-exports the hooks for convenience. If you're only using @nostr-wot/data (no UI), construct a signer yourself (from @nostr-wot/signers) and call useLogin()(signer) to attach it.
Outbox model (NIP-65)
Every fetcher routes through the outbox helper by default. When you ask for an author's content, the SDK resolves their kind-10002 (NIP-65) relay list and queries the union of their declared write relays + your defaults. This is the only reliable way to find content that isn't pinned to popular aggregators.
Override globally:
setDefaultRelays(["wss://relay.damus.io", "wss://nos.lol"]);
setProfileAggregators(["wss://purplepag.es"]);Or per-call (most fetchers accept an optional relays override):
await fetchProfile(pubkey, ["wss://my-private-relay"]);Pool sharing
@nostr-wot/data owns a single SimplePool instance, accessible via getPool(). If you have your own pool (e.g. for binary-frame coercion or NDK interop), wire it once at startup:
import { setPool } from "@nostr-wot/data";
const pool = new SimplePool({ websocketImplementation: MyWebSocket });
setPool(pool);Other @nostr-wot/* packages reuse this same pool — DM subscriptions, blossom uploads, WoT lookups, all share connections.
License
MIT
