···11+# Inlay
22+33+Inlay lets you build social UIs from components that live on the AT Protocol.
44+55+For example, consider this tree of Inlay components:
66+77+```jsx
88+<com.pfrazee.ProfileCard uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self">
99+ <mov.danabra.FollowCount uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd" />
1010+ <org.atsui.Stack gap="small">
1111+ <org.atsui.Avatar src={profile.avatar} />
1212+ <org.atsui.Text>{profile.displayName}</org.atsui.Text>
1313+ </org.atsui.Stack>
1414+</com.pfrazee.ProfileCard>
1515+```
1616+1717+A Inlay component like `ProfileCard` or `FollowCount` is just a name ([NSID](https://atproto.com/specs/nsid)) — anyone can publish an implementation for it, and a *host* (i.e. a browser for Inlay) picks which implementation to use, potentially taking both developer and user preferences into account.
1818+1919+Here, three different authors' components work together:
2020+2121+- `ProfileCard` (`com.pfrazee.ProfileCard`) might be a template stored as a record
2222+- `FollowCount` (`mov.danabra.FollowCount`) might be an XRPC procedure on someone's server
2323+- `Stack`, `Avatar`, and `Text` (`org.atsui.*`) are primitives that the host renders directly. The host resolves each one until everything bottoms out in primitives, then turns those into HTML, React, SwiftUI, or whatever it wants.
2424+2525+**Demo:** https://inlay-proto.up.railway.app/ ([source](proto/))
2626+2727+## Packages
2828+2929+| Package | Description |
3030+|---------|-------------|
3131+| [`@inlay/core`](packages/@inlay/core) | Element trees, JSX runtime, and serialization |
3232+| [`@inlay/render`](packages/@inlay/render) | Server-side component resolution |
3333+| [`@inlay/cache`](packages/@inlay/cache) | Cache policy for XRPC component handlers |
3434+3535+## How it works
3636+3737+Each component is an [`at.inlay.component`](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.component#schema) record with one of three body kinds:
3838+3939+- **No body** — a primitive. The host renders it directly.
4040+- **Template** — a stored element tree with Binding placeholders that get filled in with props.
4141+- **External** — an XRPC endpoint that receives props and returns an element tree.
4242+4343+Each component also imports a list of [packs](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.pack#schema). A pack maps NSIDs to component URIs — it's how the host knows which implementation to use.
4444+4545+[Atsui (`org.atsui`)](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema) is the first design system for Inlay — including `<Text>`, `<Avatar>`, `<List>`, and others. In a sense, it's like Inlay's HTML. Atsui is not a traditional component library because it solely defines *lexicons* (i.e. interfaces). Each host (an Inlay browser like [this demo](https://inlay-proto.up.railway.app/)) may choose which components are built-in, and how they work. A host could even decide to not implement Atsui, and instead to implement another set of primitives.
4646+4747+A host chooses its own tech stack. For example, you could write a host [with Hono and htmx](./proto), or with React Server Components, or even with SwiftUI and a custom server-side JSON endpoint. The resolution algorithm is shared, but each host may interpret the final tree as it wishes.
4848+4949+## Declaring components
5050+5151+There is no visual editor yet. Components are created by writing records to your PDS.
5252+5353+### Pick an NSID
5454+5555+An NSID describes *what* a component does, not *how*. If someone already defined one that fits (e.g. `com.pfrazee.BskyPost` with a `uri` prop), you can implement it yourself. Multiple implementations of the same NSID can coexist — packs control which one gets used.
5656+5757+If you're creating a new NSID, pick one under a domain you control so you can [publish a Lexicon](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) for it later.
5858+5959+### Component record
6060+6161+This is the record you write to your PDS.
6262+6363+#### Template
6464+6565+The element tree lives inside the record. Bindings are placeholders that get filled in with props at render time.
6666+6767+```json
6868+{
6969+ "$type": "at.inlay.component",
7070+ "type": "mov.danabra.Greeting",
7171+ "body": {
7272+ "$type": "at.inlay.component#bodyTemplate",
7373+ "node": {
7474+ "$": "$", "type": "org.atsui.Stack", "props": {
7575+ "gap": "small",
7676+ "children": [
7777+ {
7878+ "$": "$",
7979+ "type": "org.atsui.Text",
8080+ "props": { "children": ["Hello, "] },
8181+ "key": "0"
8282+ },
8383+ {
8484+ "$": "$",
8585+ "type": "org.atsui.Text",
8686+ "props": { "children": [ { "$": "$", "type": "at.inlay.Binding", "props": { "path": ["name"] } } ] },
8787+ "key": "1"
8888+ }
8989+ ]
9090+ }
9191+ }
9292+ },
9393+ "imports": [
9494+ "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
9595+ "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
9696+ ]
9797+}
9898+```
9999+100100+The `imports` array lists the packs your component needs — typically the [Atsui pack](https://pdsls.dev/at/did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m) so you can use [`org.atsui.*` primitives](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema).
101101+102102+When [rendered](packages/@inlay/render) as `<mov.danabra.Greeting name="world" />`, this component resolves to:
103103+104104+```jsx
105105+<org.atsui.Stack gap="small">
106106+ <org.atsui.Text>Hello</org.atsui.Text>
107107+ <org.atsui.Text>world</org.atsui.Text>
108108+</org.atsui.Stack>
109109+```
110110+111111+#### External
112112+113113+As an alternative to templates (which are very limited), you can declare components as [XRPC](https://atproto.com/guides/glossary#xrpc) server endpoints. Then the Inlay host will hit your endpoint to resolve the component tree.
114114+115115+Here's a [real external component record](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6) for `mov.danabra.Greeting`. Notice its `body` points to a service `did` instead of an inline `node`:
116116+117117+```json
118118+{
119119+ "$type": "at.inlay.component",
120120+ "type": "mov.danabra.Greeting",
121121+ "body": {
122122+ "$type": "at.inlay.component#bodyExternal",
123123+ "did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run"
124124+ },
125125+ "imports": [
126126+ "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
127127+ "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
128128+ ]
129129+}
130130+```
131131+132132+The `did` is a `did:web`, so the host resolves it by fetching [`/.well-known/did.json`](https://gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run/.well-known/did.json) to find the service endpoint, then POSTs props to `/xrpc/mov.danabra.Greeting` and gets back `{ node, cache }`.
133133+134134+This particular handler runs on [Val Town](https://www.val.town/x/gaearon/greeting-jsx/code/main.tsx):
135135+136136+```tsx
137137+/* @jsxImportSource npm:@inlay/core@0.0.13 */
138138+import { component, serve } from "https://esm.town/v/gaearon/inlay/main.ts";
139139+import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts";
140140+141141+component("mov.danabra.Greeting", ({ name }) => (
142142+ <Stack gap="small">
143143+ <Text>Hello,</Text>
144144+ <Text>{name || "stranger"}</Text>
145145+ </Stack>
146146+));
147147+148148+export default serve;
149149+```
150150+151151+It's recommended to tag XRPC return values as cacheable; see [`@inlay/cache`](packages/@inlay/cache) for how to do this. In the calling code, it's recommended to wrap XRPC components into `<at.inlay.Placeholder fallback=...>` so that they don't block the entire page.
152152+153153+### Pack record
154154+155155+To let other components use yours, add it to a pack. Here's the [`danabra.mov/ui` pack](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.pack/3meflbzhr7c2f):
156156+157157+```json
158158+{
159159+ "$type": "at.inlay.pack",
160160+ "name": "ui",
161161+ "exports": [
162162+ { "type": "mov.danabra.Greeting", "component": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6" },
163163+ ...
164164+ ]
165165+}
166166+```
167167+168168+Other components import this pack URI in their `imports` array to get access to `mov.danabra.Greeting`.
169169+170170+### Lexicon (optional)
171171+172172+A [Lexicon](https://atproto.com/specs/lexicon) defines the prop schema for an NSID. You don't need one to get started, but publishing one enables type checking, validation, and codegen with [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex):
173173+174174+```json
175175+{
176176+ "lexicon": 1,
177177+ "id": "mov.danabra.Greeting",
178178+ "defs": {
179179+ "main": {
180180+ "type": "procedure",
181181+ "input": {
182182+ "encoding": "application/json",
183183+ "schema": {
184184+ "type": "object",
185185+ "required": ["name"],
186186+ "properties": {
187187+ "name": { "type": "string", "maxLength": 100 }
188188+ }
189189+ }
190190+ },
191191+ "output": {
192192+ "encoding": "application/json",
193193+ "schema": { "type": "ref", "ref": "at.inlay.defs#response" }
194194+ }
195195+ }
196196+ }
197197+}
198198+```
199199+200200+See [`@inlay/core`](packages/@inlay/core) for JSX support and more ways to build element trees.
201201+202202+## Rendering as a host
203203+204204+A host walks an element tree, resolves each component through its packs, and maps the resulting primitives to output (HTML, React, etc).
205205+206206+[`@inlay/render`](packages/@inlay/render) does the resolution step — call `render()` on an element, get back the expanded tree or a primitive. The host calls it in a loop until everything is resolved. The [render README](packages/@inlay/render) has a minimal working example that turns Inlay elements into HTML.
207207+208208+If you're writing XRPC component handlers, [`@inlay/cache`](packages/@inlay/cache) lets you declare cache lifetime and invalidation tags (`cacheLife()`, `cacheTagRecord()`, `cacheTagLink()`). The host uses these to know when to re-render.
209209+210210+[`proto/`](proto/) is a simple but complete host with streaming, caching, and prefetching.
211211+212212+## Development
213213+214214+```bash
215215+npm install
216216+npm run build # generate lexicons + build all packages
217217+npm run typecheck # type-check everything
218218+npm test # run tests
219219+```
220220+221221+## Project structure
222222+223223+```
224224+packages/@inlay/core/ — element primitives and JSX
225225+packages/@inlay/render/ — rendering engine
226226+packages/@inlay/cache/ — cache declarations
227227+proto/ — prototype host server (Hono)
228228+invalidator/ — cache invalidation service
229229+lexicons/ — AT Protocol lexicon definitions
230230+generated/ — generated TypeScript from lexicons
231231+```
232232+233233+## License
234234+235235+MIT
+176
packages/@inlay/cache/README.md
···11+# @inlay/cache
22+33+Every Inlay XRPC component response may include a [`CachePolicy`](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.defs#schema:cachePolicy) — a lifetime and a set of invalidation tags:
44+55+```json
66+{
77+ "life": "hours",
88+ "tags": [
99+ // Invalidate me when this record changes
1010+ { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:abc/app.bsky.actor.profile/self" }
1111+ ]
1212+}
1313+```
1414+1515+This package lets you build that policy declaratively. Instead of constructing the object by hand, call `cacheLife` and `cacheTagRecord` anywhere during your handler — including inside async helper functions.
1616+1717+A server runtime can collect these calls and produce the cache policy object.
1818+1919+## Install
2020+2121+```
2222+npm install @inlay/cache
2323+```
2424+2525+## Usage
2626+2727+```ts
2828+import { $ } from "@inlay/core";
2929+import { cacheLife, cacheTagRecord, cacheTagLink } from "@inlay/cache";
3030+3131+async function fetchRecord(uri) {
3232+ cacheTagRecord(uri); // Invalidate me when this record changes
3333+ cacheLife("max");
3434+ const [, , repo, collection, rkey] = uri.split("/");
3535+ const params = new URLSearchParams({ repo, collection, rkey });
3636+ const res = await fetch(
3737+ `https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord?${params}`
3838+ );
3939+ return (await res.json()).value;
4040+}
4141+4242+async function fetchProfileStats(did) {
4343+ cacheLife("hours");
4444+ cacheTagLink(`at://${did}`, "app.bsky.graph.follow"); // Invalidate me on backlinks
4545+ const params = new URLSearchParams({ actor: did });
4646+ const res = await fetch(
4747+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}`
4848+ );
4949+ const data = await res.json();
5050+ return { followersCount: data.followersCount };
5151+}
5252+5353+async function ProfileCard({ uri }) {
5454+ const profile = await fetchRecord(uri);
5555+ const did = uri.split("/")[2];
5656+ const stats = await fetchProfileStats(did);
5757+5858+ return $("org.atsui.Stack", { gap: "small" },
5959+ $("org.atsui.Avatar", { src: profile.avatar, did }),
6060+ $("org.atsui.Text", {}, profile.displayName),
6161+ $("org.atsui.Text", {}, `${stats.followersCount} followers`),
6262+ );
6363+}
6464+```
6565+6666+A server runtime could then provide a way to run `ProfileCard` *and* collect its cache policy:
6767+6868+```json
6969+{
7070+ "node": {
7171+ "$": "$",
7272+ "type": "org.atsui.Stack",
7373+ "props": {
7474+ "gap": "small",
7575+ "children": [
7676+ { "$": "$", "type": "org.atsui.Avatar", "props": { "src": "...", "did": "did:plc:ragtjsm2j2vknwkz3zp4oxrd" }, "key": "0" },
7777+ { "$": "$", "type": "org.atsui.Text", "props": { "children": ["Paul Frazee"] }, "key": "1" },
7878+ { "$": "$", "type": "org.atsui.Text", "props": { "children": ["308032 followers"] }, "key": "2" }
7979+ ]
8080+ }
8181+ },
8282+ "cache": {
8383+ "life": "hours",
8484+ "tags": [
8585+ { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self" },
8686+ { "$type": "at.inlay.defs#tagLink", "subject": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd", "from": "app.bsky.graph.follow" }
8787+ ]
8888+ }
8989+}
9090+```
9191+9292+## Server
9393+9494+In practice, you'll use these functions through a server SDK that handles accumulation for you. For example, the Inlay [Val Town SDK](https://www.val.town/x/gaearon/inlay/code/main.ts) re-exports them and automatically includes the resulting cache policy in each XRPC response:
9595+9696+```tsx
9797+import { component, serve, cacheLife, cacheTagRecord, cacheTagLink } from "https://esm.town/v/gaearon/inlay/main.ts";
9898+9999+async function fetchRecord(uri) {
100100+ cacheTagRecord(uri);
101101+ cacheLife("max");
102102+ // ... fetch and return record
103103+}
104104+105105+async function fetchProfileStats(did) {
106106+ cacheLife("hours");
107107+ cacheTagLink(`at://${did}`, "app.bsky.graph.follow");
108108+ // ... fetch and return stats
109109+}
110110+111111+component("mov.danabra.ProfileCard", async ({ uri }) => {
112112+ const profile = await fetchRecord(uri);
113113+ const stats = await fetchProfileStats(uri.split("/")[2]);
114114+ // ... return element tree
115115+});
116116+117117+export default serve;
118118+```
119119+120120+See below for how it works under the hood. If you're writing your own server, you can either use the same approach (explained below) or you can just explicitly return `{ node, cache }` without using any of these accumulating helpers.
121121+122122+### How it works
123123+124124+Cache functions write to a `Dispatcher` installed on `Symbol.for("inlay.cache")`. The SDK (or your own server runtime) would provide the dispatcher so you can collect the calls to `cacheLife` and `cacheTag*` functions from `@inlay/cache`. Here's a minimal implementation:
125125+126126+```ts
127127+import { AsyncLocalStorage } from "node:async_hooks";
128128+import { serializeTree } from "@inlay/core";
129129+import type { Dispatcher } from "@inlay/cache";
130130+131131+const LIFE_ORDER = ["seconds", "minutes", "hours", "max"];
132132+const cacheStore = new AsyncLocalStorage();
133133+134134+// Install the dispatcher — cache functions will write here
135135+globalThis[Symbol.for("inlay.cache")] = {
136136+ cacheLife(life) { cacheStore.getStore().lives.push(life); },
137137+ cacheTag(tag) { cacheStore.getStore().tags.push(tag); },
138138+} satisfies Dispatcher;
139139+140140+// Run a handler and collect its cache policy
141141+async function runHandler(handler, props) {
142142+ const state = { lives: [], tags: [] };
143143+ const node = await cacheStore.run(state, () => handler(props));
144144+ const life = state.lives.reduce((a, b) =>
145145+ LIFE_ORDER.indexOf(a) < LIFE_ORDER.indexOf(b) ? a : b
146146+ );
147147+ return {
148148+ node: serializeTree(node),
149149+ cache: { life, tags: state.tags },
150150+ };
151151+}
152152+153153+const result = await runHandler(ProfileCard, {
154154+ uri: "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self",
155155+});
156156+console.log(JSON.stringify(result, null, 2));
157157+// => { node: { ... }, cache: { life: "hours", tags: [...] } }
158158+```
159159+160160+Installation happens via a global so that coordination doesn't depend on package versioning or hoisting working correctly. Helpers like `fetchRecord` and `fetchProfileStats` can be moved into libraries.
161161+162162+## API
163163+164164+| Function | Description |
165165+|----------|-------------|
166166+| `cacheLife(life)` | Set cache duration. Strictest (shortest) call wins. Values: `"seconds"`, `"minutes"`, `"hours"`, `"max"` |
167167+| `cacheTagRecord(uri)` | Invalidate when this AT Protocol record is created, updated, or deleted |
168168+| `cacheTagLink(subject, from?)` | Invalidate when any record linking to `subject` changes. Optionally restrict to a specific collection |
169169+170170+### Types
171171+172172+- **`Life`** — `"seconds" | "minutes" | "hours" | "max"`
173173+- **`CacheTag`** — `TagRecord | TagLink`
174174+- **`TagRecord`** — `{ $type: "at.inlay.defs#tagRecord", uri: string }`
175175+- **`TagLink`** — `{ $type: "at.inlay.defs#tagLink", subject: string, from?: string }`
176176+- **`Dispatcher`** — interface for the server runtime to implement
+193
packages/@inlay/core/README.md
···11+# @inlay/core
22+33+Build element trees for Inlay, a UI component system for the AT Protocol.
44+55+## Install
66+77+```
88+npm install @inlay/core
99+```
1010+1111+## Creating elements
1212+1313+The `$` function creates elements. Pass a string [NSID](https://atproto.com/specs/nsid) as the type:
1414+1515+```ts
1616+import { $ } from "@inlay/core";
1717+1818+const el = $("org.atsui.Stack", { gap: "small" },
1919+ $("org.atsui.Text", {}, "Hello"),
2020+ $("org.atsui.Text", {}, "world")
2121+);
2222+```
2323+2424+You can then turn this tree into JSON with `serializeTree(el)`:
2525+2626+```json
2727+{
2828+ "$": "$",
2929+ "type": "org.atsui.Stack",
3030+ "props": {
3131+ "gap": "small",
3232+ "children": [
3333+ {
3434+ "$": "$",
3535+ "type": "org.atsui.Text",
3636+ "props": { "children": ["Hello"] },
3737+ "key": "0"
3838+ },
3939+ {
4040+ "$": "$",
4141+ "type": "org.atsui.Text",
4242+ "props": { "children": ["world"] },
4343+ "key": "1"
4444+ }
4545+ ]
4646+ }
4747+}
4848+```
4949+5050+If the components you use are [published lexicons](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution), you can use the typed `$` overload:
5151+5252+```ts
5353+import { $ } from "@inlay/core";
5454+import { Stack, Text } from "./generated/org/atsui";
5555+5656+$(Stack, { gap: "small" },
5757+ $(Text, {}, "Hello"),
5858+ $(Text, {}, "world")
5959+);
6060+```
6161+6262+Then their props will be checked.
6363+6464+## JSX
6565+6666+Set `@inlay/core` as the JSX import source in your tsconfig:
6767+6868+```json
6969+{
7070+ "compilerOptions": {
7171+ "jsx": "react-jsx",
7272+ "jsxImportSource": "@inlay/core"
7373+ }
7474+}
7575+```
7676+7777+Then use Lexicon modules as tags:
7878+7979+```tsx
8080+import { jslex } from "@inlay/core";
8181+import * as atsui from "./generated/org/atsui";
8282+const { Stack, Text } = jslex(atsui);
8383+8484+<Stack gap="medium">
8585+ <Text>Hello world</Text>
8686+</Stack>
8787+```
8888+8989+## HTTP imports
9090+9191+In environments that support URL imports (Val Town, Deno), you can import any published Lexicons like this:
9292+9393+```tsx
9494+/* @jsxImportSource npm:@inlay/core */
9595+import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts";
9696+9797+<Stack gap="medium">
9898+ <Text>Hello world</Text>
9999+</Stack>
100100+```
101101+102102+## Lexicons
103103+104104+Components are defined by Lexicons — JSON schemas that declare props. For example, `org.atsui.Avatar`:
105105+106106+```json
107107+{
108108+ "lexicon": 1,
109109+ "id": "org.atsui.Avatar",
110110+ "defs": {
111111+ "main": {
112112+ "type": "procedure",
113113+ "description": "Circular profile image at a fixed size.",
114114+ "input": {
115115+ "encoding": "application/json",
116116+ "schema": {
117117+ "type": "object",
118118+ "required": ["src"],
119119+ "properties": {
120120+ "src": {
121121+ "type": "unknown",
122122+ "description": "Blob ref for the image."
123123+ },
124124+ "did": {
125125+ "type": "string",
126126+ "format": "did",
127127+ "description": "DID of the blob owner. Used to resolve blob URLs."
128128+ },
129129+ "size": {
130130+ "type": "string",
131131+ "maxLength": 32,
132132+ "description": "Size token.",
133133+ "knownValues": ["xsmall", "small", "medium", "large"],
134134+ "default": "medium"
135135+ }
136136+ }
137137+ }
138138+ },
139139+ "output": {
140140+ "encoding": "application/json",
141141+ "schema": {
142142+ "type": "ref",
143143+ "ref": "at.inlay.defs#response"
144144+ }
145145+ }
146146+ }
147147+ }
148148+}
149149+```
150150+151151+Use `@atproto/lex` to generate TypeScript from Lexicon files. For published lexicons, install from the network:
152152+153153+```bash
154154+npm install @atproto/lex
155155+npx lex install org.atsui.Stack org.atsui.Text
156156+npx lex build --out generated
157157+```
158158+159159+For your own lexicons, put `.json` files in a `lexicons/` directory (e.g. `lexicons/com/example/MyComponent.json`) and point `lex build` at them:
160160+161161+```bash
162162+npx lex build --lexicons lexicons --out generated
163163+```
164164+165165+## Serialization
166166+167167+Convert element trees to/from JSON-safe representations:
168168+169169+```ts
170170+import { serializeTree, deserializeTree } from "@inlay/core";
171171+172172+const json = serializeTree(elementTree);
173173+const restored = deserializeTree(json);
174174+```
175175+176176+## API
177177+178178+| Function | Description |
179179+|----------|-------------|
180180+| `$(type, props?, ...children)` | Create an element from a Lexicon module or NSID string |
181181+| `jslex(namespace)` | Cast Lexicon modules for use as JSX components (no-op at runtime) |
182182+| `isValidElement(value)` | Type guard for `Element` |
183183+| `serializeTree(tree, visitor?)` | Convert element tree to JSON-serializable form |
184184+| `deserializeTree(tree, visitor?)` | Inverse of `serializeTree` |
185185+| `walkTree(tree, mapObject)` | Recursive tree walker for plain objects |
186186+| `resolveBindings(tree, resolve)` | Replace `at.inlay.Binding` elements using a resolver callback |
187187+188188+### Types
189189+190190+- **`Element`** — `{ type: string, key?: string, props?: Record<string, unknown> }`
191191+- **`LexiconComponent`** — Object with a `$nsid` field
192192+- **`Component<M>`** — A Lexicon module usable as a JSX component
193193+- **`ElementVisitor`** — `(element: Element) => unknown`
+180
packages/@inlay/render/README.md
···11+# @inlay/render
22+33+Server-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.
44+55+## Install
66+77+```
88+npm install @inlay/render @inlay/core
99+```
1010+1111+## Example
1212+1313+A minimal Hono server that accepts an element, resolves it through packs, and returns HTML. Uses Hono's JSX for safe output.
1414+1515+```tsx
1616+/** @jsxImportSource hono/jsx */
1717+import { Hono } from "hono";
1818+import { serve } from "@hono/node-server";
1919+import { $, serializeTree, deserializeTree, isValidElement } from "@inlay/core";
2020+import { render } from "@inlay/render";
2121+import type { RenderContext } from "@inlay/render";
2222+2323+// Records — in-memory stand-in for AT Protocol
2424+const records = {
2525+ // Primitives: no body. The host renders these directly.
2626+ "at://did:plc:host/at.inlay.component/stack": {
2727+ $type: "at.inlay.component", type: "org.atsui.Stack",
2828+ },
2929+ "at://did:plc:host/at.inlay.component/text": {
3030+ $type: "at.inlay.component", type: "org.atsui.Text",
3131+ },
3232+3333+ // Template: a stored element tree with Binding placeholders.
3434+ "at://did:plc:app/at.inlay.component/greeting": {
3535+ $type: "at.inlay.component",
3636+ type: "com.example.Greeting",
3737+ body: {
3838+ $type: "at.inlay.component#bodyTemplate",
3939+ node: serializeTree(
4040+ $("org.atsui.Stack", { gap: "small" },
4141+ $("org.atsui.Text", {}, "Hello, "),
4242+ $("org.atsui.Text", {}, $("at.inlay.Binding", { path: ["name"] })),
4343+ )
4444+ ),
4545+ },
4646+ imports: ["at://did:plc:host/at.inlay.pack/main"],
4747+ },
4848+4949+ // Pack: maps NSIDs → component records
5050+ "at://did:plc:host/at.inlay.pack/main": {
5151+ $type: "at.inlay.pack", name: "host",
5252+ exports: [
5353+ { type: "org.atsui.Stack", component: "at://did:plc:host/at.inlay.component/stack" },
5454+ { type: "org.atsui.Text", component: "at://did:plc:host/at.inlay.component/text" },
5555+ { type: "com.example.Greeting", component: "at://did:plc:app/at.inlay.component/greeting" },
5656+ ],
5757+ },
5858+};
5959+6060+// Resolver — I/O layer that render() calls into
6161+const resolver = {
6262+ async fetchRecord(uri) { return records[uri] ?? null; },
6363+ async xrpc(params) { throw new Error(`No handler for ${params.nsid}`); },
6464+ async resolveLexicon() { return null; },
6565+};
6666+6767+// Primitives → JSX
6868+const primitives = {
6969+ "org.atsui.Stack": async ({ props, ctx }) => {
7070+ const p = props as { gap?: string; children?: unknown[] };
7171+ return (
7272+ <div style={`display:flex;flex-direction:column;gap:${p.gap ?? 0}`}>
7373+ {await renderChildren(p.children, ctx)}
7474+ </div>
7575+ );
7676+ },
7777+ "org.atsui.Text": async ({ props, ctx }) => {
7878+ const p = props as { children?: unknown };
7979+ return <span>{await renderNode(p.children, ctx)}</span>;
8080+ },
8181+};
8282+8383+// Walk loop — resolve elements, render primitives to JSX
8484+async function renderNode(node, ctx) {
8585+ if (node == null) return <></>;
8686+ if (typeof node === "string") return <>{node}</>;
8787+ if (Array.isArray(node)) return <>{await Promise.all(node.map(n => renderNode(n, ctx)))}</>;
8888+8989+ if (isValidElement(node)) {
9090+ const { resolved, node: out, context: outCtx } = await render(
9191+ node, ctx, { resolver, validate: false }
9292+ );
9393+ if (resolved && isValidElement(out)) {
9494+ const Primitive = primitives[out.type];
9595+ if (!Primitive) return <>{`<!-- unknown: ${out.type} -->`}</>;
9696+ return Primitive({ props: out.props ?? {}, ctx: outCtx });
9797+ }
9898+ return renderNode(out, outCtx);
9999+ }
100100+ return <>{String(node)}</>;
101101+}
102102+103103+async function renderChildren(children, ctx) {
104104+ if (!children) return <></>;
105105+ const items = Array.isArray(children) ? children : [children];
106106+ return <>{await Promise.all(items.map(c => renderNode(c, ctx)))}</>;
107107+}
108108+109109+// Server
110110+const app = new Hono();
111111+112112+app.get("/", async (c) => {
113113+ const element = deserializeTree({
114114+ $: "$", type: "com.example.Greeting", props: { name: "world" },
115115+ });
116116+ const ctx = { imports: ["at://did:plc:host/at.inlay.pack/main"] };
117117+ const body = await renderNode(element, ctx);
118118+ return c.html(<html><body>{body}</body></html>);
119119+});
120120+121121+serve({ fetch: app.fetch, port: 3000 });
122122+```
123123+124124+Returns:
125125+126126+```html
127127+<html><body>
128128+ <div style="display:flex;flex-direction:column;gap:small">
129129+ <span>Hello, </span>
130130+ <span>world</span>
131131+ </div>
132132+</body></html>
133133+```
134134+135135+The `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.
136136+137137+## In practice
138138+139139+A production host will also want:
140140+141141+- **Persistent global caching** keyed by component + props + response cache tags
142142+- **Streaming** so slow XRPC calls and record fetches don't block the page
143143+- **Prefetching** for links so navigation feels instant
144144+145145+See [`proto/`](../../../proto) for a working implementation with all of the above.
146146+147147+## How it works
148148+149149+`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.
150150+151151+1. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope.
152152+2. **Type lookup** — the element's NSID is looked up across the import stack. First pack that exports the type wins.
153153+3. **Component rendering** — depends on the component's body:
154154+ - **No body** — a primitive; returned as-is.
155155+ - **Template** — element tree expanded with prop bindings.
156156+ - **External** — XRPC call to the component's service. Children are passed as slots and restored from the response.
157157+4. **Error handling** — errors become `at.inlay.Throw` elements. `MissingError` propagates for `at.inlay.Maybe` to catch.
158158+159159+## API
160160+161161+| Function | Description |
162162+|----------|-------------|
163163+| `createContext(component, componentUri?)` | Create a `RenderContext` from a component record |
164164+| `render(element, context, options)` | Render a single element. Returns `Promise<RenderResult>` |
165165+| `resolvePath(obj, path)` | Resolve a dotted path array against an object |
166166+| `MissingError` | Thrown when a binding path can't be resolved |
167167+168168+**Render options:**
169169+- `resolver: Resolver` — I/O callbacks (required)
170170+- `maxDepth?: number` — nesting limit (default 30)
171171+- `validate?: boolean` — prop validation (default true)
172172+173173+### Types
174174+175175+- **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }`
176176+- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope? }`
177177+- **`RenderResult`** — `{ resolved, node, context, cache? }`
178178+- **`RenderOptions`** — `{ resolver, maxDepth?, validate? }`
179179+- **`ComponentRecord`** — re-exported from generated lexicon defs
180180+- **`CachePolicy`** — re-exported from generated lexicon defs
+38
proto/README.md
···11+# proto
22+33+A prototype Inlay host built with [Hono](https://hono.dev/) and [htmx](https://htmx.org/). It resolves Inlay component trees into HTML and streams the result to the browser.
44+55+**Live demo:** https://inlay-proto.up.railway.app/
66+77+## Running
88+99+```bash
1010+npm install # from the repo root
1111+npm run dev # starts on http://localhost:3001
1212+```
1313+1414+Expects a Redis instance for caching. Set `REDIS_URL` in `.env` or `.env.local` at the repo root (defaults to `redis://localhost:6379`). Records and DID resolution go through [Slingshot](https://slingshot.microcosm.blue).
1515+1616+## How it works
1717+1818+The main route is `/at/:did/:collection/:rkey?componentUri=...`. It fetches the component record, creates an element (`$(componentType, { uri })`), and calls [`@inlay/render`](../packages/@inlay/render) in a loop until everything bottoms out in primitives. Each primitive maps to a custom HTML element (`org-atsui-stack`, `org-atsui-text`, etc.) styled by two CSS files.
1919+2020+The response is streamed with Hono's `renderToReadableStream`. Slow XRPC calls don't block the whole page — `at.inlay.Placeholder` components use `<Suspense>` to show a fallback while the rest of the page streams.
2121+2222+## Primitives
2323+2424+This host implements the [Atsui](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema) primitives. Each primitive is a function in `src/primitives.tsx` that takes `{ ctx, props }` and returns Hono JSX. A `componentMap` at the bottom registers them by NSID.
2525+2626+## Caching
2727+2828+Records and XRPC responses are cached in Redis. Cache keys are tagged using the same scheme as `@inlay/cache` — `tagRecord` for AT URIs, `tagLink` for backlink patterns. The existing [invalidator](../invalidator/) (a Jetstream firehose listener) can flush entries when records change.
2929+3030+TTLs follow the `life` values from component responses: `seconds` (30s), `minutes` (5m), `hours` (1h), `max` (24h).
3131+3232+## Infinite scroll
3333+3434+`org.atsui.List` renders the first page server-side, then uses htmx to load more pages as the user scrolls. The `/htmx/list` endpoint handles pagination — it takes a cursor, fetches the next page from the component's XRPC query, renders the items, and returns an HTML fragment with a new sentinel `div` for the next page.
3535+3636+## Link prefetching
3737+3838+An `IntersectionObserver` watches for `<a>` tags pointing to `/at/...` routes. When one scrolls into view, it injects a `<link rel="prefetch">` so the browser fetches the page before the user clicks.