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.
npm install @inlay/render @inlay/core
A minimal Hono server that accepts an element, resolves it, 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.
// Each component's rkey is the NSID it implements.
const records = {
// Primitives: no body. The host renders these directly.
"at://did:plc:host/at.inlay.component/org.atsui.Stack": {
$type: "at.inlay.component",
},
"at://did:plc:host/at.inlay.component/org.atsui.Text": {
$type: "at.inlay.component",
},
// Template: a stored element tree with Binding placeholders.
"at://did:plc:app/at.inlay.component/com.example.Greeting": {
$type: "at.inlay.component",
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: ["did:plc:host"],
},
};
// 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; },
async resolve(dids, collection, rkey) {
for (const did of dids) {
const uri = `at://${did}/${collection}/${rkey}`;
const record = records[uri];
if (record) return { did, uri, record };
}
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: ["did:plc:host", "did:plc:app"] };
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.
A production host will also want:
See proto/ for a working implementation with all of the above.
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.
at.inlay.Binding elements in props are resolved against the current scope. The concrete values are available on result.props.at://{did}/at.inlay.component/{nsid} — first match wins.node: null with resolved props.componentStack stamped on the error. MissingError propagates for at.inlay.Maybe to catch.| 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)Resolver — { fetchRecord, xrpc, resolveLexicon, resolve }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