Monorepo for the nibling.run platform
core packages sdk README.md
8.1 kB · 43 lines
Markdown
at main

@nibling/sdk #

The browser SDK for nibling's platform layer (VISION.md D18 to D22). It reads and writes nibling records on atproto, and it never imports @nibling/kernel. File trees cross its API as Map<string, Uint8Array>, and the app adapts them to the kernel's filesystem.

Every write takes a Session, a Client authenticated as a DID against that DID's PDS. Every read takes a DID or an AT-URI and reads publicly from the owner's PDS.

Reading through the gateway #

configure({ gateway: 'https://api.nibling.dev' }) sends every read to nibling's gateway (packages/api, D22) instead of each service: repo reads and blobs, identity, Constellation backlinks, Slingshot's getRecordByUri, and tangled imports through /git/<repo DID>. The gateway caches, and it names us to each service with a User-Agent, which a browser cannot set.

  • A gateway that does not answer, times out, or returns a 5xx gets one retry on the direct path for that call (preferGateway in gateway.ts). A 4xx is an answer. The direct path for a repo read resolves the PDS without the gateway.
  • Reads of the signed-in account's own repo send x-nib-fresh: 1, so the gateway skips its cache and a save shows up on the next read. freshFor() in identity.ts makes that call. auth.ts sets config.viewer on sign-in and clears it on sign-out.
  • Writes still go from the Session to the PDS, and pushes from isomorphic-git to the knot. OAuth's DPoP tokens are bound to a key in the browser, and a push token is single-use and lasts 60 seconds, so neither can pass through the gateway.
  • blobUrl(actor, cid) gives a getBlob URL for an <img>, on the gateway when one is set.

Unset, the default, every read goes straight to the services as before.

Outside a browser (no document), every default request and every git request sends User-Agent: nibling.dev/<version> (+https://nibling.dev/contact), from user-agent.ts. The worker and the lexicon publish script send the same string.

Modules #

  • identity.ts resolves a handle or DID to { did, handle?, pds }. It asks the gateway's resolveMiniDoc first when a gateway is set, then Slingshot, and falls back to PLC or did:web, with handles resolved through the Bluesky appview. resolveService reads a service entry such as #tangled_knot from a DID document. configure sets every endpoint, and it also takes an identity resolver and a handlerFor hook so that tests can run with no network. Every default request, identity resolution included, aborts after timeoutMs (30 s). A resolved handle, DID, or DID document is reused for 60 s (IDENTITY_TTL_MS), a handle and its DID sharing one entry, and configure forgets them all. Identical GETs in flight at once share one request, each caller reading its own clone.
  • records.ts reads records from a PDS. getRecord and listRecords build each record's URI from the repo, collection, and rkey that were asked for, and drop any record whose returned URI names something else, because a PDS can answer with any URI. Write results are checked the same way. paging.ts stops every listing at an empty page, a missing or repeated cursor, or 100 pages; listRecords also stops at a page shorter than the 100 it asked for, since a PDS sends a cursor even with its last page.
  • microcosm.ts reads Constellation backlinks and Slingshot's record cache, through the gateway when one is set. Both services are optional, so each call returns undefined when neither the gateway nor the service answers.
  • auth.ts wraps @atcute/oauth-browser-client. It holds the D19 scope string and builds client-metadata.json, with the http://localhost loopback client for development on http://127.0.0.1. signIn, completeSignIn, resume, and signOut run only in the browser.
  • projects/paths.ts rejects absolute paths, empty, ., and .. segments, and backslashes, and returns paths in NFC.
  • projects/tree.ts holds pure functions. diffAgainstBase computes the overlay that turns a base tree into the current tree, and applyOverlay applies one. blobCid computes the raw CID that a PDS assigns to a blob.
  • projects/store.ts lists, loads, creates, saves, forks, and deletes dev.nibling.project records. A save uploads only the blobs that the loaded record does not already reference, then writes with swapRecord. A lost swap throws ProjectConflictError. Before any upload, create, save, and fork check the record they are about to write against the lexicon (at most 1,000 entries, the name, description, and tag limits) and each file against 5 MB, and fail with ERR_NIB_LIMIT. Records and blobs from a PDS are checked against the lexicon and their CIDs before the store returns them. Loading downloads blobs 8 at a time and stops any blob past 5 MB and the project past maxBytes (100 MB), whatever the record claims. A project with a git base needs a loadBase hook, which the tangled module supplies.
  • feed.ts calls the index's getRecent, getTrending, and getKnown. Each returns undefined when no index is configured or the index is down.
  • social/ is D21. likes.ts has like, unlike, likeCount, likers, and myLike, which finds the session's own like by listing its dev.nibling.feed.like records, since there is no index to ask "did I like this." comments.ts has comment and comments, which reads Constellation backlinks, fetches each record through Slingshot or its author's PDS, and orders the valid ones into a thread. follows.ts has follow, unfollow, followers, following, myFollow, and forks; following and myFollow list the account's own PDS, and the rest read Constellation backlinks. import.ts has importFollows, which reads app.bsky.graph.follow or org.tangled.graph.follow plus legacy sh.tangled.graph.follow (merged without duplicates) from the session's own PDS, filters through the index's getKnown when one is configured, skips subjects already followed, caps what is left at max (500), and writes dev.nibling.graph.follow with via set to the source collection, in applyWrites batches of at most 200. It is idempotent: a second run writes nothing, and a run cut short by max continues where it stopped. An index that is configured but does not answer makes it return { outcome: 'indexUnavailable' } without writing. page.ts has actorPage, which merges dev.nibling.actor.profile with app.bsky.actor.profile's displayName, description, and avatar as a fallback, and lists the account's projects and its org.tangled.repo.declaration repos; it works for tangled orgs, which are ordinary accounts. setProfile writes the session's own profile. Every record this module reads from a PDS or Slingshot is checked against its lexicon before use.

Testing #

pnpm test runs node --test on Node 24's type stripping. tests/fake-pds.ts is an in-memory PDS that hosts several accounts and counts calls per method. Set pds.tamper.uri or pds.tamper.cursor to make it forge record URIs or repeat cursors, as a hostile PDS would. It stores whatever it is given, like a real PDS with an unknown lexicon, so tests can plant records that break limits. Pass fakeNetwork([pds, ...], services) to configure to route identity and XRPC to fakes. fakeGateway([pds]) answers the gateway's XRPC routes over the same fakes, and tests/gateway.test.ts checks that a configured gateway leaves no direct reads and that a failing one falls back.

node scripts/resolve.ts <handle> [--no-slingshot] resolves an actor against live infrastructure. It only reads.

Unverified #

  • The PDS's maximum record size. A project without a base can list 1,000 files, and nobody has measured that record against a real PDS.
  • How soon a PDS garbage-collects blobs that no record references. A save uploads blobs before it writes the record, so a failed or conflicting save leaves orphaned blobs.
  • Whether com.atproto.server.getServiceAuth honors the OAuth scope rpc:org.tangled.git.receivePack?aud=* for a knot audience.
  • The form of a knot's service DID. The tangled module assumes did:web:<knot host>.