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

event-bridge-client

v1.2.0

Published

Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands.

Readme

event-bridge-client

A lightweight client for connecting an application to a control backend: register the app, batch and push lifecycle events, and handle HMAC-signed remote commands.

Install

npm install event-bridge-client

(Or pnpm add / yarn add.)

Quickstart (Hono)

import { createManagementClient } from "event-bridge-client";

const client = createManagementClient({
  baseUrl: process.env.BRIDGE_BASE_URL!,
  apiKey: process.env.BRIDGE_API_KEY!,
  callbackUrl: "https://api.example.com/bridge/commands",
  callbackSecret: process.env.BRIDGE_CALLBACK_SECRET!,
  capabilities: ["user.ban", "user.unban"],
});

client.onCommand("user.ban", async ({ externalUserId, reason }) =>
  ({ ok: true, result: await banAccount(externalUserId, reason) }),
);

app.route("/bridge/commands", client.middleware.hono());

await client.register();

// Anywhere in your app:
client.events.emit("user.created", {
  externalUserId: "usr_123",
  email: "[email protected]",
});

Payments

Take money through the backend's payment module. You create a payment (which gives you a hosted checkout URL to send the payer to) and receive a webhook when it settles — both through the same client, authenticated with the same key.

Do not emit payment.* as events; inbound payment events are rejected. The payments API and the onPayment webhook are the only payment path.

// 1. Create a payment → redirect/send the payer to the checkout URL.
const { payment, hostedCheckoutUrl } = await client.payments.create({
  externalId: "order_4821",          // your idempotency key
  currency: "UZS",
  lineItems: [{ name: "Pro plan — 1 month", unitAmount: 120_000 }],
  customer: { email: "[email protected]", externalId: "usr_123" },
});
// hostedCheckoutUrl → e.g. https://pay.example.com/payment/<id>

// 2. Receive the outcome. The same callback middleware that handles commands
//    verifies the signature and dispatches here. Make it idempotent —
//    delivery is at-least-once.
client.onPayment(async (n) => {
  if (n.type === "payment.settled") await markOrderPaid(n.data.externalId);
  if (n.type === "payment.failed")  await markOrderFailed(n.data.externalId);
});

// (Optional) Poll instead of / in addition to the webhook:
const latest = await client.payments.getByExternalId("order_4821");

Notes:

  • externalId is your idempotency key. Re-creating with the same one returns the existing payment (idempotent: true).
  • Line items take either an ad-hoc name + unitAmount, or a catalogue variantId (price resolves server-side from the variant).
  • Non-base currencies require rateToUzs.
  • The webhook POSTs to your callbackUrl by default. Pass notifyUrl on create() to send a specific payment's webhook somewhere else.

Managed resources

Beyond commands, an app can declare resources — any entity it wants visible and manageable from the control backend (users, orders, anything). The backend renders a generic list / search / detail view + action buttons straight from the descriptor — no backend-side code changes to add one.

Records are never shipped to the backend; it proxies list / get / action queries back to your app over the same signed callback channel, on demand.

client.defineResource({
  key: "widgetUser",
  label: "Widget User",
  labelPlural: "Widget Users",
  titleField: "email", // the field shown as each record's headline
  fields: [
    { key: "id", label: "ID", type: "string", listVisible: false },
    { key: "email", label: "Email", type: "email", filterable: true },
    { key: "plan", label: "Plan", type: "enum", enumValues: ["free", "pro"] },
    { key: "createdAt", label: "Joined", type: "datetime", sortable: true },
    { key: "banned", label: "Banned", type: "boolean" },
  ],
  actions: [
    {
      capability: "widgetUser.ban",
      label: "Ban",
      confirm: true,
      destructive: true,
      fields: [{ name: "reason", label: "Reason", kind: "textarea", required: true }],
    },
  ],
  // Answer a paginated, filtered list query.
  list: async ({ page, pageSize, q }) => {
    const { rows, total } = await myDb.searchWidgetUsers({ page, pageSize, q });
    return { records: rows, total };
  },
  // Optional — fetch one record for the detail view.
  get: async (id) => myDb.findWidgetUser(id),
  // Optional — run a declared action.
  action: async ({ capability, recordId, params }) => {
    if (capability === "widgetUser.ban") {
      await myDb.banWidgetUser(recordId, String(params.reason));
      return { ok: true, message: "User banned" };
    }
    return { ok: false, error: "unknown action" };
  },
});

await client.register(); // descriptors are sent with register()

Resource queries ride the same callback middleware — nothing extra to mount. Each record SHOULD carry an id field; the backend uses it to open the detail view and to address get / action calls. Field type is one of: string, number, boolean, date, datetime, enum, currency, badge, email, url, json. Action capabilities are declared here on the resource — they do not need to go in the top-level capabilities array (that array is for command-flow capabilities only).

Options

| Option | Default | Notes | |-------------------|-------------|-------------------------------------------------------------| | baseUrl | required | Control backend base URL | | apiKey | required | API key minted by the backend admin | | callbackUrl | required | HTTPS URL the backend POSTs commands + payment webhooks to | | callbackSecret | required | HMAC shared secret minted alongside the API key | | capabilities | required | Strings matching command types, e.g. user.ban | | enabled | true | If false, all methods are no-ops (safe staged rollout) | | batchIntervalMs | 1500 | Event batcher flush interval | | batchMaxSize | 100 | Force-flush when this many events are queued | | maxBufferSize | 10000 | Hard cap on buffered events; oldest dropped past it | | maxRetries | 6 | Exponential backoff retries for event batch POSTs | | nonceStore | in-memory | Replay-protection store; supply a shared one for multi-instance apps |

Receiving commands (Express)

The inbound signature is computed over the raw request bytes. If a JSON body parser consumes the stream first, those bytes are lost and every verification fails. Either mount this middleware before express.json(), or capture the raw bytes:

app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
app.post("/bridge/commands", client.middleware.express());

The Hono middleware reads the raw body itself, so it needs no special setup.

Replay protection across instances

The default replay cache is in-process — it only protects a single instance. If you run more than one instance behind a load balancer, supply a shared nonceStore (e.g. Redis) so a captured command can't be replayed against another instance inside the 300-second signature window:

const nonceStore = {
  has: (nonce: string) => redis.exists(`bridge:nonce:${nonce}`).then((n) => n > 0),
  add: (nonce: string, ttlMs: number) =>
    redis.set(`bridge:nonce:${nonce}`, "1", "PX", ttlMs).then(() => undefined),
};