easy-ping 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +548 -508
  2. package/dist/adapters/mongodb.cjs +142 -0
  3. package/dist/adapters/mongodb.cjs.map +1 -1
  4. package/dist/adapters/mongodb.d.cts +39 -1
  5. package/dist/adapters/mongodb.d.ts +39 -1
  6. package/dist/adapters/mongodb.js +73 -1
  7. package/dist/adapters/mongodb.js.map +1 -1
  8. package/dist/adapters/postgres.cjs +146 -0
  9. package/dist/adapters/postgres.cjs.map +1 -1
  10. package/dist/adapters/postgres.d.cts +36 -1
  11. package/dist/adapters/postgres.d.ts +36 -1
  12. package/dist/adapters/postgres.js +78 -3
  13. package/dist/adapters/postgres.js.map +1 -1
  14. package/dist/adapters/sqlite.js +1 -1
  15. package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
  16. package/dist/chunk-JKTMUMQK.js.map +1 -0
  17. package/dist/{chunk-BECMTNK7.js → chunk-JOVIZXO6.js} +142 -6
  18. package/dist/chunk-JOVIZXO6.js.map +1 -0
  19. package/dist/chunk-ORZKH224.js +75 -0
  20. package/dist/chunk-ORZKH224.js.map +1 -0
  21. package/dist/client.cjs +297 -17
  22. package/dist/client.cjs.map +1 -1
  23. package/dist/client.d.cts +59 -1
  24. package/dist/client.d.ts +59 -1
  25. package/dist/client.js +297 -17
  26. package/dist/client.js.map +1 -1
  27. package/dist/index.cjs +293 -10
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +46 -1
  30. package/dist/index.d.ts +46 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/node.cjs +26 -5
  34. package/dist/node.cjs.map +1 -1
  35. package/dist/node.d.cts +1 -1
  36. package/dist/node.d.ts +1 -1
  37. package/dist/node.js +28 -6
  38. package/dist/node.js.map +1 -1
  39. package/dist/plugins/preferences.cjs +2 -2
  40. package/dist/plugins/preferences.cjs.map +1 -1
  41. package/dist/plugins/preferences.js +2 -1
  42. package/dist/plugins/preferences.js.map +1 -1
  43. package/dist/plugins/push.cjs +2 -2
  44. package/dist/plugins/push.cjs.map +1 -1
  45. package/dist/plugins/push.js +3 -2
  46. package/dist/plugins/push.js.map +1 -1
  47. package/dist/react.cjs +320 -19
  48. package/dist/react.cjs.map +1 -1
  49. package/dist/react.d.cts +58 -0
  50. package/dist/react.d.ts +58 -0
  51. package/dist/react.js +320 -19
  52. package/dist/react.js.map +1 -1
  53. package/dist/schema.js +1 -1
  54. package/dist/signals-kPmOlKDX.d.cts +27 -0
  55. package/dist/signals-kPmOlKDX.d.ts +27 -0
  56. package/dist/sw.cjs +39 -0
  57. package/dist/sw.cjs.map +1 -0
  58. package/dist/sw.d.cts +52 -0
  59. package/dist/sw.d.ts +52 -0
  60. package/dist/sw.js +36 -0
  61. package/dist/sw.js.map +1 -0
  62. package/package.json +9 -3
  63. package/dist/chunk-BECMTNK7.js.map +0 -1
  64. package/dist/chunk-GJNLOQJS.js.map +0 -1
