diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md new file mode 100644 index 0000000..285136b --- /dev/null +++ b/MICROCOSM_PLAN.md @@ -0,0 +1,163 @@ +# Plan: replacing the Bluesky API with microcosm services + +## TL;DR — the hard truth + +The app makes **~91 `app.bsky.*` read/hydrated queries** (timeline, post threads, +profiles, author feeds, feeds, search…). **None of the three microcosm services +provide these.** They serve atproto *primitives*, not the hydrated "View" objects +the UI is built on: + +| Service | Serves | Replaces in this app | +|---|---|---| +| **Slingshot** | `com.atproto.repo.getRecord`, `resolveHandle`, by-URI record fetch, MiniDoc identity, service resolution, a `hydrateQueryResponse` proxy | the ~12 `com.atproto.repo.*`/`sync` **record reads** + identity resolution | +| **Constellation** | backlink index: counts + lists of who-liked / who-reposted / who-replied / who-follows / who-quotes | the **interaction counts & lists** that today come baked into hydrated `app.bsky.*` views | +| **Spacedust** | WebSocket firehose of interactions (a "notifications firehose") | a live **notifications** stream, and live counters | + +**Conclusion:** microcosm gives the *ingredients*, not the *meal*. There is no +"getTimeline" or "getPostThread" primitive. To be independent of `api.bsky.app` +we must **build a client-side hydration layer** that assembles `app.bsky.*`-shaped +View objects from {Slingshot records + Slingshot identities + Constellation counts}. +That is, in effect, writing a mini-AppView inside the client. + +This is a real project, not a config swap. Below is a staged plan that yields +working increments instead of a big-bang rewrite. + +--- + +## Current architecture (what we're replacing) + +- All API calls go through `@atproto/api`'s `AtpAgent` (`agent.app.bsky.*`, + `agent.com.atproto.*`). The agent is created in `src/state/session/agent.ts` + (`BskyAppAgent extends AtpAgent`). +- Reads are routed to the **AppView** via service proxying: every agent sets the + `atproto-proxy: #bsky_appview` header (`BLUESKY_PROXY_HEADER` in + `src/lib/constants.ts`), so `app.bsky.*` queries hit `api.bsky.app` + (`PUBLIC_APPVIEW` / `PUBLIC_APPVIEW_DID`). +- Logged-out / public reads use `createPublicAgent()` → `PUBLIC_BSKY_SERVICE` + (`public.api.bsky.app`). +- Reads/writes are wrapped in `@tanstack/react-query` hooks under + `src/state/queries/**`. **This is the seam to intercept** — the UI consumes + hooks, not the agent directly, so we can swap the data source per-hook behind + the same hook signature. + +### API surface inventory (from `agent.*` call sites in `src/`) +- **~91** `app.bsky.*` read queries (`get*`, `search*`) → *no microcosm equivalent; need hydration layer* +- **~11** `com.atproto.repo.*` (getRecord + applyWrites/putRecord/createRecord/deleteRecord) +- **1** `com.atproto.sync.getRepo` +- **~20** `com.atproto.server.*` (auth/account — these go to the **PDS**, never the AppView; unaffected) +- writes (`putRecord`/`applyWrites`/`createRecord`) go to the user's **PDS**, also unaffected by AppView choice + +### Key files +- `src/lib/constants.ts` — `PUBLIC_APPVIEW`, `PUBLIC_APPVIEW_DID`, `PUBLIC_BSKY_SERVICE`, `BLUESKY_PROXY_HEADER` +- `src/state/session/agent.ts` — agent construction + `configureProxy` +- `src/state/queries/**` — ~all read hooks (interception layer) +- record reads to convert to Slingshot: + - `src/features/liveNow/index.tsx:268` + - `src/state/queries/messages/actor-declaration.ts:128` + - `src/state/queries/threadgate/index.ts:119` + - `src/state/queries/postgate/index.ts:58` + +--- + +## What each microcosm service maps to + +### Slingshot — record + identity reads ✅ clean drop-in +- `com.atproto.repo.getRecord` → **same lexicon, compatible response** (`{uri, cid, value}`). Point record reads at `slingshot.microcosm.blue` instead of the PDS/AppView. +- `blue.microcosm.repo.getRecordByUri` → ergonomic at-uri fetch (no repo/collection/rkey split). +- `com.atproto.identity.resolveHandle` → handle→DID, bi-directionally verified. +- `blue.microcosm.identity.resolveMiniDoc` → `{did, handle, pds, signing_key}` — everything needed to then talk to the right PDS for writes. +- `com.bad-example.proxy.hydrateQueryResponse` → can fetch records referenced by paths in *any* upstream XRPC response (partial hydration helper; experimental). + +### Constellation — interaction graph ✅ verified working (see `constellation.openapi.yaml`) +Source strings map directly to bsky interactions: +- likes on a post: `source=app.bsky.feed.like:subject.uri` +- reposts: `source=app.bsky.feed.repost:subject.uri` +- replies: `source=app.bsky.feed.post:reply.parent.uri` +- quotes: `source=app.bsky.embed.record:record.uri` (and recordWithMedia) +- followers of a DID: `subject=&source=app.bsky.graph.follow:subject` +- blocks: `source=app.bsky.graph.block:subject` +- `getBacklinksCount` → like/repost/reply counts +- `getBacklinkDids` → "liked by" / "followers" lists (DIDs → resolve via Slingshot) + +### Spacedust — live interactions ✅ websocket +- `/subscribe?wantedSources=...&wantedSubjectDids=` → a **notifications** stream (someone liked/replied/followed *you*), replacing `app.bsky.notification.*` polling for the live case. + +--- + +## Staged plan + +### Phase 0 — client scaffolding (no behavior change) +- Add `src/lib/microcosm/` with typed clients generated/handwritten from the 3 specs: + - `constellation.ts` (from `constellation.openapi.yaml`, verified) + - `slingshot.ts` (from `slingshot.json`) + - `spacedust.ts` (websocket) +- All requests send `User-Agent: fih (microcosm client; scan@scanash.com)` per microcosm etiquette. +- Add `src/env` hosts: `CONSTELLATION_URL`, `SLINGSHOT_URL`, `SPACEDUST_URL` (default to public instances). +- **No wiring yet** — just the clients + a manual test screen. + +### Phase 1 — route record + identity reads through Slingshot (low risk) +- Replace the 4 `com.atproto.repo.getRecord` call sites with a Slingshot-backed + helper (falls back to the PDS on miss). These are isolated and already + `com.atproto`-shaped, so this is a near-drop-in. +- Route handle/DID resolution through Slingshot `resolveMiniDoc` where the app + resolves identities outside the logged-in agent. +- **Win:** record reads no longer depend on `api.bsky.app`. Fully behind a flag. + +### Phase 2 — interaction counts/lists from Constellation (high value, isolated) +- Add hooks: `useBacklinkCount(subject, source)`, `useLikedBy(uri)`, + `useRepostedBy(uri)`, `useFollowers(did)` backed by Constellation. +- First surface them as **additive UI** (e.g. real-time counts, "who liked" + lists) — this works *alongside* the bsky AppView and immediately does + something the stock client can't (cross-lexicon backlinks). +- Then optionally use them to **override** the counts in hydrated views. + +### Phase 3 — the hydration layer (the actual AppView replacement) +Build `src/lib/microcosm/hydrate.ts` that assembles Views client-side: +- `PostView` ← Slingshot getRecord(post) + Slingshot MiniDoc(author) + + Constellation counts(likes/reposts/replies) + viewer-state (likes/reposts by + *me*, via Constellation `did` filter on my DID). +- `ProfileView` ← Slingshot MiniDoc + the profile record + Constellation + follower/following counts. +- `ThreadView` ← post + Constellation replies (`app.bsky.feed.post:reply.parent.uri`) + → recurse. +- Wire `getProfile` / `getPostThread` / single-post hooks to this behind the flag. +- **This is the bulk of the work** and where "build a mini-AppView" lives. + +### Phase 4 — feeds & timeline (hardest; may stay hybrid) +- **Following timeline:** read my `app.bsky.graph.follow` records (Constellation + or Slingshot) → fan-out fetch recent posts per follow → merge client-side. + Expensive; viable for modest follow counts, needs caching/windowing. +- **Algorithmic feeds (`getFeed`)**: require a feed generator producing a + skeleton; cannot be reproduced from microcosm primitives alone. Either keep + using third-party feed generators (not bsky AppView — just the FG service) + + Slingshot `hydrateQueryResponse` to hydrate the skeleton, or drop algo feeds. +- **Search (`searchActors`/`searchPosts`)**: no microcosm equivalent → keep, or + integrate a separate search index later. + +### Phase 5 — live notifications via Spacedust +- Replace notification polling with a Spacedust subscription on the user's DID. + +### Phase 6 — writes & auth (mostly already independent) +- `com.atproto.server.*` and `com.atproto.repo.put/applyWrites` already target + the user's **PDS**, not the AppView — independent of this work once OAuth/app- + password login resolves the PDS (Slingshot MiniDoc gives the PDS URL). + +--- + +## Honest assessment of "fully independent" + +- **Achievable for:** record reads (Slingshot), identity (Slingshot), + interaction counts/lists (Constellation), live notifications (Spacedust), + writes/auth (PDS, already independent), the following-feed and single-post/ + profile/thread views (hydration layer). +- **Hard / maybe-not:** algorithmic feeds (need a feed generator) and full-text + search (need a search index). These are genuinely separate services in + atproto; microcosm doesn't provide them. A fully-independent app would either + drop them or add another service. +- **Effort:** Phases 0–2 are a few focused sessions and give big, demoable wins. + Phase 3 is the real build. Phases 4–5 are research-heavy. + +## Recommended first move +Phase 0 + Phase 2 (Constellation): scaffolding + a real "who liked this / live +like count" feature sourced entirely from microcosm. Proves the whole pipe +end-to-end, ships something the stock client can't do, and de-risks Phase 3. diff --git a/constellation.openapi.yaml b/constellation.openapi.yaml new file mode 100644 index 0000000..731ac99 --- /dev/null +++ b/constellation.openapi.yaml @@ -0,0 +1,574 @@ +openapi: 3.1.0 +info: + title: Constellation API + version: '1.0.0' + summary: atproto-wide index of PDS record back-links + description: > + Constellation 🌌 (from microcosm ✨) is a self-hosted JSON API to an + atproto-wide index of PDS record back-links, letting you query social + interactions in real time — e.g. how many people liked a post, who follows + an identity, all replies to a record, or all sources linking to a URI. + + + It works by recursively walking all records coming through the firehose, + searching for anything that looks like a link. Links are indexed by the + **target** they point at, the **collection** the record came from, and the + **JSON path** to the link within that record. + + + **Etiquette:** When using the public instance, set a `User-Agent` header + that includes your project name and a contact (bsky username or email). + + + **Note on response schemas:** The upstream Constellation docs document + request parameters and the `cursor` pagination field, but do not publish + full response body schemas. The response schemas below were verified against + the live public instance (June 2026). + contact: + name: microcosm + url: https://www.microcosm.blue/ + license: + name: See microcosm.blue + +servers: + - url: https://constellation.microcosm.blue + description: Public instance + +tags: + - name: links + description: Current (XRPC, blue.microcosm.links.*) endpoints + - name: legacy + description: Deprecated REST endpoints — supported for the foreseeable future, but new apps should prefer the XRPC endpoints + +paths: + /xrpc/blue.microcosm.links.getBacklinks: + get: + operationId: getBacklinks + tags: [links] + summary: List records linking to a target + description: A list of records linking to any record, identity, or URI. + parameters: + - $ref: '#/components/parameters/Subject' + - $ref: '#/components/parameters/Source' + - $ref: '#/components/parameters/DidFilter' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Reverse' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of backlink records. + content: + application/json: + schema: + $ref: '#/components/schemas/BacklinksResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /xrpc/blue.microcosm.links.getBacklinksCount: + get: + operationId: getBacklinksCount + tags: [links] + summary: Count of links pointing at a target + description: The total number of links pointing at a given target. + parameters: + - $ref: '#/components/parameters/Subject' + - $ref: '#/components/parameters/Source' + responses: + '200': + description: The total link count. + content: + application/json: + schema: + $ref: '#/components/schemas/CountResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /xrpc/blue.microcosm.links.getBacklinkDids: + get: + operationId: getBacklinkDids + tags: [links] + summary: List distinct DIDs linking to a target + description: > + A list of distinct DIDs (identities) with links to a target. + + + Note: the Constellation docs title this section "getDistinct" but the + worked example uses the path `blue.microcosm.links.getBacklinkDids`, + which is treated as authoritative here. + parameters: + - $ref: '#/components/parameters/SubjectDistinct' + - $ref: '#/components/parameters/Source' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of distinct linking DIDs. + content: + application/json: + schema: + $ref: '#/components/schemas/DistinctDidsResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /xrpc/blue.microcosm.links.getManyToMany: + get: + operationId: getManyToMany + tags: [links] + summary: List many-to-many join records + description: > + A list of many-to-many join records linking to a target and a secondary + target. + parameters: + - $ref: '#/components/parameters/Subject' + - $ref: '#/components/parameters/Source' + - $ref: '#/components/parameters/PathToOther' + - $ref: '#/components/parameters/DidFilter' + - $ref: '#/components/parameters/OtherSubject' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of many-to-many join records. + content: + application/json: + schema: + $ref: '#/components/schemas/ManyToManyResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /xrpc/blue.microcosm.links.getManyToManyCounts: + get: + operationId: getManyToManyCounts + tags: [links] + summary: Counts over many-to-many join records + description: Counts of many-to-many join records by secondary target. + parameters: + - $ref: '#/components/parameters/Subject' + - $ref: '#/components/parameters/Source' + - $ref: '#/components/parameters/PathToOther' + - $ref: '#/components/parameters/DidFilter' + - $ref: '#/components/parameters/OtherSubject' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of counts grouped by secondary target. + content: + application/json: + schema: + $ref: '#/components/schemas/ManyToManyCountsResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links: + get: + operationId: getLinksLegacy + tags: [legacy] + deprecated: true + summary: '[DEPRECATED] List records linking to a target' + description: > + Deprecated. Use `GET /xrpc/blue.microcosm.links.getBacklinks`. Remains + supported for the foreseeable future. + parameters: + - $ref: '#/components/parameters/Target' + - $ref: '#/components/parameters/Collection' + - $ref: '#/components/parameters/Path' + - $ref: '#/components/parameters/DidFilter' + - name: from_dids + in: query + deprecated: true + description: 'Deprecated. Use `did` instead. Comma-separated list of DIDs.' + schema: + type: string + example: did:plc:vc7f4oafdgxsihk4cry2xpze,did:plc:vc7f4oafdgxsihk4cry2xpze + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Reverse' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of backlink records. + content: + application/json: + schema: + $ref: '#/components/schemas/BacklinksResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links/distinct-dids: + get: + operationId: getDistinctDidsLegacy + tags: [legacy] + summary: List distinct DIDs linking to a target + description: A list of distinct DIDs (identities) with links to a target. + parameters: + - $ref: '#/components/parameters/Target' + - $ref: '#/components/parameters/Collection' + - $ref: '#/components/parameters/Path' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of distinct linking DIDs. + content: + application/json: + schema: + $ref: '#/components/schemas/DistinctDidsResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links/count: + get: + operationId: getLinksCountLegacy + tags: [legacy] + deprecated: true + summary: '[DEPRECATED] Count of links pointing at a target' + description: > + Deprecated. Use `GET /xrpc/blue.microcosm.links.getBacklinksCount`. + The total number of links pointing at a given target. + parameters: + - $ref: '#/components/parameters/Target' + - $ref: '#/components/parameters/Collection' + - $ref: '#/components/parameters/Path' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: The total link count. + content: + application/json: + schema: + $ref: '#/components/schemas/CountResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links/count/distinct-dids: + get: + operationId: getDistinctDidsCountLegacy + tags: [legacy] + summary: Count of distinct DIDs linking to a target + description: The total number of DIDs (identities) with links to a given target. + parameters: + - $ref: '#/components/parameters/Target' + - $ref: '#/components/parameters/Collection' + - $ref: '#/components/parameters/Path' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: The distinct-DID count. + content: + application/json: + schema: + $ref: '#/components/schemas/CountResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links/all: + get: + operationId: getAllLinks + tags: [legacy] + summary: All sources with links to a target + description: > + Show all sources with links to a target, including linking record counts + and distinct linking DIDs. + parameters: + - $ref: '#/components/parameters/Target' + responses: + '200': + description: All link sources for the target, with counts. + content: + application/json: + schema: + $ref: '#/components/schemas/AllLinksResponse' + '400': + $ref: '#/components/responses/BadRequest' + + /links/all/count: + get: + operationId: getAllLinksCount + tags: [legacy] + deprecated: true + summary: '[DEPRECATED] Total counts of all links by collection and path' + description: > + Deprecated. Use `GET /links/all` instead. The total counts of all links + pointing at a given target, by collection and path. + parameters: + - $ref: '#/components/parameters/Target' + responses: + '200': + description: All link counts for the target, by collection and path. + content: + application/json: + schema: + $ref: '#/components/schemas/AllLinksResponse' + '400': + $ref: '#/components/responses/BadRequest' + +components: + parameters: + Subject: + name: subject + in: query + required: true + description: > + The target being linked to (URL-encoded). A DID, an AT-URI, or a URI. + schema: + $ref: '#/components/schemas/Target' + example: at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r + SubjectDistinct: + name: subject + in: query + required: true + description: > + The target being linked to (URL-encoded). A DID or an AT-URI. + schema: + $ref: '#/components/schemas/Target' + example: at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r + Source: + name: source + in: query + required: true + description: > + A link source — a collection NSID and a JSON path, joined by `:`, + e.g. `app.bsky.feed.like:subject.uri`. + schema: + type: string + example: app.bsky.feed.like:subject.uri + PathToOther: + name: pathToOther + in: query + required: true + description: Path to the secondary link in the many-to-many record. + schema: + type: string + example: subject + DidFilter: + name: did + in: query + required: false + description: > + Filter links to those from specific identities. Repeat the parameter to + filter by multiple DIDs. + schema: + type: array + items: + type: string + style: form + explode: true + example: [did:plc:vc7f4oafdgxsihk4cry2xpze] + OtherSubject: + name: otherSubject + in: query + required: false + description: > + Filter secondary links to specific subjects. Repeat the parameter to + filter by multiple subjects. + schema: + type: array + items: + $ref: '#/components/schemas/Target' + style: form + explode: true + Target: + name: target + in: query + required: true + description: The target being linked to (URL-encoded). A DID, AT-URI, or URI. + schema: + $ref: '#/components/schemas/Target' + example: at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r + Collection: + name: collection + in: query + required: true + description: A record NSID. + schema: + type: string + example: app.bsky.feed.like + Path: + name: path + in: query + required: true + description: > + URL-encoded JSON-path-ish location of the link within the record. The + special path `.` represents the record's rkey. + schema: + type: string + example: .subject.uri + Limit: + name: limit + in: query + required: false + description: Number of results to return. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 16 + Reverse: + name: reverse + in: query + required: false + description: Return links in reverse order. + schema: + type: boolean + default: false + Cursor: + name: cursor + in: query + required: false + description: > + Pagination cursor. Paged responses include a `cursor` property; when it + is `null`, no more data is available. Otherwise, repeat the request with + this value to fetch the next page. + schema: + type: string + + responses: + BadRequest: + description: Invalid or missing parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Target: + type: string + description: > + A DID (e.g. `did:plc:hdhoaan3xa3jiuq4fg4mefid`), an AT-URI + (e.g. `at://did:plc:.../app.bsky.feed.post/3lgu4lg6j2k2v`), or a URI + (e.g. `https://example.com`). + examples: + - did:plc:hdhoaan3xa3jiuq4fg4mefid + - at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3lgu4lg6j2k2v + - https://example.com + Cursor: + type: [string, 'null'] + description: > + Pagination cursor. `null` when no more data is available; otherwise pass + back as the `cursor` query parameter to fetch the next page. + RecordRef: + type: object + description: A reference to a single indexed linking record on a PDS. + required: [did, collection, rkey] + properties: + did: + type: string + description: The DID of the identity whose record contains the link. + collection: + type: string + description: The collection NSID of the linking record. + rkey: + type: string + description: The record key of the linking record. + BacklinksResponse: + type: object + required: [total, records] + properties: + total: + type: integer + description: The total number of links pointing at the subject. + records: + type: array + items: + $ref: '#/components/schemas/RecordRef' + cursor: + $ref: '#/components/schemas/Cursor' + DistinctDidsResponse: + type: object + required: [total, linking_dids] + properties: + total: + type: integer + description: The total number of distinct linking identities. + linking_dids: + type: array + items: + type: string + description: Distinct DIDs (identities) linking to the subject. + cursor: + $ref: '#/components/schemas/Cursor' + CountResponse: + type: object + required: [total] + properties: + total: + type: integer + description: The total count. + cursor: + $ref: '#/components/schemas/Cursor' + ManyToManyItem: + type: object + description: A many-to-many join record and the secondary target it points at. + required: [linkRecord, otherSubject] + properties: + linkRecord: + $ref: '#/components/schemas/RecordRef' + otherSubject: + $ref: '#/components/schemas/Target' + ManyToManyResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ManyToManyItem' + cursor: + $ref: '#/components/schemas/Cursor' + ManyToManyCount: + type: object + description: Counts of join records grouped by a secondary subject. + required: [subject, total, distinct] + properties: + subject: + $ref: '#/components/schemas/Target' + total: + type: integer + description: Total number of join records pointing at this secondary subject. + distinct: + type: integer + description: Number of distinct identities among those join records. + ManyToManyCountsResponse: + type: object + required: [counts_by_other_subject] + properties: + counts_by_other_subject: + type: array + items: + $ref: '#/components/schemas/ManyToManyCount' + cursor: + $ref: '#/components/schemas/Cursor' + AllLinksSourceCounts: + type: object + description: Record and distinct-DID counts for a single (collection, path) link source. + required: [records, distinct_dids] + properties: + records: + type: integer + description: Number of linking records from this source. + distinct_dids: + type: integer + description: Number of distinct identities linking from this source. + AllLinksResponse: + type: object + description: > + All link sources pointing at a target. `links` is a nested map keyed + first by collection NSID, then by JSON path, with record and + distinct-DID counts at the leaves. + required: [links] + properties: + links: + type: object + description: Map of collection NSID -> path -> counts. + additionalProperties: + type: object + description: Map of JSON path -> counts. + additionalProperties: + $ref: '#/components/schemas/AllLinksSourceCounts' + Error: + type: object + properties: + error: + type: string + message: + type: string + additionalProperties: true diff --git a/slingshot.json b/slingshot.json new file mode 100644 index 0000000..592f854 --- /dev/null +++ b/slingshot.json @@ -0,0 +1,959 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Slingshot", + "description": "_A [gravitational slingshot](https://en.wikipedia.org/wiki/Gravity_assist) makes use of the gravity and relative movements of celestial bodies to accelerate a spacecraft and change its trajectory._\n\n\n# Slingshot: edge record and identity cache\n\nApplications in [ATProtocol](https://atproto.com/) store data in users' own [PDS](https://atproto.com/guides/self-hosting) (Personal Data Server), which are distributed across thousands of independently-run servers all over the world. Trying to access this data poses challenges for client applications:\n\n- A PDS might be far away with long network latency\n- or may be on an unreliable connection\n- or overloaded when you need it, or offline, or…\n\nLarge projects like [Bluesky](https://bsky.app/) control their performance and reliability by syncing all app-relevant data from PDSs into first-party databases. But for new apps, building out this additional data infrastructure adds significant effort and complexity up front.\n\n**Slingshot is a fast, eager, production-grade cache of data in the [ATmosphere](https://atproto.com/)**, offering performance and reliability without custom infrastructure.\n\n\n### Current status\n\n> [!important]\n> Slingshot is currently in a **v0, pre-release state**. There is one production instance and you can use it! Expect short downtimes for restarts as development progresses and lower cache hit-rates as the internal storage caches are adjusted and reset.\n\nThe core APIs will not change, since they are standard third-party `com.atproto` query APIs from ATProtocol.\n\n\n## Eager caching\n\nIn many cases, Slingshot can cache the data you need *before* first request!\n\nSlingshot subscribes to the global [Firehose](https://atproto.com/specs/sync#firehose) of data updates. It keeps a short-term rolling indexed window of *all* data, and automatically promotes content likely to be requested to its longer-term main cache. _(automatic promotion is still a work in progress)_\n\nWhen there is a cache miss, Slingshot can often still accelerate record fetching, since it keeps a large cache of resolved identities: it can usually request from the correct PDS without extra lookups.\n\n\n## Precise invalidation\n\nThe fireshose includes **update** and **delete** events, which Slingshot uses to ensure stale and deleted data is removed within a very short window. Additonally, identity and account-level events can trigger rapid cleanup of data for deactivated and deleted accounts. _(some of this is still a work in progress)_\n\n\n## Low-trust\n\nThe \"AT\" in ATProtocol [stands for _Authenticated Transfer_](https://atproto.com/guides/glossary#at-protocol): all data is cryptographically signed, which makes it possible to broadcast data through third parties and trust that it's real _without_ having to directly contact the originating server.\n\nTwo core standard query APIs are supported to balance convenience and trust. They both fetch [records](https://atproto.com/guides/glossary#record):\n\n### [`com.atproto.repo.getRecord`](#tag/comatproto-queries/get/xrpc/com.atproto.repo.getRecord)\n\n- convenient `JSON` response format\n- cannot be proven authentic\n\n### [`com.atproto.sync.getRecord`](#tag/comatproto-queries/get/xrpc/com.atproto.sync.getRecord)\n\n- [`DAG-CBOR`](https://atproto.com/specs/data-model)-encoded response requires extra libraries to decode, but\n- includes a cryptographic proof of authenticity!\n\n_(work on this endpoint is in progress)_\n\n\n## Service proxying\n\nClients can proxy atproto queries through their own PDS with [Service Proxying](https://atproto.com/specs/xrpc#service-proxying), and this is supported by Slingshot. The Slingshot instance must be started the `--domain` argument specified.\n\nService-proxied requests can specify a Slingshot instance via the `atproto-proxy` header:\n\n```http\nGET /xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com\nHost: \natproto-proxy: did:web:#slingshot\n```\n\nWhere `` is the user's own PDS host, and `` is the domain that the slingshot instance is deployed at (eg. `slingshot.microcosm.blue`). See the [Service Proxying](https://atproto.com/specs/xrpc#service-proxying) docs for more.\n\n> [!tip]\n> Service proxying is supported but completely optional. All APIs are directly accessible over the public internet, and GeoDNS helps route users to the closest instance to them for the lowest possible latency. (_note: deploying multiple slingshot instances with GeoDNS is still TODO_)\n\n\n## Ergonomic APIs\n\n- Slingshot also offers variants of the `getRecord` endpoints that accept a full `at-uri` as a parameter, to save clients from needing to parse and validate all parts of a record location.\n\n- Bi-directionally verifying identity endpoints, so you can directly exchange atproto [`handle`](https://atproto.com/guides/glossary#handle)s for [`DID`](https://atproto.com/guides/glossary#did-decentralized-id)s without extra steps, plus a convenient [Mini-Doc](#tag/slingshot-specific-queries/get/xrpc/com.bad-example.identity.resolveMiniDoc) verified identity summary.\n\n\n## Part of microcosm\n\n[Microcosm](https://www.microcosm.blue/) is a collection of services and independent community-run infrastructure for ATProtocol.\n\nSlingshot excels when combined with _shallow indexing_ services, which offer fast queries of global data relationships but with only references to the data records. Microcosm has a few!\n\n- [🌌 Constellation](https://constellation.microcosm.blue/), a global backlink index (all social interactions in atproto are links!)\n- [🎇 Spacedust](https://spacedust.microcosm.blue/), a firehose of all social interactions\n\n> [!success]\n> All microcosm projects are [open source](https://tangled.org/bad-example.com/microcosm-links). **You can help sustain Slingshot** and all of microcosm by becoming a [Github sponsor](https://github.com/sponsors/uniphil/) or a [Ko-fi supporter](https://ko-fi.com/bad_example)!\n", + "version": "0.1.0", + "contact": { + "name": "@microcosm.blue", + "url": "https://bsky.app/profile/microcosm.blue" + } + }, + "servers": [ + { + "url": "https://slingshot.microcosm.blue" + } + ], + "tags": [ + { + "name": "com.atproto.* queries", + "description": "Core ATProtocol-compatible APIs.\n\n> [!tip]\n> Upstream documentation is available at\n> https://docs.bsky.app/docs/category/http-reference\n\nThese queries are usually executed directly against the PDS containing\nthe data being requested. Slingshot offers a caching view of the same\ncontents with better expected performance and reliability." + }, + { + "name": "slingshot-specific queries", + "description": "Additional and improved APIs.\n\nThese APIs offer small tweaks to the core ATProtocol APIs, with more\nmore convenient [request parameters](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.repo.getUriRecord)\nor [response formats](#tag/slingshot-specific-queries/GET/xrpc/com.bad-example.identity.resolveMiniDoc).\n\n> [!important]\n> At the moment, these are namespaced under the `com.bad-example.*` NSID\n> prefix, but as they stabilize they may be migrated to an org namespace\n> like `blue.microcosm.*`. Support for asliasing to `com.bad-example.*`\n> will be maintained as long as it's in use." + } + ], + "paths": { + "/xrpc/com.atproto.repo.getRecord": { + "get": { + "tags": [ + "com.atproto.* queries" + ], + "summary": "com.atproto.repo.getRecord", + "description": "Get a single record from a repository. Does not require auth.\n\n> [!tip]\n> See also the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)\n> that this endpoint aims to be compatible with.", + "parameters": [ + { + "name": "repo", + "schema": { + "type": "string", + "example": "did:plc:hdhoaan3xa3jiuq4fg4mefid" + }, + "in": "query", + "description": "The DID or handle of the repo", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "collection", + "schema": { + "type": "string", + "example": "app.bsky.feed.like" + }, + "in": "query", + "description": "The NSID of the record collection", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "rkey", + "schema": { + "type": "string", + "example": "3lv4ouczo2b2a" + }, + "in": "query", + "description": "The Record key", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "cid", + "schema": { + "type": "string" + }, + "in": "query", + "description": "Optional: the CID of the version of the record.\n\nIf not specified, then return the most recent version.\n\nIf a stale `CID` is specified and a newer version of the record\nexists, Slingshot returns a `NotFound` error. That is: Slingshot\nonly retains the most recent version of a record.", + "required": false, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Record found", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FoundRecordResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or no record to return\n\nThe only error name in the repo.getRecord lexicon is `RecordNotFound`,\nbut the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)\nalso list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of\nthese, slingshot will only generate `RecordNotFound` or `InvalidRequest`,\nbut may return any proxied error code from the upstream repo.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + }, + "500": { + "description": "Server errors", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/blue.microcosm.repo.getRecordByUri": { + "get": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "blue.microcosm.repo.getRecordByUri", + "description": "alias of `com.bad-example.repo.getUriRecord` with intention to stabilize under this name", + "parameters": [ + { + "name": "at_uri", + "schema": { + "type": "string", + "example": "at://did:plc:hdhoaan3xa3jiuq4fg4mefid/app.bsky.feed.like/3lv4ouczo2b2a" + }, + "in": "query", + "description": "The at-uri of the record\n\nThe identifier can be a DID or an atproto handle, and the collection\nand rkey segments must be present.", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "cid", + "schema": { + "type": "string" + }, + "in": "query", + "description": "Optional: the CID of the version of the record.\n\nIf not specified, then return the most recent version.\n\n> [!tip]\n> If specified and a newer version of the record exists, returns 404 not\n> found. That is: slingshot only retains the most recent version of a\n> record.", + "required": false, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Record found", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FoundRecordResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or no record to return\n\nThe only error name in the repo.getRecord lexicon is `RecordNotFound`,\nbut the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)\nalso list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of\nthese, slingshot will only generate `RecordNotFound` or `InvalidRequest`,\nbut may return any proxied error code from the upstream repo.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + }, + "500": { + "description": "Server errors", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/com.bad-example.repo.getUriRecord": { + "get": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "com.bad-example.repo.getUriRecord", + "description": "Ergonomic complement to [`com.atproto.repo.getRecord`](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)\nwhich accepts an `at-uri` instead of individual repo/collection/rkey params", + "parameters": [ + { + "name": "at_uri", + "schema": { + "type": "string", + "example": "at://did:plc:hdhoaan3xa3jiuq4fg4mefid/app.bsky.feed.like/3lv4ouczo2b2a" + }, + "in": "query", + "description": "The at-uri of the record\n\nThe identifier can be a DID or an atproto handle, and the collection\nand rkey segments must be present.", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "cid", + "schema": { + "type": "string" + }, + "in": "query", + "description": "Optional: the CID of the version of the record.\n\nIf not specified, then return the most recent version.\n\n> [!tip]\n> If specified and a newer version of the record exists, returns 404 not\n> found. That is: slingshot only retains the most recent version of a\n> record.", + "required": false, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Record found", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FoundRecordResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or no record to return\n\nThe only error name in the repo.getRecord lexicon is `RecordNotFound`,\nbut the [canonical api docs](https://docs.bsky.app/docs/api/com-atproto-repo-get-record)\nalso list `InvalidRequest`, `ExpiredToken`, and `InvalidToken`. Of\nthese, slingshot will only generate `RecordNotFound` or `InvalidRequest`,\nbut may return any proxied error code from the upstream repo.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + }, + "500": { + "description": "Server errors", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/com.atproto.identity.resolveHandle": { + "get": { + "tags": [ + "com.atproto.* queries" + ], + "summary": "com.atproto.identity.resolveHandle", + "description": "Resolves an atproto [`handle`](https://atproto.com/guides/glossary#handle)\n(hostname) to a [`DID`](https://atproto.com/guides/glossary#did-decentralized-id).\n\n> [!tip]\n> Compatibility note: Slingshot will **always bi-directionally verify\n> against the DID document**, which is optional according to the\n> authoritative lexicon.\n\n> [!tip]\n> See the [canonical `com.atproto` XRPC documentation](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle)\n> that this endpoint aims to be compatible with.", + "parameters": [ + { + "name": "handle", + "schema": { + "type": "string", + "example": "bad-example.com" + }, + "in": "query", + "description": "The handle to resolve.", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Resolution succeeded", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FoundDidResponseObject" + } + } + } + }, + "400": { + "description": "Bad request, failed to resolve, or failed to verify\n\n`error` will be one of `InvalidRequest`, `HandleNotFound`.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + }, + "500": { + "description": "Something went wrong trying to complete the request", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/blue.microcosm.identity.resolveMiniDoc": { + "get": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "blue.microcosm.identity.resolveMiniDoc", + "description": "alias of `com.bad-example.identity.resolveMiniDoc` with intention to stabilize under this name", + "parameters": [ + { + "name": "identifier", + "schema": { + "type": "string", + "example": "bad-example.com" + }, + "in": "query", + "description": "Handle or DID to resolve", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Identity resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MiniDocResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or identity not resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/com.bad-example.identity.resolveMiniDoc": { + "get": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "com.bad-example.identity.resolveMiniDoc", + "description": "Like [com.atproto.identity.resolveIdentity](https://docs.bsky.app/docs/api/com-atproto-identity-resolve-identity)\nbut instead of the full `didDoc` it returns an atproto-relevant subset.", + "parameters": [ + { + "name": "identifier", + "schema": { + "type": "string", + "example": "bad-example.com" + }, + "in": "query", + "description": "Handle or DID to resolve", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Identity resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MiniDocResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or identity not resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/com.bad-example.identity.resolveService": { + "get": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "com.bad-example.identity.resolveService", + "description": "resolve an atproto service did + id to its http endpoint\n\n> [!important]\n> this endpoint is experimental and may change", + "parameters": [ + { + "name": "did", + "schema": { + "type": "string", + "example": "did:web:constellation.microcosm.blue" + }, + "in": "query", + "description": "the service's did", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "id", + "schema": { + "type": "string", + "example": "#constellation" + }, + "in": "query", + "description": "id fragment, starting with '#'\n\nmust be url-encoded!", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "type", + "schema": { + "type": "string" + }, + "in": "query", + "description": "optionally, the exact service type to filter\n\nresolving a pds requires matching the type as well as id. service\nproxying ignores the type.", + "required": false, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "Service resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceResponseObject" + } + } + } + }, + "400": { + "description": "Bad request or service not resolved", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + }, + "/xrpc/com.bad-example.proxy.hydrateQueryResponse": { + "post": { + "tags": [ + "slingshot-specific queries" + ], + "summary": "com.bad-example.proxy.hydrateQueryResponse", + "description": "> [!important]\n> Unstable! This endpoint is experimental and may change.\n\nFetch + include records referenced from an upstream xrpc query response", + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProxyQueryPayload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProxyHydrateResponseObject" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/XrpcErrorResponseObject" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "FoundDidResponseObject": { + "type": "object", + "title": "FoundDidResponseObject", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "description": "the DID, bi-directionally verified if using Slingshot" + } + }, + "example": { + "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid" + } + }, + "FoundRecordResponseObject": { + "type": "object", + "title": "FoundRecordResponseObject", + "required": [ + "uri", + "value" + ], + "properties": { + "uri": { + "type": "string", + "description": "at-uri for this record" + }, + "cid": { + "type": "string", + "description": "CID for this exact version of the record\n\nSlingshot will always return the CID, despite it not being a required\nresponse property in the official lexicon.\n\nTODO: probably actually let it be optional, idk are some pds's weirdly\nnot returning it?" + }, + "value": { + "description": "the record itself as JSON" + } + }, + "example": { + "cid": "bafyreialv3mzvvxaoyrfrwoer3xmabbmdchvrbyhayd7bga47qjbycy74e", + "uri": "at://did:plc:hdhoaan3xa3jiuq4fg4mefid/app.bsky.feed.like/3lv4ouczo2b2a", + "value": { + "$type": "app.bsky.feed.like", + "createdAt": "2025-07-29T18:02:02.327Z", + "subject": { + "cid": "bafyreia2gy6eyk5qfetgahvshpq35vtbwy6negpy3gnuulcdi723mi7vxy", + "uri": "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv4lkb4vgs2k" + } + } + } + }, + "HydrationSource": { + "type": "object", + "title": "HydrationSource", + "required": [ + "path", + "shape" + ], + "properties": { + "path": { + "type": "string", + "description": "Record Path syntax for locating fields" + }, + "shape": { + "type": "string", + "description": "What to expect at the path: 'strong-ref', 'at-uri', 'at-uri-parts', 'did', 'handle', or 'at-identifier'.\n\n- `strong-ref`: object in the shape of `com.atproto.repo.strongRef` with `uri` and `cid` keys.\n- `at-uri`: string, must have all segments present (identifier, collection, rkey)\n- `at-uri-parts`: object with keys (`repo` or `did`), `collection`, `rkey`, and optional `cid`. Other keys may be present and will be ignored.\n- `did`: string, `did` format\n- `handle`: string, `handle` format\n- `at-identifier`: string, `did` or `handle` format" + } + } + }, + "Hydration_FoundRecordResponseObject": { + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/Hydration_FoundRecordResponseObject_Error" + }, + { + "$ref": "#/components/schemas/Hydration_FoundRecordResponseObject_Pending" + }, + { + "$ref": "#/components/schemas/Hydration_FoundRecordResponseObject_Found" + } + ], + "discriminator": { + "propertyName": "status", + "mapping": { + "error": "#/components/schemas/Hydration_FoundRecordResponseObject_Error", + "pending": "#/components/schemas/Hydration_FoundRecordResponseObject_Pending", + "found": "#/components/schemas/Hydration_FoundRecordResponseObject_Found" + } + } + }, + "Hydration_FoundRecordResponseObject_Error": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ], + "example": "error" + } + } + }, + { + "$ref": "#/components/schemas/ProxyHydrationError" + } + ] + }, + "Hydration_FoundRecordResponseObject_Found": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "found" + ], + "example": "found" + } + } + }, + { + "$ref": "#/components/schemas/FoundRecordResponseObject" + } + ] + }, + "Hydration_FoundRecordResponseObject_Pending": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ], + "example": "pending" + } + } + }, + { + "$ref": "#/components/schemas/ProxyHydrationPending" + } + ] + }, + "Hydration_MiniDocResponseObject": { + "type": "object", + "anyOf": [ + { + "$ref": "#/components/schemas/Hydration_MiniDocResponseObject_Error" + }, + { + "$ref": "#/components/schemas/Hydration_MiniDocResponseObject_Pending" + }, + { + "$ref": "#/components/schemas/Hydration_MiniDocResponseObject_Found" + } + ], + "discriminator": { + "propertyName": "status", + "mapping": { + "error": "#/components/schemas/Hydration_MiniDocResponseObject_Error", + "pending": "#/components/schemas/Hydration_MiniDocResponseObject_Pending", + "found": "#/components/schemas/Hydration_MiniDocResponseObject_Found" + } + } + }, + "Hydration_MiniDocResponseObject_Error": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ], + "example": "error" + } + } + }, + { + "$ref": "#/components/schemas/ProxyHydrationError" + } + ] + }, + "Hydration_MiniDocResponseObject_Found": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "found" + ], + "example": "found" + } + } + }, + { + "$ref": "#/components/schemas/MiniDocResponseObject" + } + ] + }, + "Hydration_MiniDocResponseObject_Pending": { + "allOf": [ + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending" + ], + "example": "pending" + } + } + }, + { + "$ref": "#/components/schemas/ProxyHydrationPending" + } + ] + }, + "MiniDocResponseObject": { + "type": "object", + "title": "MiniDocResponseObject", + "required": [ + "did", + "handle", + "pds", + "signing_key" + ], + "properties": { + "did": { + "type": "string", + "description": "DID, bi-directionally verified if a handle was provided in the query." + }, + "handle": { + "type": "string", + "description": "The validated handle of the account or `handle.invalid` if the handle\ndid not bi-directionally match the DID document." + }, + "pds": { + "type": "string", + "description": "The identity's PDS URL" + }, + "signing_key": { + "type": "string", + "description": "The atproto signing key publicKeyMultibase\n\nLegacy key encoding not supported. the key is returned directly; `id`,\n`type`, and `controller` are omitted." + } + }, + "example": { + "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid", + "handle": "bad-example.com", + "pds": "https://porcini.us-east.host.bsky.network", + "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j" + } + }, + "ProxyHydrateResponseObject": { + "type": "object", + "title": "ProxyHydrateResponseObject", + "required": [ + "output", + "records", + "identifiers" + ], + "properties": { + "output": { + "description": "The original upstream response content" + }, + "records": { + "type": "object", + "description": "Any hydrated records", + "additionalProperties": { + "$ref": "#/components/schemas/Hydration_FoundRecordResponseObject" + } + }, + "identifiers": { + "type": "object", + "description": "Any hydrated identifiers", + "additionalProperties": { + "$ref": "#/components/schemas/Hydration_MiniDocResponseObject" + } + } + }, + "example": { + "identifiers": {}, + "output": {}, + "records": { + "asdf": { + "followUp": "/xrpc/com.atproto.repo.getRecord?...", + "reason": "deadline", + "status": "pending" + } + } + } + }, + "ProxyHydrationError": { + "type": "object", + "title": "ProxyHydrationError", + "required": [ + "reason", + "shouldRetry", + "followUp" + ], + "properties": { + "reason": { + "type": "string", + "description": "Short description of why the hydration failed" + }, + "shouldRetry": { + "type": "boolean", + "description": "Whether or not it's recommended to retry requesting this item" + }, + "followUp": { + "type": "string", + "description": "URL to follow up at if retrying" + } + } + }, + "ProxyHydrationPending": { + "type": "object", + "title": "ProxyHydrationPending", + "required": [ + "followUp", + "reason" + ], + "properties": { + "followUp": { + "type": "string", + "description": "URL you can request to finish hydrating this item" + }, + "reason": { + "type": "string", + "description": "Why this item couldn't be hydrated: 'deadline' or 'limit'\n\n- `deadline`: the item fetch didn't complete before the response was\ndue, but will continue on slingshot in the background -- `followUp`\nrequests are coalesced into the original item fetch to be available as\nearly as possible.\n\n- `limit`: slingshot only attempts to hydrate the first 100 items found\nin a proxied response, with the remaining marked `pending`. You can\nrequest `followUp` to fetch them.\n\nIn the future, Slingshot may put pending links after `limit` into a low-\npriority fetch queue, so that these items become available sooner on\nfollow-up request as well." + } + } + }, + "ProxyQueryPayload": { + "type": "object", + "title": "ProxyQueryPayload", + "required": [ + "xrpc", + "atproto_proxy", + "hydration_sources" + ], + "properties": { + "xrpc": { + "type": "string", + "description": "The NSID of the XRPC you wish to forward" + }, + "atproto_proxy": { + "type": "string", + "description": "The destination service the request will be forwarded to" + }, + "authorization": { + "type": "string", + "description": "An optional auth token to pass on\n\nthe `aud` field must match the upstream atproto_proxy service" + }, + "atproto_accept_labelers": { + "type": "string", + "description": "An optional set of labelers to request be applied by the upstream" + }, + "params": { + "description": "The `params` for the destination service XRPC endpoint\n\nCurrently this will be passed along unchecked, but a future version of\nslingshot may attempt to do lexicon resolution to validate `params`\nbased on the upstream service" + }, + "hydration_sources": { + "type": "array", + "description": "Paths within the response to look for at-uris that can be hydrated", + "items": { + "$ref": "#/components/schemas/HydrationSource" + } + } + }, + "example": { + "atproto_accept_labelers": null, + "atproto_proxy": "did:web:blue.mackuba.eu#bsky_fg", + "authorization": null, + "hydration_sources": [ + { + "path": "feed[].post", + "shape": "at-uri" + } + ], + "params": { + "feed": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.generator/atproto" + }, + "xrpc": "app.bsky.feed.getFeedSkeleton" + } + }, + "ServiceResponseObject": { + "type": "object", + "title": "ServiceResponseObject", + "required": [ + "endpoint" + ], + "properties": { + "endpoint": { + "type": "string", + "description": "The service endpoint URL, if found" + } + }, + "example": { + "endpoint": "https://example.com" + } + }, + "XrpcErrorResponseObject": { + "type": "object", + "title": "XrpcErrorResponseObject", + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string", + "description": "Should correspond an error `name` in the lexicon errors array" + }, + "message": { + "type": "string", + "description": "Human-readable description and possibly additonal context" + } + }, + "example": { + "error": "RecordNotFound", + "message": "This record was deleted" + } + } + } + }, + "externalDocs": { + "url": "https://microcosm.blue/slingshot" + } +} diff --git a/spacedust.json b/spacedust.json new file mode 100644 index 0000000..c42d1f2 --- /dev/null +++ b/spacedust.json @@ -0,0 +1,68 @@ +{ + "components": {}, + "info": { + "contact": { + "name": "part of @microcosm.blue", + "url": "https://microcosm.blue" + }, + "description": "A configurable ATProto notifications firehose.", + "title": "Spacedust", + "version": "0.1.0" + }, + "openapi": "3.0.3", + "paths": { + "/subscribe": { + "get": { + "operationId": "subscribe", + "parameters": [ + { + "description": "One or more link sources to receive links about\n\nTODO: docs about link sources\n\neg, a bluesky like's link source: `app.bsky.feed.like:subject.uri`\n\nPass this parameter multiple times to specify multiple sources", + "in": "query", + "name": "wantedSources", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "One or more DIDs to receive links about\n\nPass this parameter multiple times to specify multiple collections", + "in": "query", + "name": "wantedSubjectDids", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "One or more at-uris to receive links about\n\nThe at-uri must be url-encoded\n\nPass this parameter multiple times to specify multiple collections, like `wantedSubjects=[...]&wantedSubjects=[...]`", + "in": "query", + "name": "wantedSubjects", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Bypass the 21-sec delay buffer\n\nBy default, spacedust holds all firehose links for 21 seconds before emitting them, to prevent quickly- undone interactions from generating notifications.\n\nSetting `instant` to true bypasses this buffer, allowing faster (and noisier) notification delivery.\n\nTypically [a little less than 1%](https://bsky.app/profile/bad-example.com/post/3ls32wctsrs2l) of links links get deleted within 21s of being created.", + "in": "query", + "name": "instant", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": {} + } + }, + "description": "" + } + }, + "x-dropshot-websocket": {} + } + } + } +} diff --git a/src/components/microcosm/NetworkLikeCount.tsx b/src/components/microcosm/NetworkLikeCount.tsx new file mode 100644 index 0000000..e8173c1 --- /dev/null +++ b/src/components/microcosm/NetworkLikeCount.tsx @@ -0,0 +1,34 @@ +/** + * A small, additive indicator showing the live like count for a post sourced + * from Constellation (microcosm's atproto-wide backlink index) rather than the + * AppView. Renders nothing until the count loads, and nothing if microcosm is + * disabled. Purely informational — proves the microcosm read pipeline + * end-to-end without altering existing AppView-sourced UI. + */ +import {Trans} from '@lingui/react/macro' + +import {useLikeCountQuery} from '#/state/queries/microcosm/constellation' +import {atoms as a, useTheme} from '#/alf' +import {useFormatPostStatCount} from '#/components/PostControls/util' +import {Text} from '#/components/Typography' + +export function NetworkLikeCount({postUri}: {postUri: string}) { + const t = useTheme() + const formatPostStatCount = useFormatPostStatCount() + const {data: count} = useLikeCountQuery(postUri) + + if (count == null) return null + + return ( + + + + {formatPostStatCount(count)} + {' '} + on the network + + + ) +} diff --git a/src/env/common.ts b/src/env/common.ts index adb6019..cc2ba3b 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -92,3 +92,25 @@ export const GCP_PROJECT_ID: number = process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined ? 0 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) + +/** + * microcosm (https://www.microcosm.blue/) service hosts. These let the app read + * atproto data independently of Bluesky's first-party AppView: + * + * - Constellation: atproto-wide backlink index (interaction counts/lists) + * - Slingshot: edge cache of records + identity resolution (com.atproto.* APIs) + * - Spacedust: websocket firehose of interactions (live notifications) + */ +export const CONSTELLATION_URL: string = + process.env.EXPO_PUBLIC_CONSTELLATION_URL || + 'https://constellation.microcosm.blue' +export const SLINGSHOT_URL: string = + process.env.EXPO_PUBLIC_SLINGSHOT_URL || 'https://slingshot.microcosm.blue' +export const SPACEDUST_URL: string = + process.env.EXPO_PUBLIC_SPACEDUST_URL || 'wss://spacedust.microcosm.blue' + +/** + * User-Agent sent with microcosm API requests, per their etiquette request to + * identify your project and a contact. + */ +export const MICROCOSM_USER_AGENT = 'fih (microcosm client; scan@scanash.com)' diff --git a/src/lib/microcosm/config.ts b/src/lib/microcosm/config.ts new file mode 100644 index 0000000..ece0482 --- /dev/null +++ b/src/lib/microcosm/config.ts @@ -0,0 +1,9 @@ +/** + * Feature flag for routing reads through microcosm services instead of (or + * before) Bluesky's first-party AppView. + * + * Defaults on. Set `EXPO_PUBLIC_MICROCOSM_ENABLED=false` to disable and use the + * stock atproto agent everywhere. + */ +export const MICROCOSM_ENABLED = + process.env.EXPO_PUBLIC_MICROCOSM_ENABLED !== 'false' diff --git a/src/lib/microcosm/constellation.ts b/src/lib/microcosm/constellation.ts new file mode 100644 index 0000000..c4c29bf --- /dev/null +++ b/src/lib/microcosm/constellation.ts @@ -0,0 +1,233 @@ +/** + * Typed client for Constellation — microcosm's atproto-wide backlink index. + * + * Constellation indexes every "link" in every record on the network (an at-uri, + * DID, or URL found at some JSON path inside a record), keyed by the target and + * by a "source" string of the form `:`. That lets us ask + * questions the stock AppView bakes into hydrated views, e.g. "how many likes + * does this post have" or "who follows this account", without Bluesky's AppView. + * + * Response shapes here were verified against the live public instance. + * @see constellation.openapi.yaml at the repo root + */ +import {CONSTELLATION_URL, MICROCOSM_USER_AGENT} from '#/env' + +/** A target being linked to: a DID, an at-uri, or a plain URL. */ +export type Target = string + +/** + * A link source: a collection NSID and a JSON path joined by `:`, + * e.g. `app.bsky.feed.like:subject.uri`. + */ +export type Source = string + +/** A reference to a single indexed linking record. */ +export type RecordRef = { + did: string + collection: string + rkey: string +} + +export type BacklinksResponse = { + total: number + records: RecordRef[] + cursor: string | null +} + +export type DistinctDidsResponse = { + total: number + linking_dids: string[] + cursor: string | null +} + +export type ManyToManyItem = { + linkRecord: RecordRef + otherSubject: Target +} + +export type ManyToManyResponse = { + items: ManyToManyItem[] + cursor: string | null +} + +export type ManyToManyCount = { + subject: Target + total: number + distinct: number +} + +export type ManyToManyCountsResponse = { + counts_by_other_subject: ManyToManyCount[] + cursor: string | null +} + +/** Map of collection NSID -> JSON path -> {records, distinct_dids}. */ +export type AllLinksResponse = { + links: Record< + string, + Record + > +} + +/** + * Common bsky interaction sources, as `collection:path` strings. These are the + * link sources Constellation indexes for standard Bluesky interactions. + */ +export const Sources = { + /** Likes on a post (subject = post at-uri). */ + likes: 'app.bsky.feed.like:subject.uri', + /** Reposts of a post (subject = post at-uri). */ + reposts: 'app.bsky.feed.repost:subject.uri', + /** Direct replies to a post (subject = parent post at-uri). */ + replies: 'app.bsky.feed.post:reply.parent.uri', + /** Quote posts referencing a post (subject = quoted post at-uri). */ + quotes: 'app.bsky.embed.record:record.uri', + /** Followers of an identity (subject = followed DID). */ + followers: 'app.bsky.graph.follow:subject', + /** Blocks of an identity (subject = blocked DID). */ + blocks: 'app.bsky.graph.block:subject', +} as const + +class ConstellationError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message) + this.name = 'ConstellationError' + } +} + +async function get( + path: string, + params: Record, + signal?: AbortSignal, +): Promise { + const url = new URL(path, CONSTELLATION_URL) + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue + if (Array.isArray(value)) { + for (const v of value) url.searchParams.append(key, v) + } else { + url.searchParams.set(key, String(value)) + } + } + const res = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + 'User-Agent': MICROCOSM_USER_AGENT, + }, + signal, + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new ConstellationError( + `Constellation ${path} failed: ${res.status} ${body}`, + res.status, + ) + } + return (await res.json()) as T +} + +/** The total number of links pointing at a target for a given source. */ +export function getBacklinksCount( + subject: Target, + source: Source, + signal?: AbortSignal, +): Promise<{total: number}> { + return get('/xrpc/blue.microcosm.links.getBacklinksCount', {subject, source}, signal) +} + +/** A page of linking records pointing at a target. */ +export function getBacklinks( + args: { + subject: Target + source: Source + did?: string[] + limit?: number + reverse?: boolean + cursor?: string + }, + signal?: AbortSignal, +): Promise { + const {subject, source, did, limit, reverse, cursor} = args + return get( + '/xrpc/blue.microcosm.links.getBacklinks', + {subject, source, did, limit, reverse, cursor}, + signal, + ) +} + +/** A page of distinct DIDs (identities) linking to a target. */ +export function getBacklinkDids( + args: {subject: Target; source: Source; limit?: number; cursor?: string}, + signal?: AbortSignal, +): Promise { + const {subject, source, limit, cursor} = args + return get( + '/xrpc/blue.microcosm.links.getBacklinkDids', + {subject, source, limit, cursor}, + signal, + ) +} + +/** Many-to-many join records linking a target to a secondary target. */ +export function getManyToMany( + args: { + subject: Target + source: Source + pathToOther: string + did?: string[] + otherSubject?: Target[] + limit?: number + cursor?: string + }, + signal?: AbortSignal, +): Promise { + return get('/xrpc/blue.microcosm.links.getManyToMany', {...args}, signal) +} + +/** Counts of many-to-many join records, grouped by secondary subject. */ +export function getManyToManyCounts( + args: { + subject: Target + source: Source + pathToOther: string + did?: string[] + otherSubject?: Target[] + limit?: number + cursor?: string + }, + signal?: AbortSignal, +): Promise { + return get( + '/xrpc/blue.microcosm.links.getManyToManyCounts', + {...args}, + signal, + ) +} + +/** All link sources pointing at a target, with per-source counts. */ +export function getAllLinks( + target: Target, + signal?: AbortSignal, +): Promise { + return get('/links/all', {target}, signal) +} + +// --- Convenience helpers for common bsky interactions --- + +export const likeCount = (postUri: string, signal?: AbortSignal) => + getBacklinksCount(postUri, Sources.likes, signal) + +export const repostCount = (postUri: string, signal?: AbortSignal) => + getBacklinksCount(postUri, Sources.reposts, signal) + +export const replyCount = (postUri: string, signal?: AbortSignal) => + getBacklinksCount(postUri, Sources.replies, signal) + +export const quoteCount = (postUri: string, signal?: AbortSignal) => + getBacklinksCount(postUri, Sources.quotes, signal) + +export const followerCount = (did: string, signal?: AbortSignal) => + getBacklinksCount(did, Sources.followers, signal) diff --git a/src/lib/microcosm/index.ts b/src/lib/microcosm/index.ts new file mode 100644 index 0000000..fee968d --- /dev/null +++ b/src/lib/microcosm/index.ts @@ -0,0 +1,12 @@ +/** + * microcosm — read atproto data independently of Bluesky's first-party AppView. + * + * - {@link constellation}: backlink index (interaction counts/lists) + * - {@link slingshot}: record + identity reads (com.atproto.* compatible) + * - {@link spacedust}: live interactions firehose (notifications) + * + * @see MICROCOSM_PLAN.md at the repo root + */ +export * as constellation from '#/lib/microcosm/constellation' +export * as slingshot from '#/lib/microcosm/slingshot' +export * as spacedust from '#/lib/microcosm/spacedust' diff --git a/src/lib/microcosm/records.ts b/src/lib/microcosm/records.ts new file mode 100644 index 0000000..8e29b3c --- /dev/null +++ b/src/lib/microcosm/records.ts @@ -0,0 +1,45 @@ +/** + * Record-read helpers that prefer Slingshot (microcosm's edge cache) and fall + * back to the regular atproto agent (the PDS / AppView) on miss or error. + * + * Use this ONLY for read-only public lookups. Do NOT use it for read-then-write + * flows that rely on `swapRecord`/CID optimistic concurrency — those must read + * from the authoritative PDS via the agent, since a cache can be stale. + */ +import {type AtpAgent} from '@atproto/api' + +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {getRecord as slingshotGetRecord} from '#/lib/microcosm/slingshot' +import {logger} from '#/logger' + +export type GetRecordResult = { + uri: string + cid?: string + value: unknown +} + +/** + * Fetch a single public record. Tries Slingshot first (when microcosm is + * enabled), then falls back to the agent. Returns the same `{uri, cid, value}` + * shape as `com.atproto.repo.getRecord`. + */ +export async function getPublicRecord( + agent: AtpAgent, + args: {repo: string; collection: string; rkey: string}, + signal?: AbortSignal, +): Promise { + if (MICROCOSM_ENABLED) { + try { + const res = await slingshotGetRecord(args, signal) + return {uri: res.uri, cid: res.cid, value: res.value} + } catch (e) { + // Cache miss / transient error — fall through to the agent. Log at debug + // so we can see hit-rate without spamming. + logger.debug('slingshot getRecord miss, falling back to agent', { + safeMessage: String(e), + }) + } + } + const res = await agent.api.com.atproto.repo.getRecord(args) + return {uri: res.data.uri, cid: res.data.cid, value: res.data.value} +} diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts new file mode 100644 index 0000000..ce689a5 --- /dev/null +++ b/src/lib/microcosm/slingshot.ts @@ -0,0 +1,132 @@ +/** + * Typed client for Slingshot — microcosm's edge cache of atproto records and + * identities. It speaks the standard `com.atproto.*` query APIs (so it's a + * drop-in for record reads) plus ergonomic extras: at-uri record fetch and a + * "MiniDoc" verified identity summary. + * + * @see slingshot.json at the repo root + */ +import {MICROCOSM_USER_AGENT, SLINGSHOT_URL} from '#/env' + +/** A record as returned by getRecord — `value` is the raw record JSON. */ +export type FoundRecord = { + uri: string + cid?: string + value: unknown +} + +/** A compact, verified identity summary. */ +export type MiniDoc = { + did: string + /** Validated handle, or `handle.invalid` if it didn't bi-directionally match. */ + handle: string + /** The identity's PDS URL. */ + pds: string + /** atproto signing key (publicKeyMultibase). */ + signing_key: string +} + +export class SlingshotError extends Error { + constructor( + message: string, + public status: number, + public errorCode?: string, + ) { + super(message) + this.name = 'SlingshotError' + } +} + +async function get( + path: string, + params: Record, + signal?: AbortSignal, +): Promise { + const url = new URL(path, SLINGSHOT_URL) + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, value) + } + const res = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + 'User-Agent': MICROCOSM_USER_AGENT, + }, + signal, + }) + if (!res.ok) { + let errorCode: string | undefined + let message = `${res.status}` + try { + const body = (await res.json()) as {error?: string; message?: string} + errorCode = body.error + message = body.message || message + } catch { + // non-JSON error body + } + throw new SlingshotError( + `Slingshot ${path} failed: ${message}`, + res.status, + errorCode, + ) + } + return (await res.json()) as T +} + +/** + * `com.atproto.repo.getRecord` — fetch a single record by repo/collection/rkey. + * Compatible with the canonical atproto lexicon. + */ +export function getRecord( + args: {repo: string; collection: string; rkey: string; cid?: string}, + signal?: AbortSignal, +): Promise { + return get('/xrpc/com.atproto.repo.getRecord', {...args}, signal) +} + +/** + * Ergonomic record fetch by full at-uri (slingshot-specific). The identifier + * segment may be a DID or a handle. + */ +export function getRecordByUri( + atUri: string, + cid?: string, + signal?: AbortSignal, +): Promise { + return get( + '/xrpc/blue.microcosm.repo.getRecordByUri', + {at_uri: atUri, cid}, + signal, + ) +} + +/** + * `com.atproto.identity.resolveHandle` — resolve a handle to a DID. Slingshot + * always bi-directionally verifies against the DID document. + */ +export async function resolveHandle( + handle: string, + signal?: AbortSignal, +): Promise { + const res = await get<{did: string}>( + '/xrpc/com.atproto.identity.resolveHandle', + {handle}, + signal, + ) + return res.did +} + +/** + * Resolve a handle or DID to a compact verified identity summary + * (`{did, handle, pds, signing_key}`). The `pds` is what you need to then talk + * to the right server for writes. + */ +export function resolveMiniDoc( + identifier: string, + signal?: AbortSignal, +): Promise { + return get( + '/xrpc/blue.microcosm.identity.resolveMiniDoc', + {identifier}, + signal, + ) +} diff --git a/src/lib/microcosm/spacedust.ts b/src/lib/microcosm/spacedust.ts new file mode 100644 index 0000000..5b96299 --- /dev/null +++ b/src/lib/microcosm/spacedust.ts @@ -0,0 +1,87 @@ +/** + * Client for Spacedust — microcosm's configurable atproto interactions firehose + * (a "notifications firehose"). You open a websocket subscription filtered by + * link source and by subject (a DID or at-uri), and receive link events in real + * time — e.g. someone liking/replying-to/following a target you care about. + * + * @see spacedust.json at the repo root + */ +import {MICROCOSM_USER_AGENT, SPACEDUST_URL} from '#/env' + +export type SpacedustSubscription = { + /** Link sources to receive, e.g. `app.bsky.feed.like:subject.uri`. */ + wantedSources: string[] + /** DIDs whose interactions you want (e.g. notifications for your own DID). */ + wantedSubjectDids?: string[] + /** at-uris you want interactions about (will be url-encoded). */ + wantedSubjects?: string[] + /** + * Bypass the 21s debounce buffer for faster (noisier) delivery. By default + * Spacedust holds links 21s so quickly-undone interactions don't notify. + */ + instant?: boolean +} + +/** A raw link event from the firehose. Shape is firehose-defined; passed through. */ +export type SpacedustEvent = Record + +export type SpacedustHandlers = { + onEvent: (event: SpacedustEvent) => void + onError?: (error: Event) => void + onOpen?: () => void + onClose?: (event: CloseEvent) => void +} + +function buildUrl(sub: SpacedustSubscription): string { + const url = new URL('/subscribe', SPACEDUST_URL) + for (const s of sub.wantedSources) { + url.searchParams.append('wantedSources', s) + } + for (const d of sub.wantedSubjectDids ?? []) { + url.searchParams.append('wantedSubjectDids', d) + } + for (const s of sub.wantedSubjects ?? []) { + url.searchParams.append('wantedSubjects', s) + } + if (sub.instant) url.searchParams.set('instant', 'true') + return url.toString() +} + +/** + * Open a Spacedust subscription. Returns a handle with `.close()`. + * + * Note: `User-Agent` can't be set on a browser WebSocket; it's encoded into the + * protocol field where supported so microcosm can still attribute the client. + */ +export function subscribe( + sub: SpacedustSubscription, + handlers: SpacedustHandlers, +): {close: () => void} { + const ws = new WebSocket(buildUrl(sub), [ + // best-effort attribution; ignored by servers that don't negotiate it + MICROCOSM_USER_AGENT.replace(/[^\w.-]/g, '_'), + ]) + + ws.onopen = () => handlers.onOpen?.() + ws.onerror = e => handlers.onError?.(e) + ws.onclose = e => handlers.onClose?.(e) + ws.onmessage = ev => { + try { + const data = + typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data + handlers.onEvent(data as SpacedustEvent) + } catch { + // ignore non-JSON frames + } + } + + return { + close: () => { + try { + ws.close() + } catch { + // already closed + } + }, + } +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 474f393..a9bac4c 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -40,6 +40,7 @@ import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {GalleryBleed} from '#/components/images/Gallery' +import {NetworkLikeCount} from '#/components/microcosm/NetworkLikeCount' import {Link} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' @@ -489,6 +490,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + useBacklinkCountQuery({subject: postUri, source: constellation.Sources.likes, enabled}) +export const useRepostCountQuery = (postUri?: string, enabled = true) => + useBacklinkCountQuery({subject: postUri, source: constellation.Sources.reposts, enabled}) +export const useReplyCountQuery = (postUri?: string, enabled = true) => + useBacklinkCountQuery({subject: postUri, source: constellation.Sources.replies, enabled}) +export const useQuoteCountQuery = (postUri?: string, enabled = true) => + useBacklinkCountQuery({subject: postUri, source: constellation.Sources.quotes, enabled}) +export const useFollowerCountQuery = (did?: string, enabled = true) => + useBacklinkCountQuery({subject: did, source: constellation.Sources.followers, enabled}) + +/** + * Paginated list of distinct DIDs linking to a subject for a given source — + * e.g. who liked a post, or who follows a DID. Returns DIDs; resolve to + * profiles separately (Slingshot / agent). + */ +export function useBacklinkDidsQuery({ + subject, + source, + enabled = true, +}: { + subject: string | undefined + source: constellation.Source + enabled?: boolean +}) { + type Page = constellation.DistinctDidsResponse + return useInfiniteQuery< + Page, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + enabled: MICROCOSM_ENABLED && enabled && !!subject, + staleTime: STALE.MINUTES.ONE, + queryKey: [RQKEY_ROOT, 'dids', source, subject ?? ''], + initialPageParam: undefined, + async queryFn({pageParam, signal}) { + return constellation.getBacklinkDids( + {subject: subject!, source, limit: 100, cursor: pageParam}, + signal, + ) + }, + getNextPageParam: last => last.cursor ?? undefined, + }) +} + +/** Who liked a post (DIDs). */ +export const useLikedByDidsQuery = (postUri?: string, enabled = true) => + useBacklinkDidsQuery({subject: postUri, source: constellation.Sources.likes, enabled}) +/** Who reposted a post (DIDs). */ +export const useRepostedByDidsQuery = (postUri?: string, enabled = true) => + useBacklinkDidsQuery({subject: postUri, source: constellation.Sources.reposts, enabled}) +/** Who follows an identity (DIDs). */ +export const useFollowerDidsQuery = (did?: string, enabled = true) => + useBacklinkDidsQuery({subject: did, source: constellation.Sources.followers, enabled}) diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts index 82a5273..09d5055 100644 --- a/src/state/queries/postgate/index.ts +++ b/src/state/queries/postgate/index.ts @@ -10,6 +10,7 @@ import { import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {networkRetry, retry} from '#/lib/async/retry' +import {getPublicRecord} from '#/lib/microcosm/records' import {logger} from '#/logger' import {updatePostShadow} from '#/state/cache/post-shadow' import {STALE} from '#/state/queries' @@ -41,7 +42,7 @@ export async function getPostgateRecord({ } try { - const {data} = await retry( + const data = await retry( 2, e => { /* @@ -55,7 +56,7 @@ export async function getPostgateRecord({ return true }, () => - agent.api.com.atproto.repo.getRecord({ + getPublicRecord(agent, { repo: urip.host, collection: POSTGATE_COLLECTION, rkey: urip.rkey, diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index 561275c..0d2fbf1 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -7,6 +7,7 @@ import { import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {networkRetry, retry} from '#/lib/async/retry' +import {getPublicRecord} from '#/lib/microcosm/records' import {STALE} from '#/state/queries' import {useGetPost} from '#/state/queries/post' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' @@ -102,7 +103,7 @@ export async function getThreadgateRecord({ } try { - const {data} = await retry( + const data = await retry( 2, e => { /* @@ -116,7 +117,7 @@ export async function getThreadgateRecord({ return true }, () => - agent.api.com.atproto.repo.getRecord({ + getPublicRecord(agent, { repo: urip.host, collection: 'app.bsky.feed.threadgate', rkey: urip.rkey,