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.
pnpm add boop-node
# npm i boop-node · yarn add boop-nodeThe 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
})// 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).
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.
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) }))- Never throws from
send/sendAsync; every failure is a result value or anonErrorcall. - Redacts
password,secret,token,api_key,authorization,cookie,private_keyand friends anywhere indatabefore sending (the server does it again). - Truncates over-long strings (title 200, body 4000) rather than rejecting; drops
dataover 256 KB, or that won't JSON-serialise, with a note inbody. - 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.
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 }) })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.
pnpm install
pnpm test # vitest
pnpm typecheck # tsc --noEmit
pnpm build # tsup → distNode is pinned for development in .nvmrc and .tool-versions; the published
package supports Node 18+.
MIT.