social components inlay.at
atproto components sdui
86
fork

Configure Feed

Select the types of activity you want to include in your feed.

README.md

@inlay/cache#

Every Inlay XRPC component response may include a CachePolicy — a lifetime and a set of invalidation tags:

{
  "life": "hours",
  "tags": [
    // Invalidate me when this record changes
    { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:abc/app.bsky.actor.profile/self" }
  ]
}

This package lets you build that policy declaratively. Instead of constructing the object by hand, call cacheLife and cacheTagRecord anywhere during your handler — including inside async helper functions.

A server runtime can collect these calls and produce the cache policy object.

Install#

npm install @inlay/cache

Usage#

import { $ } from "@inlay/core";
import { cacheLife, cacheTagRecord, cacheTagLink } from "@inlay/cache";

async function fetchRecord(uri) {
  cacheTagRecord(uri); // Invalidate me when this record changes
  cacheLife("max");
  const [, , repo, collection, rkey] = uri.split("/");
  const params = new URLSearchParams({ repo, collection, rkey });
  const res = await fetch(
    `https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord?${params}`
  );
  return (await res.json()).value;
}

async function fetchProfileStats(did) {
  cacheLife("hours");
  cacheTagLink(`at://${did}`, "app.bsky.graph.follow"); // Invalidate me on backlinks
  const params = new URLSearchParams({ actor: did });
  const res = await fetch(
    `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?${params}`
  );
  const data = await res.json();
  return { followersCount: data.followersCount };
}

async function ProfileCard({ uri }) {
  const profile = await fetchRecord(uri);
  const did = uri.split("/")[2];
  const stats = await fetchProfileStats(did);

  return $("org.atsui.Stack", { gap: "small" },
    $("org.atsui.Avatar", { src: profile.avatar, did }),
    $("org.atsui.Text", {}, profile.displayName),
    $("org.atsui.Text", {}, `${stats.followersCount} followers`),
  );
}

A server runtime could then provide a way to run ProfileCard and collect its cache policy:

{
  "node": {
    "$": "$",
    "type": "org.atsui.Stack",
    "props": {
      "gap": "small",
      "children": [
        { "$": "$", "type": "org.atsui.Avatar", "props": { "src": "...", "did": "did:plc:ragtjsm2j2vknwkz3zp4oxrd" }, "key": "0" },
        { "$": "$", "type": "org.atsui.Text", "props": { "children": ["Paul Frazee"] }, "key": "1" },
        { "$": "$", "type": "org.atsui.Text", "props": { "children": ["308032 followers"] }, "key": "2" }
      ]
    }
  },
  "cache": {
    "life": "hours",
    "tags": [
      { "$type": "at.inlay.defs#tagRecord", "uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self" },
      { "$type": "at.inlay.defs#tagLink", "subject": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd", "from": "app.bsky.graph.follow" }
    ]
  }
}

Server#

In practice, you'll use these functions through a server SDK that handles accumulation for you. For example, the Inlay Val Town SDK re-exports them and automatically includes the resulting cache policy in each XRPC response:

import { component, serve, cacheLife, cacheTagRecord, cacheTagLink } from "https://esm.town/v/gaearon/inlay/main.ts";

async function fetchRecord(uri) {
  cacheTagRecord(uri);
  cacheLife("max");
  // ... fetch and return record
}

async function fetchProfileStats(did) {
  cacheLife("hours");
  cacheTagLink(`at://${did}`, "app.bsky.graph.follow");
  // ... fetch and return stats
}

component("mov.danabra.ProfileCard", async ({ uri }) => {
  const profile = await fetchRecord(uri);
  const stats = await fetchProfileStats(uri.split("/")[2]);
  // ... return element tree
});

export default serve;

See below for how it works under the hood. If you're writing your own server, you can either use the same approach (explained below) or you can just explicitly return { node, cache } without using any of these accumulating helpers.

How it works#

Cache functions write to a Dispatcher installed on Symbol.for("inlay.cache"). The SDK (or your own server runtime) would provide the dispatcher so you can collect the calls to cacheLife and cacheTag* functions from @inlay/cache. Here's a minimal implementation:

import { AsyncLocalStorage } from "node:async_hooks";
import { serializeTree } from "@inlay/core";
import type { Dispatcher } from "@inlay/cache";

const LIFE_ORDER = ["seconds", "minutes", "hours", "max"];
const cacheStore = new AsyncLocalStorage();

// Install the dispatcher — cache functions will write here
globalThis[Symbol.for("inlay.cache")] = {
  cacheLife(life) { cacheStore.getStore().lives.push(life); },
  cacheTag(tag) { cacheStore.getStore().tags.push(tag); },
} satisfies Dispatcher;

// Run a handler and collect its cache policy
async function runHandler(handler, props) {
  const state = { lives: [], tags: [] };
  const node = await cacheStore.run(state, () => handler(props));
  const life = state.lives.reduce((a, b) =>
    LIFE_ORDER.indexOf(a) < LIFE_ORDER.indexOf(b) ? a : b
  );
  return {
    node: serializeTree(node),
    cache: { life, tags: state.tags },
  };
}

const result = await runHandler(ProfileCard, {
  uri: "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self",
});
console.log(JSON.stringify(result, null, 2));
// => { node: { ... }, cache: { life: "hours", tags: [...] } }

Installation happens via a global so that coordination doesn't depend on package versioning or hoisting working correctly. Helpers like fetchRecord and fetchProfileStats can be moved into libraries.

API#

Function Description
cacheLife(life) Set cache duration. Strictest (shortest) call wins. Values: "seconds", "minutes", "hours", "max"
cacheTagRecord(uri) Invalidate when this AT Protocol record is created, updated, or deleted
cacheTagLink(subject, from?) Invalidate when any record linking to subject changes. Optionally restrict to a specific collection

Types#

  • Life"seconds" | "minutes" | "hours" | "max"
  • CacheTagTagRecord | TagLink
  • TagRecord{ $type: "at.inlay.defs#tagRecord", uri: string }
  • TagLink{ $type: "at.inlay.defs#tagLink", subject: string, from?: string }
  • Dispatcher — interface for the server runtime to implement