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

@frondruntime/hub

v0.4.0

Published

Frond devtools hub: a local daemon that attached runtimes stream to.

Readme

@frondruntime/hub

A local devtools daemon. Frond runtimes attach to it and stream their event history in; a coding agent reads that history — and the graph as it stands right now — back out over MCP.

Run

bunx @frondruntime/hub

Bun, not Node. This package ships as TypeScript source rather than a bundle: its bin is src/cli.tsx behind a #!/usr/bin/env bun shebang, and it renders with Ink. bunx runs it. npx does not — Node reaches the .tsx and dies on a parse error that says nothing about why, which is the worst way to learn about a runtime requirement. Whatever starts the hub has to start it with Bun.

It binds 127.0.0.1:17391 and takes over the terminal with a dashboard of attached runtimes. --host and --port override the defaults, but the port is worth leaving alone: apps hardcode it, and a hub somewhere else looks from the app's side exactly like a hub that is not running. --version prints both numbers — frond-hub v0.4.0 (protocol 2) — because they answer different questions: the release is what a bug report needs, and the protocol is what an attaching app compares. See Version mismatches.

A port collision is fatal rather than silently resolved, for the same reason.

The dashboard takes ↑↓ to move the cursor, p to pause the feed, / to filter, and q to quit. Keys are live only on a terminal: with stdin redirected the dashboard still renders and stops accepting input, rather than failing on a raw-mode request the stream cannot serve.

While it runs, it writes .frond/hub-<port>.json in the working directory and removes it on exit. readHubLock from @frondruntime/devtools/node reads it.

Attach an app

import { attachDevtools } from "@frondruntime/devtools";

if (import.meta.env.DEV) {
  attachDevtools({ runtime, name: "my-app" });
}

No URL and no credential: the socket is unauthenticated and the address is the default. Attaching never throws, never rejects, and returns before it connects, so the call is safe in an entry file — but guard it anyway, because a production build that dials whatever answers on loopback is not a devtools client.

The hub attaches to itself the same way, through the same public entry point, so the client half is exercised end to end on every boot. Its own row sorts to the bottom of the dashboard and is marked (self) — it is always attached and is never the thing being debugged, so leaving it in arrival order would put the cursor on it every time a fresh hub has nothing else to show.

Connect an agent

claude mcp add --transport http frond http://127.0.0.1:17391/mcp

Four tools, all read-only:

  • frond_list_runtimes — what is attached, and how much history the hub still holds for each. Start here; the others take an attachmentId from it.
  • frond_read_events — events oldest first, filterable by tag, category, severity, and nodeId, paged with since. Signals also filter by channel and name, which the record carries beside its tag: category: "signal" is every publication on the app's message bus, channel narrows to one bus, name to one kind of message — none of which reads a payload, so the filters work the same on an app that publishes shapes as on one that publishes values.
  • frond_read_work — every event belonging to one workId, which is one whole acquire/refresh/action cascade. The tool for "what actually happened when that failed".
  • frond_read_state — the graph as it is now, rather than how it got here. Omit nodeId for the whole topology; pass one to get that node with its result and the edges on either side of it.

Every event reply carries a coverage block, because the honest answer to "did anything else happen?" is often "yes, and the hub no longer has it". evictedByHub counts what the ring dropped; droppedBySender counts what the app dropped before sending. oldestRetainedSequence above 1 means history is missing from the front — normally just because the hub subscribed after the runtime started, but also after a reconnect, where generation above 0 is the tell.

Reading state

A whole-graph read answers a topology question, and answering it with a few hundred node results would bury the topology under the values. So results are withheld from it entirely and appear only on a single-node read:

{
  "capturedAt": 1738000000000,
  "sequence": 4412,
  "runtimeId": "runtime-1",
  "runtimeStatus": "running",
  "graphStatus": "running",
  "observedInputs": 3,
  "values": "shape",
  "nodes": [
    {
      "nodeId": "smoke/config:v1:\"singleton\"",
      "tag": "smoke/config",
      "kind": "service",
      "state": "Ready",
      "revision": 3,
      "status": "Wired{_tag,run}",
      "liveDemand": "{isLive,sources,scopes}",
      "operation": "Idle{_tag}"
    }
  ],
  "edges": [
    {
      "from": "smoke/feed:v1:{\"region\":\"eu-west\",\"topic\":\"orders\"}",
      "to": "smoke/config:v1:\"singleton\"",
      "dependency": "config"
    }
  ]
}

A nodeId is tag:key, which is why it reads like a fragment of JSON with a tag stuck to the front — the key half is the canonical encoding of that node's identity, and quoting it back is how you ask about that one node. state is the arm every reader branches on; revision is core's per-node write counter, the cheap way to tell "still the value I saw" from "recomputed to something that looks the same". Edges run from the dependent to its dependency, named by the slot the dependent declared.

