social components inlay-proto.up.railway.app/
atproto components sdui
at main 179 lines 6.7 kB view raw view rendered
1# @inlay/render 2 3Server-side component resolution for Inlay element trees. Given an element like `<com.example.Greeting name="world">`, the renderer looks up the component implementation, expands it, and recurses until everything is primitives the host can render. 4 5## Install 6 7``` 8npm install @inlay/render @inlay/core 9``` 10 11## Example 12 13A minimal Hono server that accepts an element, resolves it through packs, and returns HTML. Uses Hono's JSX for safe output. 14 15```tsx 16/** @jsxImportSource hono/jsx */ 17import { Hono } from "hono"; 18import { serve } from "@hono/node-server"; 19import { $, serializeTree, deserializeTree, isValidElement } from "@inlay/core"; 20import { render } from "@inlay/render"; 21import type { RenderContext } from "@inlay/render"; 22 23// Records — in-memory stand-in for AT Protocol 24const records = { 25 // Primitives: no body. The host renders these directly. 26 "at://did:plc:host/at.inlay.component/stack": { 27 $type: "at.inlay.component", type: "org.atsui.Stack", 28 }, 29 "at://did:plc:host/at.inlay.component/text": { 30 $type: "at.inlay.component", type: "org.atsui.Text", 31 }, 32 33 // Template: a stored element tree with Binding placeholders. 34 "at://did:plc:app/at.inlay.component/greeting": { 35 $type: "at.inlay.component", 36 type: "com.example.Greeting", 37 body: { 38 $type: "at.inlay.component#bodyTemplate", 39 node: serializeTree( 40 $("org.atsui.Stack", { gap: "small" }, 41 $("org.atsui.Text", {}, "Hello, "), 42 $("org.atsui.Text", {}, $("at.inlay.Binding", { path: ["name"] })), 43 ) 44 ), 45 }, 46 imports: ["at://did:plc:host/at.inlay.pack/main"], 47 }, 48 49 // Pack: maps NSIDs → component records 50 "at://did:plc:host/at.inlay.pack/main": { 51 $type: "at.inlay.pack", name: "host", 52 exports: [ 53 { type: "org.atsui.Stack", component: "at://did:plc:host/at.inlay.component/stack" }, 54 { type: "org.atsui.Text", component: "at://did:plc:host/at.inlay.component/text" }, 55 { type: "com.example.Greeting", component: "at://did:plc:app/at.inlay.component/greeting" }, 56 ], 57 }, 58}; 59 60// Resolver — I/O layer that render() calls into 61const resolver = { 62 async fetchRecord(uri) { return records[uri] ?? null; }, 63 async xrpc(params) { throw new Error(`No handler for ${params.nsid}`); }, 64 async resolveLexicon() { return null; }, 65}; 66 67// Primitives → JSX 68const primitives = { 69 "org.atsui.Stack": async ({ props, ctx }) => { 70 const p = props as { gap?: string; children?: unknown[] }; 71 return ( 72 <div style={`display:flex;flex-direction:column;gap:${p.gap ?? 0}`}> 73 {await renderChildren(p.children, ctx)} 74 </div> 75 ); 76 }, 77 "org.atsui.Text": async ({ props, ctx }) => { 78 const p = props as { children?: unknown }; 79 return <span>{await renderNode(p.children, ctx)}</span>; 80 }, 81}; 82 83// Walk loop — resolve elements, render primitives to JSX 84async function renderNode(node, ctx) { 85 if (node == null) return <></>; 86 if (typeof node === "string") return <>{node}</>; 87 if (Array.isArray(node)) return <>{await Promise.all(node.map(n => renderNode(n, ctx)))}</>; 88 89 if (isValidElement(node)) { 90 const { resolved, node: out, context: outCtx } = await render( 91 node, ctx, { resolver } 92 ); 93 if (resolved && isValidElement(out)) { 94 const Primitive = primitives[out.type]; 95 if (!Primitive) return <>{`<!-- unknown: ${out.type} -->`}</>; 96 return Primitive({ props: out.props ?? {}, ctx: outCtx }); 97 } 98 return renderNode(out, outCtx); 99 } 100 return <>{String(node)}</>; 101} 102 103async function renderChildren(children, ctx) { 104 if (!children) return <></>; 105 const items = Array.isArray(children) ? children : [children]; 106 return <>{await Promise.all(items.map(c => renderNode(c, ctx)))}</>; 107} 108 109// Server 110const app = new Hono(); 111 112app.get("/", async (c) => { 113 const element = deserializeTree({ 114 $: "$", type: "com.example.Greeting", props: { name: "world" }, 115 }); 116 const ctx = { imports: ["at://did:plc:host/at.inlay.pack/main"] }; 117 const body = await renderNode(element, ctx); 118 return c.html(<html><body>{body}</body></html>); 119}); 120 121serve({ fetch: app.fetch, port: 3000 }); 122``` 123 124Returns: 125 126```html 127<html><body> 128 <div style="display:flex;flex-direction:column;gap:small"> 129 <span>Hello, </span> 130 <span>world</span> 131 </div> 132</body></html> 133``` 134 135The `com.example.Greeting` template expanded its `Binding` for `name`, resolved to `org.atsui.Stack` and `org.atsui.Text` primitives, and the host turned those into HTML. 136 137## In practice 138 139A production host will also want: 140 141- **Persistent global caching** keyed by component + props + response cache tags 142- **Streaming** so slow XRPC calls and record fetches don't block the page 143- **Prefetching** for links so navigation feels instant 144 145See [`proto/`](../../../proto) for a working implementation with all of the above. 146 147## How it works 148 149`render()` resolves one element at a time. It returns `resolved: true` when the element is a primitive (the host handles it) or `resolved: false` when the element expanded into a subtree that needs more passes. The walk loop drives this to completion. 150 1511. **Binding resolution**`at.inlay.Binding` elements in props are resolved against the current scope. 1522. **Type lookup** — the element's NSID is looked up across the import stack. First pack that exports the type wins. 1533. **Component rendering** — depends on the component's body: 154 - **No body** — a primitive; returned as-is. 155 - **Template** — element tree expanded with prop bindings. 156 - **External** — XRPC call to the component's service. Children are passed as slots and restored from the response. 1574. **Error handling** — errors become `at.inlay.Throw` elements. `MissingError` propagates for `at.inlay.Maybe` to catch. 158 159## API 160 161| Function | Description | 162|----------|-------------| 163| `createContext(component, componentUri?)` | Create a `RenderContext` from a component record | 164| `render(element, context, options)` | Render a single element. Returns `Promise<RenderResult>` | 165| `resolvePath(obj, path)` | Resolve a dotted path array against an object | 166| `MissingError` | Thrown when a binding path can't be resolved | 167 168**Render options:** 169- `resolver: Resolver` — I/O callbacks (required) 170- `maxDepth?: number` — nesting limit (default 30) 171 172### Types 173 174- **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }` 175- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }` 176- **`RenderResult`** — `{ resolved, node, context, cache? }` 177- **`RenderOptions`** — `{ resolver, maxDepth? }` 178- **`ComponentRecord`** — re-exported from generated lexicon defs 179- **`CachePolicy`** — re-exported from generated lexicon defs