···11// Backend entrypoint: Hono API served on Deno.serve.
22-// Foundation only — /healthz plus opening the SQLite store on boot. The star
33-// data routes (/api/repo, /api/trending) and /svg render land in later commits.
22+// /healthz + /api/repo (star history from the local store). /api/trending and
33+// the /svg render land in later commits.
44import { Hono } from "hono"
55import { cors } from "hono/cors"
66+import type { ContentfulStatusCode } from "hono/utils/http-status"
67import logger from "./logger.ts"
78import "./db.ts" // opens the SQLite store + runs migrations on boot
99+import { getRepoFromDb } from "./stars.ts"
1010+import { normalizeRepoInput } from "../shared/common/api.ts"
811912const app = new Hono()
1013···2326})
24272528app.get("/healthz", (c) => c.json({ status: "OK", commit: Deno.env.get("GIT_COMMIT") || "unknown" }))
2929+3030+// JSON star data for the frontend, served from the local SQLite store
3131+// (Jetstream-fed + Constellation-backfilled): { repo, starRecords, count, logoUrl }.
3232+app.get("/api/repo", async (c) => {
3333+ const repo = c.req.query("repo")
3434+ if (!repo) return c.json({ error: "repo query param required" }, 400)
3535+ try {
3636+ const data = await getRepoFromDb(normalizeRepoInput(repo.trim()))
3737+ return c.json(data)
3838+ } catch (err) {
3939+ const e = err as { status?: number; data?: string; message?: string }
4040+ const message = (typeof e.data === "string" ? e.data : e.message) || "Failed to fetch repo"
4141+ logger.error(`/api/repo ${repo}: ${message}`)
4242+ return c.json({ error: message }, (e.status ?? 500) as ContentfulStatusCode)
4343+ }
4444+})
26452746const port = Number(Deno.env.get("PORT")) || 8080
2847Deno.serve(
+163
backend/stars.ts
···11+// Serve a repo's star history from the local SQLite store. On the first request
22+// for a repo we backfill its full history from Constellation (both star formats);
33+// thereafter the Jetstream ingester keeps it fresh, so reads are a single query.
44+import api, { decodeTidTimestamp, fetchBacklinkRecords, resolveRepo, STAR_COLLECTION } from "../shared/common/api.ts"
55+import type { RepoRef } from "../shared/common/api.ts"
66+import utils from "../shared/common/utils.ts"
77+import {
88+ addStars,
99+ getRepo,
1010+ getRepoLogo,
1111+ getResolution,
1212+ getStarTimestamps,
1313+ markBackfilled,
1414+ setRepoLogo,
1515+ setResolution,
1616+ upsertRepo,
1717+} from "./db.ts"
1818+import type { StarInput } from "./db.ts"
1919+import logger from "./logger.ts"
2020+2121+export interface RepoStarData {
2222+ repo: string
2323+ starRecords: { date: string; count: number }[]
2424+ count: number
2525+ logoUrl: string
2626+}
2727+2828+// Disk-cached resolution; TTL bounds staleness from handle re-mappings / repo deletes.
2929+const RESOLUTION_TTL_MS = Number(Deno.env.get("RESOLUTION_TTL_HOURS") || 24) * 3600 * 1000
3030+3131+// "owner/repo" → canonical key + resolved at-uri/repoDid, persisted for bookkeeping.
3232+async function resolve(repo: string): Promise<{ repoKey: string; ref: RepoRef }> {
3333+ const inputKey = repo.toLowerCase()
3434+ const name = repo.slice(repo.indexOf("/") + 1)
3535+3636+ let ref: RepoRef
3737+ const cached = getResolution(inputKey)
3838+ if (cached && Date.now() - cached.resolvedAtMs < RESOLUTION_TTL_MS) {
3939+ ref = { atUri: cached.atUri, atUris: cached.atUris, repoDid: cached.repoDid }
4040+ } else {
4141+ ref = await resolveRepo(repo) // throws { status: 404 } if the repo doesn't exist
4242+ setResolution(inputKey, ref.atUri, ref.atUris, ref.repoDid)
4343+ }
4444+4545+ const ownerDid = ref.atUri.split("/")[2]
4646+ const repoKey = `${ownerDid}/${name.toLowerCase()}`
4747+ // Idempotent upsert on both paths ensures the repos row exists even after a mid-write crash.
4848+ upsertRepo({
4949+ repo_key: repoKey,
5050+ owner_did: ownerDid,
5151+ repo_did: ref.repoDid || null,
5252+ at_uri: ref.atUri,
5353+ name,
5454+ })
5555+ return { repoKey, ref }
5656+}
5757+5858+// One-time Constellation sweep, de-duplicated against concurrent requests.
5959+const backfillInFlight = new Map<string, Promise<void>>()
6060+6161+export function ensureBackfilled(repoKey: string, ref: RepoRef): Promise<void> {
6262+ const existing = getRepo(repoKey)
6363+ if (existing?.backfilled_at) return Promise.resolve()
6464+6565+ const inFlight = backfillInFlight.get(repoKey)
6666+ if (inFlight) return inFlight
6767+6868+ const p = doBackfill(repoKey, ref).finally(() => backfillInFlight.delete(repoKey))
6969+ backfillInFlight.set(repoKey, p)
7070+ return p
7171+}
7272+7373+async function doBackfill(repoKey: string, ref: RepoRef): Promise<void> {
7474+ // Old-format stars across every at-uri the repo has + new-format stars by repoDid.
7575+ // The scattered old-format stars are why tangled.org/core reads ~1150, not ~217.
7676+ const oldByUri = await Promise.all(
7777+ ref.atUris.map((uri) =>
7878+ fetchBacklinkRecords(uri, `${STAR_COLLECTION}:subject`).then((recs) => ({ uri, recs }))
7979+ ),
8080+ )
8181+ const newRecs = ref.repoDid ? await fetchBacklinkRecords(ref.repoDid, `${STAR_COLLECTION}:subject.did`) : []
8282+8383+ const rows: StarInput[] = []
8484+ let oldCount = 0
8585+ for (const { uri, recs } of oldByUri) {
8686+ for (const r of recs) {
8787+ rows.push({
8888+ stargazerDid: r.did,
8989+ rkey: r.rkey,
9090+ // Normalize to repoDid so scattered old-format stars key off one id; subject_uri
9191+ // stays for repos without a repoDid.
9292+ subjectDid: ref.repoDid || null,
9393+ subjectUri: uri,
9494+ createdAt: new Date(decodeTidTimestamp(r.rkey)).toISOString(),
9595+ })
9696+ oldCount++
9797+ }
9898+ }
9999+ for (const r of newRecs) {
100100+ rows.push({
101101+ stargazerDid: r.did,
102102+ rkey: r.rkey,
103103+ subjectDid: ref.repoDid,
104104+ subjectUri: null,
105105+ createdAt: new Date(decodeTidTimestamp(r.rkey)).toISOString(),
106106+ })
107107+ }
108108+109109+ addStars(rows)
110110+ markBackfilled(repoKey)
111111+ const nRec = ref.atUris.length
112112+ logger.info(
113113+ `[backfill] ${repoKey}: ${oldCount} old-format (${nRec} record${
114114+ nRec === 1 ? "" : "s"
115115+ }) + ${newRecs.length} new-format stars`,
116116+ )
117117+}
118118+119119+// Cumulative star curve: one point per star + a final "today" endpoint. Same
120120+// { date, count }[] shape as the direct Constellation path in api.tsx.
121121+function buildStarRecords(timestampsMs: number[]): { date: string; count: number }[] {
122122+ if (timestampsMs.length === 0) return []
123123+ const sorted = timestampsMs.slice().sort((a, b) => a - b)
124124+ const map = new Map<string, number>()
125125+ let running = 0
126126+ for (const ms of sorted) {
127127+ running++
128128+ map.set(utils.getDateString(ms), running)
129129+ }
130130+ map.set(utils.getDateString(Date.now()), sorted.length)
131131+132132+ const records: { date: string; count: number }[] = []
133133+ map.forEach((count, date) => records.push({ date, count }))
134134+ return records
135135+}
136136+137137+// Everything the frontend needs for one repo, served from the DB.
138138+export async function getRepoFromDb(repo: string): Promise<RepoStarData> {
139139+ const { repoKey, ref } = await resolve(repo)
140140+141141+ // Avatar is cached in the DB after the first fetch (null = never fetched).
142142+ let logoUrl = getRepoLogo(repoKey)
143143+ const [, fetchedLogo] = await Promise.all([
144144+ ensureBackfilled(repoKey, ref),
145145+ logoUrl === null ? api.getRepoLogoUrl(repo).catch(() => "") : Promise.resolve(logoUrl),
146146+ ])
147147+ if (logoUrl === null) {
148148+ logoUrl = fetchedLogo
149149+ setRepoLogo(repoKey, logoUrl)
150150+ }
151151+152152+ const timestamps = getStarTimestamps(ref.repoDid || null, ref.atUris)
153153+ // A repo that exists but has no stars yet still needs a renderable point.
154154+ const starRecords = timestamps.length
155155+ ? buildStarRecords(timestamps)
156156+ : [{ date: utils.getDateString(Date.now(), "yyyy/MM/dd"), count: 0 }]
157157+ return {
158158+ repo,
159159+ starRecords,
160160+ count: timestamps.length,
161161+ logoUrl,
162162+ }
163163+}
+1
deno.jsonc
···35353636 // Preact JSX — used by the shared chart components and the frontend.
3737 "compilerOptions": {
3838+ "lib": ["deno.ns", "dom", "dom.iterable", "esnext"],
3839 "jsx": "react-jsx",
3940 "jsxImportSource": "preact"
4041 },
+488
shared/common/api.ts
···11+import utils from "./utils.ts"
22+33+const API_PER_PAGE = 100
44+const REQUEST_TIMEOUT_MS = 15000
55+66+// Cross-runtime env: Deno (backend), Node (legacy), Vite import.meta.env (frontend build).
77+const runtime = globalThis as {
88+ Deno?: { env?: { get(key: string): string | undefined } }
99+ process?: { env?: Record<string, string | undefined> }
1010+}
1111+const viteEnv = (import.meta as { env?: Record<string, string | undefined> }).env
1212+1313+// Backlink index; override for a self-hosted Asterism.
1414+const CONSTELLATION_BASE = runtime.Deno?.env?.get?.("CONSTELLATION_BASE") ||
1515+ runtime.process?.env?.CONSTELLATION_BASE ||
1616+ viteEnv?.VITE_CONSTELLATION_BASE ||
1717+ "https://constellation.microcosm.blue"
1818+const ATPROTO_PDS_DEFAULT = "https://bsky.social"
1919+const PLC_DIRECTORY_BASE = "https://plc.directory"
2020+// Relay collection index (com.atproto.sync.listReposByCollection) → every DID holding a collection.
2121+const RELAY_BASE = runtime.Deno?.env?.get?.("RELAY_BASE") ||
2222+ runtime.process?.env?.RELAY_BASE ||
2323+ viteEnv?.VITE_RELAY_BASE ||
2424+ "https://relay1.us-east.bsky.network"
2525+export const STAR_COLLECTION = "sh.tangled.feed.star"
2626+export const REPO_COLLECTION = "sh.tangled.repo"
2727+2828+// --- XRPC / AT-Proto response shapes (only the fields we read) ---
2929+interface ResolveHandleResp {
3030+ did: string
3131+}
3232+interface DidDoc {
3333+ service?: { id: string; type?: string; serviceEndpoint?: string }[]
3434+ alsoKnownAs?: string[]
3535+}
3636+interface RepoRecord {
3737+ uri: string
3838+ value?: { name?: string; repoDid?: string }
3939+}
4040+interface ListRecordsResp {
4141+ records?: RepoRecord[]
4242+ cursor?: string
4343+}
4444+interface BacklinksResp {
4545+ records?: { did: string; rkey: string }[]
4646+ cursor?: string
4747+}
4848+interface BacklinksCountResp {
4949+ total?: number
5050+}
5151+interface ListReposByCollectionResp {
5252+ repos?: { did?: string }[]
5353+ cursor?: string
5454+}
5555+interface ProfileRecordResp {
5656+ value?: { avatar?: { ref?: { $link?: string } } }
5757+}
5858+5959+// JSON GET over fetch. noRedirect rejects on any 3xx — part of the SSRF guard for
6060+// attacker-controlled PDS hosts. Throws { status } on non-2xx so callers can branch on it.
6161+async function fetchJson(
6262+ baseUrl: string,
6363+ opts: { params?: Record<string, string>; timeoutMs?: number; noRedirect?: boolean } = {},
6464+): Promise<unknown> {
6565+ const url = new URL(baseUrl)
6666+ for (const [k, v] of Object.entries(opts.params ?? {})) url.searchParams.set(k, v)
6767+6868+ const res = await fetch(url, {
6969+ signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS),
7070+ redirect: opts.noRedirect ? "error" : "follow",
7171+ headers: { accept: "application/json" },
7272+ })
7373+7474+ if (!res.ok) throw { status: res.status, data: await res.text().catch(() => "") }
7575+ return res.json()
7676+}
7777+7878+// In the browser with VITE_API_URL set, read from our backend (Jetstream-fed SQLite) in one
7979+// round trip. Server-side callers (no DOM) always take the direct Constellation path.
8080+const API_URL = viteEnv?.VITE_API_URL || ""
8181+function useBackend(): boolean {
8282+ return typeof document !== "undefined" && API_URL !== ""
8383+}
8484+8585+// Owner (handle/DID) is case-insensitive → lowercased for stable keys; the repo name
8686+// after the slash is case-sensitive and left untouched.
8787+export function normalizeRepoInput(input: string): string {
8888+ const slash = input.indexOf("/")
8989+ return slash < 0 ? input.toLowerCase() : input.slice(0, slash).toLowerCase() + input.slice(slash)
9090+}
9191+9292+interface BackendRepo {
9393+ repo: string
9494+ starRecords: { date: string; count: number }[]
9595+ count: number
9696+ logoUrl: string
9797+}
9898+9999+// De-dup concurrent calls for the same repo; cleared on settle.
100100+const backendInFlight = new Map<string, Promise<BackendRepo>>()
101101+102102+function fetchRepoFromBackend(repo: string): Promise<BackendRepo> {
103103+ const key = normalizeRepoInput(repo)
104104+ const inflight = backendInFlight.get(key)
105105+ if (inflight) return inflight
106106+ const p = fetchJson(`${API_URL}/api/repo`, { params: { repo: key } })
107107+ .then((d) => d as BackendRepo)
108108+ .finally(() => backendInFlight.delete(key))
109109+ backendInFlight.set(key, p)
110110+ return p
111111+}
112112+113113+const repoCache = new Map<string, RepoRef>()
114114+115115+export interface RepoRef {
116116+ atUri: string // primary record at://ownerDid/sh.tangled.repo/rkey (display / repo key)
117117+ atUris: string[] // EVERY record at-uri sharing repoDid — old-format stars scatter across all of them
118118+ repoDid: string // the repo's own DID (from record.value.repoDid), used for new-format stars
119119+}
120120+121121+// AT-Proto TID charset (base32 sortable, no 0/1/8/9)
122122+const TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz"
123123+124124+// TID layout: top 53 bits = µs since Unix epoch, bottom 10 = clock id. Returns 0 for a non-TID rkey.
125125+export function decodeTidTimestamp(rkey: string): number {
126126+ let n = BigInt(0)
127127+ for (const ch of rkey) {
128128+ const i = TID_CHARSET.indexOf(ch)
129129+ if (i < 0) return 0
130130+ n = n * BigInt(32) + BigInt(i)
131131+ }
132132+ return Number((n >> BigInt(10)) / BigInt(1000)) // ms
133133+}
134134+135135+// SSRF guard: a DID doc's PDS endpoint is attacker-controlled and fetched server-side.
136136+// Only https to a public host is allowed; anything else falls back to the default PDS.
137137+function isPrivateHost(hostname: string): boolean {
138138+ const h = hostname.toLowerCase()
139139+ if (h === "localhost" || h.endsWith(".local") || h.endsWith(".internal")) return true
140140+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true
141141+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
142142+ if (m) {
143143+ const a = Number(m[1])
144144+ const b = Number(m[2])
145145+ if (a === 0 || a === 10 || a === 127) return true
146146+ if (a === 169 && b === 254) return true // link-local + cloud metadata
147147+ if (a === 192 && b === 168) return true
148148+ if (a === 172 && b >= 16 && b <= 31) return true
149149+ }
150150+ return false
151151+}
152152+153153+function sanitizePdsEndpoint(endpoint: string | undefined): string {
154154+ if (!endpoint) return ATPROTO_PDS_DEFAULT
155155+ try {
156156+ const url = new URL(endpoint)
157157+ if (url.protocol !== "https:" || isPrivateHost(url.hostname)) return ATPROTO_PDS_DEFAULT
158158+ return url.origin
159159+ } catch {
160160+ return ATPROTO_PDS_DEFAULT
161161+ }
162162+}
163163+164164+export async function resolveDid(owner: string): Promise<string> {
165165+ if (owner.startsWith("did:")) return owner
166166+ const data = await fetchJson(
167167+ `${ATPROTO_PDS_DEFAULT}/xrpc/com.atproto.identity.resolveHandle`,
168168+ { params: { handle: owner } },
169169+ ) as ResolveHandleResp
170170+ return data.did
171171+}
172172+173173+// did:plc → plc.directory; did:web:<host>[:path] → that host's /.well-known/did.json (or
174174+// /<path>/did.json). did:web is common among self-hosters, so it's required, not optional.
175175+// The did:web host is SSRF-guarded and redirects refused (fetched server-side).
176176+async function resolveDidDoc(did: string): Promise<DidDoc> {
177177+ if (did.startsWith("did:web:")) {
178178+ const rest = did.slice("did:web:".length)
179179+ if (!rest) throw { status: 400, data: "invalid did:web" }
180180+ const parts = rest.split(":").map((p) => {
181181+ try {
182182+ return decodeURIComponent(p)
183183+ } catch {
184184+ return p
185185+ }
186186+ })
187187+ let origin: string
188188+ try {
189189+ const u = new URL(`https://${parts[0]}`)
190190+ if (isPrivateHost(u.hostname)) throw new Error("private host")
191191+ origin = u.origin
192192+ } catch {
193193+ throw { status: 400, data: "invalid did:web host" }
194194+ }
195195+ const path = parts.length === 1 ? "/.well-known/did.json" : `/${parts.slice(1).join("/")}/did.json`
196196+ return await fetchJson(`${origin}${path}`, { noRedirect: true }) as DidDoc
197197+ }
198198+ return await fetchJson(`${PLC_DIRECTORY_BASE}/${did}`) as DidDoc
199199+}
200200+201201+// DID → PDS host (SSRF-guarded). Falls back to the default PDS if the doc can't be resolved.
202202+async function resolvePds(did: string): Promise<string> {
203203+ try {
204204+ const data = await resolveDidDoc(did)
205205+ const entry = data.service?.find((s) => s.id === "#atproto_pds")
206206+ return sanitizePdsEndpoint(entry?.serviceEndpoint)
207207+ } catch {
208208+ return ATPROTO_PDS_DEFAULT
209209+ }
210210+}
211211+212212+export async function resolveRepo(ownerAndRepo: string): Promise<RepoRef> {
213213+ const cached = repoCache.get(ownerAndRepo)
214214+ if (cached) return cached
215215+216216+ const slashIdx = ownerAndRepo.indexOf("/")
217217+ const owner = ownerAndRepo.slice(0, slashIdx)
218218+ const repoName = ownerAndRepo.slice(slashIdx + 1).toLowerCase()
219219+220220+ const did = await resolveDid(owner)
221221+ const pds = await resolvePds(did)
222222+223223+ // A URL slug matches the record's rkey (older, name-less repos) OR value.name (newer). One
224224+ // logical repo can span MULTIPLE records sharing a repoDid, with old-format stars scattered
225225+ // across each at-uri — so we return every at-uri sharing the matched repoDid, else we
226226+ // undercount badly (tangled.org/core: 217 on the repoDid vs 936 on a sibling at-uri).
227227+ interface Rec {
228228+ rkey: string
229229+ name?: string
230230+ uri: string
231231+ repoDid: string
232232+ }
233233+ const records: Rec[] = []
234234+ let cursor: string | undefined
235235+ for (let page = 0; page < 20; page++) {
236236+ const params: Record<string, string> = { repo: did, collection: "sh.tangled.repo", limit: "100" }
237237+ if (cursor) params.cursor = cursor
238238+239239+ const data = await fetchJson(`${pds}/xrpc/com.atproto.repo.listRecords`, {
240240+ params,
241241+ noRedirect: true,
242242+ }) as ListRecordsResp
243243+ for (const record of data.records ?? []) {
244244+ records.push({
245245+ rkey: record.uri.split("/").pop()!,
246246+ name: record.value?.name,
247247+ uri: record.uri,
248248+ repoDid: record.value?.repoDid ?? "",
249249+ })
250250+ }
251251+ if (!data.cursor || (data.records ?? []).length === 0) break
252252+ cursor = data.cursor
253253+ }
254254+255255+ const matched = records.find(
256256+ (r) => r.rkey.toLowerCase() === repoName || (typeof r.name === "string" && r.name.toLowerCase() === repoName),
257257+ )
258258+ if (!matched) throw { status: 404, data: `Repo ${ownerAndRepo} not found on Tangled` }
259259+260260+ const repoDid = matched.repoDid
261261+ const atUris = repoDid
262262+ ? [...new Set(records.filter((r) => r.repoDid === repoDid).map((r) => r.uri))]
263263+ : [matched.uri]
264264+ const ref: RepoRef = { atUri: matched.uri, atUris, repoDid }
265265+ repoCache.set(ownerAndRepo, ref)
266266+ return ref
267267+}
268268+269269+// All backlink records for a subject+source: { did: stargazer, rkey: star tid }. Constellation
270270+// paginates by shard and returns short pages that still carry a cursor, so loop until the cursor
271271+// is empty — stopping on a short page silently truncates the count.
272272+export async function fetchBacklinkRecords(
273273+ subject: string,
274274+ source: string,
275275+): Promise<{ did: string; rkey: string }[]> {
276276+ const out: { did: string; rkey: string }[] = []
277277+ let cursor: string | undefined
278278+ for (let page = 0; page < 200; page++) {
279279+ const params: Record<string, string> = {
280280+ subject,
281281+ source,
282282+ limit: String(API_PER_PAGE),
283283+ }
284284+ if (cursor) params.cursor = cursor
285285+286286+ const data = await fetchJson(
287287+ `${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinks`,
288288+ { params },
289289+ ) as BacklinksResp
290290+291291+ const records = data.records ?? []
292292+ for (const r of records) out.push({ did: r.did, rkey: r.rkey })
293293+294294+ if (!data.cursor || records.length === 0) break
295295+ cursor = data.cursor
296296+ }
297297+ return out
298298+}
299299+300300+export interface OwnerRepo {
301301+ ownerDid: string
302302+ repoDid: string // "" when the record omits repoDid
303303+ atUri: string
304304+ name: string
305305+}
306306+307307+// Every repo owner DID on the network via the relay's collection index (~4k in a few seconds).
308308+// The complete discovery source — supersedes seed-based crawling.
309309+export async function listRepoOwners(): Promise<string[]> {
310310+ const owners = new Set<string>()
311311+ let cursor: string | undefined
312312+ for (let page = 0; page < 500; page++) {
313313+ const params: Record<string, string> = { collection: REPO_COLLECTION, limit: "1000" }
314314+ if (cursor) params.cursor = cursor
315315+ let data: ListReposByCollectionResp
316316+ try {
317317+ data = await fetchJson(`${RELAY_BASE}/xrpc/com.atproto.sync.listReposByCollection`, {
318318+ params,
319319+ }) as ListReposByCollectionResp
320320+ } catch {
321321+ break // relay hiccup — return what we have rather than aborting the sweep
322322+ }
323323+ for (const r of data.repos ?? []) if (r?.did) owners.add(r.did)
324324+ if (!data.cursor || (data.repos ?? []).length === 0) break
325325+ cursor = data.cursor
326326+ }
327327+ return [...owners]
328328+}
329329+330330+// Every sh.tangled.repo record owned by a DID. `name` falls back to the rkey for older,
331331+// name-less repos, so they're included rather than dropped by a naive value.name filter.
332332+export async function listOwnerRepos(did: string): Promise<OwnerRepo[]> {
333333+ const pds = await resolvePds(did)
334334+ const out: OwnerRepo[] = []
335335+ let cursor: string | undefined
336336+ for (let page = 0; page < 20; page++) {
337337+ const params: Record<string, string> = { repo: did, collection: "sh.tangled.repo", limit: "100" }
338338+ if (cursor) params.cursor = cursor
339339+ let data: ListRecordsResp
340340+ try {
341341+ data = await fetchJson(`${pds}/xrpc/com.atproto.repo.listRecords`, {
342342+ params,
343343+ noRedirect: true,
344344+ }) as ListRecordsResp
345345+ } catch {
346346+ break // self-hosted PDS down / rejecting — skip this account
347347+ }
348348+ for (const rec of data.records ?? []) {
349349+ const rkey = rec.uri.split("/").pop()!
350350+ const rawName = rec.value?.name
351351+ const name = typeof rawName === "string" && rawName ? rawName : rkey
352352+ out.push({ ownerDid: did, repoDid: rec.value?.repoDid ?? "", atUri: rec.uri, name })
353353+ }
354354+ if (!data.cursor || (data.records ?? []).length === 0) break
355355+ cursor = data.cursor
356356+ }
357357+ return out
358358+}
359359+360360+// DID → handle for display (from the doc's at:// alias); falls back to the DID itself.
361361+export async function handleForDid(did: string): Promise<string> {
362362+ try {
363363+ const doc = await resolveDidDoc(did)
364364+ const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://"))
365365+ return aka ? aka.slice("at://".length) : did
366366+ } catch {
367367+ return did
368368+ }
369369+}
370370+371371+// DID → avatar CDN URL (from the account's app.bsky profile blob), or "" if none.
372372+export async function avatarForDid(did: string): Promise<string> {
373373+ try {
374374+ const pds = await resolvePds(did)
375375+ const data = await fetchJson(`${pds}/xrpc/com.atproto.repo.getRecord`, {
376376+ params: { repo: did, collection: "app.bsky.actor.profile", rkey: "self" },
377377+ noRedirect: true,
378378+ }) as ProfileRecordResp
379379+ const cid = data.value?.avatar?.ref?.$link
380380+ return cid ? `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@jpeg` : ""
381381+ } catch {
382382+ return ""
383383+ }
384384+}
385385+386386+async function getRepoStargazersCount(repo: string): Promise<number> {
387387+ if (useBackend()) return (await fetchRepoFromBackend(repo)).count
388388+389389+ const ref = await resolveRepo(repo)
390390+391391+ // Sum new-format (repoDid) + old-format (each at-uri) counts. Each star lives under
392392+ // exactly one subject, so summing never double-counts.
393393+ const counts = await Promise.all([
394394+ ...ref.atUris.map((uri) =>
395395+ fetchJson(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`, {
396396+ params: { subject: uri, source: `${STAR_COLLECTION}:subject` },
397397+ }).then((d) => (d as BacklinksCountResp).total ?? 0).catch(() => 0)
398398+ ),
399399+ ref.repoDid
400400+ ? fetchJson(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`, {
401401+ params: { subject: ref.repoDid, source: `${STAR_COLLECTION}:subject.did` },
402402+ }).then((d) => (d as BacklinksCountResp).total ?? 0).catch(() => 0)
403403+ : Promise.resolve(0),
404404+ ])
405405+406406+ return counts.reduce((a, b) => a + b, 0)
407407+}
408408+409409+async function getRepoStarRecords(
410410+ repo: string,
411411+ maxRequestAmount: number,
412412+): Promise<{ date: string; count: number }[]> {
413413+ if (useBackend()) {
414414+ const data = await fetchRepoFromBackend(repo)
415415+ // Match the direct path: no stars throws empty-data so the UI shows "no star history".
416416+ if (!data.starRecords || data.starRecords.length === 0 || data.count === 0) {
417417+ throw { status: 200, data: [] }
418418+ }
419419+ return data.starRecords
420420+ }
421421+422422+ const ref = await resolveRepo(repo)
423423+424424+ // Old-format stars (subject = each record at-uri) + new-format stars (subject.did = repoDid).
425425+ const results = await Promise.all([
426426+ ...ref.atUris.map((uri) => fetchBacklinkRecords(uri, `${STAR_COLLECTION}:subject`)),
427427+ ref.repoDid ? fetchBacklinkRecords(ref.repoDid, `${STAR_COLLECTION}:subject.did`) : Promise.resolve([]),
428428+ ])
429429+430430+ // Merge + dedup by stargazer+rkey, then sort ascending (TIDs are time-ordered).
431431+ const seen = new Set<string>()
432432+ const rkeys: string[] = []
433433+ for (const r of results.flat()) {
434434+ const key = `${r.did}/${r.rkey}`
435435+ if (seen.has(key)) continue
436436+ seen.add(key)
437437+ rkeys.push(r.rkey)
438438+ }
439439+ rkeys.sort()
440440+441441+ if (rkeys.length === 0) {
442442+ throw { status: 200, data: [] }
443443+ }
444444+445445+ const totalStars = rkeys.length
446446+ const starRecordsMap = new Map<string, number>()
447447+448448+ if (totalStars <= maxRequestAmount) {
449449+ rkeys.forEach((rkey, i) => {
450450+ starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkey)), i + 1)
451451+ })
452452+ } else {
453453+ for (let i = 0; i < maxRequestAmount; i++) {
454454+ const idx = Math.round((i * totalStars) / maxRequestAmount)
455455+ starRecordsMap.set(utils.getDateString(decodeTidTimestamp(rkeys[idx])), idx + 1)
456456+ }
457457+ }
458458+459459+ starRecordsMap.set(utils.getDateString(Date.now()), totalStars)
460460+461461+ const starRecords: { date: string; count: number }[] = []
462462+ starRecordsMap.forEach((count, date) => {
463463+ starRecords.push({ date, count })
464464+ })
465465+466466+ return starRecords
467467+}
468468+469469+async function getRepoLogoUrl(repo: string): Promise<string> {
470470+ if (useBackend()) {
471471+ try {
472472+ return (await fetchRepoFromBackend(repo)).logoUrl
473473+ } catch {
474474+ return ""
475475+ }
476476+ }
477477+478478+ try {
479479+ const did = await resolveDid(repo.slice(0, repo.indexOf("/")))
480480+ return await avatarForDid(did)
481481+ } catch {
482482+ return ""
483483+ }
484484+}
485485+486486+// Public surface consumed by the frontend + backend (was `namespace api`).
487487+const api = { getRepoStargazersCount, getRepoStarRecords, getRepoLogoUrl }
488488+export default api
+157
shared/common/utils.ts
···11+export function range(from: number, to: number): number[] {
22+ const r: number[] = []
33+ for (let i = from; i <= to; i++) {
44+ r.push(i)
55+ }
66+ return r
77+}
88+99+export function getTimeStampByDate(t: Date | number | string): number {
1010+ const d = new Date(t)
1111+ return d.getTime()
1212+}
1313+1414+export function computed<T>(func: () => T): T {
1515+ return func()
1616+}
1717+1818+export function getDateString(t: Date | number | string, format = "yyyy/MM/dd hh:mm:ss"): string {
1919+ const d = new Date(getTimeStampByDate(t))
2020+2121+ const year = d.getFullYear()
2222+ const month = d.getMonth() + 1
2323+ const date = d.getDate()
2424+ const hours = d.getHours()
2525+ const minutes = d.getMinutes()
2626+ const seconds = d.getSeconds()
2727+2828+ const formatedString = format
2929+ .replace("yyyy", String(year))
3030+ .replace("MM", String(month))
3131+ .replace("dd", String(date))
3232+ .replace("hh", String(hours))
3333+ .replace("mm", String(minutes))
3434+ .replace("ss", String(seconds))
3535+3636+ return formatedString
3737+}
3838+3939+export async function copyTextToClipboard(text: string) {
4040+ if (navigator.clipboard && navigator.clipboard.writeText) {
4141+ try {
4242+ await navigator.clipboard.writeText(text)
4343+ } catch (error: unknown) {
4444+ console.warn("Copy to clipboard failed.", error)
4545+ }
4646+ } else {
4747+ console.warn("Copy to clipboard failed, methods not supports.")
4848+ }
4949+}
5050+5151+export function convertSVGToDataURL(svgElement: SVGSVGElement) {
5252+ const xml = new XMLSerializer().serializeToString(svgElement)
5353+ const encodedData = btoa(xml)
5454+ return `data:image/svg+xml;base64,${encodedData}`
5555+}
5656+5757+export function waitImageLoaded(image: HTMLImageElement): Promise<void> {
5858+ image.loading = "eager"
5959+6060+ return new Promise((resolve, reject) => {
6161+ image.onload = () => {
6262+ // NOTE: There is image loading problem in Safari, fix it with some trick
6363+ setTimeout(() => {
6464+ resolve()
6565+ }, 200)
6666+ }
6767+ image.onerror = () => {
6868+ reject("Image load failed")
6969+ }
7070+ })
7171+}
7272+7373+export function calcBytes(d: unknown): number {
7474+ let bytes = 0
7575+7676+ if (typeof d === "number") {
7777+ bytes += 8
7878+ } else if (typeof d === "string") {
7979+ bytes += d.length * 2
8080+ } else if (typeof d === "boolean") {
8181+ bytes += 1
8282+ } else if (typeof d === "object" && d !== null) {
8383+ if (Array.isArray(d)) {
8484+ for (const i of d) {
8585+ bytes += calcBytes(i)
8686+ }
8787+ } else {
8888+ for (const k in d) {
8989+ bytes += calcBytes((d as Record<string, unknown>)[k])
9090+ }
9191+ }
9292+ }
9393+9494+ return bytes
9595+}
9696+9797+export function calcReadingTime(content: string): string {
9898+ const wordsPerMinute = 200
9999+ const wordAmount = content.split(" ").length
100100+ if (wordAmount <= 200) {
101101+ return "less than 1 min read"
102102+ }
103103+104104+ const count = Math.ceil(wordAmount / wordsPerMinute)
105105+ return `${count} min read`
106106+}
107107+108108+export function getBase64Image(url: string): Promise<string> {
109109+ return new Promise((resolve, reject) => {
110110+ const img = new Image()
111111+ img.src = url
112112+ img.setAttribute("crossOrigin", "anonymous")
113113+114114+ img.onload = () => {
115115+ const canvas = document.createElement("canvas")
116116+ canvas.width = img.width
117117+ canvas.height = img.height
118118+ const ctx = canvas.getContext("2d")
119119+ if (!ctx) {
120120+ reject("Get canvas context failed.")
121121+ return
122122+ }
123123+ ctx.drawImage(img, 0, 0)
124124+ const dataURL = canvas.toDataURL("image/png")
125125+ resolve(dataURL)
126126+ }
127127+128128+ img.onerror = function () {
129129+ reject("The image could not be loaded.")
130130+ }
131131+ })
132132+}
133133+134134+export function absolutifyLink(rel: string): string {
135135+ if (typeof document !== "undefined") {
136136+ const anchor = document.createElement("a")
137137+ anchor.setAttribute("href", rel)
138138+ return anchor.href
139139+ }
140140+ return rel
141141+}
142142+143143+const utils = {
144144+ range,
145145+ getTimeStampByDate,
146146+ computed,
147147+ getDateString,
148148+ copyTextToClipboard,
149149+ convertSVGToDataURL,
150150+ waitImageLoaded,
151151+ calcBytes,
152152+ calcReadingTime,
153153+ getBase64Image,
154154+ absolutifyLink,
155155+}
156156+157157+export default utils