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

@polyphron/viper-react

v0.5.8

Published

React provider and hooks for the Viper app API.

Readme

@polyphron/viper-react

React provider and hooks for a Viper backend. A thin layer over @polyphron/viper-client — everything the client can do is still there, viper. and all.

npm install @polyphron/viper-react

React 18.3 or 19.

Use it

Wrap the app once:

import { ViperProvider } from "@polyphron/viper-react"

createRoot(document.getElementById("root")!).render(
  <ViperProvider url="http://localhost:8090" project="<project id>">
    <App />
  </ViperProvider>
)

Then read records:

import { useRecords } from "@polyphron/viper-react"

function Posts() {
  const { data, loading, error } = useRecords("posts", {
    sort: "-created",
    perPage: 20,
    live: true, // re-fetch whenever a post changes
  })

  if (loading) return <p>Loading…</p>
  if (error) return <p>Something went wrong.</p>

  return <ul>{data?.items.map((p) => <li key={p.id}>{String(p.title)}</li>)}</ul>
}

Log in:

import { useAuth } from "@polyphron/viper-react"

function SignIn() {
  const { user, auth } = useAuth("users")

  if (user) return <button onClick={() => auth.logout()}>Sign out</button>

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const data = new FormData(e.currentTarget)
        auth.loginWithPassword(String(data.get("email")), String(data.get("password")))
      }}
    >
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button>Sign in</button>
    </form>
  )
}

The hooks

With personal tokens or device sign-in enabled, useAuth().auth exposes createToken, tokens, revokeToken, startDeviceSignIn, authorizeDevice and pollDeviceSignIn. Polling an approved device saves its normal session, so useUser() updates. Only display the user code and verification URI; keep the device code private and honor each polling result's interval.

With email change confirmation enabled, useAuth() exposes auth.requestEmailChange(newEmail) and auth.confirmEmailChange(token). Both need the current user's session. After confirming, call auth.me() to refresh the email shown by useUser().

| Hook | What it gives you | | --- | --- | | useViper() | The Viper client — writes go through it: viper.collection("posts").create(…) | | useRecords(collection, options) | One page of records: { data, loading, refreshing, error, reload } | | useRecord(collection, id, options) | One record, same shape | | useAuth(collection) | { user, auth } for an auth collection | | useSession() / useUser() | The session / the logged-in user, re-rendering on change | | useSessionOf(client, read) | The same for any client with onSessionChange (the admin client, say); read returns what to show, like () => admin.user | | useAuthMethods({ collection?, enabled? }) | How to sign in here (methods, sign-up, project branding), or null while it loads | | useSessions() | The user's active sessions: { sessions, error, loading, refresh, revoke, logoutEverywhere } | | useInvites() | Invites the user sent: { invites, error, loading, refresh, invite, cancel } | | useTenants(name) | The user's memberships of tenant collection name; never the last user's list | | useRun(name, started, { every? }) | Follows a background workflow's run until it ends: { data, error, pending } | | useRealtime(topics, onEvent) | Watch changes yourself; returns the connection status | | useRealtimeStatus() | "closed", "connecting" or "live" |

useRecords and useRecord take live: true to re-fetch on every change to what they are showing. The last good data stays on screen while a reload is in flight, so lists don't flash.

  • loading is true only until the first answer for what is shown. A reload or a live update does not set it again. Changing the collection (or the record id, for useRecord) clears data and sets it again, so a form never sees the previous record.
  • refreshing is true whenever a request is in flight, first load included. Use it for a small spinner. (Before, loading was also true during reloads; read refreshing if you relied on that.)
  • With live: true, the list is also re-fetched once after the socket comes back from a drop, since events may have been missed while it was down.

useSessions and useInvites both give loading (true until the first answer for this user), error (an Error or null) and refresh(), which resolves once the new answer is in. They key their state on the signed-in user, so after a user switch you never see the last user's data. useLoaded(subject, load) is the helper they and useRecords are built on; use it to write your own fetching hook. It returns the same Query shape as useRecords.

@polyphron/viper-client is a peer dependency: install it next to this package, so the app and the hooks share one client.

Writes

There is no mutation hook: the client's own methods are already promises.

const viper = useViper()
const { reload } = useRecords("posts")

await viper.collection("posts").create({ title: "Hello" })
reload() // or pass live: true and let realtime do it

Viper collections (experimental)

@polyphron/viper-react/db puts a Viper collection into TanStack DB, so you can run live queries over records kept in the browser. It is opt-in: the client and the hooks above don't need it, and the API may change. Install the peers, pinned to the versions it is tested with:

npm install @tanstack/[email protected] @tanstack/[email protected]
import { createCollection, eq } from "@tanstack/db"
import { useLiveQuery, viperCollectionOptions } from "@polyphron/viper-react/db"

const posts = createCollection(
  viperCollectionOptions<Post>({ viper, collection: "posts" })
)

