social components
inlay.at
atproto
components
sdui
1import { AtUri } from "@atproto/syntax";
2import { LexResolver } from "@atproto/lex-resolver";
3import { resolveDidToService } from "./resolve.ts";
4import { cacheGet, cacheSet } from "./cache.ts";
5import { getServiceJwt } from "./auth.ts";
6import { type Resolver, MissingError } from "@inlay/render";
7
8const lexResolver = new LexResolver({});
9
10type CacheTag = {
11 $type: string;
12 uri?: string;
13 subject?: string;
14 from?: string;
15};
16
17type CachePolicy = {
18 life?: string;
19 tags?: CacheTag[];
20};
21
22type ComponentResponse = {
23 node: unknown;
24 cache?: CachePolicy;
25};
26
27const SLINGSHOT = "https://slingshot.microcosm.blue";
28
29async function fetchRecordFromPds(uri: string): Promise<unknown | null> {
30 const key = `record:${uri}`;
31 const hit = await cacheGet(key);
32 if (hit !== undefined) return hit;
33
34 const parsed = new AtUri(uri);
35 const res = await fetch(
36 `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.host)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`
37 );
38 if (!res.ok) {
39 // Cache misses to avoid hammering PDS for records that don't exist.
40 // Uses shorter TTL than hits — the record might be created later.
41 await cacheSet(key, null, { life: "minutes" });
42 return null;
43 }
44
45 const data = await res.json();
46 const value = data.value as Record<string, unknown>;
47 await cacheSet(key, value, {
48 life: "hours",
49 tags: [{ $type: "at.inlay.defs#tagRecord", uri }],
50 });
51 return value;
52}
53
54// --- XRPC ---
55
56async function callXrpc(
57 serviceUrl: string,
58 params: {
59 nsid: string;
60 type?: string;
61 body?: unknown;
62 params?: Record<string, string>;
63 },
64 jwt?: string | null
65): Promise<unknown> {
66 const headers: Record<string, string> = {};
67 if (jwt) headers["Authorization"] = `Bearer ${jwt}`;
68
69 if (params.type === "query") {
70 const qs = new URLSearchParams();
71 if (params.params) {
72 for (const [k, v] of Object.entries(params.params)) {
73 if (v != null) qs.set(k, v);
74 }
75 }
76 const qsStr = qs.toString();
77 const url = `${serviceUrl}/xrpc/${params.nsid}${qsStr ? `?${qsStr}` : ""}`;
78 const res = await fetch(url, { headers });
79 if (!res.ok) {
80 const text = await res.text().catch(() => "");
81 MissingError.rethrowFromResponse(text);
82 throw new Error(
83 `XRPC query failed (${params.nsid}): ${res.status} ${text}`
84 );
85 }
86 return res.json();
87 }
88
89 headers["Content-Type"] = "application/json";
90 const url = `${serviceUrl}/xrpc/${params.nsid}`;
91 const res = await fetch(url, {
92 method: "POST",
93 headers,
94 body: JSON.stringify(params.body ?? {}),
95 });
96 if (!res.ok) {
97 const text = await res.text().catch(() => "");
98 MissingError.rethrowFromResponse(text);
99 throw new Error(
100 `XRPC procedure failed (${params.nsid}): ${res.status} ${text}`
101 );
102 }
103 return res.json();
104}
105
106export function createResolver(viewerDid?: string | null): Resolver {
107 return {
108 async fetchRecord(uri) {
109 return fetchRecordFromPds(uri);
110 },
111
112 async resolve(dids, collection, rkey) {
113 const uris = dids.map((did) => `at://${did}/${collection}/${rkey}`);
114 const promises = uris.map((uri) => fetchRecordFromPds(uri));
115 for (let i = 0; i < dids.length; i++) {
116 const record = await promises[i];
117 if (record) return { did: dids[i], uri: uris[i] as any, record };
118 }
119 return null;
120 },
121
122 async xrpc(params) {
123 let serviceUrl: string;
124 try {
125 serviceUrl = await resolveDidToService(params.did, "#inlay");
126 } catch {
127 throw new Error(
128 `XRPC resolve failed for ${params.nsid} (did=${params.did})`
129 );
130 }
131
132 // Personalized: get service JWT, skip server cache
133 if (params.personalized && viewerDid) {
134 const jwt = await getServiceJwt(viewerDid, params.did, params.nsid);
135 return callXrpc(serviceUrl, params, jwt);
136 }
137
138 if (!params.componentUri || params.type === "query") {
139 return callXrpc(serviceUrl, params);
140 }
141
142 const key = `xrpc:${JSON.stringify(params)}`;
143 const hit = await cacheGet(key);
144 if (hit !== undefined) return hit;
145
146 const value = await callXrpc(serviceUrl, params);
147 const response = value as ComponentResponse;
148
149 const tags: CacheTag[] = [
150 ...(response.cache?.tags ?? []),
151 { $type: "at.inlay.defs#tagRecord", uri: params.componentUri },
152 ];
153 const life = response.cache?.life ?? "hours";
154
155 await cacheSet(key, value, { life, tags });
156 return value;
157 },
158
159 async resolveLexicon(nsid) {
160 const key = `lexicon:${nsid}`;
161 const hit = await cacheGet(key);
162 if (hit !== undefined) return hit;
163
164 try {
165 const { lexicon } = await lexResolver.get(nsid);
166 await cacheSet(key, lexicon, { life: "hours" });
167 return lexicon;
168 } catch {
169 await cacheSet(key, null, { life: "hours" });
170 return null;
171 }
172 },
173 };
174}