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

@inlay/render#

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.

Install#

npm install @inlay/render @inlay/core

Example#

A minimal Hono server that accepts an element, resolves it through packs, and returns HTML. Uses Hono's JSX for safe output.

/** @jsxImportSource hono/jsx */
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { $, serializeTree, deserializeTree, isValidElement } from "@inlay/core";
import { render } from "@inlay/render";
import type { RenderContext } from "@inlay/render";

// Records — in-memory stand-in for AT Protocol
const records = {
  // Primitives: no body. The host renders these directly.
  "at://did:plc:host/at.inlay.component/stack": {
    $type: "at.inlay.component", type: "org.atsui.Stack",
  },
  "at://did:plc:host/at.inlay.component/text": {
    $type: "at.inlay.component", type: "org.atsui.Text",
  },

  // Template: a stored element tree with Binding placeholders.
  "at://did:plc:app/at.inlay.component/greeting": {
    $type: "at.inlay.component",
    type: "com.example.Greeting",
    body: {
      $type: "at.inlay.component#bodyTemplate",
      node: serializeTree(
        $("org.atsui.Stack", { gap: "small" },
          $("org.atsui.Text", {}, "Hello, "),
          $("org.atsui.Text", {}, $("at.inlay.Binding", { path: ["name"] })),
        )
      ),
    },
    imports: ["at://did:plc:host/at.inlay.pack/main"],
  },

  // Pack: maps NSIDs → component records
  "at://did:plc:host/at.inlay.pack/main": {
    $type: "at.inlay.pack", name: "host",
    exports: [
      { type: "org.atsui.Stack", component: "at://did:plc:host/at.inlay.component/stack" },
      { type: "org.atsui.Text", component: "at://did:plc:host/at.inlay.component/text" },
      { type: "com.example.Greeting", component: "at://did:plc:app/at.inlay.component/greeting" },
    ],
  },
};

// Resolver — I/O layer that render() calls into
const resolver = {
  async fetchRecord(uri) { return records[uri] ?? null; },
  async xrpc(params) { throw new Error(`No handler for ${params.nsid}`); },
  async resolveLexicon() { return null; },
};

// Primitives → JSX
const primitives = {
  "org.atsui.Stack": async ({ props, ctx }) => {
    const p = props as { gap?: string; children?: unknown[] };
    return (
      <div style={`display:flex;flex-direction:column;gap:${p.gap ?? 0}`}>
        {await renderChildren(p.children, ctx)}
      </div>
    );
  },
  "org.atsui.Text": async ({ props, ctx }) => {
    const p = props as { children?: unknown };
    return <span>{await renderNode(p.children, ctx)}</span>;
  },
};

// Walk loop — resolve elements, render primitives to JSX
async function renderNode(node, ctx) {
  if (node == null) return <></>;
  if (typeof node === "string") return <>{node}</>;
  if (Array.isArray(node)) return <>{await Promise.all(node.map(n => renderNode(n, ctx)))}</>;

  if (isValidElement(node)) {
    const { resolved, node: out, context: outCtx } = await render(
      node, ctx, { resolver }
    );
    if (resolved && isValidElement(out)) {
      const Primitive = primitives[out.type];
      if (!Primitive) return <>{`<!-- unknown: ${out.type} -->`}</>;
      return Primitive({ props: out.props ?? {}, ctx: outCtx });
    }
    return renderNode(out, outCtx);
  }
  return <>{String(node)}</>;
}

async function renderChildren(children, ctx) {
  if (!children) return <></>;
  const items = Array.isArray(children) ? children : [children];
  return <>{await Promise.all(items.map(c => renderNode(c, ctx)))}</>;
}

// Server
const app = new Hono();

app.get("/", async (c) => {
  const element = deserializeTree({
    $: "$", type: "com.example.Greeting", props: { name: "world" },
  });
  const ctx = { imports: ["at://did:plc:host/at.inlay.pack/main"] };
  const body = await renderNode(element, ctx);
  return c.html(<html><body>{body}</body></html>);
});

serve({ fetch: app.fetch, port: 3000 });

Returns:

<html><body>
  <div style="display:flex;flex-direction:column;gap:small">
    <span>Hello, </span>
    <span>world</span>
  </div>
</body></html>

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.

In practice#

A production host will also want:

  • Persistent global caching keyed by component + props + response cache tags
  • Streaming so slow XRPC calls and record fetches don't block the page
  • Prefetching for links so navigation feels instant

See proto/ for a working implementation with all of the above.

How it works#

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.

  1. Binding resolutionat.inlay.Binding elements in props are resolved against the current scope.
  2. Type lookup — the element's NSID is looked up across the import stack. First pack that exports the type wins.
  3. Component rendering — depends on the component's body:
    • No body — a primitive; returned as-is.
    • Template — element tree expanded with prop bindings.
    • External — XRPC call to the component's service. Children are passed as slots and restored from the response.
  4. Error handling — errors become at.inlay.Throw elements. MissingError propagates for at.inlay.Maybe to catch.

API#

Function Description
createContext(component, componentUri?) Create a RenderContext from a component record
render(element, context, options) Render a single element. Returns Promise<RenderResult>
resolvePath(obj, path) Resolve a dotted path array against an object
MissingError Thrown when a binding path can't be resolved

Render options:

  • resolver: Resolver — I/O callbacks (required)
  • maxDepth?: number — nesting limit (default 30)

Types#

  • Resolver{ fetchRecord, xrpc, resolveLexicon }
  • RenderContext{ imports, component?, componentUri?, depth?, scope?, stack? }
  • RenderResult{ resolved, node, context, cache? }
  • RenderOptions{ resolver, maxDepth? }
  • ComponentRecord — re-exported from generated lexicon defs
  • CachePolicy — re-exported from generated lexicon defs