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

@ulvio/client

v0.4.0

Published

Official TypeScript client for the Ulvio platform, html-to-pdf, and utilities services.

Readme

@ulvio/client

Official TypeScript client for the Ulvio platform.

A single Ulvio class exposes domain-scoped sub-clients (mail, mailbox, sms, whatsapp, voice, files, ai, html-to-pdf, utilities), all reachable via the same baseUrl + apiKey. The platform proxies html-to-pdf and utilities to their internal services transparently, so consumers only need one set of credentials.

Install

npm install @ulvio/client

Requires Node.js 24+.

Configuration

import { Ulvio } from '@ulvio/client';

const client = new Ulvio({
  baseUrl: process.env.ULVIO_BASE_URL,
  apiKey: process.env.ULVIO_API_KEY,
});

Both baseUrl and apiKey are required for every sub-client. If either is missing when a method is called, the call throws an UlvioError with code not_configured — distinct from runtime/network errors, so consumers can detect misconfiguration explicitly:

import { UlvioError, NOT_CONFIGURED_CODE } from '@ulvio/client';

try {
  await client.mail.sendTransactional({ ... });
} catch (err) {
  if (err instanceof UlvioError && err.code === NOT_CONFIGURED_CODE) {
    // surface a deployment-time configuration error
  }
  throw err;
}

Rate-limit handling

When the platform returns a 429 with code: "RATE_LIMITED", the client transparently waits for the window indicated by Retry-After (falling back to x-ratelimit-reset, then the message hint) and retries the request. Concurrent in-flight calls coalesce on a single pause timer per Ulvio instance, so a single rate-limit response doesn't fan out into a retry storm.

A 403 with the same RATE_LIMITED code is the platform's ban response and is not retried — it surfaces as an UlvioError with code rate_limited_banned.

Bounds and opt-out are configurable:

const client = new Ulvio({
  baseUrl: process.env.ULVIO_BASE_URL,
  apiKey: process.env.ULVIO_API_KEY,
  rateLimit: {
    maxRetries: 3,      // default 3
    maxWaitMs: 60_000,  // default 60s cumulative
    disabled: false,    // set true to throw immediately on a 429
  },
});

If retries are exhausted or the cumulative wait would exceed maxWaitMs, the call throws an UlvioError with code rate_limited. Use isRateLimitedError to handle both:

import { isRateLimitedError } from '@ulvio/client';

try {
  await client.mail.sendTransactional({ ... });
} catch (err) {
  if (isRateLimitedError(err)) {
    // back off or surface a 503 to your caller
  }
  throw err;
}

Usage

Mail

await client.mail.sendTransactional({
  from: '[email protected]',
  to: ['[email protected]'],
  subject: 'Welcome',
  body_html: '<p>Hi!</p>',
});

Mailbox

const { messages } = await client.mailbox.list(20);
const msg = await client.mailbox.get(messages[0].id);
await client.mailbox.markProcessed(msg.id);

getConnectorStatus() reports whether the upstream connector is healthy. When unhealthy, the listing/reading methods throw an UlvioError whose code is CONNECTOR_UNHEALTHY — use the isConnectorUnhealthyError helper to detect it:

import { isConnectorUnhealthyError } from '@ulvio/client';

try {
  await client.mailbox.list();
} catch (err) {
  if (isConnectorUnhealthyError(err)) {
    // back off polling
  } else {
    throw err;
  }
}

SMS / WhatsApp / Voice

await client.sms.send({ from: '+1...', to: '+1...', body: 'hello' });
await client.whatsapp.send({ to: '+1...', template_name: 'reminder', language_code: 'en' });

// Transcribe (speech-to-text) — from an inline base64 buffer…
await client.voice.transcribe({ file: base64Audio, file_name: 'call.webm' });
// …or by URL the platform fetches server-side (provide one of file / url).
await client.voice.transcribe({ url: 'https://cdn.example.com/call.webm' });

// Synthesize (text-to-speech) — returns base64-encoded audio in `data`.
const { data } = await client.voice.synthesizeSpeech({ voiceId: 'voice_123', text: 'Hello there' });

WhatsApp: templates, session messages, and webhooks

All WhatsApp sending goes through the platform proxy — no Meta access token, phone number id, or app secret lives on the consumer.

// List the templates configured on the Business Account. Defaults to APPROVED
// (the ones sendable without an open 24h session); widen with { status }.
const { templates } = await client.whatsapp.getTemplates();
const pending = await client.whatsapp.getTemplates({ status: 'PENDING' });