package/README.md CHANGED
@@ -1,508 +1,548 @@
1
- <div align="center">
2
-
3
- # easy-ping
4
-
5
- **Own your notifications.**
6
- In-app inbox, transactional email and web push — running inside your app, stored in your
7
- database, with no per-notification bill.
8
-
9
- [![npm](https://img.shields.io/npm/v/easy-ping?color=%23e0362a&label=npm)](https://www.npmjs.com/package/easy-ping)
10
- [![install size](https://packagephobia.com/badge?p=easy-ping)](https://packagephobia.com/result?p=easy-ping)
11
- [![node](https://img.shields.io/node/v/easy-ping)](https://www.npmjs.com/package/easy-ping)
12
- [![license](https://img.shields.io/npm/l/easy-ping?color=blue)](./LICENSE)
13
-
14
- [Documentation](https://easy-notify-website.vercel.app) · [Quickstart](https://easy-notify-website.vercel.app/docs/quickstart) · [Changelog](https://easy-notify-website.vercel.app/docs/changelog) · [AI context](https://easy-notify-website.vercel.app/docs/ai-assistant)
15
-
16
- </div>
17
-
18
- ---
19
-
20
- ```ts
21
- await notify.send("commentReply", {
22
- to: threadOwnerId,
23
- payload: { authorName: "Dana", commentId: "c_123" }, // typed from your schema
24
- dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
25
- });
26
- ```
27
-
28
- ```tsx
29
- const { notifications, unseenCount, markSeen, markAsRead } = useNotifications();
30
- ```
31
-
32
- One config file, one `send()`, one hook. No queue, no worker service, no vendor.
33
-
34
- ---
35
-
36
- ## Why
37
-
38
- Every app past the weekend-project stage needs a notification bell, transactional email and user
39
- preferences. The alternatives are a platform you deploy, a SaaS you rent per notification, or
40
- hand-rolling it badly.
41
-
42
- | | easy-ping | Novu (self-hosted) | Knock · Courier |
43
- | --- | --- | --- | --- |
44
- | **What you deploy** | nothing, it is a dependency | four services | nothing, it is their cloud |
45
- | **Infrastructure it adds** | none | MongoDB, Redis, S3 | none |
46
- | **Where notifications live** | your Postgres or MongoDB | Novu's MongoDB | theirs |
47
- | **Cost per notification** | none | none, you pay for servers | metered per send |
48
- | **License** | MIT, all of it | MIT core, commercial modules | proprietary |
49
- | **Visual workflow editor** | **no**, a notification is code | yes | yes |
50
- | **Channels out of the box** | **in-app, email, web push** | dozens | dozens |
51
-
52
- The last two rows are the trade. If you need a workflow editor a non-developer can edit, use one
53
- of the others — they are good tools solving a bigger problem.
54
-
55
- **Scale target:** thousands to low-millions of notifications per month. Not Slack-scale fan-out.
56
- Every "no queue required" decision below follows from that.
57
-
58
- > **Status: published, pre-1.0.** The core pipeline, all three adapters, the React client and the
59
- > preferences, digests and push plugins are covered by tests against real databases. Web push is
60
- > verified end to end against Mozilla's production push service and cross-checked against
61
- > `http_ece`. Realtime and batching are not built, and the Resend provider has only been exercised
62
- > against a stub. Minor versions may still move APIs before 1.0.
63
-
64
- ---
65
-
66
- ## Quickstart
67
-
68
- ### 1. Install
69
-
70
- ```bash
71
- pnpm add easy-ping pg zod
72
- ```
73
-
74
- ### 2. Create the tables
75
-
76
- No ORM required. The whole database contract is one function: run a parameterised statement,
77
- return rows.
78
-
79
- ```ts
80
- import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
81
- import { Pool } from "pg";
82
-
83
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
84
- const query = async (text, params) => (await pool.query(text, params)).rows;
85
-
86
- // Renders the DDL from the installed version's own schema, so the tables
87
- // cannot drift from the package. Every statement is IF NOT EXISTS.
88
- await createPostgresTables(query);
89
-
90
- export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
91
- ```
92
-
93
- Anything that can run a statement works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
94
- serverless drivers, or Prisma's `$queryRawUnsafe`.
95
-
96
- <details>
97
- <summary>Already on Drizzle</summary>
98
-
99
- ```ts
100
- // db/schema.ts
101
- import { createSchema } from "easy-ping/adapters/drizzle";
102
-
103
- export const { notification, notificationDelivery, notificationPreference } = createSchema();
104
- ```
105
-
106
- Push it with `drizzle-kit`, then `drizzleAdapter(db)` in place of `postgresAdapter`.
107
-
108
- </details>
109
-
110
- <details>
111
- <summary>On MongoDB instead</summary>
112
-
113
- ```bash
114
- pnpm add easy-ping mongodb zod
115
- ```
116
-
117
- ```ts
118
- import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
119
-
120
- const client = new MongoClient(process.env.MONGO_URL!);
121
- await client.connect();
122
- const db = client.db("app");
123
-
124
- // No tables, only indexes. Run once at startup.
125
- await createMongoIndexes(db);
126
-
127
- // Pass the client too: it is what makes a notification and its deliveries
128
- // land together, which needs a replica set.
129
- export const database = mongoAdapter(db, { client });
130
- ```
131
-
132
- Everything after this point is identical.
133
-
134
- </details>
135
-
136
- <details>
137
- <summary>On MySQL instead</summary>
138
-
139
- ```bash
140
- pnpm add easy-ping mysql2 zod
141
- ```
142
-
143
- ```ts
144
- import { createMysqlTables, mysql2Query, mysqlAdapter, mysqlTransaction } from "easy-ping/adapters/mysql";
145
- import mysql from "mysql2/promise";
146
-
147
- // timezone "Z": the adapter writes DATETIME columns as UTC and must read them back unshifted.
148
- const pool = mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "Z" });
149
- const query = mysql2Query(pool);
150
-
151
- await createMysqlTables(query); // CREATE TABLE IF NOT EXISTS, safe on every boot
152
-
153
- // With a transaction, a claim uses FOR UPDATE SKIP LOCKED (MySQL 8+). Without
154
- // one it is a single lock-free UPDATE that still never double-claims a row.
155
- export const database = mysqlAdapter(query, { transaction: mysqlTransaction(pool) });
156
- ```
157
-
158
- MySQL 8 or MariaDB. Any driver works: the contract is `(text, params) => Promise<{ rows, affectedRows }>`.
159
-
160
- </details>
161
-
162
- <details>
163
- <summary>On SQLite instead</summary>
164
-
165
- ```ts
166
- import { createSqliteTables, sqliteAdapter, sqliteQuery, sqliteTransaction } from "easy-ping/adapters/sqlite";
167
- import { DatabaseSync } from "node:sqlite"; // Node 22.13+; better-sqlite3 works the same way
168
-
169
- const db = new DatabaseSync("./app.db");
170
- const query = sqliteQuery(db);
171
-
172
- await createSqliteTables(query);
173
-
174
- export const database = sqliteAdapter(query, { transaction: sqliteTransaction(db) });
175
- ```
176
-
177
- SQLite has one writer at a time, so the claim is a plain `UPDATE … WHERE id IN (SELECT … LIMIT ?)`; two sweeps cannot interleave. Dates are ISO text, booleans 0/1, JSON text.
178
-
179
- </details>
180
-
181
- <details>
182
- <summary>Adding a plugin's tables</summary>
183
-
184
- Plugins own their own tables and export the declaration, so no plugin instance is needed:
185
-
186
- ```ts
187
- import { pushSchema } from "easy-ping/plugins/push";
188
-
189
- await createPostgresTables(query, { plugins: [pushSchema] });
190
- ```
191
-
192
- </details>
193
-
194
- ### 3. Configure
195
-
196
- ```ts
197
- // notify.ts
198
- import { defineNotification, easyPing, escapeHtml } from "easy-ping";
199
- import { resend } from "easy-ping/providers/resend";
200
- import { after } from "next/server";
201
- import { z } from "zod";
202
- import { auth } from "./auth";
203
- import { database, pool } from "./db"; // from step 2
204
-
205
- export const notify = easyPing({
206
- database,
207
-
208
- // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
209
- secret: process.env.NOTIFY_SECRET!,
210
- cron: { secret: process.env.NOTIFY_CRON_SECRET! },
211
-
212
- // Required. The mounted endpoints serve a user's private inbox.
213
- session: {
214
- getUserId: async (request) =>
215
- (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
216
- },
217
-
218
- // Batched: one call per send, never one per recipient.
219
- getRecipients: async (userIds) => {
220
- const { rows } = await pool.query(
221
- "select id, email, timezone, locale from users where id = any($1)",
222
- [userIds],
223
- );
224
- return rows.map((u) => ({
225
- userId: u.id,
226
- email: u.email,
227
- timezone: u.timezone,
228
- locale: u.locale,
229
- }));
230
- },
231
-
232
- channels: {
233
- inApp: { enabled: true },
234
- email: { provider: resend({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <hi@acme.dev>" }) },
235
- },
236
-
237
- delivery: { mode: "deferred", waitUntil: after },
238
-
239
- notifications: {
240
- commentReply: defineNotification({
241
- schema: z.object({ authorName: z.string(), commentId: z.string() }),
242
- channels: ["inApp", "email"],
243
- email: {
244
- subject: (p) => `${p.authorName} replied to you`,
245
- // Anything a user typed goes through escapeHtml, or their markup ships from your domain.
246
- template: (p) =>
247
- `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
248
- },
249
- }),
250
- },
251
- });
252
- ```
253
-
254
- ### 4. Mount the endpoints
255
-
256
- ```ts
257
- // app/api/notifications/[[...notify]]/route.ts
258
- // Double brackets: Next's required catch-all would not match the bare
259
- // /api/notifications path, which is where the feed lives.
260
- import { notify } from "@/notify";
261
-
262
- export const { GET, POST } = notify.handler;
263
- ```
264
-
265
- ### 5. Send
266
-
267
- ```ts
268
- await notify.send("commentReply", {
269
- to: threadOwnerId,
270
- payload: { authorName: "Dana", commentId: "c_123" }, // typed against the schema
271
- dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
272
- });
273
- ```
274
-
275
- ### 6. Render the bell
276
-
277
- ```tsx
278
- "use client";
279
- import { useNotifications } from "easy-ping/react";
280
-
281
- export function Bell() {
282
- const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();
283
-
284
- return (
285
- <button onClick={() => markSeen()}>
286
- {unseenCount}
287
- {notifications.map((n) => (
288
- <div key={n.id} onClick={() => markAsRead(n.id)}>
289
- {n.type} {n.readAt ? "" : "•"}
290
- </div>
291
- ))}
292
- </button>
293
- );
294
- }
295
- ```
296
-
297
- ### 7. Wire the cron
298
-
299
- ```
300
- POST /api/notifications/cron
301
- Authorization: Bearer $NOTIFY_CRON_SECRET
302
- ```
303
-
304
- Every 1–5 minutes, from Vercel Cron, GitHub Actions, or anything else. This is the durability floor beneath every delivery mode — **required** for `cron` and `deferred`.
305
-
306
- ---
307
-
308
- ## How delivery works
309
-
310
- `send()` validates, resolves recipients, runs hooks, and writes the notification and delivery rows in one transaction. **Committing those rows is the only thing it has to do** — a 200–800ms provider round trip has no business on a comment POST.
311
-
312
- What happens next is `delivery.mode`'s job. In `deferred`, `worker` and `cron`, `send()` returns as soon as the rows are committed and never waits on a provider. `inline` is the exception: it awaits that send's own deliveries before resolving, which is the tradeoff you accept for the simplest possible setup.
313
-
314
- Delivery then happens according to `delivery.mode`:
315
-
316
- | mode | delivery happens | latency | needs cron |
317
- | --- | --- | --- | --- |
318
- | `inline` | before `send()` resolves | in-request | recommended |
319
- | `deferred` | after the response, via `waitUntil` | ~1s | **yes** |
320
- | `worker` | in-process poller | ~1s | optional |
321
- | `cron` | when the sweep runs | up to the interval | **yes** |
322
-
323
- Modes are **additive**. `deferred` and `worker` are latency optimisations layered over the cron sweep — the rows are already committed, so a missing platform primitive or a crashed process costs latency, never a notification.
324
-
325
- ```ts
326
- const worker = notify.startWorker({ intervalMs: 1000 });
327
- process.on("SIGTERM", () => worker.stop()); // drains in-flight work, releases leases
328
- ```
329
-
330
- **Delivery is at-least-once.** Providers receive an idempotency key derived from the delivery id. Retries use exponential backoff with jitter (30s → 2m → 8m → 32m, five attempts), floored by your cron interval. Non-retryable failures — a revoked API key, an invalid recipient — fail immediately rather than burning all five attempts.
331
-
332
- **Not for OTP or 2FA codes.** Use your auth library's own sender. A retry-and-sweep model is wrong for a 60-second TTL.
333
-
334
- ---
335
-
336
- ## Channels
337
-
338
- | channel | state |
339
- | --- | --- |
340
- | `inApp` | ✅ built in, on by default, needs no provider |
341
- | `email` | ✅ Resend provider; the interface is open for others |
342
- | `push` | ✅ push plugin + `webPush()` — VAPID and aes128gcm on Web Crypto, so it runs on edge too |
343
- | `sms` | ⬜ not implemented |
344
- | `slack` | ⬜ not implemented |
345
-
346
- A channel is usable when core carries it (`inApp`, `email`) or a plugin declares it and can `deliver` it. That is how push works, and how sms and slack will.
347
-
348
- Declaring a channel nothing can carry **warns at startup** and reports `skipped: "channel-unavailable"` — deliberately distinct from `"no-channels"`, so a missing provider never looks like a user opt-out.
349
-
350
- ## Upgrading
351
-
352
- A later version may add a column. How you pick it up depends on how you created the tables:
353
-
354
- | you bootstrapped with | to upgrade |
355
- | --- | --- |
356
- | `createSchema()` + drizzle-kit | `drizzle-kit` diffs it for you — nothing to do here |
357
- | `renderPostgresDdl()` | `planPostgresMigration()` — see below |
358
- | `createMongoIndexes()` | rerun it; `createIndex` is idempotent and additive |
359
-
360
- **`renderPostgresDdl` cannot upgrade you.** It emits `CREATE TABLE IF NOT EXISTS`, which is correct exactly once and a silent no-op afterwards. Rerunning it on an existing database does nothing at all.
361
-
362
- ```ts
363
- import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
364
-
365
- const plan = await planPostgresMigration(
366
- async () =>
367
- (await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
368
- table: row.table_name,
369
- column: row.column_name,
370
- type: row.data_type,
371
- nullable: row.is_nullable === "YES",
372
- })),
373
- coreSchema,
374
- );
375
-
376
- for (const statement of plan.statements) await sql.unsafe(statement);
377
- if (plan.unsupported.length) console.warn(plan.unsupported);
378
- ```
379
-
380
- **Additive only, deliberately.** It adds missing columns and indexes, and creates tables that do not exist. It never drops a column, never changes a type, and never touches a column it did not declare — those land in `unsupported` as a message for a human, because they are destructive and context-dependent.
381
-
382
- One behaviour worth knowing: a **required** column with no default is added **nullable**, because `NOT NULL` would abort against existing rows. That is reported in `unsupported`; backfill it and `SET NOT NULL` yourself.
383
-
384
- Run the plugins' schemas the same way — `push({...}).schema`, `preferences().schema`.
385
-
386
- ---
387
-
388
- ## When something fails
389
-
390
- Delivery is at-least-once with five attempts and backoff, which means failures are quiet by design. To see them:
391
-
392
- ```ts
393
- const failed = await notify.getFailedDeliveries({ since: new Date(Date.now() - 86_400_000) });
394
- // [{ id, notificationId, channel, attempts, lastError, updatedAt, ... }]
395
- ```
396
-
397
- Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or an alert — otherwise the retry machinery is a black box, and a revoked API key looks exactly like nothing happening.
398
-
399
- `notify.healthCheck()` reports the delivery mode and warns when a mode needs a cron you have not mounted.
400
-
401
- ---
402
-
403
- ## Security notes
404
-
405
- - **`session.getUserId` is mandatory.** There is no default and no dev bypass — an insecure default ships, a startup crash doesn't. Returning `null` yields 401; *throwing* yields 500, because a broken session store and an absent session are different bugs.
406
- - **Every query is scoped server-side.** No route accepts a user id from the client. Marking someone else's notification read returns 404, not 403 — a 403 confirms the row exists.
407
- - **`cron.secret` is required** for modes that rely on the sweep. Unauthenticated, that endpoint is a free flush-everything trigger against your email provider. Compared in constant time. Plugin machine routes (`/push/prune`, `/digests/cron`) accept it too unless you set `machineSecret`, which keeps the scheduler's credential to `/cron` alone. One call drains at most `cron.maxSweeps` sweeps (default 50).
408
- - **Plugins never see the secret.** They get `ctx.sign()`, which mints tokens only for the purposes their own signed routes declare; keys are derived per purpose with HKDF, so an unsubscribe key signs nothing else. `notify.listRoutes()` shows every mounted route with its auth scope, and a `custom`-scoped route is named in a startup warning with its justification.
409
- - **Secrets must be at least 16 characters** and not a placeholder; startup throws otherwise. A short HMAC key makes every unsubscribe link forgeable offline.
410
- - **CSRF.** Every POST must be `application/json` (415 otherwise) and, when the browser sends an `Origin`, it must match the request host or an entry in `trustedOrigins` (403 otherwise). This holds even if your session cookie is `SameSite=None`. Set `trustedOrigins: ["https://app.example.com", "*.example.com"]` when the API lives on a different origin from the page.
411
- - **Push endpoints are validated at registration.** Public https hosts only, well-formed keys, one owner per endpoint for life (409 if another account registered it), at most `maxDevicesPerUser` rows per user. Pin `allowedEndpointHosts` to the push services you expect if you want the SSRF surface closed entirely.
412
- - **Email templates are sent as-is.** Run every payload field a user could have typed through `escapeHtml`, or use a templating library that escapes by default. The quickstart shows the pattern.
413
- - **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read, and never render it with `innerHTML`.
414
- - **Request bodies are capped at 64 KiB** (`maxBodyBytes`), responses carry `Cache-Control: private, no-store`, and a thrown adapter error is a logged 500 rather than a stack trace or a crashed process.
415
- - **Rate limiting.** `rateLimit: { max, windowMs }` is an in-process fixed-window limiter keyed by the client address from `cf-connecting-ip` / `x-real-ip` / `x-forwarded-for` (or your own `key`), applied to every route including `/cron` and `/unsubscribe`. It is per process, not global: enough to blunt secret guessing and poll storms, not a quota. `onRequest` runs before it if you have a shared limiter of your own. The inbox is polled by every open tab, so size limits with that in mind.
416
- - **Unsubscribe tokens** travel in the URL, so they will appear in access logs. They are valid for 30 days by default, capped at 90. A token is refused if the user changed that preference after it was issued, so an old link cannot undo a newer decision; clicking the same link twice is still a 200. `last_error` stores provider messages with email addresses redacted.
417
-
418
- ---
419
-
420
- ## Status
421
-
422
- | | |
423
- | --- | --- |
424
- | ✅ Core `send()` pipeline, hooks, dedupe | |
425
- | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
426
- | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
427
- | ✅ MongoDB | same conformance suite |
428
- | ✅ MySQL 8 / MariaDB | `SKIP LOCKED` with a transaction, lock-free `UPDATE` without |
429
- | ✅ SQLite | `node:sqlite` or better-sqlite3; one writer, so the claim is one `UPDATE` |
430
- | ✅ Delivery runner, all four modes, retry + backoff | |
431
- | ✅ Resend provider | |
432
- | ✅ Route handler, session scoping, cron | |
433
- | ✅ React client, polling, optimistic updates | |
434
- | ✅ preferences plugin + headless `usePreferences` | the wedge |
435
- | ✅ digests plugin, timezone-aware | |
436
- | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
437
- | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
438
- | ✅ scoped plugin storage, so plugins own their tables | |
439
- | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()`; MySQL and SQLite are bootstrap-only for now |
440
- | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
441
- | ⬜ realtime, batching | |
442
- | ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
443
-
444
- ---
445
-
446
- ## Writing an adapter
447
-
448
- The one operation with no equivalent in other libraries is atomic claiming — without it, two concurrent sweeps send the same email twice. Verify yours:
449
-
450
- ```ts
451
- import { adapterConformanceCases } from "easy-ping/testing";
452
-
453
- for (const testCase of adapterConformanceCases) {
454
- it(testCase.name, () => testCase.run({ adapter, reset, setAttempts, lockRow }));
455
- }
456
- ```
457
-
458
- Fifteen cases, run against six backends in this repo (two Postgres paths, MongoDB, two MySQL paths, SQLite). The one that matters asserts a row locked by another transaction is *skipped*, not waited on; it is tagged `requires: "rowLock"`, and a store whose claim is a single atomic update filters it out rather than faking it.
459
-
460
- Declare your dialect on the adapter so the plugin store writes what your driver expects:
461
-
462
- ```ts
463
- naming: "snake_case" | "preserve" // columns, or the declared field names
464
- serializesJson: boolean // json as a string, or natively
465
- ```
466
-
467
- ---
468
-
469
- ## Development
470
-
471
- ```bash
472
- pnpm install
473
- docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
474
- pnpm test
475
- ```
476
-
477
- Mongo runs as a single-node replica set, because that is the only way it offers transactions. SQLite needs nothing running: the suite uses Node's own `node:sqlite` in memory.
478
-
479
- Database tests skip locally when a database is unreachable, and **fail** in CI — a green build that ran none of them is worse than a red one.
480
-
481
- The push crypto is checked two ways. `web-push-reference.test.ts` decrypts our output with `http_ece` — the library `web-push` npm uses — because a decryptor written from the same RFC would share any misreading and agree with itself. `web-push-live.test.ts` then sends through Mozilla's production push service for real; it is opt-in so CI never goes red because someone else's service is having a bad afternoon:
482
-
483
- ```bash
484
- EASY_PING_LIVE_PUSH=1 pnpm --filter easy-ping test web-push-live
485
- ```
486
-
487
- The Resend provider is checked against Resend's real API too: the rejection paths need no credentials — a bogus key coming back as a structured 401 rather than a 400 is what proves the request shape is right. The delivery leg needs your own key:
488
-
489
- ```bash
490
- RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping test resend-live
491
- ```
492
-
493
- ## Releasing
494
-
495
- Every release is automatic. Open a PR with a changeset:
496
-
497
- ```bash
498
- pnpm changeset # describe the change, pick patch/minor/major
499
- ```
500
-
501
- On merge to `main`, the Release workflow opens a version PR. Merging *that* publishes to npm via
502
- [trusted publishing](https://docs.npmjs.com/trusted-publishers/), tags the commit and opens a
503
- GitHub Release from the changelog. No npm token is stored anywhere; the workflow mints a
504
- short-lived OIDC token instead.
505
-
506
- ## License
507
-
508
- MIT
1
+ <div align="center">
2
+
3
+ # easy-ping
4
+
5
+ **Own your notifications.**
6
+ In-app inbox, transactional email and web push — running inside your app, stored in your
7
+ database, with no per-notification bill.
8
+
9
+ [![npm](https://img.shields.io/npm/v/easy-ping?color=%23e0362a&label=npm)](https://www.npmjs.com/package/easy-ping)
10
+ [![install size](https://packagephobia.com/badge?p=easy-ping)](https://packagephobia.com/result?p=easy-ping)
11
+ [![node](https://img.shields.io/node/v/easy-ping)](https://www.npmjs.com/package/easy-ping)
12
+ [![license](https://img.shields.io/npm/l/easy-ping?color=blue)](./LICENSE)
13
+
14
+ [Documentation](https://easy-ping.teklumoges.dev) · [Quickstart](https://easy-ping.teklumoges.dev/docs/quickstart) · [Changelog](https://easy-ping.teklumoges.dev/docs/changelog) · [AI context](https://easy-ping.teklumoges.dev/docs/ai-assistant)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ```ts
21
+ await notify.send("commentReply", {
22
+ to: threadOwnerId,
23
+ payload: { authorName: "Dana", commentId: "c_123" }, // typed from your schema
24
+ dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
25
+ });
26
+ ```
27
+
28
+ ```tsx
29
+ const { notifications, unseenCount, markSeen, markAsRead } = useNotifications();
30
+ ```
31
+
32
+ One config file, one `send()`, one hook. No queue, no worker service, no vendor.
33
+
34
+ ---
35
+
36
+ ## Why
37
+
38
+ Every app past the weekend-project stage needs a notification bell, transactional email and user
39
+ preferences. The alternatives are a platform you deploy, a SaaS you rent per notification, or
40
+ hand-rolling it badly.
41
+
42
+ | | easy-ping | Novu (self-hosted) | Knock · Courier |
43
+ | --- | --- | --- | --- |
44
+ | **What you deploy** | nothing, it is a dependency | four services | nothing, it is their cloud |
45
+ | **Infrastructure it adds** | none | MongoDB, Redis, S3 | none |
46
+ | **Where notifications live** | your Postgres or MongoDB | Novu's MongoDB | theirs |
47
+ | **Cost per notification** | none | none, you pay for servers | metered per send |
48
+ | **License** | MIT, all of it | MIT core, commercial modules | proprietary |
49
+ | **Visual workflow editor** | **no**, a notification is code | yes | yes |
50
+ | **Channels out of the box** | **in-app, email, web push** | dozens | dozens |
51
+
52
+ The last two rows are the trade. If you need a workflow editor a non-developer can edit, use one
53
+ of the others — they are good tools solving a bigger problem.
54
+
55
+ **Scale target:** thousands to low-millions of notifications per month. Not Slack-scale fan-out.
56
+ Every "no queue required" decision below follows from that.
57
+
58
+ > **Status: published, pre-1.0.** The core pipeline, all three adapters, the React client and the
59
+ > preferences, digests and push plugins are covered by tests against real databases. Web push is
60
+ > verified end to end against Mozilla's production push service and cross-checked against
61
+ > `http_ece`. The bell updates over a server-sent event stream with polling as the fallback;
62
+ > batching is not built, and the Resend provider has only been exercised against a stub. Minor
63
+ > versions may still move APIs before 1.0.
64
+
65
+ ---
66
+
67
+ ## Quickstart
68
+
69
+ ### 1. Install
70
+
71
+ ```bash
72
+ pnpm add easy-ping pg zod
73
+ ```
74
+
75
+ ### 2. Create the tables
76
+
77
+ No ORM required. The whole database contract is one function: run a parameterised statement,
78
+ return rows.
79
+
80
+ ```ts
81
+ import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
82
+ import { Pool } from "pg";
83
+
84
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
85
+ const query = async (text, params) => (await pool.query(text, params)).rows;
86
+
87
+ // Renders the DDL from the installed version's own schema, so the tables
88
+ // cannot drift from the package. Every statement is IF NOT EXISTS.
89
+ await createPostgresTables(query);
90
+
91
+ export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
92
+ ```
93
+
94
+ Anything that can run a statement works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
95
+ serverless drivers, or Prisma's `$queryRawUnsafe`.
96
+
97
+ <details>
98
+ <summary>Already on Drizzle</summary>
99
+
100
+ ```ts
101
+ // db/schema.ts
102
+ import { createSchema } from "easy-ping/adapters/drizzle";
103
+
104
+ export const { notification, notificationDelivery, notificationPreference } = createSchema();
105
+ ```
106
+
107
+ Push it with `drizzle-kit`, then `drizzleAdapter(db)` in place of `postgresAdapter`.
108
+
109
+ </details>
110
+
111
+ <details>
112
+ <summary>On MongoDB instead</summary>
113
+
114
+ ```bash
115
+ pnpm add easy-ping mongodb zod
116
+ ```
117
+
118
+ ```ts
119
+ import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
120
+
121
+ const client = new MongoClient(process.env.MONGO_URL!);
122
+ await client.connect();
123
+ const db = client.db("app");
124
+
125
+ // No tables, only indexes. Run once at startup.
126
+ await createMongoIndexes(db);
127
+
128
+ // Pass the client too: it is what makes a notification and its deliveries
129
+ // land together, which needs a replica set.
130
+ export const database = mongoAdapter(db, { client });
131
+ ```
132
+
133
+ Everything after this point is identical.
134
+
135
+ </details>
136
+
137
+ <details>
138
+ <summary>On MySQL instead</summary>
139
+
140
+ ```bash
141
+ pnpm add easy-ping mysql2 zod
142
+ ```
143
+
144
+ ```ts
145
+ import { createMysqlTables, mysql2Query, mysqlAdapter, mysqlTransaction } from "easy-ping/adapters/mysql";
146
+ import mysql from "mysql2/promise";
147
+
148
+ // timezone "Z": the adapter writes DATETIME columns as UTC and must read them back unshifted.
149
+ const pool = mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "Z" });
150
+ const query = mysql2Query(pool);
151
+
152
+ await createMysqlTables(query); // CREATE TABLE IF NOT EXISTS, safe on every boot
153
+
154
+ // With a transaction, a claim uses FOR UPDATE SKIP LOCKED (MySQL 8+). Without
155
+ // one it is a single lock-free UPDATE that still never double-claims a row.
156
+ export const database = mysqlAdapter(query, { transaction: mysqlTransaction(pool) });
157
+ ```
158
+
159
+ MySQL 8 or MariaDB. Any driver works: the contract is `(text, params) => Promise<{ rows, affectedRows }>`.
160
+
161
+ </details>
162
+
163
+ <details>
164
+ <summary>On SQLite instead</summary>
165
+
166
+ ```ts
167
+ import { createSqliteTables, sqliteAdapter, sqliteQuery, sqliteTransaction } from "easy-ping/adapters/sqlite";
168
+ import { DatabaseSync } from "node:sqlite"; // Node 22.13+; better-sqlite3 works the same way
169
+
170
+ const db = new DatabaseSync("./app.db");
171
+ const query = sqliteQuery(db);
172
+
173
+ await createSqliteTables(query);
174
+
175
+ export const database = sqliteAdapter(query, { transaction: sqliteTransaction(db) });
176
+ ```
177
+
178
+ SQLite has one writer at a time, so the claim is a plain `UPDATE … WHERE id IN (SELECT … LIMIT ?)`; two sweeps cannot interleave. Dates are ISO text, booleans 0/1, JSON text.
179
+
180
+ </details>
181
+
182
+ <details>
183
+ <summary>Adding a plugin's tables</summary>
184
+
185
+ Plugins own their own tables and export the declaration, so no plugin instance is needed:
186
+
187
+ ```ts
188
+ import { pushSchema } from "easy-ping/plugins/push";
189
+
190
+ await createPostgresTables(query, { plugins: [pushSchema] });
191
+ ```
192
+
193
+ </details>
194
+
195
+ ### 3. Configure
196
+
197
+ ```ts
198
+ // notify.ts
199
+ import { defineNotification, easyPing, escapeHtml } from "easy-ping";
200
+ import { resend } from "easy-ping/providers/resend";
201
+ import { after } from "next/server";
202
+ import { z } from "zod";
203
+ import { auth } from "./auth";
204
+ import { database, pool } from "./db"; // from step 2
205
+
206
+ export const notify = easyPing({
207
+ database,
208
+
209
+ // Both at least 16 characters; `openssl rand -base64 32` is the easy way.
210
+ secret: process.env.NOTIFY_SECRET!,
211
+ cron: { secret: process.env.NOTIFY_CRON_SECRET! },
212
+
213
+ // Required. The mounted endpoints serve a user's private inbox.
214
+ session: {
215
+ getUserId: async (request) =>
216
+ (await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
217
+ },
218
+
219
+ // Batched: one call per send, never one per recipient.
220
+ getRecipients: async (userIds) => {
221
+ const { rows } = await pool.query(
222
+ "select id, email, timezone, locale from users where id = any($1)",
223
+ [userIds],
224
+ );
225
+ return rows.map((u) => ({
226
+ userId: u.id,
227
+ email: u.email,
228
+ timezone: u.timezone,
229
+ locale: u.locale,
230
+ }));
231
+ },
232
+
233
+ channels: {
234
+ inApp: { enabled: true },
235
+ email: { provider: resend({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <hi@acme.dev>" }) },
236
+ },
237
+
238
+ delivery: { mode: "deferred", waitUntil: after },
239
+
240
+ notifications: {
241
+ commentReply: defineNotification({
242
+ schema: z.object({ authorName: z.string(), commentId: z.string() }),
243
+ channels: ["inApp", "email"],
244
+ email: {
245
+ subject: (p) => `${p.authorName} replied to you`,
246
+ // Anything a user typed goes through escapeHtml, or their markup ships from your domain.
247
+ template: (p) =>
248
+ `<p>${escapeHtml(p.authorName)} replied. <a href="/c/${encodeURIComponent(p.commentId)}">View</a></p>`,
249
+ },
250
+ }),
251
+ },
252
+ });
253
+ ```
254
+
255
+ ### 4. Mount the endpoints
256
+
257
+ ```ts
258
+ // app/api/notifications/[[...notify]]/route.ts
259
+ // Double brackets: Next's required catch-all would not match the bare
260
+ // /api/notifications path, which is where the feed lives.
261
+ import { notify } from "@/notify";
262
+
263
+ export const { GET, POST } = notify.handler;
264
+ ```
265
+
266
+ ### 5. Send
267
+
268
+ ```ts
269
+ await notify.send("commentReply", {
270
+ to: threadOwnerId,
271
+ payload: { authorName: "Dana", commentId: "c_123" }, // typed against the schema
272
+ dedupeKey: `commentReply:c_123:${threadOwnerId}`, // optional, makes retries safe
273
+ });
274
+ ```
275
+
276
+ ### 6. Render the bell
277
+
278
+ ```tsx
279
+ "use client";
280
+ import { useNotifications } from "easy-ping/react";
281
+
282
+ export function Bell() {
283
+ const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();
284
+
285
+ return (
286
+ <button onClick={() => markSeen()}>
287
+ {unseenCount}
288
+ {notifications.map((n) => (
289
+ <div key={n.id} onClick={() => markAsRead(n.id)}>
290
+ {n.type} {n.readAt ? "" : "•"}
291
+ </div>
292
+ ))}
293
+ </button>
294
+ );
295
+ }
296
+ ```
297
+
298
+ The hook is headless and keeps itself fresh without a socket server: one tab per browser holds a
299
+ `GET /events` stream and the others mirror it over `BroadcastChannel`, so ten open tabs cost one
300
+ connection and zero idle queries. `send()`, `/read` and `/seen` push a `changed` event, the tab
301
+ refreshes, and where a stream cannot live (a serverless host that cuts it, a hardened proxy) the
302
+ client notices and falls back to polling that slows down while the user is idle. Nothing to
303
+ configure; `transport: "poll"` switches the stream off if you want the old behaviour.
304
+
305
+ Two optional extras when you want the bell fresh with no dedicated request at all:
306
+
307
+ ```ts
308
+ // Piggyback: any response from your own API can carry the inbox version.
309
+ return Response.json(data, { headers: notify.inboxHeaders(userId) });
310
+ // ...and the client refreshes only when that number moves.
311
+ const fetchWithBell = client.instrument(fetch);
312
+
313
+ // Push relay: if you already run the push plugin, the service worker wakes the bell too.
314
+ import { handlePush } from "easy-ping/sw";
315
+ self.addEventListener("push", (event) => handlePush(event));
316
+ ```
317
+
318
+ ### 7. Wire the cron
319
+
320
+ ```
321
+ POST /api/notifications/cron
322
+ Authorization: Bearer $NOTIFY_CRON_SECRET
323
+ ```
324
+
325
+ Every 1–5 minutes, from Vercel Cron, GitHub Actions, or anything else. This is the durability floor beneath every delivery mode — **required** for `cron` and `deferred`.
326
+
327
+ ---
328
+
329
+ ## How delivery works
330
+
331
+ `send()` validates, resolves recipients, runs hooks, and writes the notification and delivery rows in one transaction. **Committing those rows is the only thing it has to do** — a 200–800ms provider round trip has no business on a comment POST.
332
+
333
+ What happens next is `delivery.mode`'s job. In `deferred`, `worker` and `cron`, `send()` returns as soon as the rows are committed and never waits on a provider. `inline` is the exception: it awaits that send's own deliveries before resolving, which is the tradeoff you accept for the simplest possible setup.
334
+
335
+ Delivery then happens according to `delivery.mode`:
336
+
337
+ | mode | delivery happens | latency | needs cron |
338
+ | --- | --- | --- | --- |
339
+ | `inline` | before `send()` resolves | in-request | recommended |
340
+ | `deferred` | after the response, via `waitUntil` | ~1s | **yes** |
341
+ | `worker` | in-process loop, woken by `send()` | ~instant in-process, ≤10s across replicas | optional |
342
+ | `cron` | when the sweep runs | up to the interval | **yes** |
343
+
344
+ Modes are **additive**. `deferred` and `worker` are latency optimisations layered over the cron sweep — the rows are already committed, so a missing platform primitive or a crashed process costs latency, never a notification.
345
+
346
+ ```ts
347
+ const worker = notify.startWorker(); // idles 10s between sweeps; a send() in this process wakes it at once
348
+ process.on("SIGTERM", () => worker.stop()); // drains in-flight work, releases leases
349
+ ```
350
+
351
+ On `cron` and `deferred` hosts that see traffic but no scheduler for minutes at a time,
352
+ `delivery.sweepOnRequest: true` runs a small bounded sweep after any request, at most every 5 s,
353
+ so a free-tier deployment delivers within seconds of the next page load instead of the next cron.
354
+
355
+ **Across replicas**, wake-ups travel through `signals`: a publish says "something changed, go
356
+ look" and carries no payload, so losing one costs latency, never a notification.
357
+
358
+ ```ts
359
+ import { postgresSignals } from "easy-ping/adapters/postgres"; // LISTEN/NOTIFY, one channel
360
+ import { mongoSignals } from "easy-ping/adapters/mongodb"; // change stream on a capped collection
361
+
362
+ easyPing({ ..., signals: postgresSignals(sql) });
363
+ ```
364
+
365
+ Without one, each process uses in-memory signals and the event stream falls back to a cheap
366
+ fingerprint probe every 30 s (`events.probeIntervalMs`), which is exactly right on SQLite or a
367
+ single replica.
368
+
369
+ **Delivery is at-least-once.** Providers receive an idempotency key derived from the delivery id. Retries use exponential backoff with jitter (30s → 2m → 8m → 32m, five attempts), floored by your cron interval. Non-retryable failures — a revoked API key, an invalid recipient — fail immediately rather than burning all five attempts.
370
+
371
+ **Not for OTP or 2FA codes.** Use your auth library's own sender. A retry-and-sweep model is wrong for a 60-second TTL.
372
+
373
+ ---
374
+
375
+ ## Channels
376
+
377
+ | channel | state |
378
+ | --- | --- |
379
+ | `inApp` | ✅ built in, on by default, needs no provider |
380
+ | `email` | ✅ Resend provider; the interface is open for others |
381
+ | `push` | ✅ push plugin + `webPush()` — VAPID and aes128gcm on Web Crypto, so it runs on edge too |
382
+ | `sms` | ⬜ not implemented |
383
+ | `slack` | ⬜ not implemented |
384
+
385
+ A channel is usable when core carries it (`inApp`, `email`) or a plugin declares it and can `deliver` it. That is how push works, and how sms and slack will.
386
+
387
+ Declaring a channel nothing can carry **warns at startup** and reports `skipped: "channel-unavailable"` — deliberately distinct from `"no-channels"`, so a missing provider never looks like a user opt-out.
388
+
389
+ ## Upgrading
390
+
391
+ A later version may add a column. How you pick it up depends on how you created the tables:
392
+
393
+ | you bootstrapped with | to upgrade |
394
+ | --- | --- |
395
+ | `createSchema()` + drizzle-kit | `drizzle-kit` diffs it for you — nothing to do here |
396
+ | `renderPostgresDdl()` | `planPostgresMigration()` — see below |
397
+ | `createMongoIndexes()` | rerun it; `createIndex` is idempotent and additive |
398
+
399
+ **`renderPostgresDdl` cannot upgrade you.** It emits `CREATE TABLE IF NOT EXISTS`, which is correct exactly once and a silent no-op afterwards. Rerunning it on an existing database does nothing at all.
400
+
401
+ ```ts
402
+ import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
403
+
404
+ const plan = await planPostgresMigration(
405
+ async () =>
406
+ (await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
407
+ table: row.table_name,
408
+ column: row.column_name,
409
+ type: row.data_type,
410
+ nullable: row.is_nullable === "YES",
411
+ })),
412
+ coreSchema,
413
+ );
414
+
415
+ for (const statement of plan.statements) await sql.unsafe(statement);
416
+ if (plan.unsupported.length) console.warn(plan.unsupported);
417
+ ```
418
+
419
+ **Additive only, deliberately.** It adds missing columns and indexes, and creates tables that do not exist. It never drops a column, never changes a type, and never touches a column it did not declare — those land in `unsupported` as a message for a human, because they are destructive and context-dependent.
420
+
421
+ One behaviour worth knowing: a **required** column with no default is added **nullable**, because `NOT NULL` would abort against existing rows. That is reported in `unsupported`; backfill it and `SET NOT NULL` yourself.
422
+
423
+ Run the plugins' schemas the same way — `push({...}).schema`, `preferences().schema`.
424
+
425
+ ---
426
+
427
+ ## When something fails
428
+
429
+ Delivery is at-least-once with five attempts and backoff, which means failures are quiet by design. To see them:
430
+
431
+ ```ts
432
+ const failed = await notify.getFailedDeliveries({ since: new Date(Date.now() - 86_400_000) });
433
+ // [{ id, notificationId, channel, attempts, lastError, updatedAt, ... }]
434
+ ```
435
+
436
+ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or an alert — otherwise the retry machinery is a black box, and a revoked API key looks exactly like nothing happening.
437
+
438
+ `notify.healthCheck()` reports the delivery mode and warns when a mode needs a cron you have not mounted.
439
+
440
+ ---
441
+
442
+ ## Security notes
443
+
444
+ - **`session.getUserId` is mandatory.** There is no default and no dev bypass — an insecure default ships, a startup crash doesn't. Returning `null` yields 401; *throwing* yields 500, because a broken session store and an absent session are different bugs.
445
+ - **Every query is scoped server-side.** No route accepts a user id from the client. Marking someone else's notification read returns 404, not 403 — a 403 confirms the row exists.
446
+ - **`cron.secret` is required** for modes that rely on the sweep. Unauthenticated, that endpoint is a free flush-everything trigger against your email provider. Compared in constant time. Plugin machine routes (`/push/prune`, `/digests/cron`) accept it too unless you set `machineSecret`, which keeps the scheduler's credential to `/cron` alone. One call drains at most `cron.maxSweeps` sweeps (default 50).
447
+ - **Plugins never see the secret.** They get `ctx.sign()`, which mints tokens only for the purposes their own signed routes declare; keys are derived per purpose with HKDF, so an unsubscribe key signs nothing else. `notify.listRoutes()` shows every mounted route with its auth scope, and a `custom`-scoped route is named in a startup warning with its justification.
448
+ - **Secrets must be at least 16 characters** and not a placeholder; startup throws otherwise. A short HMAC key makes every unsubscribe link forgeable offline.
449
+ - **CSRF.** Every POST must be `application/json` (415 otherwise) and, when the browser sends an `Origin`, it must match the request host or an entry in `trustedOrigins` (403 otherwise). This holds even if your session cookie is `SameSite=None`. Set `trustedOrigins: ["https://app.example.com", "*.example.com"]` when the API lives on a different origin from the page.
450
+ - **Push endpoints are validated at registration.** Public https hosts only, well-formed keys, one owner per endpoint for life (409 if another account registered it), at most `maxDevicesPerUser` rows per user. Pin `allowedEndpointHosts` to the push services you expect if you want the SSRF surface closed entirely.
451
+ - **Email templates are sent as-is.** Run every payload field a user could have typed through `escapeHtml`, or use a templating library that escapes by default. The quickstart shows the pattern.
452
+ - **In-app payloads are served to the browser verbatim.** Never put anything in `payload` the recipient shouldn't read, and never render it with `innerHTML`.
453
+ - **Request bodies are capped at 64 KiB** (`maxBodyBytes`), responses carry `Cache-Control: private, no-store`, and a thrown adapter error is a logged 500 rather than a stack trace or a crashed process.
454
+ - **Rate limiting.** `rateLimit: { max, windowMs }` is an in-process fixed-window limiter keyed by the client address from `cf-connecting-ip` / `x-real-ip` / `x-forwarded-for` (or your own `key`), applied to every route including `/cron` and `/unsubscribe`. It is per process, not global: enough to blunt secret guessing and refresh storms, not a quota. `onRequest` runs before it if you have a shared limiter of your own. Each browser holds one `GET /events` stream and refreshes the feed on every `changed`, so size limits with that in mind.
455
+ - **Unsubscribe tokens** travel in the URL, so they will appear in access logs. They are valid for 30 days by default, capped at 90. A token is refused if the user changed that preference after it was issued, so an old link cannot undo a newer decision; clicking the same link twice is still a 200. `last_error` stores provider messages with email addresses redacted.
456
+
457
+ ---
458
+
459
+ ## Status
460
+
461
+ | | |
462
+ | --- | --- |
463
+ | ✅ Core `send()` pipeline, hooks, dedupe | |
464
+ | ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
465
+ | ✅ Postgres via Drizzle, for those already on it | same conformance suite |
466
+ | ✅ MongoDB | same conformance suite |
467
+ | ✅ MySQL 8 / MariaDB | `SKIP LOCKED` with a transaction, lock-free `UPDATE` without |
468
+ | ✅ SQLite | `node:sqlite` or better-sqlite3; one writer, so the claim is one `UPDATE` |
469
+ | ✅ Delivery runner, all four modes, retry + backoff | |
470
+ | ✅ Resend provider | |
471
+ | ✅ Route handler, session scoping, cron | |
472
+ | ✅ React client, optimistic updates, one event stream per browser with polling fallback | `GET /events`, `navigator.locks` leader, `BroadcastChannel` mirror |
473
+ | ✅ wake-ups: worker woken by `send()`, LISTEN/NOTIFY and change-stream signals, request-driven sweep | `signals`, `delivery.sweepOnRequest` |
474
+ | ✅ preferences plugin + headless `usePreferences` | the wedge |
475
+ | ✅ digests plugin, timezone-aware | |
476
+ | ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
477
+ | ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
478
+ | ✅ scoped plugin storage, so plugins own their tables | |
479
+ | ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()`; MySQL and SQLite are bootstrap-only for now |
480
+ | ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
481
+ | ⬜ batching | |
482
+ | ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
483
+
484
+ ---
485
+
486
+ ## Writing an adapter
487
+
488
+ The one operation with no equivalent in other libraries is atomic claiming — without it, two concurrent sweeps send the same email twice. Verify yours:
489
+
490
+ ```ts
491
+ import { adapterConformanceCases } from "easy-ping/testing";
492
+
493
+ for (const testCase of adapterConformanceCases) {
494
+ it(testCase.name, () => testCase.run({ adapter, reset, setAttempts, lockRow }));
495
+ }
496
+ ```
497
+
498
+ Fifteen cases, run against six backends in this repo (two Postgres paths, MongoDB, two MySQL paths, SQLite). The one that matters asserts a row locked by another transaction is *skipped*, not waited on; it is tagged `requires: "rowLock"`, and a store whose claim is a single atomic update filters it out rather than faking it.
499
+
500
+ Declare your dialect on the adapter so the plugin store writes what your driver expects:
501
+
502
+ ```ts
503
+ naming: "snake_case" | "preserve" // columns, or the declared field names
504
+ serializesJson: boolean // json as a string, or natively
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Development
510
+
511
+ ```bash
512
+ pnpm install
513
+ docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
514
+ pnpm test
515
+ ```
516
+
517
+ Mongo runs as a single-node replica set, because that is the only way it offers transactions. SQLite needs nothing running: the suite uses Node's own `node:sqlite` in memory.
518
+
519
+ Database tests skip locally when a database is unreachable, and **fail** in CI — a green build that ran none of them is worse than a red one.
520
+
521
+ The push crypto is checked two ways. `web-push-reference.test.ts` decrypts our output with `http_ece` — the library `web-push` npm uses — because a decryptor written from the same RFC would share any misreading and agree with itself. `web-push-live.test.ts` then sends through Mozilla's production push service for real; it is opt-in so CI never goes red because someone else's service is having a bad afternoon:
522
+
523
+ ```bash
524
+ EASY_PING_LIVE_PUSH=1 pnpm --filter easy-ping test web-push-live
525
+ ```
526
+
527
+ The Resend provider is checked against Resend's real API too: the rejection paths need no credentials — a bogus key coming back as a structured 401 rather than a 400 is what proves the request shape is right. The delivery leg needs your own key:
528
+
529
+ ```bash
530
+ RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping test resend-live
531
+ ```
532
+
533
+ ## Releasing
534
+
535
+ Every release is automatic. Open a PR with a changeset:
536
+
537
+ ```bash
538
+ pnpm changeset # describe the change, pick patch/minor/major
539
+ ```
540
+
541
+ On merge to `main`, the Release workflow opens a version PR. Merging *that* publishes to npm via
542
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers/), tags the commit and opens a
543
+ GitHub Release from the changelog. No npm token is stored anywhere; the workflow mints a
544
+ short-lived OIDC token instead.
545
+
546
+ ## License
547
+
548
+ MIT