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

@synonymdev/pubky-pulse-node

v0.2.1

Published

Pubky Pulse Node.js server SDK

Readme

@synonymdev/pubky-pulse-node

Pubky Pulse Node.js server SDK — logging, errors, metrics and funnels for backend services.

Test

Zero runtime dependencies. Works with any Node.js framework, and on serverless runtimes.

Install

npm install @synonymdev/pubky-pulse-node
# pnpm add @synonymdev/pubky-pulse-node
# yarn add @synonymdev/pubky-pulse-node

Requires Node.js 20+.

Quick start

ESM:

import { Pulse } from "@synonymdev/pubky-pulse-node";

CommonJS:

const { Pulse } = require("@synonymdev/pubky-pulse-node");

Configure once at process start, then log from anywhere:

Pulse.configure({
  apiKey: "pulse_client_...",
  serviceName: "api",
  appVersion: "1.4.2",
});

// Log events
Pulse.info("User signed up", { screen: "onboarding" });
Pulse.error(new Error("Payment failed"), "Checkout failed", { orderId: "abc123" });

// Track metrics
const op = Pulse.startOperation("api-request");
// ... do work ...
op.complete({ route: "/users" });

// Record funnel steps
Pulse.step("signup-started");

// Scope events to a user and a browser/app session
Pulse.withUser("user_123").withSession(sessionIdFromHeader).info("Cart updated");

Leaving endpoint out sends events to Pubky's hosted instance at https://ingest.pubkypulse.com, and the fallback is silent, so a self-hosted deployment that omits it ships its data to Pubky's instance instead of its own. The optional endpoint needs 0.2.0 or newer; on 0.1.1 and earlier it is always required.

Use it in your environment

Create one module that configures the SDK exactly once and export it — importing that module from your handlers avoids reconfiguring on every request.

// lib/pulse-server.ts
import { Pulse } from "@synonymdev/pubky-pulse-node";

Pulse.configure({
  endpoint: process.env.PULSE_ENDPOINT!,
  apiKey: process.env.PULSE_API_KEY!,
  serviceName: "web",
  appVersion: process.env.APP_VERSION,
});

export { Pulse };

Express

import express from "express";
import { Pulse } from "./lib/pulse-server.js";

const app = express();

app.use((req, _res, next) => {
  const sessionId = req.get("x-pulse-session-id");
  req.pulse = sessionId ? Pulse.withSession(sessionId) : Pulse;
  next();
});

app.post("/api/checkout", async (req, res) => {
  // req.user is populated by your auth middleware.
  const pulse = req.pulse.withUser(req.user.id);
  const op = pulse.startOperation("checkout", { item: req.body.item });
  try {
    const receipt = await charge(req.body);
    op.complete({ item: req.body.item });
    res.json(receipt);
  } catch (err) {
    pulse.error(err, "Checkout failed", { item: req.body.item });
    op.fail("charge_failed");
    res.status(500).json({ error: "Checkout failed" });
  }
});

// Drain the buffer on shutdown so in-flight events are not lost.
process.on("SIGTERM", async () => {
  await Pulse.shutdown();
  process.exit(0);
});

Fastify

import Fastify from "fastify";
import { Pulse } from "./lib/pulse-server.js";

const fastify = Fastify();

fastify.decorateRequest("pulse", null);
fastify.addHook("onRequest", async (request) => {
  const sessionId = request.headers["x-pulse-session-id"];
  request.pulse = sessionId ? Pulse.withSession(sessionId) : Pulse;
});

fastify.post("/api/greet", async (request) => {
  // request.user comes from your auth plugin.
  const pulse = request.pulse.withUser(request.user.id);
  pulse.info("Greeted", { name: request.body.name });
  return { message: `Hello, ${request.body.name}!` };
});

fastify.addHook("onClose", async () => {
  await Pulse.shutdown();
});

Next.js — App Router route handler

// app/api/checkout/route.ts
import { getSession } from "@/lib/auth";
import { Pulse } from "@/lib/pulse-server";

