an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
at main 4.4 kB view raw
1import { ValidationResult } from "npm:@atproto/lexicon"; 2import { parseAtUri } from "./aturi.ts"; 3import { FINEPDSAndHandleFromDid } from "./identity.ts"; 4import * as ATPAPI from "npm:@atproto/api" 5 6export function validate<R extends { $type?: string }>( 7 record: unknown, 8 validator: (v: unknown) => ValidationResult<R> 9): record is R { 10 return validator(record).success; 11} 12 13type NotificationDeclarationRecord = { 14 $type: "app.bsky.notification.declaration"; 15 // its not a string its a enum but we just dont have the lexicons for it 16 allowSubscriptions: string; 17 [k: string]: unknown; 18}; 19// declare function validateNotificationDeclarationRecord<V>(v: V): ValidationResult<NotificationDeclarationRecord & V>; 20function validateNotificationDeclaration( 21 obj: unknown 22): obj is NotificationDeclarationRecord { 23 return ( 24 typeof obj === "object" && 25 obj !== null && 26 typeof (obj as any).allowSubscriptions === "string" 27 ); 28} 29 30const recordValidators = { 31 "app.bsky.actor.profile": ATPAPI.AppBskyActorProfile.validateRecord, 32 "app.bsky.feed.generator": ATPAPI.AppBskyFeedGenerator.validateRecord, 33 "app.bsky.feed.like": ATPAPI.AppBskyFeedLike.validateRecord, 34 "app.bsky.feed.post": ATPAPI.AppBskyFeedPost.validateRecord, 35 "app.bsky.feed.repost": ATPAPI.AppBskyFeedRepost.validateRecord, 36 "app.bsky.feed.threadgate": ATPAPI.AppBskyFeedThreadgate.validateRecord, 37 "app.bsky.graph.block": ATPAPI.AppBskyGraphBlock.validateRecord, 38 "app.bsky.graph.follow": ATPAPI.AppBskyGraphFollow.validateRecord, 39 "app.bsky.graph.list": ATPAPI.AppBskyGraphList.validateRecord, 40 "app.bsky.graph.listblock": ATPAPI.AppBskyGraphListblock.validateRecord, 41 "app.bsky.graph.listitem": ATPAPI.AppBskyGraphListitem.validateRecord, 42 "app.bsky.notification.declaration": validateNotificationDeclaration, // smh my head 43} as const; 44 45type RecordTypeMap = { 46 "app.bsky.actor.profile": ATPAPI.AppBskyActorProfile.Record; 47 "app.bsky.feed.generator": ATPAPI.AppBskyFeedGenerator.Record; 48 "app.bsky.feed.like": ATPAPI.AppBskyFeedLike.Record; 49 "app.bsky.feed.post": ATPAPI.AppBskyFeedPost.Record; 50 "app.bsky.feed.repost": ATPAPI.AppBskyFeedRepost.Record; 51 "app.bsky.feed.threadgate": ATPAPI.AppBskyFeedThreadgate.Record; 52 "app.bsky.graph.block": ATPAPI.AppBskyGraphBlock.Record; 53 "app.bsky.graph.follow": ATPAPI.AppBskyGraphFollow.Record; 54 "app.bsky.graph.list": ATPAPI.AppBskyGraphList.Record; 55 "app.bsky.graph.listblock": ATPAPI.AppBskyGraphListblock.Record; 56 "app.bsky.graph.listitem": ATPAPI.AppBskyGraphListitem.Record; 57 "app.bsky.notification.declaration": NotificationDeclarationRecord; // smh my smh head smh my head 58}; 59 60// type RecordTypeMap = { 61// [K in KnownRecordType]: ReturnType<typeof recordValidators[K]> extends ValidationResult<infer T> ? T : never 62// } 63type KnownRecordType = keyof typeof recordValidators; 64 65export function validateRecord<T extends KnownRecordType>( 66 record: unknown 67): RecordTypeMap[T] | undefined { 68 const type = (record as { $type?: string })?.$type; 69 if (!type || !(type in recordValidators)) return undefined; 70 71 const validator = recordValidators[type as T] as ( 72 v: unknown 73 ) => ValidationResult<RecordTypeMap[T]>; 74 const result = validator(record); 75 if (result.success) return result.value; 76 return undefined; 77} 78export function assertRecord<T extends KnownRecordType>( 79 record: unknown 80): RecordTypeMap[T] | undefined { 81 if (typeof record !== 'object' || record === null) { 82 return undefined; 83 } 84 const type = (record as { $type?: string })?.$type; 85 if (typeof type !== 'string' || !(type in recordValidators)) { 86 return undefined; 87 } 88 return record as RecordTypeMap[T]; 89} 90export async function resolveRecordFromURI({ 91 did, 92 uri, 93}: { 94 did?: string; 95 uri: string; 96}): Promise<RecordTypeMap[KnownRecordType] | undefined> { 97 const parsed = parseAtUri(uri); 98 const safeDid = did ?? parsed?.did!; 99 const pdsurl = await FINEPDSAndHandleFromDid(safeDid); 100 101 const res = await fetch( 102 `${pdsurl}/xrpc/com.atproto.repo.getRecord?repo=${safeDid}&collection=${parsed?.collection}&rkey=${parsed?.rkey}` 103 ); 104 const json = await res.json(); 105 const record = json?.value; 106 107 return validateRecord(record); 108} 109 110async function _test(uri: string) { 111 const record = await resolveRecordFromURI({ uri }); 112 113 if (record?.$type === "app.bsky.feed.post") { 114 record.text; // Type-safe access to `AppBskyFeedPost.Record` 115 } 116}