easy-ping 0.3.0 → 0.5.0
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.
- package/README.md +174 -82
- package/dist/adapter-B7R4rXtJ.d.cts +17 -0
- package/dist/adapter-B7R4rXtJ.d.ts +17 -0
- package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
- package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
- package/dist/adapters/drizzle.cjs +4 -4
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +35 -1
- package/dist/adapters/drizzle.d.ts +35 -1
- package/dist/adapters/drizzle.js +5 -5
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +8 -7
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +4 -17
- package/dist/adapters/mongodb.d.ts +4 -17
- package/dist/adapters/mongodb.js +7 -7
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/mysql.cjs +605 -0
- package/dist/adapters/mysql.cjs.map +1 -0
- package/dist/adapters/mysql.d.cts +38 -0
- package/dist/adapters/mysql.d.ts +38 -0
- package/dist/adapters/mysql.js +51 -0
- package/dist/adapters/mysql.js.map +1 -0
- package/dist/adapters/postgres.cjs +19 -11
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +9 -54
- package/dist/adapters/postgres.d.ts +9 -54
- package/dist/adapters/postgres.js +6 -41
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.cjs +593 -0
- package/dist/adapters/sqlite.cjs.map +1 -0
- package/dist/adapters/sqlite.d.cts +37 -0
- package/dist/adapters/sqlite.d.ts +37 -0
- package/dist/adapters/sqlite.js +53 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +2 -14
- package/dist/browser.d.ts +2 -14
- package/dist/browser.js.map +1 -1
- package/dist/chunk-2EYKPF5A.js +393 -0
- package/dist/chunk-2EYKPF5A.js.map +1 -0
- package/dist/{chunk-OZSOIX23.js → chunk-3VOQOGDR.js} +54 -12
- package/dist/chunk-3VOQOGDR.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-AEYYTE5I.js} +4 -3
- package/dist/chunk-AEYYTE5I.js.map +1 -0
- package/dist/chunk-BECMTNK7.js +315 -0
- package/dist/chunk-BECMTNK7.js.map +1 -0
- package/dist/chunk-CK5UPVYP.js +48 -0
- package/dist/chunk-CK5UPVYP.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-E4IZ2CQC.js} +2 -2
- package/dist/chunk-E4IZ2CQC.js.map +1 -0
- package/dist/chunk-GJNLOQJS.js +226 -0
- package/dist/chunk-GJNLOQJS.js.map +1 -0
- package/dist/chunk-HY47TQMY.js +52 -0
- package/dist/chunk-HY47TQMY.js.map +1 -0
- package/dist/chunk-M4R77BJD.js +64 -0
- package/dist/chunk-M4R77BJD.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-XIDVLYNQ.js} +14 -4
- package/dist/chunk-XIDVLYNQ.js.map +1 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +362 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -37
- package/dist/index.d.ts +56 -37
- package/dist/index.js +120 -359
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-CHd2ZiGC.d.cts} +37 -25
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-Cg9usuUi.d.ts} +37 -25
- package/dist/plugins/digests.cjs +1 -4
- package/dist/plugins/digests.cjs.map +1 -1
- package/dist/plugins/digests.d.cts +6 -31
- package/dist/plugins/digests.d.ts +6 -31
- package/dist/plugins/digests.js +1 -4
- package/dist/plugins/digests.js.map +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences-react.cjs.map +1 -1
- package/dist/plugins/preferences-react.js.map +1 -1
- package/dist/plugins/preferences.cjs +101 -17
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +4 -2
- package/dist/plugins/preferences.d.ts +4 -2
- package/dist/plugins/preferences.js +36 -9
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +198 -40
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +20 -18
- package/dist/plugins/push.d.ts +20 -18
- package/dist/plugins/push.js +141 -41
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +10 -11
- package/dist/providers/web-push.d.ts +10 -11
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -4
- package/dist/react.d.ts +1 -4
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +121 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +25 -14
- package/dist/schema.d.ts +25 -14
- package/dist/schema.js +4 -2
- package/dist/testing.cjs +3 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -22
- package/dist/testing.d.ts +4 -22
- package/dist/testing.js +3 -19
- package/dist/testing.js.map +1 -1
- package/package.json +12 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-RBDW4S6F.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,20 +1,65 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# easy-ping
|
|
2
4
|
|
|
3
|
-
**Own your notifications.**
|
|
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
|
+
[](https://www.npmjs.com/package/easy-ping)
|
|
10
|
+
[](https://packagephobia.com/result?p=easy-ping)
|
|
11
|
+
[](https://www.npmjs.com/package/easy-ping)
|
|
12
|
+
[](./LICENSE)
|
|
4
13
|
|
|
5
|
-
|
|
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)
|
|
6
15
|
|
|
7
|
-
>
|
|
16
|
+
</div>
|
|
8
17
|
|
|
9
18
|
---
|
|
10
19
|
|
|
11
|
-
|
|
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.
|
|
12
33
|
|
|
13
|
-
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Why
|
|
14
37
|
|
|
15
|
-
|
|
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.
|
|
16
41
|
|
|
17
|
-
|
|
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.
|
|
18
63
|
|
|
19
64
|
---
|
|
20
65
|
|
|
@@ -23,64 +68,42 @@ easy-ping runs **inside your app**, stores notifications in **your database**, a
|
|
|
23
68
|
### 1. Install
|
|
24
69
|
|
|
25
70
|
```bash
|
|
26
|
-
pnpm add easy-ping
|
|
71
|
+
pnpm add easy-ping pg zod
|
|
27
72
|
```
|
|
28
73
|
|
|
29
74
|
### 2. Create the tables
|
|
30
75
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import { createSchema } from "easy-ping/adapters/drizzle";
|
|
76
|
+
No ORM required. The whole database contract is one function: run a parameterised statement,
|
|
77
|
+
return rows.
|
|
34
78
|
|
|
35
|
-
|
|
36
|
-
|
|
79
|
+
```ts
|
|
80
|
+
import { createPostgresTables, pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";
|
|
81
|
+
import { Pool } from "pg";
|
|
37
82
|
|
|
38
|
-
|
|
83
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
84
|
+
const query = async (text, params) => (await pool.query(text, params)).rows;
|
|
39
85
|
|
|
40
|
-
|
|
41
|
-
|
|
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);
|
|
42
89
|
|
|
43
|
-
|
|
90
|
+
export const database = postgresAdapter(query, { transaction: pgTransaction(pool) });
|
|
44
91
|
```
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
Anything that can run a statement works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
|
|
94
|
+
serverless drivers, or Prisma's `$queryRawUnsafe`.
|
|
48
95
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
96
|
+
<details>
|
|
97
|
+
<summary>Already on Drizzle</summary>
|
|
52
98
|
|
|
53
99
|
```ts
|
|
54
|
-
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
100
|
+
// db/schema.ts
|
|
101
|
+
import { createSchema } from "easy-ping/adapters/drizzle";
|
|
58
102
|
|
|
59
|
-
const
|
|
60
|
-
async (text, params) => (await pool.query(text, params as unknown[])).rows,
|
|
61
|
-
{
|
|
62
|
-
// Optional, but it is what makes createNotifications atomic.
|
|
63
|
-
transaction: async (fn) => {
|
|
64
|
-
const client = await pool.connect();
|
|
65
|
-
try {
|
|
66
|
-
await client.query("BEGIN");
|
|
67
|
-
const result = await fn(async (t, p) => (await client.query(t, p as unknown[])).rows);
|
|
68
|
-
await client.query("COMMIT");
|
|
69
|
-
return result;
|
|
70
|
-
} catch (error) {
|
|
71
|
-
await client.query("ROLLBACK");
|
|
72
|
-
throw error;
|
|
73
|
-
} finally {
|
|
74
|
-
client.release();
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
);
|
|
103
|
+
export const { notification, notificationDelivery, notificationPreference } = createSchema();
|
|
79
104
|
```
|
|
80
105
|
|
|
81
|
-
|
|
82
|
-
Anything that can do that works: `pg`, `postgres.js`, Kysely, Neon or PlanetScale's
|
|
83
|
-
serverless drivers, or Prisma's `$queryRawUnsafe`.
|
|
106
|
+
Push it with `drizzle-kit`, then `drizzleAdapter(db)` in place of `postgresAdapter`.
|
|
84
107
|
|
|
85
108
|
</details>
|
|
86
109
|
|
|
@@ -98,34 +121,91 @@ const client = new MongoClient(process.env.MONGO_URL!);
|
|
|
98
121
|
await client.connect();
|
|
99
122
|
const db = client.db("app");
|
|
100
123
|
|
|
101
|
-
//
|
|
124
|
+
// No tables, only indexes. Run once at startup.
|
|
102
125
|
await createMongoIndexes(db);
|
|
103
126
|
|
|
104
127
|
// Pass the client too: it is what makes a notification and its deliveries
|
|
105
128
|
// land together, which needs a replica set.
|
|
106
|
-
const database = mongoAdapter(db, { client });
|
|
129
|
+
export const database = mongoAdapter(db, { client });
|
|
107
130
|
```
|
|
108
131
|
|
|
109
132
|
Everything after this point is identical.
|
|
110
133
|
|
|
111
134
|
</details>
|
|
112
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
|
+
|
|
113
194
|
### 3. Configure
|
|
114
195
|
|
|
115
196
|
```ts
|
|
116
197
|
// notify.ts
|
|
117
|
-
import { defineNotification, easyPing } from "easy-ping";
|
|
118
|
-
import { drizzleAdapter } from "easy-ping/adapters/drizzle";
|
|
198
|
+
import { defineNotification, easyPing, escapeHtml } from "easy-ping";
|
|
119
199
|
import { resend } from "easy-ping/providers/resend";
|
|
120
200
|
import { after } from "next/server";
|
|
121
|
-
import { inArray } from "drizzle-orm";
|
|
122
201
|
import { z } from "zod";
|
|
123
202
|
import { auth } from "./auth";
|
|
124
|
-
import {
|
|
203
|
+
import { database, pool } from "./db"; // from step 2
|
|
125
204
|
|
|
126
205
|
export const notify = easyPing({
|
|
127
|
-
database
|
|
206
|
+
database,
|
|
128
207
|
|
|
208
|
+
// Both at least 16 characters; `openssl rand -base64 32` is the easy way.
|
|
129
209
|
secret: process.env.NOTIFY_SECRET!,
|
|
130
210
|
cron: { secret: process.env.NOTIFY_CRON_SECRET! },
|
|
131
211
|
|
|
@@ -135,14 +215,19 @@ export const notify = easyPing({
|
|
|
135
215
|
(await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
|
|
136
216
|
},
|
|
137
217
|
|
|
138
|
-
// Batched
|
|
139
|
-
getRecipients: async (userIds) =>
|
|
140
|
-
|
|
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) => ({
|
|
141
225
|
userId: u.id,
|
|
142
226
|
email: u.email,
|
|
143
227
|
timezone: u.timezone,
|
|
144
228
|
locale: u.locale,
|
|
145
|
-
}))
|
|
229
|
+
}));
|
|
230
|
+
},
|
|
146
231
|
|
|
147
232
|
channels: {
|
|
148
233
|
inApp: { enabled: true },
|
|
@@ -157,7 +242,9 @@ export const notify = easyPing({
|
|
|
157
242
|
channels: ["inApp", "email"],
|
|
158
243
|
email: {
|
|
159
244
|
subject: (p) => `${p.authorName} replied to you`,
|
|
160
|
-
|
|
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>`,
|
|
161
248
|
},
|
|
162
249
|
}),
|
|
163
250
|
},
|
|
@@ -167,7 +254,9 @@ export const notify = easyPing({
|
|
|
167
254
|
### 4. Mount the endpoints
|
|
168
255
|
|
|
169
256
|
```ts
|
|
170
|
-
// app/api/notifications/[...notify]/route.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.
|
|
171
260
|
import { notify } from "@/notify";
|
|
172
261
|
|
|
173
262
|
export const { GET, POST } = notify.handler;
|
|
@@ -315,8 +404,16 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
|
|
|
315
404
|
|
|
316
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.
|
|
317
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.
|
|
318
|
-
- **`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.
|
|
319
|
-
- **
|
|
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.
|
|
320
417
|
|
|
321
418
|
---
|
|
322
419
|
|
|
@@ -328,6 +425,8 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
|
|
|
328
425
|
| ✅ Postgres through any driver — no ORM needed | `pg`, `postgres.js`, Kysely, Neon… |
|
|
329
426
|
| ✅ Postgres via Drizzle, for those already on it | same conformance suite |
|
|
330
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` |
|
|
331
430
|
| ✅ Delivery runner, all four modes, retry + backoff | |
|
|
332
431
|
| ✅ Resend provider | |
|
|
333
432
|
| ✅ Route handler, session scoping, cron | |
|
|
@@ -337,10 +436,10 @@ Defaults to the last 24 hours, capped at 1000 rows. Wire it to an admin page or
|
|
|
337
436
|
| ✅ push plugin + web-push provider | VAPID + RFC 8291, no node:crypto |
|
|
338
437
|
| ✅ push verified against a live push service | Mozilla autopush, plus a cross-check against `http_ece` |
|
|
339
438
|
| ✅ scoped plugin storage, so plugins own their tables | |
|
|
340
|
-
| ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()
|
|
439
|
+
| ✅ additive schema migrations for the raw-SQL path | `planPostgresMigration()`; MySQL and SQLite are bootstrap-only for now |
|
|
341
440
|
| ✅ failed deliveries reachable from the instance | `notify.getFailedDeliveries()` |
|
|
342
441
|
| ⬜ realtime, batching | |
|
|
343
|
-
| ⬜ Prisma
|
|
442
|
+
| ⬜ Prisma adapter, Vue / Svelte bindings | Kysely already works through `postgresAdapter` |
|
|
344
443
|
|
|
345
444
|
---
|
|
346
445
|
|
|
@@ -356,7 +455,7 @@ for (const testCase of adapterConformanceCases) {
|
|
|
356
455
|
}
|
|
357
456
|
```
|
|
358
457
|
|
|
359
|
-
|
|
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.
|
|
360
459
|
|
|
361
460
|
Declare your dialect on the adapter so the plugin store writes what your driver expects:
|
|
362
461
|
|
|
@@ -371,11 +470,11 @@ serializesJson: boolean // json as a string, or natively
|
|
|
371
470
|
|
|
372
471
|
```bash
|
|
373
472
|
pnpm install
|
|
374
|
-
docker compose up -d # Postgres on :54329, MongoDB on :27019
|
|
473
|
+
docker compose up -d # Postgres on :54329, MongoDB on :27019, MySQL on :33069
|
|
375
474
|
pnpm test
|
|
376
475
|
```
|
|
377
476
|
|
|
378
|
-
Mongo runs as a single-node replica set, because that is the only way it offers transactions.
|
|
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.
|
|
379
478
|
|
|
380
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.
|
|
381
480
|
|
|
@@ -393,23 +492,16 @@ RESEND_API_KEY=re_... RESEND_FROM="Acme <hi@acme.dev>" pnpm --filter easy-ping
|
|
|
393
492
|
|
|
394
493
|
## Releasing
|
|
395
494
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
The Release workflow only maintains the version PR; it does **not** publish. npm trusted publishing (OIDC) cannot create a package that does not exist — a trusted publisher is configured against an existing package, so the first `PUT` is rejected as `E404`, which reads like "name taken" and is not.
|
|
399
|
-
|
|
400
|
-
The first release is manual:
|
|
495
|
+
Every release is automatic. Open a PR with a changeset:
|
|
401
496
|
|
|
402
497
|
```bash
|
|
403
|
-
#
|
|
404
|
-
pnpm changeset # choose minor -> 0.1.0
|
|
405
|
-
pnpm changeset version
|
|
406
|
-
|
|
407
|
-
# 2. publish once, by hand
|
|
408
|
-
npm login
|
|
409
|
-
pnpm --filter easy-ping publish --access public
|
|
498
|
+
pnpm changeset # describe the change, pick patch/minor/major
|
|
410
499
|
```
|
|
411
500
|
|
|
412
|
-
|
|
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.
|
|
413
505
|
|
|
414
506
|
## License
|
|
415
507
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type Row = Record<string, unknown>;
|
|
2
|
+
type SqlResult = {
|
|
3
|
+
rows: readonly Row[];
|
|
4
|
+
/** Rows an INSERT/UPDATE/DELETE touched; drivers without RETURNING report through this. */
|
|
5
|
+
affectedRows: number;
|
|
6
|
+
};
|
|
7
|
+
/** The whole driver contract for the MySQL and SQLite adapters. */
|
|
8
|
+
type SqlExec = (text: string, params: readonly unknown[]) => Promise<SqlResult>;
|
|
9
|
+
|
|
10
|
+
type SqlAdapterOptions = {
|
|
11
|
+
/** Table-name prefix. Must match the instance's `tablePrefix`. */
|
|
12
|
+
prefix?: string;
|
|
13
|
+
/** Makes createNotifications atomic and, on MySQL, lets a claim use FOR UPDATE SKIP LOCKED. */
|
|
14
|
+
transaction?: <T>(fn: (exec: SqlExec) => Promise<T>) => Promise<T>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type { SqlAdapterOptions as S, SqlExec as a, SqlResult as b };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type Row = Record<string, unknown>;
|
|
2
|
+
type SqlResult = {
|
|
3
|
+
rows: readonly Row[];
|
|
4
|
+
/** Rows an INSERT/UPDATE/DELETE touched; drivers without RETURNING report through this. */
|
|
5
|
+
affectedRows: number;
|
|
6
|
+
};
|
|
7
|
+
/** The whole driver contract for the MySQL and SQLite adapters. */
|
|
8
|
+
type SqlExec = (text: string, params: readonly unknown[]) => Promise<SqlResult>;
|
|
9
|
+
|
|
10
|
+
type SqlAdapterOptions = {
|
|
11
|
+
/** Table-name prefix. Must match the instance's `tablePrefix`. */
|
|
12
|
+
prefix?: string;
|
|
13
|
+
/** Makes createNotifications atomic and, on MySQL, lets a claim use FOR UPDATE SKIP LOCKED. */
|
|
14
|
+
transaction?: <T>(fn: (exec: SqlExec) => Promise<T>) => Promise<T>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type { SqlAdapterOptions as S, SqlExec as a, SqlResult as b };
|
|
@@ -41,11 +41,7 @@ type DeliveryRecord = {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
type Scalar = string | number | boolean | Date | null;
|
|
44
|
-
/**
|
|
45
|
-
* One operator per condition. Operators are tagged objects so a bare value is
|
|
46
|
-
* always an equality test; json columns therefore cannot be filtered, since an
|
|
47
|
-
* object value would be ambiguous.
|
|
48
|
-
*/
|
|
44
|
+
/** Operators are tagged objects so a bare value is always equality; json columns cannot be filtered. */
|
|
49
45
|
type WhereCondition = Scalar | {
|
|
50
46
|
in: readonly (string | number)[];
|
|
51
47
|
} | {
|
|
@@ -70,13 +66,7 @@ type QueryOptions = {
|
|
|
70
66
|
type UpsertOptions = {
|
|
71
67
|
onConflict: readonly string[];
|
|
72
68
|
};
|
|
73
|
-
/**
|
|
74
|
-
* Table access for plugins, scoped to the tables that plugin declared.
|
|
75
|
-
*
|
|
76
|
-
* Plugins could always declare tables via schema() but had no way to read
|
|
77
|
-
* them, so preferences had to bolt its queries onto the core adapter. That
|
|
78
|
-
* does not generalise to digests or push.
|
|
79
|
-
*/
|
|
69
|
+
/** Table access for plugins, scoped to the tables the plugin declared. */
|
|
80
70
|
type PluginStore = {
|
|
81
71
|
find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
|
|
82
72
|
insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
|
|
@@ -138,12 +128,7 @@ type DeliveryOutcome = {
|
|
|
138
128
|
error: string;
|
|
139
129
|
retryable: boolean;
|
|
140
130
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Nothing to do, and nothing wrong. A channel whose preconditions are
|
|
143
|
-
* simply absent — push with no registered device, say — must not land in
|
|
144
|
-
* `getFailedDeliveries`, or the one view meant to surface real breakage
|
|
145
|
-
* fills up with people who never opted in. Terminal: never retried.
|
|
146
|
-
*/
|
|
131
|
+
/** Nothing to do and nothing wrong (push with no device, say). Terminal; kept out of getFailedDeliveries. */
|
|
147
132
|
| {
|
|
148
133
|
result: "skipped";
|
|
149
134
|
reason: string;
|
|
@@ -167,16 +152,9 @@ type FeedPage = {
|
|
|
167
152
|
};
|
|
168
153
|
type DatabaseAdapter = {
|
|
169
154
|
readonly name: string;
|
|
170
|
-
/**
|
|
171
|
-
* How the adapter names stored fields. SQL adapters use snake_case columns;
|
|
172
|
-
* document stores keep the declared camelCase. The plugin store translates
|
|
173
|
-
* accordingly rather than assuming one of them.
|
|
174
|
-
*/
|
|
155
|
+
/** snake_case columns (SQL) or the declared camelCase (document stores); the store translates. */
|
|
175
156
|
readonly naming?: "snake_case" | "preserve";
|
|
176
|
-
/**
|
|
177
|
-
* True when json values must reach the driver as strings. postgres-js cannot
|
|
178
|
-
* bind a plain object; Mongo stores one natively.
|
|
179
|
-
*/
|
|
157
|
+
/** True when json must reach the driver as a string. */
|
|
180
158
|
readonly serializesJson?: boolean;
|
|
181
159
|
/** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
|
|
182
160
|
createNotifications(rows: readonly InsertNotification[]): Promise<{
|
|
@@ -196,11 +174,7 @@ type DatabaseAdapter = {
|
|
|
196
174
|
since: Date;
|
|
197
175
|
limit: number;
|
|
198
176
|
}): Promise<readonly DeliveryRecord[]>;
|
|
199
|
-
/**
|
|
200
|
-
* Generic table access for plugin-declared tables. Reached only through a
|
|
201
|
-
* PluginStore, which validates the table and every column against the
|
|
202
|
-
* plugin's own schema() before anything gets here.
|
|
203
|
-
*/
|
|
177
|
+
/** Plugin-table access. Reached only through a PluginStore, which validated table and columns. */
|
|
204
178
|
queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
|
|
205
179
|
insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
|
|
206
180
|
updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
@@ -41,11 +41,7 @@ type DeliveryRecord = {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
type Scalar = string | number | boolean | Date | null;
|
|
44
|
-
/**
|
|
45
|
-
* One operator per condition. Operators are tagged objects so a bare value is
|
|
46
|
-
* always an equality test; json columns therefore cannot be filtered, since an
|
|
47
|
-
* object value would be ambiguous.
|
|
48
|
-
*/
|
|
44
|
+
/** Operators are tagged objects so a bare value is always equality; json columns cannot be filtered. */
|
|
49
45
|
type WhereCondition = Scalar | {
|
|
50
46
|
in: readonly (string | number)[];
|
|
51
47
|
} | {
|
|
@@ -70,13 +66,7 @@ type QueryOptions = {
|
|
|
70
66
|
type UpsertOptions = {
|
|
71
67
|
onConflict: readonly string[];
|
|
72
68
|
};
|
|
73
|
-
/**
|
|
74
|
-
* Table access for plugins, scoped to the tables that plugin declared.
|
|
75
|
-
*
|
|
76
|
-
* Plugins could always declare tables via schema() but had no way to read
|
|
77
|
-
* them, so preferences had to bolt its queries onto the core adapter. That
|
|
78
|
-
* does not generalise to digests or push.
|
|
79
|
-
*/
|
|
69
|
+
/** Table access for plugins, scoped to the tables the plugin declared. */
|
|
80
70
|
type PluginStore = {
|
|
81
71
|
find<T = Record<string, unknown>>(table: string, where?: WhereClause, options?: QueryOptions): Promise<T[]>;
|
|
82
72
|
insert(table: string, rows: readonly Record<string, unknown>[]): Promise<number>;
|
|
@@ -138,12 +128,7 @@ type DeliveryOutcome = {
|
|
|
138
128
|
error: string;
|
|
139
129
|
retryable: boolean;
|
|
140
130
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Nothing to do, and nothing wrong. A channel whose preconditions are
|
|
143
|
-
* simply absent — push with no registered device, say — must not land in
|
|
144
|
-
* `getFailedDeliveries`, or the one view meant to surface real breakage
|
|
145
|
-
* fills up with people who never opted in. Terminal: never retried.
|
|
146
|
-
*/
|
|
131
|
+
/** Nothing to do and nothing wrong (push with no device, say). Terminal; kept out of getFailedDeliveries. */
|
|
147
132
|
| {
|
|
148
133
|
result: "skipped";
|
|
149
134
|
reason: string;
|
|
@@ -167,16 +152,9 @@ type FeedPage = {
|
|
|
167
152
|
};
|
|
168
153
|
type DatabaseAdapter = {
|
|
169
154
|
readonly name: string;
|
|
170
|
-
/**
|
|
171
|
-
* How the adapter names stored fields. SQL adapters use snake_case columns;
|
|
172
|
-
* document stores keep the declared camelCase. The plugin store translates
|
|
173
|
-
* accordingly rather than assuming one of them.
|
|
174
|
-
*/
|
|
155
|
+
/** snake_case columns (SQL) or the declared camelCase (document stores); the store translates. */
|
|
175
156
|
readonly naming?: "snake_case" | "preserve";
|
|
176
|
-
/**
|
|
177
|
-
* True when json values must reach the driver as strings. postgres-js cannot
|
|
178
|
-
* bind a plain object; Mongo stores one natively.
|
|
179
|
-
*/
|
|
157
|
+
/** True when json must reach the driver as a string. */
|
|
180
158
|
readonly serializesJson?: boolean;
|
|
181
159
|
/** One transaction. A duplicate (userId, dedupeKey) is reported, not thrown. */
|
|
182
160
|
createNotifications(rows: readonly InsertNotification[]): Promise<{
|
|
@@ -196,11 +174,7 @@ type DatabaseAdapter = {
|
|
|
196
174
|
since: Date;
|
|
197
175
|
limit: number;
|
|
198
176
|
}): Promise<readonly DeliveryRecord[]>;
|
|
199
|
-
/**
|
|
200
|
-
* Generic table access for plugin-declared tables. Reached only through a
|
|
201
|
-
* PluginStore, which validates the table and every column against the
|
|
202
|
-
* plugin's own schema() before anything gets here.
|
|
203
|
-
*/
|
|
177
|
+
/** Plugin-table access. Reached only through a PluginStore, which validated table and columns. */
|
|
204
178
|
queryTable(table: string, where: WhereClause, options: QueryOptions): Promise<Record<string, unknown>[]>;
|
|
205
179
|
insertRows(table: string, rows: readonly Record<string, unknown>[], onConflict?: readonly string[]): Promise<number>;
|
|
206
180
|
updateRows(table: string, where: WhereClause, set: Record<string, unknown>): Promise<number>;
|
|
@@ -30,7 +30,7 @@ var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}
|
|
|
30
30
|
|
|
31
31
|
// src/core/store.ts
|
|
32
32
|
var OPERATORS = ["in", "lt", "lte", "gt", "gte", "not"];
|
|
33
|
-
var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => op
|
|
33
|
+
var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => Object.hasOwn(value, op));
|
|
34
34
|
|
|
35
35
|
// src/adapters/drizzle/adapter.ts
|
|
36
36
|
function toRows(result) {
|
|
@@ -397,8 +397,7 @@ function createSchema(prefix = "") {
|
|
|
397
397
|
pgCore.index(name("notification_feed_idx")).on(table.userId, table.createdAt.desc()),
|
|
398
398
|
pgCore.index(name("notification_unseen_idx")).on(table.userId).where(drizzleOrm.sql`${table.seenAt} is null`),
|
|
399
399
|
pgCore.index(name("notification_group_idx")).on(table.userId, table.groupKey),
|
|
400
|
-
//
|
|
401
|
-
// dedupe key while a present one is enforced unique per user.
|
|
400
|
+
// NULLs are distinct here: unlimited rows without a dedupe key.
|
|
402
401
|
pgCore.uniqueIndex(name("notification_dedupe_idx")).on(table.userId, table.dedupeKey)
|
|
403
402
|
]
|
|
404
403
|
);
|
|
@@ -429,7 +428,8 @@ function createSchema(prefix = "") {
|
|
|
429
428
|
type: pgCore.text("type").notNull(),
|
|
430
429
|
channel: pgCore.text("channel").$type().notNull(),
|
|
431
430
|
enabled: pgCore.boolean("enabled").notNull().default(true),
|
|
432
|
-
frequency: pgCore.text("frequency").$type().notNull().default("instant")
|
|
431
|
+
frequency: pgCore.text("frequency").$type().notNull().default("instant"),
|
|
432
|
+
updatedAt: pgCore.timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
433
433
|
},
|
|
434
434
|
(table) => [pgCore.primaryKey({ columns: [table.userId, table.type, table.channel] })]
|
|
435
435
|
);
|