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

@projectpac/plugin-registry

v0.6.0

Published

Part of PAC: @projectpac/plugin-registry.

Readme

@projectpac/plugin-registry

What a node could install, and what each thing is for. It exists because a plugin's manifest deliberately does not say: the schema is strict and carries an id, a kind and its routes, and no description, no title, no readme. So a person deciding whether to run somebody's code has, from the node alone, a name and a category. This is where the sentence comes from.

It holds the names as much as the descriptions. An id is what a node calls a plugin, what its folder under the data directory is named, and what a reader matches an installed plugin against -- so a registry that lists the core plugins is one in which those ids are taken. It is this table's primary key, so two plugins claiming negotiation is not a thing that can happen quietly.

It serves flows and adapters from one list. Inside a node those are different kinds with different seams; from outside they are the same decision -- something you did not have, that you now want your node to run. There is one endpoint.

It is not the node directory beside it, and the two are worth telling apart:

| | node directory | plugin registry | | ------------------ | ---------------------------------- | -------------------------- | | holds | claims nodes make about themselves | one operator's list | | who writes | anybody, signed by their node key | the operator, with a token | | lifetime | presence expires | durable until removed | | losing it costs | one republish tick | one registry:publish |

Nothing here is a package host. An entry carries a spec; fetching it is the node's own package source doing what it already does.

What an entry is

{
  "id": "negotiation",
  "package": "@projectpac/[email protected]",
  "description": "The negotiation engine: the rounds and the settlement...",
  "publisher": "core",
  "tier": "extra",
  "manifest": { "id": "negotiation", "kind": "flow-service", "serviceKey": "negotiation", "...": "..." }
}

Three things about that shape are deliberate:

  • package is what the node is handed, under the node's own name for it. POST /plugins {package} takes it verbatim. It names an exact version, because a floating spec resolves to whatever is newest, which becomes a different sdk generation the day one is published -- and a manifest from the wrong generation is refused by a node's strict schema with nothing useful to say about why. Where the bytes come from is not repeated beside it: the spec's own shape says, and the node decides which of its package sources serves it.
  • kind and serviceKey are not repeated outside the manifest. They are in it, it is served with every entry, and a field held twice is a field that disagrees with itself. id is the exception, because it is the key, and a check holds it to the manifest's.
  • A readme is not a field. It rides in on the same write and comes back on its own route, because the readmes outweigh the rest of the catalogue several times over and only the row somebody opened needs one.

The routes

| method | path | who | answers | | ------ | --------------------- | --------- | --------------------------------------------------- | | GET | /health | anybody | {ok, plugins: N} -- counts, not contents | | GET | /plugins | anybody | {plugins: [...]} -- the whole list, no readmes | | GET | /plugins/:id/readme | anybody | one readme, as text/plain | | PUT | /plugins/:id | the token | register or replace one entry, with its readme | | DELETE | /plugins/:id | the token | remove one |

Reading needs nobody's permission, because a node has no credential to offer one. Writing needs PAC_REGISTRY_TOKEN and there is no other way in: no signature, unlike the directory, because there is no id being claimed here and so nothing for a key to prove. What a write asserts is "I am the person who runs this registry", and a bearer token is exactly that claim.

A registry started without a token does not start, and says which variable it wanted. That is deliberately not "open on loopback, closed elsewhere": conditional auth is how a service ends up writable in production because somebody moved a bind address. Nor is it "read-only until one is set": a catalogue is registered rather than committed, so a registry nobody can write to is one that will never list anything.

Readmes are served as text, never html. Rendering one is the reader's business, and a registry that served markup would be a registry that could inject some.

Filling one

The catalogue is registered, not committed. A list baked into this package would need a release to correct; a list checked in beside the code would be a second copy of facts that already exist, in each package's own metadata where sync-manifests.ts put them. So a script walks the checkouts and PUTs what it finds, and the same script fills a registry on a laptop and the deployed one:

pnpm registry:list                       # what the walk finds, sending nothing
pnpm registry:publish --url https://registry.example.com --token "$TOKEN" --prune

Nothing is imported. A manifest is read out of package.json, which is the whole point of it being copied there: describing a plugin must not mean running it. --prune also removes rows the walk did not produce, which is what makes a deployed registry match the source rather than accumulate; without it they are listed and left alone, because a walk that came up short for some other reason must not take the catalogue with it.

The description is the one thing that cannot be generated. No manifest carries a sentence, and every package.json description in this constellation is the package's own name repeated -- so it comes from each README's opening paragraph, which in this codebase is reliably the module's own top-of-file comment in prose. Where that reads badly out of context, scripts/descriptions.json holds a hand-written one keyed by npm name, and it wins. A package with neither stops the publish rather than being listed undescribed.

