social components
inlay-proto.up.railway.app/
atproto
components
sdui
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