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

@hedgerow/reader

v0.3.1

Published

Browser-side reader identity for Bluesky comments: atproto OAuth login (BrowserOAuthClient) and writing a reply post to the reader's own PDS.

Readme

@hedgerow/reader

Compatibility and advanced browser package. New integrations should start with createBrowser() from hedgerow/browser, then bind the returned session with hedgerow/social or hedgerow/site. createReader() remains supported while existing sites migrate.

Browser-side reader identity for Bluesky comments: atproto OAuth login (via @atproto/oauth-client-browser) and writing a reply post to the reader's own PDS. This is v1 of "comment in place" — a reader logs into their own Bluesky account in your page and posts a real app.bsky.feed.post reply, no redirect to bsky.app required.

Framework-agnostic (no React) so @hedgerow/react's Reply.* parts stay dependency-light — you wire the two together yourself. See docs/architecture.md for the dependency rule.

Sessions are per-origin: each domain that embeds a comment box gets its own login and its own cached session, in that browser's IndexedDB. There is no cross-site single sign-on in v1.

Install

npm install @hedgerow/reader

Quick start

import { createReader } from "@hedgerow/reader";

const reader = createReader(); // loopback client id in dev; see Client ID below for prod

// Once per page load — resumes the last session, or completes a pending
// OAuth redirect if the page just landed back from one.
const session = await reader.restore(); // { did, handle, displayName? } | null

if (!session) {
  // Existing account: redirects the browser to the reader's PDS/authorization
  // server. Never resolves on success.
  await reader.signIn("someone.bsky.social");

  // New to Bluesky: signUp() starts the flow with prompt: "create" instead of
  // a handle — the reader creates their account on the authorization server
  // mid-flow and lands back here already authorized. No separate "go create
  // an account, then come back and log in" round trip.
  await reader.signUp(); // defaults to https://bsky.social; pass a different service to override
}

const reply = await reader.createReply({
  root: { uri: "at://did:plc:.../app.bsky.feed.post/abc", cid: "bafy..." },
  parent: { uri: "at://did:plc:.../app.bsky.feed.post/abc", cid: "bafy..." },
  text: "Great post!",
});
// { uri, cid } of the new reply record, written to the reader's own PDS.

await reader.signOut();

API

function createReader(options?: CreateReaderOptions): Reader;

interface CreateReaderOptions {
  clientId?: string;          // hosted client-metadata.json URL; omit for loopback dev
  scope?: string;             // defaults to legacy generic for createReader compatibility
  handleResolver?: string;    // resolves handles to DIDs; default the public Bluesky AppView
  plcDirectoryUrl?: string;   // override the PLC directory; local/test networks only
  allowHttp?: boolean;        // allow http:// AS/resource metadata endpoints; local/test networks only
  createClient?(): OAuthClientLike | Promise<OAuthClientLike>;  // test seam
  createAgent?(session: OAuthSessionLike): AgentLike;           // test seam
}

interface Reader {
  restore(): Promise<ReaderSession | null>;
  signIn(handle: string, opts?: { state?: string; scope?: string }): Promise<never>;
  signUp(service?: string, opts?: { state?: string; scope?: string }): Promise<never>;
  signOut(): Promise<void>;
  getProfile(): Promise<ReaderProfile | null>;  // did, handle, displayName, avatar — always hits the network
  createReply(input: CreateReplyInput): Promise<StrongRef>;
  asPublisher(): PublisherLike;  // deprecated compatibility transport
  like(subject: StrongRef): Promise<StrongRef>;     // app.bsky.feed.like; deduped against findLike first
  unlike(likeUri: string): Promise<void>;
  findLike(subjectUri: string): Promise<string | null>;  // "did I like this?" — see the bound below
  takeCallbackState(): string | null;  // one-shot; see "Resuming intent after the redirect" below
}