Two packages are absent because they declare no manifest at all -- @projectpac/executor-cli, since the worker composes executors from its own list, and @projectpac/artifacts, which is a contract two stores implement. @projectpac/artifacts-memory does declare one and is excluded by name: it is the in-memory double a suite uses instead of git, and its own README says it must not be installable as though it were the store. It is not marked private, so nothing else would keep it out.

Hosting one

One process, one sqlite file, and one token.

PAC_REGISTRY_TOKEN=dev-token npx @projectpac/plugin-registry

It listens on 127.0.0.1:7461 and writes plugin-registry.db beside you. The token is the one variable it will not start without; three more move the rest:

| variable | default | what it is | | -------------------- | -------------------- | ------------------------------------------------------- | | PAC_REGISTRY_TOKEN | required | the token a write must carry | | PAC_REGISTRY_HOST | 127.0.0.1 | the interface to bind | | PAC_REGISTRY_PORT | 7461 | the port | | PAC_REGISTRY_DB | plugin-registry.db | the sqlite file, resolved against the working directory |

Hosting one for real

The same recipe as the node directory beside it. Both carry better-sqlite3, so the "check the binary actually built" step applies to both.

1. Node, outside anybody's home directory

sudo cp -a "$NVM_DIR/versions/node/$(node -v)" /opt/node
sudo ln -sf /opt/node/bin/node /usr/local/bin/node
sudo ln -sf /opt/node/bin/npm /usr/local/bin/npm

2. Install it once

sudo /usr/local/bin/npm install --prefix /opt/pac-plugin-registry \
  @projectpac/plugin-registry
ls /opt/pac-plugin-registry/node_modules/better-sqlite3/build/Release/better_sqlite3.node

If that binary is missing the service crashes on its first start: sudo /usr/local/bin/npm rebuild --prefix /opt/pac-plugin-registry better-sqlite3.

3. A user, a directory, and a token

sudo useradd --system --no-create-home --shell /usr/sbin/nologin pac
sudo mkdir -p /var/lib/pac
sudo chown pac:pac /var/lib/pac
printf 'PAC_REGISTRY_TOKEN=%s\n' "$(openssl rand -hex 32)" \
  | sudo tee /etc/pac-registry.env >/dev/null
sudo chmod 600 /etc/pac-registry.env

That is its own file rather than a line in the unit, because a unit is world-readable and a token is not.

4. The unit

/etc/systemd/system/pac-plugin-registry.service:

[Unit]
Description=PAC plugin registry
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pac
Group=pac
WorkingDirectory=/var/lib/pac
ExecStart=/usr/local/bin/node /opt/pac-plugin-registry/node_modules/@projectpac/plugin-registry/dist/main.js
Environment=PAC_REGISTRY_HOST=127.0.0.1
Environment=PAC_REGISTRY_PORT=7461
Environment=PAC_REGISTRY_DB=/var/lib/pac/plugin-registry.db
EnvironmentFile=/etc/pac-registry.env
Restart=always
RestartSec=2

# It needs one directory and one socket, and nothing else on the machine.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/pac
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes
SystemCallArchitectures=native

[Install]
WantedBy=multi-user.target

MemoryDenyWriteExecute= is deliberately absent: v8 writes the code it executes, so setting it stops the process from starting at all.

sudo systemctl daemon-reload
sudo systemctl enable --now pac-plugin-registry
curl -s localhost:7461/health
{ "ok": true, "plugins": 0 }

Empty, because nothing has been registered yet. From a checkout with the sibling repos beside it:

pnpm registry:publish --url https://registry.example.com --token "$TOKEN" --prune

5. https, which is not optional

The unit binds loopback on purpose: the terminator in front holds the certificate, and 7461 never faces the internet.

Serve it over https even though the list is public, for the reason the directory gives about its own board -- every record read from one steers what a node does next. Here it is sharper than there: an entry carries the spec a node will hand to npm, so anyone able to rewrite this list in flight chooses what gets installed. And the token crosses the same wire. A client enforces it rather than advising it: the desktop app refuses a registry that is neither https nor on the machine asking.

registry.example.com {
    reverse_proxy 127.0.0.1:7461
}

Two things have to be true before Caddy can get a certificate: an A record for the name pointing at this box, and inbound 80 and 443 open. Leave 7461 closed.

What it costs to lose

One registry:publish. Everything in the database was walked out of the checkouts and can be walked again; back it up if you like, but nothing depends on it surviving.

What it does not do

It has no accounts and no submissions: there is one token, and whoever holds it may write anything. publisher exists so a third-party entry could be told apart from a core one, and every entry today says core because there is no way for anybody else to add one. When submissions arrive they need a per-publisher credential, a review step, and a rule about who may claim an id -- none of which is built. Until then a plugin nobody has listed is installed the way it always was, by naming its package.