easy-pingv0.7.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Getting started / Configuration

Edit this page

Configuration

The full easyPing() option reference.

The full shape

Every option easyPing() accepts, in one place:

notify.ts
easyPing({
  // --- required ---
  database,   // drizzleAdapter(db) | mongoAdapter(db, { client })
  secret,     // signs unsubscribe links and other session-less URLs

  session: {
    getUserId: (request: Request) => string | null | Promise<string | null>,
  },

  getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>,

  notifications: {
    [typeName: string]: defineNotification({
      schema?,        // any Standard Schema validator — zod, valibot, arktype
      channels,       // readonly Channel[]
      email?,         // { subject, template }
      maxAttempts?,   // overrides delivery.maxAttempts for this type
    }),
  },

  channels: {
    inApp?: { enabled: boolean },
    email?: { provider: EmailProvider },
  },

  // --- optional ---
  cron?: { secret, maxSweeps? },  // required by "cron" and "deferred" — throws without it
  machineSecret?,     // plugin machine routes; falls back to cron.secret
  trustedOrigins?,    // origins other than the request host that may POST; "*.example.com" ok
  onRequest?,         // (request) => Response | void — runs before routing
  rateLimit?: { max, windowMs, key? },  // in-process limiter, applied before routing
  maxBodyBytes?,      // default 65536

  delivery?: {
    mode?: "inline" | "deferred" | "worker" | "cron",   // default "cron"
    waitUntil?: (promise: Promise<unknown>) => void,     // "deferred" needs this
    maxAttempts?: number,   // default 5
    leaseMs?: number,       // default 60000 — must exceed the slowest provider timeout
    batchSize?: number,     // default 20
    backoff?: "exponential" | ((attempt: number) => number),
    throwOnError?: boolean, // "inline" only
    sweepOnRequest?: true | { everyMs?: number, limit?: number },  // bounded pass after any request
  },

  events?: {                  // GET /events, the stream behind the bell; false removes the route
    heartbeatMs?: number,     // default 25000 — keepalive comment interval
    probeIntervalMs?: number, // default 30000 — fingerprint probe when signals are in-process only
    maxDurationMs?: number,   // default 0 (no limit) — close streams before a host's cap does
    maxStreamsPerUser?: number, // default 10 — more open streams for one user get a 429
    maxStreams?: number,      // default 5000 — more open streams in this process get a 503
  } | false,
  signals?: Signals,          // default in-memory; postgresSignals(sql) | mongoSignals(db) across replicas

  plugins?: [],
  basePath?,      // default "/api/notifications" — must match where you mount the handler
  tablePrefix?,   // must match the prefix you passed to the adapter
  logger?,        // { warn, error } — defaults to console
});
Recipient

getRecipients must return { userId, timezone, locale, email?, phone?, pushTokens? }. timezone and locale are required — the digests plugin resolves send windows in the recipient's own timezone, and email templates read the locale.

secret, cron.secret, machineSecret

secret signs the unsubscribe links the preferences plugin issues, and any other session-less URL a plugin needs to authenticate without a cookie. Rotate it and every outstanding link breaks — that's the tradeoff for not storing tokens in a table.

cron.secret authenticates the /cron route. It is a separate secret on purpose: cron access and unsubscribe-link signing are different trust boundaries, and rotating one should never invalidate the other. machineSecret optionally does the same for plugin machine routes (/push/prune, /digests/cron); unset, they accept cron.secret.

Each must be at least 16 characters and not a placeholder such as changeme, or construction throws. openssl rand -base64 32 produces a suitable value.

Fails loudly

If delivery.mode is "cron" or "deferred" and cron.secret is missing, easyPing() throws a ConfigError at construction — your app won't start. That's deliberate: the alternative is mounting an unauthenticated flush-everything endpoint against your email provider, and a crash at boot is a far better outcome than that shipping quietly.

easyPing() validates eagerly and refuses to construct on any of these: a missing database, notifications, secret, session.getUserId or getRecipients; a weak or placeholder secret; a duplicate plugin id; a dependsOn naming a plugin that isn't registered; two plugins claiming the same route; or a rateLimit with a non-positive max or windowMs. None of those degrade into a runtime surprise.

Request hardening options