interface ReaderSession { did: string; handle: string; displayName?: string }
interface ReaderProfile extends ReaderSession { avatar?: string }
interface StrongRef { uri: string; cid: string }
interface CreateReplyInput {
  root: StrongRef;    // the thread's root post
  parent: StrongRef;  // the post being replied to directly
  text: string;       // no facets (mentions/links/hashtags) in v1
}
interface PublisherLike {
  did: string;
  readonly supportsSwapRecord: true;
  putRecord(collection: string, rkey: string, record: Record<string, unknown>, options?: { swapRecord?: string | null }): Promise<{ uri: string; cid: string }>;
  getRecord(collection: string, rkey: string): Promise<Record<string, unknown> | null>;  // null if absent
  getRecordWithCid(collection: string, rkey: string): Promise<{ uri: string; cid: string; value: Record<string, unknown> } | null>;
  deleteRecord(collection: string, rkey: string, options?: { swapRecord?: string }): Promise<void>;
}

Legacy record transport: asPublisher()

asPublisher() is retained for compatibility. New code should use author(session, { ownerDid, publicationUri }) from hedgerow/site, which does not expose arbitrary collection and record-key writes.

const session = await reader.restore();
if (session) {
  const publisher = reader.asPublisher(); // throws if called while signed out
  await publisher.putRecord("site.standard.document", rkey, updatedRecord, {
    swapRecord: loadedDocument.cid,
  });
}

The methods close over the live session, so a Publisher built before a later signOut() correctly starts throwing on use afterward too, rather than silently keeping a stale session alive. apps/demo's /edit route is the reference consumer: it passes this adapter to @hedgerow/publish's CID-safe updateDocument() operation.

restore() is the only call you need on page load: it transparently handles both "returning visitor with a cached session" and "just landed back from the OAuth redirect" — the underlying BrowserOAuthClient.init() distinguishes them internally. It's safe to call more than once (e.g. a component mounting twice under React Strict Mode); later calls reuse the first call's result rather than re-running the OAuth client's one-time init.

Liking a post

const ref = await reader.like({ uri: post.uri, cid: post.cid }); // { uri, cid } of the new like record
await reader.unlike(ref.uri);

There is no authenticated AppView to ask "did I like this" directly — the public AppView (what @hedgerow/comments reads) has no viewer state. findLike answers it instead by paging the reader's own app.bsky.feed.like collection via com.atproto.repo.listRecords, newest first:

const likeUri = await reader.findLike(post.uri); // the like's own uri, or null

