Skip to content

Repository files navigation

boop-node

npm Downloads License

Something happened in your app. Tell your phone.

The Node.js client for Boop, a tiny self-hosted push notification inbox.

It exists so a deploy finishing, a backup failing, or a payment landing is one line away from a native push on your iPhone, with nothing between your server and Apple:

import boop from 'boop-node'

await boop.send('Deploy complete')

Zero runtime dependencies. Node 18+. ESM and CommonJS, with types.

Installation

pnpm add boop-node
# npm i boop-node · yarn add boop-node

Configuration

The default export reads BOOP_URL and BOOP_API_KEY from the environment. Or construct a client:

import { Boop } from 'boop-node'

const boop = new Boop({
  url: 'https://boop.example.com',
  apiKey: process.env.BOOP_API_KEY,
  source: 'my_app',                       // optional: tags every event
  enabled: process.env.NODE_ENV === 'production', // default true
  timeout: 10_000,                        // ms, default
  redactKeys: ['ssn'],                    // extra keys to redact inside data
  maxRetries: 2,                          // default, for network errors and 5xx
  retryDelay: 200,                        // ms, backoff base (jittered), default
  onError: (error, payload) => log.warn(error.code, payload.title), // for sendAsync failures
})

Usage

// Minimum
await boop.send('Backup complete')

// Rich
await boop.send({
  title: 'Payment received',
  body: '£19.99',
  level: 'success',
  source: 'stripe',
  data: { customerId: '123', amount: 19.99, currency: 'GBP' },
})

// Fire and forget: returns immediately, reports failures via onError, never throws
boop.sendAsync({ title: 'Cron finished', level: 'info' })

send resolves to a result and never rejects:

const result = await boop.send('x')
if (result.ok) console.log(result.id)          // "evt_…", result.createdAt is a Date
else console.warn(result.error.code)           // 'invalid' | 'not_configured' | 'unauthorized' | 'rejected' | 'server_error' | 'unreachable' | 'unexpected'
// Is the server up? Never throws; false if unconfigured or unreachable.
if (!(await boop.healthy())) console.warn('boop is down')

The default client's methods are also exported directly, if you would rather not name it:

import { send, sendAsync, healthy } from 'boop-node'

Levels: info (default), success, warning, error, critical (prominent push).

Fields: title (required, ≤200 chars), body (≤4000), level, source, type, externalId, fingerprint (≤200 each), occurredAt (Date or ISO 8601), data (plain object).

Errors with stacktraces

try {
  await risky()
} catch (err) {
  boop.sendAsync({
    title: err instanceof Error ? err.name : 'Error',
    body: err instanceof Error ? err.message : String(err),
    level: 'error',
    data: boop.exception(err, { tags: { env: 'prod' }, context: { userId } }),
  })
  throw err
}

exception, stacktrace, tags, context and breadcrumbs in data get a rich rendering in the Boop web UI and iOS app. Anything else is kept as-is.

Express / Fastify / Next.js

Nothing special. Put a client in a module and import it; use sendAsync inside request handlers so a slow or down Boop server never affects a response. For unhandled errors:

process.on('unhandledRejection', (reason) => boop.sendAsync({ title: 'Unhandled rejection', level: 'error', data: boop.exception(reason) }))

Behaviour

  • Never throws from send/sendAsync; every failure is a result value or an onError call.
  • Redacts password, secret, token, api_key, authorization, cookie, private_key and friends anywhere in data before sending (the server does it again).
  • Truncates over-long strings (title 200, body 4000) rather than rejecting; drops data over 256 KB, or that won't JSON-serialise, with a note in body.
  • Retries network errors and 5xx twice with jittered backoff (maxRetries, retryDelay); never retries 4xx.
  • A timeout on every request (AbortController).
  • Never logs the API key or full payloads.

Testing your app

Pass a fake fetch, or point url at a local stub server, or set enabled: false so every send resolves { ok: true, disabled: true }.

const boop = new Boop({ url: 'https://boop.test', apiKey: 'k', fetch: async () => Response.json({ id: 'evt_1', created_at: new Date().toISOString() }, { status: 201 }) })

Building on it

The pieces the client is made of are exported too, if you want them on their own: buildPayload (validate and normalise an event into the wire payload), exceptionData, parseStack, redact, BoopError (with a retryable getter), and the LEVELS, LIMITS and DEFAULT_REDACT_KEYS constants.

Development

pnpm install
pnpm test        # vitest
pnpm typecheck   # tsc --noEmit
pnpm build       # tsup → dist

Node is pinned for development in .nvmrc and .tool-versions; the published package supports Node 18+.

Licence

MIT.

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages