easy-ping 0.5.0 → 0.6.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 (62) hide show
  1. package/README.md +548 -508
  2. package/dist/adapters/mongodb.cjs +142 -0
  3. package/dist/adapters/mongodb.cjs.map +1 -1
  4. package/dist/adapters/mongodb.d.cts +39 -1
  5. package/dist/adapters/mongodb.d.ts +39 -1
  6. package/dist/adapters/mongodb.js +73 -1
  7. package/dist/adapters/mongodb.js.map +1 -1
  8. package/dist/adapters/postgres.cjs +146 -0
  9. package/dist/adapters/postgres.cjs.map +1 -1
  10. package/dist/adapters/postgres.d.cts +36 -1
  11. package/dist/adapters/postgres.d.ts +36 -1
  12. package/dist/adapters/postgres.js +78 -3
  13. package/dist/adapters/postgres.js.map +1 -1
  14. package/dist/adapters/sqlite.js +1 -1
  15. package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
  16. package/dist/chunk-JKTMUMQK.js.map +1 -0
  17. package/dist/chunk-ORZKH224.js +75 -0
  18. package/dist/chunk-ORZKH224.js.map +1 -0
  19. package/dist/{chunk-BECMTNK7.js → chunk-USQ6CQTJ.js} +88 -3
  20. package/dist/chunk-USQ6CQTJ.js.map +1 -0
  21. package/dist/client.cjs +288 -17
  22. package/dist/client.cjs.map +1 -1
  23. package/dist/client.d.cts +53 -1
  24. package/dist/client.d.ts +53 -1
  25. package/dist/client.js +288 -17
  26. package/dist/client.js.map +1 -1
  27. package/dist/index.cjs +237 -7
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +42 -1
  30. package/dist/index.d.ts +42 -1
  31. package/dist/index.js +55 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/node.cjs +26 -5
  34. package/dist/node.cjs.map +1 -1
  35. package/dist/node.d.cts +1 -1
  36. package/dist/node.d.ts +1 -1
  37. package/dist/node.js +28 -6
  38. package/dist/node.js.map +1 -1
  39. package/dist/plugins/preferences.cjs.map +1 -1
  40. package/dist/plugins/preferences.js +2 -1
  41. package/dist/plugins/preferences.js.map +1 -1
  42. package/dist/plugins/push.cjs.map +1 -1
  43. package/dist/plugins/push.js +3 -2
  44. package/dist/plugins/push.js.map +1 -1
  45. package/dist/react.cjs +311 -19
  46. package/dist/react.cjs.map +1 -1
  47. package/dist/react.d.cts +52 -0
  48. package/dist/react.d.ts +52 -0
  49. package/dist/react.js +311 -19
  50. package/dist/react.js.map +1 -1
  51. package/dist/schema.js +1 -1
  52. package/dist/signals-kPmOlKDX.d.cts +27 -0
  53. package/dist/signals-kPmOlKDX.d.ts +27 -0
  54. package/dist/sw.cjs +39 -0
  55. package/dist/sw.cjs.map +1 -0
  56. package/dist/sw.d.cts +52 -0
  57. package/dist/sw.d.ts +52 -0
  58. package/dist/sw.js +36 -0
  59. package/dist/sw.js.map +1 -0
  60. package/package.json +6 -1
  61. package/dist/chunk-BECMTNK7.js.map +0 -1
  62. package/dist/chunk-GJNLOQJS.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -20,6 +20,9 @@ function decodeBase64Url(value) {
20
20
  return bytes ? new TextDecoder().decode(bytes) : null;
21
21
  }
22
22
 
23
+ // src/core/config.ts
24
+ var INBOX_VERSION_HEADER = "x-easy-ping-inbox";
25
+
23
26
  // src/core/definition.ts
