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

@commersive/xnd

v0.2.0

Published

Push custom components to Xanadu — a built web app becomes a placeable layer on a retail wall.

Readme

xnd — custom components for Xanadu

Build a web app, push it, and an author places it as a layer in the editor like any other. Your settings become their form; your events become their triggers.

npx @commersive/xnd login          # email + six-digit code, same as the CMS
npx @commersive/xnd init my-component
cd my-component             # build your app into ./dist
npx @commersive/xnd push

Or install it, which gives you the xnd command:

npm i -g @commersive/xnd
xnd push

The scope is not optional. Unscoped xnd on npm is an unrelated package somebody else published in 2021, so npx xnd runs a stranger's code. The command you get is still plain xnd — that comes from the bin field.

From a checkout of this repo instead:

pnpm run cli:build          # produces apps/cli/dist/xnd.mjs
node apps/cli/dist/xnd.mjs login

Let Claude build it

npx @commersive/xnd skill install

Writes a skill into ~/.claude/skills/xanadu-component/ — the schema, the postMessage contract, the limits, the push flow, and the wall-specific design notes. Open a new session, ask for a Xanadu component, and it is picked up automatically. --dir .claude/skills installs it project-scoped instead; --force overwrites an older copy after a CLI upgrade.

The skill ships inside the CLI rather than being downloaded, so it works on a plane and can promise that what it describes is what your version does.

What a component is

A built web app — html, css, js and images — that runs inside a sandboxed iframe in an authored experience, served from its own hostname (lyrs.xnd.media). It is a layer on the canvas: an author positions it, animates it, shows and hides it, and wires what it emits.

It is deliberately not trusted. The frame has no allow-same-origin, so it runs on an opaque origin: no cookies, no access to the page around it, and no storage APIs at all. If you need to persist something, emit an event and let the experience decide.

xnd.json

Lives beside your package.json. Everything except dist, slug and organizationId is the schema the editor reads.

{
  "organizationId": "…",
  "slug": "spin-to-win",
  "name": "Spin to win",
  "version": "1.0.0",
  "entry": "index.html",
  "dist": "dist",
  "settings": [
    { "id": "headline", "type": "text",  "label": "Headline", "default": "Spin to win" },
    { "id": "accent",   "type": "color", "label": "Accent",   "default": "#22D3EE" },
    { "id": "prizes",   "type": "number","label": "Prizes",   "default": 6, "min": 2, "max": 12 },
    { "id": "hero",     "type": "asset", "label": "Backdrop" }
  ],
  "events": [
    { "id": "win",  "label": "Player wins", "payload": { "prizeId": "string" } },
    { "id": "lose", "label": "Player loses" }
  ],
  "commands": [{ "id": "reset", "label": "Start again" }]
}

Setting types: text, textarea, number, boolean, color, select (with options), asset (an image from the account's library), product.

A version is immutable. Pushing one that already exists is a 409 — bump it. That is what lets a published experience pin component@version and know that what is on a wall today is what was on it yesterday.

Talking to the experience

const V = 1;
const send = (msg) => parent.postMessage({ v: V, ...msg }, "*");

addEventListener("message", (e) => {
  const m = e.data;
  if (!m || m.v !== V) return;

  if (m.type === "init") {
    // m.mode      — "run" on a wall, "edit" while somebody is positioning you
    // m.settings  — the author's values, by setting id
    // m.assets    — resolved urls for `asset` settings, by setting id
    // m.size      — the layer's authored width and height
  }

  if (m.type === "command") {
    // m.id — one of your declared commands, e.g. "reset"
  }
});

send({ type: "ready" });               // ask for init
send({ type: "event", id: "win", payload: { prizeId: "p-1" } });

Two rules worth knowing rather than discovering:

  • Only declared events get through. The host drops anything not in your events list — the schema is the allowlist, not documentation.
  • init arrives after ready, and again on every settings change, so an author dragging a colour picker sees it live. Re-render; don't assume once.

Limits

| | | |---|---| | Images | downscaled to 1920×1080, 5 MB each (install sharp and the CLI does it for you) | | Video and audio | cannot be bundled, in any container | | Other files | 5 MB each, 40 MB and 250 files per bundle | | Paths | relative only — build with base: './' |

Video is refused because the platform already has a video layer the timeline can seek, the scheduler can pre-cache and the kiosk agent can take offline. A clip inside an iframe is none of those. Need moving pictures? Place a video layer behind or over your component, or point at an asset-library url — those are served from the CMS and cached on the wall like any other asset.

Absolute paths (/app.js) are refused too: your files are served from a versioned folder, so a leading slash points outside it. It works in your dev server and 404s on the wall, which is the worst kind of bug to ship.

Where things end up

Files land in R2 under components/<componentId>/<version>/… and are served by the lyrs worker with a one-year immutable cache. A kiosk mirrors the whole folder to disk on sync, preserving your relative layout, so a component keeps working with the store's network down.