@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 result = await render(node, ctx, { resolver });
if (result.node === null) {
// Primitive: host renders using element type + result.props
const Primitive = primitives[node.type];
if (!Primitive) return <>{`<!-- unknown: ${node.type} -->`}</>;
return Primitive({ props: result.props, ctx: result.context });
}
return renderNode(result.node, result.context);
}
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 node: null when the element is a primitive (the host renders it using element.type + result.props) or a non-null node tree when the element expanded into a subtree that needs more passes. The walk loop drives this to completion.
- Binding resolution —
at.inlay.Bindingelements in props are resolved against the current scope. The concrete values are available onresult.props. - Type lookup — the element's NSID is looked up across the import stack. First pack that exports the type wins.
- Component rendering — depends on the component's body:
- No body — a primitive; returns
node: nullwith resolved props. - 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.
- No body — a primitive; returns
- Error handling — errors throw with
componentStackstamped on the error.MissingErrorpropagates forat.inlay.Maybeto 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—{ node, context, props, cache? }RenderOptions—{ resolver, maxDepth? }ComponentRecord— re-exported from generated lexicon defsCachePolicy— re-exported from generated lexicon defs