social components inlay-proto.up.railway.app/
atproto components sdui
at main 223 lines 6.9 kB view raw
1import { Lexicons, type LexiconDoc } from "@atproto/lexicon"; 2import { AtUri } from "@atproto/syntax"; 3import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 4import { 5 viewRecord as viewRecordSchema, 6 viewPrimitive as viewPrimitiveSchema, 7} from "../../../../generated/at/inlay/component.defs.js"; 8import type { Resolver } from "./index.js"; 9 10// --- Lexicon cache (module-level) --- 11 12const lexiconCache = new Map<string, Lexicons>(); 13 14// --- Public API --- 15 16export async function validateProps( 17 type: string, 18 props: Record<string, unknown>, 19 component: ComponentRecord, 20 resolver: Resolver 21): Promise<Record<string, unknown>> { 22 let result: Record<string, unknown> = props; 23 24 const lex = await resolver.resolveLexicon(type); 25 if (lex) { 26 let lexicons = lexiconCache.get(type); 27 if (!lexicons) { 28 lexicons = await buildLexicons(lex as Record<string, unknown>, resolver); 29 lexiconCache.set(type, lexicons); 30 } 31 result = lexicons.assertValidXrpcInput(type, props) as Record< 32 string, 33 unknown 34 >; 35 } else if (component.view) { 36 // Synthesize validation from view entries (viewPrimitive + viewRecord) 37 const { prop: viewProp, accepts } = component.view; 38 const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v)); 39 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v)); 40 41 if (primitives.length > 0 || records.length > 0) { 42 const propEntries = new Map< 43 string, 44 { type: string; formats: Set<string> } 45 >(); 46 47 // viewPrimitive entries define typed props 48 for (const vp of primitives) { 49 const existing = propEntries.get(viewProp); 50 if (existing) { 51 if (vp.format) existing.formats.add(vp.format); 52 } else { 53 const formats = new Set<string>(); 54 if (vp.format) formats.add(vp.format); 55 propEntries.set(viewProp, { type: vp.type, formats }); 56 } 57 } 58 59 // viewRecord entries imply a string prop (at-uri or did format) 60 for (const vr of records) { 61 const existing = propEntries.get(viewProp); 62 if (existing) { 63 existing.formats.add("at-uri"); 64 if (vr.rkey) existing.formats.add("did"); 65 } else { 66 const formats = new Set<string>(["at-uri"]); 67 if (vr.rkey) formats.add("did"); 68 propEntries.set(viewProp, { type: "string", formats }); 69 } 70 } 71 72 if (propEntries.size > 0) { 73 const properties: Record<string, { type: string; format?: string }> = 74 {}; 75 const required = [...propEntries.keys()]; 76 for (const [prop, { type: t, formats }] of propEntries) { 77 const format = unionFormats(formats); 78 properties[prop] = format ? { type: t, format } : { type: t }; 79 } 80 const syntheticLex = { 81 lexicon: 1, 82 id: type, 83 defs: { 84 main: { 85 type: "procedure", 86 input: { 87 encoding: "application/json", 88 schema: { type: "object", required, properties }, 89 }, 90 }, 91 }, 92 } as unknown as LexiconDoc; 93 const lexicons = new Lexicons([syntheticLex]); 94 result = lexicons.assertValidXrpcInput(type, props) as Record< 95 string, 96 unknown 97 >; 98 } 99 } 100 } 101 102 // Collection constraint checking from viewRecord entries 103 if (component.view) { 104 const { prop: collectionProp, accepts } = component.view; 105 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v)); 106 if (records.length > 0) { 107 const allowedCollections = new Map<string, Set<string>>(); 108 for (const vr of records) { 109 if (!vr.collection) { 110 continue; 111 } 112 let set = allowedCollections.get(collectionProp); 113 if (!set) { 114 set = new Set(); 115 allowedCollections.set(collectionProp, set); 116 } 117 set.add(vr.collection); 118 } 119 for (const [prop, allowed] of allowedCollections) { 120 const value = result[prop]; 121 if (typeof value !== "string" || !value.startsWith("at://")) { 122 continue; 123 } 124 const parsed = new AtUri(value); 125 if (!parsed.collection) { 126 continue; 127 } 128 if (!allowed.has(parsed.collection)) { 129 throw new Error( 130 `${type}: ${prop} expects ${[...allowed].join(" or ")}, got ${parsed.collection}` 131 ); 132 } 133 } 134 } 135 } 136 137 return result; 138} 139 140// --- Internals --- 141 142const FORMAT_ANCESTORS: Record<string, string[]> = { 143 did: ["uri", "at-identifier"], 144 "at-uri": ["uri"], 145 handle: ["at-identifier"], 146}; 147 148function unionFormats(formats: Set<string>): string | undefined { 149 const arr = [...formats]; 150 if (arr.length <= 1) { 151 return arr[0]; 152 } 153 const ancestorSets = arr.map( 154 (f) => new Set([f, ...(FORMAT_ANCESTORS[f] ?? [])]) 155 ); 156 const common = [...ancestorSets[0]].filter((f) => 157 ancestorSets.every((s) => s.has(f)) 158 ); 159 if (common.length === 0) { 160 return undefined; 161 } 162 if (common.length === 1) return common[0]; 163 return common.find( 164 (f) => 165 !common.some((g) => g !== f && (FORMAT_ANCESTORS[g] ?? []).includes(f)) 166 ); 167} 168 169function collectRefNsids(obj: unknown, out = new Set<string>()): Set<string> { 170 if (!obj || typeof obj !== "object") return out; 171 const o = obj as Record<string, unknown>; 172 if (o.type === "ref" && typeof o.ref === "string") { 173 const nsid = (o.ref as string).split("#")[0]; 174 if (nsid) out.add(nsid); 175 } 176 if (o.type === "union" && Array.isArray(o.refs)) { 177 for (const ref of o.refs) { 178 if (typeof ref === "string") { 179 const nsid = ref.split("#")[0]; 180 if (nsid) out.add(nsid); 181 } 182 } 183 } 184 for (const val of Object.values(o)) { 185 if (Array.isArray(val)) { 186 val.forEach((v) => collectRefNsids(v, out)); 187 } else if (val && typeof val === "object") { 188 collectRefNsids(val, out); 189 } 190 } 191 return out; 192} 193 194async function buildLexicons( 195 root: Record<string, unknown>, 196 resolver: Resolver 197): Promise<Lexicons> { 198 const loaded = new Map<string, Record<string, unknown>>(); 199 loaded.set((root as { id: string }).id, root); 200 201 let pending = collectRefNsids(root); 202 while (pending.size > 0) { 203 const newNsids = [...pending].filter((nsid) => !loaded.has(nsid)); 204 if (newNsids.length === 0) break; 205 const docs = await Promise.all( 206 newNsids.map((nsid) => resolver.resolveLexicon(nsid)) 207 ); 208 const nextPending = new Set<string>(); 209 for (let i = 0; i < newNsids.length; i++) { 210 const doc = docs[i] as Record<string, unknown> | null; 211 if (doc) { 212 loaded.set(newNsids[i], doc); 213 collectRefNsids(doc, nextPending); 214 } 215 } 216 pending = nextPending; 217 } 218 219 const docs = [...loaded.values()].map( 220 (d) => JSON.parse(JSON.stringify(d)) as LexiconDoc 221 ); 222 return new Lexicons(docs); 223}