The bound, honestly: this pages at most ~10 pages (~1000 like records) before giving up. A reader who has liked more than ~1000 things more recently than the post in question is still found (it's newest-first); one who liked THIS post a long time ago, buried under a mountain of more recent likes, may come back null even though a like technically exists — the button would then show "not liked". Liking again in that state is harmless on Bluesky's side (both records just count toward the post's likeCount) but does create a duplicate record. like() mitigates this by calling findLike first and reusing the existing ref instead of writing a new one, so the residual failure mode narrows to a reader who both (a) has that pathological like history and (b) clicks like on a post they secretly already liked ages ago — accepted as-is for v1.

Results are cached in memory for the lifetime of the Reader instance (cleared on sign-in/out), so repeated findLike/like calls for the same subject after the first are free — no re-paging.

Client ID

The OAuth client_id tells the authorization server who's asking. @hedgerow/reader supports both stories atproto OAuth defines for a public client:

  • Local dev, on a loopback origin (127.0.0.1 / [::1]) — omit clientId. default-client.ts builds a spec-correct loopback client id for the current page and embeds the configured scope alongside redirect_uri. The authorization server synthesizes matching client metadata, so there is nothing to host. Only works on an actual loopback address (not localhost).

  • A real deployment — pass clientId pointing at a hosted client-metadata.json (the URL is the client id). createReader({ clientId: "https://example.com/oauth/client-metadata.json" }) fetches it via BrowserOAuthClient.load(). An example document is at apps/demo/public/oauth/client-metadata.json — copy it, update client_id/client_uri/redirect_uris to your real domain, and serve it from that exact URL. It only takes effect once it's live on a real domain; the demo repo can't self-host its own client metadata for local testing (that's what the loopback path is for).

createBrowser() defaults to identity-only (atproto). SOCIAL_SCOPE grants only profile lookup, reply creation, and like create/delete. createReader() keeps LEGACY_GENERIC_SCOPE as its default solely for compatibility; pass SOCIAL_SCOPE explicitly after updating hosted client metadata. Metadata is the maximum grant, while signIn({ scope }) may request a subset or a later expanded grant.

Consent

Both signIn() and signUp() land on a consent screen every time, on the reader's own authorization server — this is enforced server-side, not a choice @hedgerow/reader makes. @atproto/oauth-provider forces consent for any client whose token_endpoint_auth_method is "none" (a public browser SPA is exactly that) and rejects a silent (prompt: "none") authorization request outright. signUp()'s prompt: "create" is the one value the provider exempts from that forced-consent gate — it's what lets a new account land back authorized without an extra approval step, not a way to skip consent for an existing one.

The only silent path here is restore() resuming a session already cached in this origin's IndexedDB. There is no silent sign-in across a fresh login, and no cross-site session sharing — word your UI accordingly ("you'll approve access on your Bluesky server"), not as an "instant" login.

Resuming intent after the redirect

signIn()/signUp() navigate the whole page away to the reader's authorization server and back — anything held in memory (which reply box was open, what the reader had typed, which post they were replying to) is gone by the time restore() runs on the reloaded page. opts.state on signIn/signUp, paired with Reader.takeCallbackState(), is how you carry a small piece of that intent through the round trip: it's passed straight through to the OAuth state parameter, which the authorization server round-trips verbatim and hands back on the callback.

// Before redirecting — stash an id for whatever the reader was doing.
await reader.signIn(handle, { state: replyBoxId });

// After the redirect, once per page load:
const session = await reader.restore();
const replyBoxId = reader.takeCallbackState(); // the id, or null
if (replyBoxId) {
  // scroll back to / re-open that specific reply box, now signed in
}

takeCallbackState() is one-shot: it returns the stashed value the first time it's called after a restore() that completed a fresh OAuth callback, then resets to null — a second call, or any call after a restore() that instead resumed a session from cache (no redirect involved), returns null. There is no separate "was this a fresh callback?" boolean: the presence of a non-null state is that signal. If you only need the boolean and have no payload to carry, pass any non-empty string as state and treat a non-null takeCallbackState() return as "yes, this restore just completed a callback."

Keep whatever you put in state small and non-sensitive — it round-trips through the authorization server and the URL, not a private channel. An opaque id you use to look up local state (like replyBoxId above) is the intended shape, not the state itself.

Handle resolver

Resolving a handle (e.g. someone.bsky.social) to a DID needs a DNS lookup the browser can't do itself, so BrowserOAuthClient delegates it to an HTTP service implementing com.atproto.identity.resolveHandle. @hedgerow/reader defaults handleResolver to https://public.api.bsky.app (the same AppView @hedgerow/comments reads from) for convenience.

Privacy note: any Bluesky-hosted resolver (the default, or https://bsky.social) sees the handle being resolved and the caller's IP. If you self-host a PDS, pass its URL as handleResolver instead to keep that resolution off Bluesky's infrastructure.

Testing

Unit tests inject both createClient and createAgent, so nothing in test/reader.test.ts ever touches WebCrypto, IndexedDB, or the network — see the file header for the full rationale. src/default-client.ts (the real BrowserOAuthClient/Agent wiring) is exercised for real, automatically, by apps/demo's Playwright suite (pnpm --filter demo e2e) — see docs/local-testing.md for how that drives an actual /oauth/authorize → password → consent → redirect flow against a fully local atproto network, with no live network involved.

restore() is resilient to a failed profile fetch right after login: the session itself is valid the moment the OAuth redirect completes, but getProfile() (a separate app.bsky.actor.getProfile call) can fail independently — e.g. on a bare local test PDS with no AppView to proxy to. Rather than let that collapse a genuinely successful login back to "signed out," restore() falls back to the reader's did as a placeholder handle; a later getProfile() call fills in the real one once/if it succeeds. On a real deployment (a real PDS backed by a real AppView) this fallback essentially never triggers.

License

MIT