@ewanc26/atproto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # @ewanc26/atproto
2
+
3
+ AT Protocol service layer extracted from [ewancroft.uk](https://ewancroft.uk). Handles identity resolution, record fetching, Bluesky posts, Standard.site documents, music/mood status, and more — with a built-in in-memory cache.
4
+
5
+ **Key difference from the app's internal service layer:** all functions accept `did: string` as their first argument rather than reading `PUBLIC_ATPROTO_DID` from the environment.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @ewanc26/atproto
11
+ ```
12
+
13
+ Requires `@atproto/api >= 0.13.0` as a peer dependency.
14
+
15
+ ## What's Exported
16
+
17
+ ### Fetch Functions
18
+
19
+ | Function | Description |
20
+ |----------|-------------|
21
+ | `fetchProfile(did)` | Bluesky profile including pronouns |
22
+ | `fetchSiteInfo(did)` | `uk.ewancroft.site.info` record |
23
+ | `fetchLinks(did)` | `blue.linkat.board` link cards |
24
+ | `fetchMusicStatus(did)` | teal.fm music status with cascading artwork lookup |
25
+ | `fetchKibunStatus(did)` | kibun.social mood status |
26
+ | `fetchTangledRepos(did)` | Tangled code repositories |
27
+ | `fetchPublications(did)` | Standard.site publications |
28
+ | `fetchDocuments(did, pubRkey)` | Standard.site documents for a publication |
29
+ | `fetchBlogPosts(did, pubRkey)` | Blog posts for a publication |
30
+ | `fetchRecentDocuments(did, limit)` | Most recent documents across all publications |
31
+ | `fetchLatestBlueskyPost(did)` | Latest non-reply Bluesky post with thread context |
32
+ | `fetchPostFromUri(did, uri)` | Single Bluesky post by AT URI |
33
+ | `fetchAllEngagement(uris)` | Like/repost counts via Constellation API |
34
+
35
+ ### Agents & Identity
36
+
37
+ ```typescript
38
+ import { resolveIdentity, getPublicAgent, getPDSAgent, createAgent, withFallback, resetAgents } from '@ewanc26/atproto';
39
+ ```
40
+
41
+ ### Pagination
42
+
43
+ ```typescript
44
+ import { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto';
45
+ ```
46
+
47
+ ### Cache
48
+
49
+ ```typescript
50
+ import { cache, ATProtoCache, CACHE_TTL } from '@ewanc26/atproto';
51
+
52
+ cache.clear();
53
+ cache.delete('profile:did:plc:…');
54
+ ```
55
+
56
+ ### Music Artwork
57
+
58
+ ```typescript
59
+ import { findArtwork, searchMusicBrainzRelease, buildCoverArtUrl } from '@ewanc26/atproto';
60
+ // Cascading: MusicBrainz → iTunes → Deezer → Last.fm → PDS blob
61
+ ```
62
+
63
+ ### Media
64
+
65
+ ```typescript
66
+ import { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from '@ewanc26/atproto';
67
+ ```
68
+
69
+ ### Types
70
+
71
+ All interfaces (`ProfileData`, `BlueskyPost`, `BlogPost`, `MusicStatusData`, `KibunStatusData`, `TangledRepo`, `StandardSiteDocument`, `StandardSitePublication`, `SiteInfoData`, `LinkData`, `ResolvedIdentity`, …) are exported from the package root.
72
+
73
+ ## Build
74
+
75
+ ```bash
76
+ pnpm build # tsc
77
+ pnpm dev # tsc --watch
78
+ pnpm check # tsc --noEmit
79
+ ```
80
+
81
+ ## Licence
82
+
83
+ See the root [LICENSE](../../LICENSE).
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ewanc26/atproto",
3
+ "version": "0.1.0",
4
+ "description": "AT Protocol service layer extracted from ewancroft.uk",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "source": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "publishConfig": { "access": "public" },
16
+ "files": ["dist", "src"],
17
+ "scripts": {
18
+ "build": "tsc --project tsconfig.json",
19
+ "dev": "tsc --project tsconfig.json --watch",
20
+ "check": "tsc --noEmit"
21
+ },
22
+ "peerDependencies": {
23
+ "@atproto/api": ">=0.13.0"
24
+ },
25
+ "devDependencies": {
26
+ "@atproto/api": "^0.18.1",
27
+ "typescript": "^5.9.3"
28
+ }
29
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { AtpAgent } from '@atproto/api';
2
+ import type { ResolvedIdentity } from './types.js';
3
+ import { cache } from './cache.js';
4
+
5
+ export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent {
6
+ const wrappedFetch = fetchFn
7
+ ? async (url: URL | RequestInfo, init?: RequestInit) => {
8
+ const urlStr = url instanceof URL ? url.toString() : url;
9
+ const response = await fetchFn(urlStr, init);
10
+ const headers = new Headers(response.headers);
11
+ if (!headers.has('content-type')) {
12
+ headers.set('content-type', 'application/json');
13
+ }
14
+ return new Response(response.body, {
15
+ status: response.status,
16
+ statusText: response.statusText,
17
+ headers
18
+ });
19
+ }
20
+ : undefined;
21
+
22
+ return new AtpAgent({
23
+ service,
24
+ ...(wrappedFetch && { fetch: wrappedFetch })
25
+ });
26
+ }
27
+
28
+ export const constellationAgent = createAgent('https://constellation.microcosm.blue');
29
+ export const defaultAgent = createAgent('https://public.api.bsky.app');
30
+
31
+ let resolvedAgent: AtpAgent | null = null;
32
+ let pdsAgent: AtpAgent | null = null;
33
+
34
+ export async function resolveIdentity(
35
+ did: string,
36
+ fetchFn?: typeof fetch
37
+ ): Promise<ResolvedIdentity> {
38
+ const cacheKey = `identity:${did}`;
39
+ const cached = cache.get<ResolvedIdentity>(cacheKey);
40
+ if (cached) return cached;
41
+
42
+ const _fetch = fetchFn ?? globalThis.fetch;
43
+ const response = await _fetch(
44
+ `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`
45
+ );
46
+
47
+ if (!response.ok) {
48
+ throw new Error(
49
+ `Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`
50
+ );
51
+ }
52
+
53
+ const rawText = await response.text();
54
+ let data: any;
55
+ try {
56
+ data = JSON.parse(rawText);
57
+ } catch (err) {
58
+ throw new Error('Failed to parse identity resolver response');
59
+ }
60
+
61
+ if (!data.did || !data.pds) {
62
+ throw new Error('Invalid response from identity resolver');
63
+ }
64
+
65
+ cache.set(cacheKey, data);
66
+ return data;
67
+ }
68
+
69
+ export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> {
70
+ if (resolvedAgent) return resolvedAgent;
71
+
72
+ try {
73
+ try {
74
+ const response = await constellationAgent.getProfile({ actor: did });
75
+ if (response.success) {
76
+ resolvedAgent = constellationAgent;
77
+ return resolvedAgent;
78
+ }
79
+ } catch {
80
+ // fall through
81
+ }
82
+
83
+ const resolved = await resolveIdentity(did, fetchFn);
84
+ resolvedAgent = createAgent(resolved.pds, fetchFn);
85
+ return resolvedAgent;
86
+ } catch {
87
+ resolvedAgent = defaultAgent;
88
+ return resolvedAgent;
89
+ }
90
+ }
91
+
92
+ export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> {
93
+ if (pdsAgent) return pdsAgent;
94
+ const resolved = await resolveIdentity(did, fetchFn);
95
+ pdsAgent = createAgent(resolved.pds, fetchFn);
96
+ return pdsAgent;
97
+ }
98
+
99
+ export async function withFallback<T>(
100
+ did: string,
101
+ operation: (agent: AtpAgent) => Promise<T>,
102
+ usePDSFirst = false,
103
+ fetchFn?: typeof fetch
104
+ ): Promise<T> {
105
+ const defaultAgentFn = () =>
106
+ fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent);
107
+
108
+ const agents = usePDSFirst
109
+ ? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
110
+ : [defaultAgentFn, () => getPDSAgent(did, fetchFn)];
111
+
112
+ let lastError: any;
113
+ for (const getAgent of agents) {
114
+ try {
115
+ const agent = await getAgent();
116
+ return await operation(agent);
117
+ } catch (error) {
118
+ lastError = error;
119
+ }
120
+ }
121
+ throw lastError;
122
+ }
123
+
124
+ export function resetAgents(): void {
125
+ resolvedAgent = null;
126
+ pdsAgent = null;
127
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { CacheEntry } from './types.js';
2
+
3
+ /**
4
+ * Cache TTL values in milliseconds (production defaults).
5
+ * All functions that previously read PUBLIC_ATPROTO_DID from the environment
6
+ * now accept `did: string` as their first argument.
7
+ */
8
+ export const CACHE_TTL = {
9
+ PROFILE: 60 * 60 * 1000,
10
+ SITE_INFO: 120 * 60 * 1000,
11
+ LINKS: 60 * 60 * 1000,
12
+ MUSIC_STATUS: 10 * 60 * 1000,
13
+ KIBUN_STATUS: 15 * 60 * 1000,
14
+ TANGLED_REPOS: 60 * 60 * 1000,
15
+ BLOG_POSTS: 30 * 60 * 1000,
16
+ PUBLICATIONS: 60 * 60 * 1000,
17
+ INDIVIDUAL_POST: 60 * 60 * 1000,
18
+ IDENTITY: 24 * 60 * 60 * 1000
19
+ } as const;
20
+
21
+ export class ATProtoCache {
22
+ private cache = new Map<string, CacheEntry<any>>();
23
+
24
+ private getTTL(key: string): number {
25
+ if (key.startsWith('profile:')) return CACHE_TTL.PROFILE;
26
+ if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO;
27
+ if (key.startsWith('links:')) return CACHE_TTL.LINKS;
28
+ if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS;
29
+ if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS;
30
+ if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS;
31
+ if (key.startsWith('blog-posts:') || key.startsWith('blogposts:')) return CACHE_TTL.BLOG_POSTS;
32
+ if (key.startsWith('publications:') || key.startsWith('standard-site:publications:'))
33
+ return CACHE_TTL.PUBLICATIONS;
34
+ if (key.startsWith('post:') || key.startsWith('blueskypost:')) return CACHE_TTL.INDIVIDUAL_POST;
35
+ if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY;
36
+ return 30 * 60 * 1000;
37
+ }
38
+
39
+ get<T>(key: string): T | null {
40
+ const entry = this.cache.get(key);
41
+ if (!entry) return null;
42
+ const ttl = this.getTTL(key);
43
+ if (Date.now() - entry.timestamp > ttl) {
44
+ this.cache.delete(key);
45
+ return null;
46
+ }
47
+ return entry.data;
48
+ }
49
+
50
+ set<T>(key: string, data: T): void {
51
+ this.cache.set(key, { data, timestamp: Date.now() });
52
+ }
53
+
54
+ delete(key: string): void {
55
+ this.cache.delete(key);
56
+ }
57
+
58
+ clear(): void {
59
+ this.cache.clear();
60
+ }
61
+ }
62
+
63
+ export const cache = new ATProtoCache();
@@ -0,0 +1,247 @@
1
+ import { cache } from './cache.js';
2
+ import { withFallback, resolveIdentity } from './agents.js';
3
+ import { buildPdsBlobUrl } from './media.js';
4
+ import type {
5
+ StandardSitePublication,
6
+ StandardSitePublicationsData,
7
+ StandardSiteDocument,
8
+ StandardSiteDocumentsData,
9
+ StandardSiteBasicTheme,
10
+ StandardSiteThemeColor,
11
+ BlogPost
12
+ } from './types.js';
13
+
14
+ interface DocumentRecord {
15
+ site: string;
16
+ path?: string;
17
+ title: string;
18
+ description?: string;
19
+ coverImage?: unknown;
20
+ content?: unknown;
21
+ textContent?: string;
22
+ bskyPostRef?: { uri: string; cid: string };
23
+ tags?: string[];
24
+ publishedAt: string;
25
+ updatedAt?: string;
26
+ }
27
+
28
+ interface PublicationRecord {
29
+ url: string;
30
+ name: string;
31
+ description?: string;
32
+ icon?: unknown;
33
+ basicTheme?: {
34
+ background: StandardSiteThemeColor;
35
+ foreground: StandardSiteThemeColor;
36
+ accent: StandardSiteThemeColor;
37
+ accentForeground: StandardSiteThemeColor;
38
+ };
39
+ preferences?: { showInDiscover?: boolean };
40
+ }
41
+
42
+ async function getBlobUrl(
43
+ did: string,
44
+ blob: any,
45
+ fetchFn?: typeof fetch
46
+ ): Promise<string | undefined> {
47
+ try {
48
+ const cid = blob.ref?.$link || blob.cid;
49
+ if (!cid) return undefined;
50
+ const resolved = await resolveIdentity(did, fetchFn);
51
+ return buildPdsBlobUrl(resolved.pds, did, cid);
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ function resolveViewUrl(
58
+ site: string,
59
+ path: string | undefined,
60
+ publicationUrl: string | undefined,
61
+ rkey: string
62
+ ): string {
63
+ const docPath = path || `/${rkey}`;
64
+ const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`;
65
+
66
+ if (site.startsWith('at://')) {
67
+ if (!publicationUrl) return `${site}${normalizedPath}`;
68
+ const baseUrl = publicationUrl.startsWith('http') ? publicationUrl : `https://${publicationUrl}`;
69
+ return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`;
70
+ } else {
71
+ const baseUrl = site.startsWith('http') ? site : `https://${site}`;
72
+ return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`;
73
+ }
74
+ }
75
+
76
+ export async function fetchPublications(
77
+ did: string,
78
+ fetchFn?: typeof fetch
79
+ ): Promise<StandardSitePublicationsData> {
80
+ const cacheKey = `standard-site:publications:${did}`;
81
+ const cached = cache.get<StandardSitePublicationsData>(cacheKey);
82
+ if (cached) return cached;
83
+
84
+ const publications: StandardSitePublication[] = [];
85
+
86
+ try {
87
+ const publicationsRecords = await withFallback(
88
+ did,
89
+ async (agent) => {
90
+ const response = await agent.com.atproto.repo.listRecords({
91
+ repo: did,
92
+ collection: 'site.standard.publication',
93
+ limit: 100
94
+ });
95
+ return response.data.records;
96
+ },
97
+ true,
98
+ fetchFn
99
+ );
100
+
101
+ for (const pubRecord of publicationsRecords) {
102
+ const pubValue = pubRecord.value as unknown as PublicationRecord;
103
+ const rkey = pubRecord.uri.split('/').pop() || '';
104
+
105
+ let basicTheme: StandardSiteBasicTheme | undefined;
106
+ if (pubValue.basicTheme) {
107
+ basicTheme = {
108
+ background: pubValue.basicTheme.background,
109
+ foreground: pubValue.basicTheme.foreground,
110
+ accent: pubValue.basicTheme.accent,
111
+ accentForeground: pubValue.basicTheme.accentForeground
112
+ };
113
+ }
114
+
115
+ publications.push({
116
+ name: pubValue.name,
117
+ rkey,
118
+ uri: pubRecord.uri,
119
+ url: pubValue.url,
120
+ description: pubValue.description,
121
+ icon: pubValue.icon ? await getBlobUrl(did, pubValue.icon, fetchFn) : undefined,
122
+ basicTheme,
123
+ preferences: pubValue.preferences
124
+ });
125
+ }
126
+
127
+ const data: StandardSitePublicationsData = { publications };
128
+ cache.set(cacheKey, data);
129
+ return data;
130
+ } catch {
131
+ return { publications: [] };
132
+ }
133
+ }
134
+
135
+ export async function fetchDocuments(
136
+ did: string,
137
+ fetchFn?: typeof fetch
138
+ ): Promise<StandardSiteDocumentsData> {
139
+ const cacheKey = `standard-site:documents:${did}`;
140
+ const cached = cache.get<StandardSiteDocumentsData>(cacheKey);
141
+ if (cached) return cached;
142
+
143
+ const documents: StandardSiteDocument[] = [];
144
+
145
+ try {
146
+ const publicationsData = await fetchPublications(did, fetchFn);
147
+ const publicationsMap = new Map<string, StandardSitePublication>();
148
+ for (const pub of publicationsData.publications) {
149
+ publicationsMap.set(pub.uri, pub);
150
+ }
151
+
152
+ const documentsRecords = await withFallback(
153
+ did,
154
+ async (agent) => {
155
+ const response = await agent.com.atproto.repo.listRecords({
156
+ repo: did,
157
+ collection: 'site.standard.document',
158
+ limit: 100
159
+ });
160
+ return response.data.records;
161
+ },
162
+ true,
163
+ fetchFn
164
+ );
165
+
166
+ for (const docRecord of documentsRecords) {
167
+ const docValue = docRecord.value as unknown as DocumentRecord;
168
+ const rkey = docRecord.uri.split('/').pop() || '';
169
+
170
+ const site = docValue.site;
171
+ let publication: StandardSitePublication | undefined;
172
+ let publicationRkey: string | undefined;
173
+ let pubUrl: string | undefined;
174
+
175
+ if (site.startsWith('at://')) {
176
+ publication = publicationsMap.get(site);
177
+ publicationRkey = site.split('/').pop();
178
+ pubUrl = publication?.url;
179
+ } else {
180
+ pubUrl = site;
181
+ }
182
+
183
+ const url = resolveViewUrl(site, docValue.path, pubUrl, rkey);
184
+ const coverImage = docValue.coverImage
185
+ ? await getBlobUrl(did, docValue.coverImage, fetchFn)
186
+ : undefined;
187
+
188
+ documents.push({
189
+ title: docValue.title,
190
+ rkey,
191
+ uri: docRecord.uri,
192
+ url,
193
+ site,
194
+ path: docValue.path,
195
+ description: docValue.description,
196
+ coverImage,
197
+ content: docValue.content,
198
+ textContent: docValue.textContent,
199
+ bskyPostRef: docValue.bskyPostRef,
200
+ tags: docValue.tags,
201
+ publishedAt: docValue.publishedAt,
202
+ updatedAt: docValue.updatedAt,
203
+ publicationName: publication?.name,
204
+ publicationRkey
205
+ });
206
+ }
207
+
208
+ documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
209
+
210
+ const data: StandardSiteDocumentsData = { documents };
211
+ cache.set(cacheKey, data);
212
+ return data;
213
+ } catch {
214
+ return { documents: [] };
215
+ }
216
+ }
217
+
218
+ export async function fetchRecentDocuments(
219
+ did: string,
220
+ limit = 5,
221
+ fetchFn?: typeof fetch
222
+ ): Promise<StandardSiteDocument[]> {
223
+ const { documents } = await fetchDocuments(did, fetchFn);
224
+ return documents.slice(0, limit);
225
+ }
226
+
227
+ export async function fetchBlogPosts(
228
+ did: string,
229
+ fetchFn?: typeof fetch
230
+ ): Promise<{ posts: BlogPost[] }> {
231
+ const { documents } = await fetchDocuments(did, fetchFn);
232
+ const posts: BlogPost[] = documents.map((doc) => ({
233
+ title: doc.title,
234
+ url: doc.url,
235
+ createdAt: doc.publishedAt,
236
+ platform: 'standard.site' as const,
237
+ description: doc.description,
238
+ rkey: doc.rkey,
239
+ publicationName: doc.publicationName,
240
+ publicationRkey: doc.publicationRkey,
241
+ tags: doc.tags,
242
+ coverImage: doc.coverImage,
243
+ textContent: doc.textContent,
244
+ updatedAt: doc.updatedAt
245
+ }));
246
+ return { posts };
247
+ }
@@ -0,0 +1,56 @@
1
+ import { cache } from './cache.js';
2
+
3
+ export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost';
4
+
5
+ interface EngagementResponse {
6
+ dids: string[];
7
+ cursor?: string;
8
+ }
9
+
10
+ export async function fetchEngagementFromConstellation(
11
+ uri: string,
12
+ type: EngagementType,
13
+ cursor?: string
14
+ ): Promise<EngagementResponse> {
15
+ const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`;
16
+ const cached = cache.get<EngagementResponse>(cacheKey);
17
+ if (cached) return cached;
18
+
19
+ const url = new URL('https://constellation.microcosm.blue/links/distinct-dids');
20
+ url.searchParams.append('target', uri);
21
+ url.searchParams.append('collection', type);
22
+ url.searchParams.append('path', '');
23
+ url.searchParams.append('limit', '100');
24
+ if (cursor) url.searchParams.append('cursor', cursor);
25
+
26
+ const response = await fetch(url);
27
+ if (!response.ok) {
28
+ throw new Error(`Constellation HTTP error! Status: ${response.status}`);
29
+ }
30
+
31
+ const data = await response.json();
32
+ const result: EngagementResponse = {
33
+ dids: data.dids || [],
34
+ cursor: data.cursor
35
+ };
36
+
37
+ cache.set(cacheKey, result);
38
+ return result;
39
+ }
40
+
41
+ export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> {
42
+ const allDids: Set<string> = new Set();
43
+ let cursor: string | undefined = undefined;
44
+
45
+ try {
46
+ do {
47
+ const response = await fetchEngagementFromConstellation(uri, type, cursor);
48
+ response.dids.forEach((did) => allDids.add(did));
49
+ cursor = response.cursor;
50
+ } while (cursor);
51
+ } catch {
52
+ // return what we have
53
+ }
54
+
55
+ return Array.from(allDids);
56
+ }