npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@estiva-app/interop

v0.32.0

Published

Render and act on another app's objects from its published NIP-89 manifest. The owner defines the projection; the consumer decides how it looks.

Downloads

3,706

Readme

@estiva-app/interop

Render and act on another app's objects, from a manifest that app published. Your app learns nothing about theirs.

npm install @estiva-app/interop @estiva-app/protocol

No registry auth, no .npmrc, no token — ADR 0002 §3.

The one rule everything follows

The owner defines the projection. The consumer decides how it looks.

An app that owns objects publishes a NIP-89 kind:31990 manifest saying which of its fields matter, what its statuses mean, and what another app may do to them. It never says how to draw anything — an owner who could specify layout would be designing your product, which is the same objection that rules out iframes.

So this package returns values with enough shape to render, and no components. Nothing in it knows what any particular app is.


1. Render an object from an app you know nothing about

You are holding a reference somebody pasted — nostr:naddr1…, or a bare kind:pubkey:d. You do not know which app owns it.

import { resolveForeignObject } from '@estiva-app/interop'
import { Relay } from '@estiva-app/protocol'

const relay = new Relay('https://your.relay', signer)
const query = (filters) => relay.query(filters)

const object = await resolveForeignObject(reference, query)

query is the only thing this package touches the outside world with, and you supply it. There is no client inside, no global, and no configuration — which is also why the whole test suite runs with an array standing in for the relay.

What comes back:

{
  ref: '30851:abc…:9c69f247-…',   // stable handle, whatever the object is
  kind: 30851,
  appName: 'Estiva Ship',
  widget: 'row',                  // a hint. See §3
  slots: {
    title:    { value: 'Billing entry' },
    status:   { value: 'In Progress', colour: 'blue' },
    subtitle: { value: 'Settings entry point.' },
    body:     { value: '**Stripe Checkout** for billing.', format: 'marker' },
  },
  meta: [{ label: 'Assignee', value: 'abc…', isPubkey: true }],
  children: [ /* … more of the same, if the owner declared a list */ ],
  actions: [ /* … see §2 */ ],
  openUrl: 'https://ship.estiva.app/#/o/naddr1…',
  people: { 'abc…': { displayName: 'Ana', picture: '…' } },
}

Render the slots you implement and ignore the ones you do not. That is not politeness, it is required: the slot set is closed but it grows, and producers upgrade before consumers do. title is always present, so an object you only half understand still renders as a named, resolvable thing.

Holding a set? Resolve it as one

