social components inlay-proto.up.railway.app/
atproto components sdui

add stacks

+254 -66
+1 -1
packages/@inlay/render/README.md
··· 173 ### Types 174 175 - **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }` 176 - - **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope? }` 177 - **`RenderResult`** — `{ resolved, node, context, cache? }` 178 - **`RenderOptions`** — `{ resolver, maxDepth?, validate? }` 179 - **`ComponentRecord`** — re-exported from generated lexicon defs
··· 173 ### Types 174 175 - **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }` 176 + - **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }` 177 - **`RenderResult`** — `{ resolved, node, context, cache? }` 178 - **`RenderOptions`** — `{ resolver, maxDepth?, validate? }` 179 - **`ComponentRecord`** — re-exported from generated lexicon defs
+51 -19
packages/@inlay/render/src/index.ts
··· 63 * component boundary to prevent infinite recursion. 64 * - `scope`: template variable bindings. Bindings in child element props 65 * resolve against this at the next render boundary. 66 */ 67 export type RenderContext = { 68 imports: AtUriString[]; ··· 70 componentUri?: string; 71 depth?: number; 72 scope?: Record<string, unknown>; 73 }; 74 75 export type RenderResult = { ··· 115 options: RenderOptions 116 ): Promise<RenderResult> { 117 const { resolver } = options; 118 - const effective = slotContexts.get(element) ?? context; 119 - const depth = effective.depth ?? 0; 120 const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; 121 const validate = options.validate ?? true; 122 123 try { 124 const type = element.type; 125 let props = (element.props ?? {}) as Record<string, unknown>; 126 127 // Resolve any Bindings left in props by the parent template 128 - if (effective.scope) { 129 - props = resolveBindings(props, scopeResolver(effective.scope)); 130 } 131 132 // Root render: use component directly 133 - if (effective.component) { 134 - if (type !== effective.component.type) { 135 throw new Error( 136 - `render was given ${effective.component.type}, cannot render ${type}` 137 ); 138 } 139 return await renderComponent( 140 resolver, 141 - effective.component, 142 - effective.componentUri, 143 element, 144 props, 145 - effective, 146 validate 147 ); 148 } ··· 160 return { 161 resolved: true, 162 node: $(type, { ...props, key: element.key }), 163 - context: { imports: effective.imports, scope: effective.scope }, 164 }; 165 } 166 167 if (depth >= maxDepth) { 168 throw Error("Component depth limit exceeded"); ··· 170 171 const { component, componentUri } = await resolveType( 172 type, 173 - effective.imports, 174 resolver 175 ); 176 return await renderComponent( ··· 179 componentUri, 180 element, 181 props, 182 - effective, 183 validate 184 ); 185 } catch (e) { 186 if (e instanceof MissingError) throw e; 187 const err = e as Error; 188 const errProps: Record<string, unknown> = { message: err.message }; 189 - if (err.stack) errProps.stack = err.stack; 190 return { 191 resolved: true, 192 node: $("at.inlay.Throw", errProps as Record<string, string>), 193 - context: { imports: effective.imports }, 194 }; 195 } 196 } ··· 259 validate: boolean 260 ): Promise<RenderResult> { 261 const depth = ctx.depth ?? 0; 262 263 // Primitive: no body, return element with resolved props 264 if (!component.body) { ··· 269 imports: component.imports?.length ? component.imports : ctx.imports, 270 depth, 271 scope: ctx.scope, 272 }, 273 }; 274 } ··· 287 resolver 288 ); 289 } 290 291 if (component.body.$type === "at.inlay.component#bodyTemplate") { 292 - return renderTemplate(resolver, component, resolvedProps, ctx); 293 } 294 295 if (component.body.$type === "at.inlay.component#bodyExternal") { ··· 299 component, 300 componentUri, 301 resolvedProps, 302 - ctx 303 ); 304 } 305 ··· 364 365 // Replace caller-provided elements with Slots so they resolve through the 366 // caller's imports, not the component's — same isolation as external slots. 367 const callerCtx: RenderContext = { 368 imports: ctx.imports, 369 depth, 370 scope: ctx.scope, 371 }; 372 const slottedProps = walkTree(props, (obj, walk) => { 373 if (isValidElement(obj)) { ··· 417 return { 418 resolved: false, 419 node, 420 - context: { imports: component.imports ?? [], depth: depth + 1, scope }, 421 cache, 422 }; 423 } ··· 505 })) as { node: unknown; cache?: CachePolicy }; 506 507 // Restore slots — register caller context in the WeakMap 508 const callerCtx: RenderContext = { 509 imports: ctx.imports, 510 depth, 511 scope: ctx.scope, 512 }; 513 514 const node = deserializeTree(response.node, (el) => { ··· 536 return { 537 resolved: false, 538 node, 539 - context: { imports: component.imports ?? [], depth: depth + 1 }, 540 cache: response.cache, 541 }; 542 }
··· 63 * component boundary to prevent infinite recursion. 64 * - `scope`: template variable bindings. Bindings in child element props 65 * resolve against this at the next render boundary. 66 + * - `stack`: component NSID chain for error reporting. Most recent 67 + * component first — the "who rendered who" owner chain. 68 */ 69 export type RenderContext = { 70 imports: AtUriString[]; ··· 72 componentUri?: string; 73 depth?: number; 74 scope?: Record<string, unknown>; 75 + stack?: string[]; 76 }; 77 78 export type RenderResult = { ··· 118 options: RenderOptions 119 ): Promise<RenderResult> { 120 const { resolver } = options; 121 + const ctx = slotContexts.get(element) ?? context; 122 + const depth = ctx.depth ?? 0; 123 const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; 124 const validate = options.validate ?? true; 125 126 + let errorStack = ctx.stack; 127 + 128 try { 129 const type = element.type; 130 let props = (element.props ?? {}) as Record<string, unknown>; 131 132 // Resolve any Bindings left in props by the parent template 133 + if (ctx.scope) { 134 + props = resolveBindings(props, scopeResolver(ctx.scope)); 135 } 136 137 // Root render: use component directly 138 + if (ctx.component) { 139 + if (type !== ctx.component.type) { 140 throw new Error( 141 + `render was given ${ctx.component.type}, cannot render ${type}` 142 ); 143 } 144 + errorStack = [type, ...(ctx.stack ?? [])]; 145 return await renderComponent( 146 resolver, 147 + ctx.component, 148 + ctx.componentUri, 149 element, 150 props, 151 + ctx, 152 validate 153 ); 154 } ··· 166 return { 167 resolved: true, 168 node: $(type, { ...props, key: element.key }), 169 + context: { 170 + imports: ctx.imports, 171 + scope: ctx.scope, 172 + stack: ctx.stack, 173 + }, 174 }; 175 } 176 + 177 + // Past here we're rendering a component — include it in the owner stack. 178 + errorStack = [type, ...(ctx.stack ?? [])]; 179 180 if (depth >= maxDepth) { 181 throw Error("Component depth limit exceeded"); ··· 183 184 const { component, componentUri } = await resolveType( 185 type, 186 + ctx.imports, 187 resolver 188 ); 189 return await renderComponent( ··· 192 componentUri, 193 element, 194 props, 195 + ctx, 196 validate 197 ); 198 } catch (e) { 199 if (e instanceof MissingError) throw e; 200 const err = e as Error; 201 const errProps: Record<string, unknown> = { message: err.message }; 202 + if (errorStack && errorStack.length > 0) { 203 + errProps.stack = errorStack.map((nsid) => ` at ${nsid}`).join("\n"); 204 + } 205 return { 206 resolved: true, 207 node: $("at.inlay.Throw", errProps as Record<string, string>), 208 + context: { imports: ctx.imports }, 209 }; 210 } 211 } ··· 274 validate: boolean 275 ): Promise<RenderResult> { 276 const depth = ctx.depth ?? 0; 277 + const stack = [element.type, ...(ctx.stack ?? [])]; 278 279 // Primitive: no body, return element with resolved props 280 if (!component.body) { ··· 285 imports: component.imports?.length ? component.imports : ctx.imports, 286 depth, 287 scope: ctx.scope, 288 + stack, 289 }, 290 }; 291 } ··· 304 resolver 305 ); 306 } 307 + 308 + const childCtx = { ...ctx, stack }; 309 310 if (component.body.$type === "at.inlay.component#bodyTemplate") { 311 + return renderTemplate(resolver, component, resolvedProps, childCtx); 312 } 313 314 if (component.body.$type === "at.inlay.component#bodyExternal") { ··· 318 component, 319 componentUri, 320 resolvedProps, 321 + childCtx 322 ); 323 } 324 ··· 383 384 // Replace caller-provided elements with Slots so they resolve through the 385 // caller's imports, not the component's — same isolation as external slots. 386 + const callerStack = ctx.stack?.slice(1); 387 const callerCtx: RenderContext = { 388 imports: ctx.imports, 389 depth, 390 scope: ctx.scope, 391 + stack: callerStack, 392 }; 393 const slottedProps = walkTree(props, (obj, walk) => { 394 if (isValidElement(obj)) { ··· 438 return { 439 resolved: false, 440 node, 441 + context: { 442 + imports: component.imports ?? [], 443 + depth: depth + 1, 444 + scope, 445 + stack: ctx.stack, 446 + }, 447 cache, 448 }; 449 } ··· 531 })) as { node: unknown; cache?: CachePolicy }; 532 533 // Restore slots — register caller context in the WeakMap 534 + const callerStack = ctx.stack?.slice(1); 535 const callerCtx: RenderContext = { 536 imports: ctx.imports, 537 depth, 538 scope: ctx.scope, 539 + stack: callerStack, 540 }; 541 542 const node = deserializeTree(response.node, (el) => { ··· 564 return { 565 resolved: false, 566 node, 567 + context: { 568 + imports: component.imports ?? [], 569 + depth: depth + 1, 570 + stack: ctx.stack, 571 + }, 572 cache: response.cache, 573 }; 574 }
+202 -46
packages/@inlay/render/test/render.test.ts
··· 187 children: await walk((el.props as Record<string, unknown>)?.children), 188 }), 189 [Throw]: async (el) => { 190 - throw new Error((el.props as Record<string, unknown>)?.message as string); 191 }, 192 [Maybe]: async (el, walk) => { 193 const p = (el.props ?? {}) as Record<string, unknown>; ··· 222 extra?: Record<string, Primitive> 223 ): Promise<unknown> { 224 const primitives = extra ? { ...HOST_PRIMITIVES, ...extra } : HOST_PRIMITIVES; 225 - return walkNode(node, options, ctx, primitives); 226 } 227 228 async function walkNode( ··· 243 } = await render(node, ctx, options); 244 if (resolved && isValidElement(out)) { 245 const walk = (n: unknown) => walkNode(n, options, outCtx, primitives); 246 - try { 247 - return await primitives[out.type](out, walk, outCtx); 248 - } catch (e) { 249 - if (e instanceof MissingError) throw e; 250 - return `[Error: ${(e as Error).message}]`; 251 - } 252 } 253 return walkNode(out, options, outCtx, primitives); 254 } ··· 413 assert.deepEqual(actual, expanded); 414 } 415 416 - function assertError(actual: unknown, message: string) { 417 - assert.equal(actual, `[Error: ${message}]`); 418 } 419 420 // ============================================================================ ··· 1099 options, 1100 createContext(rootComponent) 1101 ); 1102 - assertError(output, `No pack exports type: ${unknown}`); 1103 }); 1104 }); 1105 ··· 1310 createContext(pageComponent) 1311 ); 1312 1313 - assertError(output, `No pack exports type: ${Card}`); 1314 }); 1315 1316 it("composed children resolve through caller, not callee", async () => { ··· 1795 // 6. Error handling — Throw elements 1796 // ============================================================================ 1797 // 1798 - // Errors during render are caught and turned into at.inlay.Throw elements. 1799 // For now, the seam where the error is displayed is controlled by the host. 1800 // In the future, a Catch boundary may be added to let the user control this. 1801 1802 describe("error handling", () => { 1803 - it("infinite recursion is caught", async () => { 1804 const loopComponent: ComponentRecord = { 1805 $type: "at.inlay.component", 1806 type: Loop, ··· 1827 { ...options, maxDepth: 5 }, 1828 createContext(loopComponent) 1829 ); 1830 - assertError(output, "Component depth limit exceeded"); 1831 }); 1832 1833 it("catches resolver errors", async () => { ··· 1858 errorOptions, 1859 createContext(rootComponent) 1860 ); 1861 - assertError(output, "network down"); 1862 - }); 1863 - 1864 - it("catches xrpc service errors", async () => { 1865 - const greetingComponent: ComponentRecord = { 1866 - $type: "at.inlay.component", 1867 - type: Greeting, 1868 - body: { 1869 - $type: "at.inlay.component#bodyExternal", 1870 - did: SERVICE_DID, 1871 - }, 1872 - imports: [HOST_PACK_URI], 1873 - }; 1874 - 1875 - const { options } = world({ 1876 - [`xrpc:${SERVICE_DID}:${Greeting}`]: () => { 1877 - throw new Error("service unavailable"); 1878 - }, 1879 - }); 1880 - 1881 - const output = await renderToCompletion( 1882 - $(Greeting, { name: "world" }), 1883 - options, 1884 - createContext(greetingComponent) 1885 - ); 1886 - assertError(output, "service unavailable"); 1887 }); 1888 1889 it("unused children don't trigger errors", async () => { 1890 - // Ignore is a template that doesn't reference its children. 1891 // Even though the child is an external that throws, no error 1892 // occurs because the child is never rendered. 1893 const ignoreComponent: ComponentRecord = { ··· 2746 assert.deepEqual(ok, h("span", {})); 2747 2748 const bad = await renderToCompletion($(View, {}), opts, ctx); 2749 - assertError(bad, 'Input must have the property "uri"'); 2750 }); 2751 2752 it("synthesizes validation from viewRecord", async () => { ··· 2785 options, 2786 ctx 2787 ); 2788 - assertError(bad, "Input/uri must be a string"); 2789 }); 2790 2791 it("synthesizes validation from viewPrimitive", async () => { ··· 2825 options, 2826 ctx 2827 ); 2828 - assertError(bad, "Input/value must be a string"); 2829 }); 2830 2831 it("validates collection constraints from viewRecord", async () => { ··· 2866 ); 2867 assertError( 2868 bad, 2869 - `${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like` 2870 ); 2871 }); 2872 });
··· 187 children: await walk((el.props as Record<string, unknown>)?.children), 188 }), 189 [Throw]: async (el) => { 190 + const p = (el.props ?? {}) as Record<string, unknown>; 191 + const err = new Error(p.message as string); 192 + (err as any).inlayStack = p.stack; 193 + throw err; 194 }, 195 [Maybe]: async (el, walk) => { 196 const p = (el.props ?? {}) as Record<string, unknown>; ··· 225 extra?: Record<string, Primitive> 226 ): Promise<unknown> { 227 const primitives = extra ? { ...HOST_PRIMITIVES, ...extra } : HOST_PRIMITIVES; 228 + try { 229 + return await walkNode(node, options, ctx, primitives); 230 + } catch (e) { 231 + if (e instanceof MissingError) throw e; 232 + const err = e as Error & { inlayStack?: string }; 233 + return h("error", { message: err.message, stack: err.inlayStack }); 234 + } 235 } 236 237 async function walkNode( ··· 252 } = await render(node, ctx, options); 253 if (resolved && isValidElement(out)) { 254 const walk = (n: unknown) => walkNode(n, options, outCtx, primitives); 255 + return await primitives[out.type](out, walk, outCtx); 256 } 257 return walkNode(out, options, outCtx, primitives); 258 } ··· 417 assert.deepEqual(actual, expanded); 418 } 419 420 + function assertError(actual: unknown, message: string, stack: string) { 421 + assert.ok(actual instanceof Output, "expected an Output node"); 422 + assert.equal(actual.tag, "error"); 423 + assert.equal(actual.attrs.message, message); 424 + assert.equal(actual.attrs.stack, stack); 425 } 426 427 // ============================================================================ ··· 1106 options, 1107 createContext(rootComponent) 1108 ); 1109 + assertError( 1110 + output, 1111 + `No pack exports type: ${unknown}`, 1112 + ` at ${unknown}\n at ${Root}` 1113 + ); 1114 }); 1115 }); 1116 ··· 1321 createContext(pageComponent) 1322 ); 1323 1324 + assertError( 1325 + output, 1326 + `No pack exports type: ${Card}`, 1327 + ` at ${Card}\n at ${Greeting}\n at ${Page}` 1328 + ); 1329 }); 1330 1331 it("composed children resolve through caller, not callee", async () => { ··· 1810 // 6. Error handling — Throw elements 1811 // ============================================================================ 1812 // 1813 + // Errors during render are caught and turned into at.inlay.Throw elements 1814 + // with a component stack trace showing the owner chain. 1815 + // 1816 // For now, the seam where the error is displayed is controlled by the host. 1817 // In the future, a Catch boundary may be added to let the user control this. 1818 1819 describe("error handling", () => { 1820 + it("template chain shows owner stack", async () => { 1821 + // Page renders Card, Card renders Greeting. Greeting fails. 1822 + // The stack should read bottom-up: Greeting, Card, Page. 1823 + const pageComponent: ComponentRecord = { 1824 + $type: "at.inlay.component", 1825 + type: Page, 1826 + body: { 1827 + $type: "at.inlay.component#bodyTemplate", 1828 + node: serializeTree($(Card, {})), 1829 + }, 1830 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1831 + }; 1832 + 1833 + const cardComponent: ComponentRecord = { 1834 + $type: "at.inlay.component", 1835 + type: Card, 1836 + body: { 1837 + $type: "at.inlay.component#bodyTemplate", 1838 + node: serializeTree($(Greeting, {})), 1839 + }, 1840 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1841 + }; 1842 + 1843 + const greetingComponent: ComponentRecord = { 1844 + $type: "at.inlay.component", 1845 + type: Greeting, 1846 + body: { 1847 + $type: "at.inlay.component#bodyTemplate", 1848 + node: serializeTree($(Stack, {})), 1849 + }, 1850 + imports: [HOST_PACK_URI], 1851 + }; 1852 + 1853 + const { options } = testResolver({ 1854 + ...HOST_RECORDS, 1855 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 1856 + ["at://did:plc:test/at.inlay.component/card"]: cardComponent, 1857 + ["at://did:plc:test/at.inlay.component/greet"]: greetingComponent, 1858 + ["at://did:plc:test/at.inlay.pack/app"]: { 1859 + $type: "at.inlay.pack", 1860 + name: "app", 1861 + exports: [ 1862 + { 1863 + type: Page, 1864 + component: "at://did:plc:test/at.inlay.component/page", 1865 + }, 1866 + { 1867 + type: Card, 1868 + component: "at://did:plc:test/at.inlay.component/card", 1869 + }, 1870 + { 1871 + type: Greeting, 1872 + component: "at://did:plc:test/at.inlay.component/greet", 1873 + }, 1874 + ], 1875 + }, 1876 + }); 1877 + 1878 + // Greeting's resolver fails — error should bubble with full owner chain. 1879 + const output = await renderToCompletion( 1880 + $(Page, {}), 1881 + { 1882 + ...options, 1883 + resolver: { 1884 + ...options.resolver, 1885 + fetchRecord: ((original) => async (uri: AtUriString) => { 1886 + const result = await original(uri); 1887 + if (result && (result as ComponentRecord).type === Greeting) { 1888 + // Return a component whose template references a missing type. 1889 + return { 1890 + ...result, 1891 + body: { 1892 + $type: "at.inlay.component#bodyTemplate", 1893 + node: serializeTree($("test.app.DoesNotExist", {})), 1894 + }, 1895 + }; 1896 + } 1897 + return result; 1898 + })(options.resolver.fetchRecord), 1899 + xrpc: options.resolver.xrpc, 1900 + resolveLexicon: options.resolver.resolveLexicon, 1901 + }, 1902 + }, 1903 + createContext(pageComponent) 1904 + ); 1905 + assertError( 1906 + output, 1907 + "No pack exports type: test.app.DoesNotExist", 1908 + [ 1909 + " at test.app.DoesNotExist", 1910 + " at test.app.Greeting", 1911 + " at test.app.Card", 1912 + " at test.app.Page", 1913 + ].join("\n") 1914 + ); 1915 + }); 1916 + 1917 + it("infinite recursion stack shows the repeated component", async () => { 1918 const loopComponent: ComponentRecord = { 1919 $type: "at.inlay.component", 1920 type: Loop, ··· 1941 { ...options, maxDepth: 5 }, 1942 createContext(loopComponent) 1943 ); 1944 + assertError( 1945 + output, 1946 + "Component depth limit exceeded", 1947 + [ 1948 + " at test.infinite.Loop", 1949 + " at test.infinite.Loop", 1950 + " at test.infinite.Loop", 1951 + " at test.infinite.Loop", 1952 + " at test.infinite.Loop", 1953 + " at test.infinite.Loop", 1954 + ].join("\n") 1955 + ); 1956 + }); 1957 + 1958 + it("xrpc error includes the external component in the stack", async () => { 1959 + const pageComponent: ComponentRecord = { 1960 + $type: "at.inlay.component", 1961 + type: Page, 1962 + body: { 1963 + $type: "at.inlay.component#bodyTemplate", 1964 + node: serializeTree($(Greeting, {})), 1965 + }, 1966 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1967 + }; 1968 + 1969 + const greetingComponent: ComponentRecord = { 1970 + $type: "at.inlay.component", 1971 + type: Greeting, 1972 + body: { 1973 + $type: "at.inlay.component#bodyExternal", 1974 + did: SERVICE_DID, 1975 + }, 1976 + imports: [HOST_PACK_URI], 1977 + }; 1978 + 1979 + const { options } = world({ 1980 + [`xrpc:${SERVICE_DID}:${Greeting}`]: () => { 1981 + throw new Error("service unavailable"); 1982 + }, 1983 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 1984 + ["at://did:plc:test/at.inlay.component/greet"]: greetingComponent, 1985 + ["at://did:plc:test/at.inlay.pack/app"]: { 1986 + $type: "at.inlay.pack", 1987 + name: "app", 1988 + exports: [ 1989 + { 1990 + type: Page, 1991 + component: "at://did:plc:test/at.inlay.component/page", 1992 + }, 1993 + { 1994 + type: Greeting, 1995 + component: "at://did:plc:test/at.inlay.component/greet", 1996 + }, 1997 + ], 1998 + }, 1999 + }); 2000 + 2001 + const output = await renderToCompletion( 2002 + $(Page, {}), 2003 + options, 2004 + createContext(pageComponent) 2005 + ); 2006 + assertError( 2007 + output, 2008 + "service unavailable", 2009 + [" at test.app.Greeting", " at test.app.Page"].join("\n") 2010 + ); 2011 }); 2012 2013 it("catches resolver errors", async () => { ··· 2038 errorOptions, 2039 createContext(rootComponent) 2040 ); 2041 + assertError(output, "network down", ` at ${Stack}\n at ${Root}`); 2042 }); 2043 2044 it("unused children don't trigger errors", async () => { 2045 + // Layout is a template that doesn't reference its children. 2046 // Even though the child is an external that throws, no error 2047 // occurs because the child is never rendered. 2048 const ignoreComponent: ComponentRecord = { ··· 2901 assert.deepEqual(ok, h("span", {})); 2902 2903 const bad = await renderToCompletion($(View, {}), opts, ctx); 2904 + assertError(bad, 'Input must have the property "uri"', ` at ${View}`); 2905 }); 2906 2907 it("synthesizes validation from viewRecord", async () => { ··· 2940 options, 2941 ctx 2942 ); 2943 + assertError(bad, "Input/uri must be a string", ` at ${Card}`); 2944 }); 2945 2946 it("synthesizes validation from viewPrimitive", async () => { ··· 2980 options, 2981 ctx 2982 ); 2983 + assertError(bad, "Input/value must be a string", ` at ${Timestamp}`); 2984 }); 2985 2986 it("validates collection constraints from viewRecord", async () => { ··· 3021 ); 3022 assertError( 3023 bad, 3024 + `${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`, 3025 + ` at ${Card}` 3026 ); 3027 }); 3028 });