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.js ADDED
@@ -0,0 +1,723 @@
1
+ import { verifyToken } from './chunk-W3BMEPPQ.js';
2
+ export { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken, verifyToken } from './chunk-W3BMEPPQ.js';
3
+ export { defineClientPlugin, definePlugin } from './chunk-BV3U542P.js';
4
+ import { consoleLogger, createPluginStore, ConfigError, ValidationError } from './chunk-OZSOIX23.js';
5
+ export { ConfigError, EasyPingError, ValidationError, consoleLogger } from './chunk-OZSOIX23.js';
6
+ export { decodeBase64Url, encodeBase64Url } from './chunk-PTGHMCCG.js';
7
+ import './chunk-NYDVNMKG.js';
8
+
9
+ // src/core/definition.ts
10
+ function defineNotification(definition) {
11
+ return definition;
12
+ }
13
+
14
+ // src/core/handler.ts
15
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
16
+ status,
17
+ headers: { "content-type": "application/json" }
18
+ });
19
+ var empty = (status) => new Response(null, { status });
20
+ var MAX_READ_IDS = 200;
21
+ function timingSafeEqual(a, b) {
22
+ const encoder = new TextEncoder();
23
+ const left = encoder.encode(a);
24
+ const right = encoder.encode(b);
25
+ let diff = left.length ^ right.length;
26
+ const length = Math.max(left.length, right.length);
27
+ for (let i = 0; i < length; i += 1) {
28
+ diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
29
+ }
30
+ return diff === 0;
31
+ }
32
+ function routePath(url, basePath) {
33
+ const index = url.pathname.indexOf(basePath);
34
+ if (index < 0) return url.pathname;
35
+ return url.pathname.slice(index + basePath.length) || "/";
36
+ }
37
+ async function readJson(request) {
38
+ try {
39
+ const body = await request.json();
40
+ return body && typeof body === "object" ? body : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+ function createHandler(deps) {
46
+ async function resolveUserId(request) {
47
+ try {
48
+ return await deps.session.getUserId(request);
49
+ } catch (error) {
50
+ deps.logger.error("session.getUserId threw", { error });
51
+ return "error";
52
+ }
53
+ }
54
+ async function handleCron(request) {
55
+ if (!deps.cronSecret) return empty(404);
56
+ const header = request.headers.get("authorization") ?? "";
57
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
58
+ if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
59
+ const result = await deps.runner.drain();
60
+ return json(result);
61
+ }
62
+ async function handlePluginRoute(route, request, url) {
63
+ if (route.scope.type === "user") {
64
+ const userId = await resolveUserId(request);
65
+ if (userId === "error") return empty(500);
66
+ if (userId === null) return empty(401);
67
+ return route.handler({ request, userId, claims: null, params: {} });
68
+ }
69
+ if (route.scope.type === "machine") {
70
+ if (!deps.cronSecret) return empty(404);
71
+ const header = request.headers.get("authorization") ?? "";
72
+ const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
73
+ if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
74
+ return route.handler({ request, userId: null, claims: null, params: {} });
75
+ }
76
+ if (route.scope.type === "signed") {
77
+ const token = url.searchParams.get("token") ?? (request.headers.get("authorization") ?? "").replace(/^Bearer /, "");
78
+ const claims = await verifyToken(deps.secret, token, route.scope.purpose);
79
+ if (!claims) return empty(400);
80
+ return route.handler({ request, userId: claims.uid, claims, params: {} });
81
+ }
82
+ return route.handler({ request, userId: null, claims: null, params: {} });
83
+ }
84
+ async function handle(request) {
85
+ const url = new URL(request.url);
86
+ const path = routePath(url, deps.basePath);
87
+ if (path === "/cron") {
88
+ if (request.method !== "POST") return empty(405);
89
+ return handleCron(request);
90
+ }
91
+ const pluginRoute = deps.pluginRoutes?.find(
92
+ (route) => route.path === path && route.method === request.method
93
+ );
94
+ if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);
95
+ const userId = await resolveUserId(request);
96
+ if (userId === "error") return empty(500);
97
+ if (userId === null) return empty(401);
98
+ if (request.method === "GET" && path === "/") {
99
+ const limitParam = Number(url.searchParams.get("limit") ?? 20);
100
+ const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
101
+ const cursor = url.searchParams.get("cursor");
102
+ const page = await deps.adapter.listNotifications({
103
+ userId,
104
+ limit,
105
+ ...cursor ? { cursor } : {},
106
+ ...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
107
+ });
108
+ return json(page);
109
+ }
110
+ if (request.method === "GET" && path === "/count") {
111
+ return json({ unseen: await deps.adapter.countUnseen(userId) });
112
+ }
113
+ if (request.method === "POST" && path === "/seen") {
114
+ const body = await readJson(request);
115
+ const before = typeof body.before === "string" ? new Date(body.before) : null;
116
+ const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
117
+ await deps.adapter.markSeen(userId, cutoff);
118
+ return json({ ok: true });
119
+ }
120
+ if (request.method === "POST" && path === "/read") {
121
+ const body = await readJson(request);
122
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : [];
123
+ if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
124
+ if (ids.length > MAX_READ_IDS) {
125
+ return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
126
+ }
127
+ const updated = await deps.adapter.markRead(userId, ids);
128
+ return updated === 0 ? empty(404) : json({ updated });
129
+ }
130
+ if (request.method === "POST" && path === "/read-all") {
131
+ return json({ updated: await deps.adapter.markAllRead(userId) });
132
+ }
133
+ return empty(404);
134
+ }
135
+ return {
136
+ handle,
137
+ GET: handle,
138
+ POST: handle
139
+ };
140
+ }
141
+
142
+ // src/core/backoff.ts
143
+ var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
144
+ function backoffMs(attempt, strategy = "exponential") {
145
+ if (typeof strategy === "function") return Math.max(0, strategy(attempt));
146
+ const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;
147
+ const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 3e4;
148
+ return Math.round(base * (0.8 + Math.random() * 0.4));
149
+ }
150
+ var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
151
+
152
+ // src/core/runner.ts
153
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
154
+ async function withTimeout(run, ms, label) {
155
+ const controller = new AbortController();
156
+ let timer;
157
+ const timeout = new Promise((_, reject) => {
158
+ timer = setTimeout(() => {
159
+ controller.abort();
160
+ reject(new Error(`${label} timed out after ${ms}ms`));
161
+ }, ms);
162
+ });
163
+ try {
164
+ return await Promise.race([run(controller.signal), timeout]);
165
+ } finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+ var message = (error) => error instanceof Error ? error.message : String(error);
170
+ function createRunner(deps) {
171
+ async function deliverOne(delivery, recipient) {
172
+ if (!recipient) {
173
+ return { result: "failed", error: "recipient could not be resolved", retryable: false };
174
+ }
175
+ if (delivery.channel === "inApp") {
176
+ return { result: "sent" };
177
+ }
178
+ if (delivery.channel === "email") {
179
+ const provider = deps.channels.email?.provider;
180
+ if (!provider) {
181
+ return { result: "failed", error: "no email provider configured", retryable: false };
182
+ }
183
+ const to = recipient.email;
184
+ if (!to) {
185
+ return { result: "failed", error: "recipient has no email address", retryable: false };
186
+ }
187
+ const email = deps.definitions[delivery.notification.type]?.email;
188
+ if (!email) {
189
+ return {
190
+ result: "failed",
191
+ error: `notification type "${delivery.notification.type}" has no email template`,
192
+ retryable: false
193
+ };
194
+ }
195
+ try {
196
+ const payload = delivery.notification.payload;
197
+ const html = await email.template(payload);
198
+ await withTimeout(
199
+ (signal) => provider.send({
200
+ to,
201
+ subject: email.subject(payload),
202
+ html,
203
+ // Delivery is at-least-once; the provider dedupes on this.
204
+ idempotencyKey: delivery.id,
205
+ signal
206
+ }),
207
+ provider.timeoutMs ?? 3e4,
208
+ `${provider.name} send`
209
+ );
210
+ return { result: "sent" };
211
+ } catch (error) {
212
+ return {
213
+ result: "failed",
214
+ error: message(error),
215
+ retryable: provider.isRetryable(error)
216
+ };
217
+ }
218
+ }
219
+ const owner = deps.plugins.find(
220
+ (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver
221
+ );
222
+ if (!owner?.hooks?.deliver) {
223
+ return {
224
+ result: "failed",
225
+ error: `no plugin delivers channel "${delivery.channel}"`,
226
+ retryable: false
227
+ };
228
+ }
229
+ try {
230
+ return await withTimeout(
231
+ (signal) => Promise.resolve(
232
+ // biome-ignore lint/style/noNonNullAssertion: guarded directly above
233
+ owner.hooks.deliver({
234
+ channel: delivery.channel,
235
+ deliveryId: delivery.id,
236
+ attempt: delivery.attempts + 1,
237
+ recipient,
238
+ notification: {
239
+ id: delivery.notificationId,
240
+ userId: delivery.notification.userId,
241
+ type: delivery.notification.type,
242
+ payload: delivery.notification.payload
243
+ },
244
+ signal
245
+ })
246
+ ),
247
+ 3e4,
248
+ `plugin "${owner.id}" deliver`
249
+ );
250
+ } catch (error) {
251
+ return { result: "failed", error: message(error), retryable: true };
252
+ }
253
+ }
254
+ async function notifyAfterDeliver(delivery, outcome) {
255
+ const isTerminal = outcome.result === "sent" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
256
+ for (const plugin of deps.plugins) {
257
+ const hook = plugin.hooks?.afterDeliver;
258
+ if (!hook) continue;
259
+ try {
260
+ await hook({
261
+ deliveryId: delivery.id,
262
+ notificationId: delivery.notificationId,
263
+ userId: delivery.notification.userId,
264
+ channel: delivery.channel,
265
+ outcome,
266
+ attempt: delivery.attempts + 1,
267
+ isTerminal
268
+ });
269
+ } catch (error) {
270
+ deps.logger.error(`plugin "${plugin.id}" afterDeliver threw; ignoring`, {
271
+ deliveryId: delivery.id,
272
+ error
273
+ });
274
+ }
275
+ }
276
+ }
277
+ async function runOnce(options = {}) {
278
+ const claimToken = crypto.randomUUID();
279
+ const claimed = await deps.adapter.claimPendingDeliveries({
280
+ limit: options.limit ?? deps.batchSize,
281
+ leaseMs: deps.leaseMs,
282
+ claimToken,
283
+ ...options.channels ? { channels: options.channels } : {},
284
+ ...options.ids ? { ids: options.ids } : {}
285
+ });
286
+ if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0 };
287
+ const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
288
+ const recipients = await deps.getRecipients(userIds);
289
+ const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
290
+ const releases = [];
291
+ let sent = 0;
292
+ let failed = 0;
293
+ for (const delivery of claimed) {
294
+ const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
295
+ if (outcome.result === "sent") sent += 1;
296
+ else failed += 1;
297
+ releases.push({
298
+ id: delivery.id,
299
+ claimToken,
300
+ outcome,
301
+ ...outcome.result === "failed" && outcome.retryable ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) } : {}
302
+ });
303
+ await notifyAfterDeliver(delivery, outcome);
304
+ }
305
+ await deps.adapter.releaseDeliveries(releases);
306
+ return { claimed: claimed.length, sent, failed };
307
+ }
308
+ async function drain(options = {}) {
309
+ const maxSweeps = options.maxSweeps ?? 50;
310
+ const total = { claimed: 0, sent: 0, failed: 0 };
311
+ for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
312
+ const result = await runOnce(options.ids ? { ids: options.ids } : {});
313
+ total.claimed += result.claimed;
314
+ total.sent += result.sent;
315
+ total.failed += result.failed;
316
+ if (result.claimed === 0) break;
317
+ }
318
+ return total;
319
+ }
320
+ let warnedMissingWaitUntil = false;
321
+ function scheduleDeferred(waitUntil, ids) {
322
+ if (waitUntil) {
323
+ waitUntil(
324
+ drain({ maxSweeps: 10, ...ids ? { ids } : {} }).catch(
325
+ (error) => deps.logger.error("deferred sweep failed", { error })
326
+ )
327
+ );
328
+ return;
329
+ }
330
+ if (!warnedMissingWaitUntil) {
331
+ warnedMissingWaitUntil = true;
332
+ deps.logger.warn(
333
+ "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."
334
+ );
335
+ }
336
+ }
337
+ function startWorker(options = {}) {
338
+ const intervalMs = options.intervalMs ?? 1e3;
339
+ let stopped = false;
340
+ const loop = (async () => {
341
+ while (!stopped) {
342
+ try {
343
+ const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
344
+ if (result.claimed === 0) await sleep(intervalMs);
345
+ } catch (error) {
346
+ deps.logger.error("worker sweep failed", { error });
347
+ await sleep(intervalMs);
348
+ }
349
+ }
350
+ })();
351
+ return {
352
+ stop: async () => {
353
+ stopped = true;
354
+ await loop;
355
+ }
356
+ };
357
+ }
358
+ return { runOnce, drain, scheduleDeferred, startWorker };
359
+ }
360
+
361
+ // src/core/send.ts
362
+ async function validatePayload(type, schema, payload) {
363
+ const result = await schema["~standard"].validate(payload);
364
+ if (result.issues) {
365
+ throw new ValidationError(
366
+ type,
367
+ result.issues.map((issue) => ({
368
+ message: issue.message,
369
+ ...issue.path ? { path: issue.path.map(String).join(".") } : {}
370
+ }))
371
+ );
372
+ }
373
+ return result.value;
374
+ }
375
+ function isChannelUsable(channel, channels, plugins = []) {
376
+ if (channel === "inApp") return channels.inApp?.enabled !== false;
377
+ if (channel === "email") return Boolean(channels.email?.provider);
378
+ return plugins.some((plugin) => plugin.channels?.includes(channel) && plugin.hooks?.deliver);
379
+ }
380
+ async function runPrepare(plugins, ctx, logger) {
381
+ const prepared = /* @__PURE__ */ new Map();
382
+ for (const plugin of plugins) {
383
+ const hook = plugin.hooks?.prepare;
384
+ if (!hook) continue;
385
+ try {
386
+ prepared.set(plugin.id, await hook(ctx));
387
+ } catch (error) {
388
+ logger.error(`plugin "${plugin.id}" prepare threw; its hooks will see undefined`, {
389
+ type: ctx.type,
390
+ error
391
+ });
392
+ prepared.set(plugin.id, void 0);
393
+ }
394
+ }
395
+ return prepared;
396
+ }
397
+ async function runBeforeSend(plugins, base, payload, dedupeKey, prepared, logger) {
398
+ let currentPayload = payload;
399
+ let currentDedupeKey = dedupeKey;
400
+ for (const plugin of plugins) {
401
+ const hook = plugin.hooks?.beforeSend;
402
+ if (!hook) continue;
403
+ let result;
404
+ try {
405
+ result = await hook({
406
+ ...base,
407
+ payload: currentPayload,
408
+ dedupeKey: currentDedupeKey,
409
+ prepared: prepared.get(plugin.id)
410
+ });
411
+ } catch (error) {
412
+ logger.error(`plugin "${plugin.id}" beforeSend threw; cancelling send`, {
413
+ type: base.type,
414
+ userId: base.recipient.userId,
415
+ error
416
+ });
417
+ return { action: "cancel", reason: `plugin "${plugin.id}" beforeSend threw` };
418
+ }
419
+ if (result.action === "cancel") return result;
420
+ if (result.payload !== void 0) currentPayload = result.payload;
421
+ if (result.dedupeKey !== void 0) currentDedupeKey = result.dedupeKey;
422
+ }
423
+ return {
424
+ action: "continue",
425
+ payload: currentPayload,
426
+ ...currentDedupeKey === null ? {} : { dedupeKey: currentDedupeKey }
427
+ };
428
+ }
429
+ async function runResolveChannels(plugins, base, initial, prepared, logger) {
430
+ let decisions = initial;
431
+ for (const plugin of plugins) {
432
+ const hook = plugin.hooks?.resolveChannels;
433
+ if (!hook) continue;
434
+ try {
435
+ decisions = [...await hook({ ...base, decisions, prepared: prepared.get(plugin.id) })];
436
+ } catch (error) {
437
+ logger.error(`plugin "${plugin.id}" resolveChannels threw; keeping prior decisions`, {
438
+ type: base.type,
439
+ userId: base.recipient.userId,
440
+ error
441
+ });
442
+ return decisions;
443
+ }
444
+ }
445
+ return decisions;
446
+ }
447
+ async function runSend(type, input, deps) {
448
+ const definition = deps.definitions[type];
449
+ if (!definition) {
450
+ throw new ValidationError(type, [{ message: `unknown notification type "${type}"` }]);
451
+ }
452
+ const payload = definition.schema ? await validatePayload(type, definition.schema, input.payload) : input.payload;
453
+ const userIds = [...new Set(Array.isArray(input.to) ? input.to : [input.to])];
454
+ const recipients = await deps.getRecipients(userIds);
455
+ const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
456
+ const newId = deps.generateId ?? (() => crypto.randomUUID());
457
+ const skipped = [];
458
+ const rows = [];
459
+ const actorId = input.actorId ?? null;
460
+ const declared = input.overrides?.channels ?? definition.channels;
461
+ const requested = declared.filter(
462
+ (channel) => isChannelUsable(channel, deps.channels, deps.plugins)
463
+ );
464
+ if (declared.length > 0 && requested.length === 0) {
465
+ return {
466
+ notifications: [],
467
+ skipped: userIds.map((userId) => ({ userId, reason: "channel-unavailable" }))
468
+ };
469
+ }
470
+ const prepared = await runPrepare(deps.plugins, { type, recipients }, deps.logger);
471
+ for (const userId of userIds) {
472
+ const recipient = byUserId.get(userId);
473
+ if (!recipient) {
474
+ skipped.push({ userId, reason: "no-recipient" });
475
+ continue;
476
+ }
477
+ const before = await runBeforeSend(
478
+ deps.plugins,
479
+ { type, recipient, actorId },
480
+ payload,
481
+ input.dedupeKey ?? null,
482
+ prepared,
483
+ deps.logger
484
+ );
485
+ if (before.action === "cancel") {
486
+ skipped.push({ userId, reason: "no-channels" });
487
+ continue;
488
+ }
489
+ const resolved = await runResolveChannels(
490
+ deps.plugins,
491
+ { type, recipient, payload: before.payload ?? payload },
492
+ requested.map((channel) => ({ channel, action: "send" })),
493
+ prepared,
494
+ deps.logger
495
+ );
496
+ const now = /* @__PURE__ */ new Date();
497
+ const deliveries = resolved.filter((decision) => decision.action !== "skip").filter((decision) => isChannelUsable(decision.channel, deps.channels, deps.plugins)).map((decision) => ({
498
+ id: newId(),
499
+ channel: decision.channel,
500
+ maxAttempts: definition.maxAttempts ?? deps.maxAttempts,
501
+ // `defer` needs no machinery beyond not_before — the claim query
502
+ // already refuses rows whose time has not come. RFC 0004 §4.2.
503
+ notBefore: decision.action === "defer" && decision.notBefore ? decision.notBefore : now
504
+ }));
505
+ if (deliveries.length === 0) {
506
+ skipped.push({ userId, reason: "no-channels" });
507
+ continue;
508
+ }
509
+ const dedupeKey = before.action === "continue" ? before.dedupeKey : void 0;
510
+ rows.push({
511
+ id: newId(),
512
+ userId,
513
+ type,
514
+ payload: before.payload ?? payload,
515
+ ...actorId ? { actorId } : {},
516
+ ...dedupeKey ? { dedupeKey } : {},
517
+ deliveries
518
+ });
519
+ }
520
+ if (rows.length === 0) return { notifications: [], skipped };
521
+ const { created, deduped } = await deps.adapter.createNotifications(rows);
522
+ const createdSet = new Set(created);
523
+ for (const row of rows) {
524
+ if (deduped.includes(row.id)) skipped.push({ userId: row.userId, reason: "deduped" });
525
+ }
526
+ const notifications = rows.filter((row) => createdSet.has(row.id)).map((row) => ({
527
+ id: row.id,
528
+ userId: row.userId,
529
+ deliveries: row.deliveries.map((delivery) => ({
530
+ id: delivery.id,
531
+ channel: delivery.channel
532
+ }))
533
+ }));
534
+ for (const plugin of deps.plugins) {
535
+ const hook = plugin.hooks?.afterSend;
536
+ if (!hook) continue;
537
+ try {
538
+ await hook({
539
+ type,
540
+ payload,
541
+ notifications: notifications.map(({ id, userId }) => ({ id, userId })),
542
+ prepared: prepared.get(plugin.id)
543
+ });
544
+ } catch (error) {
545
+ deps.logger.error(`plugin "${plugin.id}" afterSend threw; ignoring`, { type, error });
546
+ }
547
+ }
548
+ return { notifications, skipped };
549
+ }
550
+
551
+ // src/core/instance.ts
552
+ var DEFAULTS = {
553
+ maxAttempts: 5,
554
+ leaseMs: 6e4,
555
+ batchSize: 20,
556
+ basePath: "/api/notifications",
557
+ mode: "cron"
558
+ };
559
+ function validate(config, plugins) {
560
+ if (!config.database) throw new ConfigError("`database` is required.");
561
+ if (!config.notifications) throw new ConfigError("`notifications` is required.");
562
+ if (typeof config.session?.getUserId !== "function") {
563
+ throw new ConfigError(
564
+ "`session.getUserId` is required \u2014 the mounted endpoints serve a user's private inbox and must resolve the caller. See RFC 0002."
565
+ );
566
+ }
567
+ if (!config.secret) {
568
+ throw new ConfigError(
569
+ "`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."
570
+ );
571
+ }
572
+ if (typeof config.getRecipients !== "function") {
573
+ throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
574
+ }
575
+ const seen = /* @__PURE__ */ new Set();
576
+ for (const plugin of plugins) {
577
+ if (seen.has(plugin.id)) {
578
+ throw new ConfigError(`duplicate plugin id "${plugin.id}" \u2014 ids must be unique.`);
579
+ }
580
+ seen.add(plugin.id);
581
+ }
582
+ for (const plugin of plugins) {
583
+ for (const dependency of plugin.dependsOn ?? []) {
584
+ if (!seen.has(dependency)) {
585
+ throw new ConfigError(
586
+ `plugin "${plugin.id}" requires "${dependency}", which is not registered.`
587
+ );
588
+ }
589
+ }
590
+ }
591
+ const routes = /* @__PURE__ */ new Set();
592
+ for (const plugin of plugins) {
593
+ for (const route of plugin.routes ?? []) {
594
+ const signature = `${route.method} ${route.path}`;
595
+ if (routes.has(signature)) {
596
+ throw new ConfigError(`route collision: two plugins both registered "${signature}".`);
597
+ }
598
+ routes.add(signature);
599
+ }
600
+ }
601
+ const mode = config.delivery?.mode ?? DEFAULTS.mode;
602
+ if ((mode === "cron" || mode === "deferred") && !config.cron?.secret) {
603
+ throw new ConfigError(
604
+ `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.`
605
+ );
606
+ }
607
+ }
608
+ function easyPing(config) {
609
+ const plugins = config.plugins ?? [];
610
+ validate(config, plugins);
611
+ const logger = config.logger ?? consoleLogger;
612
+ const tablePrefix = config.tablePrefix ?? "";
613
+ const mode = config.delivery?.mode ?? DEFAULTS.mode;
614
+ const leaseMs = config.delivery?.leaseMs ?? DEFAULTS.leaseMs;
615
+ const warnings = [];
616
+ const providerTimeout = config.channels.email?.provider?.timeoutMs;
617
+ if (providerTimeout !== void 0 && providerTimeout >= leaseMs / 2) {
618
+ warnings.push(
619
+ `email provider timeout (${providerTimeout}ms) is at least half of delivery.leaseMs (${leaseMs}ms); raise leaseMs or duplicate sends become systematic.`
620
+ );
621
+ }
622
+ const unusable = /* @__PURE__ */ new Map();
623
+ for (const [type, definition] of Object.entries(config.notifications)) {
624
+ const missing = definition.channels.filter(
625
+ (channel) => !isChannelUsable(channel, config.channels, plugins)
626
+ );
627
+ if (missing.length > 0) unusable.set(type, [...missing]);
628
+ }
629
+ for (const [type, channels] of unusable) {
630
+ const dead = channels.length === config.notifications[type]?.channels.length;
631
+ warnings.push(
632
+ `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.")
633
+ );
634
+ }
635
+ if (mode === "deferred" && !config.delivery?.waitUntil) {
636
+ warnings.push(
637
+ "delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
638
+ );
639
+ }
640
+ for (const warning of warnings) logger.warn(warning);
641
+ const runner = createRunner({
642
+ adapter: config.database,
643
+ definitions: config.notifications,
644
+ channels: config.channels,
645
+ plugins,
646
+ getRecipients: config.getRecipients,
647
+ logger,
648
+ leaseMs,
649
+ batchSize: config.delivery?.batchSize ?? DEFAULTS.batchSize,
650
+ backoff: config.delivery?.backoff ?? "exponential"
651
+ });
652
+ const pluginRoutes = plugins.flatMap((plugin) => plugin.routes ?? []);
653
+ const handler = createHandler({
654
+ adapter: config.database,
655
+ session: config.session,
656
+ runner,
657
+ logger,
658
+ secret: config.secret,
659
+ basePath: config.basePath ?? DEFAULTS.basePath,
660
+ pluginRoutes,
661
+ ...config.cron?.secret ? { cronSecret: config.cron.secret } : {}
662
+ });
663
+ async function send(type, args) {
664
+ const result = await runSend(
665
+ type,
666
+ {
667
+ to: args.to,
668
+ payload: args.payload,
669
+ actorId: args.actorId,
670
+ dedupeKey: args.dedupeKey,
671
+ overrides: args.overrides
672
+ },
673
+ {
674
+ adapter: config.database,
675
+ definitions: config.notifications,
676
+ channels: config.channels,
677
+ plugins,
678
+ getRecipients: config.getRecipients,
679
+ maxAttempts: config.delivery?.maxAttempts ?? DEFAULTS.maxAttempts,
680
+ logger
681
+ }
682
+ );
683
+ if (result.notifications.length > 0) {
684
+ const ids = result.notifications.flatMap(
685
+ (notification) => notification.deliveries.map((delivery) => delivery.id)
686
+ );
687
+ if (mode === "inline") await runner.drain({ ids });
688
+ else if (mode === "deferred") runner.scheduleDeferred(config.delivery?.waitUntil, ids);
689
+ }
690
+ return result;
691
+ }
692
+ for (const plugin of plugins) {
693
+ plugin.init?.({
694
+ store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
695
+ secret: config.secret,
696
+ logger,
697
+ getRecipients: config.getRecipients,
698
+ send: (type, args) => send(type, args)
699
+ });
700
+ }
701
+ return {
702
+ send,
703
+ handler,
704
+ startWorker: (options) => runner.startWorker(options ?? {}),
705
+ getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
706
+ since: options.since ?? new Date(Date.now() - 24 * 36e5),
707
+ limit: Math.min(options.limit ?? 100, 1e3)
708
+ }),
709
+ healthCheck: async () => {
710
+ const cronMounted = Boolean(config.cron?.secret);
711
+ return {
712
+ mode,
713
+ cronMounted,
714
+ cronRequiredButMissing: !cronMounted && (mode === "cron" || mode === "deferred" || mode === "inline"),
715
+ warnings
716
+ };
717
+ }
718
+ };
719
+ }
720
+
721
+ export { createRunner, defineNotification, easyPing };
722
+ //# sourceMappingURL=index.js.map
723
+ //# sourceMappingURL=index.js.map