Pass that nodeId back to get the node itself, its result, and every edge touching it in either direction — "what does this depend on" and "what breaks if this does" being the same question asked twice. The node entry from that reply, on an app that has opted into full:

{
  "nodeId": "smoke/config:v1:\"singleton\"",
  "tag": "smoke/config",
  "kind": "service",
  "state": "Ready",
  "revision": 3,
  "result": { "failureRate": 0.18, "baseLatencyMs": 40 }
}

Line a snapshot up against the event log using capturedAt and sequence: the latter is the last event the runtime had emitted when the snapshot was taken, so a frond_read_events page and a frond_read_state reply are two accounts of one system rather than two systems.

The value policy

none < shape < full. The hub asks for full on every state read and takes what it is given: an attached app declares a ceiling that defaults to shape, and the lower of the two is what actually crosses. The common outcome is therefore that real values never leave the app.

That is the intended arrangement rather than a gap in it. The decision to put an application's real values on a socket belongs to the app that owns them, not to the tool reading them — and the runtimes worth attaching devtools to are exactly the ones holding tokens and account state.

At shape, a value arrives as a one-line descriptor of itself: Wired{_tag,run}, string[42], Map(3), {failureRate,baseLatencyMs}. Enough to see the structure of a graph, never enough to read what is in it — which is why the whole-graph read above shows descriptors where the single-node read, against an app that raised its ceiling, shows the value.

Every snapshot reports the policy it was actually built under, in its own values field, because a reader that finds no result on a node has to be able to tell "redacted" from "not ready" — those are the same absence otherwise. frond_list_runtimes reports each app's declared ceiling in the same field. What each policy sends, in detail, is in the devtools README.

Which attachment a call reads

Every read takes an optional attachmentId. Omitted, the hub resolves one, and the rules are worth knowing because the interesting case used to be silent:

  • One app attached — that one, with no round trip. Making the common case cost an extra call is how a tool ends up unused.
  • Only the hub attached — a refusal that says how to attach an app, and gives the hub's own attachment id for reading the hub anyway. The hub is always in the list, so it would otherwise be the sole candidate on an idle machine, and answering "what does my graph look like" with the devtools' own graph is a wrong answer wearing the shape of a right one.
  • Several apps — an error naming every candidate, so the next call can be right. A guess the caller cannot see is worse than a question it can answer.

Naming the hub's own attachment id explicitly still resolves. Inspecting the hub is a real thing to want; only the accidental case was the bug.

Refusals come back as ordinary results tagged McpReadError, not as MCP errors. Raising would render each one through Cause.pretty and spend the caller's context window on six stack frames that are about Effect rather than about the mistake.

Version mismatches

The two halves must agree on HUB_PROTOCOL_VERSION exactly. There is no capability negotiation, because a partial mismatch presents as "the runtime stopped emitting" — an app that cannot decode a command it has never heard of drops its socket, and that is the most expensive way this can fail.

So the hub refuses outright, in one line naming both numbers and which side is behind, and the app prints that line and stops. It does not keep dialing: every retry would send the same version and earn the same answer. A refusal is terminal and loud, where every other attach failure is retried and quiet.

Restarts

Nothing here is durable, deliberately. An app that loses the hub retries every two seconds and comes back as the same instanceId with generation bumped, but the events it emitted while disconnected are gone, and stopping the hub discards every retained ring. That is the intended shape of a tool you point at a process you are actively working on — if you need history that survives a restart, you need a sink, not a devtools hub.

Security

Unauthenticated on both paths, deliberately and only for local development. The hub binds loopback and the attach socket carries data one way, so the worst an uninvited local caller does is put junk in a dashboard — but that junk then reaches an agent through MCP. Records are not proof of their own origin: a reader that treats a record's tag or fields as instructions rather than as data is trusting whatever managed to open a socket.

MCP is also the direction in which app data leaves, which is what the value policy is for. That policy is enforced by the app rather than by the hub, so an app's ceiling holds no matter what the endpoint asks for.

Do not point this at production data, and do not expose the port beyond loopback.

Develop

bun --conditions=source apps/hub/src/cli.tsx
bun --conditions=source test apps/hub/test
bun --conditions=source apps/hub/smoke/readState.ts

The smoke script is the end-to-end check for frond_read_state: a hub, two apps, and an MCP client in one process, over real websockets and real HTTP, asserting on which attachment each call resolves to. It listens beside the default port so it can run while a development hub holds 17391.

The hub is itself a Frond app — canonical nodes, an Ink UI built on @frondruntime/react — so it reports on itself with no special-casing. isHubInternal is the one exception: it drops records whose nodes are all hub/-prefixed, without which applying a batch would emit the events that make up the next batch.

AI use

Frond is AI-assisted (mainly Claude and Codex), iterated over months rather than one-shot generated. Full note: https://frondruntime.dev/ai-use