social components inlay.at
atproto components sdui
86
fork

Configure Feed

Select the types of activity you want to include in your feed.

let Missing propagate via xrpc. List and Tabs catch it by default

+180 -106
+2
.gitignore
··· 24 24 # Worktrees 25 25 .worktrees/ 26 26 27 + .claude 27 28 tap.db* 28 29 records/ 30 + val/
+19
app/sandbox/errors.ts
··· 16 16 throw error; 17 17 } 18 18 19 + /** 20 + * If `e` is a MissingError, re-throw it as a digest error so 21 + * MaybeBoundary can catch it. Otherwise does nothing. 22 + */ 23 + export function rethrowMissing(e: unknown): void { 24 + if ( 25 + e != null && 26 + typeof e === "object" && 27 + "kind" in e && 28 + (e as { kind: string }).kind === "missing" 29 + ) { 30 + const err = e as unknown as { path: string[]; componentStack?: string[] }; 31 + const stack = 32 + err.componentStack?.map((nsid: string) => `at ${nsid}`).join("\n") ?? ""; 33 + const pathStr = err.path[0]?.includes(":") ? "record" : err.path.join("."); 34 + throwDigest(`${MISSING_DIGEST}:${pathStr}${stack ? "\n" + stack : ""}`); 35 + } 36 + } 37 + 19 38 function addDigest(e: unknown): never { 20 39 throwDigest((e as Error).message); 21 40 }
+36 -35
app/sandbox/render/list.tsx
··· 1 1 import { type ReactNode } from "react"; 2 - import { renderNode, toHostContext, type HostContext } from "./render"; 2 + import { 3 + renderNode, 4 + toHostContext, 5 + renderOptions, 6 + type HostContext, 7 + } from "./render"; 3 8 import { deserializeTree } from "@inlay/core"; 4 9 import { resolveEndpoint } from "@inlay/render"; 5 - import { resolveDidToService } from "@/data/resolve"; 10 + import { rethrowMissing } from "../errors"; 6 11 import { MaybeBoundary } from "./client"; 7 12 import { ListClient } from "./list-client"; 8 13 ··· 29 34 props: unknown; 30 35 }) { 31 36 const { query, input } = props as ListProps; 37 + const resolver = ctx.options.resolver; 32 38 33 - // Resolve which service implements this query 34 - const endpoint = await resolveEndpoint( 35 - query, 36 - ctx.render.imports, 37 - ctx.options.resolver 38 - ); 39 + const endpoint = await resolveEndpoint(query, ctx.render.imports, resolver); 39 40 const did = endpoint.did; 40 41 41 - // Initial fetch 42 - const page = await callQuery(did, query, input); 42 + const params = toQueryParams(input); 43 43 44 - // Render each item element through the pack stack, with separators 44 + let page: Page; 45 + try { 46 + page = (await resolver.xrpc({ 47 + did, 48 + nsid: query, 49 + type: "query", 50 + params, 51 + })) as Page; 52 + } catch (e) { 53 + rethrowMissing(e); 54 + throw e; 55 + } 56 + 45 57 const renderedItems: ReactNode[] = []; 46 58 page.items.forEach((raw, i) => { 47 59 if (i > 0) renderedItems.push(<hr key={`sep-${i}`} />); ··· 58 70 ): Promise<{ items: ReactNode; cursor?: string }> { 59 71 "use server"; 60 72 61 - const nextPage = await callQuery(did, query, { 62 - ...input, 63 - cursor, 64 - }); 73 + const nextPage = (await renderOptions.resolver.xrpc({ 74 + did, 75 + nsid: query, 76 + type: "query", 77 + params: { ...params, cursor }, 78 + })) as Page; 65 79 66 80 const hostCtx = toHostContext(renderCtx); 67 81 const items: ReactNode[] = []; ··· 86 100 ); 87 101 } 88 102 89 - async function callQuery( 90 - did: string, 91 - query: string, 103 + function toQueryParams( 92 104 input?: Record<string, unknown> 93 - ): Promise<Page> { 94 - const serviceUrl = await resolveDidToService(did, "#inlay"); 95 - const params = new URLSearchParams(); 96 - if (input) { 97 - for (const [k, v] of Object.entries(input)) { 98 - if (v != null) params.set(k, String(v)); 99 - } 105 + ): Record<string, string> | undefined { 106 + if (!input) return undefined; 107 + const params: Record<string, string> = {}; 108 + for (const [k, v] of Object.entries(input)) { 109 + if (v != null) params[k] = String(v); 100 110 } 101 - const qs = params.toString(); 102 - const url = `${serviceUrl}/xrpc/${query}${qs ? `?${qs}` : ""}`; 103 - const res = await fetch(url); 104 - 105 - if (!res.ok) { 106 - const text = await res.text().catch(() => ""); 107 - throw new Error(`List query failed (${query}): ${res.status} ${text}`); 108 - } 109 - 110 - return (await res.json()) as Page; 111 + return params; 111 112 }
+7 -8
app/sandbox/render/render.tsx
··· 3 3 import { inlay } from "@/generated/at"; 4 4 import { tagRecord, tagLink } from "@/generated/at/inlay/defs.defs"; 5 5 import { ErrorBoundary } from "./client"; 6 - import { MISSING_DIGEST, throwDigest } from "../errors"; 6 + import { throwDigest, rethrowMissing } from "../errors"; 7 7 import { componentMap } from "./builtins"; 8 8 import { isValidElement } from "@inlay/core"; 9 9 import { ··· 16 16 } from "@inlay/render"; 17 17 import type { ComponentResponse } from "@/data/types"; 18 18 import { fetchComponentByUri, fetchEndpointByUri } from "@/data/queries"; 19 - import { callComponent } from "@/data/xrpc"; 19 + import { callComponent, callQuery } from "@/data/xrpc"; 20 20 import { getServiceJwt } from "@/auth/session"; 21 21 import { resolveLexicon } from "@/data/lexicon"; 22 22 import { AtUri } from "@atproto/syntax"; ··· 136 136 }, 137 137 138 138 async xrpc(params) { 139 + if (params.type === "query") { 140 + return callQuery(params.did, params.nsid, params.params); 141 + } 142 + 139 143 const input = (params.body ?? {}) as Record<string, unknown>; 140 144 141 145 // Personalized with viewer: get service JWT, skip cache ··· 239 243 try { 240 244 result = await render(element, context.render, context.options); 241 245 } catch (e) { 242 - if (e instanceof MissingError) { 243 - const stack = 244 - e.componentStack?.map((nsid: string) => `at ${nsid}`).join("\n") ?? ""; 245 - const pathStr = e.path[0]?.includes(":") ? "record" : e.path.join("."); 246 - throwDigest(`${MISSING_DIGEST}:${pathStr}${stack ? "\n" + stack : ""}`); 247 - } 246 + rethrowMissing(e); 248 247 const err = e as Error & { componentStack?: string[] }; 249 248 const stack = 250 249 err.componentStack?.map((nsid: string) => ` at ${nsid}`).join("\n") ??
+5 -2
app/sandbox/render/tabs.tsx
··· 1 - import { createElement, Fragment, type ReactNode } from "react"; 1 + import { createElement, type ReactNode } from "react"; 2 2 import { renderNode, type HostContext } from "./render"; 3 3 import { isValidElement } from "@inlay/core"; 4 4 import { withDigest } from "../errors"; 5 + import { MaybeBoundary } from "./client"; 5 6 import { TabsClient } from "./tabs-client"; 6 7 7 8 type TabItem = { ··· 51 52 const labels = items.map((item) => item.label); 52 53 const keys = items.map((item) => item.key); 53 54 const rendered: ReactNode[] = items.map((item) => ( 54 - <Fragment key={item.key}>{renderNode(item.content, ctx)}</Fragment> 55 + <MaybeBoundary key={item.key} fallback={null}> 56 + {renderNode(item.content, ctx)} 57 + </MaybeBoundary> 55 58 )); 56 59 57 60 return createElement(
+30 -11
data/xrpc.ts
··· 2 2 import "server-only"; 3 3 import type { ComponentResponse } from "./types"; 4 4 import { resolveDidToService } from "./resolve"; 5 + import { MissingError } from "@inlay/render"; 5 6 6 7 /** 7 - * Call a component's XRPC endpoint 8 - * 9 - * @param did - The DID of the service hosting the component 10 - * @param procedure - The NSID of the procedure (e.g., "com.example.renderPost") 11 - * @param input - The input props to send 12 - * @param serviceJwt - Optional service JWT for personalized components 13 - * @returns The ComponentResponse (node + cache config) returned by the component 8 + * Call a component's XRPC procedure endpoint (POST). 14 9 */ 15 10 export async function callComponent( 16 11 did: string, ··· 50 45 ); 51 46 } 52 47 48 + return handleResponse(res, procedure); 49 + } 50 + 51 + /** 52 + * Call an XRPC query endpoint (GET). 53 + */ 54 + export async function callQuery( 55 + did: string, 56 + nsid: string, 57 + params?: Record<string, string> 58 + ): Promise<unknown> { 59 + const serviceUrl = await resolveDidToService(did, "#inlay"); 60 + const qs = new URLSearchParams(); 61 + if (params) { 62 + for (const [k, v] of Object.entries(params)) { 63 + if (v != null) qs.set(k, v); 64 + } 65 + } 66 + const qsStr = qs.toString(); 67 + const url = `${serviceUrl}/xrpc/${nsid}${qsStr ? `?${qsStr}` : ""}`; 68 + const res = await fetch(url); 69 + return handleResponse(res, nsid); 70 + } 71 + 72 + async function handleResponse(res: Response, label: string): Promise<any> { 53 73 if (!res.ok) { 54 74 const text = await res.text().catch(() => ""); 75 + MissingError.rethrowFromResponse(text); 55 76 let detail = text; 56 77 try { 57 78 const json = JSON.parse(text); 58 79 const err = json.error ?? json; 59 80 detail = err.message ?? err.name ?? text; 60 81 } catch {} 61 - throw new Error(`XRPC ${procedure} failed (${res.status}): ${detail}`); 82 + throw new Error(`XRPC ${label} failed (${res.status}): ${detail}`); 62 83 } 63 - 64 - const data = await res.json(); 65 - return data as ComponentResponse; 84 + return res.json(); 66 85 }
+42 -20
package-lock.json
··· 7 7 "name": "inlay-at", 8 8 "hasInstallScript": true, 9 9 "workspaces": [ 10 + "packages/@inlay/*", 10 11 "db", 11 12 "ingester", 12 13 "invalidator", ··· 24 25 "@codemirror/lint": "^6.9.3", 25 26 "@codemirror/state": "^6.5.4", 26 27 "@codemirror/view": "^6.39.12", 27 - "@inlay/core": "0.0.13", 28 - "@inlay/render": "^0.3.0", 28 + "@inlay/core": "*", 29 + "@inlay/render": "*", 29 30 "@radix-ui/react-dialog": "^1.1.15", 30 31 "@radix-ui/react-popover": "^1.1.15", 31 32 "codemirror": "^6.0.2", ··· 3096 3097 "resolved": "invalidator", 3097 3098 "link": true 3098 3099 }, 3100 + "node_modules/@inlay/cache": { 3101 + "resolved": "packages/@inlay/cache", 3102 + "link": true 3103 + }, 3099 3104 "node_modules/@inlay/core": { 3100 - "version": "0.0.13", 3101 - "resolved": "https://registry.npmjs.org/@inlay/core/-/core-0.0.13.tgz", 3102 - "integrity": "sha512-UO3M3l96+Ed0RikY5SyDCP16yb+ceyYksQ2PLO3PyBZJ8EDvw9CfKIdbsOzraQp3lyUkmhrToJj1GED0tkvi1Q==", 3103 - "license": "MIT", 3104 - "dependencies": { 3105 - "@atproto/lex": "^0.0.18", 3106 - "@atproto/syntax": "^0.4.3" 3107 - } 3105 + "resolved": "packages/@inlay/core", 3106 + "link": true 3108 3107 }, 3109 3108 "node_modules/@inlay/render": { 3110 - "version": "0.3.0", 3111 - "resolved": "https://registry.npmjs.org/@inlay/render/-/render-0.3.0.tgz", 3112 - "integrity": "sha512-vHcrf/LVNZiG+6OEIb53BdkxTNF7t1y7/n//9lQbbGftQDRZuuDEYMQ6QLnWHFU8f5Y9/Pb7W1Px0XeOMpbGKA==", 3113 - "dependencies": { 3114 - "@atproto/lexicon": "^0.6.1", 3115 - "@atproto/syntax": "^0.4.3" 3116 - }, 3117 - "peerDependencies": { 3118 - "@inlay/core": "*" 3119 - } 3109 + "resolved": "packages/@inlay/render", 3110 + "link": true 3120 3111 }, 3121 3112 "node_modules/@ioredis/commands": { 3122 3113 "version": "1.5.0", ··· 11229 11220 }, 11230 11221 "peerDependencies": { 11231 11222 "zod": "^3.25.0 || ^4.0.0" 11223 + } 11224 + }, 11225 + "packages/@inlay/cache": { 11226 + "version": "0.0.2", 11227 + "license": "MIT", 11228 + "devDependencies": { 11229 + "typescript": "^5.9.0" 11230 + } 11231 + }, 11232 + "packages/@inlay/core": { 11233 + "version": "0.0.13", 11234 + "license": "MIT", 11235 + "dependencies": { 11236 + "@atproto/lex": "^0.0.18", 11237 + "@atproto/syntax": "^0.4.3" 11238 + }, 11239 + "devDependencies": { 11240 + "typescript": "^5.9.0" 11241 + } 11242 + }, 11243 + "packages/@inlay/render": { 11244 + "version": "0.3.0", 11245 + "dependencies": { 11246 + "@atproto/lexicon": "^0.6.1", 11247 + "@atproto/syntax": "^0.4.3" 11248 + }, 11249 + "devDependencies": { 11250 + "typescript": "^5.9.0" 11251 + }, 11252 + "peerDependencies": { 11253 + "@inlay/core": "*" 11232 11254 } 11233 11255 }, 11234 11256 "proto": {
+3 -2
package.json
··· 3 3 "private": true, 4 4 "type": "module", 5 5 "workspaces": [ 6 + "packages/@inlay/*", 6 7 "db", 7 8 "ingester", 8 9 "invalidator", ··· 44 45 "@codemirror/lint": "^6.9.3", 45 46 "@codemirror/state": "^6.5.4", 46 47 "@codemirror/view": "^6.39.12", 47 - "@inlay/core": "0.0.13", 48 - "@inlay/render": "^0.3.0", 48 + "@inlay/core": "*", 49 + "@inlay/render": "*", 49 50 "@radix-ui/react-dialog": "^1.1.15", 50 51 "@radix-ui/react-popover": "^1.1.15", 51 52 "codemirror": "^6.0.2",
+17
packages/@inlay/render/src/index.ts
··· 110 110 super(`Missing: ${path.join(".")}`); 111 111 this.path = path; 112 112 } 113 + 114 + /** 115 + * If an XRPC error response body encodes a MissingError, throw it. 116 + * No-op if the body doesn't represent a MissingError. 117 + */ 118 + static rethrowFromResponse(body: string): void { 119 + try { 120 + const json = JSON.parse(body); 121 + const err = json.error ?? json; 122 + if (err.name === "MissingError" && typeof err.message === "string") { 123 + const raw = err.message.replace(/^Missing:\s*/, ""); 124 + throw new MissingError([raw]); 125 + } 126 + } catch (e) { 127 + if (e instanceof MissingError) throw e; 128 + } 129 + } 113 130 } 114 131 115 132 const DEFAULT_MAX_DEPTH = 30;
+8 -11
proto/src/index.tsx
··· 17 17 import { deserializeTree, $ } from "@inlay/core"; 18 18 import { resolveEndpoint } from "@inlay/render"; 19 19 import { setQueryString } from "./primitives.tsx"; 20 - import { resolveDidToService } from "./resolve.ts"; 21 20 import { 22 21 initOAuthClient, 23 22 getViewerDid, ··· 301 300 const ctx: RenderContext = { imports }; 302 301 303 302 const endpoint = await resolveEndpoint(query, imports, sharedResolver); 304 - const serviceUrl = await resolveDidToService(endpoint.did, "#inlay"); 305 - const params = new URLSearchParams(); 303 + const params: Record<string, string> = {}; 306 304 for (const [k, v] of Object.entries({ ...input, cursor })) { 307 - if (v != null) params.set(k, String(v)); 308 - } 309 - const url = `${serviceUrl}/xrpc/${query}?${params.toString()}`; 310 - const res = await fetch(url); 311 - 312 - if (!res.ok) { 313 - return c.html(<div class="error">List query failed: {res.status}</div>); 305 + if (v != null) params[k] = String(v); 314 306 } 315 307 316 - const page = (await res.json()) as { items: unknown[]; cursor?: string }; 308 + const page = (await sharedResolver.xrpc({ 309 + did: endpoint.did, 310 + nsid: query, 311 + type: "query", 312 + params, 313 + })) as { items: unknown[]; cursor?: string }; 317 314 318 315 const resolved = await Promise.all( 319 316 page.items.map((item) =>
+8 -16
proto/src/primitives.tsx
··· 424 424 } 425 425 426 426 const endpoint = await resolveEndpoint(p.query, ctx.imports, _resolver); 427 - const serviceUrl = await resolveDidToService(endpoint.did, "#inlay"); 428 - const params = new URLSearchParams(); 427 + const params: Record<string, string> = {}; 429 428 if (p.input) { 430 429 for (const [k, v] of Object.entries(p.input)) { 431 - if (v != null) params.set(k, String(v)); 430 + if (v != null) params[k] = String(v); 432 431 } 433 432 } 434 - const qs = params.toString(); 435 - const url = `${serviceUrl}/xrpc/${p.query}${qs ? `?${qs}` : ""}`; 436 433 437 - const res = await fetch(url); 438 - if (!res.ok) { 439 - const text = await res.text().catch(() => ""); 440 - return ( 441 - <div class="error"> 442 - List query failed: {res.status} {text} 443 - </div> 444 - ); 445 - } 446 - 447 - const page = (await res.json()) as { items: unknown[]; cursor?: string }; 434 + const page = (await _resolver.xrpc({ 435 + did: endpoint.did, 436 + nsid: p.query, 437 + type: "query", 438 + params, 439 + })) as { items: unknown[]; cursor?: string }; 448 440 449 441 const resolved = await Promise.all( 450 442 page.items.map((item) => rn(deserializeTree(item), ctx))
+3 -1
proto/src/resolver.ts
··· 3 3 import { resolveDidToService } from "./resolve.ts"; 4 4 import { cacheGet, cacheSet } from "./cache.ts"; 5 5 import { getServiceJwt } from "./auth.ts"; 6 - import type { Resolver } from "@inlay/render"; 6 + import { type Resolver, MissingError } from "@inlay/render"; 7 7 8 8 const lexResolver = new LexResolver({}); 9 9 ··· 78 78 const res = await fetch(url, { headers }); 79 79 if (!res.ok) { 80 80 const text = await res.text().catch(() => ""); 81 + MissingError.rethrowFromResponse(text); 81 82 throw new Error( 82 83 `XRPC query failed (${params.nsid}): ${res.status} ${text}` 83 84 ); ··· 94 95 }); 95 96 if (!res.ok) { 96 97 const text = await res.text().catch(() => ""); 98 + MissingError.rethrowFromResponse(text); 97 99 throw new Error( 98 100 `XRPC procedure failed (${params.nsid}): ${res.status} ${text}` 99 101 );