easy-ping 0.1.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/adapter-f0OR2DhY.d.cts +200 -0
  4. package/dist/adapter-f0OR2DhY.d.ts +200 -0
  5. package/dist/adapters/drizzle.cjs +440 -0
  6. package/dist/adapters/drizzle.cjs.map +1 -0
  7. package/dist/adapters/drizzle.d.cts +991 -0
  8. package/dist/adapters/drizzle.d.ts +991 -0
  9. package/dist/adapters/drizzle.js +409 -0
  10. package/dist/adapters/drizzle.js.map +1 -0
  11. package/dist/adapters/mongodb.cjs +458 -0
  12. package/dist/adapters/mongodb.cjs.map +1 -0
  13. package/dist/adapters/mongodb.d.cts +32 -0
  14. package/dist/adapters/mongodb.d.ts +32 -0
  15. package/dist/adapters/mongodb.js +376 -0
  16. package/dist/adapters/mongodb.js.map +1 -0
  17. package/dist/browser.cjs +96 -0
  18. package/dist/browser.cjs.map +1 -0
  19. package/dist/browser.d.cts +45 -0
  20. package/dist/browser.d.ts +45 -0
  21. package/dist/browser.js +89 -0
  22. package/dist/browser.js.map +1 -0
  23. package/dist/chunk-BV3U542P.js +11 -0
  24. package/dist/chunk-BV3U542P.js.map +1 -0
  25. package/dist/chunk-NYDVNMKG.js +62 -0
  26. package/dist/chunk-NYDVNMKG.js.map +1 -0
  27. package/dist/chunk-OZSOIX23.js +120 -0
  28. package/dist/chunk-OZSOIX23.js.map +1 -0
  29. package/dist/chunk-PTGHMCCG.js +23 -0
  30. package/dist/chunk-PTGHMCCG.js.map +1 -0
  31. package/dist/chunk-W3BMEPPQ.js +50 -0
  32. package/dist/chunk-W3BMEPPQ.js.map +1 -0
  33. package/dist/client.cjs +160 -0
  34. package/dist/client.cjs.map +1 -0
  35. package/dist/client.d.cts +42 -0
  36. package/dist/client.d.ts +42 -0
  37. package/dist/client.js +158 -0
  38. package/dist/client.js.map +1 -0
  39. package/dist/index.cjs +920 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +203 -0
  42. package/dist/index.d.ts +203 -0
  43. package/dist/index.js +723 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/node.cjs +49 -0
  46. package/dist/node.cjs.map +1 -0
  47. package/dist/node.d.cts +8 -0
  48. package/dist/node.d.ts +8 -0
  49. package/dist/node.js +46 -0
  50. package/dist/node.js.map +1 -0
  51. package/dist/plugin-Bqj9xAOS.d.cts +217 -0
  52. package/dist/plugin-bTJKmnlq.d.ts +217 -0
  53. package/dist/plugins/digests.cjs +206 -0
  54. package/dist/plugins/digests.cjs.map +1 -0
  55. package/dist/plugins/digests.d.cts +68 -0
  56. package/dist/plugins/digests.d.ts +68 -0
  57. package/dist/plugins/digests.js +201 -0
  58. package/dist/plugins/digests.js.map +1 -0
  59. package/dist/plugins/preferences-client.cjs +23 -0
  60. package/dist/plugins/preferences-client.cjs.map +1 -0
  61. package/dist/plugins/preferences-client.d.cts +29 -0
  62. package/dist/plugins/preferences-client.d.ts +29 -0
  63. package/dist/plugins/preferences-client.js +18 -0
  64. package/dist/plugins/preferences-client.js.map +1 -0
  65. package/dist/plugins/preferences-react.cjs +125 -0
  66. package/dist/plugins/preferences-react.cjs.map +1 -0
  67. package/dist/plugins/preferences-react.d.cts +40 -0
  68. package/dist/plugins/preferences-react.d.ts +40 -0
  69. package/dist/plugins/preferences-react.js +101 -0
  70. package/dist/plugins/preferences-react.js.map +1 -0
  71. package/dist/plugins/preferences.cjs +147 -0
  72. package/dist/plugins/preferences.cjs.map +1 -0
  73. package/dist/plugins/preferences.d.cts +22 -0
  74. package/dist/plugins/preferences.d.ts +22 -0
  75. package/dist/plugins/preferences.js +119 -0
  76. package/dist/plugins/preferences.js.map +1 -0
  77. package/dist/plugins/push.cjs +146 -0
  78. package/dist/plugins/push.cjs.map +1 -0
  79. package/dist/plugins/push.d.cts +60 -0
  80. package/dist/plugins/push.d.ts +60 -0
  81. package/dist/plugins/push.js +144 -0
  82. package/dist/plugins/push.js.map +1 -0
  83. package/dist/provider-CA45WVqt.d.cts +23 -0
  84. package/dist/provider-CA45WVqt.d.ts +23 -0
  85. package/dist/providers/resend.cjs +69 -0
  86. package/dist/providers/resend.cjs.map +1 -0
  87. package/dist/providers/resend.d.cts +20 -0
  88. package/dist/providers/resend.d.ts +20 -0
  89. package/dist/providers/resend.js +66 -0
  90. package/dist/providers/resend.js.map +1 -0
  91. package/dist/providers/web-push.cjs +211 -0
  92. package/dist/providers/web-push.cjs.map +1 -0
  93. package/dist/providers/web-push.d.cts +33 -0
  94. package/dist/providers/web-push.d.ts +33 -0
  95. package/dist/providers/web-push.js +193 -0
  96. package/dist/providers/web-push.js.map +1 -0
  97. package/dist/react.cjs +218 -0
  98. package/dist/react.cjs.map +1 -0
  99. package/dist/react.d.cts +57 -0
  100. package/dist/react.d.ts +57 -0
  101. package/dist/react.js +193 -0
  102. package/dist/react.js.map +1 -0
  103. package/dist/schema.cjs +263 -0
  104. package/dist/schema.cjs.map +1 -0
  105. package/dist/schema.d.cts +180 -0
  106. package/dist/schema.d.ts +180 -0
  107. package/dist/schema.js +200 -0
  108. package/dist/schema.js.map +1 -0
  109. package/dist/testing.cjs +342 -0
  110. package/dist/testing.cjs.map +1 -0
  111. package/dist/testing.d.cts +38 -0
  112. package/dist/testing.d.ts +38 -0
  113. package/dist/testing.js +340 -0
  114. package/dist/testing.js.map +1 -0
  115. package/package.json +168 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,920 @@