A list you keep live — followed files, open work — should not resolve its entries one at a time. Each resolve is a request, and a relay that meters reads per request (the Estiva relay allows 300 a minute, shared by a person's tabs) runs out on a refresh of a few dozen.

import { resolveForeignObjects, createProjectionCache } from '@estiva-app/interop'

const cache = createProjectionCache() // keep it for the session
const objects = await resolveForeignObjects(addresses, query, undefined, cache)
// { [address]: ForeignObject | null } — each exactly what resolveForeignObject returns

Every object keeps its own filters and limits, so a busy one never crowds out another's comments; they share the POST. A warm refresh of up to 32 objects is one request and a people lookup. Event references (nevent) are not addresses and come back null; resolve those with resolveForeignEvent.

A body says which content model it is in — read it, do not guess

body is the one slot that carries structure, and it arrives in one of two models that must never be read as each other. The slot tells you which, so you never have to look at the text:

| slot.body.format | what you have | | --- | --- | | 'marker' | the marker dialect — **bold**, # heading, > quote, fenced code | | 'blocks' | a JSON block document | | 'unknown' | a model published after your app was written |

import { toRenderTree } from '@estiva-app/protocol'

// One tree, whichever model it came in. Marks decided, blocks decided,
// nothing left to parse — so you cannot render markup by accident.
const blocks = toRenderTree(object.slots.body.value, object.slots.body.format)

Never decide the model by inspecting the body. A description that happens to begin with { is marker text if its event carries no content-format tag, and there are hundreds of those already published. 'unknown' exists so you can decline: rendering a body you do not understand as plain text is correct, and guessing at it is not.

A slot that is not a body carries no format at all — that is how you tell "this is prose in the marker dialect" from "this is a title, and models do not apply to it".

The states you must draw, and the one that catches everyone

A foreign object has more failure states than anything else on your screen, because you control none of its lifecycle.

| state | how you know | | --- | --- | | resolved | you got an object | | you may not see it | object.unreachable === true | | nothing claims this kind | null | | declared a list, and it is empty | children is [] | | declared no list | children is undefined |

"You may not see it" and "it is empty" must never look the same. A gated read returns nothing at all — the same nothing as a thing with no content — so the relay cannot distinguish them and neither can you unless you use unreachable. Draw one state for both and you have built a screen that quietly lies.

2. Let a reader act on it, without an API

An action is an event to publish, never an endpoint to call. There is no server in the loop, and the owning app can be offline.

import { buildActionEvent, resolveManifest } from '@estiva-app/interop'

const resolved = await resolveManifest(object.kind, query)

const built = buildActionEvent({
  manifest: resolved.manifest,
  kind: object.kind,
  address: object.ref,
  objectAuthor: pointer.pubkey,
  folder,                       // the object's channel — see below
  actionId: 'set-issue-status',
  value: 'done',
  pubkey: me,                   // whoever is about to sign
  createdAtMs: Date.now(),
})

// A refusal is a string, and it is written for a person to read.
if (typeof built === 'string') return show(built)

await relay.publish(await signer.sign(built))

Check the string. buildActionEvent returns UnsignedActionEvent | string, and the string is why it refused — an undeclared field, a required one left empty, a value outside the declared vocabulary. Treat the result as an event without checking and you will sign the refusal.

Everything it needs is passed in rather than reached for: this package opens no socket, reads no clock and holds no identity, so the built event is a pure function of its inputs and the whole suite runs against an array.

object.actions is already resolved against the owner's vocabularies, so a select arrives with its options and the value it currently holds.

Actions that create an object, not just change one

An action with control: 'form' makes a whole new object rather than setting a field on this one. It arrives with the schema already resolved:

const action = object.actions.find((a) => a.control === 'form')

action.fields        // [{ name: 'title', type: 'string', required: true }, …]
action.createsUnder  // the address the new object will hang under

Draw the fields, then pass an object rather than a scalar, plus an id for the thing being made:

const built = buildActionEvent({
  /* …as above… */
  actionId: action.id,
  value: { title: 'Payment fails on retry' },
  newId: crypto.randomUUID(),
})

Declaring one, from the producer's side — required is a list of names, not a flag on each property:

{
  "id": "add-issue", "label": "Add issue", "appliesTo": "31800",
  "emits": { "kind": 31801, "setTag": "a", "toAddressOf": "self" },
  "input": {
    "type": "object",
    "properties": {
      "title": { "type": "string" },
      "note": { "type": "string", "target": "content" }
    },
    "required": ["title"]
  }
}

newId is required for an addressable kind and is supplied by you rather than generated here — an object published without one has no address at all, so nothing could reference it, comment on it or act on it afterwards.

Two rules worth knowing before you draw the form. A property's name is the tag its value is written to, which is what lets a consumer build an event for an app it has never seen. And one property may target the event's content instead, with "target": "content" — at most one, because an event has one body, and a consumer refuses the whole action rather than choosing between two.

That second rule is new in 0.13.0. Before it, nothing could be written to content at all, and the consequence was not a rough edge: an app whose object is its body — a message, a note, a comment — could not declare a create action at all, because a body in a tag is not a body. Peek's manifest said "actions": [] for exactly that reason.

A field carrying target reaches you on the resolved action, so you can draw prose where the producer meant prose. Ignoring it is safe — the event is correct either way; you will just have drawn a single-line input for a paragraph.

Two consequences worth knowing before you ship it. Validation is an honour system — nothing stops you publishing a status outside the declared vocabulary, and a consumer that skips the check is the one putting junk in a shared record. And an object with no naddr offers no actions: a change names its target with an a tag, and a regular event cannot be named that way. That is the model being honest, not a gap.

Actions that delete

An action whose emits.kind is 5 is a deletion, and it arrives as control: 'confirm' — one button, no value, ask first (its effect is destructive). buildActionEvent builds NIP-09's request naming the object's address, and the relay decides who may: the author, or the owner of an agent that wrote it. Offer it to everyone and report the answer. A consumer that hid the control behind an author check of its own would hide it from the one person the relay would have let through, and could never evaluate the second half anyway (SPEC §6.5).

const action = object.actions.find((a) => a.control === 'confirm')
// after the person has confirmed:
const built = buildActionEvent({ /* …as above… */ actionId: action.id, value: '' })

The bare file's built-in manifest declares one, beside rename (a title change) and comment. A consumer that draws every field-setting action as an input will now draw a "Rename" box under a bare file's card; whether that is what its card is for is the consumer's call, and the field it sets is the one slots.title.field names, so the two are one fact either way (PEEK-18).

It also declares move (a parent change; SPEC §6.7). Its value is an address, so a text box under a card is the wrong control for it: the object names the field in parentField, and a consumer drawing property rows keeps that one out, as it keeps the title's out, and offers the move where it draws the tree.

Nesting

nestingOf(files) draws a listing's nesting — roots, childrenOf, ancestorsOf (a breadcrumb), parentOf, moveTargetsOf — in memory, from the parentRef each file already carries. Hand it the whole team listing from resolveFolderContents, never the answer to a #a query: a move is a change event whose value no relay indexes, so "what is under X?" asked by tag misses every file moved in. A parent outside the listing is not drawn, and a file on a cycle is drawn at the top, so every listed file stays reachable and no walk loops.

3. Widgets: draw what you know, degrade honestly

widget is a layout hint, and it may be a single type or an ordered chain:

"widget": ["message", "card"]   // a message if you know it, else a card

A chain always ends in one of card, row, table, stat, so there is always something you can draw.

import { pickWidget, CLOSED_WIDGETS } from '@estiva-app/interop'

const layout = pickWidget(object.widget, ['message', ...CLOSED_WIDGETS], 'card')

Never render nothing. An object that is present but blank is indistinguishable from one the reader is not allowed to see, and reports "that app is broken" about an app that is behaving correctly.


4. Make your objects renderable by other apps

Publish one kind:31990. Here is a hiring tool declaring a Candidate — an app this suite knows nothing about, which is the point.

{
  "name": "Hiring",
  "records": {
    "changeKind": 1851, "targetTag": "a", "fieldTag": "field", "valueTag": "value",
    "order": ["ts", "created_at", "id"], "rule": "last-write-wins-per-field"
  },
  "projections": {
    "31800": {
      "widget": ["candidate", "card"],
      "slots": {
        "title":    { "tag": "name" },
        "subtitle": { "tag": "headline", "truncate": 120 },
        "status":   { "fold": "stage", "map": "stages", "default": "applied" },
        "meta":     [{ "label": "Recruiter", "fold": "owner", "as": "pubkey" }],
        "list":     { "children": { "kind": 31801, "via": "a", "limit": 50 } }
      }
    }
  },
  "vocabularies": {
    "stages": [
      { "value": "applied",   "label": "Applied",   "colour": "neutral", "stage": "open" },
      { "value": "screening", "label": "Screening", "colour": "blue",    "stage": "started" },
      { "value": "hired",     "label": "Hired",     "colour": "green",   "stage": "done" },
      { "value": "passed",    "label": "Passed",    "colour": "muted",   "stage": "dropped" }
    ]
  },
  "actions": [
    {
      "id": "set-stage", "label": "Change stage", "appliesTo": "31800",
      "emits": { "kind": 1851, "field": "stage" },
      "input": { "type": "string", "enum": "stages" },
      "description": "Move a candidate to a different hiring stage",
      "effect": "writes"
    }
  ]
}

A chat app now renders your candidate — with your stages, your colours, your recruiter — in its design language, and lets someone move a stage without leaving the conversation. It knows nothing about hiring.

Six things that will bite, each of them something we got wrong first:

  • title is required. It is what makes ignoring an unknown slot safe.
  • A mutable field must be a fold, never a tag. A status that can be set by someone who is not the author does not live on the root event, so a tag renders empty for ever.
  • stage says what a status means. Without it a consumer reporting progress has to guess from your label, and the only way to guess is a list of English words — which fails for the next app that spells things differently. dropped is the one nothing can infer: neither outstanding nor progress.
  • Never truncate a field that carries structure. truncate is a plain-text operation. A subtitle promises plain text; a body carries structure. Cut markdown at 120 characters and you have published 120 characters of markup.
  • A widget chain must terminate in a closed type. ["candidate"] is not publishable. widgetChainProblem() is exported so you can check before you sign — a manifest is read by apps that cannot ask what you meant.
  • An action's description is read by a machine, not shown on a button. A caller choosing between every action every app declares has that prose and nothing else. "Add" is not rejected anywhere — your action is simply never the one chosen, and nothing tells you. actionProblems() is exported for the same reason as the check above: run it in your own tests before you sign.
  • Your objects need a Folder tag to be actionable. A consumer finds where to write by reading h, then the relay's buzz-channel, on the object being acted on; an object carrying neither is refused for having nowhere to go. folderOf() is exported so you can check what a consumer will conclude about your records.

If you own no kinds, say which aspect you render

The hiring tool above is a specialized app: it owns a kind and draws every part of it. A generic app owns nothing and renders one aspect of every file in the workspace — a chat app draws each file's conversation, a writing app each file's document (RFC 0.5 §10.7). Its manifest says so with one field:

{ "name": "Peek", "aspect": "conversation", "projections": {} }

What a consumer does with it today: a bare file (kind:30840, SPEC §6.7) has no owner and so no web template of its own, and the app that renders its conversation is where a link to it belongs. Publish aspect and a web template for naddr on the same kind:31990, and every consumer's card for a topic becomes a link into your app — with no change on their side. ASPECTS is exported and holds the two values.

The same is true of a comment (kind:1111): every app writes one and none owns it, so the built-in manifest draws it — the author as its title, the text as its body, with the message widget — and it opens by your nevent template. Publish one, or one web string for both entity types.

Say what your links look like

A URL somebody pastes is a reference too (RFC 0.5 §7.5). Declare each shape you serve as a urls tag on the manifest event, with the kind it names:

["urls", "https://peek.estiva.app/topic/<slug>-<d>", "30840"]
["urls", "https://peek.estiva.app/topic/<slug>-<d>?thread=<id>", "1111"]
["urls", "https://peek.estiva.app/message/<id>", "9"]

<d> resolves with #d, <id> with ids; the slug is decoration. A placeholder in the query is the identity, and the path's is then the container (within). A consumer calls matchObjectUrl(url, urlPatternsOf(manifest)) and gets { identifier, by, kind } or null — a URL nothing claims is a link and stays one.


5. Read a whole folder, across every app in it

A Folder is where the suite's navigation lives: teams you belong to, and the files inside them. resolveFolderContents answers "what is in this folder?" without knowing what any of the answers are.

import { resolveFolderContents, createProjectionCache } from '@estiva-app/interop'

const cache = createProjectionCache()
const folder = await resolveFolderContents(folderId, query, undefined, cache)

folder.name                       // the folder names itself
folder.files                      // ForeignObjects, exactly as §1 draws them
folder.source                     // 'state' | 'channel' — see below

Every file comes back as the same ForeignObject §1 renders, so a folder view is §1 in a loop. A project from one app and a topic from another sit side by side as peers, because RFC 0.4 §5.2 established that one tag type — a — names every file, topics included. Nothing here special-cases either.

Reuse one ProjectionCache across folders. Round trips are flat in the number of files and linear in the number of apps: 24, 25 and 9 files measured at 5, 5 and 4 requests warm.

A file you cannot see is absent, not greyed out

The count is the disclosure. A folder that renders three rows and two placeholders has told an outsider exactly how much they are missing, which for a folder named after a person is the sensitive part. So an address that resolves to nothing is dropped, and nothing in the return value counts what was dropped.

This is a deliberate divergence from upstream NIP-MP, whose fold requires the opposite for public repositories. It is also why this does not reuse resolveForeignObject, which returns unreachable: true — right for one pasted link, wrong for a list.

source tells you which model you are looking at

'state' means the relay maintains the folder's contents. 'channel' is the approximation available before that exists: containment read off each file's own h tag. It lists an app's records perfectly well and cannot list a topic, because under h a topic is the container rather than something inside it. A UI explaining a short list should read this field.

A sidebar

import { listFolders } from '@estiva-app/interop'

const folders = await listFolders(query)   // { id, name, hasState, listedIn? }[]
const topLevel = folders.filter((f) => !f.listedIn)

Asks for folders by kind rather than walking anything else. Discovering things only through their parent loses them when the parent goes — the failure RFC 0.4 §4.2 records, one level up from where it was first paid for.

A channel is addressable, so a folder can be a file in another folder — Peek's topics are, each listed by its team's state. listedIn names those containers, read off the states the call already holds, so a sidebar draws the team and not, beside it, every topic in it.

A record a state lists places its own channel there too: a project's conversation lives in the channel its buzz-channel (or h) names, and that channel's listedIn is every Folder that lists the project, so an unread verdict on it has a team to reach. It costs one more request, for the records. A channel with state of its own is a Folder, and only states place it.

Changing a folder

import { planMoveFile } from '@estiva-app/interop'

const plan = planMoveFile(me, Date.now(), {
  address,
  from: { id: here.id, hasState: here.hasState },  // from a read, never a guess
  to: { id: there.id, hasState: there.hasState },
})
if (!plan.ok) return plan.reason          // 'source-has-no-state' | 'target-has-no-state'
for (const event of plan.events) {        // publish in order; stop at the first refusal
  if (!(await publish(await sign(event))).ok) break
}

planCreateFolder, planRenameFolder, planPlaceFile, planUnlistFile and planMoveFile return unsigned events in publish order. Signing and publishing stay yours.

Every one but create takes hasState, because the relay computes a folder's next state from its current one and a folder with no kind:30890 has none: a kind:1852 against it emits state listing only what the command named, and everything filed in it by h stops being listed. So rename, place and unlist send no command to such a folder, and a move into or out of one is refused.


What is not in here, deliberately

No fold. Manifest semantics are normative; an app's interpretation of its own records is not. Two apps folding the same events are supposed to be able to differ (SPEC §10).

No rendering. Not a React dependency, not a component. A package that shipped a widget would be specifying the UI of every app that installed it.

No relay client. @estiva-app/protocol has two; this takes a function.

The specification, which outranks this package

SPEC §7 is normative and must stay sufficient to implement all of this without installing anything. If this package ever becomes the only place that knows how projection works, an interop standard has quietly been traded for a monoculture — and then a defect in it is a defect in every app at once, invisible from all of them.

The package is a reference implementation. The specification is the standard. In that order.