social components
0
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 );