npm stats
  • Search
  • About
  • Repo
  • Sponsor
  • more
    • Search
    • About
    • Repo
    • Sponsor

Made by Antonio Ramirez

@inlay/render

0.3.1

@gaearon

npmSnykSocket
Downloads:1702
$ npm install @inlay/render
DailyWeeklyMonthlyYearly

@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.

/** @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.

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.

  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

FunctionDescription
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
MissingErrorThrown 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