a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate api to twister/xrpc/

+1828 -934
+47 -1
apps/twisted/README.md
··· 1 1 # Twisted App 2 2 3 - Ionic Vue client for the Twisted monorepo. 3 + Ionic Vue client for Twisted — a Tangled browser and search app for Android & iOS. 4 + 5 + ## Requirements 6 + 7 + - Node.js 20+ 8 + - pnpm 9 + - The Twister API running locally (see `packages/api/README.md`) 10 + 11 + ## Running locally 12 + 13 + ```sh 14 + # From the repo root, install dependencies 15 + pnpm install 16 + 17 + # Copy env file and point it at your local Twister API 18 + cp apps/twisted/.env.example apps/twisted/.env 19 + 20 + # Start the Vite dev server 21 + cd apps/twisted 22 + pnpm dev 23 + ``` 24 + 25 + The dev server runs at `http://localhost:5173` by default. 26 + 27 + ## Environment variables 28 + 29 + | Variable | Default | Description | 30 + | --------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | 31 + | `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. | 32 + 33 + > All upstream requests — knot XRPC, PDS records, handle resolution, DID documents, 34 + > Constellation backlink counts, and the Jetstream activity stream — are routed 35 + > through the Twister API. The app makes no direct calls to external services. 36 + 37 + ## Building for mobile 38 + 39 + ```sh 40 + # Build the web assets 41 + pnpm build 42 + 43 + # Sync to native projects 44 + pnpm cap sync 45 + 46 + # Open in Xcode / Android Studio 47 + pnpm cap open ios 48 + pnpm cap open android 49 + ```
+9
apps/twisted/src/core/config/project.ts
··· 10 10 11 11 return new URL(path.replace(/^\/+/, ""), `${twisterApiBaseUrl}/`).toString(); 12 12 } 13 + 14 + export function getTwisterWsUrl(path: string): string { 15 + if (!hasTwisterApi) { 16 + throw new Error("Twister API base URL is not configured."); 17 + } 18 + 19 + const wsBase = twisterApiBaseUrl.replace(/^http/, (m) => (m === "https" ? "wss" : "ws")); 20 + return new URL(path.replace(/^\/+/, ""), `${wsBase}/`).toString(); 21 + }
+9 -17
apps/twisted/src/features/activity/ActivityPage.vue
··· 68 68 @click="handleItemClick(item)" 69 69 @actor-click="handleActorClick(item)" /> 70 70 </div> 71 - 72 71 </ion-content> 73 72 </ion-page> 74 73 </template> ··· 97 96 import EmptyState from "@/components/common/EmptyState.vue"; 98 97 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 99 98 import { JetstreamClient } from "@/services/jetstream/client.js"; 100 - import { resolveHandleFromDid } from "@/services/tangled/endpoints.js"; 99 + import { fetchActor } from "@/services/tangled/endpoints.js"; 101 100 import type { ActivityItem } from "@/domain/models/activity.js"; 102 101 103 102 type ConnectionStatus = "connecting" | "connected" | "disconnected"; ··· 154 153 155 154 function resolveHandle(did: string): void { 156 155 if (handleCache.value.has(did)) return; 157 - // Optimistically mark as in-progress by setting to DID to avoid re-entrancy 158 156 handleCache.value.set(did, did); 159 157 160 - resolveHandleFromDid(did) 161 - .then((handle) => { 158 + fetchActor(did) 159 + .then((actor) => { 162 160 const next = new Map(handleCache.value); 163 - next.set(did, handle); 161 + next.set(did, actor.handle); 164 162 handleCache.value = next; 165 163 }) 166 164 .catch(() => { 167 - // Leave the placeholder handle from the item 168 165 handleCache.value.delete(did); 169 166 }); 170 167 } ··· 230 227 if (handle && handle !== item.actorDid) { 231 228 router.push(`/tabs/activity/user/${handle}`); 232 229 } else { 233 - // Resolve then navigate 234 - resolveHandleFromDid(item.actorDid) 235 - .then((h) => { 230 + fetchActor(item.actorDid) 231 + .then((actor) => { 232 + const h = actor.handle; 236 233 const next = new Map(handleCache.value); 237 234 next.set(item.actorDid, h); 238 235 handleCache.value = next; 239 236 router.push(`/tabs/activity/user/${h}`); 240 237 }) 241 - .catch(() => { 242 - // Cannot navigate without a handle 243 - }); 238 + .catch(console.warn); 244 239 } 245 240 } 246 241 247 242 function handleItemClick(item: ActivityItem) { 248 - // Navigate to repo if we can determine owner handle and repo name 249 243 if (item.targetName && item.targetOwnerDid) { 250 244 const ownerHandle = handleCache.value.get(item.targetOwnerDid); 251 245 if (ownerHandle && ownerHandle !== item.targetOwnerDid) { 252 246 router.push(`/tabs/activity/repo/${ownerHandle}/${item.targetName}`); 253 - } 254 - // If handle not yet resolved, resolve it in background for future clicks 255 - else { 247 + } else { 256 248 resolveHandle(item.targetOwnerDid); 257 249 } 258 250 }
+2 -4
apps/twisted/src/features/home/HomePage.vue
··· 182 182 const hasRecentItems = computed(() => recentRepos.value.length > 0 || recentProfiles.value.length > 0); 183 183 184 184 const identity = useIdentity(activeHandle, { enabled: computed(() => !!activeHandle.value) }); 185 - const did = computed(() => identity.data.value?.did ?? ""); 186 - const pds = computed(() => identity.data.value?.pds ?? ""); 187 185 const hasResolvedIdentity = computed(() => !!identity.data.value); 188 186 189 - const profileQuery = useActorProfile(pds, did, activeHandle, undefined, { enabled: hasResolvedIdentity }); 190 - const reposQuery = useUserRepos(pds, did, activeHandle, { enabled: hasResolvedIdentity }); 187 + const profileQuery = useActorProfile(activeHandle, undefined, { enabled: hasResolvedIdentity }); 188 + const reposQuery = useUserRepos(activeHandle, { enabled: hasResolvedIdentity }); 191 189 192 190 const repos = computed(() => reposQuery.data.value ?? []); 193 191 const displayName = computed(() => profileQuery.data.value?.displayName ?? "Public Tangled account");
+6 -7
apps/twisted/src/features/profile/UserProfilePage.vue
··· 195 195 196 196 const identity = useIdentity(handle); 197 197 const did = computed(() => identity.data.value?.did ?? ""); 198 - const pds = computed(() => identity.data.value?.pds ?? ""); 199 198 const hasIdentity = computed(() => !!identity.data.value); 200 199 const tabPrefix = computed(() => { 201 200 if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; ··· 203 202 return "/tabs/home"; 204 203 }); 205 204 206 - const profileQuery = useActorProfile(pds, did, handle, undefined, { enabled: hasIdentity }); 207 - const reposQuery = useUserRepos(pds, did, handle, { enabled: hasIdentity }); 208 - const stringsQuery = useUserStrings(pds, did, { enabled: hasIdentity }); 209 - const issuesQuery = useUserIssues(pds, did, handle, { enabled: hasIdentity }); 210 - const pullRequestsQuery = useUserPullRequests(pds, did, handle, { enabled: hasIdentity }); 211 - const followingQuery = useUserFollowing(pds, did, { enabled: hasIdentity }); 205 + const profileQuery = useActorProfile(handle, undefined, { enabled: hasIdentity }); 206 + const reposQuery = useUserRepos(handle, { enabled: hasIdentity }); 207 + const stringsQuery = useUserStrings(handle, { enabled: hasIdentity }); 208 + const issuesQuery = useUserIssues(handle, { enabled: hasIdentity }); 209 + const pullRequestsQuery = useUserPullRequests(handle, { enabled: hasIdentity }); 210 + const followingQuery = useUserFollowing(handle, { enabled: hasIdentity }); 212 211 const indexedProfileSummaryQuery = useIndexedProfileSummary(did, { enabled: hasIdentity }); 213 212 214 213 const profile = computed(() => profileQuery.data.value);
+10 -24
apps/twisted/src/features/repo/IssueDetailPage.vue
··· 92 92 import EmptyState from "@/components/common/EmptyState.vue"; 93 93 import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 94 94 import CommentThread from "@/components/repo/CommentThread.vue"; 95 - import { useIdentity, useRepoRecord, useIssueDetail, useIssueComments, useDefaultBranch } from "@/services/tangled/queries.js"; 95 + import { useRepoRecord, useIssueDetail, useIssueComments, useDefaultBranch } from "@/services/tangled/queries.js"; 96 96 import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 97 97 98 98 const route = useRoute(); ··· 100 100 const repoName = computed(() => String(route.params.repo ?? "")); 101 101 const issueId = computed(() => String(route.params.issueId ?? "")); 102 102 103 - const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 104 - const did = computed(() => identity.data.value?.did ?? ""); 105 - const pds = computed(() => identity.data.value?.pds ?? ""); 106 - const hasIdentity = computed(() => !!identity.data.value); 107 - 108 - const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 109 - const knotHost = computed(() => repoQuery.data.value?.knot ?? ""); 110 - const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 111 - const branchQuery = useDefaultBranch(knotHost, knotRepo, { 112 - enabled: computed(() => !!knotHost.value && !!knotRepo.value), 103 + const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 104 + const branchQuery = useDefaultBranch(owner, repoName, { 105 + enabled: computed(() => !!owner.value && !!repoName.value), 113 106 }); 114 107 const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 115 108 const markdownContext = computed<RepoAssetContext | undefined>(() => { 116 - if (!knotHost.value || !knotRepo.value || !branchQuery.data.value?.name) return undefined; 109 + if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 117 110 118 111 return { 119 112 owner: owner.value, 120 113 repo: repoName.value, 121 114 branch: branchQuery.data.value.name, 122 - knotHost: knotHost.value, 123 - knotRepo: knotRepo.value, 124 115 }; 125 116 }); 126 117 127 - const issueQuery = useIssueDetail(pds, did, owner, issueId, { enabled: hasIdentity }); 128 - const issueAtUri = computed(() => issueQuery.data.value?.atUri ?? ""); 129 - const commentsQuery = useIssueComments(pds, did, owner, issueAtUri, { enabled: computed(() => !!issueAtUri.value) }); 118 + const issueQuery = useIssueDetail(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 119 + const commentsQuery = useIssueComments(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 130 120 131 121 const issue = computed(() => { 132 122 const value = issueQuery.data.value; ··· 137 127 138 128 const comments = computed(() => commentsQuery.data.value ?? []); 139 129 const isLoading = computed( 140 - () => 141 - identity.isPending.value || 142 - repoQuery.isPending.value || 143 - issueQuery.isPending.value || 144 - commentsQuery.isPending.value, 130 + () => repoQuery.isPending.value || issueQuery.isPending.value || commentsQuery.isPending.value, 145 131 ); 146 132 const isError = computed( 147 - () => identity.isError.value || repoQuery.isError.value || issueQuery.isError.value || commentsQuery.isError.value, 133 + () => repoQuery.isError.value || issueQuery.isError.value || commentsQuery.isError.value, 148 134 ); 149 135 const errorMessage = computed(() => { 150 - const error = identity.error.value ?? repoQuery.error.value ?? issueQuery.error.value ?? commentsQuery.error.value; 136 + const error = repoQuery.error.value ?? issueQuery.error.value ?? commentsQuery.error.value; 151 137 return error instanceof Error ? error.message : "An unexpected error occurred."; 152 138 }); 153 139
+10 -22
apps/twisted/src/features/repo/PullRequestDetailPage.vue
··· 102 102 import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 103 103 import CommentThread from "@/components/repo/CommentThread.vue"; 104 104 import { 105 - useIdentity, 106 105 useRepoRecord, 107 106 useDefaultBranch, 108 107 usePullRequestDetail, ··· 115 114 const repoName = computed(() => String(route.params.repo ?? "")); 116 115 const pullId = computed(() => String(route.params.pullId ?? "")); 117 116 118 - const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 119 - const did = computed(() => identity.data.value?.did ?? ""); 120 - const pds = computed(() => identity.data.value?.pds ?? ""); 121 - const hasIdentity = computed(() => !!identity.data.value); 122 - 123 - const repoQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 124 - const knotHost = computed(() => repoQuery.data.value?.knot ?? ""); 125 - const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 126 - const branchQuery = useDefaultBranch(knotHost, knotRepo, { 127 - enabled: computed(() => !!knotHost.value && !!knotRepo.value), 117 + const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 118 + const branchQuery = useDefaultBranch(owner, repoName, { 119 + enabled: computed(() => !!owner.value && !!repoName.value), 128 120 }); 129 121 const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 130 122 const markdownContext = computed<RepoAssetContext | undefined>(() => { 131 - if (!knotHost.value || !knotRepo.value || !branchQuery.data.value?.name) return undefined; 123 + if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 132 124 133 125 return { 134 126 owner: owner.value, 135 127 repo: repoName.value, 136 128 branch: branchQuery.data.value.name, 137 - knotHost: knotHost.value, 138 - knotRepo: knotRepo.value, 139 129 }; 140 130 }); 141 131 142 - const pullQuery = usePullRequestDetail(pds, did, owner, pullId, { enabled: hasIdentity }); 143 - const pullAtUri = computed(() => pullQuery.data.value?.atUri ?? ""); 144 - const commentsQuery = usePullRequestComments(pds, did, owner, pullAtUri, { 145 - enabled: computed(() => !!pullAtUri.value), 132 + const pullQuery = usePullRequestDetail(owner, pullId, { enabled: computed(() => !!owner.value && !!pullId.value) }); 133 + const commentsQuery = usePullRequestComments(owner, pullId, { 134 + enabled: computed(() => !!owner.value && !!pullId.value), 146 135 }); 147 136 148 137 const pullRequest = computed(() => { ··· 154 143 155 144 const comments = computed(() => commentsQuery.data.value ?? []); 156 145 const isLoading = computed( 157 - () => 158 - identity.isPending.value || repoQuery.isPending.value || pullQuery.isPending.value || commentsQuery.isPending.value, 146 + () => repoQuery.isPending.value || pullQuery.isPending.value || commentsQuery.isPending.value, 159 147 ); 160 148 const isError = computed( 161 - () => identity.isError.value || repoQuery.isError.value || pullQuery.isError.value || commentsQuery.isError.value, 149 + () => repoQuery.isError.value || pullQuery.isError.value || commentsQuery.isError.value, 162 150 ); 163 151 const errorMessage = computed(() => { 164 - const error = identity.error.value ?? repoQuery.error.value ?? pullQuery.error.value ?? commentsQuery.error.value; 152 + const error = repoQuery.error.value ?? pullQuery.error.value ?? commentsQuery.error.value; 165 153 return error instanceof Error ? error.message : "An unexpected error occurred."; 166 154 }); 167 155
+14 -24
apps/twisted/src/features/repo/RepoDetailPage.vue
··· 45 45 :markdown-context="markdownContext" /> 46 46 <RepoFiles 47 47 v-else-if="segment === 'files'" 48 - :knot-host="knotHost" 49 - :knot-repo="knotRepo" 48 + :owner="owner" 49 + :repo="repoName" 50 50 :branch="defaultBranch" /> 51 51 <RepoIssues 52 52 v-else-if="segment === 'issues'" ··· 85 85 import RepoIssues from "./RepoIssues.vue"; 86 86 import RepoPRs from "./RepoPRs.vue"; 87 87 import { 88 - useIdentity, 89 88 useRepoRecord, 90 89 useDefaultBranch, 91 90 useRepoBlob, ··· 129 128 router.replace({ path: route.path, query: nextQuery }); 130 129 }); 131 130 132 - const identity = useIdentity(owner, { enabled: computed(() => !!owner.value) }); 133 - const did = computed(() => identity.data.value?.did ?? ""); 134 - const pds = computed(() => identity.data.value?.pds ?? ""); 135 - const hasIdentity = computed(() => !!identity.data.value); 131 + const recordQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 132 + const hasRecord = computed(() => !!recordQuery.data.value); 136 133 137 - const recordQuery = useRepoRecord(pds, did, repoName, owner, { enabled: hasIdentity }); 138 - const knotHost = computed(() => recordQuery.data.value?.knot ?? ""); 139 - const knotRepo = computed(() => (did.value && repoName.value ? `${did.value}/${repoName.value}` : "")); 140 - const hasRecord = computed(() => !!recordQuery.data.value?.knot && !!did.value); 141 - 142 - const branchQuery = useDefaultBranch(knotHost, knotRepo, { enabled: hasRecord }); 134 + const branchQuery = useDefaultBranch(owner, repoName, { enabled: hasRecord }); 143 135 const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 144 136 const hasBranch = computed(() => !!branchQuery.data.value?.name); 145 137 const markdownContext = computed<RepoAssetContext | undefined>(() => { 146 - if (!knotHost.value || !knotRepo.value || !defaultBranch.value) return undefined; 138 + if (!owner.value || !repoName.value || !defaultBranch.value) return undefined; 147 139 148 140 return { 149 141 owner: owner.value, 150 142 repo: repoName.value, 151 143 branch: defaultBranch.value, 152 - knotHost: knotHost.value, 153 - knotRepo: knotRepo.value, 154 144 sourcePath: "README.md", 155 145 }; 156 146 }); 157 147 158 - const languagesQuery = useRepoLanguages(knotHost, knotRepo, undefined, { enabled: hasBranch }); 159 - const readmeQuery = useRepoBlob(knotHost, knotRepo, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 160 - const logQuery = useRepoLog(knotHost, knotRepo, defaultBranch, { limit: 20, enabled: hasBranch }); 148 + const languagesQuery = useRepoLanguages(owner, repoName, undefined, { enabled: hasBranch }); 149 + const readmeQuery = useRepoBlob(owner, repoName, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 150 + const logQuery = useRepoLog(owner, repoName, defaultBranch, { limit: 20, enabled: hasBranch }); 161 151 162 152 const repo = computed((): RepoDetail | undefined => { 163 153 const rec = recordQuery.data.value; ··· 176 166 177 167 const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri }); 178 168 179 - const issuesQuery = useRepoIssues(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 180 - const prsQuery = useRepoPRs(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 169 + const issuesQuery = useRepoIssues(owner, repoName, { enabled: hasAtUri }); 170 + const prsQuery = useRepoPRs(owner, repoName, { enabled: hasAtUri }); 181 171 182 172 const commits = computed(() => logQuery.data.value ?? []); 183 173 const issues = computed(() => issuesQuery.data.value ?? []); ··· 189 179 return "/tabs/home"; 190 180 }); 191 181 192 - const isLoading = computed(() => identity.isPending.value || recordQuery.isPending.value); 193 - const isError = computed(() => identity.isError.value || recordQuery.isError.value); 182 + const isLoading = computed(() => recordQuery.isPending.value); 183 + const isError = computed(() => recordQuery.isError.value); 194 184 const errorMessage = computed(() => { 195 - const err = identity.error.value ?? recordQuery.error.value; 185 + const err = recordQuery.error.value; 196 186 return err instanceof Error ? err.message : "An unexpected error occurred."; 197 187 }); 198 188
+6 -6
apps/twisted/src/features/repo/RepoFiles.vue
··· 78 78 import { highlightCode } from "@/lib/syntax.js"; 79 79 import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 80 80 81 - const props = defineProps<{ knotHost: string; knotRepo: string; branch: string }>(); 81 + const props = defineProps<{ owner: string; repo: string; branch: string }>(); 82 82 83 83 const selectedFile = ref<RepoFile | null>(null); 84 84 const currentPath = ref(""); 85 85 86 86 const treeQuery = useRepoTree( 87 - computed(() => props.knotHost), 88 - computed(() => props.knotRepo), 87 + computed(() => props.owner), 88 + computed(() => props.repo), 89 89 computed(() => props.branch), 90 90 currentPath, 91 - { enabled: computed(() => !!props.knotHost && !!props.knotRepo && !!props.branch) }, 91 + { enabled: computed(() => !!props.owner && !!props.repo && !!props.branch) }, 92 92 ); 93 93 94 94 const sortedFiles = computed(() => { ··· 107 107 const isFileSelected = computed(() => !!selectedFile.value && selectedFile.value.type === "file"); 108 108 109 109 const blobQuery = useRepoBlob( 110 - computed(() => props.knotHost), 111 - computed(() => props.knotRepo), 110 + computed(() => props.owner), 111 + computed(() => props.repo), 112 112 computed(() => props.branch), 113 113 filePath, 114 114 { enabled: isFileSelected },
+7 -19
apps/twisted/src/services/constellation/queries.ts
··· 1 1 /** 2 - * TanStack Query hooks for the Constellation backlink API. 3 - * https://constellation.microcosm.blue 4 - * 5 - * Constellation is a public AT Protocol backlink index. It answers 6 - * "how many records link to this subject?" — star counts, follower 7 - * counts, reaction counts — without requiring authentication. 8 - * 9 - * Calling it directly from the app avoids adding per-resource endpoints 10 - * to the Twister API for every social signal we need. 2 + * TanStack Query hooks for Constellation backlink counts, proxied through the Twister API. 11 3 */ 12 4 import { useQuery } from "@tanstack/vue-query"; 13 5 import { computed, toValue } from "vue"; 14 6 import type { MaybeRef } from "vue"; 15 - 16 - const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 7 + import { getTwisterApiUrl } from "@/core/config/project.js"; 17 8 18 - // AT Protocol collection + field paths used as Constellation "sources". 19 - const SOURCE_STAR = "sh.tangled.feed.star:subject.uri"; 20 - const SOURCE_FOLLOW = "sh.tangled.graph.follow:subject"; 9 + const SOURCE_STAR = "sh.tangled.feed.star:.subject"; 10 + const SOURCE_FOLLOW = "sh.tangled.graph.follow:.subject"; 21 11 22 12 const MIN = 60_000; 23 13 24 14 async function fetchBacklinksCount(subject: string, source: string): Promise<number> { 25 - const url = new URL(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`); 15 + const url = new URL(getTwisterApiUrl("/backlinks/count")); 26 16 url.searchParams.set("subject", subject); 27 17 url.searchParams.set("source", source); 28 18 29 - const res = await fetch(url.toString(), { 30 - headers: { Accept: "application/json" }, 31 - }); 19 + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); 32 20 33 21 if (!res.ok) { 34 - throw new Error(`Constellation request failed: ${res.status}`); 22 + throw new Error(`Backlinks request failed: ${res.status}`); 35 23 } 36 24 37 25 const data = (await res.json()) as { count: number };
+5 -9
apps/twisted/src/services/jetstream/client.ts
··· 1 1 /** 2 - * JetstreamClient — subscribes to the AT Protocol Jetstream WebSocket firehose 3 - * and filters for sh.tangled.* collection events, emitting ActivityItems. 2 + * JetstreamClient — subscribes to the Twister activity stream WebSocket, which 3 + * proxies the AT Protocol Jetstream firehose and filters for sh.tangled.* events. 4 4 * 5 5 * Connects on demand, auto-reconnects after disconnection, and tracks the last 6 6 * event cursor so gap-free resume is possible on reconnect. 7 - * 8 - * Data source decision: Jetstream is chosen over PDS polling because it provides 9 - * a public, real-time stream of all network events without requiring authentication 10 - * or prior knowledge of specific user DIDs. PDS polling would require a known list 11 - * of accounts to follow, and the Twister API does not yet expose an activity feed. 12 7 */ 13 8 import type { ActivityItem } from "@/domain/models/activity.js"; 9 + import { getTwisterWsUrl } from "@/core/config/project.js"; 14 10 15 - const JETSTREAM_URL = "wss://jetstream2.us-east.bsky.network/subscribe"; 11 + const ACTIVITY_STREAM_PATH = "/activity/stream"; 16 12 const MAX_ITEMS = 200; 17 13 const RECONNECT_DELAY_MS = 3_000; 18 14 ··· 160 156 if (this.cursor !== null) { 161 157 params.set("cursor", String(this.cursor)); 162 158 } 163 - return `${JETSTREAM_URL}?${params.toString()}`; 159 + return `${getTwisterWsUrl(ACTIVITY_STREAM_PATH)}?${params.toString()}`; 164 160 } 165 161 166 162 private _open(): void {
apps/twisted/src/services/tangled/.gitkeep

This is a binary file and will not be displayed.

+204 -350
apps/twisted/src/services/tangled/endpoints.ts
··· 1 1 /** 2 - * Typed wrappers around XRPC queries to Tangled knots and the AT Protocol PDS. 3 - * 4 - * Knot endpoints use raw fetch so we can control query serialization for the 5 - * `repo=did:.../repoName` parameter. PDS endpoints also use raw fetch because 6 - * some `com.atproto.repo.*` calls are not typed in the installed packages. 7 - * 8 - * --- API Validation Notes (to verify against live endpoints) --- 9 - * Knot XRPC base: https://<knot>/xrpc/<nsid> (e.g. us-west.tangled.sh) 10 - * PDS XRPC base: https://bsky.social/xrpc/<nsid> (or user's own PDS) 2 + * Typed wrappers around Twister API endpoints. 11 3 * 12 - * CORS: knot endpoints need to be confirmed CORS-safe from a browser context. 13 - * Appview (tangled.org) serves HTML/HTMX — not a JSON API. Profile & repo 14 - * metadata must come from PDS records via com.atproto.repo.getRecord. 15 - * 16 - * Data routing: 17 - * - Git data (tree, blob, log, branches, languages) → knot XRPC 18 - * - Repo metadata & profile → PDS com.atproto.repo.getRecord/listRecords 4 + * The app calls Twister for everything — no direct XRPC calls to PDSes or 5 + * knots. Twister resolves handles, routes to the right knot/PDS, and returns 6 + * the raw Lexicon records so the existing normalizers can still apply. 19 7 */ 20 8 21 9 import { ··· 23 11 ShTangledRepoBlob, 24 12 ShTangledRepoGetDefaultBranch, 25 13 ShTangledRepoLanguages, 26 - ShTangledRepoTags, 27 14 ShTangledRepoDiff, 28 15 ShTangledRepoCompare, 29 16 ShTangledRepo, 30 17 ShTangledActorProfile, 31 18 ShTangledRepoIssue, 32 19 ShTangledRepoIssueComment, 33 - ShTangledRepoIssueState, 34 20 ShTangledRepoPull, 35 21 ShTangledRepoPullComment, 36 - ShTangledRepoPullStatus, 37 22 ShTangledGraphFollow, 38 23 ShTangledString, 39 24 } from "@atcute/tangled"; 40 25 import { throwOnXrpcError } from "@/services/atproto/client.js"; 41 - import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js"; 26 + import { getTwisterApiUrl } from "@/core/config/project.js"; 42 27 43 - type KnotParams = Record<string, string | number | boolean | undefined | Array<string | number | boolean>>; 44 - type BlueskyProfileResponse = { did: string; handle: string; displayName?: string; avatar?: string }; 28 + export type RecordEntry<T> = { uri: string; cid: string; value: T }; 29 + export type IssueEntry = RecordEntry<ShTangledRepoIssue.Main> & { state: "open" | "closed" }; 30 + export type PullEntry = RecordEntry<ShTangledRepoPull.Main> & { status: "open" | "merged" | "closed" }; 45 31 46 - function encodeKnotQueryParam(key: string, value: string | number | boolean): string { 47 - const encodedValue = encodeURIComponent(String(value)); 48 - return `${encodeURIComponent(key)}=${key === "repo" ? encodedValue.replaceAll("%2F", "/") : encodedValue}`; 49 - } 32 + export type ActorResponse = { 33 + did: string; 34 + handle: string; 35 + pds: string; 36 + profile: RecordEntry<ShTangledActorProfile.Main>; 37 + bsky?: { displayName?: string; avatar?: string } | null; 38 + }; 50 39 51 - function buildKnotQuery(params: KnotParams): string { 52 - const pairs: string[] = []; 40 + export type ActorReposResponse = { did: string; handle: string; records: RecordEntry<ShTangledRepo.Main>[] }; 41 + 42 + export type ActorRepoResponse = { 43 + did: string; 44 + handle: string; 45 + knot_host: string; 46 + record: RecordEntry<ShTangledRepo.Main>; 47 + }; 48 + 49 + export type ActorIssuesResponse = { did: string; handle: string; records: IssueEntry[] }; 50 + 51 + export type ActorPullsResponse = { did: string; handle: string; records: PullEntry[] }; 53 52 54 - for (const [key, rawValue] of Object.entries(params)) { 55 - if (rawValue === undefined) continue; 53 + export type ActorFollowingResponse = { did: string; handle: string; records: RecordEntry<ShTangledGraphFollow.Main>[] }; 56 54 57 - if (Array.isArray(rawValue)) { 58 - for (const value of rawValue) { 59 - pairs.push(encodeKnotQueryParam(key, value)); 60 - } 61 - continue; 62 - } 55 + export type ActorStringsResponse = { did: string; handle: string; records: RecordEntry<ShTangledString.Main>[] }; 63 56 64 - pairs.push(encodeKnotQueryParam(key, rawValue)); 65 - } 57 + export type IssueDetailResponse = IssueEntry; 66 58 67 - return pairs.length > 0 ? `?${pairs.join("&")}` : ""; 68 - } 59 + export type IssueCommentsResponse = { 60 + did: string; 61 + handle: string; 62 + issueUri: string; 63 + records: RecordEntry<ShTangledRepoIssueComment.Main>[]; 64 + }; 69 65 70 - export function buildKnotUrl(knotHost: string, nsid: string, params: KnotParams): string { 71 - return `https://${knotHost}/xrpc/${nsid}${buildKnotQuery(params)}`; 72 - } 66 + export type PullDetailResponse = PullEntry; 73 67 74 - async function readKnotError(res: Response): Promise<never> { 75 - const contentType = res.headers.get("content-type") ?? ""; 68 + export type PullCommentsResponse = { 69 + did: string; 70 + handle: string; 71 + pullUri: string; 72 + records: RecordEntry<ShTangledRepoPullComment.Main>[]; 73 + }; 76 74 77 - if (contentType.includes("application/json")) { 75 + async function get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> { 76 + const url = new URL(getTwisterApiUrl(path)); 77 + if (params) { 78 + for (const [key, value] of Object.entries(params)) { 79 + if (value !== undefined) url.searchParams.set(key, value); 80 + } 81 + } 82 + const res = await fetch(url.toString()); 83 + if (!res.ok) { 78 84 const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 79 85 throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 80 86 } 81 - 82 - const text = await res.text().catch(() => ""); 83 - throwOnXrpcError(res.status, "Unknown", text || undefined); 87 + return res.json() as Promise<T>; 84 88 } 85 89 86 - async function fetchKnotJson<T>(knotHost: string, nsid: string, params: KnotParams): Promise<T> { 87 - const res = await fetch(buildKnotUrl(knotHost, nsid, params)); 88 - if (!res.ok) return readKnotError(res); 89 - return res.json() as Promise<T>; 90 + async function getProxy<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> { 91 + const normalized: Record<string, string | undefined> = {}; 92 + if (params) { 93 + for (const [key, value] of Object.entries(params)) { 94 + normalized[key] = value === undefined ? undefined : String(value); 95 + } 96 + } 97 + return get<T>(path, normalized); 90 98 } 91 99 92 - async function fetchKnotBytes(knotHost: string, nsid: string, params: KnotParams): Promise<Uint8Array> { 93 - const res = await fetch(buildKnotUrl(knotHost, nsid, params)); 94 - if (!res.ok) return readKnotError(res); 100 + async function getBytes(path: string, params?: Record<string, string | undefined>): Promise<Uint8Array> { 101 + const url = new URL(getTwisterApiUrl(path)); 102 + if (params) { 103 + for (const [key, value] of Object.entries(params)) { 104 + if (value !== undefined) url.searchParams.set(key, value); 105 + } 106 + } 107 + const res = await fetch(url.toString()); 108 + if (!res.ok) { 109 + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 110 + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 111 + } 95 112 return new Uint8Array(await res.arrayBuffer()); 96 113 } 97 114 98 - export async function fetchRepoTree( 99 - knotHost: string, 100 - params: ShTangledRepoTree.$params, 101 - ): Promise<ShTangledRepoTree.$output> { 102 - return fetchKnotJson<ShTangledRepoTree.$output>(knotHost, "sh.tangled.repo.tree", params); 115 + export async function fetchActor(handle: string): Promise<ActorResponse> { 116 + return get<ActorResponse>(`/actors/${encodeURIComponent(handle)}`); 103 117 } 104 118 105 - export async function fetchRepoBlob( 106 - knotHost: string, 107 - params: ShTangledRepoBlob.$params, 108 - ): Promise<ShTangledRepoBlob.$output> { 109 - return fetchKnotJson<ShTangledRepoBlob.$output>(knotHost, "sh.tangled.repo.blob", params); 119 + export async function fetchActorRepos(handle: string): Promise<ActorReposResponse> { 120 + return get<ActorReposResponse>(`/actors/${encodeURIComponent(handle)}/repos`); 110 121 } 111 122 112 - export async function fetchDefaultBranch( 113 - knotHost: string, 114 - params: ShTangledRepoGetDefaultBranch.$params, 115 - ): Promise<ShTangledRepoGetDefaultBranch.$output> { 116 - return fetchKnotJson<ShTangledRepoGetDefaultBranch.$output>(knotHost, "sh.tangled.repo.getDefaultBranch", params); 123 + export async function fetchActorRepo(handle: string, repo: string): Promise<ActorRepoResponse> { 124 + return get<ActorRepoResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}`); 117 125 } 118 126 119 - export async function fetchLanguages( 120 - knotHost: string, 121 - params: ShTangledRepoLanguages.$params, 122 - ): Promise<ShTangledRepoLanguages.$output> { 123 - return fetchKnotJson<ShTangledRepoLanguages.$output>(knotHost, "sh.tangled.repo.languages", params); 127 + export async function fetchActorFollowing(handle: string): Promise<ActorFollowingResponse> { 128 + return get<ActorFollowingResponse>(`/actors/${encodeURIComponent(handle)}/following`); 124 129 } 125 130 126 - /** 127 - * Fetch commit log. The wire format is a raw blob; the decoded text is returned 128 - * as-is so the normalizer can handle it once the format is confirmed against 129 - * the live API. Expected: newline-delimited JSON or git log text. 130 - */ 131 - export async function fetchRepoLog( 132 - knotHost: string, 133 - params: { repo: string; ref: string; path?: string; limit?: number; cursor?: string }, 134 - ): Promise<string> { 135 - return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.log", params)); 131 + export async function fetchActorStrings(handle: string): Promise<ActorStringsResponse> { 132 + return get<ActorStringsResponse>(`/actors/${encodeURIComponent(handle)}/strings`); 136 133 } 137 134 138 - /** 139 - * Fetch branch list. The wire format is a raw blob; decoded text is returned 140 - * for the normalizer to parse once the live format is confirmed. 141 - */ 142 - export async function fetchRepoBranches( 143 - knotHost: string, 144 - params: { repo: string; limit?: number; cursor?: string }, 145 - ): Promise<string> { 146 - return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.branches", params)); 135 + export async function fetchActorIssues(handle: string): Promise<ActorIssuesResponse> { 136 + return get<ActorIssuesResponse>(`/actors/${encodeURIComponent(handle)}/issues`); 147 137 } 148 138 149 - /** Tag list. Wire format is a raw blob — decoded text returned for normalizer. */ 150 - export async function fetchRepoTags(knotHost: string, params: ShTangledRepoTags.$params): Promise<string> { 151 - return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.tags", params)); 152 - } 153 - 154 - /** Diff for a ref. Wire format is a raw blob — patch text. */ 155 - export async function fetchRepoDiff(knotHost: string, params: ShTangledRepoDiff.$params): Promise<string> { 156 - return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.diff", params)); 157 - } 158 - 159 - /** Comparison between two revisions. Wire format is a raw blob — patch text. */ 160 - export async function fetchRepoCompare(knotHost: string, params: ShTangledRepoCompare.$params): Promise<string> { 161 - return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.compare", params)); 139 + export async function fetchActorPulls(handle: string): Promise<ActorPullsResponse> { 140 + return get<ActorPullsResponse>(`/actors/${encodeURIComponent(handle)}/pulls`); 162 141 } 163 142 164 - type GetRecordResponse<T> = { uri: string; cid: string; value: T }; 143 + type ResolveHandleResponse = { did: string }; 165 144 166 - /** 167 - * Fetch a single record from the AT Protocol PDS. 168 - * Uses raw fetch against /xrpc/com.atproto.repo.getRecord since this NSID 169 - * is not currently typed in the installed @atcute packages. 170 - */ 171 - async function getRecord<T>( 172 - pds: string, 173 - repo: string, 174 - collection: string, 175 - rkey: string, 176 - ): Promise<GetRecordResponse<T>> { 177 - const url = new URL(`https://${pds}/xrpc/com.atproto.repo.getRecord`); 178 - url.searchParams.set("repo", repo); 179 - url.searchParams.set("collection", collection); 180 - url.searchParams.set("rkey", rkey); 145 + type DidDocument = { 146 + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>; 147 + alsoKnownAs?: string[]; 148 + }; 181 149 182 - const res = await fetch(url.toString()); 183 - if (!res.ok) { 184 - const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 185 - throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 186 - } 187 - return res.json() as Promise<GetRecordResponse<T>>; 150 + export async function resolveHandle(handle: string): Promise<string> { 151 + const data = await getProxy<ResolveHandleResponse>("/identity/resolve", { handle }); 152 + return data.did; 188 153 } 189 154 190 - export async function fetchActorProfile( 191 - pds: string, 192 - did: string, 193 - ): Promise<GetRecordResponse<ShTangledActorProfile.Main>> { 194 - return getRecord<ShTangledActorProfile.Main>(pds, did, "sh.tangled.actor.profile", "self"); 155 + export async function fetchDidDocument(did: string): Promise<DidDocument> { 156 + return get<DidDocument>(`/identity/did/${encodeURIComponent(did)}`); 195 157 } 196 158 197 - export async function fetchBlueskyProfile(actor: string): Promise<BlueskyProfileResponse> { 198 - const url = new URL("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"); 199 - url.searchParams.set("actor", actor); 200 - 201 - const res = await fetch(url.toString()); 202 - if (!res.ok) { 203 - const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 204 - throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 159 + export async function resolvePds(did: string): Promise<string> { 160 + const doc = await fetchDidDocument(did); 161 + const endpoint = doc.service?.find((entry) => entry.id === "#atproto_pds")?.serviceEndpoint; 162 + if (!endpoint) { 163 + throw new Error(`No PDS endpoint found in DID document for ${did}`); 205 164 } 206 - 207 - return res.json() as Promise<BlueskyProfileResponse>; 165 + return new URL(endpoint).hostname; 208 166 } 209 167 210 - /** 211 - * Fetch a repo record by its PDS record key. 212 - * This is distinct from the repo's `name`, which is the identifier used by 213 - * knot endpoints in the `did:.../repoName` format. 214 - */ 215 - export async function fetchRepoRecord( 216 - pds: string, 217 - did: string, 218 - rkey: string, 219 - ): Promise<GetRecordResponse<ShTangledRepo.Main>> { 220 - return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", rkey); 168 + export async function resolveDidIdentity(did: string): Promise<{ did: string; handle: string; pds: string }> { 169 + const actor = await fetchActor(did); 170 + return { did: actor.did, handle: actor.handle, pds: new URL(actor.pds).hostname }; 221 171 } 222 172 223 - /** 224 - * Fetch a repo record by matching on the record's `name` field. 225 - * Use this when the UI route or knot API identifies a repo by repo name rather 226 - * than by the underlying AT Protocol record key. 227 - */ 228 - export async function fetchRepoRecordByName( 229 - pds: string, 230 - did: string, 231 - repoName: string, 232 - ): Promise<GetRecordResponse<ShTangledRepo.Main>> { 233 - let cursor: string | undefined; 234 - 235 - for (;;) { 236 - const response = await listRepoRecords(pds, did, 100, cursor); 237 - const record = response.records.find((entry) => entry.value.name === repoName); 238 - if (record) return record; 239 - if (!response.cursor) break; 240 - cursor = response.cursor; 241 - } 242 - 243 - throw new NotFoundError(`Repository ${repoName}`); 173 + export async function fetchBskyXrpc<T>(nsid: string, params?: Record<string, string | number | undefined>): Promise<T> { 174 + return getProxy<T>(`/xrpc/bsky/${encodeURIComponent(nsid)}`, params); 244 175 } 245 176 246 - export async function fetchIssueRecord( 247 - pds: string, 248 - did: string, 249 - rkey: string, 250 - ): Promise<GetRecordResponse<ShTangledRepoIssue.Main>> { 251 - return getRecord<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", rkey); 177 + export async function fetchPdsXrpc<T>( 178 + pdsHost: string, 179 + nsid: string, 180 + params?: Record<string, string | number | undefined>, 181 + ): Promise<T> { 182 + return getProxy<T>(`/xrpc/pds/${encodeURIComponent(pdsHost)}/${encodeURIComponent(nsid)}`, params); 252 183 } 253 184 254 - export async function fetchPullRecord( 255 - pds: string, 256 - did: string, 257 - rkey: string, 258 - ): Promise<GetRecordResponse<ShTangledRepoPull.Main>> { 259 - return getRecord<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", rkey); 185 + export async function fetchKnotXrpc<T>( 186 + knotHost: string, 187 + nsid: string, 188 + params?: Record<string, string | number | undefined>, 189 + ): Promise<T> { 190 + return getProxy<T>(`/xrpc/knot/${encodeURIComponent(knotHost)}/${encodeURIComponent(nsid)}`, params); 260 191 } 261 192 262 - /** 263 - * Resolve an AT Protocol handle to a DID via bsky.social. 264 - * Returns the DID string (e.g. "did:plc:xxx"). 265 - */ 266 - export async function resolveHandle(handle: string): Promise<string> { 267 - const url = new URL("https://bsky.social/xrpc/com.atproto.identity.resolveHandle"); 268 - url.searchParams.set("handle", handle); 269 - const res = await fetch(url.toString()); 270 - if (!res.ok) { 271 - const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 272 - throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 273 - } 274 - const data = (await res.json()) as { did: string }; 275 - return data.did; 193 + export async function fetchRepoTree( 194 + handle: string, 195 + repo: string, 196 + params: ShTangledRepoTree.$params, 197 + ): Promise<ShTangledRepoTree.$output> { 198 + return get<ShTangledRepoTree.$output>( 199 + `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/tree`, 200 + { ref: params.ref, path: params.path }, 201 + ); 276 202 } 277 203 278 - type DidDocument = { alsoKnownAs?: string[]; service?: Array<{ id: string; type: string; serviceEndpoint: string }> }; 279 - 280 - async function fetchDidDocument(did: string): Promise<DidDocument> { 281 - let docUrl: string; 282 - if (did.startsWith("did:plc:")) { 283 - docUrl = `https://plc.directory/${did}`; 284 - } else if (did.startsWith("did:web:")) { 285 - const host = did.slice("did:web:".length); 286 - docUrl = `https://${host}/.well-known/did.json`; 287 - } else { 288 - throw new MalformedResponseError("resolveHandle", `Unsupported DID method: ${did}`); 289 - } 290 - 291 - const res = await fetch(docUrl); 292 - if (!res.ok) throwOnXrpcError(res.status, "ResolveFailed", `Could not fetch DID document: ${did}`); 293 - return (await res.json()) as DidDocument; 204 + export async function fetchRepoBlob( 205 + handle: string, 206 + repo: string, 207 + params: ShTangledRepoBlob.$params, 208 + ): Promise<ShTangledRepoBlob.$output> { 209 + return get<ShTangledRepoBlob.$output>( 210 + `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/blob`, 211 + { ref: params.ref, path: params.path }, 212 + ); 294 213 } 295 214 296 - /** 297 - * Fetch the DID document for a DID and extract the PDS service endpoint hostname. 298 - * Supports did:plc (via plc.directory) and did:web. 299 - */ 300 - export async function resolvePds(did: string): Promise<string> { 301 - const doc = await fetchDidDocument(did); 302 - const svc = doc.service?.find((s) => s.id === "#atproto_pds"); 303 - if (!svc?.serviceEndpoint) { 304 - throw new MalformedResponseError("resolvePds", `No PDS endpoint in DID document: ${did}`); 305 - } 306 - return new URL(svc.serviceEndpoint).hostname; 215 + export async function fetchDefaultBranch(handle: string, repo: string): Promise<ShTangledRepoGetDefaultBranch.$output> { 216 + return get<ShTangledRepoGetDefaultBranch.$output>( 217 + `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/default-branch`, 218 + ); 307 219 } 308 220 309 - export async function resolveHandleFromDid(did: string): Promise<string> { 310 - if (did.startsWith("did:web:")) return did.slice("did:web:".length); 311 - 312 - const doc = await fetchDidDocument(did); 313 - const alias = doc.alsoKnownAs?.find((entry) => entry.startsWith("at://")); 314 - if (!alias) { 315 - throw new MalformedResponseError("resolveHandleFromDid", `No handle alias in DID document: ${did}`); 316 - } 317 - 318 - return alias.slice("at://".length); 221 + export async function fetchLanguages( 222 + handle: string, 223 + repo: string, 224 + ref?: string, 225 + ): Promise<ShTangledRepoLanguages.$output> { 226 + return get<ShTangledRepoLanguages.$output>( 227 + `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/languages`, 228 + ref ? { ref } : undefined, 229 + ); 319 230 } 320 231 321 - export async function resolveDidIdentity(did: string): Promise<{ did: string; handle: string; pds: string }> { 322 - const [handle, pds] = await Promise.all([resolveHandleFromDid(did), resolvePds(did)]); 323 - return { did, handle, pds }; 232 + export async function fetchRepoLog( 233 + handle: string, 234 + repo: string, 235 + params: { ref: string; path?: string; limit?: number; cursor?: string }, 236 + ): Promise<string> { 237 + const p: Record<string, string | undefined> = { ref: params.ref, path: params.path }; 238 + if (params.limit !== undefined) p.limit = String(params.limit); 239 + if (params.cursor !== undefined) p.cursor = params.cursor; 240 + return new TextDecoder().decode( 241 + await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/log`, p), 242 + ); 324 243 } 325 244 326 - type ListRecordsResponse<T> = { records: Array<{ uri: string; cid: string; value: T }>; cursor?: string }; 327 - 328 - async function listRecords<T>( 329 - pds: string, 330 - did: string, 331 - collection: string, 332 - limit = 50, 333 - cursor?: string, 334 - ): Promise<ListRecordsResponse<T>> { 335 - const url = new URL(`https://${pds}/xrpc/com.atproto.repo.listRecords`); 336 - url.searchParams.set("repo", did); 337 - url.searchParams.set("collection", collection); 338 - url.searchParams.set("limit", String(limit)); 339 - if (cursor) url.searchParams.set("cursor", cursor); 340 - const res = await fetch(url.toString()); 341 - if (!res.ok) { 342 - const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 343 - throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 344 - } 345 - return res.json() as Promise<ListRecordsResponse<T>>; 245 + export async function fetchRepoBranches( 246 + handle: string, 247 + repo: string, 248 + params?: { limit?: number; cursor?: string }, 249 + ): Promise<string> { 250 + const p: Record<string, string | undefined> = {}; 251 + if (params?.limit !== undefined) p.limit = String(params.limit); 252 + if (params?.cursor !== undefined) p.cursor = params.cursor; 253 + return new TextDecoder().decode( 254 + await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/branches`, p), 255 + ); 346 256 } 347 257 348 - /** List sh.tangled.repo.issue records from a user's PDS. */ 349 - export async function listIssueRecords( 350 - pds: string, 351 - did: string, 352 - limit = 50, 353 - cursor?: string, 354 - ): Promise<ListRecordsResponse<ShTangledRepoIssue.Main>> { 355 - return listRecords<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", limit, cursor); 258 + export async function fetchRepoTags(handle: string, repo: string): Promise<string> { 259 + return new TextDecoder().decode( 260 + await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/tags`), 261 + ); 356 262 } 357 263 358 - /** List sh.tangled.repo.issue.state records from a user's PDS. */ 359 - export async function listIssueStateRecords( 360 - pds: string, 361 - did: string, 362 - limit = 100, 363 - cursor?: string, 364 - ): Promise<ListRecordsResponse<ShTangledRepoIssueState.Main>> { 365 - return listRecords<ShTangledRepoIssueState.Main>(pds, did, "sh.tangled.repo.issue.state", limit, cursor); 264 + export async function fetchRepoDiff(handle: string, repo: string, params: ShTangledRepoDiff.$params): Promise<string> { 265 + return new TextDecoder().decode( 266 + await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/diff`, { ref: params.ref }), 267 + ); 366 268 } 367 269 368 - /** List sh.tangled.repo.issue.comment records from a user's PDS. */ 369 - export async function listIssueCommentRecords( 370 - pds: string, 371 - did: string, 372 - limit = 100, 373 - cursor?: string, 374 - ): Promise<ListRecordsResponse<ShTangledRepoIssueComment.Main>> { 375 - return listRecords<ShTangledRepoIssueComment.Main>(pds, did, "sh.tangled.repo.issue.comment", limit, cursor); 270 + export async function fetchRepoCompare( 271 + handle: string, 272 + repo: string, 273 + params: ShTangledRepoCompare.$params, 274 + ): Promise<string> { 275 + return new TextDecoder().decode( 276 + await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/compare`, { 277 + from: params.rev1, 278 + to: params.rev2, 279 + }), 280 + ); 376 281 } 377 282 378 - /** List sh.tangled.repo.pull records from a user's PDS. */ 379 - export async function listPullRecords( 380 - pds: string, 381 - did: string, 382 - limit = 50, 383 - cursor?: string, 384 - ): Promise<ListRecordsResponse<ShTangledRepoPull.Main>> { 385 - return listRecords<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", limit, cursor); 283 + export async function fetchRepoIssues(handle: string, repo: string): Promise<ActorIssuesResponse> { 284 + return get<ActorIssuesResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/issues`); 386 285 } 387 286 388 - /** List sh.tangled.repo.pull.status records from a user's PDS. */ 389 - export async function listPullStatusRecords( 390 - pds: string, 391 - did: string, 392 - limit = 100, 393 - cursor?: string, 394 - ): Promise<ListRecordsResponse<ShTangledRepoPullStatus.Main>> { 395 - return listRecords<ShTangledRepoPullStatus.Main>(pds, did, "sh.tangled.repo.pull.status", limit, cursor); 287 + export async function fetchRepoPulls(handle: string, repo: string): Promise<ActorPullsResponse> { 288 + return get<ActorPullsResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/pulls`); 396 289 } 397 290 398 - /** List sh.tangled.repo.pull.comment records from a user's PDS. */ 399 - export async function listPullCommentRecords( 400 - pds: string, 401 - did: string, 402 - limit = 100, 403 - cursor?: string, 404 - ): Promise<ListRecordsResponse<ShTangledRepoPullComment.Main>> { 405 - return listRecords<ShTangledRepoPullComment.Main>(pds, did, "sh.tangled.repo.pull.comment", limit, cursor); 291 + export async function fetchIssueDetail(handle: string, rkey: string): Promise<IssueDetailResponse> { 292 + return get<IssueDetailResponse>(`/issues/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`); 406 293 } 407 294 408 - export async function listFollowRecords( 409 - pds: string, 410 - did: string, 411 - limit = 100, 412 - cursor?: string, 413 - ): Promise<ListRecordsResponse<ShTangledGraphFollow.Main>> { 414 - return listRecords<ShTangledGraphFollow.Main>(pds, did, "sh.tangled.graph.follow", limit, cursor); 295 + export async function fetchIssueComments(handle: string, rkey: string): Promise<IssueCommentsResponse> { 296 + return get<IssueCommentsResponse>(`/issues/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}/comments`); 415 297 } 416 298 417 - export async function listStringRecords( 418 - pds: string, 419 - did: string, 420 - limit = 100, 421 - cursor?: string, 422 - ): Promise<ListRecordsResponse<ShTangledString.Main>> { 423 - return listRecords<ShTangledString.Main>(pds, did, "sh.tangled.string", limit, cursor); 299 + export async function fetchPullDetail(handle: string, rkey: string): Promise<PullDetailResponse> { 300 + return get<PullDetailResponse>(`/pulls/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`); 424 301 } 425 302 426 - /** 427 - * List all sh.tangled.repo records from a user's PDS. 428 - * Uses com.atproto.repo.listRecords since it's not in the installed lexicons. 429 - */ 430 - export async function listRepoRecords( 431 - pds: string, 432 - did: string, 433 - limit = 50, 434 - cursor?: string, 435 - ): Promise<{ records: Array<{ uri: string; cid: string; value: ShTangledRepo.Main }>; cursor?: string }> { 436 - const url = new URL(`https://${pds}/xrpc/com.atproto.repo.listRecords`); 437 - url.searchParams.set("repo", did); 438 - url.searchParams.set("collection", "sh.tangled.repo"); 439 - url.searchParams.set("limit", String(limit)); 440 - if (cursor) url.searchParams.set("cursor", cursor); 441 - 442 - const res = await fetch(url.toString()); 443 - if (!res.ok) { 444 - const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; 445 - throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); 446 - } 447 - return res.json() as Promise<{ 448 - records: Array<{ uri: string; cid: string; value: ShTangledRepo.Main }>; 449 - cursor?: string; 450 - }>; 303 + export async function fetchPullComments(handle: string, rkey: string): Promise<PullCommentsResponse> { 304 + return get<PullCommentsResponse>(`/pulls/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}/comments`); 451 305 }
+228 -377
apps/twisted/src/services/tangled/queries.ts
··· 1 1 /** 2 - * TanStack Query hooks for Tangled data. 3 - * These are the only entry points Vue components should use — no direct 4 - * imports of @atcute/* or service/endpoint functions in components. 5 - * 6 - * Cache strategy: 7 - * Repo metadata stale: 5m gc: 30m 8 - * File tree stale: 2m gc: 10m 9 - * File content stale: 5m gc: 30m 10 - * Commit log stale: 2m gc: 10m 11 - * Branches stale: 2m gc: 10m 12 - * Profile stale: 10m gc: 60m 13 - * README stale: 5m gc: 30m 2 + * TanStack Query hooks for Tangled data via Twister API. 3 + * Components should only use these hooks rather than endpoint wrappers directly. 14 4 */ 15 5 16 6 import { useQuery } from "@tanstack/vue-query"; ··· 19 9 import type { FollowedUserSummary } from "@/domain/models/follow.js"; 20 10 import type { StringSummary } from "@/domain/models/string.js"; 21 11 import { 12 + fetchActor, 13 + fetchActorRepos, 14 + fetchActorRepo, 15 + fetchActorFollowing, 16 + fetchActorStrings, 17 + fetchActorIssues, 18 + fetchActorPulls, 22 19 fetchRepoTree, 23 20 fetchRepoBlob, 24 21 fetchDefaultBranch, ··· 28 25 fetchRepoTags, 29 26 fetchRepoDiff, 30 27 fetchRepoCompare, 31 - fetchActorProfile, 32 - fetchBlueskyProfile, 33 - fetchRepoRecordByName, 34 - fetchIssueRecord, 35 - fetchPullRecord, 36 - listRepoRecords, 37 - listIssueRecords, 38 - listIssueCommentRecords, 39 - listIssueStateRecords, 40 - listPullRecords, 41 - listPullCommentRecords, 42 - listPullStatusRecords, 43 - listFollowRecords, 44 - listStringRecords, 45 - resolveHandle, 46 - resolveDidIdentity, 47 - resolvePds, 28 + fetchRepoIssues, 29 + fetchRepoPulls, 30 + fetchIssueDetail, 31 + fetchIssueComments, 32 + fetchPullDetail, 33 + fetchPullComments, 48 34 } from "./endpoints.js"; 49 35 import { 50 36 normalizeTree, ··· 79 65 return required && (enabled === undefined || !!toValue(enabled)); 80 66 } 81 67 82 - async function resolveBlueskyProfile( 83 - record: Awaited<ReturnType<typeof fetchActorProfile>>["value"], 84 - did: string, 85 - ): Promise<{ displayName?: string; avatar?: string }> { 86 - if (!record.bluesky) return {}; 87 - 88 - try { 89 - const profile = await fetchBlueskyProfile(did); 90 - return { displayName: profile.displayName, avatar: profile.avatar }; 91 - } catch { 92 - return {}; 93 - } 94 - } 95 - 96 68 /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ 97 69 export type Identity = { did: string; pds: string }; 98 70 99 - /** 100 - * Resolve an AT Protocol handle to its DID and PDS hostname. 101 - * Result is cached for 10 minutes (handles rarely change). 102 - */ 71 + /** Resolve a handle through Twister actor endpoint. */ 103 72 export function useIdentity(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 104 73 const normalizedHandle = computed(() => toValue(handle).trim()); 105 74 106 75 return useQuery({ 107 76 queryKey: computed(() => ["identity", normalizedHandle.value]), 108 77 queryFn: async (): Promise<Identity> => { 109 - const did = await resolveHandle(normalizedHandle.value); 110 - const pds = await resolvePds(did); 111 - return { did, pds }; 78 + const actor = await fetchActor(normalizedHandle.value); 79 + return { did: actor.did, pds: new URL(actor.pds).hostname }; 112 80 }, 113 81 enabled: computed(() => isEnabled(hasText(normalizedHandle), options.enabled)), 114 82 staleTime: 10 * MIN, ··· 118 86 119 87 /** File tree for a path within a repo. */ 120 88 export function useRepoTree( 121 - knotHost: MaybeRef<string>, 89 + handle: MaybeRef<string>, 122 90 repo: MaybeRef<string>, 123 91 ref: MaybeRef<string>, 124 92 path: MaybeRef<string | undefined> = undefined, 125 93 options: { enabled?: MaybeRef<boolean> } = {}, 126 94 ) { 95 + const h = computed(() => toValue(handle).trim()); 96 + const r = computed(() => toValue(repo).trim()); 97 + 127 98 return useQuery({ 128 - queryKey: computed(() => ["tree", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), 99 + queryKey: computed(() => ["tree", h.value, r.value, toValue(ref), toValue(path)]), 129 100 queryFn: () => 130 - fetchRepoTree(toValue(knotHost), { 131 - repo: toValue(repo), 132 - ref: toValue(ref), 133 - path: toValue(path), 134 - }).then((out) => normalizeTree(out, toValue(path) ?? "")), 135 - enabled: options.enabled, 101 + fetchRepoTree(h.value, r.value, { repo: `${h.value}/${r.value}`, ref: toValue(ref), path: toValue(path) }).then( 102 + (out) => normalizeTree(out, toValue(path) ?? ""), 103 + ), 104 + enabled: computed(() => isEnabled(hasText(h) && hasText(r) && hasText(ref), options.enabled)), 136 105 staleTime: 2 * MIN, 137 106 gcTime: 10 * MIN, 138 107 }); ··· 140 109 141 110 /** Raw file content (blob) for a specific path and ref. */ 142 111 export function useRepoBlob( 143 - knotHost: MaybeRef<string>, 112 + handle: MaybeRef<string>, 144 113 repo: MaybeRef<string>, 145 114 ref: MaybeRef<string>, 146 115 path: MaybeRef<string>, 147 116 options: { readme?: boolean; enabled?: MaybeRef<boolean> } = {}, 148 117 ) { 118 + const h = computed(() => toValue(handle).trim()); 119 + const r = computed(() => toValue(repo).trim()); 120 + 149 121 return useQuery({ 150 - queryKey: computed(() => ["blob", toValue(knotHost), toValue(repo), toValue(ref), toValue(path)]), 122 + queryKey: computed(() => ["blob", h.value, r.value, toValue(ref), toValue(path)]), 151 123 queryFn: () => 152 - fetchRepoBlob(toValue(knotHost), { 153 - repo: toValue(repo), 154 - ref: toValue(ref), 155 - path: toValue(path), 156 - }).then(normalizeBlob), 157 - enabled: options.enabled, 124 + fetchRepoBlob(h.value, r.value, { repo: `${h.value}/${r.value}`, ref: toValue(ref), path: toValue(path) }).then( 125 + normalizeBlob, 126 + ), 127 + enabled: computed(() => isEnabled(hasText(h) && hasText(r) && hasText(ref) && hasText(path), options.enabled)), 158 128 staleTime: 5 * MIN, 159 129 gcTime: 30 * MIN, 160 130 }); ··· 162 132 163 133 /** Default branch name + latest commit for a repo. */ 164 134 export function useDefaultBranch( 165 - knotHost: MaybeRef<string>, 135 + handle: MaybeRef<string>, 166 136 repo: MaybeRef<string>, 167 137 options: { enabled?: MaybeRef<boolean> } = {}, 168 138 ) { 139 + const h = computed(() => toValue(handle).trim()); 140 + const r = computed(() => toValue(repo).trim()); 141 + 169 142 return useQuery({ 170 - queryKey: computed(() => ["defaultBranch", toValue(knotHost), toValue(repo)]), 171 - queryFn: () => 172 - fetchDefaultBranch(toValue(knotHost), { repo: toValue(repo) }).then(normalizeDefaultBranch), 173 - enabled: options.enabled, 143 + queryKey: computed(() => ["defaultBranch", h.value, r.value]), 144 + queryFn: () => fetchDefaultBranch(h.value, r.value).then(normalizeDefaultBranch), 145 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 174 146 staleTime: 5 * MIN, 175 147 gcTime: 30 * MIN, 176 148 }); ··· 178 150 179 151 /** Language breakdown for a repo (percentages). */ 180 152 export function useRepoLanguages( 181 - knotHost: MaybeRef<string>, 153 + handle: MaybeRef<string>, 182 154 repo: MaybeRef<string>, 183 155 ref?: MaybeRef<string | undefined>, 184 156 options: { enabled?: MaybeRef<boolean> } = {}, 185 157 ) { 158 + const h = computed(() => toValue(handle).trim()); 159 + const r = computed(() => toValue(repo).trim()); 160 + 186 161 return useQuery({ 187 - queryKey: computed(() => ["languages", toValue(knotHost), toValue(repo), toValue(ref)]), 188 - queryFn: () => 189 - fetchLanguages(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }).then(normalizeLanguages), 190 - enabled: options.enabled, 162 + queryKey: computed(() => ["languages", h.value, r.value, toValue(ref)]), 163 + queryFn: () => fetchLanguages(h.value, r.value, toValue(ref)).then(normalizeLanguages), 164 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 191 165 staleTime: 5 * MIN, 192 166 gcTime: 30 * MIN, 193 167 }); ··· 195 169 196 170 /** Paginated commit log for a repo/ref. */ 197 171 export function useRepoLog( 198 - knotHost: MaybeRef<string>, 172 + handle: MaybeRef<string>, 199 173 repo: MaybeRef<string>, 200 174 ref: MaybeRef<string>, 201 175 options: { ··· 205 179 enabled?: MaybeRef<boolean>; 206 180 } = {}, 207 181 ) { 182 + const h = computed(() => toValue(handle).trim()); 183 + const r = computed(() => toValue(repo).trim()); 184 + 208 185 return useQuery({ 209 - queryKey: computed(() => [ 210 - "log", 211 - toValue(knotHost), 212 - toValue(repo), 213 - toValue(ref), 214 - toValue(options.path), 215 - toValue(options.cursor), 216 - ]), 186 + queryKey: computed(() => ["log", h.value, r.value, toValue(ref), toValue(options.path), toValue(options.cursor)]), 217 187 queryFn: () => 218 - fetchRepoLog(toValue(knotHost), { 219 - repo: toValue(repo), 188 + fetchRepoLog(h.value, r.value, { 220 189 ref: toValue(ref), 221 190 path: toValue(options.path), 222 191 limit: options.limit, 223 192 cursor: toValue(options.cursor), 224 193 }).then(normalizeLogText), 225 - enabled: options.enabled, 194 + enabled: computed(() => isEnabled(hasText(h) && hasText(r) && hasText(ref), options.enabled)), 226 195 staleTime: 2 * MIN, 227 196 gcTime: 10 * MIN, 228 197 }); ··· 230 199 231 200 /** Branch list for a repo. */ 232 201 export function useRepoBranches( 233 - knotHost: MaybeRef<string>, 202 + handle: MaybeRef<string>, 234 203 repo: MaybeRef<string>, 235 204 defaultBranch?: MaybeRef<string | undefined>, 236 205 options: { enabled?: MaybeRef<boolean> } = {}, 237 206 ) { 207 + const h = computed(() => toValue(handle).trim()); 208 + const r = computed(() => toValue(repo).trim()); 209 + 238 210 return useQuery({ 239 - queryKey: computed(() => ["branches", toValue(knotHost), toValue(repo)]), 211 + queryKey: computed(() => ["branches", h.value, r.value]), 240 212 queryFn: () => 241 - fetchRepoBranches(toValue(knotHost), { repo: toValue(repo) }).then((raw) => 242 - normalizeBranchesText(raw, toValue(defaultBranch)), 243 - ), 244 - enabled: options.enabled, 213 + fetchRepoBranches(h.value, r.value).then((raw) => normalizeBranchesText(raw, toValue(defaultBranch))), 214 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 245 215 staleTime: 2 * MIN, 246 216 gcTime: 10 * MIN, 247 217 }); 248 218 } 249 219 250 - /** 251 - * Fetch a repo's PDS record (metadata: description, topics, knot, etc.). 252 - * `pds` is the PDS hostname, e.g. "bsky.social". 253 - */ 220 + /** Fetch a repo record (metadata: description, topics, knot, etc.). */ 254 221 export function useRepoRecord( 255 - pds: MaybeRef<string>, 256 - did: MaybeRef<string>, 222 + handle: MaybeRef<string>, 257 223 repoName: MaybeRef<string>, 258 - handle: MaybeRef<string>, 259 224 options: { enabled?: MaybeRef<boolean> } = {}, 260 225 ) { 261 - const normalizedPds = computed(() => toValue(pds).trim()); 262 - const normalizedDid = computed(() => toValue(did).trim()); 263 - const normalizedRepoName = computed(() => toValue(repoName).trim()); 226 + const h = computed(() => toValue(handle).trim()); 227 + const repo = computed(() => toValue(repoName).trim()); 264 228 265 229 return useQuery({ 266 - queryKey: computed(() => ["repoRecord", normalizedPds.value, normalizedDid.value, normalizedRepoName.value]), 230 + queryKey: computed(() => ["repoRecord", h.value, repo.value]), 267 231 queryFn: async () => { 268 - const { value: record, uri } = await fetchRepoRecordByName( 269 - normalizedPds.value, 270 - normalizedDid.value, 271 - normalizedRepoName.value, 272 - ).then((r) => ({ 273 - value: r.value, 274 - uri: r.uri, 275 - })); 276 - return normalizeRepoRecord(record, toValue(did), toValue(handle), uri); 232 + const response = await fetchActorRepo(h.value, repo.value); 233 + return normalizeRepoRecord(response.record.value, response.did, response.handle, response.record.uri); 277 234 }, 278 - enabled: computed(() => 279 - isEnabled(hasText(normalizedPds) && hasText(normalizedDid) && hasText(normalizedRepoName), options.enabled), 280 - ), 235 + enabled: computed(() => isEnabled(hasText(h) && hasText(repo), options.enabled)), 281 236 staleTime: 5 * MIN, 282 237 gcTime: 30 * MIN, 283 238 }); 284 239 } 285 240 286 - /** List all repos for a user from their PDS. */ 287 - export function useUserRepos( 288 - pds: MaybeRef<string>, 289 - did: MaybeRef<string>, 290 - handle: MaybeRef<string>, 291 - options: { enabled?: MaybeRef<boolean> } = {}, 292 - ) { 293 - const normalizedPds = computed(() => toValue(pds).trim()); 294 - const normalizedDid = computed(() => toValue(did).trim()); 241 + /** List all repos for a user. */ 242 + export function useUserRepos(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 243 + const h = computed(() => toValue(handle).trim()); 295 244 296 245 return useQuery({ 297 - queryKey: computed(() => ["userRepos", normalizedPds.value, normalizedDid.value]), 246 + queryKey: computed(() => ["userRepos", h.value]), 298 247 queryFn: async () => { 299 - const { records } = await listRepoRecords(normalizedPds.value, normalizedDid.value); 300 - return records.map((r) => normalizeRepoRecord(r.value, toValue(did), toValue(handle), r.uri)); 248 + const response = await fetchActorRepos(h.value); 249 + return response.records.map((r) => normalizeRepoRecord(r.value, response.did, response.handle, r.uri)); 301 250 }, 302 - enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), 251 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 303 252 staleTime: 5 * MIN, 304 253 gcTime: 30 * MIN, 305 254 }); 306 255 } 307 256 308 - /** Fetch a user's Tangled actor profile from their PDS. */ 257 + /** Fetch a user's Tangled actor profile. */ 309 258 export function useActorProfile( 310 - pds: MaybeRef<string>, 311 - did: MaybeRef<string>, 312 259 handle: MaybeRef<string>, 313 260 displayName?: MaybeRef<string | undefined>, 314 261 options: { enabled?: MaybeRef<boolean> } = {}, 315 262 ) { 316 - const normalizedPds = computed(() => toValue(pds).trim()); 317 - const normalizedDid = computed(() => toValue(did).trim()); 263 + const h = computed(() => toValue(handle).trim()); 318 264 319 265 return useQuery({ 320 - queryKey: computed(() => ["actorProfile", normalizedPds.value, normalizedDid.value]), 266 + queryKey: computed(() => ["actorProfile", h.value]), 321 267 queryFn: async () => { 322 - const { value } = await fetchActorProfile(normalizedPds.value, normalizedDid.value); 323 - const bluesky = await resolveBlueskyProfile(value, normalizedDid.value); 268 + const response = await fetchActor(h.value); 324 269 return normalizeActorProfile( 325 - value, 326 - toValue(did), 327 - toValue(handle), 328 - bluesky.displayName ?? toValue(displayName), 329 - bluesky.avatar, 270 + response.profile.value, 271 + response.did, 272 + response.handle, 273 + response.bsky?.displayName ?? toValue(displayName), 274 + response.bsky?.avatar, 330 275 ); 331 276 }, 332 - enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), 277 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 333 278 staleTime: 10 * MIN, 334 279 gcTime: 60 * MIN, 335 280 }); ··· 337 282 338 283 /** Tag list for a repo. Wire format is a raw blob; parsed as lines. */ 339 284 export function useRepoTags( 340 - knotHost: MaybeRef<string>, 285 + handle: MaybeRef<string>, 341 286 repo: MaybeRef<string>, 342 287 options: { enabled?: MaybeRef<boolean> } = {}, 343 288 ) { 289 + const h = computed(() => toValue(handle).trim()); 290 + const r = computed(() => toValue(repo).trim()); 291 + 344 292 return useQuery({ 345 - queryKey: computed(() => ["tags", toValue(knotHost), toValue(repo)]), 346 - queryFn: () => 347 - fetchRepoTags(toValue(knotHost), { repo: toValue(repo) }).then((raw) => raw.trim().split("\n").filter(Boolean)), 348 - enabled: options.enabled, 293 + queryKey: computed(() => ["tags", h.value, r.value]), 294 + queryFn: () => fetchRepoTags(h.value, r.value).then((raw) => raw.trim().split("\n").filter(Boolean)), 295 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 349 296 staleTime: 2 * MIN, 350 297 gcTime: 10 * MIN, 351 298 }); ··· 353 300 354 301 /** Unified diff for a ref (patch text). */ 355 302 export function useRepoDiff( 356 - knotHost: MaybeRef<string>, 303 + handle: MaybeRef<string>, 357 304 repo: MaybeRef<string>, 358 305 ref: MaybeRef<string>, 359 306 options: { enabled?: MaybeRef<boolean> } = {}, 360 307 ) { 308 + const h = computed(() => toValue(handle).trim()); 309 + const r = computed(() => toValue(repo).trim()); 310 + 361 311 return useQuery({ 362 - queryKey: computed(() => ["diff", toValue(knotHost), toValue(repo), toValue(ref)]), 363 - queryFn: () => fetchRepoDiff(toValue(knotHost), { repo: toValue(repo), ref: toValue(ref) }), 364 - enabled: options.enabled, 312 + queryKey: computed(() => ["diff", h.value, r.value, toValue(ref)]), 313 + queryFn: () => fetchRepoDiff(h.value, r.value, { repo: `${h.value}/${r.value}`, ref: toValue(ref) }), 314 + enabled: computed(() => isEnabled(hasText(h) && hasText(r) && hasText(ref), options.enabled)), 365 315 staleTime: 5 * MIN, 366 316 gcTime: 30 * MIN, 367 317 }); ··· 369 319 370 320 /** Comparison diff between two revisions (patch text). */ 371 321 export function useRepoCompare( 372 - knotHost: MaybeRef<string>, 322 + handle: MaybeRef<string>, 373 323 repo: MaybeRef<string>, 374 324 rev1: MaybeRef<string>, 375 325 rev2: MaybeRef<string>, 376 326 options: { enabled?: MaybeRef<boolean> } = {}, 377 327 ) { 328 + const h = computed(() => toValue(handle).trim()); 329 + const r = computed(() => toValue(repo).trim()); 330 + 378 331 return useQuery({ 379 - queryKey: computed(() => ["compare", toValue(knotHost), toValue(repo), toValue(rev1), toValue(rev2)]), 332 + queryKey: computed(() => ["compare", h.value, r.value, toValue(rev1), toValue(rev2)]), 380 333 queryFn: () => 381 - fetchRepoCompare(toValue(knotHost), { 382 - repo: toValue(repo), 383 - rev1: toValue(rev1), 384 - rev2: toValue(rev2), 385 - }), 386 - enabled: options.enabled, 334 + fetchRepoCompare(h.value, r.value, { repo: `${h.value}/${r.value}`, rev1: toValue(rev1), rev2: toValue(rev2) }), 335 + enabled: computed(() => isEnabled(hasText(h) && hasText(r) && hasText(rev1) && hasText(rev2), options.enabled)), 387 336 staleTime: 5 * MIN, 388 337 gcTime: 30 * MIN, 389 338 }); 390 339 } 391 340 392 - /** 393 - * Issues for a repo. Lists sh.tangled.repo.issue records from the owner's PDS, 394 - * filtered by repo AT URI, joined with state from sh.tangled.repo.issue.state. 395 - */ 341 + /** Issues for a repo, returned with current state by the backend. */ 396 342 export function useRepoIssues( 397 - pds: MaybeRef<string>, 398 - did: MaybeRef<string>, 399 343 handle: MaybeRef<string>, 400 - repoAtUri: MaybeRef<string>, 344 + repo: MaybeRef<string>, 401 345 options: { enabled?: MaybeRef<boolean> } = {}, 402 346 ) { 347 + const h = computed(() => toValue(handle).trim()); 348 + const r = computed(() => toValue(repo).trim()); 349 + 403 350 return useQuery({ 404 - queryKey: computed(() => ["issues", toValue(pds), toValue(did), toValue(repoAtUri)]), 351 + queryKey: computed(() => ["issues", h.value, r.value]), 405 352 queryFn: async () => { 406 - const [issuesRes, statesRes] = await Promise.all([ 407 - listIssueRecords(toValue(pds), toValue(did)), 408 - listIssueStateRecords(toValue(pds), toValue(did)), 409 - ]); 410 - 411 - const stateMap = new Map<string, "open" | "closed">(); 412 - for (const s of statesRes.records) { 413 - const closed = s.value.state === "sh.tangled.repo.issue.state.closed"; 414 - stateMap.set(s.value.issue, closed ? "closed" : "open"); 415 - } 416 - 417 - const targetRepo = toValue(repoAtUri); 418 - return issuesRes.records 419 - .filter((r) => !targetRepo || r.value.repo === targetRepo) 420 - .map((r) => normalizeIssueRecord(r.value, r.uri, toValue(did), toValue(handle), stateMap.get(r.uri) ?? "open")); 353 + const response = await fetchRepoIssues(h.value, r.value); 354 + return response.records.map((entry) => 355 + normalizeIssueRecord(entry.value, entry.uri, response.did, response.handle, entry.state), 356 + ); 421 357 }, 422 - enabled: options.enabled, 358 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 423 359 staleTime: 2 * MIN, 424 360 gcTime: 10 * MIN, 425 361 }); 426 362 } 427 363 428 - /** 429 - * Pull requests for a repo. Lists sh.tangled.repo.pull records from the owner's 430 - * PDS filtered by target repo AT URI, joined with status records. 431 - */ 364 + /** Pull requests for a repo, returned with current status by the backend. */ 432 365 export function useRepoPRs( 433 - pds: MaybeRef<string>, 434 - did: MaybeRef<string>, 435 366 handle: MaybeRef<string>, 436 - repoAtUri: MaybeRef<string>, 367 + repo: MaybeRef<string>, 437 368 options: { enabled?: MaybeRef<boolean> } = {}, 438 369 ) { 370 + const h = computed(() => toValue(handle).trim()); 371 + const r = computed(() => toValue(repo).trim()); 372 + 439 373 return useQuery({ 440 - queryKey: computed(() => ["prs", toValue(pds), toValue(did), toValue(repoAtUri)]), 374 + queryKey: computed(() => ["prs", h.value, r.value]), 441 375 queryFn: async () => { 442 - const [pullsRes, statusesRes] = await Promise.all([ 443 - listPullRecords(toValue(pds), toValue(did)), 444 - listPullStatusRecords(toValue(pds), toValue(did)), 445 - ]); 446 - 447 - const statusMap = new Map<string, "open" | "merged" | "closed">(); 448 - for (const s of statusesRes.records) { 449 - const raw = s.value.status ?? "sh.tangled.repo.pull.status.open"; 450 - const status = 451 - raw === "sh.tangled.repo.pull.status.merged" 452 - ? "merged" 453 - : raw === "sh.tangled.repo.pull.status.closed" 454 - ? "closed" 455 - : "open"; 456 - statusMap.set(s.value.pull, status); 457 - } 458 - 459 - const targetRepo = toValue(repoAtUri); 460 - return pullsRes.records 461 - .filter((r) => !targetRepo || r.value.target.repo === targetRepo) 462 - .map((r) => normalizePullRecord(r.value, r.uri, toValue(did), toValue(handle), statusMap.get(r.uri) ?? "open")); 376 + const response = await fetchRepoPulls(h.value, r.value); 377 + return response.records.map((entry) => 378 + normalizePullRecord(entry.value, entry.uri, response.did, response.handle, entry.status), 379 + ); 463 380 }, 464 - enabled: options.enabled, 381 + enabled: computed(() => isEnabled(hasText(h) && hasText(r), options.enabled)), 465 382 staleTime: 2 * MIN, 466 383 gcTime: 10 * MIN, 467 384 }); 468 385 } 469 386 470 - export function useUserStrings( 471 - pds: MaybeRef<string>, 472 - did: MaybeRef<string>, 473 - options: { enabled?: MaybeRef<boolean> } = {}, 474 - ) { 387 + export function useUserStrings(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 388 + const h = computed(() => toValue(handle).trim()); 389 + 475 390 return useQuery({ 476 - queryKey: computed(() => ["userStrings", toValue(pds), toValue(did)]), 391 + queryKey: computed(() => ["userStrings", h.value]), 477 392 queryFn: async (): Promise<StringSummary[]> => { 478 - const { records } = await listStringRecords(toValue(pds), toValue(did)); 479 - return records 393 + const response = await fetchActorStrings(h.value); 394 + return response.records 480 395 .map((record) => normalizeStringRecord(record.value, record.uri)) 481 396 .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); 482 397 }, 483 - enabled: options.enabled, 398 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 484 399 staleTime: 2 * MIN, 485 400 gcTime: 10 * MIN, 486 401 }); 487 402 } 488 403 489 - export function useUserFollowing( 490 - pds: MaybeRef<string>, 491 - did: MaybeRef<string>, 492 - options: { enabled?: MaybeRef<boolean> } = {}, 493 - ) { 404 + export function useUserFollowing(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 405 + const h = computed(() => toValue(handle).trim()); 406 + 494 407 return useQuery({ 495 - queryKey: computed(() => ["userFollowing", toValue(pds), toValue(did)]), 408 + queryKey: computed(() => ["userFollowing", h.value]), 496 409 queryFn: async (): Promise<FollowedUserSummary[]> => { 497 - const { records } = await listFollowRecords(toValue(pds), toValue(did)); 498 - const follows = records 410 + const response = await fetchActorFollowing(h.value); 411 + const follows = response.records 499 412 .map((record) => normalizeFollowRecord(record.value, record.uri)) 500 413 .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); 501 414 502 415 return Promise.all( 503 416 follows.map(async (follow) => { 504 - const subject = await resolveDidIdentity(follow.subjectDid); 505 - 506 417 try { 507 - const { value } = await fetchActorProfile(subject.pds, subject.did); 508 - const bluesky = await resolveBlueskyProfile(value, subject.did); 418 + const subject = await fetchActor(follow.subjectDid); 509 419 return { 510 - ...normalizeActorProfile(value, subject.did, subject.handle, bluesky.displayName, bluesky.avatar), 420 + ...normalizeActorProfile( 421 + subject.profile.value, 422 + subject.did, 423 + subject.handle, 424 + subject.bsky?.displayName, 425 + subject.bsky?.avatar, 426 + ), 511 427 followAtUri: follow.atUri, 512 428 followedAt: follow.createdAt, 513 429 }; 514 430 } catch { 515 431 return { 516 - did: subject.did, 517 - handle: subject.handle, 432 + did: follow.subjectDid, 433 + handle: follow.subjectDid, 518 434 followAtUri: follow.atUri, 519 435 followedAt: follow.createdAt, 520 436 }; ··· 522 438 }), 523 439 ); 524 440 }, 525 - enabled: options.enabled, 441 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 526 442 staleTime: 5 * MIN, 527 443 gcTime: 30 * MIN, 528 444 }); 529 445 } 530 446 531 - export function useUserIssues( 532 - pds: MaybeRef<string>, 533 - did: MaybeRef<string>, 534 - handle: MaybeRef<string>, 535 - options: { enabled?: MaybeRef<boolean> } = {}, 536 - ) { 447 + export function useUserIssues(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 448 + const h = computed(() => toValue(handle).trim()); 449 + 537 450 return useQuery({ 538 - queryKey: computed(() => ["userIssues", toValue(pds), toValue(did)]), 451 + queryKey: computed(() => ["userIssues", h.value]), 539 452 queryFn: async () => { 540 - const [issuesRes, statesRes] = await Promise.all([ 541 - listIssueRecords(toValue(pds), toValue(did)), 542 - listIssueStateRecords(toValue(pds), toValue(did)), 543 - ]); 544 - 545 - const stateMap = new Map<string, "open" | "closed">(); 546 - for (const stateRecord of statesRes.records) { 547 - const closed = stateRecord.value.state === "sh.tangled.repo.issue.state.closed"; 548 - stateMap.set(stateRecord.value.issue, closed ? "closed" : "open"); 549 - } 550 - 551 - return issuesRes.records 552 - .map((record) => 553 - normalizeIssueRecord( 554 - record.value, 555 - record.uri, 556 - toValue(did), 557 - toValue(handle), 558 - stateMap.get(record.uri) ?? "open", 559 - ), 560 - ) 453 + const response = await fetchActorIssues(h.value); 454 + return response.records 455 + .map((entry) => normalizeIssueRecord(entry.value, entry.uri, response.did, response.handle, entry.state)) 561 456 .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); 562 457 }, 563 - enabled: options.enabled, 458 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 564 459 staleTime: 2 * MIN, 565 460 gcTime: 10 * MIN, 566 461 }); 567 462 } 568 463 569 - export function useUserPullRequests( 570 - pds: MaybeRef<string>, 571 - did: MaybeRef<string>, 572 - handle: MaybeRef<string>, 573 - options: { enabled?: MaybeRef<boolean> } = {}, 574 - ) { 464 + export function useUserPullRequests(handle: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 465 + const h = computed(() => toValue(handle).trim()); 466 + 575 467 return useQuery({ 576 - queryKey: computed(() => ["userPullRequests", toValue(pds), toValue(did)]), 468 + queryKey: computed(() => ["userPullRequests", h.value]), 577 469 queryFn: async () => { 578 - const [pullsRes, statusesRes] = await Promise.all([ 579 - listPullRecords(toValue(pds), toValue(did)), 580 - listPullStatusRecords(toValue(pds), toValue(did)), 581 - ]); 582 - 583 - const statusMap = new Map<string, "open" | "merged" | "closed">(); 584 - for (const statusRecord of statusesRes.records) { 585 - const raw = statusRecord.value.status ?? "sh.tangled.repo.pull.status.open"; 586 - const status = 587 - raw === "sh.tangled.repo.pull.status.merged" 588 - ? "merged" 589 - : raw === "sh.tangled.repo.pull.status.closed" 590 - ? "closed" 591 - : "open"; 592 - statusMap.set(statusRecord.value.pull, status); 593 - } 594 - 595 - return pullsRes.records 596 - .map((record) => 597 - normalizePullRecord( 598 - record.value, 599 - record.uri, 600 - toValue(did), 601 - toValue(handle), 602 - statusMap.get(record.uri) ?? "open", 603 - ), 604 - ) 470 + const response = await fetchActorPulls(h.value); 471 + return response.records 472 + .map((entry) => normalizePullRecord(entry.value, entry.uri, response.did, response.handle, entry.status)) 605 473 .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); 606 474 }, 607 - enabled: options.enabled, 475 + enabled: computed(() => isEnabled(hasText(h), options.enabled)), 608 476 staleTime: 2 * MIN, 609 477 gcTime: 10 * MIN, 610 478 }); 611 479 } 612 480 613 481 export function useIssueDetail( 614 - pds: MaybeRef<string>, 615 - did: MaybeRef<string>, 616 482 handle: MaybeRef<string>, 617 483 issueRkey: MaybeRef<string>, 618 484 options: { enabled?: MaybeRef<boolean> } = {}, 619 485 ) { 486 + const h = computed(() => toValue(handle).trim()); 487 + const rkey = computed(() => toValue(issueRkey).trim()); 488 + 620 489 return useQuery({ 621 - queryKey: computed(() => ["issueDetail", toValue(pds), toValue(did), toValue(issueRkey)]), 490 + queryKey: computed(() => ["issueDetail", h.value, rkey.value]), 622 491 queryFn: async () => { 623 - const [issueRes, statesRes, commentsRes] = await Promise.all([ 624 - fetchIssueRecord(toValue(pds), toValue(did), toValue(issueRkey)), 625 - listIssueStateRecords(toValue(pds), toValue(did)), 626 - listIssueCommentRecords(toValue(pds), toValue(did)), 492 + const [issueRes, commentsRes, actor] = await Promise.all([ 493 + fetchIssueDetail(h.value, rkey.value), 494 + fetchIssueComments(h.value, rkey.value), 495 + fetchActor(h.value), 627 496 ]); 628 497 629 - const currentState = statesRes.records.reduce<"open" | "closed">((state, record) => { 630 - if (record.value.issue !== issueRes.uri) return state; 631 - return record.value.state === "sh.tangled.repo.issue.state.closed" ? "closed" : "open"; 632 - }, "open"); 633 - 634 - const commentCount = commentsRes.records.filter((record) => record.value.issue === issueRes.uri).length; 635 - 636 498 return normalizeIssueDetail( 637 499 issueRes.value, 638 500 issueRes.uri, 639 - toValue(did), 640 - toValue(handle), 641 - currentState, 642 - commentCount, 501 + actor.did, 502 + actor.handle, 503 + issueRes.state, 504 + commentsRes.records.length, 643 505 ); 644 506 }, 645 - enabled: options.enabled, 507 + enabled: computed(() => isEnabled(hasText(h) && hasText(rkey), options.enabled)), 646 508 staleTime: 2 * MIN, 647 509 gcTime: 10 * MIN, 648 510 }); 649 511 } 650 512 651 513 export function useIssueComments( 652 - pds: MaybeRef<string>, 653 - did: MaybeRef<string>, 654 514 handle: MaybeRef<string>, 655 - issueAtUri: MaybeRef<string>, 515 + issueRkey: MaybeRef<string>, 656 516 options: { enabled?: MaybeRef<boolean> } = {}, 657 517 ) { 518 + const h = computed(() => toValue(handle).trim()); 519 + const rkey = computed(() => toValue(issueRkey).trim()); 520 + 658 521 return useQuery({ 659 - queryKey: computed(() => ["issueComments", toValue(pds), toValue(did), toValue(issueAtUri)]), 522 + queryKey: computed(() => ["issueComments", h.value, rkey.value]), 660 523 queryFn: async () => { 661 - const response = await listIssueCommentRecords(toValue(pds), toValue(did)); 662 - const comments = response.records 663 - .filter((record) => record.value.issue === toValue(issueAtUri)) 664 - .map((record) => normalizeIssueComment(record.value, record.uri, toValue(did), toValue(handle))); 524 + const [response, actor] = await Promise.all([fetchIssueComments(h.value, rkey.value), fetchActor(h.value)]); 525 + const comments = response.records.map((record) => 526 + normalizeIssueComment(record.value, record.uri, actor.did, actor.handle), 527 + ); 665 528 666 529 return buildIssueCommentThread(comments); 667 530 }, 668 - enabled: options.enabled, 531 + enabled: computed(() => isEnabled(hasText(h) && hasText(rkey), options.enabled)), 669 532 staleTime: 2 * MIN, 670 533 gcTime: 10 * MIN, 671 534 }); 672 535 } 673 536 674 537 export function usePullRequestDetail( 675 - pds: MaybeRef<string>, 676 - did: MaybeRef<string>, 677 538 handle: MaybeRef<string>, 678 539 pullRkey: MaybeRef<string>, 679 540 options: { enabled?: MaybeRef<boolean> } = {}, 680 541 ) { 542 + const h = computed(() => toValue(handle).trim()); 543 + const rkey = computed(() => toValue(pullRkey).trim()); 544 + 681 545 return useQuery({ 682 - queryKey: computed(() => ["pullDetail", toValue(pds), toValue(did), toValue(pullRkey)]), 546 + queryKey: computed(() => ["pullDetail", h.value, rkey.value]), 683 547 queryFn: async () => { 684 - const [pullRes, statusesRes, commentsRes] = await Promise.all([ 685 - fetchPullRecord(toValue(pds), toValue(did), toValue(pullRkey)), 686 - listPullStatusRecords(toValue(pds), toValue(did)), 687 - listPullCommentRecords(toValue(pds), toValue(did)), 548 + const [pullRes, commentsRes, actor] = await Promise.all([ 549 + fetchPullDetail(h.value, rkey.value), 550 + fetchPullComments(h.value, rkey.value), 551 + fetchActor(h.value), 688 552 ]); 689 553 690 - const currentStatus = statusesRes.records.reduce<"open" | "merged" | "closed">((status, record) => { 691 - if (record.value.pull !== pullRes.uri) return status; 692 - if (record.value.status === "sh.tangled.repo.pull.status.merged") return "merged"; 693 - if (record.value.status === "sh.tangled.repo.pull.status.closed") return "closed"; 694 - return "open"; 695 - }, "open"); 696 - 697 - const roundCount = commentsRes.records.filter((record) => record.value.pull === pullRes.uri).length; 698 - 699 - return normalizePullDetail(pullRes.value, pullRes.uri, toValue(did), toValue(handle), currentStatus, roundCount); 554 + return normalizePullDetail( 555 + pullRes.value, 556 + pullRes.uri, 557 + actor.did, 558 + actor.handle, 559 + pullRes.status, 560 + commentsRes.records.length, 561 + ); 700 562 }, 701 - enabled: options.enabled, 563 + enabled: computed(() => isEnabled(hasText(h) && hasText(rkey), options.enabled)), 702 564 staleTime: 2 * MIN, 703 565 gcTime: 10 * MIN, 704 566 }); 705 567 } 706 568 707 569 export function usePullRequestComments( 708 - pds: MaybeRef<string>, 709 - did: MaybeRef<string>, 710 570 handle: MaybeRef<string>, 711 - pullAtUri: MaybeRef<string>, 571 + pullRkey: MaybeRef<string>, 712 572 options: { enabled?: MaybeRef<boolean> } = {}, 713 573 ) { 574 + const h = computed(() => toValue(handle).trim()); 575 + const rkey = computed(() => toValue(pullRkey).trim()); 576 + 714 577 return useQuery({ 715 - queryKey: computed(() => ["pullComments", toValue(pds), toValue(did), toValue(pullAtUri)]), 578 + queryKey: computed(() => ["pullComments", h.value, rkey.value]), 716 579 queryFn: async () => { 717 - const response = await listPullCommentRecords(toValue(pds), toValue(did)); 580 + const [response, actor] = await Promise.all([fetchPullComments(h.value, rkey.value), fetchActor(h.value)]); 718 581 return response.records 719 - .filter((record) => record.value.pull === toValue(pullAtUri)) 720 - .map((record) => normalizePullComment(record.value, record.uri, toValue(did), toValue(handle))) 582 + .map((record) => normalizePullComment(record.value, record.uri, actor.did, actor.handle)) 721 583 .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt)); 722 584 }, 723 - enabled: options.enabled, 585 + enabled: computed(() => isEnabled(hasText(h) && hasText(rkey), options.enabled)), 724 586 staleTime: 2 * MIN, 725 587 gcTime: 10 * MIN, 726 588 }); 727 589 } 728 590 729 - /** 730 - * Composite hook: fetch repo PDS record + default branch + languages in 731 - * parallel, returning a merged RepoDetail. 732 - */ 733 - export function useRepoDetail( 734 - pds: MaybeRef<string>, 735 - did: MaybeRef<string>, 736 - repoName: MaybeRef<string>, 737 - knotHost: MaybeRef<string>, 738 - handle: MaybeRef<string>, 739 - ) { 740 - const knotRepo = computed(() => `${toValue(did)}/${toValue(repoName)}`); 741 - 742 - const record = useRepoRecord(pds, did, repoName, handle); 743 - const branch = useDefaultBranch(knotHost, knotRepo); 744 - const languages = useRepoLanguages(knotHost, knotRepo); 591 + /** Composite hook: repo record + default branch + languages in parallel. */ 592 + export function useRepoDetail(handle: MaybeRef<string>, repoName: MaybeRef<string>) { 593 + const record = useRepoRecord(handle, repoName); 594 + const branch = useDefaultBranch(handle, repoName); 595 + const languages = useRepoLanguages(handle, repoName); 745 596 746 597 const data = computed(() => { 747 598 if (!record.data.value) return undefined; 748 599 return normalizeRepoRecordToDetail( 749 600 { 750 601 name: toValue(repoName), 751 - knot: toValue(knotHost), 602 + knot: record.data.value.knot, 752 603 createdAt: record.data.value.updatedAt ?? "", 753 604 $type: "sh.tangled.repo", 754 605 }, 755 - toValue(did), 756 - toValue(handle), 606 + record.data.value.ownerDid, 607 + record.data.value.ownerHandle, 757 608 record.data.value.atUri, 758 609 { defaultBranch: branch.data.value?.name, languages: languages.data.value }, 759 610 );
+9 -16
apps/twisted/src/services/tangled/repo-assets.ts
··· 1 1 import { fetchRepoBlob } from "./endpoints.js"; 2 2 import { normalizeBlob, type BlobContent } from "./normalizers.js"; 3 3 4 - export type RepoAssetContext = { 5 - owner: string; 6 - repo: string; 7 - branch: string; 8 - knotHost: string; 9 - knotRepo: string; 10 - sourcePath?: string; 11 - }; 4 + export type RepoAssetContext = { owner: string; repo: string; branch: string; sourcePath?: string }; 12 5 13 6 const EXTERNAL_URL_RE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i; 14 7 const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; ··· 47 40 const [pathOnly] = value.split(/[?#]/, 1); 48 41 if (!pathOnly) return null; 49 42 50 - const baseSegments = value.startsWith("/") 51 - ? [] 52 - : (sourcePath ? dirname(sourcePath).split("/").filter(Boolean) : []); 43 + const baseSegments = value.startsWith("/") ? [] : sourcePath ? dirname(sourcePath).split("/").filter(Boolean) : []; 53 44 54 45 const segments = pathOnly.replace(/^\/+/, "").split("/"); 55 46 const resolved = [...baseSegments]; ··· 77 68 if (!repoPath) return null; 78 69 79 70 try { 80 - const blob = normalizeBlob(await fetchRepoBlob(context.knotHost, { 81 - repo: context.knotRepo, 82 - ref: context.branch, 83 - path: repoPath, 84 - })); 71 + const blob = normalizeBlob( 72 + await fetchRepoBlob(context.owner, context.repo, { 73 + repo: `${context.owner}/${context.repo}`, 74 + ref: context.branch, 75 + path: repoPath, 76 + }), 77 + ); 85 78 86 79 const objectUrl = createObjectUrlFromBlobContent(blob); 87 80 if (objectUrl) return { url: objectUrl, revoke: true };
+36 -24
apps/twisted/tests/unit/tangled-normalizers.spec.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { buildKnotUrl } from "@/services/tangled/endpoints.js"; 3 - import { buildIssueCommentThread, normalizeLogText, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; 2 + import { 3 + buildIssueCommentThread, 4 + normalizeLogText, 5 + normalizeRepoRecord, 6 + normalizeTree, 7 + } from "@/services/tangled/normalizers.js"; 4 8 import { buildPublicRawUrl, resolveRepoRelativePath } from "@/services/tangled/repo-assets.js"; 5 9 import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; 6 10 import type { IssueComment } from "@/domain/models/comment.js"; ··· 44 48 expect(repo.rkey).toBe("writer-app"); 45 49 }); 46 50 47 - it("preserves the repo slash in knot XRPC query strings", () => { 48 - const url = buildKnotUrl("knot1.tangled.sh", "sh.tangled.repo.getDefaultBranch", { 49 - repo: "did:plc:xg2vq45muivyy3xwatcehspu/writer", 50 - }); 51 - 52 - expect(url).toContain("repo=did%3Aplc%3Axg2vq45muivyy3xwatcehspu/writer"); 53 - expect(url).not.toContain("%2Fwriter"); 54 - }); 55 - 56 51 it("derives file kinds from zero-padded git modes", () => { 57 52 const files = normalizeTree({ 58 53 files: [ 59 - { mode: "0040000", name: ".github", size: 75, last_commit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z" } }, 60 - { mode: "0100644", name: "README.md", size: 3126, last_commit: { hash: "b", message: "file", when: "2026-03-23T00:00:00Z" } }, 61 - { mode: "0160000", name: "vendor/lib", size: 0, last_commit: { hash: "c", message: "submodule", when: "2026-03-23T00:00:00Z" } }, 54 + { 55 + mode: "0040000", 56 + name: ".github", 57 + size: 75, 58 + last_commit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z" }, 59 + }, 60 + { 61 + mode: "0100644", 62 + name: "README.md", 63 + size: 3126, 64 + last_commit: { hash: "b", message: "file", when: "2026-03-23T00:00:00Z" }, 65 + }, 66 + { 67 + mode: "0160000", 68 + name: "vendor/lib", 69 + size: 0, 70 + last_commit: { hash: "c", message: "submodule", when: "2026-03-23T00:00:00Z" }, 71 + }, 62 72 ], 63 - lastCommit: { hash: "a", message: "dir", when: "2026-03-23T00:00:00Z", author: { name: "Test", email: "test@example.com", when: "" } }, 73 + lastCommit: { 74 + hash: "a", 75 + message: "dir", 76 + when: "2026-03-23T00:00:00Z", 77 + author: { name: "Test", email: "test@example.com", when: "" }, 78 + }, 64 79 ref: "main", 65 80 }); 66 81 ··· 128 143 }); 129 144 130 145 it("builds public raw URLs for repo assets", () => { 131 - expect(buildPublicRawUrl({ 132 - owner: "desertthunder.dev", 133 - repo: "writer", 134 - branch: "main", 135 - knotHost: "unused", 136 - knotRepo: "unused", 137 - }, "www/src/static/images/context-menu-in-sidebar.png")).toBe( 138 - "https://tangled.org/desertthunder.dev/writer/raw/main/www/src/static/images/context-menu-in-sidebar.png", 139 - ); 146 + expect( 147 + buildPublicRawUrl( 148 + { owner: "desertthunder.dev", repo: "writer", branch: "main", knotHost: "unused", knotRepo: "unused" }, 149 + "www/src/static/images/context-menu-in-sidebar.png", 150 + ), 151 + ).toBe("https://tangled.org/desertthunder.dev/writer/raw/main/www/src/static/images/context-menu-in-sidebar.png"); 140 152 }); 141 153 }); 142 154
+1
apps/twisted/tsconfig.json
··· 9 9 "resolveJsonModule": true, 10 10 "isolatedModules": true, 11 11 "esModuleInterop": true, 12 + "allowImportingTsExtensions": true, 12 13 "lib": ["ESNext", "DOM"], 13 14 "skipLibCheck": true, 14 15 "noEmit": true,
+61 -1
packages/api/README.md
··· 1 1 # Twister 2 2 3 - Tap-based indexing and search service for Tangled. 3 + Tap-based indexing and search API for Tangled. Acts as a proxy layer between the Twisted app and all upstream AT Protocol services (knots, PDS, Bluesky, Constellation, Jetstream). 4 + 5 + ## Requirements 6 + 7 + - Go 1.25+ 8 + - A Turso database (or local SQLite for development) 9 + 10 + ## Running locally 11 + 12 + ```sh 13 + cd packages/api 14 + 15 + # Start the API server with a local SQLite database (twister-dev.db) 16 + go run . api --local 17 + ``` 18 + 19 + The server listens on `:8080` by default. Logs are printed as text when `--local` is set. 20 + 21 + ## Environment variables 22 + 23 + Copy `.env.example` to `.env` in the repo root (or `packages/api/`). The server loads `.env`, `../.env`, and `../../.env` automatically. 24 + 25 + | Variable | Default | Description | 26 + | -------------------------- | -------------------------------------- | ------------------------------------------------------- | 27 + | `TURSO_DATABASE_URL` | — | Turso/libSQL connection URL (required unless `--local`) | 28 + | `TURSO_AUTH_TOKEN` | — | Auth token (required for non-file URLs) | 29 + | `HTTP_BIND_ADDR` | `:8080` | Address the HTTP server listens on | 30 + | `LOG_LEVEL` | `info` | Log level (`debug`, `info`, `warn`, `error`) | 31 + | `LOG_FORMAT` | `json` | Log format (`json` or `text`) | 32 + | `SEARCH_DEFAULT_LIMIT` | `20` | Default result count for search | 33 + | `SEARCH_MAX_LIMIT` | `100` | Maximum result count for search | 34 + | `ENABLE_ADMIN_ENDPOINTS` | `false` | Expose `/admin/*` endpoints | 35 + | `ADMIN_AUTH_TOKEN` | — | Bearer token required for admin endpoints | 36 + | `CONSTELLATION_URL` | `https://constellation.microcosm.blue` | Constellation API base URL | 37 + | `CONSTELLATION_USER_AGENT` | `twister/1.0 …` | User-Agent sent to Constellation | 38 + | `TAP_URL` | — | Tap firehose URL (indexer only) | 39 + | `TAP_AUTH_PASSWORD` | — | Tap auth password (indexer only) | 40 + | `INDEXED_COLLECTIONS` | — | Comma-separated AT collections to index | 41 + 42 + ## CLI commands 43 + 44 + ```sh 45 + twister api # Start the HTTP API server 46 + twister indexer # Start the Tap firehose consumer 47 + twister backfill # Seed the index from upstream APIs 48 + twister reindex # Re-process existing documents 49 + ``` 50 + 51 + ## Proxy endpoints 52 + 53 + The API proxies all upstream AT Protocol and social-graph requests so the app has a single origin: 54 + 55 + | Route | Upstream | 56 + | ------------------------------- | ------------------------------------------------------------- | 57 + | `GET /proxy/knot/{host}/{nsid}` | `https://{host}/xrpc/{nsid}` | 58 + | `GET /proxy/pds/{host}/{nsid}` | `https://{host}/xrpc/{nsid}` | 59 + | `GET /proxy/bsky/{nsid}` | `https://public.api.bsky.app/xrpc/{nsid}` | 60 + | `GET /identity/resolve` | `https://bsky.social/xrpc/com.atproto.identity.resolveHandle` | 61 + | `GET /identity/did/{did}` | `https://plc.directory/{did}` or `/.well-known/did.json` | 62 + | `GET /backlinks/count` | Constellation `getBacklinksCount` (cached) | 63 + | `WS /activity/stream` | `wss://jetstream2.us-east.bsky.network/subscribe` |
+900
packages/api/internal/api/actors.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + 12 + "tangled.org/desertthunder.dev/twister/internal/xrpc" 13 + ) 14 + 15 + // recordEntry is the common shape for a single PDS record returned to the app. 16 + type recordEntry struct { 17 + URI string `json:"uri"` 18 + CID string `json:"cid"` 19 + Value map[string]any `json:"value"` 20 + } 21 + 22 + // issueEntry extends recordEntry with pre-joined issue state. 23 + type issueEntry struct { 24 + recordEntry 25 + State string `json:"state"` // "open" or "closed" 26 + } 27 + 28 + // pullEntry extends recordEntry with pre-joined pull status. 29 + type pullEntry struct { 30 + recordEntry 31 + Status string `json:"status"` // "open", "merged", or "closed" 32 + } 33 + 34 + // actorContext holds resolved identity for a request. 35 + type actorContext struct { 36 + DID string `json:"did"` 37 + Handle string `json:"handle"` 38 + PDS string `json:"pds"` // full URL, e.g. "https://bsky.social" 39 + } 40 + 41 + // repoContext extends actorContext with the repo's knot host and AT URI. 42 + type repoContext struct { 43 + actorContext 44 + KnotHost string `json:"knot_host"` 45 + AtURI string `json:"at_uri"` 46 + RepoName string `json:"repo_name"` 47 + } 48 + 49 + // resolveActor resolves a handle (or DID) to its DID, PDS, and canonical handle. 50 + func (s *Server) resolveActor(r *http.Request, handleOrDID string) (*actorContext, error) { 51 + ctx := r.Context() 52 + 53 + var did string 54 + if strings.HasPrefix(handleOrDID, "did:") { 55 + did = handleOrDID 56 + } else { 57 + var err error 58 + did, err = s.xrpc.ResolveHandle(ctx, handleOrDID) 59 + if err != nil { 60 + return nil, fmt.Errorf("resolve handle %q: %w", handleOrDID, err) 61 + } 62 + } 63 + 64 + identity, err := s.xrpc.ResolveIdentity(ctx, did) 65 + if err != nil { 66 + return nil, fmt.Errorf("resolve identity %q: %w", did, err) 67 + } 68 + 69 + return &actorContext{ 70 + DID: identity.DID, 71 + Handle: identity.Handle, 72 + PDS: identity.PDS, 73 + }, nil 74 + } 75 + 76 + // resolveRepo resolves a handle + repo name to actor + knot host + AT URI. 77 + func (s *Server) resolveRepo(r *http.Request, handleOrDID, repoName string) (*repoContext, error) { 78 + actor, err := s.resolveActor(r, handleOrDID) 79 + if err != nil { 80 + return nil, err 81 + } 82 + 83 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo") 84 + if err != nil { 85 + return nil, fmt.Errorf("list repos for %s: %w", actor.DID, err) 86 + } 87 + 88 + for _, entry := range entries { 89 + name, _ := entry.Value["name"].(string) 90 + if name == repoName { 91 + knot, _ := entry.Value["knot"].(string) 92 + return &repoContext{ 93 + actorContext: *actor, 94 + KnotHost: knot, 95 + AtURI: entry.URI, 96 + RepoName: repoName, 97 + }, nil 98 + } 99 + } 100 + 101 + return nil, &xrpc.NotFoundError{Message: fmt.Sprintf("repo %q not found for %s", repoName, handleOrDID)} 102 + } 103 + 104 + // knotCall makes a GET request to a knot's XRPC endpoint and streams the response body. 105 + // The caller is responsible for closing the returned ReadCloser. 106 + func (s *Server) knotCall(r *http.Request, knotHost, nsid string, params url.Values) (io.ReadCloser, string, error) { 107 + if knotHost == "" { 108 + return nil, "", fmt.Errorf("repo has no knot host") 109 + } 110 + knotURL := "https://" + knotHost 111 + body, err := s.xrpc.Call(r.Context(), knotURL, nsid, params) 112 + if err != nil { 113 + return nil, "", err 114 + } 115 + return body, knotURL, nil 116 + } 117 + 118 + // proxyKnotJSON calls a knot endpoint and writes the JSON response verbatim. 119 + func (s *Server) proxyKnotJSON(w http.ResponseWriter, r *http.Request, repo *repoContext, nsid string, params url.Values) { 120 + params.Set("repo", repo.DID+"/"+repo.RepoName) 121 + body, _, err := s.knotCall(r, repo.KnotHost, nsid, params) 122 + if err != nil { 123 + s.knotError(w, err) 124 + return 125 + } 126 + defer body.Close() 127 + w.Header().Set("Content-Type", "application/json") 128 + w.WriteHeader(http.StatusOK) 129 + _, _ = io.Copy(w, body) 130 + } 131 + 132 + // proxyKnotBytes calls a knot endpoint and writes the raw bytes verbatim. 133 + func (s *Server) proxyKnotBytes(w http.ResponseWriter, r *http.Request, repo *repoContext, nsid string, params url.Values) { 134 + params.Set("repo", repo.DID+"/"+repo.RepoName) 135 + body, _, err := s.knotCall(r, repo.KnotHost, nsid, params) 136 + if err != nil { 137 + s.knotError(w, err) 138 + return 139 + } 140 + defer body.Close() 141 + w.Header().Set("Content-Type", "application/octet-stream") 142 + w.WriteHeader(http.StatusOK) 143 + _, _ = io.Copy(w, body) 144 + } 145 + 146 + func (s *Server) knotError(w http.ResponseWriter, err error) { 147 + var nfe *xrpc.NotFoundError 148 + var xe *xrpc.XRPCError 149 + switch { 150 + case isError(err, &nfe): 151 + writeJSON(w, http.StatusNotFound, errorBody("not_found", nfe.Message)) 152 + case isError(err, &xe): 153 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", xe.Message)) 154 + default: 155 + s.log.Debug("knot call failed", slog.String("error", err.Error())) 156 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "upstream request failed")) 157 + } 158 + } 159 + 160 + func (s *Server) actorError(w http.ResponseWriter, err error) { 161 + var nfe *xrpc.NotFoundError 162 + var xe *xrpc.XRPCError 163 + switch { 164 + case isError(err, &nfe): 165 + writeJSON(w, http.StatusNotFound, errorBody("not_found", nfe.Message)) 166 + case isError(err, &xe): 167 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", xe.Message)) 168 + default: 169 + s.log.Debug("actor resolve failed", slog.String("error", err.Error())) 170 + writeJSON(w, http.StatusBadGateway, errorBody("resolve_error", "failed to resolve actor")) 171 + } 172 + } 173 + 174 + // isError is a type-safe errors.As replacement for pointer receiver targets. 175 + func isError[T error](err error, target *T) bool { 176 + if err == nil { 177 + return false 178 + } 179 + 180 + type unwrapper interface{ Unwrap() error } 181 + for e := err; e != nil; { 182 + if t, ok := e.(T); ok { 183 + *target = t 184 + return true 185 + } 186 + if u, ok := e.(unwrapper); ok { 187 + e = u.Unwrap() 188 + } else { 189 + break 190 + } 191 + } 192 + return false 193 + } 194 + 195 + // handleGetActor returns the actor's Tangled profile + optional Bluesky info. 196 + // GET /actors/{handle} 197 + func (s *Server) handleGetActor(w http.ResponseWriter, r *http.Request) { 198 + handle := r.PathValue("handle") 199 + 200 + actor, err := s.resolveActor(r, handle) 201 + if err != nil { 202 + s.actorError(w, err) 203 + return 204 + } 205 + 206 + rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.actor.profile", "self") 207 + if err != nil { 208 + s.actorError(w, err) 209 + return 210 + } 211 + 212 + var bsky *bskyProfileResponse 213 + if linked, _ := rec.Value["bluesky"].(bool); linked { 214 + bsky = s.fetchBskyProfile(r, actor.DID) 215 + } 216 + 217 + writeJSON(w, http.StatusOK, map[string]any{ 218 + "did": actor.DID, 219 + "handle": actor.Handle, 220 + "pds": actor.PDS, 221 + "profile": recordEntry{ 222 + URI: rec.URI, 223 + CID: rec.CID, 224 + Value: rec.Value, 225 + }, 226 + "bsky": bsky, 227 + }) 228 + } 229 + 230 + // handleListActorRepos returns all sh.tangled.repo records for an actor. 231 + // GET /actors/{handle}/repos 232 + func (s *Server) handleListActorRepos(w http.ResponseWriter, r *http.Request) { 233 + handle := r.PathValue("handle") 234 + 235 + actor, err := s.resolveActor(r, handle) 236 + if err != nil { 237 + s.actorError(w, err) 238 + return 239 + } 240 + 241 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo") 242 + if err != nil { 243 + s.actorError(w, err) 244 + return 245 + } 246 + 247 + records := make([]recordEntry, len(entries)) 248 + for i, e := range entries { 249 + records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value} 250 + } 251 + 252 + writeJSON(w, http.StatusOK, map[string]any{ 253 + "did": actor.DID, 254 + "handle": actor.Handle, 255 + "records": records, 256 + }) 257 + } 258 + 259 + // handleGetActorRepo returns the repo record for a specific repo by name. 260 + // GET /actors/{handle}/repos/{repo} 261 + func (s *Server) handleGetActorRepo(w http.ResponseWriter, r *http.Request) { 262 + handle := r.PathValue("handle") 263 + repoName := r.PathValue("repo") 264 + 265 + repo, err := s.resolveRepo(r, handle, repoName) 266 + if err != nil { 267 + s.actorError(w, err) 268 + return 269 + } 270 + 271 + did, _, rkey, parseErr := parseATURI(repo.AtURI) 272 + if parseErr != nil { 273 + writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "invalid AT URI")) 274 + return 275 + } 276 + 277 + rec, err := s.xrpc.GetRecord(r.Context(), repo.PDS, did, "sh.tangled.repo", rkey) 278 + if err != nil { 279 + s.actorError(w, err) 280 + return 281 + } 282 + 283 + writeJSON(w, http.StatusOK, map[string]any{ 284 + "did": repo.DID, 285 + "handle": repo.Handle, 286 + "knot_host": repo.KnotHost, 287 + "record": recordEntry{ 288 + URI: rec.URI, 289 + CID: rec.CID, 290 + Value: rec.Value, 291 + }, 292 + }) 293 + } 294 + 295 + // handleRepoTree proxies sh.tangled.repo.tree to the knot. 296 + // GET /actors/{handle}/repos/{repo}/tree 297 + func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) { 298 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 299 + if err != nil { 300 + s.actorError(w, err) 301 + return 302 + } 303 + params := url.Values{} 304 + for _, k := range []string{"ref", "path"} { 305 + if v := r.URL.Query().Get(k); v != "" { 306 + params.Set(k, v) 307 + } 308 + } 309 + s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.tree", params) 310 + } 311 + 312 + // handleRepoBlob proxies sh.tangled.repo.blob to the knot. 313 + // GET /actors/{handle}/repos/{repo}/blob 314 + func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { 315 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 316 + if err != nil { 317 + s.actorError(w, err) 318 + return 319 + } 320 + params := url.Values{} 321 + for _, k := range []string{"ref", "path"} { 322 + if v := r.URL.Query().Get(k); v != "" { 323 + params.Set(k, v) 324 + } 325 + } 326 + s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.blob", params) 327 + } 328 + 329 + // handleRepoLog proxies sh.tangled.repo.log (raw bytes) to the knot. 330 + // GET /actors/{handle}/repos/{repo}/log 331 + func (s *Server) handleRepoLog(w http.ResponseWriter, r *http.Request) { 332 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 333 + if err != nil { 334 + s.actorError(w, err) 335 + return 336 + } 337 + params := url.Values{} 338 + for _, k := range []string{"ref", "path", "limit", "cursor"} { 339 + if v := r.URL.Query().Get(k); v != "" { 340 + params.Set(k, v) 341 + } 342 + } 343 + s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.log", params) 344 + } 345 + 346 + // handleRepoBranches proxies sh.tangled.repo.branches (raw bytes) to the knot. 347 + // GET /actors/{handle}/repos/{repo}/branches 348 + func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request) { 349 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 350 + if err != nil { 351 + s.actorError(w, err) 352 + return 353 + } 354 + params := url.Values{} 355 + for _, k := range []string{"limit", "cursor"} { 356 + if v := r.URL.Query().Get(k); v != "" { 357 + params.Set(k, v) 358 + } 359 + } 360 + s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.branches", params) 361 + } 362 + 363 + // handleRepoDefaultBranch proxies sh.tangled.repo.getDefaultBranch (JSON) to the knot. 364 + // GET /actors/{handle}/repos/{repo}/default-branch 365 + func (s *Server) handleRepoDefaultBranch(w http.ResponseWriter, r *http.Request) { 366 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 367 + if err != nil { 368 + s.actorError(w, err) 369 + return 370 + } 371 + s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.getDefaultBranch", url.Values{}) 372 + } 373 + 374 + // handleRepoLanguages proxies sh.tangled.repo.languages (JSON) to the knot. 375 + // GET /actors/{handle}/repos/{repo}/languages 376 + func (s *Server) handleRepoLanguages(w http.ResponseWriter, r *http.Request) { 377 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 378 + if err != nil { 379 + s.actorError(w, err) 380 + return 381 + } 382 + params := url.Values{} 383 + if v := r.URL.Query().Get("ref"); v != "" { 384 + params.Set("ref", v) 385 + } 386 + s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.languages", params) 387 + } 388 + 389 + // handleRepoTags proxies sh.tangled.repo.tags (raw bytes) to the knot. 390 + // GET /actors/{handle}/repos/{repo}/tags 391 + func (s *Server) handleRepoTags(w http.ResponseWriter, r *http.Request) { 392 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 393 + if err != nil { 394 + s.actorError(w, err) 395 + return 396 + } 397 + s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.tags", url.Values{}) 398 + } 399 + 400 + // handleRepoDiff proxies sh.tangled.repo.diff (raw bytes) to the knot. 401 + // GET /actors/{handle}/repos/{repo}/diff 402 + func (s *Server) handleRepoDiff(w http.ResponseWriter, r *http.Request) { 403 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 404 + if err != nil { 405 + s.actorError(w, err) 406 + return 407 + } 408 + params := url.Values{} 409 + if v := r.URL.Query().Get("ref"); v != "" { 410 + params.Set("ref", v) 411 + } 412 + s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.diff", params) 413 + } 414 + 415 + // handleRepoCompare proxies sh.tangled.repo.compare (raw bytes) to the knot. 416 + // GET /actors/{handle}/repos/{repo}/compare 417 + func (s *Server) handleRepoCompare(w http.ResponseWriter, r *http.Request) { 418 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 419 + if err != nil { 420 + s.actorError(w, err) 421 + return 422 + } 423 + params := url.Values{} 424 + for _, k := range []string{"from", "to"} { 425 + if v := r.URL.Query().Get(k); v != "" { 426 + params.Set(k, v) 427 + } 428 + } 429 + s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.compare", params) 430 + } 431 + 432 + // handleRepoIssues returns issues for a repo, pre-joined with state. 433 + // GET /actors/{handle}/repos/{repo}/issues 434 + func (s *Server) handleRepoIssues(w http.ResponseWriter, r *http.Request) { 435 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 436 + if err != nil { 437 + s.actorError(w, err) 438 + return 439 + } 440 + 441 + issues, stateMap, err := s.fetchIssuesAndStates(r, repo.PDS, repo.DID) 442 + if err != nil { 443 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues")) 444 + return 445 + } 446 + 447 + var records []issueEntry 448 + for _, e := range issues { 449 + repoURI, _ := e.Value["repo"].(string) 450 + if repo.AtURI != "" && repoURI != repo.AtURI { 451 + continue 452 + } 453 + records = append(records, issueEntry{ 454 + recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}, 455 + State: resolveIssueState(stateMap, e.URI), 456 + }) 457 + } 458 + if records == nil { 459 + records = []issueEntry{} 460 + } 461 + 462 + writeJSON(w, http.StatusOK, map[string]any{ 463 + "did": repo.DID, 464 + "handle": repo.Handle, 465 + "records": records, 466 + }) 467 + } 468 + 469 + // handleRepoPulls returns pull requests for a repo, pre-joined with status. 470 + // GET /actors/{handle}/repos/{repo}/pulls 471 + func (s *Server) handleRepoPulls(w http.ResponseWriter, r *http.Request) { 472 + repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo")) 473 + if err != nil { 474 + s.actorError(w, err) 475 + return 476 + } 477 + 478 + pulls, statusMap, err := s.fetchPullsAndStatuses(r, repo.PDS, repo.DID) 479 + if err != nil { 480 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls")) 481 + return 482 + } 483 + 484 + var records []pullEntry 485 + for _, e := range pulls { 486 + target, _ := e.Value["target"].(map[string]any) 487 + targetRepo, _ := target["repo"].(string) 488 + if repo.AtURI != "" && targetRepo != repo.AtURI { 489 + continue 490 + } 491 + records = append(records, pullEntry{ 492 + recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}, 493 + Status: resolvePullStatus(statusMap, e.URI), 494 + }) 495 + } 496 + if records == nil { 497 + records = []pullEntry{} 498 + } 499 + 500 + writeJSON(w, http.StatusOK, map[string]any{ 501 + "did": repo.DID, 502 + "handle": repo.Handle, 503 + "records": records, 504 + }) 505 + } 506 + 507 + // handleActorIssues returns all issues authored by an actor, pre-joined with state. 508 + // GET /actors/{handle}/issues 509 + func (s *Server) handleActorIssues(w http.ResponseWriter, r *http.Request) { 510 + actor, err := s.resolveActor(r, r.PathValue("handle")) 511 + if err != nil { 512 + s.actorError(w, err) 513 + return 514 + } 515 + 516 + issues, stateMap, err := s.fetchIssuesAndStates(r, actor.PDS, actor.DID) 517 + if err != nil { 518 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues")) 519 + return 520 + } 521 + 522 + records := make([]issueEntry, len(issues)) 523 + for i, e := range issues { 524 + records[i] = issueEntry{ 525 + recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}, 526 + State: resolveIssueState(stateMap, e.URI), 527 + } 528 + } 529 + 530 + writeJSON(w, http.StatusOK, map[string]any{ 531 + "did": actor.DID, 532 + "handle": actor.Handle, 533 + "records": records, 534 + }) 535 + } 536 + 537 + // handleActorPulls returns all pull requests authored by an actor, pre-joined with status. 538 + // GET /actors/{handle}/pulls 539 + func (s *Server) handleActorPulls(w http.ResponseWriter, r *http.Request) { 540 + actor, err := s.resolveActor(r, r.PathValue("handle")) 541 + if err != nil { 542 + s.actorError(w, err) 543 + return 544 + } 545 + 546 + pulls, statusMap, err := s.fetchPullsAndStatuses(r, actor.PDS, actor.DID) 547 + if err != nil { 548 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls")) 549 + return 550 + } 551 + 552 + records := make([]pullEntry, len(pulls)) 553 + for i, e := range pulls { 554 + records[i] = pullEntry{ 555 + recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}, 556 + Status: resolvePullStatus(statusMap, e.URI), 557 + } 558 + } 559 + 560 + writeJSON(w, http.StatusOK, map[string]any{ 561 + "did": actor.DID, 562 + "handle": actor.Handle, 563 + "records": records, 564 + }) 565 + } 566 + 567 + // handleActorFollowing returns sh.tangled.graph.follow records for an actor. 568 + // GET /actors/{handle}/following 569 + func (s *Server) handleActorFollowing(w http.ResponseWriter, r *http.Request) { 570 + actor, err := s.resolveActor(r, r.PathValue("handle")) 571 + if err != nil { 572 + s.actorError(w, err) 573 + return 574 + } 575 + 576 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.graph.follow") 577 + if err != nil { 578 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch follows")) 579 + return 580 + } 581 + 582 + records := make([]recordEntry, len(entries)) 583 + for i, e := range entries { 584 + records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value} 585 + } 586 + 587 + writeJSON(w, http.StatusOK, map[string]any{ 588 + "did": actor.DID, 589 + "handle": actor.Handle, 590 + "records": records, 591 + }) 592 + } 593 + 594 + // handleActorStrings returns sh.tangled.string records for an actor. 595 + // GET /actors/{handle}/strings 596 + func (s *Server) handleActorStrings(w http.ResponseWriter, r *http.Request) { 597 + actor, err := s.resolveActor(r, r.PathValue("handle")) 598 + if err != nil { 599 + s.actorError(w, err) 600 + return 601 + } 602 + 603 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.string") 604 + if err != nil { 605 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch strings")) 606 + return 607 + } 608 + 609 + records := make([]recordEntry, len(entries)) 610 + for i, e := range entries { 611 + records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value} 612 + } 613 + 614 + writeJSON(w, http.StatusOK, map[string]any{ 615 + "did": actor.DID, 616 + "handle": actor.Handle, 617 + "records": records, 618 + }) 619 + } 620 + 621 + // handleIssueDetail returns a single issue with its state. 622 + // GET /issues/{handle}/{rkey} 623 + func (s *Server) handleIssueDetail(w http.ResponseWriter, r *http.Request) { 624 + handle := r.PathValue("handle") 625 + rkey := r.PathValue("rkey") 626 + 627 + actor, err := s.resolveActor(r, handle) 628 + if err != nil { 629 + s.actorError(w, err) 630 + return 631 + } 632 + 633 + rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.issue", rkey) 634 + if err != nil { 635 + s.actorError(w, err) 636 + return 637 + } 638 + 639 + _, stateMap, err := s.fetchIssuesAndStates(r, actor.PDS, actor.DID) 640 + if err != nil { 641 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issue states")) 642 + return 643 + } 644 + 645 + writeJSON(w, http.StatusOK, issueEntry{ 646 + recordEntry: recordEntry{URI: rec.URI, CID: rec.CID, Value: rec.Value}, 647 + State: resolveIssueState(stateMap, rec.URI), 648 + }) 649 + } 650 + 651 + // handleIssueComments returns all comments for a specific issue. 652 + // GET /issues/{handle}/{rkey}/comments 653 + func (s *Server) handleIssueComments(w http.ResponseWriter, r *http.Request) { 654 + handle := r.PathValue("handle") 655 + rkey := r.PathValue("rkey") 656 + 657 + actor, err := s.resolveActor(r, handle) 658 + if err != nil { 659 + s.actorError(w, err) 660 + return 661 + } 662 + 663 + issueURI := fmt.Sprintf("at://%s/sh.tangled.repo.issue/%s", actor.DID, rkey) 664 + 665 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.issue.comment") 666 + if err != nil { 667 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments")) 668 + return 669 + } 670 + 671 + var records []recordEntry 672 + for _, e := range entries { 673 + issue, _ := e.Value["issue"].(string) 674 + if issue == issueURI { 675 + records = append(records, recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}) 676 + } 677 + } 678 + if records == nil { 679 + records = []recordEntry{} 680 + } 681 + 682 + writeJSON(w, http.StatusOK, map[string]any{ 683 + "did": actor.DID, 684 + "handle": actor.Handle, 685 + "issueUri": issueURI, 686 + "records": records, 687 + }) 688 + } 689 + 690 + // handlePullDetail returns a single pull request with its status. 691 + // GET /pulls/{handle}/{rkey} 692 + func (s *Server) handlePullDetail(w http.ResponseWriter, r *http.Request) { 693 + handle := r.PathValue("handle") 694 + rkey := r.PathValue("rkey") 695 + 696 + actor, err := s.resolveActor(r, handle) 697 + if err != nil { 698 + s.actorError(w, err) 699 + return 700 + } 701 + 702 + rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.pull", rkey) 703 + if err != nil { 704 + s.actorError(w, err) 705 + return 706 + } 707 + 708 + _, statusMap, err := s.fetchPullsAndStatuses(r, actor.PDS, actor.DID) 709 + if err != nil { 710 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pull statuses")) 711 + return 712 + } 713 + 714 + writeJSON(w, http.StatusOK, pullEntry{ 715 + recordEntry: recordEntry{URI: rec.URI, CID: rec.CID, Value: rec.Value}, 716 + Status: resolvePullStatus(statusMap, rec.URI), 717 + }) 718 + } 719 + 720 + // handlePullComments returns all comments for a specific pull request. 721 + // GET /pulls/{handle}/{rkey}/comments 722 + func (s *Server) handlePullComments(w http.ResponseWriter, r *http.Request) { 723 + handle := r.PathValue("handle") 724 + rkey := r.PathValue("rkey") 725 + 726 + actor, err := s.resolveActor(r, handle) 727 + if err != nil { 728 + s.actorError(w, err) 729 + return 730 + } 731 + 732 + pullURI := fmt.Sprintf("at://%s/sh.tangled.repo.pull/%s", actor.DID, rkey) 733 + 734 + entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.pull.comment") 735 + if err != nil { 736 + writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments")) 737 + return 738 + } 739 + 740 + var records []recordEntry 741 + for _, e := range entries { 742 + pull, _ := e.Value["pull"].(string) 743 + if pull == pullURI { 744 + records = append(records, recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}) 745 + } 746 + } 747 + if records == nil { 748 + records = []recordEntry{} 749 + } 750 + 751 + writeJSON(w, http.StatusOK, map[string]any{ 752 + "did": actor.DID, 753 + "handle": actor.Handle, 754 + "pullUri": pullURI, 755 + "records": records, 756 + }) 757 + } 758 + 759 + func (s *Server) fetchIssuesAndStates(r *http.Request, pds, did string) ([]xrpc.ListRecordEntry, map[string]string, error) { 760 + issueCh := make(chan []xrpc.ListRecordEntry, 1) 761 + stateCh := make(chan []xrpc.ListRecordEntry, 1) 762 + errCh := make(chan error, 2) 763 + 764 + go func() { 765 + entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.issue") 766 + if err != nil { 767 + errCh <- err 768 + return 769 + } 770 + issueCh <- entries 771 + }() 772 + go func() { 773 + entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.issue.state") 774 + if err != nil { 775 + errCh <- err 776 + return 777 + } 778 + stateCh <- entries 779 + }() 780 + 781 + var issues []xrpc.ListRecordEntry 782 + var states []xrpc.ListRecordEntry 783 + for i := 0; i < 2; i++ { 784 + select { 785 + case e := <-issueCh: 786 + issues = e 787 + case e := <-stateCh: 788 + states = e 789 + case err := <-errCh: 790 + return nil, nil, err 791 + } 792 + } 793 + 794 + stateMap := make(map[string]string, len(states)) 795 + for _, e := range states { 796 + issueURI, _ := e.Value["issue"].(string) 797 + state, _ := e.Value["state"].(string) 798 + if issueURI != "" { 799 + stateMap[issueURI] = state 800 + } 801 + } 802 + 803 + return issues, stateMap, nil 804 + } 805 + 806 + func (s *Server) fetchPullsAndStatuses(r *http.Request, pds, did string) ([]xrpc.ListRecordEntry, map[string]string, error) { 807 + pullCh := make(chan []xrpc.ListRecordEntry, 1) 808 + statusCh := make(chan []xrpc.ListRecordEntry, 1) 809 + errCh := make(chan error, 2) 810 + 811 + go func() { 812 + entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.pull") 813 + if err != nil { 814 + errCh <- err 815 + return 816 + } 817 + pullCh <- entries 818 + }() 819 + go func() { 820 + entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.pull.status") 821 + if err != nil { 822 + errCh <- err 823 + return 824 + } 825 + statusCh <- entries 826 + }() 827 + 828 + var pulls []xrpc.ListRecordEntry 829 + var statuses []xrpc.ListRecordEntry 830 + for i := 0; i < 2; i++ { 831 + select { 832 + case e := <-pullCh: 833 + pulls = e 834 + case e := <-statusCh: 835 + statuses = e 836 + case err := <-errCh: 837 + return nil, nil, err 838 + } 839 + } 840 + 841 + statusMap := make(map[string]string, len(statuses)) 842 + for _, e := range statuses { 843 + pullURI, _ := e.Value["pull"].(string) 844 + status, _ := e.Value["status"].(string) 845 + if pullURI != "" { 846 + statusMap[pullURI] = status 847 + } 848 + } 849 + 850 + return pulls, statusMap, nil 851 + } 852 + 853 + func resolveIssueState(stateMap map[string]string, issueURI string) string { 854 + raw := stateMap[issueURI] 855 + if strings.HasSuffix(raw, ".closed") { 856 + return "closed" 857 + } 858 + return "open" 859 + } 860 + 861 + func resolvePullStatus(statusMap map[string]string, pullURI string) string { 862 + raw := statusMap[pullURI] 863 + switch { 864 + case strings.HasSuffix(raw, ".merged"): 865 + return "merged" 866 + case strings.HasSuffix(raw, ".closed"): 867 + return "closed" 868 + default: 869 + return "open" 870 + } 871 + } 872 + 873 + type bskyProfileResponse struct { 874 + DisplayName string `json:"displayName,omitempty"` 875 + Avatar string `json:"avatar,omitempty"` 876 + } 877 + 878 + func (s *Server) fetchBskyProfile(r *http.Request, did string) *bskyProfileResponse { 879 + body, err := s.xrpc.Call(r.Context(), "https://public.api.bsky.app", "app.bsky.actor.getProfile", url.Values{"actor": {did}}) 880 + if err != nil { 881 + return nil 882 + } 883 + defer body.Close() 884 + 885 + var p bskyProfileResponse 886 + if err := json.NewDecoder(body).Decode(&p); err != nil { 887 + return nil 888 + } 889 + return &p 890 + } 891 + 892 + // parseATURI splits an AT URI (at://did/collection/rkey) into its components. 893 + func parseATURI(uri string) (did, collection, rkey string, err error) { 894 + trimmed := strings.TrimPrefix(uri, "at://") 895 + parts := strings.SplitN(trimmed, "/", 3) 896 + if len(parts) != 3 { 897 + return "", "", "", fmt.Errorf("invalid AT URI: %q", uri) 898 + } 899 + return parts[0], parts[1], parts[2], nil 900 + }
+35 -1
packages/api/internal/api/api.go
··· 17 17 "tangled.org/desertthunder.dev/twister/internal/search" 18 18 "tangled.org/desertthunder.dev/twister/internal/store" 19 19 "tangled.org/desertthunder.dev/twister/internal/view" 20 + "tangled.org/desertthunder.dev/twister/internal/xrpc" 20 21 ) 21 22 22 23 // Server is the HTTP search API server. ··· 26 27 cfg *config.Config 27 28 log *slog.Logger 28 29 constellation *constellation.Client 30 + xrpc *xrpc.Client 29 31 } 30 32 31 33 // New creates a new API server. 32 - func New(searchRepo *search.Repository, st store.Store, cfg *config.Config, log *slog.Logger, constellation *constellation.Client) *Server { 34 + func New(searchRepo *search.Repository, st store.Store, cfg *config.Config, log *slog.Logger, constellation *constellation.Client, xrpcClient *xrpc.Client) *Server { 33 35 return &Server{ 34 36 search: searchRepo, 35 37 store: st, 36 38 cfg: cfg, 37 39 log: log, 38 40 constellation: constellation, 41 + xrpc: xrpcClient, 39 42 } 40 43 } 41 44 ··· 52 55 53 56 mux.HandleFunc("GET /documents/{id}", s.handleGetDocument) 54 57 mux.HandleFunc("GET /profiles/{did}/summary", s.handleProfileSummary) 58 + 59 + mux.HandleFunc("GET /backlinks/count", s.handleBacklinksCount) 60 + mux.HandleFunc("GET /activity/stream", s.handleActivityStream) 61 + mux.HandleFunc("GET /identity/resolve", s.handleResolveHandle) 62 + mux.HandleFunc("GET /identity/did/{did}", s.handleDidDocument) 63 + mux.HandleFunc("GET /xrpc/knot/{knotHost}/{nsid}", s.handleKnotProxy) 64 + mux.HandleFunc("GET /xrpc/pds/{pds}/{nsid}", s.handlePdsProxy) 65 + mux.HandleFunc("GET /xrpc/bsky/{nsid}", s.handleBskyProxy) 66 + 67 + mux.HandleFunc("GET /actors/{handle}", s.handleGetActor) 68 + mux.HandleFunc("GET /actors/{handle}/repos", s.handleListActorRepos) 69 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}", s.handleGetActorRepo) 70 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/tree", s.handleRepoTree) 71 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/blob", s.handleRepoBlob) 72 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/log", s.handleRepoLog) 73 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/branches", s.handleRepoBranches) 74 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/default-branch", s.handleRepoDefaultBranch) 75 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/languages", s.handleRepoLanguages) 76 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/tags", s.handleRepoTags) 77 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/diff", s.handleRepoDiff) 78 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/compare", s.handleRepoCompare) 79 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/issues", s.handleRepoIssues) 80 + mux.HandleFunc("GET /actors/{handle}/repos/{repo}/pulls", s.handleRepoPulls) 81 + mux.HandleFunc("GET /actors/{handle}/issues", s.handleActorIssues) 82 + mux.HandleFunc("GET /actors/{handle}/pulls", s.handleActorPulls) 83 + mux.HandleFunc("GET /actors/{handle}/following", s.handleActorFollowing) 84 + mux.HandleFunc("GET /actors/{handle}/strings", s.handleActorStrings) 85 + mux.HandleFunc("GET /issues/{handle}/{rkey}", s.handleIssueDetail) 86 + mux.HandleFunc("GET /issues/{handle}/{rkey}/comments", s.handleIssueComments) 87 + mux.HandleFunc("GET /pulls/{handle}/{rkey}", s.handlePullDetail) 88 + mux.HandleFunc("GET /pulls/{handle}/{rkey}/comments", s.handlePullComments) 55 89 56 90 if s.cfg.EnableAdminEndpoints { 57 91 mux.HandleFunc("POST /admin/reindex", s.handleAdminReindex)
+182
packages/api/internal/api/proxy.go
··· 1 + package api 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/coder/websocket" 12 + "tangled.org/desertthunder.dev/twister/internal/constellation" 13 + ) 14 + 15 + var proxyHTTPClient = &http.Client{Timeout: 30 * time.Second} 16 + 17 + // isValidHost checks that a host string is safe to use in an upstream URL. 18 + // Rejects empty strings, anything with path separators or whitespace. 19 + func isValidHost(host string) bool { 20 + return len(host) > 0 && len(host) < 256 && 21 + !strings.ContainsAny(host, "/ \t\r\n") 22 + } 23 + 24 + // proxyHTTP fetches upstreamURL and writes the response (headers + body) to w verbatim. 25 + func (s *Server) proxyHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string) { 26 + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) 27 + if err != nil { 28 + writeJSON(w, http.StatusInternalServerError, errorBody("proxy_error", "failed to build upstream request")) 29 + return 30 + } 31 + if accept := r.Header.Get("Accept"); accept != "" { 32 + req.Header.Set("Accept", accept) 33 + } 34 + 35 + resp, err := proxyHTTPClient.Do(req) 36 + if err != nil { 37 + writeJSON(w, http.StatusBadGateway, errorBody("proxy_error", "upstream request failed")) 38 + return 39 + } 40 + defer resp.Body.Close() 41 + 42 + if ct := resp.Header.Get("Content-Type"); ct != "" { 43 + w.Header().Set("Content-Type", ct) 44 + } 45 + w.WriteHeader(resp.StatusCode) 46 + _, _ = io.Copy(w, resp.Body) 47 + } 48 + 49 + // handleKnotProxy proxies GET requests to a Tangled knot's XRPC endpoint. 50 + // Route: GET /xrpc/knot/{knotHost}/{nsid} 51 + func (s *Server) handleKnotProxy(w http.ResponseWriter, r *http.Request) { 52 + knotHost := r.PathValue("knotHost") 53 + nsid := r.PathValue("nsid") 54 + if !isValidHost(knotHost) { 55 + writeJSON(w, http.StatusBadRequest, errorBody("invalid_parameter", "invalid knot host")) 56 + return 57 + } 58 + upstream := fmt.Sprintf("https://%s/xrpc/%s", knotHost, nsid) 59 + if r.URL.RawQuery != "" { 60 + upstream += "?" + r.URL.RawQuery 61 + } 62 + s.proxyHTTP(w, r, upstream) 63 + } 64 + 65 + // handlePdsProxy proxies GET requests to an AT Protocol PDS XRPC endpoint. 66 + // Route: GET /xrpc/pds/{pds}/{nsid} 67 + func (s *Server) handlePdsProxy(w http.ResponseWriter, r *http.Request) { 68 + pds := r.PathValue("pds") 69 + nsid := r.PathValue("nsid") 70 + if !isValidHost(pds) { 71 + writeJSON(w, http.StatusBadRequest, errorBody("invalid_parameter", "invalid PDS host")) 72 + return 73 + } 74 + upstream := fmt.Sprintf("https://%s/xrpc/%s", pds, nsid) 75 + if r.URL.RawQuery != "" { 76 + upstream += "?" + r.URL.RawQuery 77 + } 78 + s.proxyHTTP(w, r, upstream) 79 + } 80 + 81 + // handleBskyProxy proxies GET requests to the Bluesky public API. 82 + // Route: GET /xrpc/bsky/{nsid} 83 + func (s *Server) handleBskyProxy(w http.ResponseWriter, r *http.Request) { 84 + nsid := r.PathValue("nsid") 85 + upstream := fmt.Sprintf("https://public.api.bsky.app/xrpc/%s", nsid) 86 + if r.URL.RawQuery != "" { 87 + upstream += "?" + r.URL.RawQuery 88 + } 89 + s.proxyHTTP(w, r, upstream) 90 + } 91 + 92 + // handleResolveHandle proxies handle → DID resolution through bsky.social. 93 + // Route: GET /identity/resolve?handle=... 94 + func (s *Server) handleResolveHandle(w http.ResponseWriter, r *http.Request) { 95 + upstream := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle" 96 + if r.URL.RawQuery != "" { 97 + upstream += "?" + r.URL.RawQuery 98 + } 99 + s.proxyHTTP(w, r, upstream) 100 + } 101 + 102 + // handleDidDocument fetches a DID document from plc.directory (did:plc) or 103 + // the well-known endpoint (did:web) and proxies the response. 104 + // Route: GET /identity/did/{did} 105 + func (s *Server) handleDidDocument(w http.ResponseWriter, r *http.Request) { 106 + did := r.PathValue("did") 107 + var docURL string 108 + switch { 109 + case strings.HasPrefix(did, "did:plc:"): 110 + docURL = fmt.Sprintf("https://plc.directory/%s", did) 111 + case strings.HasPrefix(did, "did:web:"): 112 + host := strings.TrimPrefix(did, "did:web:") 113 + docURL = fmt.Sprintf("https://%s/.well-known/did.json", host) 114 + default: 115 + writeJSON(w, http.StatusBadRequest, errorBody("invalid_parameter", "unsupported DID method")) 116 + return 117 + } 118 + s.proxyHTTP(w, r, docURL) 119 + } 120 + 121 + // handleBacklinksCount returns the Constellation backlinks count for a subject/source pair. 122 + // Route: GET /backlinks/count?subject=...&source=... 123 + func (s *Server) handleBacklinksCount(w http.ResponseWriter, r *http.Request) { 124 + if s.constellation == nil { 125 + writeJSON(w, http.StatusServiceUnavailable, errorBody("not_configured", "constellation is not configured")) 126 + return 127 + } 128 + subject := r.URL.Query().Get("subject") 129 + source := r.URL.Query().Get("source") 130 + if subject == "" || source == "" { 131 + writeJSON(w, http.StatusBadRequest, errorBody("invalid_parameter", "subject and source are required")) 132 + return 133 + } 134 + n, err := s.constellation.GetBacklinksCount(r.Context(), constellation.BacklinksParams{ 135 + Subject: subject, 136 + Source: source, 137 + }) 138 + if err != nil { 139 + s.log.Debug("backlinks count failed", slog.String("error", err.Error())) 140 + writeJSON(w, http.StatusBadGateway, errorBody("constellation_error", "failed to fetch backlinks count")) 141 + return 142 + } 143 + writeJSON(w, http.StatusOK, map[string]int{"count": n}) 144 + } 145 + 146 + // handleActivityStream accepts a WebSocket connection and proxies it to the 147 + // Jetstream firehose, forwarding all query parameters (wantedCollections, cursor, etc.) 148 + // Route: GET /activity/stream 149 + func (s *Server) handleActivityStream(w http.ResponseWriter, r *http.Request) { 150 + clientConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ 151 + OriginPatterns: []string{"*"}, 152 + }) 153 + if err != nil { 154 + s.log.Debug("activity stream: accept failed", slog.String("error", err.Error())) 155 + return 156 + } 157 + defer clientConn.CloseNow() 158 + 159 + jetstreamURL := "wss://jetstream2.us-east.bsky.network/subscribe" 160 + if r.URL.RawQuery != "" { 161 + jetstreamURL += "?" + r.URL.RawQuery 162 + } 163 + 164 + ctx := r.Context() 165 + jetstreamConn, _, err := websocket.Dial(ctx, jetstreamURL, nil) 166 + if err != nil { 167 + s.log.Debug("activity stream: jetstream dial failed", slog.String("error", err.Error())) 168 + _ = clientConn.Close(websocket.StatusBadGateway, "failed to connect to upstream") 169 + return 170 + } 171 + defer jetstreamConn.CloseNow() 172 + 173 + for { 174 + msgType, msg, err := jetstreamConn.Read(ctx) 175 + if err != nil { 176 + return 177 + } 178 + if err := clientConn.Write(ctx, msgType, msg); err != nil { 179 + return 180 + } 181 + } 182 + }
+40 -24
packages/api/internal/constellation/client.go
··· 11 11 "io" 12 12 "net/http" 13 13 "net/url" 14 + "strings" 14 15 "time" 15 16 ) 16 17 ··· 21 22 ) 22 23 23 24 // Source constants for common Tangled collections. 25 + // Format: "collection:path" where path uses Constellation dot-notation (e.g. ".subject"). 24 26 const ( 25 - SourceStarURI string = "sh.tangled.feed.star:subject.uri" 26 - SourceFollowDID string = "sh.tangled.graph.follow:subject" 27 - SourceReactionURI string = "sh.tangled.feed.reaction:subject.uri" 27 + SourceStarURI string = "sh.tangled.feed.star:.subject" 28 + SourceFollowDID string = "sh.tangled.graph.follow:.subject" 29 + SourceReactionURI string = "sh.tangled.feed.reaction:.subject" 28 30 ) 29 31 30 32 // Client is an HTTP client for the Constellation backlink API. ··· 89 91 90 92 // BacklinkRecord is one entry returned by GetBacklinks. 91 93 type BacklinkRecord struct { 92 - URI string `json:"uri"` 93 - CID string `json:"cid"` 94 - ActorDID string `json:"actorDid"` 95 - CreatedAt string `json:"createdAt"` 94 + DID string `json:"did"` 95 + Collection string `json:"collection"` 96 + RKey string `json:"rkey"` 96 97 } 97 98 98 99 // BacklinksResponse is returned by GetBacklinks. 99 100 type BacklinksResponse struct { 100 - Records []BacklinkRecord `json:"records"` 101 - Cursor string `json:"cursor,omitempty"` 101 + Total int `json:"total"` 102 + LinkingRecords []BacklinkRecord `json:"linking_records"` 103 + Cursor *string `json:"cursor,omitempty"` 102 104 } 103 105 104 106 // GetBacklinksCount returns the count of records linking to the given subject. 105 107 // Results are cached with the configured TTL. Errors are returned without caching. 108 + // p.Source must be in "collection:path" format, e.g. "sh.tangled.feed.star:.subject". 106 109 func (c *Client) GetBacklinksCount(ctx context.Context, p BacklinksParams) (int, error) { 107 110 cacheKey := "count\x00" + p.Subject + "\x00" + p.Source 108 111 if n, ok := c.countCache.Get(cacheKey); ok { 109 112 return n, nil 113 + } 114 + 115 + collection, path, ok := strings.Cut(p.Source, ":") 116 + if !ok { 117 + return 0, fmt.Errorf("constellation: invalid source %q: expected collection:path", p.Source) 110 118 } 111 119 112 120 params := url.Values{} 113 - params.Set("subject", p.Subject) 114 - params.Set("source", p.Source) 121 + params.Set("target", p.Subject) 122 + params.Set("collection", collection) 123 + params.Set("path", path) 115 124 116 125 var resp struct { 117 - Count int `json:"count"` 126 + Total int `json:"total"` 118 127 } 119 - if err := c.get(ctx, "blue.microcosm.links.getBacklinksCount", params, &resp); err != nil { 128 + if err := c.getLinks(ctx, params, &resp); err != nil { 120 129 return 0, err 121 130 } 122 131 123 - c.countCache.Set(cacheKey, resp.Count) 124 - return resp.Count, nil 132 + c.countCache.Set(cacheKey, resp.Total) 133 + return resp.Total, nil 125 134 } 126 135 127 136 // GetBacklinks returns records linking to the given subject. 128 137 // Results are not cached because paginated lists change frequently. 138 + // p.Source must be in "collection:path" format, e.g. "sh.tangled.feed.star:.subject". 129 139 func (c *Client) GetBacklinks(ctx context.Context, p BacklinksParams) (*BacklinksResponse, error) { 140 + collection, path, ok := strings.Cut(p.Source, ":") 141 + if !ok { 142 + return nil, fmt.Errorf("constellation: invalid source %q: expected collection:path", p.Source) 143 + } 144 + 130 145 params := url.Values{} 131 - params.Set("subject", p.Subject) 132 - params.Set("source", p.Source) 146 + params.Set("target", p.Subject) 147 + params.Set("collection", collection) 148 + params.Set("path", path) 133 149 if p.DID != "" { 134 150 params.Set("did", p.DID) 135 151 } ··· 141 157 } 142 158 143 159 var resp BacklinksResponse 144 - if err := c.get(ctx, "blue.microcosm.links.getBacklinks", params, &resp); err != nil { 160 + if err := c.getLinks(ctx, params, &resp); err != nil { 145 161 return nil, err 146 162 } 147 163 return &resp, nil 148 164 } 149 165 150 - func (c *Client) get(ctx context.Context, method string, params url.Values, out any) error { 151 - u := c.baseURL + "/xrpc/" + method 166 + func (c *Client) getLinks(ctx context.Context, params url.Values, out any) error { 167 + u := c.baseURL + "/links" 152 168 if len(params) > 0 { 153 169 u += "?" + params.Encode() 154 170 } ··· 163 179 164 180 resp, err := c.http.Do(req) 165 181 if err != nil { 166 - return fmt.Errorf("constellation: request %s: %w", method, err) 182 + return fmt.Errorf("constellation: request /links: %w", err) 167 183 } 168 184 defer resp.Body.Close() 169 185 ··· 173 189 Message string `json:"message"` 174 190 } 175 191 if json.Unmarshal(body, &errResp) == nil && errResp.Message != "" { 176 - return fmt.Errorf("constellation: %s: status %d: %s", method, resp.StatusCode, errResp.Message) 192 + return fmt.Errorf("constellation: /links: status %d: %s", resp.StatusCode, errResp.Message) 177 193 } 178 - return fmt.Errorf("constellation: %s: status %d", method, resp.StatusCode) 194 + return fmt.Errorf("constellation: /links: status %d", resp.StatusCode) 179 195 } 180 196 181 197 if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 182 - return fmt.Errorf("constellation: %s: decode response: %w", method, err) 198 + return fmt.Errorf("constellation: /links: decode response: %w", err) 183 199 } 184 200 return nil 185 201 }
-7
packages/api/internal/store/db.go
··· 61 61 return fmt.Errorf("create schema_migrations table: %w", err) 62 62 } 63 63 64 - // For databases that were created before migration tracking was added, 65 - // backfill schema_migrations by introspecting which tables/columns exist. 66 64 if err := backfillMigrationHistory(db); err != nil { 67 65 return fmt.Errorf("backfill migration history: %w", err) 68 66 } ··· 115 113 return nil 116 114 } 117 115 118 - // If the documents table does not exist yet this is a fresh database — nothing to backfill. 119 116 if !sqliteTableExists(db, "documents") { 120 117 return nil 121 118 } ··· 127 124 ) 128 125 } 129 126 130 - // 001 — documents table is present. 131 127 mark("001_initial.sql") 132 128 133 - // 002 — identity_handles table. 134 129 if sqliteTableExists(db, "identity_handles") { 135 130 mark("002_identity_handles.sql") 136 131 } 137 132 138 - // 003 — documents_fts virtual table. 139 133 if sqliteTableExists(db, "documents_fts") { 140 134 mark("003_documents_fts.sql") 141 135 } 142 136 143 - // 004 — web_url column on documents. 144 137 if sqliteColumnExists(db, "documents", "web_url") { 145 138 mark("004_web_url.sql") 146 139 }
+7 -1
packages/api/main.go
··· 106 106 ) 107 107 log.Info("constellation client configured", slog.String("url", cfg.ConstellationURL)) 108 108 109 - srv := api.New(searchRepo, st, cfg, log, constellationClient) 109 + xrpcClient := xrpc.NewClient( 110 + xrpc.WithPLCDirectory(cfg.PLCDirectoryURL), 111 + xrpc.WithIdentityService(cfg.IdentityServiceURL), 112 + xrpc.WithTimeout(cfg.XRPCTimeout), 113 + ) 114 + 115 + srv := api.New(searchRepo, st, cfg, log, constellationClient, xrpcClient) 110 116 111 117 ctx, cancel := baseContext() 112 118 defer cancel()