export async function POST(req: Request) {
  const body = await req.json();
  // The user identity comes from your session helper, never from the body.
  const session = await getSession();
  const sessionId = req.headers.get("x-pulse-session-id");
  const pulse = sessionId
    ? Pulse.withUser(session.userId).withSession(sessionId)
    : Pulse.withUser(session.userId);

  const op = pulse.startOperation("checkout", { item: body.item });
  try {
    const receipt = await charge(body);
    op.complete();
    return Response.json(receipt);
  } catch (err) {
    pulse.error(err, "Checkout failed");
    op.fail("charge_failed");
    return Response.json({ error: "Checkout failed" }, { status: 500 });
  }
}

On a long-running server the SDK flushes on its own interval, but on serverless hosts (Vercel functions, Lambda) wrap the handler with Pulse.wrapHandler so the buffer is flushed before the function is frozen — see AWS Lambda / Vercel functions below.

Next.js — server action

"use server";

import { headers } from "next/headers";
import { getSession } from "@/lib/auth";
import { Pulse } from "@/lib/pulse-server";

export async function submitFeedback(message: string) {
  // The user identity comes from your session helper, never from the caller.
  const session = await getSession();
  const sessionId = (await headers()).get("x-pulse-session-id");
  const pulse = sessionId
    ? Pulse.withUser(session.userId).withSession(sessionId)
    : Pulse.withUser(session.userId);

  pulse.step("feedback-submitted");
  await pulse.sendFeedback(message);
}

The same applies here: on serverless hosts (Vercel functions, Lambda) wrap the handler with Pulse.wrapHandler so the buffer is flushed before the function is frozen — see AWS Lambda / Vercel functions below.

AWS Lambda / Vercel functions

Serverless runtimes can freeze the process the moment a handler returns, so the background flush timer may never fire. Wrap the handler — wrapHandler awaits a flush in a finally block, so buffered events leave before the runtime suspends.

import { Pulse } from "./lib/pulse-server.js";

export const handler = Pulse.wrapHandler(async (event) => {
  // The caller's identity comes from the authorizer context.
  const userId = event.requestContext.authorizer.userId;
  const sessionId = event.headers?.["x-pulse-session-id"];
  const pulse = sessionId
    ? Pulse.withUser(userId).withSession(sessionId)
    : Pulse.withUser(userId);
  pulse.info("Job started", { jobId: event.jobId });
  const result = await run(event);
  return { statusCode: 200, body: JSON.stringify(result) };
});

Pairing with the browser SDK

The browser half of Pubky Pulse is @synonymdev/pubky-pulse-web. It sends an X-Pulse-Session-Id header with requests to your backend; pass that value into Pulse.withSession(...) as shown above and browser and server events land on one session timeline. Non-UUID values are ignored (the scope falls back to the process session ID), so an untrusted header can never crash a handler.

Logging

Four levels, all with the same shape. Attribute values are strings and are truncated if they get long.

Pulse.debug("Cache miss", { key });
Pulse.info("Order placed", { orderId, total: String(total) });
Pulse.warn("Payment gateway slow", { ms: String(elapsed) });
Pulse.error("Queue backed up", { depth: String(depth) });

Set consoleLogging: false to stop the SDK echoing events to the console, and debug: true to see the SDK's own diagnostics.

Errors

error() takes either a message or an error value. Passing the error extracts its type, stack, cause chain, AggregateError children and Node code/syscall/path fields into reserved _error_* attributes — the server fingerprints issues on _error_type, so different error classes with the same wording stay on separate issues.

try {
  await doWork();
} catch (err) {
  Pulse.error(err, "Work failed", { jobId });
}

Unhandled errors are captured automatically: configure() installs additive uncaughtException and unhandledRejection listeners that record the error and then preserve Node's default crash behaviour. Opt out with captureUnhandled: false.

Metrics and operations

An operation measures a unit of work and emits start/complete/fail/cancel events carrying its duration. Metric slugs should be lowercase letters, numbers and hyphens.

const op = Pulse.startOperation("photo-conversion", { format: "heic" });
try {
  await convert();
  op.complete({ bytes: String(size) });
} catch (err) {
  op.fail("convert_failed", { reason: err.message });
}

// Or a single-shot metric with no duration:
Pulse.recordMetric("cache-warmed");

