@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
@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-tools
Vanilla 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");
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");
const engagement = await fetchEngagement(["id1", "id2"]);
const relays = await relaysForAuthor("hex-pubkey");
const relayList = await fetchRelayList("hex-pubkey");
Streaming 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")) {
}
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";
const stop = sharedCoalescer.enqueue({
filters: [{ kinds: [1], authors: ["hex"], limit: 50 }],
relays: ["wss://relay.damus.io"],
onEvent: (e) => { },
onEose: (relay) => { },
});
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");
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) => { });
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);
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>;
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