// Business-initiated template carrying a quick-reply payload. The payload is
// echoed back on the inbound webhook so you can correlate the reply.
await client.whatsapp.send({
  to: '+1...',
  template_name: 'consent',
  language_code: 'nl',
  components: [
    {
      type: 'button',
      sub_type: 'quick_reply',
      index: 0,
      parameters: [{ type: 'payload', payload: workOrderId }],
    },
  ],
});

// Inside the 24h customer-service window opened by a button tap, send
// non-template messages. Meta's error is passed through if the window is closed.
await client.whatsapp.sendDocument({
  to: '+1...',
  document: { link: 'https://.../work-order.pdf', filename: 'work-order.pdf', caption: 'Your work order' },
});
await client.whatsapp.sendText({ to: '+1...', text: 'Thanks — your work order is on its way.' });

Meta delivers webhook POSTs directly to your endpoint. Verify their signature via the platform (which holds the app secret) and check the registered callback URL on startup:

// In your webhook route — `rawBody` must be the unparsed bytes Meta signed.
const ok = await client.whatsapp.verifyWebhookSignature({
  rawBody,
  signature: req.headers['x-hub-signature-256'] ?? '',
}); // -> false when the signature is missing or invalid

// On startup: confirm Meta points at the URL this instance expects.
const sub = await client.whatsapp.getWebhookSubscription();
if (sub.callback_url !== expectedUrl) log.warn('WhatsApp webhook URL mismatch', sub);

Files

await client.files.upload('reports/2026/q1.pdf', buffer, 'application/pdf');
const res = await client.files.get('reports/2026/q1.pdf');
const { url } = await client.files.presignedDownloadUrl('reports/2026/q1.pdf', 600);

Proxy

Forward any third-party API call through the Ulvio server instead of calling it directly. The server holds the upstream base URL and credentials for each named target (e.g. "hubspot"), so consumer apps only ever talk to Ulvio and the production firewall can be closed to "only the Ulvio server reaches the internet".

proxy.fetch(target, path, init?) is fetch-shaped: you pass a relative path (the client never names the upstream host) and get the raw Response back. Authentication is your normal Ulvio API key; upstream credentials are injected server-side and never touch the client.

const res = await client.proxy.fetch('hubspot', '/crm/v3/objects/contacts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ properties: { email: '[email protected]' } }),
});

// fetch semantics: an upstream non-2xx is returned on the Response, not thrown.
if (!res.ok) console.error('upstream error', res.status);
const contact = await res.json();

