Star history for tangled repositories tangled-stars.com
atproto git tangled
1

Configure Feed

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

Add Constellation resolution, backfill, star history

juprodh.me (Jul 11, 2026, 10:11 PM +0800) ec137a3b 69183ad6

+849 -3
+2 -1
backend/deno.json
··· 3 3 "exports": "./main.ts", 4 4 "imports": { 5 5 "hono": "npm:hono@^4", 6 - "hono/cors": "npm:hono@^4/cors" 6 + "hono/cors": "npm:hono@^4/cors", 7 + "hono/utils/http-status": "npm:hono@^4/utils/http-status" 7 8 } 8 9 }
+21 -2
backend/main.ts
··· 1 1 // Backend entrypoint: Hono API served on Deno.serve. 2 - // Foundation only — /healthz plus opening the SQLite store on boot. The star 3 - // data routes (/api/repo, /api/trending) and /svg render land in later commits. 2 + // /healthz + /api/repo (star history from the local store). /api/trending and 3 + // the /svg render land in later commits. 4 4 import { Hono } from "hono" 5 5 import { cors } from "hono/cors" 6 + import type { ContentfulStatusCode } from "hono/utils/http-status" 6 7 import logger from "./logger.ts" 7 8 import "./db.ts" // opens the SQLite store + runs migrations on boot 9 + import { getRepoFromDb } from "./stars.ts" 10 + import { normalizeRepoInput } from "../shared/common/api.ts" 8 11 9 12 const app = new Hono() 10 13 ··· 23 26 }) 24 27 25 28 app.get("/healthz", (c) => c.json({ status: "OK", commit: Deno.env.get("GIT_COMMIT") || "unknown" })) 29 + 30 + // JSON star data for the frontend, served from the local SQLite store 31 + // (Jetstream-fed + Constellation-backfilled): { repo, starRecords, count, logoUrl }. 32 + app.get("/api/repo", async (c) => { 33 + const repo = c.req.query("repo") 34 + if (!repo) return c.json({ error: "repo query param required" }, 400) 35 + try { 36 + const data = await getRepoFromDb(normalizeRepoInput(repo.trim())) 37 + return c.json(data) 38 + } catch (err) { 39 + const e = err as { status?: number; data?: string; message?: string } 40 + const message = (typeof e.data === "string" ? e.data : e.message) || "Failed to fetch repo" 41 + logger.error(`/api/repo ${repo}: ${message}`) 42 + return c.json({ error: message }, (e.status ?? 500) as ContentfulStatusCode) 43 + } 44 + }) 26 45 27 46 const port = Number(Deno.env.get("PORT")) || 8080 28 47 Deno.serve(
+163
backend/stars.ts
··· 1 + // Serve a repo's star history from the local SQLite store. On the first request 2 + // for a repo we backfill its full history from Constellation (both star formats); 3 + // thereafter the Jetstream ingester keeps it fresh, so reads are a single query. 4 + import api, { decodeTidTimestamp, fetchBacklinkRecords, resolveRepo, STAR_COLLECTION } from "../shared/common/api.ts" 5 + import type { RepoRef } from "../shared/common/api.ts" 6 + import utils from "../shared/common/utils.ts" 7 + import { 8 + addStars, 9 + getRepo, 10 + getRepoLogo, 11 + getResolution, 12 + getStarTimestamps, 13 + markBackfilled, 14 + setRepoLogo, 15 + setResolution, 16 + upsertRepo, 17 + } from "./db.ts" 18 + import type { StarInput } from "./db.ts" 19 + import logger from "./logger.ts" 20 + 21 + export interface RepoStarData { 22 + repo: string 23 + starRecords: { date: string; count: number }[] 24 + count: number 25 + logoUrl: string 26 + } 27 + 28 + // Disk-cached resolution; TTL bounds staleness from handle re-mappings / repo deletes. 29 + const RESOLUTION_TTL_MS = Number(Deno.env.get("RESOLUTION_TTL_HOURS") || 24) * 3600 * 1000 30 + 31 + // "owner/repo" → canonical key + resolved at-uri/repoDid, persisted for bookkeeping. 32 + async function resolve(repo: string): Promise<{ repoKey: string; ref: RepoRef }> { 33 + const inputKey = repo.toLowerCase() 34 + const name = repo.slice(repo.indexOf("/") + 1) 35 + 36 + let ref: RepoRef 37 + const cached = getResolution(inputKey) 38 + if (cached && Date.now() - cached.resolvedAtMs < RESOLUTION_TTL_MS) { 39 + ref = { atUri: cached.atUri, atUris: cached.atUris, repoDid: cached.repoDid } 40 + } else { 41 + ref = await resolveRepo(repo) // throws { status: 404 } if the repo doesn't exist 42 + setResolution(inputKey, ref.atUri, ref.atUris, ref.repoDid) 43 + } 44 + 45 + const ownerDid = ref.atUri.split("/")[2] 46 + const repoKey = `${ownerDid}/${name.toLowerCase()}` 47 + // Idempotent upsert on both paths ensures the repos row exists even after a mid-write crash. 48 + upsertRepo({ 49 + repo_key: repoKey, 50 + owner_did: ownerDid, 51 + repo_did: ref.repoDid || null, 52 + at_uri: ref.atUri, 53 + name, 54 + }) 55 + return { repoKey, ref } 56 + } 57 + 58 + // One-time Constellation sweep, de-duplicated against concurrent requests. 59 + const backfillInFlight = new Map<string, Promise<void>>() 60 + 61 + export function ensureBackfilled(repoKey: string, ref: RepoRef): Promise<void> { 62 + const existing = getRepo(repoKey) 63 + if (existing?.backfilled_at) return Promise.resolve() 64 + 65 + const inFlight = backfillInFlight.get(repoKey) 66 + if (inFlight) return inFlight 67 + 68 + const p = doBackfill(repoKey, ref).finally(() => backfillInFlight.delete(repoKey)) 69 + backfillInFlight.set(repoKey, p) 70 + return p 71 + } 72 + 73 + async function doBackfill(repoKey: string, ref: RepoRef): Promise<void> { 74 + // Old-format stars across every at-uri the repo has + new-format stars by repoDid. 75 + // The scattered old-format stars are why tangled.org/core reads ~1150, not ~217. 76 + const oldByUri = await Promise.all( 77 + ref.atUris.map((uri) => 78 + fetchBacklinkRecords(uri, `${STAR_COLLECTION}:subject`).then((recs) => ({ uri, recs })) 79 + ), 80 + ) 81 + const newRecs = ref.repoDid ? await fetchBacklinkRecords(ref.repoDid, `${STAR_COLLECTION}:subject.did`) : [] 82 + 83 + const rows: StarInput[] = [] 84 + let oldCount = 0 85 + for (const { uri, recs } of oldByUri) { 86 + for (const r of recs) { 87 + rows.push({ 88 + stargazerDid: r.did, 89 + rkey: r.rkey, 90 + // Normalize to repoDid so scattered old-format stars key off one id; subject_uri 91 + // stays for repos without a repoDid. 92 + subjectDid: ref.repoDid || null, 93 + subjectUri: uri, 94 + createdAt: new Date(decodeTidTimestamp(r.rkey)).toISOString(), 95 + }) 96 + oldCount++ 97 + } 98 + } 99 + for (const r of newRecs) { 100 + rows.push({ 101 + stargazerDid: r.did, 102 + rkey: r.rkey, 103 + subjectDid: ref.repoDid, 104 + subjectUri: null, 105 + createdAt: new Date(decodeTidTimestamp(r.rkey)).toISOString(), 106 + }) 107 + } 108 + 109 + addStars(rows) 110 + markBackfilled(repoKey) 111 + const nRec = ref.atUris.length 112 + logger.info( 113 + `[backfill] ${repoKey}: ${oldCount} old-format (${nRec} record${ 114 + nRec === 1 ? "" : "s" 115 + }) + ${newRecs.length} new-format stars`, 116 + ) 117 + } 118 + 119 + // Cumulative star curve: one point per star + a final "today" endpoint. Same 120 + // { date, count }[] shape as the direct Constellation path in api.tsx. 121 + function buildStarRecords(timestampsMs: number[]): { date: string; count: number }[] { 122 + if (timestampsMs.length === 0) return [] 123 + const sorted = timestampsMs.slice().sort((a, b) => a - b) 124 + const map = new Map<string, number>() 125 + let running = 0 126 + for (const ms of sorted) { 127 + running++ 128 + map.set(utils.getDateString(ms), running) 129 + } 130 + map.set(utils.getDateString(Date.now()), sorted.length) 131 + 132 + const records: { date: string; count: number }[] = [] 133 + map.forEach((count, date) => records.push({ date, count })) 134 + return records 135 + } 136 + 137 + // Everything the frontend needs for one repo, served from the DB. 138 + export async function getRepoFromDb(repo: string): Promise<RepoStarData> { 139 + const { repoKey, ref } = await resolve(repo) 140 + 141 + // Avatar is cached in the DB after the first fetch (null = never fetched). 142 + let logoUrl = getRepoLogo(repoKey) 143 + const [, fetchedLogo] = await Promise.all([ 144 + ensureBackfilled(repoKey, ref), 145 + logoUrl === null ? api.getRepoLogoUrl(repo).catch(() => "") : Promise.resolve(logoUrl), 146 + ]) 147 + if (logoUrl === null) { 148 + logoUrl = fetchedLogo 149 + setRepoLogo(repoKey, logoUrl) 150 + } 151 + 152 + const timestamps = getStarTimestamps(ref.repoDid || null, ref.atUris) 153 + // A repo that exists but has no stars yet still needs a renderable point. 154 + const starRecords = timestamps.length 155 + ? buildStarRecords(timestamps) 156 + : [{ date: utils.getDateString(Date.now(), "yyyy/MM/dd"), count: 0 }] 157 + return { 158 + repo, 159 + starRecords, 160 + count: timestamps.length, 161 + logoUrl, 162 + } 163 + }
+1
deno.jsonc
··· 35 35 36 36 // Preact JSX — used by the shared chart components and the frontend. 37 37 "compilerOptions": { 38 + "lib": ["deno.ns", "dom", "dom.iterable", "esnext"], 38 39 "jsx": "react-jsx", 39 40 "jsxImportSource": "preact" 40 41 },
+488
shared/common/api.ts
··· 1 + import utils from "./utils.ts" 2 + 3 + const API_PER_PAGE = 100 4 + const REQUEST_TIMEOUT_MS = 15000 5 + 6 + // Cross-runtime env: Deno (backend), Node (legacy), Vite import.meta.env (frontend build). 7 + const runtime = globalThis as { 8 + Deno?: { env?: { get(key: string): string | undefined } } 9 + process?: { env?: Record<string, string | undefined> } 10 + } 11 + const viteEnv = (import.meta as { env?: Record<string, string | undefined> }).env 12 + 13 + // Backlink index; override for a self-hosted Asterism. 14 + const CONSTELLATION_BASE = runtime.Deno?.env?.get?.("CONSTELLATION_BASE") || 15 + runtime.process?.env?.CONSTELLATION_BASE || 16 + viteEnv?.VITE_CONSTELLATION_BASE || 17 + "https://constellation.microcosm.blue" 18 + const ATPROTO_PDS_DEFAULT = "https://bsky.social" 19 + const PLC_DIRECTORY_BASE = "https://plc.directory" 20 + // Relay collection index (com.atproto.sync.listReposByCollection) → every DID holding a collection. 21 + const RELAY_BASE = runtime.Deno?.env?.get?.("RELAY_BASE") || 22 + runtime.process?.env?.RELAY_BASE || 23 + viteEnv?.VITE_RELAY_BASE || 24 + "https://relay1.us-east.bsky.network" 25 + export const STAR_COLLECTION = "sh.tangled.feed.star" 26 + export const REPO_COLLECTION = "sh.tangled.repo" 27 + 28 + // --- XRPC / AT-Proto response shapes (only the fields we read) --- 29 + interface ResolveHandleResp { 30 + did: string 31 + } 32 + interface DidDoc { 33 + service?: { id: string; type?: string; serviceEndpoint?: string }[] 34 + alsoKnownAs?: string[] 35 + } 36 + interface RepoRecord { 37 + uri: string 38 + value?: { name?: string; repoDid?: string } 39 + } 40 + interface ListRecordsResp { 41 + records?: RepoRecord[] 42 + cursor?: string 43 + } 44 + interface BacklinksResp { 45 + records?: { did: string; rkey: string }[] 46 + cursor?: string 47 + } 48 + interface BacklinksCountResp { 49 + total?: number 50 + } 51 + interface ListReposByCollectionResp { 52 + repos?: { did?: string }[] 53 + cursor?: string 54 + } 55 + interface ProfileRecordResp { 56 + value?: { avatar?: { ref?: { $link?: string } } } 57 + } 58 + 59 + // JSON GET over fetch. noRedirect rejects on any 3xx — part of the SSRF guard for 60 + // attacker-controlled PDS hosts. Throws { status } on non-2xx so callers can branch on it. 61 + async function fetchJson( 62 + baseUrl: string, 63 + opts: { params?: Record<string, string>; timeoutMs?: number; noRedirect?: boolean } = {}, 64 + ): Promise<unknown> { 65 + const url = new URL(baseUrl) 66 + for (const [k, v] of Object.entries(opts.params ?? {})) url.searchParams.set(k, v) 67 + 68 + const res = await fetch(url, { 69 + signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS), 70 + redirect: opts.noRedirect ? "error" : "follow", 71 + headers: { accept: "application/json" }, 72 + }) 73 + 74 + if (!res.ok) throw { status: res.status, data: await res.text().catch(() => "") } 75 + return res.json() 76 + } 77 + 78 + // In the browser with VITE_API_URL set, read from our backend (Jetstream-fed SQLite) in one 79 + // round trip. Server-side callers (no DOM) always take the direct Constellation path. 80 + const API_URL = viteEnv?.VITE_API_URL || "" 81 + function useBackend(): boolean { 82 + return typeof document !== "undefined" && API_URL !== "" 83 + } 84 + 85 + // Owner (handle/DID) is case-insensitive → lowercased for stable keys; the repo name 86 + // after the slash is case-sensitive and left untouched. 87 + export function normalizeRepoInput(input: string): string { 88 + const slash = input.indexOf("/") 89 + return slash < 0 ? input.toLowerCase() : input.slice(0, slash).toLowerCase() + input.slice(slash) 90 + } 91 + 92 + interface BackendRepo { 93 + repo: string 94 + starRecords: { date: string; count: number }[] 95 + count: number 96 + logoUrl: string 97 + } 98 + 99 + // De-dup concurrent calls for the same repo; cleared on settle. 100 + const backendInFlight = new Map<string, Promise<BackendRepo>>() 101 + 102 + function fetchRepoFromBackend(repo: string): Promise<BackendRepo> { 103 + const key = normalizeRepoInput(repo) 104 + const inflight = backendInFlight.get(key) 105 + if (inflight) return inflight 106 + const p = fetchJson(`${API_URL}/api/repo`, { params: { repo: key } }) 107 + .then((d) => d as BackendRepo) 108 + .finally(() => backendInFlight.delete(key)) 109 + backendInFlight.set(key, p) 110 + return p 111 + } 112 + 113 + const repoCache = new Map<string, RepoRef>() 114 + 115 + export interface RepoRef { 116 + atUri: string // primary record at://ownerDid/sh.tangled.repo/rkey (display / repo key) 117 + atUris: string[] // EVERY record at-uri sharing repoDid — old-format stars scatter across all of them 118 + repoDid: string // the repo's own DID (from record.value.repoDid), used for new-format stars 119 + } 120 + 121 + // AT-Proto TID charset (base32 sortable, no 0/1/8/9) 122 + const TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" 123 + 124 + // TID layout: top 53 bits = µs since Unix epoch, bottom 10 = clock id. Returns 0 for a non-TID rkey. 125 + export function decodeTidTimestamp(rkey: string): number { 126 + let n = BigInt(0) 127 + for (const ch of rkey) { 128 + const i = TID_CHARSET.indexOf(ch) 129 + if (i < 0) return 0 130 + n = n * BigInt(32) + BigInt(i) 131 + } 132 + return Number((n >> BigInt(10)) / BigInt(1000)) // ms 133 + } 134 + 135 + // SSRF guard: a DID doc's PDS endpoint is attacker-controlled and fetched server-side. 136 + // Only https to a public host is allowed; anything else falls back to the default PDS. 137 + function isPrivateHost(hostname: string): boolean { 138 + const h = hostname.toLowerCase() 139 + if (h === "localhost" || h.endsWith(".local") || h.endsWith(".internal")) return true 140 + if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true 141 + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) 142 + if (m) { 143 + const a = Number(m[1]) 144 + const b = Number(m[2]) 145 + if (a === 0 || a === 10 || a === 127) return true 146 + if (a === 169 && b === 254) return true // link-local + cloud metadata 147 + if (a === 192 && b === 168) return true 148 + if (a === 172 && b >= 16 && b <= 31) return true 149 + } 150 + return false 151 + } 152 + 153 + function sanitizePdsEndpoint(endpoint: string | undefined): string { 154 + if (!endpoint) return ATPROTO_PDS_DEFAULT 155 + try { 156 + const url = new URL(endpoint) 157 + if (url.protocol !== "https:" || isPrivateHost(url.hostname)) return ATPROTO_PDS_DEFAULT 158 + return url.origin 159 + } catch { 160 + return ATPROTO_PDS_DEFAULT 161 + } 162 + } 163 + 164 + export async function resolveDid(owner: string): Promise<string> { 165 + if (owner.startsWith("did:")) return owner 166 + const data = await fetchJson( 167 + `${ATPROTO_PDS_DEFAULT}/xrpc/com.atproto.identity.resolveHandle`, 168 + { params: { handle: owner } }, 169 + ) as ResolveHandleResp 170 + return data.did 171 + } 172 + 173 + // did:plc → plc.directory; did:web:<host>[:path] → that host's /.well-known/did.json (or 174 + // /<path>/did.json). did:web is common among self-hosters, so it's required, not optional. 175 + // The did:web host is SSRF-guarded and redirects refused (fetched server-side). 176 + async function resolveDidDoc(did: string): Promise<DidDoc> { 177 + if (did.startsWith("did:web:")) { 178 + const rest = did.slice("did:web:".length) 179 + if (!rest) throw { status: 400, data: "invalid did:web" } 180 + const parts = rest.split(":").map((p) => { 181 + try { 182 + return decodeURIComponent(p) 183 + } catch { 184 + return p 185 + } 186 + }) 187 + let origin: string 188 + try { 189 + const u = new URL(`https://${parts[0]}`) 190 + if (isPrivateHost(u.hostname)) throw new Error("private host") 191 + origin = u.origin 192 + } catch { 193 + throw { status: 400, data: "invalid did:web host" } 194 + } 195 + const path = parts.length === 1 ? "/.well-known/did.json" : `/${parts.slice(1).join("/")}/did.json` 196 + return await fetchJson(`${origin}${path}`, { noRedirect: true }) as DidDoc 197 + } 198 + return await fetchJson(`${PLC_DIRECTORY_BASE}/${did}`) as DidDoc 199 + } 200 + 201 + // DID → PDS host (SSRF-guarded). Falls back to the default PDS if the doc can't be resolved. 202 + async function resolvePds(did: string): Promise<string> { 203 + try { 204 + const data = await resolveDidDoc(did) 205 + const entry = data.service?.find((s) => s.id === "#atproto_pds") 206 + return sanitizePdsEndpoint(entry?.serviceEndpoint) 207 + } catch { 208 + return ATPROTO_PDS_DEFAULT 209 + } 210 + } 211 + 212 + export async function resolveRepo(ownerAndRepo: string): Promise<RepoRef> { 213 + const cached = repoCache.get(ownerAndRepo) 214 + if (cached) return cached 215 + 216 + const slashIdx = ownerAndRepo.indexOf("/") 217 + const owner = ownerAndRepo.slice(0, slashIdx) 218 + const repoName = ownerAndRepo.slice(slashIdx + 1).toLowerCase() 219 + 220 + const did = await resolveDid(owner) 221 + const pds = await resolvePds(did) 222 + 223 + // A URL slug matches the record's rkey (older, name-less repos) OR value.name (newer). One 224 + // logical repo can span MULTIPLE records sharing a repoDid, with old-format stars scattered 225 + // across each at-uri — so we return every at-uri sharing the matched repoDid, else we 226 + // undercount badly (tangled.org/core: 217 on the repoDid vs 936 on a sibling at-uri). 227 + interface Rec { 228 + rkey: string 229 + name?: string 230 + uri: string 231 + repoDid: string 232 + } 233 + const records: Rec[] = [] 234 + let cursor: string | undefined 235 + for (let page = 0; page < 20; page++) { 236 + const params: Record<string, string> = { repo: did, collection: "sh.tangled.repo", limit: "100" } 237 + if (cursor) params.cursor = cursor 238 + 239 + const data = await fetchJson(`${pds}/xrpc/com.atproto.repo.listRecords`, { 240 + params, 241 + noRedirect: true, 242 + }) as ListRecordsResp 243 + for (const record of data.records ?? []) { 244 + records.push({ 245 + rkey: record.uri.split("/").pop()!, 246 + name: record.value?.name, 247 + uri: record.uri, 248 + repoDid: record.value?.repoDid ?? "", 249 + }) 250 + } 251 + if (!data.cursor || (data.records ?? []).length === 0) break 252 + cursor = data.cursor 253 + } 254 + 255 + const matched = records.find( 256 + (r) => r.rkey.toLowerCase() === repoName || (typeof r.name === "string" && r.name.toLowerCase() === repoName), 257 + ) 258 + if (!matched) throw { status: 404, data: `Repo ${ownerAndRepo} not found on Tangled` } 259 + 260 + const repoDid = matched.repoDid 261 + const atUris = repoDid 262 + ? [...new Set(records.filter((r) => r.repoDid === repoDid).map((r) => r.uri))] 263 + : [matched.uri] 264 + const ref: RepoRef = { atUri: matched.uri, atUris, repoDid } 265 + repoCache.set(ownerAndRepo, ref) 266 + return ref 267 + } 268 + 269 + // All backlink records for a subject+source: { did: stargazer, rkey: star tid }. Constellation 270 + // paginates by shard and returns short pages that still carry a cursor, so loop until the cursor 271 + // is empty — stopping on a short page silently truncates the count. 272 + export async function fetchBacklinkRecords( 273 + subject: string, 274 + source: string, 275 + ): Promise<{ did: string; rkey: string }[]> { 276 + const out: { did: string; rkey: string }[] = [] 277 + let cursor: string | undefined 278 + for (let page = 0; page < 200; page++) { 279 + const params: Record<string, string> = { 280 + subject, 281 + source, 282 + limit: String(API_PER_PAGE), 283 + } 284 + if (cursor) params.cursor = cursor 285 + 286 + const data = await fetchJson( 287 + `${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinks`, 288 + { params }, 289 + ) as BacklinksResp 290 + 291 + const records = data.records ?? [] 292 + for (const r of records) out.push({ did: r.did, rkey: r.rkey }) 293 + 294 + if (!data.cursor || records.length === 0) break 295 + cursor = data.cursor 296 + } 297 + return out 298 + } 299 + 300 + export interface OwnerRepo { 301 + ownerDid: string 302 + repoDid: string // "" when the record omits repoDid 303 + atUri: string 304 + name: string 305 + } 306 + 307 + // Every repo owner DID on the network via the relay's collection index (~4k in a few seconds). 308 + // The complete discovery source — supersedes seed-based crawling. 309 + export async function listRepoOwners(): Promise<string[]> { 310 + const owners = new Set<string>() 311 + let cursor: string | undefined 312 + for (let page = 0; page < 500; page++) { 313 + const params: Record<string, string> = { collection: REPO_COLLECTION, limit: "1000" } 314 + if (cursor) params.cursor = cursor 315 + let data: ListReposByCollectionResp 316 + try { 317 + data = await fetchJson(`${RELAY_BASE}/xrpc/com.atproto.sync.listReposByCollection`, { 318 + params, 319 + }) as ListReposByCollectionResp 320 + } catch { 321 + break // relay hiccup — return what we have rather than aborting the sweep 322 + } 323 + for (const r of data.repos ?? []) if (r?.did) owners.add(r.did) 324 + if (!data.cursor || (data.repos ?? []).length === 0) break 325 + cursor = data.cursor 326 + } 327 + return [...owners] 328 + } 329 + 330 + // Every sh.tangled.repo record owned by a DID. `name` falls back to the rkey for older, 331 + // name-less repos, so they're included rather than dropped by a naive value.name filter. 332 + export async function listOwnerRepos(did: string): Promise<OwnerRepo[]> { 333 + const pds = await resolvePds(did) 334 + const out: OwnerRepo[] = [] 335 + let cursor: string | undefined 336 + for (let page = 0; page < 20; page++) { 337 + const params: Record<string, string> = { repo: did, collection: "sh.tangled.repo", limit: "100" } 338 + if (cursor) params.cursor = cursor 339 + let data: ListRecordsResp 340 + try { 341 + data = await fetchJson(`${pds}/xrpc/com.atproto.repo.listRecords`, { 342 + params, 343 + noRedirect: true, 344 + }) as ListRecordsResp 345 + } catch { 346 + break // self-hosted PDS down / rejecting — skip this account 347 + } 348 + for (const rec of data.records ?? []) { 349 + const rkey = rec.uri.split("/").pop()! 350 + const rawName = rec.value?.name 351 + const name = typeof rawName === "string" && rawName ? rawName : rkey 352 + out.push({ ownerDid: did, repoDid: rec.value?.repoDid ?? "", atUri: rec.uri, name }) 353 + } 354 + if (!data.cursor || (data.records ?? []).length === 0) break 355 + cursor = data.cursor 356 + } 357 + return out 358 + } 359 + 360 + // DID → handle for display (from the doc's at:// alias); falls back to the DID itself. 361 + export async function handleForDid(did: string): Promise<string> { 362 + try { 363 + const doc = await resolveDidDoc(did) 364 + const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")) 365 + return aka ? aka.slice("at://".length) : did 366 + } catch { 367 + return did 368 + } 369 + } 370 + 371 + // DID → avatar CDN URL (from the account's app.bsky profile blob), or "" if none. 372 + export async function avatarForDid(did: string): Promise<string> { 373 + try { 374 + const pds = await resolvePds(did) 375 + const data = await fetchJson(`${pds}/xrpc/com.atproto.repo.getRecord`, { 376 + params: { repo: did, collection: "app.bsky.actor.profile", rkey: "self" }, 377 + noRedirect: true, 378 + }) as ProfileRecordResp 379 + const cid = data.value?.avatar?.ref?.$link 380 + return cid ? `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@jpeg` : "" 381 + } catch { 382 + return "" 383 + } 384 + } 385 + 386 + async function getRepoStargazersCount(repo: string): Promise<number> { 387 + if (useBackend()) return (await fetchRepoFromBackend(repo)).count 388 + 389 + const ref = await resolveRepo(repo) 390 + 391 + // Sum new-format (repoDid) + old-format (each at-uri) counts. Each star lives under 392 + // exactly one subject, so summing never double-counts. 393 + const counts = await Promise.all([ 394 + ...ref.atUris.map((uri) => 395 + fetchJson(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`, { 396 + params: { subject: uri, source: `${STAR_COLLECTION}:subject` }, 397 + }).then((d) => (d as BacklinksCountResp).total ?? 0).catch(() => 0) 398 + ), 399 + ref.repoDid 400 + ? fetchJson(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`, { 401 + params: { subject: ref.repoDid, source: `${STAR_COLLECTION}:subject.did` }, 402 + }).then((d) => (d as BacklinksCountResp).total ?? 0).catch(() => 0) 403 + : Promise.resolve(0), 404 + ]) 405 + 406 + return counts.reduce((a, b) => a + b, 0) 407 + } 408 + 409 + async function getRepoStarRecords( 410 + repo: string, 411 + maxRequestAmount: number, 412 + ): Promise<{ date: string; count: number }[]> { 413 + if (useBackend()) { 414 + const data = await fetchRepoFromBackend(repo) 415 + // Match the direct path: no stars throws empty-data so the UI shows "no star history". 416 + if (!data.starRecords || data.starRecords.length === 0 || data.count === 0) { 417 + throw { status: 200, data: [] } 418 + } 419 + return data.starRecords 420 + } 421 + 422 + const ref = await resolveRepo(repo) 423 + 424 + // Old-format stars (subject = each record at-uri) + new-format stars (subject.did = repoDid). 425 + const results = await Promise.all([ 426 + ...ref.atUris.map((uri) => fetchBacklinkRecords(uri, `${STAR_COLLECTION}:subject`)), 427 + ref.repoDid ? fetchBacklinkRecords(ref.repoDid, `${STAR_COLLECTION}:subject.did`) : Promise.resolve([]), 428 + ]) 429 + 430 + // Merge + dedup by stargazer+rkey, then sort ascending (TIDs are time-ordered). 431 + const seen = new Set<string>() 432 + const rkeys: string[] = [] 433 + for (const r of results.flat()) { 434 + const key = `${r.did}/${r.rkey}` 435 + if (seen.has(key)) continue 436 + seen.add(key) 437 + rkeys.push(r.rkey) 438 + } 439 + rkeys.sort() 440 + 441 + if (rkeys.length === 0) { 442 + throw { status: 200, data: [] } 443 + } 444 + 445 + const totalStars = rkeys.length 446 + const starRecordsMap = new Map<string, number>() 447 + 448 + if (totalStars <= maxRequestAmount) { 449 + rkeys.forEach((rkey, i) => { 450 + starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkey)), i + 1) 451 + }) 452 + } else { 453 + for (let i = 0; i < maxRequestAmount; i++) { 454 + const idx = Math.round((i * totalStars) / maxRequestAmount) 455 + starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkeys[idx])), idx + 1) 456 + } 457 + } 458 + 459 + starRecordsMap.set(utils.getDateString(Date.now()), totalStars) 460 + 461 + const starRecords: { date: string; count: number }[] = [] 462 + starRecordsMap.forEach((count, date) => { 463 + starRecords.push({ date, count }) 464 + }) 465 + 466 + return starRecords 467 + } 468 + 469 + async function getRepoLogoUrl(repo: string): Promise<string> { 470 + if (useBackend()) { 471 + try { 472 + return (await fetchRepoFromBackend(repo)).logoUrl 473 + } catch { 474 + return "" 475 + } 476 + } 477 + 478 + try { 479 + const did = await resolveDid(repo.slice(0, repo.indexOf("/"))) 480 + return await avatarForDid(did) 481 + } catch { 482 + return "" 483 + } 484 + } 485 + 486 + // Public surface consumed by the frontend + backend (was `namespace api`). 487 + const api = { getRepoStargazersCount, getRepoStarRecords, getRepoLogoUrl } 488 + export default api
+157
shared/common/utils.ts
··· 1 + export function range(from: number, to: number): number[] { 2 + const r: number[] = [] 3 + for (let i = from; i <= to; i++) { 4 + r.push(i) 5 + } 6 + return r 7 + } 8 + 9 + export function getTimeStampByDate(t: Date | number | string): number { 10 + const d = new Date(t) 11 + return d.getTime() 12 + } 13 + 14 + export function computed<T>(func: () => T): T { 15 + return func() 16 + } 17 + 18 + export function getDateString(t: Date | number | string, format = "yyyy/MM/dd hh:mm:ss"): string { 19 + const d = new Date(getTimeStampByDate(t)) 20 + 21 + const year = d.getFullYear() 22 + const month = d.getMonth() + 1 23 + const date = d.getDate() 24 + const hours = d.getHours() 25 + const minutes = d.getMinutes() 26 + const seconds = d.getSeconds() 27 + 28 + const formatedString = format 29 + .replace("yyyy", String(year)) 30 + .replace("MM", String(month)) 31 + .replace("dd", String(date)) 32 + .replace("hh", String(hours)) 33 + .replace("mm", String(minutes)) 34 + .replace("ss", String(seconds)) 35 + 36 + return formatedString 37 + } 38 + 39 + export async function copyTextToClipboard(text: string) { 40 + if (navigator.clipboard && navigator.clipboard.writeText) { 41 + try { 42 + await navigator.clipboard.writeText(text) 43 + } catch (error: unknown) { 44 + console.warn("Copy to clipboard failed.", error) 45 + } 46 + } else { 47 + console.warn("Copy to clipboard failed, methods not supports.") 48 + } 49 + } 50 + 51 + export function convertSVGToDataURL(svgElement: SVGSVGElement) { 52 + const xml = new XMLSerializer().serializeToString(svgElement) 53 + const encodedData = btoa(xml) 54 + return `data:image/svg+xml;base64,${encodedData}` 55 + } 56 + 57 + export function waitImageLoaded(image: HTMLImageElement): Promise<void> { 58 + image.loading = "eager" 59 + 60 + return new Promise((resolve, reject) => { 61 + image.onload = () => { 62 + // NOTE: There is image loading problem in Safari, fix it with some trick 63 + setTimeout(() => { 64 + resolve() 65 + }, 200) 66 + } 67 + image.onerror = () => { 68 + reject("Image load failed") 69 + } 70 + }) 71 + } 72 + 73 + export function calcBytes(d: unknown): number { 74 + let bytes = 0 75 + 76 + if (typeof d === "number") { 77 + bytes += 8 78 + } else if (typeof d === "string") { 79 + bytes += d.length * 2 80 + } else if (typeof d === "boolean") { 81 + bytes += 1 82 + } else if (typeof d === "object" && d !== null) { 83 + if (Array.isArray(d)) { 84 + for (const i of d) { 85 + bytes += calcBytes(i) 86 + } 87 + } else { 88 + for (const k in d) { 89 + bytes += calcBytes((d as Record<string, unknown>)[k]) 90 + } 91 + } 92 + } 93 + 94 + return bytes 95 + } 96 + 97 + export function calcReadingTime(content: string): string { 98 + const wordsPerMinute = 200 99 + const wordAmount = content.split(" ").length 100 + if (wordAmount <= 200) { 101 + return "less than 1 min read" 102 + } 103 + 104 + const count = Math.ceil(wordAmount / wordsPerMinute) 105 + return `${count} min read` 106 + } 107 + 108 + export function getBase64Image(url: string): Promise<string> { 109 + return new Promise((resolve, reject) => { 110 + const img = new Image() 111 + img.src = url 112 + img.setAttribute("crossOrigin", "anonymous") 113 + 114 + img.onload = () => { 115 + const canvas = document.createElement("canvas") 116 + canvas.width = img.width 117 + canvas.height = img.height 118 + const ctx = canvas.getContext("2d") 119 + if (!ctx) { 120 + reject("Get canvas context failed.") 121 + return 122 + } 123 + ctx.drawImage(img, 0, 0) 124 + const dataURL = canvas.toDataURL("image/png") 125 + resolve(dataURL) 126 + } 127 + 128 + img.onerror = function () { 129 + reject("The image could not be loaded.") 130 + } 131 + }) 132 + } 133 + 134 + export function absolutifyLink(rel: string): string { 135 + if (typeof document !== "undefined") { 136 + const anchor = document.createElement("a") 137 + anchor.setAttribute("href", rel) 138 + return anchor.href 139 + } 140 + return rel 141 + } 142 + 143 + const utils = { 144 + range, 145 + getTimeStampByDate, 146 + computed, 147 + getDateString, 148 + copyTextToClipboard, 149 + convertSVGToDataURL, 150 + waitImageLoaded, 151 + calcBytes, 152 + calcReadingTime, 153 + getBase64Image, 154 + absolutifyLink, 155 + } 156 + 157 + export default utils
+17
shared/types/chart.ts
··· 1 + export type ChartMode = "Date" | "Timeline" 2 + 3 + export type LegendPosition = "top-left" | "bottom-right" 4 + 5 + export interface StarRecord { 6 + date: string 7 + count: number 8 + } 9 + 10 + export interface RepoStarData { 11 + repo: string 12 + starRecords: StarRecord[] 13 + } 14 + 15 + export interface RepoData extends RepoStarData { 16 + logoUrl: string 17 + }