npm package: @inlay/render
Description: 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.
Latest version: 0.3.1

# @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, and returns HTML. Uses Hono's JSX for safe output.

```tsx
/** @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
<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/`](../../../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.

1. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope. The concrete values are available on `result.props`.
2. **Type lookup** — the element's NSID is looked up across the import stack. For each DID, the renderer checks `at://{did}/at.inlay.component/{nsid}` — first match wins.
3. **Component rendering** — depends on the component's body:
   - **No body** — a primitive; returns `node: null` with 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.
4. **Error handling** — errors throw with `componentStack` stamped on the error. `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, resolve }`
- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }`
- **`RenderResult`** — `{ node, context, props, cache? }`
- **`RenderOptions`** — `{ resolver, maxDepth? }`
- **`ComponentRecord`** — re-exported from generated lexicon defs
- **`CachePolicy`** — re-exported from generated lexicon defs