Targets are configured on the server (ulvio-dev/server#53).

AI proxy

import { z } from 'zod';

const Person = z.object({ name: z.string(), age: z.number() });

const data = await client.ai.parse({
  model: 'claude-opus-4-7',
  input: 'Extract the person from: "Ada Lovelace, 36"',
  schema: Person,
});

for await (const event of client.ai.stream({
  model: 'claude-opus-4-7',
  input: '...',
  schema: Person,
})) {
  if (event.type === 'partial') console.log(event.data);
  if (event.type === 'complete') console.log('done', event.data);
}

HTML-to-PDF

const { pdf } = await client.htmlToPdf.convert(
  { html: btoa('<h1>Invoice</h1>'), outputMode: 'base64' },
  {
    onQueued: ({ position }) => console.log('queued at', position),
    onProcessing: ({ progress }) => console.log('progress', progress),
  },
);

Or have the service PUT the rendered PDF directly to a presigned URL:

await client.htmlToPdf.convert({
  sourceUrl: 'https://example.com/invoice/1',
  uploadUrl: presignedPutUrl,
});

Utilities

const { html } = await client.utilities.compileMjml({ mjml: '...' });
const { result } = await client.utilities.renderLiquid({ template: 'Hi {{ name }}', data: { name: 'Ada' } });
const { variables } = await client.utilities.extractLiquidVariables({ template: '{{ a }} {{ b }}' });
const { html: md } = await client.utilities.renderMarkdown({ markdown: '# title' });
const { html: email } = await client.utilities.renderEmail({ mjml: '...', data: { ... } });

Error tracking

Report errors and events to Ulvio Server's in-core error-tracking module via the errors namespace. It posts to POST /v1/errors using the same Bearer apiKey — no second dependency, no separate DSN. The server resolves the target environment from the API key.

try {
  await risky();
} catch (err) {
  await client.errors.capture(err, {
    level: 'error',               // 'fatal' | 'error' | 'warning' | 'info' | 'debug'
    transaction: 'GET /users/:id',
    tags: { region: 'eu-west-1' },
    extra: { userId: 42 },
  });
}

capture() accepts an Error (its name, message and stack become a structured exception) or a plain message string, plus an optional context object:

| Field | Description | | --- | --- | | level | Severity. Defaults to 'error'. | | environment | Deployment label (e.g. 'production'). A label only — attribution still comes from the API key. | | transaction | Route / operation name, e.g. 'GET /users/:id'. Grouping fallback. | | platform | Platform hint. Defaults to 'node'. | | release | Release identifier (git SHA, semver, …). | | serverName | Host the event originated from. | | tags | Indexed key/value tags, filterable in the issue UI. | | extra | Arbitrary extra metadata. | | user | User context (id, email, …). The server scrubs ip_address when configured. | | request | Request context (method, url, headers, …). Pass a plain, serializable object. | | fingerprint | Explicit grouping hints, overriding the default fingerprint. |

Best-effort and non-throwing. Reporting must never crash the host app, so capture() never throws — it returns an ErrorCaptureResult:

const res = await client.errors.capture(err);
if (!res.ok) {
  // res.error is a UlvioError describing why ingestion failed
  console.warn('error reporting failed', res.error?.code);
}
// res.status is 'queued' on success, or 'throttled' when the server
// deliberately sampled the event out (also a success — do not retry).

Fastify global error handler

Wire setErrorHandler once to report every unhandled route error. Extract the fields you want from the request rather than passing the raw req (it is not safely serializable):

import { Ulvio } from '@ulvio/client';

const ulvio = new Ulvio({ /* baseUrl, apiKey */ });

app.setErrorHandler((err, req, reply) => {
  // Fire-and-forget; capture() is non-throwing.
  void ulvio.errors.capture(err, {
    transaction: `${req.method} ${req.routerPath ?? req.url}`,
    tags: { method: req.method },
    request: { method: req.method, url: req.url },
  });
  reply.send(err);
});

Migrating from 0.2.x to 0.3.0

The 0.3.0 release collapses the four per-service config fields to a single { baseUrl, apiKey } pair, and routes html-to-pdf and utilities through the platform forwarder.

  • Replace platformApiUrl / htmlToPdfApiUrl / utilitiesApiUrl with one baseUrl pointing at api.ulvio.dev (or your ulvio-server URL).
  • Replace platformApiKey with apiKey — the same key now authenticates every sub-client.
  • The legacy env vars (ULVIO_PLATFORM_API_URL, HTML_TO_PDF_API_URL, UTILITIES_API_URL) should be replaced with ULVIO_BASE_URL + ULVIO_API_KEY.
  • The three legacy error codes (platform_not_configured, html_to_pdf_not_configured, utilities_not_configured) are gone; catch not_configured (exported as NOT_CONFIGURED_CODE) instead.
// before (0.2.x)
new Ulvio({
  platformApiUrl: process.env.ULVIO_PLATFORM_API_URL,
  platformApiKey: process.env.ULVIO_PLATFORM_API_KEY,
  htmlToPdfApiUrl: process.env.HTML_TO_PDF_API_URL,
  utilitiesApiUrl: process.env.UTILITIES_API_URL,
});

// after (0.3.0)
new Ulvio({
  baseUrl: process.env.ULVIO_BASE_URL,
  apiKey: process.env.ULVIO_API_KEY,
});

The platform side must be running a version that exposes /html-to-pdf/* and /utilities/* forwarders (ulvio-dev/ulvio-server).

Development

npm install
npm run typecheck
npm test
npm run build

End-to-end tests run against live containers via Docker Compose:

npm run test:e2e

Error tracking lives in Ulvio Server (Postgres-backed) rather than the platform compose stack, so it has a standalone smoke test you point at a running server with the error-tracking module enabled for the API key's environment:

ULVIO_BASE_URL=https://api.your-ulvio.dev ULVIO_API_KEY=sk_... npm run smoke:errors

Releasing

Tag the commit with the version (vX.Y.Z); the Release workflow verifies the tag matches package.json and runs npm publish --provenance.

npm version patch   # bumps package.json + creates a vX.Y.Z tag
git push --follow-tags

The workflow needs an NPM_TOKEN secret with publish access to the @ulvio scope.