24
27
  function defineNotification(definition) {
25
28
  return definition;
@@ -51,6 +54,78 @@ var consoleLogger = {
51
54
  error: (message2, meta) => console.error(`[easy-ping] ${message2}`, meta ?? "")
52
55
  };
53
56
 
57
+ // src/core/signals.ts
58
+ var DELIVERIES_CHANNEL = "deliveries";
59
+ var inboxChannel = (userId) => `inbox:${userId}`;
60
+ function createMemorySignals() {
61
+ const handlers = /* @__PURE__ */ new Map();
62
+ return {
63
+ crossProcess: false,
64
+ publish(channel) {
65
+ const set = handlers.get(channel);
66
+ if (!set) return;
67
+ for (const handler of [...set]) {
68
+ queueMicrotask(() => {
69
+ try {
70
+ handler();
71
+ } catch {
72
+ }
73
+ });
74
+ }
75
+ },
76
+ subscribe(channel, handler) {
77
+ let set = handlers.get(channel);
78
+ if (!set) {
79
+ set = /* @__PURE__ */ new Set();
80
+ handlers.set(channel, set);
81
+ }
82
+ set.add(handler);
83
+ return () => {
84
+ set?.delete(handler);
85
+ if (set?.size === 0) handlers.delete(channel);
86
+ };
87
+ }
88
+ };
89
+ }
90
+ function createBridgedSignals(transport, onError) {
91
+ const local = createMemorySignals();
92
+ const remoteUnsubscribes = /* @__PURE__ */ new Map();
93
+ const counts = /* @__PURE__ */ new Map();
94
+ return {
95
+ crossProcess: true,
96
+ publish(channel) {
97
+ local.publish(channel);
98
+ Promise.resolve().then(() => transport.publish(channel)).catch(onError);
99
+ },
100
+ subscribe(channel, handler) {
101
+ const unsubscribeLocal = local.subscribe(channel, handler);
102
+ counts.set(channel, (counts.get(channel) ?? 0) + 1);
103
+ if (!remoteUnsubscribes.has(channel)) {
104
+ remoteUnsubscribes.set(
105
+ channel,
106
+ Promise.resolve().then(() => transport.subscribe(channel, () => local.publish(channel))).catch((error) => {
107
+ onError(error);
108
+ return () => {
109
+ };
110
+ })
111
+ );
112
+ }
113
+ return () => {
114
+ unsubscribeLocal();
115
+ const remaining = (counts.get(channel) ?? 1) - 1;
116
+ if (remaining > 0) {
117
+ counts.set(channel, remaining);
118
+ return;
119
+ }
120
+ counts.delete(channel);
121
+ const pending = remoteUnsubscribes.get(channel);
122
+ remoteUnsubscribes.delete(channel);
123
+ pending?.then((unsubscribe) => unsubscribe()).catch(onError);
124
+ };
125
+ }
126
+ };
127
+ }
128
+
54
129
  // src/core/tokens.ts
55
130
  var encoder = new TextEncoder();
56
131
  var KEY_SALT = encoder.encode("easy-ping/tokens/v1");
@@ -304,6 +379,9 @@ function createHandler(deps) {
304
379
  const userId = await resolveUserId(request);
305
380
  if (userId === "error") return empty(500);
306
381
  if (userId === null) return empty(401);
382
+ if (request.method === "GET" && path === "/events") {
383
+ return deps.events ? events(request, userId, deps.events) : empty(404);
384
+ }
307
385
  if (request.method === "GET" && path === "/") {
308
386
  const limitParam = Number(url.searchParams.get("limit") ?? 20);
309
387
  const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
@@ -328,6 +406,7 @@ function createHandler(deps) {
328
406
  const before = typeof parsed.body.before === "string" ? new Date(parsed.body.before) : null;
329
407
  const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
330
408
  await deps.adapter.markSeen(userId, cutoff);
409
+ deps.signals.publish(inboxChannel(userId));
331
410
  return json({ ok: true });
332
411
  }
333
412
  if (request.method === "POST" && path === "/read") {
@@ -339,19 +418,99 @@ function createHandler(deps) {
339
418
  return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
340
419
  }
341
420
  const updated = await deps.adapter.markRead(userId, ids);
421
+ if (updated > 0) deps.signals.publish(inboxChannel(userId));
342
422
  return updated === 0 ? empty(404) : json({ updated });
343
423
  }
344
424
  if (request.method === "POST" && path === "/read-all") {
345
- return json({ updated: await deps.adapter.markAllRead(userId) });
425
+ const updated = await deps.adapter.markAllRead(userId);
426
+ if (updated > 0) deps.signals.publish(inboxChannel(userId));
427
+ return json({ updated });
346
428
  }
347
429
  return empty(404);
348
430
  }
431
+ async function fingerprint(userId) {
432
+ const [page, unseen] = await Promise.all([
433
+ deps.adapter.listNotifications({ userId, limit: 1 }),
434
+ deps.adapter.countUnseen(userId)
435
+ ]);
436
+ const newest = page.notifications[0];
437
+ return `${newest?.id ?? ""}|${newest?.readAt ? 1 : 0}|${unseen}`;
438
+ }
439
+ function events(request, userId, settings) {
440
+ const encoder3 = new TextEncoder();
441
+ const timers = [];
442
+ let unsubscribe;
443
+ let closed = false;
444
+ let cleanup = () => {
445
+ };
446
+ const stream = new ReadableStream({
447
+ start(controller) {
448
+ const write = (text) => {
449
+ if (closed) return;
450
+ try {
451
+ controller.enqueue(encoder3.encode(text));
452
+ } catch {
453
+ cleanup();
454
+ }
455
+ };
456
+ cleanup = () => {
457
+ if (closed) return;
458
+ closed = true;
459
+ unsubscribe?.();
460
+ for (const timer of timers) clearInterval(timer);
461
+ try {
462
+ controller.close();
463
+ } catch {
464
+ }
465
+ };
466
+ const changed = () => write("event: changed\ndata: {}\n\n");
467
+ write("retry: 3000\n\nevent: ready\ndata: {}\n\n");
468
+ unsubscribe = deps.signals.subscribe(inboxChannel(userId), changed);
469
+ timers.push(setInterval(() => write(": keepalive\n\n"), settings.heartbeatMs));
470
+ if (!deps.signals.crossProcess && settings.probeIntervalMs > 0) {
471
+ let last;
472
+ fingerprint(userId).then((value) => {
473
+ last = value;
474
+ }).catch(() => {
475
+ });
476
+ timers.push(
477
+ setInterval(() => {
478
+ fingerprint(userId).then((value) => {
479
+ if (last !== void 0 && value !== last) changed();
480
+ last = value;
481
+ }).catch((error) => deps.logger.error("events probe failed", { error }));
482
+ }, settings.probeIntervalMs)
483
+ );
484
+ }
485
+ if (settings.maxDurationMs > 0) {
486
+ timers.push(
487
+ setTimeout(cleanup, settings.maxDurationMs)
488
+ );
489
+ }
490
+ request.signal?.addEventListener("abort", cleanup, { once: true });
491
+ },
492
+ cancel() {
493
+ cleanup();
494
+ }
495
+ });
496
+ return new Response(stream, {
497
+ headers: {
498
+ "content-type": "text/event-stream; charset=utf-8",
499
+ "cache-control": "no-cache, no-store",
500
+ connection: "keep-alive",
501
+ "x-accel-buffering": "no",
502
+ "x-content-type-options": "nosniff"
503
+ }
504
+ });
505
+ }
349
506
  async function handle(request) {
350
507
  try {
351
508
  return await route(request);
352
509
  } catch (error) {
353
510
  deps.logger.error("request failed", { url: request.url, error });
354
511
  return empty(500);
512
+ } finally {
513
+ deps.sweepOnRequest?.();
355
514
  }
356
515
  }
357
516
  return {
@@ -416,7 +575,7 @@ function backoffMs(attempt, strategy = "exponential") {
416
575
  var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
417
576
 
418
577
  // src/core/runner.ts
419
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
578
+ var DEFAULT_WORKER_IDLE_MS = 1e4;
420
579
  async function withTimeout(run, ms, label) {
421
580
  const controller = new AbortController();
422
581
  let timer;
@@ -605,22 +764,45 @@ function createRunner(deps) {
605
764
  }
606
765
  }
607
766
  function startWorker(options = {}) {
608
- const intervalMs = options.intervalMs ?? 1e3;
767
+ const intervalMs = options.intervalMs ?? DEFAULT_WORKER_IDLE_MS;
609
768
  let stopped = false;
769
+ let wake;
770
+ let pending = false;
771
+ const unsubscribe = deps.signals?.subscribe(DELIVERIES_CHANNEL, () => {
772
+ pending = true;
773
+ wake?.();
774
+ });
775
+ const idle = (ms) => new Promise((resolve) => {
776
+ if (pending) {
777
+ pending = false;
778
+ resolve();
779
+ return;
780
+ }
781
+ const timer = setTimeout(done, ms);
782
+ function done() {
783
+ clearTimeout(timer);
784
+ wake = void 0;
785
+ pending = false;
786
+ resolve();
787
+ }
788
+ wake = done;
789
+ });
610
790
  const loop = (async () => {
611
791
  while (!stopped) {
612
792
  try {
613
793
  const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
614
- if (result.claimed === 0) await sleep(intervalMs);
794
+ if (result.claimed === 0 && !stopped) await idle(intervalMs);
615
795
  } catch (error) {
616
796
  deps.logger.error("worker sweep failed", { error });
617
- await sleep(intervalMs);
797
+ if (!stopped) await idle(intervalMs);
618
798
  }
619
799
  }
620
800
  })();
621
801
  return {
622
802
  stop: async () => {
623
803
  stopped = true;
804
+ unsubscribe?.();
805
+ wake?.();
624
806
  await loop;
625
807
  }
626
808
  };
@@ -958,6 +1140,7 @@ var CORE_ROUTES = [
958
1140
  { method: "POST", path: "/seen", scope: { type: "user" }, owner: "core" },
959
1141
  { method: "POST", path: "/read", scope: { type: "user" }, owner: "core" },
960
1142
  { method: "POST", path: "/read-all", scope: { type: "user" }, owner: "core" },
1143
+ { method: "GET", path: "/events", scope: { type: "user" }, owner: "core" },
961
1144
  { method: "POST", path: "/cron", scope: { type: "machine" }, owner: "core" }
962
1145
  ];
963
1146
  var DEFAULTS = {
@@ -1082,6 +1265,20 @@ function easyPing(config) {
1082
1265
  }
1083
1266
  }
1084
1267
  for (const warning of warnings) logger.warn(warning);
1268
+ const baseSignals = config.signals ?? createMemorySignals();
1269
+ const inboxVersions = /* @__PURE__ */ new Map();
1270
+ const signals = {
1271
+ crossProcess: baseSignals.crossProcess,
1272
+ publish(channel) {
1273
+ if (channel.startsWith("inbox:")) {
1274
+ if (inboxVersions.size > 5e4) inboxVersions.clear();
1275
+ const userId = channel.slice("inbox:".length);
1276
+ inboxVersions.set(userId, (inboxVersions.get(userId) ?? 0) + 1);
1277
+ }
1278
+ baseSignals.publish(channel);
1279
+ },
1280
+ subscribe: (channel, handler2) => baseSignals.subscribe(channel, handler2)
1281
+ };
1085
1282
  const runner = createRunner({
1086
1283
  adapter: config.database,
1087
1284
  definitions: config.notifications,
@@ -1091,15 +1288,34 @@ function easyPing(config) {
1091
1288
  logger,
1092
1289
  leaseMs,
1093
1290
  batchSize: config.delivery?.batchSize ?? DEFAULTS.batchSize,
1094
- backoff: config.delivery?.backoff ?? "exponential"
1291
+ backoff: config.delivery?.backoff ?? "exponential",
1292
+ signals
1095
1293
  });
1096
1294
  const pluginRoutes = plugins.flatMap((plugin) => plugin.routes ?? []);
1295
+ const events = config.events === false ? false : {
1296
+ heartbeatMs: config.events?.heartbeatMs ?? 25e3,
1297
+ probeIntervalMs: config.events?.probeIntervalMs ?? 3e4,
1298
+ maxDurationMs: config.events?.maxDurationMs ?? 0
1299
+ };
1300
+ const sweepConfig = config.delivery?.sweepOnRequest;
1301
+ let lastRequestSweep = 0;
1302
+ const sweepOnRequest = sweepConfig && mode !== "inline" ? () => {
1303
+ const settings = sweepConfig === true ? {} : sweepConfig;
1304
+ const now = Date.now();
1305
+ if (now - lastRequestSweep < (settings.everyMs ?? 5e3)) return;
1306
+ lastRequestSweep = now;
1307
+ const pass = runner.runOnce({ limit: settings.limit ?? 5 }).catch((error) => logger.error("request-driven sweep failed", { error }));
1308
+ config.delivery?.waitUntil?.(pass);
1309
+ } : void 0;
1097
1310
  const handler = createHandler({
1098
1311
  adapter: config.database,
1099
1312
  session: config.session,
1100
1313
  runner,
1101
1314
  logger,
1102
1315
  secret: config.secret,
1316
+ signals,
1317
+ events,
1318
+ sweepOnRequest,
1103
1319
  basePath: config.basePath ?? DEFAULTS.basePath,
1104
1320
  pluginRoutes,
1105
1321
  trustedOrigins: config.trustedOrigins,
@@ -1131,6 +1347,10 @@ function easyPing(config) {
1131
1347
  }
1132
1348
  );
1133
1349
  if (result.notifications.length > 0) {
1350
+ for (const notification of result.notifications) {
1351
+ signals.publish(inboxChannel(notification.userId));
1352
+ }
1353
+ signals.publish(DELIVERIES_CHANNEL);
1134
1354
  const ids = result.notifications.flatMap(
1135
1355
  (notification) => notification.deliveries.map((delivery) => delivery.id)
1136
1356
  );
@@ -1163,8 +1383,13 @@ function easyPing(config) {
1163
1383
  send,
1164
1384
  handler,
1165
1385
  startWorker: (options) => runner.startWorker(options ?? {}),
1386
+ inboxHeaders: (userId) => ({
1387
+ [INBOX_VERSION_HEADER]: String(inboxVersions.get(userId) ?? 0)
1388
+ }),
1166
1389
  listRoutes: () => [
1167
- ...CORE_ROUTES.filter((route) => route.path !== "/cron" || Boolean(config.cron?.secret)),
1390
+ ...CORE_ROUTES.filter(
1391
+ (route) => (route.path !== "/cron" || Boolean(config.cron?.secret)) && (route.path !== "/events" || events !== false)
1392
+ ),
1168
1393
  ...plugins.flatMap(
1169
1394
  (plugin) => (plugin.routes ?? []).map((route) => ({
1170
1395
  method: route.method,
@@ -1200,12 +1425,16 @@ function defineClientPlugin(plugin) {
1200
1425
 
1201
1426
  exports.ConfigError = ConfigError;
1202
1427
  exports.DEFAULT_TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
1428
+ exports.DELIVERIES_CHANNEL = DELIVERIES_CHANNEL;
1203
1429
  exports.EasyPingError = EasyPingError;
1430
+ exports.INBOX_VERSION_HEADER = INBOX_VERSION_HEADER;
1204
1431
  exports.MAX_BODY_BYTES = MAX_BODY_BYTES;
1205
1432
  exports.MAX_TOKEN_TTL_SECONDS = MAX_TOKEN_TTL_SECONDS;
1206
1433
  exports.MIN_SECRET_LENGTH = MIN_SECRET_LENGTH;
1207
1434
  exports.ValidationError = ValidationError;
1208
1435
  exports.consoleLogger = consoleLogger;
1436
+ exports.createBridgedSignals = createBridgedSignals;
1437
+ exports.createMemorySignals = createMemorySignals;
1209
1438
  exports.createRateLimiter = createRateLimiter;
1210
1439
  exports.createRunner = createRunner;
1211
1440
  exports.decodeBase64Url = decodeBase64Url;
@@ -1216,6 +1445,7 @@ exports.easyPing = easyPing;
1216
1445
  exports.encodeBase64Url = encodeBase64Url;
1217
1446
  exports.escapeHtml = escapeHtml;
1218
1447
  exports.expiresIn = expiresIn;
1448
+ exports.inboxChannel = inboxChannel;
1219
1449
  exports.readJsonBody = readJsonBody;
1220
1450
  exports.redactErrorMessage = redactErrorMessage;
1221
1451
  exports.signToken = signToken;