# @nibling/api The one stateless Cloudflare Worker nibling runs (D22). It is the app's read path: a gateway that answers repo reads, identity, backlinks, and git fetches from a cache. The app works without it, reading each service directly, with no global feed, no trending, no link cards, and nothing from `dev.nibling.actor.getKnown`. All project data, likes, follows, and profiles live on each account's own PDS. This worker never holds that data as the source of truth. ## Routes - `GET /xrpc/dev.nibling.feed.getRecent?limit=&cursor=&tag=` — recently created or updated projects, newest first. A project sorts by its `updatedAt` (or `createdAt`), clamped to when the index saw that version, so a far-future date can't pin it to the top. Like and fork counts count distinct accounts. - `GET /xrpc/dev.nibling.feed.getTrending?limit=&cursor=` — projects ranked by likes and forks from the last 7 days, decayed linearly to zero at the window edge. A like or fork counts from its `createdAt`, but never later than when the index first saw it, so a post-dated record can't stay fresh. - `GET /xrpc/dev.nibling.actor.getKnown?dids=` — which of the given DIDs have a `dev.nibling.project` or `dev.nibling.actor.profile` record. Used to narrow imported Bluesky or Tangled follows to accounts that already use nibling. - `GET /:actor/:name` — a project page: Open Graph tags for crawlers, then a `` redirect to the app for everyone else. `:name` is the project's slug or its rkey; a slug is found by listing the author's PDS. Links use the slug when there is one. - `GET /:actor/:name/embed` — the same metadata, but the page itself is an iframe-friendly shell around the app's `/:actor/:name/embed` (nothing navigates away). - `GET /@:actor/:name` and `/@:actor/:name/embed` — a 301 to the same path without the `@`. - `GET /oembed?url=https://nibling.dev/actor/name` — oEmbed JSON (`type: "rich"`) for the page above. - `GET /oauth/client-metadata.json`, the atproto OAuth client metadata document (D19), built by `@nibling/sdk/metadata` so the app and the worker share one scope string. The XRPC routes send `Access-Control-Allow-Origin: *` and a short `Cache-Control`, and error as `{ "error": "...", "message": "..." }`. ## Gateway Every read the app makes goes through these routes (`src/gateway/`), all `GET` unless noted, all with `Access-Control-Allow-Origin: *`. `OPTIONS` on any path answers a preflight that allows `content-type`, `git-protocol`, and `x-nib-fresh`. | Route | Upstream | Cached for | |---|---|---| | `com.atproto.repo.getRecord` with `cid` | the repo's PDS | 1 year, and only when the answer carries that `cid` | | `com.atproto.repo.getRecord` without `cid`, `listRecords`, `describeRepo` | the repo's PDS | 15 s, then served stale for 60 s while it refreshes | | `com.atproto.sync.getBlob` | the repo's PDS | 1 year | | `blue.microcosm.identity.resolveMiniDoc` | Slingshot, then PLC or did:web and DNS-over-HTTPS | 5 minutes | | `com.atproto.identity.resolveDid` | PLC or the did:web host | 5 minutes | | `blue.microcosm.links.getBacklinks`, `getBacklinksCount` | Constellation | 15 s | | `blue.microcosm.repo.getRecordByUri` (`at_uri`, optional `cid`) | Slingshot | 15 s with the stale window, or 1 year with `cid` | | `/git//info/refs?service=git-upload-pack` | the repo's `#tangled_knot` | never | | `POST /git//git-upload-pack` | the repo's `#tangled_knot` | 1 year when the body is only `want`, `deepen`, and `done` lines, keyed by its SHA-256; otherwise never | - Every response says how it was served in `x-nib-cache`: `hit`, `stale`, `miss`, or `bypass`. A request with `x-nib-fresh: 1` skips the cached answer and stores the new one, which is how a writer reads their own write. The SDK sends it for the signed-in account's own repo. - The Cache API is per data center, and a purge does not reach the others, so nothing purges. Every entry ages out on its TTL. The Cache API ignores `stale-while-revalidate`, so `src/gateway/cache.ts` serves stale entries itself and refreshes them with `waitUntil`. Only a `200` is stored. - Each route checks its params at the boundary (a DID or handle through `@nibling/sdk/identifier`, an NSID, a record key, a CID, a limit from 1 to 100) and drops any other param. A `repo` handle is resolved and forwarded as its DID, so the handle and the DID share one cache entry. - The PDS or knot comes from cached identity and must be a public `https:` URL (`isPublicHttpsUrl`). Redirects are followed by hand, at most 3, and each hop is checked the same way. The git routes proxy only to the knot the repo DID's document names, never to a host from the request. - Upstream calls time out after 10 seconds (60 for git). A JSON answer or a blob over 5 MB, and a pack over 100 MB, is cut off. An upstream 5xx, timeout, or cut-off becomes a 502 or 504, which tells the SDK to retry on the direct path. A 4xx passes through as an answer. - Blobs are served with `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff`, since an uploader picks the content type. - `git-receive-pack` is refused with a 403. Pushes, like PDS writes, go from the browser straight to the knot or PDS: OAuth's DPoP tokens are bound to a browser key, and a push token is single-use and expires in 60 seconds. ## User-Agent Every request this worker makes carries `User-Agent: nibling.dev/ (+https://nibling.dev/contact)`, so a service can tell us about runaway traffic. Set `CONTACT_URL` to change the address. `upstream()` (`src/upstream.ts`) adds the header, and only it makes the branded `Upstream` type that the router, gateway, backfill, ingest, and identity code accept, so a bare `fetch` does not typecheck. Jetstream is opened with a `fetch` carrying `Upgrade: websocket` rather than `new WebSocket`, so its handshake carries the header too. `src/services.ts` is the only file that reads `globalThis.fetch`. `tests/user-agent.test.ts` replaces the global `fetch` while link cards, backfill, ingest, and every gateway route run, and fails on any request without exactly that header. ## What's stored One SQLite-backed Durable Object (`Index`, binding `INDEX`, one global instance) holds a cache of `dev.nibling.project`, `dev.nibling.feed.like`, `dev.nibling.graph.follow`, and `dev.nibling.actor.profile` records: enough to answer `getRecent`, `getTrending`, and `getKnown` without asking every PDS on every request. It is never the source of truth — compare Tangled's Bobbin (D20) — and the schema lives in `src/index/model.ts`. Not stored as a source of truth: project files (the author's PDS), git (tangled knots), previews (the viewer's own browser), backlink counts such as "who liked this" (microcosm's Constellation), and identity (PLC and DNS). The gateway caches reads of them for a while, and nothing more. ## How it rebuilds Every 60 seconds, a Durable Object alarm (`Index.alarm`, `src/worker.ts`) runs one ingestion pass (`ingest()`, `src/index/ingest.ts`): 1. If there's no stored cursor, or it's older than Jetstream's replay window (treated as 24h), backfill first: `com.atproto.sync.listReposByCollection` on the relay for each of the four collections, then `com.atproto.repo.listRecords` against each repo's own PDS (`src/index/backfill.ts`). A repo whose PDS can't be resolved or reached is skipped, not fatal. A repo that was listed completely has its other rows in that collection deleted, which catches deletions made while the index wasn't listening. The time the backfill started becomes the cursor, so the live tail replays everything that happened during it. 2. Open a live-tail WebSocket to Jetstream from the cursor and drain it until an event's `time` is within 5 seconds of now, or a 20-second budget runs out, applying events in order and saving the cursor after each one. The cursor is a Jetstream `time_us`. A drain that stayed open and heard nothing for its last 5 seconds moves the cursor up to when the socket opened (minus 5 seconds), but never more than 10 minutes past where it started: Jetstream sends nothing on a quiet filtered stream, and its replay scans about 12 minutes of network time per second, so a silent drain has only proven that much. 3. The alarm reschedules itself for 60 seconds later, in a `finally`, so a pass that throws still re-arms (`src/alarm.ts`). No outgoing WebSocket is held open between passes, so the Durable Object isn't billed for an idle connection. The first alarm is armed from the Durable Object's constructor (`blockConcurrencyWhile`), so the first request that wakes the object starts ingestion. Relays, PDSes, PLC, and Slingshot are untrusted. Every call to them has a 10-second timeout (`src/net.ts`). Listings stop on an empty or repeated cursor, and after 1000 relay pages or 100 PDS pages per repo and collection. A repo that answers with an error or a malformed page is skipped, and its rows are not pruned. A PDS endpoint must be a public `https:` URL (`isPublicHttpsUrl`), and `did:web` documents are fetched from the path the DID names (`src/identity.ts`). Backfilled records must name the repo and collection that were listed. Every record is validated against its generated `@nibling/lexicons` schema at the boundary (`toPutEvent` in `src/index/jetstream.ts`, shared by the live tail and the backfill path); a record that fails validation is dropped, not indexed. `IndexModel.apply()` is idempotent — in Jetstream's words, delivery is at-least-once, so replaying the same create or the same delete twice leaves the index unchanged — because the whole index can be thrown away and rebuilt from the network at any time. ### Jetstream v2 Jetstream's live API is `wss:///xrpc/network.bsky.jetstream.subscribeEvents`, with a repeated `collections` param (an `app.bsky.feed.*`-style NSID-prefix wildcard works, so `dev.nibling.*` does too), `kinds`, and a `cursor` that takes either a `seq` or a `time_us`, told apart by magnitude. The index stores a `time_us`, since that also gives the cursor's age. `src/index/jetstream.ts` targets this v2 shape; `wrangler.jsonc`'s `JETSTREAM_URL` points at `wss://jetstream.us-east.bsky.network` accordingly. `com.atproto.sync.listReposByCollection` takes `collection`, `limit`, `cursor` and returns `{ repos: [{did}], cursor }`. ## Local dev ```sh pnpm --filter @nibling/api test # node --test, tests/**/*.test.ts pnpm --filter @nibling/api typecheck # tsc --noEmit pnpm --filter @nibling/api dev # wrangler dev (local only; CI deploys this worker) ``` The model tests run the same `Sql` interface the Durable Object uses, but over `node:sqlite` (`DatabaseSync`) instead of `ctx.storage.sql`, so they need no Workers runtime. The router, gateway, cards, jetstream-parsing, backfill, and ingest-loop tests all take their external dependencies (the index stub, `fetch`, the WebSocket, `resolvePds`, the cache, the clock) as injected parameters, so they also run under plain Node with no network and no Workers runtime. The gateway tests use `memoryCache()`, which expires entries on the injected clock as the Cache API does. The Jetstream parser and ingest tests run on frames captured from the live v2 endpoint (`tests/fixtures/jetstream/`): commit create and delete, identity, and account. Since nobody writes `dev.nibling.*` yet, the `dev.nibling.*` fixtures keep a captured envelope and change only `collection` and `record`. ### Reading through a local gateway ```sh cd packages/api npx wrangler dev --local --port 8787 --ip 127.0.0.1 ``` `--local` keeps the Durable Object and the cache on this machine. The gateway still reads the real network: PDSes, PLC, Slingshot, Constellation, and knots. Then point the playground at it: ```sh VITE_NIBLING_GATEWAY=http://127.0.0.1:8787 pnpm --filter @nibling/app dev ``` With `VITE_NIBLING_GATEWAY` unset, the playground reads every service directly. Check the cache from the command line: the second of two identical reads says `x-nib-cache: hit`. ```sh curl -sD - -o /dev/null 'http://127.0.0.1:8787/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=natemoo.re' | grep x-nib-cache ``` This worker is never deployed from a development session. `.tangled/workflows/deploy.yml` deploys it to `api.nibling.dev` on every push to `main`, with `pnpm run deploy`, which sets `APP_ORIGIN` to `https://nibling.dev`.