@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"CacheTag—TagRecord | TagLinkTagRecord—{ $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