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

readmes

+822
+235
README.md
··· 1 + # Inlay 2 + 3 + Inlay lets you build social UIs from components that live on the AT Protocol. 4 + 5 + For example, consider this tree of Inlay components: 6 + 7 + ```jsx 8 + <com.pfrazee.ProfileCard uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self"> 9 + <mov.danabra.FollowCount uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd" /> 10 + <org.atsui.Stack gap="small"> 11 + <org.atsui.Avatar src={profile.avatar} /> 12 + <org.atsui.Text>{profile.displayName}</org.atsui.Text> 13 + </org.atsui.Stack> 14 + </com.pfrazee.ProfileCard> 15 + ``` 16 + 17 + 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. 18 + 19 + Here, three different authors' components work together: 20 + 21 + - `ProfileCard` (`com.pfrazee.ProfileCard`) might be a template stored as a record 22 + - `FollowCount` (`mov.danabra.FollowCount`) might be an XRPC procedure on someone's server 23 + - `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. 24 + 25 + **Demo:** https://inlay-proto.up.railway.app/ ([source](proto/)) 26 + 27 + ## Packages 28 + 29 + | Package | Description | 30 + |---------|-------------| 31 + | [`@inlay/core`](packages/@inlay/core) | Element trees, JSX runtime, and serialization | 32 + | [`@inlay/render`](packages/@inlay/render) | Server-side component resolution | 33 + | [`@inlay/cache`](packages/@inlay/cache) | Cache policy for XRPC component handlers | 34 + 35 + ## How it works 36 + 37 + 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: 38 + 39 + - **No body** — a primitive. The host renders it directly. 40 + - **Template** — a stored element tree with Binding placeholders that get filled in with props. 41 + - **External** — an XRPC endpoint that receives props and returns an element tree. 42 + 43 + 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. 44 + 45 + [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. 46 + 47 + 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. 48 + 49 + ## Declaring components 50 + 51 + There is no visual editor yet. Components are created by writing records to your PDS. 52 + 53 + ### Pick an NSID 54 + 55 + 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. 56 + 57 + 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. 58 + 59 + ### Component record 60 + 61 + This is the record you write to your PDS. 62 + 63 + #### Template 64 + 65 + The element tree lives inside the record. Bindings are placeholders that get filled in with props at render time. 66 + 67 + ```json 68 + { 69 + "$type": "at.inlay.component", 70 + "type": "mov.danabra.Greeting", 71 + "body": { 72 + "$type": "at.inlay.component#bodyTemplate", 73 + "node": { 74 + "$": "$", "type": "org.atsui.Stack", "props": { 75 + "gap": "small", 76 + "children": [ 77 + { 78 + "$": "$", 79 + "type": "org.atsui.Text", 80 + "props": { "children": ["Hello, "] }, 81 + "key": "0" 82 + }, 83 + { 84 + "$": "$", 85 + "type": "org.atsui.Text", 86 + "props": { "children": [ { "$": "$", "type": "at.inlay.Binding", "props": { "path": ["name"] } } ] }, 87 + "key": "1" 88 + } 89 + ] 90 + } 91 + } 92 + }, 93 + "imports": [ 94 + "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25", 95 + "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m" 96 + ] 97 + } 98 + ``` 99 + 100 + 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). 101 + 102 + When [rendered](packages/@inlay/render) as `<mov.danabra.Greeting name="world" />`, this component resolves to: 103 + 104 + ```jsx 105 + <org.atsui.Stack gap="small"> 106 + <org.atsui.Text>Hello</org.atsui.Text> 107 + <org.atsui.Text>world</org.atsui.Text> 108 + </org.atsui.Stack> 109 + ``` 110 + 111 + #### External 112 + 113 + 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. 114 + 115 + 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`: 116 + 117 + ```json 118 + { 119 + "$type": "at.inlay.component", 120 + "type": "mov.danabra.Greeting", 121 + "body": { 122 + "$type": "at.inlay.component#bodyExternal", 123 + "did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run" 124 + }, 125 + "imports": [ 126 + "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25", 127 + "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m" 128 + ] 129 + } 130 + ``` 131 + 132 + 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 }`. 133 + 134 + This particular handler runs on [Val Town](https://www.val.town/x/gaearon/greeting-jsx/code/main.tsx): 135 + 136 + ```tsx 137 + /* @jsxImportSource npm:@inlay/core@0.0.13 */ 138 + import { component, serve } from "https://esm.town/v/gaearon/inlay/main.ts"; 139 + import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts"; 140 + 141 + component("mov.danabra.Greeting", ({ name }) => ( 142 + <Stack gap="small"> 143 + <Text>Hello,</Text> 144 + <Text>{name || "stranger"}</Text> 145 + </Stack> 146 + )); 147 + 148 + export default serve; 149 + ``` 150 + 151 + 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. 152 + 153 + ### Pack record 154 + 155 + 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): 156 + 157 + ```json 158 + { 159 + "$type": "at.inlay.pack", 160 + "name": "ui", 161 + "exports": [ 162 + { "type": "mov.danabra.Greeting", "component": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6" }, 163 + ... 164 + ] 165 + } 166 + ``` 167 + 168 + Other components import this pack URI in their `imports` array to get access to `mov.danabra.Greeting`. 169 + 170 + ### Lexicon (optional) 171 + 172 + 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): 173 + 174 + ```json 175 + { 176 + "lexicon": 1, 177 + "id": "mov.danabra.Greeting", 178 + "defs": { 179 + "main": { 180 + "type": "procedure", 181 + "input": { 182 + "encoding": "application/json", 183 + "schema": { 184 + "type": "object", 185 + "required": ["name"], 186 + "properties": { 187 + "name": { "type": "string", "maxLength": 100 } 188 + } 189 + } 190 + }, 191 + "output": { 192 + "encoding": "application/json", 193 + "schema": { "type": "ref", "ref": "at.inlay.defs#response" } 194 + } 195 + } 196 + } 197 + } 198 + ``` 199 + 200 + See [`@inlay/core`](packages/@inlay/core) for JSX support and more ways to build element trees. 201 + 202 + ## Rendering as a host 203 + 204 + A host walks an element tree, resolves each component through its packs, and maps the resulting primitives to output (HTML, React, etc). 205 + 206 + [`@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. 207 + 208 + 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. 209 + 210 + [`proto/`](proto/) is a simple but complete host with streaming, caching, and prefetching. 211 + 212 + ## Development 213 + 214 + ```bash 215 + npm install 216 + npm run build # generate lexicons + build all packages 217 + npm run typecheck # type-check everything 218 + npm test # run tests 219 + ``` 220 + 221 + ## Project structure 222 + 223 + ``` 224 + packages/@inlay/core/ — element primitives and JSX 225 + packages/@inlay/render/ — rendering engine 226 + packages/@inlay/cache/ — cache declarations 227 + proto/ — prototype host server (Hono) 228 + invalidator/ — cache invalidation service 229 + lexicons/ — AT Protocol lexicon definitions 230 + generated/ — generated TypeScript from lexicons 231 + ``` 232 + 233 + ## License 234 + 235 + MIT
+176
packages/@inlay/cache/README.md
··· 1 + # @inlay/cache 2 + 3 + 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: 4 + 5 + ```json 6 + { 7 + "life": "hours", 8 + "tags": [ 9 + // Invalidate me when this record changes 10 + { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:abc/app.bsky.actor.profile/self" } 11 + ] 12 + } 13 + ``` 14 + 15 + 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. 16 + 17 + A server runtime can collect these calls and produce the cache policy object. 18 + 19 + ## Install 20 + 21 + ``` 22 + npm install @inlay/cache 23 + ``` 24 + 25 + ## Usage 26 + 27 + ```ts 28 + import { $ } from "@inlay/core"; 29 + import { cacheLife, cacheTagRecord, cacheTagLink } from "@inlay/cache"; 30 + 31 + async function fetchRecord(uri) { 32 + cacheTagRecord(uri); // Invalidate me when this record changes 33 + cacheLife("max"); 34 + const [, , repo, collection, rkey] = uri.split("/"); 35 + const params = new URLSearchParams({ repo, collection, rkey }); 36 + const res = await fetch( 37 + `https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord?${params}` 38 + ); 39 + return (await res.json()).value; 40 + } 41 + 42 + async function fetchProfileStats(did) { 43 + cacheLife("hours"); 44 + cacheTagLink(`at://${did}`, "app.bsky.graph.follow"); // Invalidate me on backlinks 45 + const params = new URLSearchParams({ actor: did }); 46 + const res = await fetch( 47 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}` 48 + ); 49 + const data = await res.json(); 50 + return { followersCount: data.followersCount }; 51 + } 52 + 53 + async function ProfileCard({ uri }) { 54 + const profile = await fetchRecord(uri); 55 + const did = uri.split("/")[2]; 56 + const stats = await fetchProfileStats(did); 57 + 58 + return $("org.atsui.Stack", { gap: "small" }, 59 + $("org.atsui.Avatar", { src: profile.avatar, did }), 60 + $("org.atsui.Text", {}, profile.displayName), 61 + $("org.atsui.Text", {}, `${stats.followersCount} followers`), 62 + ); 63 + } 64 + ``` 65 + 66 + A server runtime could then provide a way to run `ProfileCard` *and* collect its cache policy: 67 + 68 + ```json 69 + { 70 + "node": { 71 + "$": "$", 72 + "type": "org.atsui.Stack", 73 + "props": { 74 + "gap": "small", 75 + "children": [ 76 + { "$": "$", "type": "org.atsui.Avatar", "props": { "src": "...", "did": "did:plc:ragtjsm2j2vknwkz3zp4oxrd" }, "key": "0" }, 77 + { "$": "$", "type": "org.atsui.Text", "props": { "children": ["Paul Frazee"] }, "key": "1" }, 78 + { "$": "$", "type": "org.atsui.Text", "props": { "children": ["308032 followers"] }, "key": "2" } 79 + ] 80 + } 81 + }, 82 + "cache": { 83 + "life": "hours", 84 + "tags": [ 85 + { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self" }, 86 + { "$type": "at.inlay.defs#tagLink", "subject": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd", "from": "app.bsky.graph.follow" } 87 + ] 88 + } 89 + } 90 + ``` 91 + 92 + ## Server 93 + 94 + 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: 95 + 96 + ```tsx 97 + import { component, serve, cacheLife, cacheTagRecord, cacheTagLink } from "https://esm.town/v/gaearon/inlay/main.ts"; 98 + 99 + async function fetchRecord(uri) { 100 + cacheTagRecord(uri); 101 + cacheLife("max"); 102 + // ... fetch and return record 103 + } 104 + 105 + async function fetchProfileStats(did) { 106 + cacheLife("hours"); 107 + cacheTagLink(`at://${did}`, "app.bsky.graph.follow"); 108 + // ... fetch and return stats 109 + } 110 + 111 + component("mov.danabra.ProfileCard", async ({ uri }) => { 112 + const profile = await fetchRecord(uri); 113 + const stats = await fetchProfileStats(uri.split("/")[2]); 114 + // ... return element tree 115 + }); 116 + 117 + export default serve; 118 + ``` 119 + 120 + 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. 121 + 122 + ### How it works 123 + 124 + 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: 125 + 126 + ```ts 127 + import { AsyncLocalStorage } from "node:async_hooks"; 128 + import { serializeTree } from "@inlay/core"; 129 + import type { Dispatcher } from "@inlay/cache"; 130 + 131 + const LIFE_ORDER = ["seconds", "minutes", "hours", "max"]; 132 + const cacheStore = new AsyncLocalStorage(); 133 + 134 + // Install the dispatcher — cache functions will write here 135 + globalThis[Symbol.for("inlay.cache")] = { 136 + cacheLife(life) { cacheStore.getStore().lives.push(life); }, 137 + cacheTag(tag) { cacheStore.getStore().tags.push(tag); }, 138 + } satisfies Dispatcher; 139 + 140 + // Run a handler and collect its cache policy 141 + async function runHandler(handler, props) { 142 + const state = { lives: [], tags: [] }; 143 + const node = await cacheStore.run(state, () => handler(props)); 144 + const life = state.lives.reduce((a, b) => 145 + LIFE_ORDER.indexOf(a) < LIFE_ORDER.indexOf(b) ? a : b 146 + ); 147 + return { 148 + node: serializeTree(node), 149 + cache: { life, tags: state.tags }, 150 + }; 151 + } 152 + 153 + const result = await runHandler(ProfileCard, { 154 + uri: "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self", 155 + }); 156 + console.log(JSON.stringify(result, null, 2)); 157 + // => { node: { ... }, cache: { life: "hours", tags: [...] } } 158 + ``` 159 + 160 + 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. 161 + 162 + ## API 163 + 164 + | Function | Description | 165 + |----------|-------------| 166 + | `cacheLife(life)` | Set cache duration. Strictest (shortest) call wins. Values: `"seconds"`, `"minutes"`, `"hours"`, `"max"` | 167 + | `cacheTagRecord(uri)` | Invalidate when this AT Protocol record is created, updated, or deleted | 168 + | `cacheTagLink(subject, from?)` | Invalidate when any record linking to `subject` changes. Optionally restrict to a specific collection | 169 + 170 + ### Types 171 + 172 + - **`Life`** — `"seconds" | "minutes" | "hours" | "max"` 173 + - **`CacheTag`** — `TagRecord | TagLink` 174 + - **`TagRecord`** — `{ $type: "at.inlay.defs#tagRecord", uri: string }` 175 + - **`TagLink`** — `{ $type: "at.inlay.defs#tagLink", subject: string, from?: string }` 176 + - **`Dispatcher`** — interface for the server runtime to implement
+193
packages/@inlay/core/README.md
··· 1 + # @inlay/core 2 + 3 + Build element trees for Inlay, a UI component system for the AT Protocol. 4 + 5 + ## Install 6 + 7 + ``` 8 + npm install @inlay/core 9 + ``` 10 + 11 + ## Creating elements 12 + 13 + The `$` function creates elements. Pass a string [NSID](https://atproto.com/specs/nsid) as the type: 14 + 15 + ```ts 16 + import { $ } from "@inlay/core"; 17 + 18 + const el = $("org.atsui.Stack", { gap: "small" }, 19 + $("org.atsui.Text", {}, "Hello"), 20 + $("org.atsui.Text", {}, "world") 21 + ); 22 + ``` 23 + 24 + You can then turn this tree into JSON with `serializeTree(el)`: 25 + 26 + ```json 27 + { 28 + "$": "$", 29 + "type": "org.atsui.Stack", 30 + "props": { 31 + "gap": "small", 32 + "children": [ 33 + { 34 + "$": "$", 35 + "type": "org.atsui.Text", 36 + "props": { "children": ["Hello"] }, 37 + "key": "0" 38 + }, 39 + { 40 + "$": "$", 41 + "type": "org.atsui.Text", 42 + "props": { "children": ["world"] }, 43 + "key": "1" 44 + } 45 + ] 46 + } 47 + } 48 + ``` 49 + 50 + If the components you use are [published lexicons](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution), you can use the typed `$` overload: 51 + 52 + ```ts 53 + import { $ } from "@inlay/core"; 54 + import { Stack, Text } from "./generated/org/atsui"; 55 + 56 + $(Stack, { gap: "small" }, 57 + $(Text, {}, "Hello"), 58 + $(Text, {}, "world") 59 + ); 60 + ``` 61 + 62 + Then their props will be checked. 63 + 64 + ## JSX 65 + 66 + Set `@inlay/core` as the JSX import source in your tsconfig: 67 + 68 + ```json 69 + { 70 + "compilerOptions": { 71 + "jsx": "react-jsx", 72 + "jsxImportSource": "@inlay/core" 73 + } 74 + } 75 + ``` 76 + 77 + Then use Lexicon modules as tags: 78 + 79 + ```tsx 80 + import { jslex } from "@inlay/core"; 81 + import * as atsui from "./generated/org/atsui"; 82 + const { Stack, Text } = jslex(atsui); 83 + 84 + <Stack gap="medium"> 85 + <Text>Hello world</Text> 86 + </Stack> 87 + ``` 88 + 89 + ## HTTP imports 90 + 91 + In environments that support URL imports (Val Town, Deno), you can import any published Lexicons like this: 92 + 93 + ```tsx 94 + /* @jsxImportSource npm:@inlay/core */ 95 + import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts"; 96 + 97 + <Stack gap="medium"> 98 + <Text>Hello world</Text> 99 + </Stack> 100 + ``` 101 + 102 + ## Lexicons 103 + 104 + Components are defined by Lexicons — JSON schemas that declare props. For example, `org.atsui.Avatar`: 105 + 106 + ```json 107 + { 108 + "lexicon": 1, 109 + "id": "org.atsui.Avatar", 110 + "defs": { 111 + "main": { 112 + "type": "procedure", 113 + "description": "Circular profile image at a fixed size.", 114 + "input": { 115 + "encoding": "application/json", 116 + "schema": { 117 + "type": "object", 118 + "required": ["src"], 119 + "properties": { 120 + "src": { 121 + "type": "unknown", 122 + "description": "Blob ref for the image." 123 + }, 124 + "did": { 125 + "type": "string", 126 + "format": "did", 127 + "description": "DID of the blob owner. Used to resolve blob URLs." 128 + }, 129 + "size": { 130 + "type": "string", 131 + "maxLength": 32, 132 + "description": "Size token.", 133 + "knownValues": ["xsmall", "small", "medium", "large"], 134 + "default": "medium" 135 + } 136 + } 137 + } 138 + }, 139 + "output": { 140 + "encoding": "application/json", 141 + "schema": { 142 + "type": "ref", 143 + "ref": "at.inlay.defs#response" 144 + } 145 + } 146 + } 147 + } 148 + } 149 + ``` 150 + 151 + Use `@atproto/lex` to generate TypeScript from Lexicon files. For published lexicons, install from the network: 152 + 153 + ```bash 154 + npm install @atproto/lex 155 + npx lex install org.atsui.Stack org.atsui.Text 156 + npx lex build --out generated 157 + ``` 158 + 159 + For your own lexicons, put `.json` files in a `lexicons/` directory (e.g. `lexicons/com/example/MyComponent.json`) and point `lex build` at them: 160 + 161 + ```bash 162 + npx lex build --lexicons lexicons --out generated 163 + ``` 164 + 165 + ## Serialization 166 + 167 + Convert element trees to/from JSON-safe representations: 168 + 169 + ```ts 170 + import { serializeTree, deserializeTree } from "@inlay/core"; 171 + 172 + const json = serializeTree(elementTree); 173 + const restored = deserializeTree(json); 174 + ``` 175 + 176 + ## API 177 + 178 + | Function | Description | 179 + |----------|-------------| 180 + | `$(type, props?, ...children)` | Create an element from a Lexicon module or NSID string | 181 + | `jslex(namespace)` | Cast Lexicon modules for use as JSX components (no-op at runtime) | 182 + | `isValidElement(value)` | Type guard for `Element` | 183 + | `serializeTree(tree, visitor?)` | Convert element tree to JSON-serializable form | 184 + | `deserializeTree(tree, visitor?)` | Inverse of `serializeTree` | 185 + | `walkTree(tree, mapObject)` | Recursive tree walker for plain objects | 186 + | `resolveBindings(tree, resolve)` | Replace `at.inlay.Binding` elements using a resolver callback | 187 + 188 + ### Types 189 + 190 + - **`Element`** — `{ type: string, key?: string, props?: Record<string, unknown> }` 191 + - **`LexiconComponent`** — Object with a `$nsid` field 192 + - **`Component<M>`** — A Lexicon module usable as a JSX component 193 + - **`ElementVisitor`** — `(element: Element) => unknown`
+180
packages/@inlay/render/README.md
··· 1 + # @inlay/render 2 + 3 + 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. 4 + 5 + ## Install 6 + 7 + ``` 8 + npm install @inlay/render @inlay/core 9 + ``` 10 + 11 + ## Example 12 + 13 + A 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 */ 17 + import { Hono } from "hono"; 18 + import { serve } from "@hono/node-server"; 19 + import { $, serializeTree, deserializeTree, isValidElement } from "@inlay/core"; 20 + import { render } from "@inlay/render"; 21 + import type { RenderContext } from "@inlay/render"; 22 + 23 + // Records — in-memory stand-in for AT Protocol 24 + const 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 61 + const 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 68 + const 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 84 + async 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, validate: false } 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 + 103 + async 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 110 + const app = new Hono(); 111 + 112 + app.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 + 121 + serve({ fetch: app.fetch, port: 3000 }); 122 + ``` 123 + 124 + Returns: 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 + 135 + 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. 136 + 137 + ## In practice 138 + 139 + A 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 + 145 + See [`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 + 151 + 1. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope. 152 + 2. **Type lookup** — the element's NSID is looked up across the import stack. First pack that exports the type wins. 153 + 3. **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. 157 + 4. **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 + - `validate?: boolean` — prop validation (default true) 172 + 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 180 + - **`CachePolicy`** — re-exported from generated lexicon defs
+38
proto/README.md
··· 1 + # proto 2 + 3 + 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. 4 + 5 + **Live demo:** https://inlay-proto.up.railway.app/ 6 + 7 + ## Running 8 + 9 + ```bash 10 + npm install # from the repo root 11 + npm run dev # starts on http://localhost:3001 12 + ``` 13 + 14 + 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). 15 + 16 + ## How it works 17 + 18 + 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. 19 + 20 + 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. 21 + 22 + ## Primitives 23 + 24 + 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. 25 + 26 + ## Caching 27 + 28 + 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. 29 + 30 + TTLs follow the `life` values from component responses: `seconds` (30s), `minutes` (5m), `hours` (1h), `max` (24h). 31 + 32 + ## Infinite scroll 33 + 34 + `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. 35 + 36 + ## Link prefetching 37 + 38 + 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.