Funnels

Pulse.step("signup-started");
Pulse.step("signup-email-verified");
Pulse.step("signup-completed");

Identity scoping

withUser and withSession return an immutable ScopedPulse and chain in either order.

const pulse = Pulse.withUser("user_123").withSession(sessionId);
pulse.info("Settings saved");
pulse.startOperation("settings-save").complete();

User properties

Properties merge server-side — keys you leave out are preserved, and an empty string value removes a key.

Pulse.setUserProperties("user_123", { plan: "pro", company: "Acme" });

// Or from a user-scoped instance:
Pulse.withUser("user_123").setUserProperties({ plan: "pro" });

Feedback

Forward feedback your own frontend collected. Throws on failure, so wrap it in try/catch.

try {
  const receipt = await Pulse.withUser(userId).sendFeedback(message, {
    name: "Ada",
    email: "[email protected]",
  });
  console.log(receipt.id, receipt.createdAt);
} catch (err) {
  // 4xx responses surface as thrown errors carrying the server's message
}

Attachments

Attach a file on disk or in-memory bytes to any event. Uploads run in the background and are drained by flush() / shutdown().

Pulse.error("Import failed", { file: name }, {
  attachments: [
    { path: "/tmp/import.csv" },
    { buffer: Buffer.from(report), name: "report.json", contentType: "application/json" },
  ],
});

Flush, shutdown and serverless

Events are buffered and flushed on a timer (flushIntervalMs) or once flushThreshold events are queued. A beforeExit hook makes a best-effort final flush.

  • await Pulse.flush() — send everything buffered now, keep the SDK usable.
  • await Pulse.shutdown() — flush, remove the unhandled-error listeners and tear down.
  • Pulse.wrapHandler(fn) — wrap a serverless handler so it flushes in a finally.

Failed sends are retried with exponential backoff, up to six attempts. On a 429 or 503 the server's Retry-After header (delta-seconds or an HTTP-date) is honoured whenever it asks for longer than the backoff would wait, capped at 60 seconds. flush() and shutdown() wait for a send already in flight — including one sleeping between retries — and then drain anything buffered meanwhile, so a clean exit never drops a batch.

Configuration

| Option | Type | Default | Description | |---|---|---|---| | endpoint | string | https://ingest.pubkypulse.com | Pubky's hosted ingest host; a trailing slash is stripped, and self-hosters must set their own explicitly | | apiKey | string | — (required) | Client key for a server-platform app; must start with pulse_client_ | | serviceName | string | "unknown" | Service name used for logging/debugging | | appVersion | string | — | Application version reported with each event | | debug | boolean | false | Print the SDK's own diagnostics to console.error | | flushIntervalMs | number | 5000 | Background flush interval | | flushThreshold | number | 20 | Buffered events that trigger an immediate flush | | maxBufferSize | number | 10000 | Buffer cap; oldest events are dropped past it | | isDev | boolean | process.env.NODE_ENV !== "production" | Mark events as development builds | | consoleLogging | boolean | true | Echo events to the console | | captureUnhandled | boolean | true | Auto-capture uncaught exceptions and unhandled rejections |

Example

A runnable demo server lives at Examples/Demo/. It exercises the full SDK surface (operations, feedback, user properties, wrapHandler) and resolves the SDK via file:../.., so it doubles as a pre-release smoke test.

Development

npm ci
npm test          # build + unit tests

Integration tests

Integration tests run against a live Pubky Pulse API server and are not part of CI. Point them at your server with three environment variables:

| Variable | Description | |---|---| | PULSE_TEST_ENDPOINT | API server base URL (default http://127.0.0.1:4112) | | PULSE_TEST_SERVER_KEY | A pulse_client_ key for a server-platform app | | PULSE_TEST_AGENT_KEY | A pulse_agent_ key used to read events back for assertions |

PULSE_TEST_ENDPOINT=http://127.0.0.1:4112 \
PULSE_TEST_SERVER_KEY=pulse_client_... \
PULSE_TEST_AGENT_KEY=pulse_agent_... \
npm run test:integration

Links

License

MIT — see LICENSE.