social components inlay.at
atproto components sdui

add endpoints

+202 -42
+39 -10
packages/@inlay/render/src/index.ts
··· 32 32 export type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 33 33 export type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js"; 34 34 35 + export type EndpointRecord = { 36 + did: string; 37 + createdAt: string; 38 + }; 39 + 35 40 export interface Resolver { 36 41 fetchRecord(uri: AtUriString): Promise<unknown | null>; 37 42 xrpc(params: { ··· 44 49 personalized?: boolean; 45 50 }): Promise<unknown>; 46 51 resolveLexicon(nsid: string): Promise<unknown | null>; 52 + /** 53 + * Find the first DID (in order) that has a record in the given collection 54 + * with the given rkey. Used for component and endpoint resolution. 55 + */ 56 + resolve( 57 + dids: DidString[], 58 + collection: string, 59 + rkey: string 60 + ): Promise<{ did: DidString; uri: AtUriString; record: unknown } | null>; 47 61 } 48 62 49 63 export type RenderOptions = { ··· 336 350 componentUri: string; 337 351 component: ComponentRecord; 338 352 }> { 339 - const uris = importStack.map( 340 - (did) => `at://${did}/at.inlay.component/${nsid}` as AtUriString 353 + const result = await resolver.resolve( 354 + importStack, 355 + "at.inlay.component", 356 + nsid 341 357 ); 342 - const promises = uris.map((uri) => resolver.fetchRecord(uri)); 358 + if (!result) throw new Error(`Unresolved type: ${nsid}`); 359 + return { 360 + componentUri: result.uri, 361 + component: result.record as ComponentRecord, 362 + }; 363 + } 343 364 344 - for (let i = 0; i < importStack.length; i++) { 345 - const component = (await promises[i]) as ComponentRecord | null; 346 - if (!component) continue; 347 - return { componentUri: uris[i], component }; 348 - } 349 - 350 - throw new Error(`Unresolved type: ${nsid}`); 365 + /** 366 + * Resolve an endpoint NSID through the import stack. 367 + * Walks DIDs looking for `at.inlay.endpoint/{nsid}` records. 368 + * Returns the service DID from the first matching record. 369 + */ 370 + export async function resolveEndpoint( 371 + nsid: string, 372 + imports: DidString[], 373 + resolver: Resolver 374 + ): Promise<{ did: string; endpointUri: string }> { 375 + const result = await resolver.resolve(imports, "at.inlay.endpoint", nsid); 376 + if (!result) throw new Error(`Unresolved endpoint: ${nsid}`); 377 + const record = result.record as EndpointRecord; 378 + if (!record.did) throw new Error(`Endpoint record missing did: ${nsid}`); 379 + return { did: record.did, endpointUri: result.uri }; 351 380 } 352 381 353 382 async function renderTemplate(
+127 -18
packages/@inlay/render/test/render.test.ts
··· 51 51 import { 52 52 render, 53 53 createContext, 54 + resolveEndpoint, 54 55 MissingError, 55 56 type ComponentRecord, 56 57 type CachePolicy, ··· 359 360 }, 360 361 resolveLexicon: async (nsid) => { 361 362 log.push(`lexicon ${nsid}`); 363 + return null; 364 + }, 365 + async resolve(dids, collection, rkey) { 366 + const uris = dids.map( 367 + (did) => `at://${did}/${collection}/${rkey}` as AtUriString 368 + ); 369 + const promises = uris.map((uri) => resolver.fetchRecord(uri)); 370 + for (let i = 0; i < dids.length; i++) { 371 + const record = await promises[i]; 372 + if (record) return { did: dids[i], uri: uris[i], record }; 373 + } 362 374 return null; 363 375 }, 364 376 }; ··· 1811 1823 }); 1812 1824 1813 1825 // Greeting's resolver fails — error should bubble with full owner chain. 1826 + const wrappedFetch = ((original) => async (uri: AtUriString) => { 1827 + const result = await original(uri); 1828 + if (result && uri === `at://${APP_DID}/at.inlay.component/${Greeting}`) { 1829 + // Return a component whose template references a missing type. 1830 + return { 1831 + ...result, 1832 + body: { 1833 + $type: "at.inlay.component#bodyTemplate", 1834 + node: serializeTree($("test.app.DoesNotExist", {})), 1835 + }, 1836 + }; 1837 + } 1838 + return result; 1839 + })(options.resolver.fetchRecord); 1840 + 1814 1841 const output = await renderToCompletion( 1815 1842 $(Page, {}), 1816 1843 { 1817 1844 ...options, 1818 1845 resolver: { 1819 1846 ...options.resolver, 1820 - fetchRecord: ((original) => async (uri: AtUriString) => { 1821 - const result = await original(uri); 1822 - if ( 1823 - result && 1824 - uri === `at://${APP_DID}/at.inlay.component/${Greeting}` 1825 - ) { 1826 - // Return a component whose template references a missing type. 1827 - return { 1828 - ...result, 1829 - body: { 1830 - $type: "at.inlay.component#bodyTemplate", 1831 - node: serializeTree($("test.app.DoesNotExist", {})), 1832 - }, 1833 - }; 1847 + fetchRecord: wrappedFetch, 1848 + async resolve(dids, collection, rkey) { 1849 + const uris = dids.map( 1850 + (did) => `at://${did}/${collection}/${rkey}` as AtUriString 1851 + ); 1852 + const promises = uris.map((uri) => wrappedFetch(uri)); 1853 + for (let i = 0; i < dids.length; i++) { 1854 + const record = await promises[i]; 1855 + if (record) return { did: dids[i], uri: uris[i], record }; 1834 1856 } 1835 - return result; 1836 - })(options.resolver.fetchRecord), 1837 - xrpc: options.resolver.xrpc, 1838 - resolveLexicon: options.resolver.resolveLexicon, 1857 + return null; 1858 + }, 1839 1859 }, 1840 1860 }, 1841 1861 createContext(pageComponent, `at://${APP_DID}/at.inlay.component/${Page}`) ··· 2005 2025 throw new Error("unreachable"); 2006 2026 }, 2007 2027 resolveLexicon: async () => null, 2028 + resolve: async () => { 2029 + throw new Error("network down"); 2030 + }, 2008 2031 }, 2009 2032 }; 2010 2033 ··· 4500 4523 assert.equal(capturedPersonalized, false); 4501 4524 }); 4502 4525 }); 4526 + 4527 + // ============================================================================ 4528 + // Endpoint resolution 4529 + // ============================================================================ 4530 + 4531 + describe("endpoint resolution", () => { 4532 + const QUERY_NSID = "test.app.getItems"; 4533 + const ENDPOINT_DID = "did:plc:endpoint-author"; 4534 + const SERVICE_DID_EP = "did:web:my-val.val.run"; 4535 + 4536 + it("resolves endpoint through import stack", async () => { 4537 + const { options } = testResolver({ 4538 + [`at://${ENDPOINT_DID}/at.inlay.endpoint/${QUERY_NSID}`]: { 4539 + did: SERVICE_DID_EP, 4540 + createdAt: "2025-01-01T00:00:00Z", 4541 + }, 4542 + } as any); 4543 + 4544 + const result = await resolveEndpoint( 4545 + QUERY_NSID, 4546 + [ENDPOINT_DID as any], 4547 + options.resolver 4548 + ); 4549 + assert.equal(result.did, SERVICE_DID_EP); 4550 + assert.equal( 4551 + result.endpointUri, 4552 + `at://${ENDPOINT_DID}/at.inlay.endpoint/${QUERY_NSID}` 4553 + ); 4554 + }); 4555 + 4556 + it("first DID in import stack wins", async () => { 4557 + const DID_A = "did:plc:a" as any; 4558 + const DID_B = "did:plc:b" as any; 4559 + 4560 + const { options } = testResolver({ 4561 + [`at://${DID_A}/at.inlay.endpoint/${QUERY_NSID}`]: { 4562 + did: "did:web:val-a.val.run", 4563 + createdAt: "2025-01-01T00:00:00Z", 4564 + }, 4565 + [`at://${DID_B}/at.inlay.endpoint/${QUERY_NSID}`]: { 4566 + did: "did:web:val-b.val.run", 4567 + createdAt: "2025-01-01T00:00:00Z", 4568 + }, 4569 + } as any); 4570 + 4571 + const result = await resolveEndpoint( 4572 + QUERY_NSID, 4573 + [DID_A, DID_B], 4574 + options.resolver 4575 + ); 4576 + assert.equal(result.did, "did:web:val-a.val.run"); 4577 + }); 4578 + 4579 + it("falls through to later DIDs", async () => { 4580 + const DID_A = "did:plc:a" as any; 4581 + const DID_B = "did:plc:b" as any; 4582 + 4583 + const { options } = testResolver({ 4584 + [`at://${DID_B}/at.inlay.endpoint/${QUERY_NSID}`]: { 4585 + did: "did:web:val-b.val.run", 4586 + createdAt: "2025-01-01T00:00:00Z", 4587 + }, 4588 + } as any); 4589 + 4590 + const result = await resolveEndpoint( 4591 + QUERY_NSID, 4592 + [DID_A, DID_B], 4593 + options.resolver 4594 + ); 4595 + assert.equal(result.did, "did:web:val-b.val.run"); 4596 + }); 4597 + 4598 + it("throws when no DID has the endpoint", async () => { 4599 + const { options } = testResolver({} as any); 4600 + 4601 + await assert.rejects( 4602 + () => 4603 + resolveEndpoint( 4604 + QUERY_NSID, 4605 + ["did:plc:nobody" as any], 4606 + options.resolver 4607 + ), 4608 + { message: `Unresolved endpoint: ${QUERY_NSID}` } 4609 + ); 4610 + }); 4611 + });
+6 -5
proto/src/index.tsx
··· 15 15 type JSXElement, 16 16 } from "./render.tsx"; 17 17 import { deserializeTree, $ } from "@inlay/core"; 18 + import { resolveEndpoint } from "@inlay/render"; 18 19 import { setQueryString } from "./primitives.tsx"; 19 20 import { resolveDidToService } from "./resolve.ts"; 20 21 import { ··· 42 43 43 44 app.get("/", (c) => 44 45 c.redirect( 45 - "/at/did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self?componentUri=at%3A%2F%2Fdid%3Aplc%3Afpruhuo22xkm5o7ttr2ktxdo%2Fat.inlay.component%2Fmov.danabra.ProfilePage&layout=page" 46 + "/at/did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self?componentUri=at%3A%2F%2Fdid%3Aplc%3Afpruhuo22xkm5o7ttr2ktxdo%2Fat.inlay.component%2Fmov.danabra.Profile&layout=page" 46 47 ) 47 48 ); 48 49 ··· 283 284 // --- HTMX list pagination endpoint --- 284 285 app.get("/htmx/list", async (c) => { 285 286 const query = c.req.query("query"); 286 - const did = c.req.query("did"); 287 287 const cursor = c.req.query("cursor"); 288 288 const inputStr = c.req.query("input"); 289 289 const importsStr = c.req.query("imports"); 290 290 291 - if (!query || !did || !cursor) { 291 + if (!query || !cursor) { 292 292 return c.html(<div class="error">Missing params</div>); 293 293 } 294 294 ··· 300 300 301 301 const ctx: RenderContext = { imports }; 302 302 303 - const serviceUrl = await resolveDidToService(did, "#inlay"); 303 + const endpoint = await resolveEndpoint(query, imports, sharedResolver); 304 + const serviceUrl = await resolveDidToService(endpoint.did, "#inlay"); 304 305 const params = new URLSearchParams(); 305 306 for (const [k, v] of Object.entries({ ...input, cursor })) { 306 307 if (v != null) params.set(k, String(v)); ··· 327 328 328 329 const importsParam = encodeURIComponent(JSON.stringify(imports)); 329 330 const sentinelUrl = page.cursor 330 - ? `/htmx/list?query=${encodeURIComponent(query)}&did=${encodeURIComponent(did)}&cursor=${encodeURIComponent(page.cursor)}&input=${encodeURIComponent(JSON.stringify(input))}&imports=${importsParam}` 331 + ? `/htmx/list?query=${encodeURIComponent(query)}&cursor=${encodeURIComponent(page.cursor)}&input=${encodeURIComponent(JSON.stringify(input))}&imports=${importsParam}` 331 332 : null; 332 333 333 334 c.header("Cache-Control", "private, max-age=120");
+12 -7
proto/src/primitives.tsx
··· 2 2 import { Suspense } from "hono/jsx/streaming"; 3 3 import { ErrorBoundary } from "hono/jsx"; 4 4 import type { RenderContext } from "@inlay/render"; 5 - import { MissingError } from "@inlay/render"; 5 + import { MissingError, resolveEndpoint } from "@inlay/render"; 6 6 import { resolveDidToService } from "./resolve.ts"; 7 7 import { isValidElement, deserializeTree } from "@inlay/core"; 8 8 import "./types.ts"; ··· 10 10 // Hono's JSX.Element — what all JSX expressions produce 11 11 type JSXElement = HtmlEscapedString | Promise<HtmlEscapedString>; 12 12 13 - // renderNode is injected to avoid circular deps 13 + // renderNode and resolver are injected to avoid circular deps 14 14 let _renderNode: (node: unknown, ctx: RenderContext) => Promise<JSXElement>; 15 + let _resolver: import("@inlay/render").Resolver; 15 16 let _qs: string = ""; 16 17 17 18 export function setRenderNode( 18 19 fn: (node: unknown, ctx: RenderContext) => Promise<JSXElement> 19 20 ) { 20 21 _renderNode = fn; 22 + } 23 + 24 + export function setResolver(resolver: import("@inlay/render").Resolver) { 25 + _resolver = resolver; 21 26 } 22 27 23 28 export function setQueryString(qs: string) { ··· 412 417 async function List({ ctx, props }: PrimitiveProps) { 413 418 const p = props as { 414 419 query: string; 415 - did: string; 416 420 input?: Record<string, unknown>; 417 421 }; 418 - if (!p.did || !p.query) { 419 - return <div class="error">List: missing did or query</div>; 422 + if (!p.query) { 423 + return <div class="error">List: missing query</div>; 420 424 } 421 425 422 - const serviceUrl = await resolveDidToService(p.did, "#inlay"); 426 + const endpoint = await resolveEndpoint(p.query, ctx.imports, _resolver); 427 + const serviceUrl = await resolveDidToService(endpoint.did, "#inlay"); 423 428 const params = new URLSearchParams(); 424 429 if (p.input) { 425 430 for (const [k, v] of Object.entries(p.input)) { ··· 451 456 } 452 457 453 458 const importsParam = encodeURIComponent(JSON.stringify(ctx.imports)); 454 - const sentinelUrl = `/htmx/list?query=${encodeURIComponent(p.query)}&did=${encodeURIComponent(p.did)}&cursor=${encodeURIComponent(page.cursor ?? "")}&input=${encodeURIComponent(JSON.stringify(p.input ?? {}))}&imports=${importsParam}`; 459 + const sentinelUrl = `/htmx/list?query=${encodeURIComponent(p.query)}&cursor=${encodeURIComponent(page.cursor ?? "")}&input=${encodeURIComponent(JSON.stringify(p.input ?? {}))}&imports=${importsParam}`; 455 460 456 461 return ( 457 462 <>
+2 -1
proto/src/render.tsx
··· 9 9 type RenderOptions, 10 10 } from "@inlay/render"; 11 11 import { isValidElement } from "@inlay/core"; 12 - import { componentMap, setRenderNode } from "./primitives.tsx"; 12 + import { componentMap, setRenderNode, setResolver } from "./primitives.tsx"; 13 13 import "./types.ts"; 14 14 15 15 // Hono's JSX.Element type — what all JSX expressions return ··· 75 75 76 76 export function initRender(options: RenderOptions) { 77 77 setRenderNode((node, ctx) => renderNode(node, ctx, options)); 78 + setResolver(options.resolver); 78 79 }
+16 -1
proto/src/resolver.ts
··· 35 35 const res = await fetch( 36 36 `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.host)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}` 37 37 ); 38 - if (!res.ok) return null; 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 + } 39 44 40 45 const data = await res.json(); 41 46 const value = data.value as Record<string, unknown>; ··· 100 105 return { 101 106 async fetchRecord(uri) { 102 107 return fetchRecordFromPds(uri); 108 + }, 109 + 110 + async resolve(dids, collection, rkey) { 111 + const uris = dids.map((did) => `at://${did}/${collection}/${rkey}`); 112 + const promises = uris.map((uri) => fetchRecordFromPds(uri)); 113 + for (let i = 0; i < dids.length; i++) { 114 + const record = await promises[i]; 115 + if (record) return { did: dids[i], uri: uris[i] as any, record }; 116 + } 117 + return null; 103 118 }, 104 119 105 120 async xrpc(params) {