function Live() {
  const { data } = useLiveQuery((q) =>
    q.from({ p: posts }).where(({ p }) => eq(p.status, "live"))
  )

  return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

// Shows at once, is saved in the background, and is rolled back if the
// server refuses it. Give it your own 15-character id.
posts.insert({ id, title: "Hi" })
  • It loads every record the list rule lets the user see, then applies realtime events straight to the copy: no request per change. After a dropped connection, or a refresh from a view collection, it loads again.
  • Writes call the records API. Several in one transaction go to viper.batch() and succeed or fail together. The server's answer (id, created, updated, stamped defaults) replaces the optimistic row.
  • A realtime event older than the row's updated is ignored, so an echo of your own write can't undo a newer one.
  • Eager (the default) suits small collections. For big ones pass syncMode: "on-demand": nothing loads until a live query asks, and then only its where, orderBy and limit are sent, as a Viper filter, sort and page. What Viper can't say (not, nested paths, null) is left out, so a little more loads and the query filters it again; like and ilike become ~ (a case-insensitive contains). A limit is only sent when the whole where and orderBy could be. Realtime events are applied for every row the user may see, and a reload fetches the queries again. Paging is by page number with skipTotal, so the server never counts; a keyset cursor cannot be used, because TanStack DB asks for an offset and Viper's cursor is a token only the previous page hands out.
  • Rules stay on the server; the copy holds only what the list rule and realtime allow.

Hooks

useViperCollection<Row>(name, { syncMode? }) gives the collection for the client in <ViperProvider>, made once and shared by every component that asks for the same name and mode. useLiveRecords<Row>(name, { syncMode?, query? }) reads it as { data, error, loading } (data is null until loaded), with query for a filter, sort or join. Both are opt-in; useRecords and useRecord work as before.

A live server page

For a table that pages on the server (a text filter, totals, expand, the Deleted view), useLivePage<Row>(name, query) holds one page of records.list in a TanStack DB collection and keeps it fresh. query takes what records.list takes: page, perPage, sort, filter, expand, deleted.

const { items, totalItems, totalPages, loading, collection } =
  useLivePage<Post>("posts", { page, perPage: 30, sort: "-updated", filter })

collection.delete(ids) // hidden at once, one batch, put back if refused
collection.utils.refetch() // ask for the page again
  • A change that can't move rows between pages (an edit that keeps the sort values, on a page with no filter) patches its row; no request. Anything else (a create, a delete, a filter the browser can't run) asks for the page again, once per burst. expand is kept while the links don't change.
  • While a new query loads, the last page stays on screen.
  • viperPage(viper, name, query).preload() starts the fetch early, from a route loader say; the hook then reads the same collection.
  • collection.utils.apply(event) shows a change you made yourself (a save) without waiting for its realtime event.

Any endpoint as a collection

For data that isn't records (settings, keys, logs, anything an endpoint hands back whole), fetched(owner, options) makes a TanStack DB collection from a fetch, and useFetched(collection) reads it as { data, error, isPending, isFetching, updatedAt, refetch }.

import { fetched, refetchFetched, useFetched } from "@polyphron/viper-react/db"

const settings = (p: string) =>
  fetched<Settings>(client, {
    key: ["projects", p, "settings"],
    fetch: (signal) => api.settings(p, { signal }),
  })

const { data } = useFetched(settings(projectId))

await save(input)
await refetchFetched(client, ["projects", projectId]) // every key under it
  • The same owner and key always give the same collection, so every reader shares one load. A list (pass getKey) keeps the server's order; a single object is one row.
  • It loads again when told to (refetchFetched by key prefix, or collection.utils.refetch()), on refetchInterval while read, and when the window comes back after staleTime (30 seconds by default). Only changed rows are rewritten, so the rest keep their identity.
  • collection.utils.set(data) puts in a save's answer with no request. await collection.utils.ready() waits for the first load, from a route loader say. forgetFetched(owner, prefix) drops collections (after a delete).
  • Network errors retry twice; a "no" or "not found" doesn't. The error is in error, and the last good data stays.
  • useFetched(collection, { keepPrevious: true }) keeps the last data on screen while a new collection (the next page, a new filter) loads. useFetchedAll(collections) reads several, one per project say.

The Viper dashboard reads the whole admin API this way, through @polyphron/viper-react/db/admin (adminCollections(admin)). That entry stays in the workspace, like the admin client it wraps, and isn't published.

Recipe: typed collections, a join and an optimistic insert

  1. Write the types once with viper typegen --out src/viper-types.ts (see the root README). Each collection gets a row type and a Collections map.
  2. Get each collection with useViperCollection<Collections["posts"]>("posts").
  3. Read with useLiveQuery, joining collections in the browser.
  4. Write with collection.insert. The row shows at once, and is rolled back if the server refuses it, so put the form text back in that case.
import { eq } from "@tanstack/db"
import { useLiveQuery, useViperCollection } from "@polyphron/viper-react/db"
import { useState, type FormEvent } from "react"

import type { Collections, PostCreate } from "./viper-types"

// Viper ids are 15 characters. The row needs one before the server answers.
const newId = () =>
  Array.from(
    { length: 15 },
    () => "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 36)]
  ).join("")

export function LivePosts({ authorId }: { authorId: string }) {
  const posts = useViperCollection<Collections["posts"]>("posts")
  const authors = useViperCollection<Collections["authors"]>("authors")
  const [title, setTitle] = useState("")

  // A join between two collections; only changed rows re-render.
  const { data = [], isLoading } = useLiveQuery((q) =>
    q
      .from({ p: posts })
      .innerJoin({ a: authors }, ({ p, a }) => eq(p.author, a.id))
      .where(({ p }) => eq(p.status, "live"))
      .orderBy(({ p }) => p.created, "desc")
      .select(({ p, a }) => ({ id: p.id, title: p.title, by: a.name }))
  )

  const add = async (event: FormEvent) => {
    event.preventDefault()

    const draft: PostCreate & { id: string } = {
      id: newId(),
      title,
      status: "live",
      author: authorId,
    }
    // The cast is because insert wants a whole row; the server fills in
    // `created`, `updated` and defaults.
    const tx = posts.insert(draft as Collections["posts"])

    setTitle("")

    try {
      await tx.isPersisted.promise
    } catch {
      setTitle(draft.title)
    }
  }

  if (isLoading) return <p>Loading</p>

  return (
    <form onSubmit={add}>
      <ul>
        {data.map((row) => (
          <li key={row.id}>
            {row.title} by {row.by}
          </li>
        ))}
      </ul>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
    </form>
  )
}