1
+ 'use strict';
2
+
3
+ // src/core/base64url.ts
4
+ function encodeBase64Url(input) {
5
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
6
+ let binary = "";
7
+ for (const byte of bytes) binary += String.fromCharCode(byte);
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+ function decodeBase64UrlBytes(value) {
11
+ try {
12
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
13
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ function decodeBase64Url(value) {
19
+ const bytes = decodeBase64UrlBytes(value);
20
+ return bytes ? new TextDecoder().decode(bytes) : null;
21
+ }
22
+
23
+ // src/core/definition.ts
24
+ function defineNotification(definition) {
25
+ return definition;
26
+ }
27
+
28
+ // src/core/errors.ts
29
+ var EasyPingError = class extends Error {
30
+ constructor(message2) {
31
+ super(message2);
32
+ this.name = "EasyPingError";
33
+ }
34
+ };
35
+ var ValidationError = class extends EasyPingError {
36
+ issues;
37
+ constructor(type, issues) {
38
+ super(`Payload for "${type}" failed validation: ${issues.map((i) => i.message).join("; ")}`);
39
+ this.name = "ValidationError";
40
+ this.issues = issues;
41
+ }
42
+ };
43
+ var ConfigError = class extends EasyPingError {
44
+ constructor(message2) {
45
+ super(message2);
46
+ this.name = "ConfigError";
47
+ }
48
+ };
49
+ var consoleLogger = {
50
+ warn: (message2, meta) => console.warn(`[easy-ping] ${message2}`, meta ?? ""),
51
+ error: (message2, meta) => console.error(`[easy-ping] ${message2}`, meta ?? "")
52
+ };
53
+
54
+ // src/core/tokens.ts
55
+ var encoder = new TextEncoder();
56
+ async function hmacKey(secret) {
57
+ return crypto.subtle.importKey(
58
+ "raw",
59
+ encoder.encode(secret),
60
+ { name: "HMAC", hash: "SHA-256" },
61
+ false,
62
+ ["sign", "verify"]
63
+ );
64
+ }
65
+ async function signToken(secret, claims) {
66
+ const body = encodeBase64Url(JSON.stringify(claims));
67
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(body));
68
+ return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;
69
+ }
70
+ async function verifyToken(secret, token, purpose, now = /* @__PURE__ */ new Date()) {
71
+ const separator = token.lastIndexOf(".");
72
+ if (separator <= 0) return null;
73
+ const body = token.slice(0, separator);
74
+ const signature = decodeBase64UrlBytes(token.slice(separator + 1));
75
+ if (!signature) return null;
76
+ const valid = await crypto.subtle.verify(
77
+ "HMAC",
78
+ await hmacKey(secret),
79
+ signature,
80
+ encoder.encode(body)
81
+ );
82
+ if (!valid) return null;
83
+ const decoded = decodeBase64Url(body);
84
+ if (!decoded) return null;
85
+ let claims;
86
+ try {
87
+ claims = JSON.parse(decoded);
88
+ } catch {
89
+ return null;
90
+ }
91
+ if (typeof claims?.uid !== "string" || typeof claims.exp !== "number") return null;
92
+ if (claims.purpose !== purpose) return null;
93
+ if (claims.exp * 1e3 <= now.getTime()) return null;
94
+ return claims;
95
+ }
96
+ var DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
97
+ var expiresIn = (seconds, now = /* @__PURE__ */ new Date()) => Math.floor(now.getTime() / 1e3) + seconds;
98
+
99
+ // src/core/handler.ts
100
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
101
+ status,
102
+ headers: { "content-type": "application/json" }
103
+ });
104
+ var empty = (status) => new Response(null, { status });
105
+ var MAX_READ_IDS = 200;
106
+ function timingSafeEqual(a, b) {
107
+ const encoder2 = new TextEncoder();
108
+ const left = encoder2.encode(a);
109
+ const right = encoder2.encode(b);
110
+ let diff = left.length ^ right.length;
111
+ const length = Math.max(left.length, right.length);
112
+ for (let i = 0; i < length; i += 1) {
113
+ diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
114
+ }
115
+ return diff === 0;
116
+ }
117
+ function routePath(url, basePath) {
118
+ const index = url.pathname.indexOf(basePath);
119
+ if (index < 0) return url.pathname;
120
+ return url.pathname.slice(index + basePath.length) || "/";
121
+ }
122
+ async function readJson(request) {
123
+ try {
124
+ const body = await request.json();
125
+ return body && typeof body === "object" ? body : {};
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+ function createHandler(deps) {
131
+ async function resolveUserId(request) {
132
+ try {
133
+ return await deps.session.getUserId(request);
134
+ } catch (error) {
135
+ deps.logger.error("session.getUserId threw", { error });
136
+ return "error";
137
+ }
138
+ }
139
+ async function handleCron(request) {
140
+ if (!deps.cronSecret) return empty(404);
141
+ const header = request.headers.get("authorization") ?? "";
142
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
143
+ if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
144
+ const result = await deps.runner.drain();
145
+ return json(result);
146
+ }
147
+ async function handlePluginRoute(route, request, url) {
148
+ if (route.scope.type === "user") {
149
+ const userId = await resolveUserId(request);
150
+ if (userId === "error") return empty(500);
151
+ if (userId === null) return empty(401);
152
+ return route.handler({ request, userId, claims: null, params: {} });
153
+ }
154
+ if (route.scope.type === "machine") {
155
+ if (!deps.cronSecret) return empty(404);
156
+ const header = request.headers.get("authorization") ?? "";
157
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
158
+ if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
159
+ return route.handler({ request, userId: null, claims: null, params: {} });
160
+ }
161
+ if (route.scope.type === "signed") {
162
+ const token = url.searchParams.get("token") ?? (request.headers.get("authorization") ?? "").replace(/^Bearer /, "");
163
+ const claims = await verifyToken(deps.secret, token, route.scope.purpose);
164
+ if (!claims) return empty(400);
165
+ return route.handler({ request, userId: claims.uid, claims, params: {} });
166
+ }
167
+ return route.handler({ request, userId: null, claims: null, params: {} });
168
+ }
169
+ async function handle(request) {
170
+ const url = new URL(request.url);
171
+ const path = routePath(url, deps.basePath);
172
+ if (path === "/cron") {
173
+ if (request.method !== "POST") return empty(405);
174
+ return handleCron(request);
175
+ }
176
+ const pluginRoute = deps.pluginRoutes?.find(
177
+ (route) => route.path === path && route.method === request.method
178
+ );
179
+ if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);
180
+ const userId = await resolveUserId(request);
181
+ if (userId === "error") return empty(500);
182
+ if (userId === null) return empty(401);
183
+ if (request.method === "GET" && path === "/") {
184
+ const limitParam = Number(url.searchParams.get("limit") ?? 20);
185
+ const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
186
+ const cursor = url.searchParams.get("cursor");
187
+ const page = await deps.adapter.listNotifications({
188
+ userId,
189
+ limit,
190
+ ...cursor ? { cursor } : {},
191
+ ...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
192
+ });
193
+ return json(page);
194
+ }
195
+ if (request.method === "GET" && path === "/count") {
196
+ return json({ unseen: await deps.adapter.countUnseen(userId) });
197
+ }
198
+ if (request.method === "POST" && path === "/seen") {
199
+ const body = await readJson(request);
200
+ const before = typeof body.before === "string" ? new Date(body.before) : null;
201
+ const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
202
+ await deps.adapter.markSeen(userId, cutoff);
203
+ return json({ ok: true });
204
+ }
205
+ if (request.method === "POST" && path === "/read") {
206
+ const body = await readJson(request);
207
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : [];
208
+ if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
209
+ if (ids.length > MAX_READ_IDS) {
210
+ return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
211
+ }
212
+ const updated = await deps.adapter.markRead(userId, ids);
213
+ return updated === 0 ? empty(404) : json({ updated });
214
+ }
215
+ if (request.method === "POST" && path === "/read-all") {
216
+ return json({ updated: await deps.adapter.markAllRead(userId) });
217
+ }
218
+ return empty(404);
219
+ }
220
+ return {
221
+ handle,
222
+ GET: handle,
223
+ POST: handle
224
+ };
225
+ }
226
+
227
+ // src/core/backoff.ts
228
+ var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
229
+ function backoffMs(attempt, strategy = "exponential") {
230
+ if (typeof strategy === "function") return Math.max(0, strategy(attempt));
231
+ const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;
232
+ const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 3e4;
233
+ return Math.round(base * (0.8 + Math.random() * 0.4));
234
+ }
235
+ var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
236
+
237
+ // src/core/runner.ts
238
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
239
+ async function withTimeout(run, ms, label) {
240
+ const controller = new AbortController();
241
+ let timer;
242
+ const timeout = new Promise((_, reject) => {
243
+ timer = setTimeout(() => {
244
+ controller.abort();
245
+ reject(new Error(`${label} timed out after ${ms}ms`));
246
+ }, ms);
247
+ });
248
+ try {
249
+ return await Promise.race([run(controller.signal), timeout]);
250
+ } finally {
251
+ clearTimeout(timer);
252
+ }
253
+ }
254
+ var message = (error) => error instanceof Error ? error.message : String(error);
255
+ function createRunner(deps) {
256
+ async function deliverOne(delivery, recipient) {
257
+ if (!recipient) {
258
+ return { result: "failed", error: "recipient could not be resolved", retryable: false };
259
+ }
260
+ if (delivery.channel === "inApp") {
261
+ return { result: "sent" };
262
+ }
263
+ if (delivery.channel === "email") {
264
+ const provider = deps.channels.email?.provider;
265
+ if (!provider) {
266
+ return { result: "failed", error: "no email provider configured", retryable: false };
267
+ }
268
+ const to = recipient.email;
269
+ if (!to) {
270
+ return { result: "failed", error: "recipient has no email address", retryable: false };
271
+ }
272
+ const email = deps.definitions[delivery.notification.type]?.email;
273
+ if (!email) {
274
+ return {
275
+ result: "failed",
276
+ error: `notification type "${delivery.notification.type}" has no email template`,
277
+ retryable: false
278
+ };
279
+ }
280
+ try {
281
+ const payload = delivery.notification.payload;
282
+ const html = await email.template(payload);
283
+ await withTimeout(
284
+ (signal) => provider.send({
285
+ to,
286
+ subject: email.subject(payload),
287
+ html,
288
+ // Delivery is at-least-once; the provider dedupes on this.
289
+ idempotencyKey: delivery.id,
290
+ signal
291
+ }),
292
+ provider.timeoutMs ?? 3e4,
293
+ `${provider.name} send`
294
+ );
295
+ return { result: "sent" };
296
+ } catch (error) {
297
+ return {
298
+ result: "failed",
299
+ error: message(error),
300
+ retryable: provider.isRetryable(error)
301
+ };
302
+ }
303
+ }
304
+ const owner = deps.plugins.find(
305
+ (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver
306
+ );
307
+ if (!owner?.hooks?.deliver) {
308
+ return {
309
+ result: "failed",
310
+ error: `no plugin delivers channel "${delivery.channel}"`,
311
+ retryable: false
312
+ };
313
+ }
314
+ try {
315
+ return await withTimeout(
316
+ (signal) => Promise.resolve(
317
+ // biome-ignore lint/style/noNonNullAssertion: guarded directly above
318
+ owner.hooks.deliver({
319
+ channel: delivery.channel,
320
+ deliveryId: delivery.id,
321
+ attempt: delivery.attempts + 1,
322
+ recipient,
323
+ notification: {
324
+ id: delivery.notificationId,
325
+ userId: delivery.notification.userId,
326
+ type: delivery.notification.type,
327
+ payload: delivery.notification.payload
328
+ },
329
+ signal
330
+ })
331
+ ),
332
+ 3e4,
333
+ `plugin "${owner.id}" deliver`
334
+ );
335
+ } catch (error) {
336
+ return { result: "failed", error: message(error), retryable: true };
337
+ }
338
+ }
339
+ async function notifyAfterDeliver(delivery, outcome) {
340
+ const isTerminal = outcome.result === "sent" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
341
+ for (const plugin of deps.plugins) {
342
+ const hook = plugin.hooks?.afterDeliver;
343
+ if (!hook) continue;
344
+ try {
345
+ await hook({
346
+ deliveryId: delivery.id,
347
+ notificationId: delivery.notificationId,
348
+ userId: delivery.notification.userId,
349
+ channel: delivery.channel,
350
+ outcome,
351
+ attempt: delivery.attempts + 1,
352
+ isTerminal
353
+ });
354
+ } catch (error) {
355
+ deps.logger.error(`plugin "${plugin.id}" afterDeliver threw; ignoring`, {
356
+ deliveryId: delivery.id,
357
+ error
358
+ });
359
+ }
360
+ }
361
+ }
362
+ async function runOnce(options = {}) {
363
+ const claimToken = crypto.randomUUID();
364
+ const claimed = await deps.adapter.claimPendingDeliveries({
365
+ limit: options.limit ?? deps.batchSize,
366
+ leaseMs: deps.leaseMs,
367
+ claimToken,
368
+ ...options.channels ? { channels: options.channels } : {},
369
+ ...options.ids ? { ids: options.ids } : {}
370
+ });
371
+ if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0 };
372
+ const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
373
+ const recipients = await deps.getRecipients(userIds);
374
+ const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
375
+ const releases = [];
376
+ let sent = 0;
377
+ let failed = 0;
378
+ for (const delivery of claimed) {
379
+ const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
380
+ if (outcome.result === "sent") sent += 1;
381
+ else failed += 1;
382
+ releases.push({
383
+ id: delivery.id,
384
+ claimToken,
385
+ outcome,
386
+ ...outcome.result === "failed" && outcome.retryable ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) } : {}
387
+ });
388
+ await notifyAfterDeliver(delivery, outcome);
389
+ }
390
+ await deps.adapter.releaseDeliveries(releases);
391
+ return { claimed: claimed.length, sent, failed };
392
+ }
393
+ async function drain(options = {}) {
394
+ const maxSweeps = options.maxSweeps ?? 50;
395
+ const total = { claimed: 0, sent: 0, failed: 0 };
396
+ for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
397
+ const result = await runOnce(options.ids ? { ids: options.ids } : {});
398
+ total.claimed += result.claimed;
399
+ total.sent += result.sent;
400
+ total.failed += result.failed;
401
+ if (result.claimed === 0) break;
402
+ }
403
+ return total;
404
+ }
405
+ let warnedMissingWaitUntil = false;
406
+ function scheduleDeferred(waitUntil, ids) {
407
+ if (waitUntil) {
408
+ waitUntil(
409
+ drain({ maxSweeps: 10, ...ids ? { ids } : {} }).catch(
410
+ (error) => deps.logger.error("deferred sweep failed", { error })
411
+ )
412
+ );
413
+ return;
414
+ }
415
+ if (!warnedMissingWaitUntil) {
416
+ warnedMissingWaitUntil = true;
417
+ deps.logger.warn(
418
+ "delivery.mode is 'deferred' but no delivery.waitUntil was supplied \u2014 falling back to the cron sweep. Notifications are not lost, but they will not be delivered until POST /cron runs."
419
+ );
420
+ }
421
+ }
422
+ function startWorker(options = {}) {
423
+ const intervalMs = options.intervalMs ?? 1e3;
424
+ let stopped = false;
425
+ const loop = (async () => {
426
+ while (!stopped) {
427
+ try {
428
+ const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
429
+ if (result.claimed === 0) await sleep(intervalMs);
430
+ } catch (error) {
431
+ deps.logger.error("worker sweep failed", { error });
432
+ await sleep(intervalMs);
433
+ }
434
+ }
435
+ })();
436
+ return {
437
+ stop: async () => {
438
+ stopped = true;
439
+ await loop;
440
+ }
441
+ };
442
+ }
443
+ return { runOnce, drain, scheduleDeferred, startWorker };
444
+ }
445
+
446
+ // src/core/send.ts
447
+ async function validatePayload(type, schema, payload) {
448
+ const result = await schema["~standard"].validate(payload);
449
+ if (result.issues) {
450
+ throw new ValidationError(
451
+ type,
452
+ result.issues.map((issue) => ({
453
+ message: issue.message,
454
+ ...issue.path ? { path: issue.path.map(String).join(".") } : {}
455
+ }))
456
+ );
457
+ }
458
+ return result.value;
459
+ }
460
+ function isChannelUsable(channel, channels, plugins = []) {
461
+ if (channel === "inApp") return channels.inApp?.enabled !== false;
462
+ if (channel === "email") return Boolean(channels.email?.provider);
463
+ return plugins.some((plugin) => plugin.channels?.includes(channel) && plugin.hooks?.deliver);
464
+ }
465
+ async function runPrepare(plugins, ctx, logger) {
466
+ const prepared = /* @__PURE__ */ new Map();
467
+ for (const plugin of plugins) {
468
+ const hook = plugin.hooks?.prepare;
469
+ if (!hook) continue;
470
+ try {
471
+ prepared.set(plugin.id, await hook(ctx));
472
+ } catch (error) {
473
+ logger.error(`plugin "${plugin.id}" prepare threw; its hooks will see undefined`, {
474
+ type: ctx.type,
475
+ error
476
+ });
477
+ prepared.set(plugin.id, void 0);
478
+ }
479
+ }
480
+ return prepared;
481
+ }
482
+ async function runBeforeSend(plugins, base, payload, dedupeKey, prepared, logger) {
483
+ let currentPayload = payload;
484
+ let currentDedupeKey = dedupeKey;
485
+ for (const plugin of plugins) {
486
+ const hook = plugin.hooks?.beforeSend;
487
+ if (!hook) continue;
488
+ let result;
489
+ try {
490
+ result = await hook({
491
+ ...base,
492
+ payload: currentPayload,
493
+ dedupeKey: currentDedupeKey,
494
+ prepared: prepared.get(plugin.id)
495
+ });
496
+ } catch (error) {
497
+ logger.error(`plugin "${plugin.id}" beforeSend threw; cancelling send`, {
498
+ type: base.type,
499
+ userId: base.recipient.userId,
500
+ error
501
+ });
502
+ return { action: "cancel", reason: `plugin "${plugin.id}" beforeSend threw` };
503
+ }
504
+ if (result.action === "cancel") return result;
505
+ if (result.payload !== void 0) currentPayload = result.payload;
506
+ if (result.dedupeKey !== void 0) currentDedupeKey = result.dedupeKey;
507
+ }
508
+ return {
509
+ action: "continue",
510
+ payload: currentPayload,
511
+ ...currentDedupeKey === null ? {} : { dedupeKey: currentDedupeKey }
512
+ };
513
+ }
514
+ async function runResolveChannels(plugins, base, initial, prepared, logger) {
515
+ let decisions = initial;
516
+ for (const plugin of plugins) {
517
+ const hook = plugin.hooks?.resolveChannels;
518
+ if (!hook) continue;
519
+ try {
520
+ decisions = [...await hook({ ...base, decisions, prepared: prepared.get(plugin.id) })];
521
+ } catch (error) {
522
+ logger.error(`plugin "${plugin.id}" resolveChannels threw; keeping prior decisions`, {
523
+ type: base.type,
524
+ userId: base.recipient.userId,
525
+ error
526
+ });
527
+ return decisions;
528
+ }
529
+ }
530
+ return decisions;
531
+ }
532
+ async function runSend(type, input, deps) {
533
+ const definition = deps.definitions[type];
534
+ if (!definition) {
535
+ throw new ValidationError(type, [{ message: `unknown notification type "${type}"` }]);
536
+ }
537
+ const payload = definition.schema ? await validatePayload(type, definition.schema, input.payload) : input.payload;
538
+ const userIds = [...new Set(Array.isArray(input.to) ? input.to : [input.to])];
539
+ const recipients = await deps.getRecipients(userIds);
540
+ const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
541
+ const newId = deps.generateId ?? (() => crypto.randomUUID());
542
+ const skipped = [];
543
+ const rows = [];
544
+ const actorId = input.actorId ?? null;
545
+ const declared = input.overrides?.channels ?? definition.channels;
546
+ const requested = declared.filter(
547
+ (channel) => isChannelUsable(channel, deps.channels, deps.plugins)
548
+ );
549
+ if (declared.length > 0 && requested.length === 0) {
550
+ return {
551
+ notifications: [],
552
+ skipped: userIds.map((userId) => ({ userId, reason: "channel-unavailable" }))
553
+ };
554
+ }
555
+ const prepared = await runPrepare(deps.plugins, { type, recipients }, deps.logger);
556
+ for (const userId of userIds) {
557
+ const recipient = byUserId.get(userId);
558
+ if (!recipient) {
559
+ skipped.push({ userId, reason: "no-recipient" });
560
+ continue;
561
+ }
562
+ const before = await runBeforeSend(
563
+ deps.plugins,
564
+ { type, recipient, actorId },
565
+ payload,
566
+ input.dedupeKey ?? null,
567
+ prepared,
568
+ deps.logger
569
+ );
570
+ if (before.action === "cancel") {
571
+ skipped.push({ userId, reason: "no-channels" });
572
+ continue;
573
+ }
574
+ const resolved = await runResolveChannels(
575
+ deps.plugins,
576
+ { type, recipient, payload: before.payload ?? payload },
577
+ requested.map((channel) => ({ channel, action: "send" })),
578
+ prepared,
579
+ deps.logger
580
+ );
581
+ const now = /* @__PURE__ */ new Date();
582
+ const deliveries = resolved.filter((decision) => decision.action !== "skip").filter((decision) => isChannelUsable(decision.channel, deps.channels, deps.plugins)).map((decision) => ({
583
+ id: newId(),
584
+ channel: decision.channel,
585
+ maxAttempts: definition.maxAttempts ?? deps.maxAttempts,
586
+ // `defer` needs no machinery beyond not_before — the claim query
587
+ // already refuses rows whose time has not come. RFC 0004 §4.2.
588
+ notBefore: decision.action === "defer" && decision.notBefore ? decision.notBefore : now
589
+ }));
590
+ if (deliveries.length === 0) {
591
+ skipped.push({ userId, reason: "no-channels" });
592
+ continue;
593
+ }
594
+ const dedupeKey = before.action === "continue" ? before.dedupeKey : void 0;
595
+ rows.push({
596
+ id: newId(),
597
+ userId,
598
+ type,
599
+ payload: before.payload ?? payload,
600
+ ...actorId ? { actorId } : {},
601
+ ...dedupeKey ? { dedupeKey } : {},
602
+ deliveries
603
+ });
604
+ }
605
+ if (rows.length === 0) return { notifications: [], skipped };
606
+ const { created, deduped } = await deps.adapter.createNotifications(rows);
607
+ const createdSet = new Set(created);
608
+ for (const row of rows) {
609
+ if (deduped.includes(row.id)) skipped.push({ userId: row.userId, reason: "deduped" });
610
+ }
611
+ const notifications = rows.filter((row) => createdSet.has(row.id)).map((row) => ({
612
+ id: row.id,
613
+ userId: row.userId,
614
+ deliveries: row.deliveries.map((delivery) => ({
615
+ id: delivery.id,
616
+ channel: delivery.channel
617
+ }))
618
+ }));
619
+ for (const plugin of deps.plugins) {
620
+ const hook = plugin.hooks?.afterSend;
621
+ if (!hook) continue;
622
+ try {
623
+ await hook({
624
+ type,
625
+ payload,
626
+ notifications: notifications.map(({ id, userId }) => ({ id, userId })),
627
+ prepared: prepared.get(plugin.id)
628
+ });
629
+ } catch (error) {
630
+ deps.logger.error(`plugin "${plugin.id}" afterSend threw; ignoring`, { type, error });
631
+ }
632
+ }
633
+ return { notifications, skipped };
634
+ }
635
+
636
+ // src/schema/declaration.ts
637
+ var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
638
+
639
+ // src/core/store.ts
640
+ function createPluginStore(pluginId, schema, storage, prefix) {
641
+ const byTableName = /* @__PURE__ */ new Map();
642
+ for (const declaration of Object.values(schema ?? {})) {
643
+ byTableName.set(declaration.tableName, declaration);
644
+ }
645
+ function resolve(table) {
646
+ const declaration = byTableName.get(table);
647
+ if (!declaration) {
648
+ throw new ConfigError(
649
+ `plugin "${pluginId}" accessed table "${table}", which it does not declare in schema().`
650
+ );
651
+ }
652
+ return declaration;
653
+ }
654
+ function checkFields(table, fields, context) {
655
+ for (const field of fields) {
656
+ const spec = table.fields[field];
657
+ if (!spec) {
658
+ throw new ConfigError(
659
+ `plugin "${pluginId}" used unknown field "${field}" on "${table.tableName}" (${context}).`
660
+ );
661
+ }
662
+ if (context === "where" && spec.type === "json") {
663
+ throw new ConfigError(
664
+ `plugin "${pluginId}" cannot filter on json field "${field}"; a bare object value is indistinguishable from an operator.`
665
+ );
666
+ }
667
+ }
668
+ }
669
+ const qualified = (table) => prefix + table.tableName;
670
+ const column = (field) => storage.naming === "preserve" ? field : toSnakeCase(field);
671
+ const serialize = (table, row) => {
672
+ if (storage.serializesJson === false) return row;
673
+ const out = {};
674
+ for (const [field, value] of Object.entries(row)) {
675
+ out[field] = table.fields[field]?.type === "json" && value !== null && value !== void 0 ? JSON.stringify(value) : value;
676
+ }
677
+ return out;
678
+ };
679
+ return {
680
+ async find(table, where = {}, options = {}) {
681
+ const declaration = resolve(table);
682
+ checkFields(declaration, Object.keys(where), "where");
683
+ if (options.orderBy) checkFields(declaration, [options.orderBy.field], "orderBy");
684
+ const rows = await storage.queryTable(qualified(declaration), where, options);
685
+ return rows.map((row) => {
686
+ const mapped = {};
687
+ for (const field of Object.keys(declaration.fields)) {
688
+ const value = row[column(field)];
689
+ mapped[field] = declaration.fields[field]?.type === "json" && typeof value === "string" ? JSON.parse(value) : value;
690
+ }
691
+ return mapped;
692
+ });
693
+ },
694
+ async insert(table, rows) {
695
+ const declaration = resolve(table);
696
+ for (const row of rows) checkFields(declaration, Object.keys(row), "insert");
697
+ return storage.insertRows(
698
+ qualified(declaration),
699
+ rows.map((row) => serialize(declaration, row))
700
+ );
701
+ },
702
+ async upsert(table, rows, options) {
703
+ const declaration = resolve(table);
704
+ for (const row of rows) checkFields(declaration, Object.keys(row), "upsert");
705
+ checkFields(declaration, options.onConflict, "onConflict");
706
+ return storage.insertRows(
707
+ qualified(declaration),
708
+ rows.map((row) => serialize(declaration, row)),
709
+ options.onConflict
710
+ );
711
+ },
712
+ async update(table, where, set) {
713
+ const declaration = resolve(table);
714
+ checkFields(declaration, Object.keys(where), "where");
715
+ checkFields(declaration, Object.keys(set), "update");
716
+ return storage.updateRows(qualified(declaration), where, serialize(declaration, set));
717
+ },
718
+ async remove(table, where) {
719
+ const declaration = resolve(table);
720
+ checkFields(declaration, Object.keys(where), "where");
721
+ return storage.deleteRows(qualified(declaration), where);
722
+ }
723
+ };
724
+ }
725
+
726
+ // src/core/instance.ts
727
+ var DEFAULTS = {
728
+ maxAttempts: 5,
729
+ leaseMs: 6e4,
730
+ batchSize: 20,
731
+ basePath: "/api/notifications",
732
+ mode: "cron"
733
+ };
734
+ function validate(config, plugins) {
735
+ if (!config.database) throw new ConfigError("`database` is required.");
736
+ if (!config.notifications) throw new ConfigError("`notifications` is required.");
737
+ if (typeof config.session?.getUserId !== "function") {
738
+ throw new ConfigError(
739
+ "`session.getUserId` is required \u2014 the mounted endpoints serve a user's private inbox and must resolve the caller. See RFC 0002."
740
+ );
741
+ }
742
+ if (!config.secret) {
743
+ throw new ConfigError(
744
+ "`secret` is required \u2014 it signs unsubscribe and other session-less links. Adding it later is a breaking change, so it is mandatory from the start."
745
+ );
746
+ }
747
+ if (typeof config.getRecipients !== "function") {
748
+ throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
749
+ }
750
+ const seen = /* @__PURE__ */ new Set();
751
+ for (const plugin of plugins) {
752
+ if (seen.has(plugin.id)) {
753
+ throw new ConfigError(`duplicate plugin id "${plugin.id}" \u2014 ids must be unique.`);
754
+ }
755
+ seen.add(plugin.id);
756
+ }
757
+ for (const plugin of plugins) {
758
+ for (const dependency of plugin.dependsOn ?? []) {
759
+ if (!seen.has(dependency)) {
760
+ throw new ConfigError(
761
+ `plugin "${plugin.id}" requires "${dependency}", which is not registered.`
762
+ );
763
+ }
764
+ }
765
+ }
766
+ const routes = /* @__PURE__ */ new Set();
767
+ for (const plugin of plugins) {
768
+ for (const route of plugin.routes ?? []) {
769
+ const signature = `${route.method} ${route.path}`;
770
+ if (routes.has(signature)) {
771
+ throw new ConfigError(`route collision: two plugins both registered "${signature}".`);
772
+ }
773
+ routes.add(signature);
774
+ }
775
+ }
776
+ const mode = config.delivery?.mode ?? DEFAULTS.mode;
777
+ if ((mode === "cron" || mode === "deferred") && !config.cron?.secret) {
778
+ throw new ConfigError(
779
+ `delivery.mode "${mode}" relies on the cron sweep, so \`cron.secret\` is required. Left unset the endpoint would be unauthenticated \u2014 a free flush-everything trigger against your email provider. See RFC 0002 \xA74.`
780
+ );
781
+ }
782
+ }
783
+ function easyPing(config) {
784
+ const plugins = config.plugins ?? [];
785
+ validate(config, plugins);
786
+ const logger = config.logger ?? consoleLogger;
787
+ const tablePrefix = config.tablePrefix ?? "";
788
+ const mode = config.delivery?.mode ?? DEFAULTS.mode;
789
+ const leaseMs = config.delivery?.leaseMs ?? DEFAULTS.leaseMs;
790
+ const warnings = [];
791
+ const providerTimeout = config.channels.email?.provider?.timeoutMs;
792
+ if (providerTimeout !== void 0 && providerTimeout >= leaseMs / 2) {
793
+ warnings.push(
794
+ `email provider timeout (${providerTimeout}ms) is at least half of delivery.leaseMs (${leaseMs}ms); raise leaseMs or duplicate sends become systematic.`
795
+ );
796
+ }
797
+ const unusable = /* @__PURE__ */ new Map();
798
+ for (const [type, definition] of Object.entries(config.notifications)) {
799
+ const missing = definition.channels.filter(
800
+ (channel) => !isChannelUsable(channel, config.channels, plugins)
801
+ );
802
+ if (missing.length > 0) unusable.set(type, [...missing]);
803
+ }
804
+ for (const [type, channels] of unusable) {
805
+ const dead = channels.length === config.notifications[type]?.channels.length;
806
+ warnings.push(
807
+ `notification "${type}" declares ${channels.map((c) => `"${c}"`).join(", ")} but no provider is configured for ${channels.length > 1 ? "them" : "it"}` + (dead ? " \u2014 this type can never be delivered." : "; those channels will be skipped.")
808
+ );
809
+ }
810
+ if (mode === "deferred" && !config.delivery?.waitUntil) {
811
+ warnings.push(
812
+ "delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
813
+ );
814
+ }
815
+ for (const warning of warnings) logger.warn(warning);
816
+ const runner = createRunner({
817
+ adapter: config.database,
818
+ definitions: config.notifications,
819
+ channels: config.channels,
820
+ plugins,
821
+ getRecipients: config.getRecipients,
822
+ logger,
823
+ leaseMs,
824
+ batchSize: config.delivery?.batchSize ?? DEFAULTS.batchSize,
825
+ backoff: config.delivery?.backoff ?? "exponential"
826
+ });
827
+ const pluginRoutes = plugins.flatMap((plugin) => plugin.routes ?? []);
828
+ const handler = createHandler({
829
+ adapter: config.database,
830
+ session: config.session,
831
+ runner,
832
+ logger,
833
+ secret: config.secret,
834
+ basePath: config.basePath ?? DEFAULTS.basePath,
835
+ pluginRoutes,
836
+ ...config.cron?.secret ? { cronSecret: config.cron.secret } : {}
837
+ });
838
+ async function send(type, args) {
839
+ const result = await runSend(
840
+ type,
841
+ {
842
+ to: args.to,
843
+ payload: args.payload,
844
+ actorId: args.actorId,
845
+ dedupeKey: args.dedupeKey,
846
+ overrides: args.overrides
847
+ },
848
+ {
849
+ adapter: config.database,
850
+ definitions: config.notifications,
851
+ channels: config.channels,
852
+ plugins,
853
+ getRecipients: config.getRecipients,
854
+ maxAttempts: config.delivery?.maxAttempts ?? DEFAULTS.maxAttempts,
855
+ logger
856
+ }
857
+ );
858
+ if (result.notifications.length > 0) {
859
+ const ids = result.notifications.flatMap(
860
+ (notification) => notification.deliveries.map((delivery) => delivery.id)
861
+ );
862
+ if (mode === "inline") await runner.drain({ ids });
863
+ else if (mode === "deferred") runner.scheduleDeferred(config.delivery?.waitUntil, ids);
864
+ }
865
+ return result;
866
+ }
867
+ for (const plugin of plugins) {
868
+ plugin.init?.({
869
+ store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
870
+ secret: config.secret,
871
+ logger,
872
+ getRecipients: config.getRecipients,
873
+ send: (type, args) => send(type, args)
874
+ });
875
+ }
876
+ return {
877
+ send,
878
+ handler,
879
+ startWorker: (options) => runner.startWorker(options ?? {}),
880
+ getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
881
+ since: options.since ?? new Date(Date.now() - 24 * 36e5),
882
+ limit: Math.min(options.limit ?? 100, 1e3)
883
+ }),
884
+ healthCheck: async () => {
885
+ const cronMounted = Boolean(config.cron?.secret);
886
+ return {
887
+ mode,
888
+ cronMounted,
889
+ cronRequiredButMissing: !cronMounted && (mode === "cron" || mode === "deferred" || mode === "inline"),
890
+ warnings
891
+ };
892
+ }
893
+ };
894
+ }
895
+
896
+ // src/core/plugin.ts
897
+ function definePlugin(plugin) {
898
+ return plugin;
899
+ }
900
+ function defineClientPlugin(plugin) {
901
+ return plugin;
902
+ }
903
+
904
+ exports.ConfigError = ConfigError;
905
+ exports.DEFAULT_TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
906
+ exports.EasyPingError = EasyPingError;
907
+ exports.ValidationError = ValidationError;
908
+ exports.consoleLogger = consoleLogger;
909
+ exports.createRunner = createRunner;
910
+ exports.decodeBase64Url = decodeBase64Url;
911
+ exports.defineClientPlugin = defineClientPlugin;
912
+ exports.defineNotification = defineNotification;
913
+ exports.definePlugin = definePlugin;
914
+ exports.easyPing = easyPing;
915
+ exports.encodeBase64Url = encodeBase64Url;
916
+ exports.expiresIn = expiresIn;
917
+ exports.signToken = signToken;
918
+ exports.verifyToken = verifyToken;
919
+ //# sourceMappingURL=index.cjs.map
920
+ //# sourceMappingURL=index.cjs.map