OptionDefaultDoes
trustedOrigins[]Origins other than the request host allowed to POST. Every POST must also be application/json.
onRequestRuns before routing; return a Response to short-circuit.
rateLimitoff{ max, windowMs, key? }, in-process fixed window, 429 with Retry-After. Keyed by client address from the usual proxy headers unless key says otherwise.
maxBodyBytes64 KiBCap on any JSON body, enforced while streaming.
cron.maxSweeps50Sweeps one POST /cron may run before it returns.

See Security for what each one defends against.

session.getUserId

Not optional. Every mounted route that reads or writes "the current user's" data resolves through this function — there's no way to opt out per-route. Return the user id, or null to make the request 401. Throwing surfaces as a 500, which is deliberately distinct from an unauthenticated request.

session: {
  getUserId: async (request) =>
    (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
},

getRecipients

Called once per send(), with every recipient id in that call — never once per recipient. Return whatever a channel needs: an email address for the email channel, a timezone for the digests plugin, a locale if your templates use one.

getRecipients: async (userIds) =>
  db.query.users.findMany({ where: inArray(users.id, [...userIds]) })
    .then((rows) => rows.map((u) => ({
      userId: u.id,
      email: u.email,
      timezone: u.timezone ?? "UTC",
      locale: u.locale ?? "en",
    }))),

channels

inApp is built in and needs no provider — just { enabled: true }. email needs a provider; Resend ships one, or implement the EmailProvider interface yourself. Any other channel — push, sms, slack — is added by a plugin declaring it, not by this object. See Web push for the one that ships.

delivery.mode

See Delivery modes for the full comparison. The short version: inline delivers before send() returns; the other three deliver after, at different latencies, and all of them need the /cron sweep running as the durability floor underneath.

events and signals

events tunes GET /events, the server-sent event stream the client holds instead of polling. heartbeatMs is the keepalive comment interval (keep it under any proxy idle timeout). maxDurationMs closes each stream after that long so a host with a hard response cap closes it cleanly instead of killing it; the client reconnects. events: false removes the route, and notify.listRoutes() stops listing it.

Streams are capped: maxStreamsPerUser (10) returns a 429 with Retry-After to a user who already holds that many in this process, and maxStreams (5000) returns a 503 when the process is full. One browser holds one stream, so ten covers ten devices. A stream whose consumer stops reading is closed once 256 chunks are queued unread. The database probe that runs when signals are in-process only is shared by every stream of the same user, so tabs do not multiply queries.

signals is how a change in one process reaches subscribers in another: the worker's wake-up, and every open stream. It defaults to in-memory. postgresSignals(sql) and mongoSignals(db) ship for multi-replica deployments; see Wake-ups across replicas. When the signal is in-process only, each stream also compares a cheap inbox fingerprint every probeIntervalMs, so a send on another replica still shows up within that interval.

notify.inboxHeaders(userId) returns { "x-easy-ping-inbox": "<version>" } to attach to your own API responses, and client.instrument(fetch) refreshes the bell when the value moves.

notifications

Each entry is defineNotification({ schema, channels, email? }). schema is a Standard Schema-compatible validator — Zod, Valibot, ArkType all work — and it's what makes send()'s payload argument type-checked against the notification you're sending. channels lists which of the configured channels this notification type uses.

notifications: {
  commentReply: defineNotification({
    schema: z.object({ authorName: z.string(), commentId: z.string() }),
    channels: ["inApp", "email"],
    email: {
      subject: (p) => `${p.authorName} replied to you`,
      // authorName is user input: escape it, or their markup ships from your domain.
      template: (p) =>
        `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
    },
  }),
},

plugins

An array of plugin instances — preferences(), digests(), push({ provider, render }), or one you wrote. Order matters only where dependsOn says it does: digests depends on preferences for the per-type frequency it reads. See Writing a plugin.

basePath and tablePrefix

Two settings that are easy to miss and break things quietly when they're wrong.

basePath defaults to /api/notifications. It's how the handler strips its own prefix off an incoming URL to work out which route you asked for. Mount the handler anywhere else without changing it and every request resolves to the wrong route — usually a 404 on endpoints that plainly exist.

// handler mounted at /api/inbox/[...notify]
basePath: "/api/inbox",

tablePrefix must match the prefix you gave the adapter. They're separate arguments, so nothing stops them disagreeing:

database: drizzleAdapter(db, { prefix: "en_" }),
tablePrefix: "en_",   // the same value — plugin tables resolve through this one