easy-ping 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +548 -508
  2. package/dist/adapters/mongodb.cjs +142 -0
  3. package/dist/adapters/mongodb.cjs.map +1 -1
  4. package/dist/adapters/mongodb.d.cts +39 -1
  5. package/dist/adapters/mongodb.d.ts +39 -1
  6. package/dist/adapters/mongodb.js +73 -1
  7. package/dist/adapters/mongodb.js.map +1 -1
  8. package/dist/adapters/postgres.cjs +146 -0
  9. package/dist/adapters/postgres.cjs.map +1 -1
  10. package/dist/adapters/postgres.d.cts +36 -1
  11. package/dist/adapters/postgres.d.ts +36 -1
  12. package/dist/adapters/postgres.js +78 -3
  13. package/dist/adapters/postgres.js.map +1 -1
  14. package/dist/adapters/sqlite.js +1 -1
  15. package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
  16. package/dist/chunk-JKTMUMQK.js.map +1 -0
  17. package/dist/{chunk-BECMTNK7.js → chunk-JOVIZXO6.js} +142 -6
  18. package/dist/chunk-JOVIZXO6.js.map +1 -0
  19. package/dist/chunk-ORZKH224.js +75 -0
  20. package/dist/chunk-ORZKH224.js.map +1 -0
  21. package/dist/client.cjs +297 -17
  22. package/dist/client.cjs.map +1 -1
  23. package/dist/client.d.cts +59 -1
  24. package/dist/client.d.ts +59 -1
  25. package/dist/client.js +297 -17
  26. package/dist/client.js.map +1 -1
  27. package/dist/index.cjs +293 -10
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +46 -1
  30. package/dist/index.d.ts +46 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/node.cjs +26 -5
  34. package/dist/node.cjs.map +1 -1
  35. package/dist/node.d.cts +1 -1
  36. package/dist/node.d.ts +1 -1
  37. package/dist/node.js +28 -6
  38. package/dist/node.js.map +1 -1
  39. package/dist/plugins/preferences.cjs +2 -2
  40. package/dist/plugins/preferences.cjs.map +1 -1
  41. package/dist/plugins/preferences.js +2 -1
  42. package/dist/plugins/preferences.js.map +1 -1
  43. package/dist/plugins/push.cjs +2 -2
  44. package/dist/plugins/push.cjs.map +1 -1
  45. package/dist/plugins/push.js +3 -2
  46. package/dist/plugins/push.js.map +1 -1
  47. package/dist/react.cjs +320 -19
  48. package/dist/react.cjs.map +1 -1
  49. package/dist/react.d.cts +58 -0
  50. package/dist/react.d.ts +58 -0
  51. package/dist/react.js +320 -19
  52. package/dist/react.js.map +1 -1
  53. package/dist/schema.js +1 -1
  54. package/dist/signals-kPmOlKDX.d.cts +27 -0
  55. package/dist/signals-kPmOlKDX.d.ts +27 -0
  56. package/dist/sw.cjs +39 -0
  57. package/dist/sw.cjs.map +1 -0
  58. package/dist/sw.d.cts +52 -0
  59. package/dist/sw.d.ts +52 -0
  60. package/dist/sw.js +36 -0
  61. package/dist/sw.js.map +1 -0
  62. package/package.json +9 -3
  63. package/dist/chunk-BECMTNK7.js.map +0 -1
  64. package/dist/chunk-GJNLOQJS.js.map +0 -1
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The wake-up seam. A publish carries no payload: it says "something changed
3
+ * on this channel, go look", so losing one costs latency, never data.
4
+ * RFC 0006 §4C.
5
+ */
6
+ type Signals = {
7
+ /** True when a publish in one process reaches subscribers in another. */
8
+ readonly crossProcess: boolean;
9
+ publish(channel: string): void;
10
+ subscribe(channel: string, handler: () => void): () => void;
11
+ close?(): Promise<void> | void;
12
+ };
13
+ declare const DELIVERIES_CHANNEL = "deliveries";
14
+ declare const inboxChannel: (userId: string) => string;
15
+ /** In-process only. The default, and exactly right on SQLite or a single replica. */
16
+ declare function createMemorySignals(): Signals;
17
+ /**
18
+ * Wraps a cross-process transport (LISTEN/NOTIFY, a change stream) so local
19
+ * subscribers still hear local publishes even if the transport drops them,
20
+ * and a transport failure degrades to in-process rather than to silence.
21
+ */
22
+ declare function createBridgedSignals(transport: {
23
+ publish(channel: string): Promise<void> | void;
24
+ subscribe(channel: string, handler: () => void): Promise<() => void> | (() => void);
25
+ }, onError: (error: unknown) => void): Signals;
26
+
27
+ export { DELIVERIES_CHANNEL as D, type Signals as S, createMemorySignals as a, createBridgedSignals as c, inboxChannel as i };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The wake-up seam. A publish carries no payload: it says "something changed
3
+ * on this channel, go look", so losing one costs latency, never data.
4
+ * RFC 0006 §4C.
5
+ */
6
+ type Signals = {
7
+ /** True when a publish in one process reaches subscribers in another. */
8
+ readonly crossProcess: boolean;
9
+ publish(channel: string): void;
10
+ subscribe(channel: string, handler: () => void): () => void;
11
+ close?(): Promise<void> | void;
12
+ };
13
+ declare const DELIVERIES_CHANNEL = "deliveries";
14
+ declare const inboxChannel: (userId: string) => string;
15
+ /** In-process only. The default, and exactly right on SQLite or a single replica. */
16
+ declare function createMemorySignals(): Signals;
17
+ /**
18
+ * Wraps a cross-process transport (LISTEN/NOTIFY, a change stream) so local
19
+ * subscribers still hear local publishes even if the transport drops them,
20
+ * and a transport failure degrades to in-process rather than to silence.
21
+ */
22
+ declare function createBridgedSignals(transport: {
23
+ publish(channel: string): Promise<void> | void;
24
+ subscribe(channel: string, handler: () => void): Promise<() => void> | (() => void);
25
+ }, onError: (error: unknown) => void): Signals;
26
+
27
+ export { DELIVERIES_CHANNEL as D, type Signals as S, createMemorySignals as a, createBridgedSignals as c, inboxChannel as i };
package/dist/sw.cjs ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // src/sw/index.ts
4
+ var isRelayMessage = (value) => {
5
+ const message = value;
6
+ return message?.source === "easy-ping" && message.type === "changed";
7
+ };
8
+ async function handlePush(event, options = {}, scope = globalThis) {
9
+ let payload = {};
10
+ try {
11
+ payload = event.data?.json() ?? {};
12
+ } catch {
13
+ payload = {};
14
+ }
15
+ const notificationId = typeof payload.data?.notificationId === "string" ? payload.data.notificationId : void 0;
16
+ if (options.showNotification !== false) {
17
+ const rendered = options.render?.(payload) ?? {
18
+ title: payload.title ?? "Notification",
19
+ options: {
20
+ body: payload.body ?? "",
21
+ data: payload.data ?? {},
22
+ ...notificationId ? { tag: notificationId } : {}
23
+ }
24
+ };
25
+ await scope.registration.showNotification(rendered.title, rendered.options);
26
+ }
27
+ const message = {
28
+ source: "easy-ping",
29
+ type: "changed",
30
+ ...notificationId ? { notificationId } : {}
31
+ };
32
+ const tabs = await scope.clients.matchAll({ type: "window", includeUncontrolled: true });
33
+ for (const tab of tabs) tab.postMessage(message);
34
+ }
35
+
36
+ exports.handlePush = handlePush;
37
+ exports.isRelayMessage = isRelayMessage;
38
+ //# sourceMappingURL=sw.cjs.map
39
+ //# sourceMappingURL=sw.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sw/index.ts"],"names":[],"mappings":";;;AAwBO,IAAM,cAAA,GAAiB,CAAC,KAAA,KAA0C;AACvE,EAAA,MAAM,OAAA,GAAU,KAAA;AAChB,EAAA,OAAO,OAAA,EAAS,MAAA,KAAW,WAAA,IAAe,OAAA,CAAQ,IAAA,KAAS,SAAA;AAC7D;AAuBA,eAAsB,WACpB,KAAA,EACA,OAAA,GAA6B,EAAC,EAC9B,QAAgC,UAAA,EACjB;AACf,EAAA,IAAI,UAAuB,EAAC;AAC5B,EAAA,IAAI;AACF,IAAA,OAAA,GAAW,KAAA,CAAM,IAAA,EAAM,IAAA,EAAK,IAAqB,EAAC;AAAA,EACpD,CAAA,CAAA,MAAQ;AACN,IAAA,OAAA,GAAU,EAAC;AAAA,EACb;AAEA,EAAA,MAAM,cAAA,GACJ,OAAO,OAAA,CAAQ,IAAA,EAAM,mBAAmB,QAAA,GAAW,OAAA,CAAQ,KAAK,cAAA,GAAiB,MAAA;AAEnF,EAAA,IAAI,OAAA,CAAQ,qBAAqB,KAAA,EAAO;AACtC,IAAA,MAAM,QAAA,GAAW,OAAA,CAAQ,MAAA,GAAS,OAAO,CAAA,IAAK;AAAA,MAC5C,KAAA,EAAO,QAAQ,KAAA,IAAS,cAAA;AAAA,MACxB,OAAA,EAAS;AAAA,QACP,IAAA,EAAM,QAAQ,IAAA,IAAQ,EAAA;AAAA,QACtB,IAAA,EAAM,OAAA,CAAQ,IAAA,IAAQ,EAAC;AAAA,QACvB,GAAI,cAAA,GAAiB,EAAE,GAAA,EAAK,cAAA,KAAmB;AAAC;AAClD,KACF;AACA,IAAA,MAAM,MAAM,YAAA,CAAa,gBAAA,CAAiB,QAAA,CAAS,KAAA,EAAO,SAAS,OAAO,CAAA;AAAA,EAC5E;AAEA,EAAA,MAAM,OAAA,GAAwB;AAAA,IAC5B,MAAA,EAAQ,WAAA;AAAA,IACR,IAAA,EAAM,SAAA;AAAA,IACN,GAAI,cAAA,GAAiB,EAAE,cAAA,KAAmB;AAAC,GAC7C;AACA,EAAA,MAAM,IAAA,GAAO,MAAM,KAAA,CAAM,OAAA,CAAQ,QAAA,CAAS,EAAE,IAAA,EAAM,QAAA,EAAU,mBAAA,EAAqB,IAAA,EAAM,CAAA;AACvF,EAAA,KAAA,MAAW,GAAA,IAAO,IAAA,EAAM,GAAA,CAAI,WAAA,CAAY,OAAO,CAAA;AACjD","file":"sw.cjs","sourcesContent":["/**\n * The service-worker half of the push relay (RFC 0006 idea 4).\n *\n * import { handlePush } from \"easy-ping/sw\";\n * self.addEventListener(\"push\", (event) => event.waitUntil(handlePush(event)));\n *\n * Shows the OS notification the push was sent for, then tells every open tab\n * on this origin that the inbox changed, so the bell refreshes without a poll.\n * Nothing here imports service-worker types: the globals are taken\n * structurally so the file compiles under the DOM lib.\n */\n\nexport type PushPayload = {\n title?: string;\n body?: string;\n data?: Record<string, unknown>;\n};\n\nexport type RelayMessage = {\n source: \"easy-ping\";\n type: \"changed\";\n notificationId?: string;\n};\n\nexport const isRelayMessage = (value: unknown): value is RelayMessage => {\n const message = value as Partial<RelayMessage> | null;\n return message?.source === \"easy-ping\" && message.type === \"changed\";\n};\n\ntype PushEventLike = { data: { json(): unknown } | null };\n\ntype ServiceWorkerScopeLike = {\n registration: {\n showNotification(title: string, options?: Record<string, unknown>): Promise<void>;\n };\n clients: {\n matchAll(options?: {\n type?: \"window\";\n includeUncontrolled?: boolean;\n }): Promise<{ postMessage(message: unknown): void }[]>;\n };\n};\n\nexport type HandlePushOptions = {\n /** Override what the OS notification shows. Default: the payload's title and body. */\n render?: (payload: PushPayload) => { title: string; options?: Record<string, unknown> };\n /** Skip the OS notification. Browsers ration silent pushes; leave this on unless you know why. */\n showNotification?: boolean;\n};\n\nexport async function handlePush(\n event: PushEventLike,\n options: HandlePushOptions = {},\n scope: ServiceWorkerScopeLike = globalThis as unknown as ServiceWorkerScopeLike,\n): Promise<void> {\n let payload: PushPayload = {};\n try {\n payload = (event.data?.json() as PushPayload) ?? {};\n } catch {\n payload = {};\n }\n\n const notificationId =\n typeof payload.data?.notificationId === \"string\" ? payload.data.notificationId : undefined;\n\n if (options.showNotification !== false) {\n const rendered = options.render?.(payload) ?? {\n title: payload.title ?? \"Notification\",\n options: {\n body: payload.body ?? \"\",\n data: payload.data ?? {},\n ...(notificationId ? { tag: notificationId } : {}),\n },\n };\n await scope.registration.showNotification(rendered.title, rendered.options);\n }\n\n const message: RelayMessage = {\n source: \"easy-ping\",\n type: \"changed\",\n ...(notificationId ? { notificationId } : {}),\n };\n const tabs = await scope.clients.matchAll({ type: \"window\", includeUncontrolled: true });\n for (const tab of tabs) tab.postMessage(message);\n}\n"]}
package/dist/sw.d.cts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The service-worker half of the push relay (RFC 0006 idea 4).
3
+ *
4
+ * import { handlePush } from "easy-ping/sw";
5
+ * self.addEventListener("push", (event) => event.waitUntil(handlePush(event)));
6
+ *
7
+ * Shows the OS notification the push was sent for, then tells every open tab
8
+ * on this origin that the inbox changed, so the bell refreshes without a poll.
9
+ * Nothing here imports service-worker types: the globals are taken
10
+ * structurally so the file compiles under the DOM lib.
11
+ */
12
+ type PushPayload = {
13
+ title?: string;
14
+ body?: string;
15
+ data?: Record<string, unknown>;
16
+ };
17
+ type RelayMessage = {
18
+ source: "easy-ping";
19
+ type: "changed";
20
+ notificationId?: string;
21
+ };
22
+ declare const isRelayMessage: (value: unknown) => value is RelayMessage;
23
+ type PushEventLike = {
24
+ data: {
25
+ json(): unknown;
26
+ } | null;
27
+ };
28
+ type ServiceWorkerScopeLike = {
29
+ registration: {
30
+ showNotification(title: string, options?: Record<string, unknown>): Promise<void>;
31
+ };
32
+ clients: {
33
+ matchAll(options?: {
34
+ type?: "window";
35
+ includeUncontrolled?: boolean;
36
+ }): Promise<{
37
+ postMessage(message: unknown): void;
38
+ }[]>;
39
+ };
40
+ };
41
+ type HandlePushOptions = {
42
+ /** Override what the OS notification shows. Default: the payload's title and body. */
43
+ render?: (payload: PushPayload) => {
44
+ title: string;
45
+ options?: Record<string, unknown>;
46
+ };
47
+ /** Skip the OS notification. Browsers ration silent pushes; leave this on unless you know why. */
48
+ showNotification?: boolean;
49
+ };
50
+ declare function handlePush(event: PushEventLike, options?: HandlePushOptions, scope?: ServiceWorkerScopeLike): Promise<void>;
51
+
52
+ export { type HandlePushOptions, type PushPayload, type RelayMessage, handlePush, isRelayMessage };
package/dist/sw.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The service-worker half of the push relay (RFC 0006 idea 4).
3
+ *
4
+ * import { handlePush } from "easy-ping/sw";
5
+ * self.addEventListener("push", (event) => event.waitUntil(handlePush(event)));
6
+ *
7
+ * Shows the OS notification the push was sent for, then tells every open tab
8
+ * on this origin that the inbox changed, so the bell refreshes without a poll.
9
+ * Nothing here imports service-worker types: the globals are taken
10
+ * structurally so the file compiles under the DOM lib.
11
+ */
12
+ type PushPayload = {
13
+ title?: string;
14
+ body?: string;
15
+ data?: Record<string, unknown>;
16
+ };
17
+ type RelayMessage = {
18
+ source: "easy-ping";
19
+ type: "changed";
20
+ notificationId?: string;
21
+ };
22
+ declare const isRelayMessage: (value: unknown) => value is RelayMessage;
23
+ type PushEventLike = {
24
+ data: {
25
+ json(): unknown;
26
+ } | null;
27
+ };
28
+ type ServiceWorkerScopeLike = {
29
+ registration: {
30
+ showNotification(title: string, options?: Record<string, unknown>): Promise<void>;
31
+ };
32
+ clients: {
33
+ matchAll(options?: {
34
+ type?: "window";
35
+ includeUncontrolled?: boolean;
36
+ }): Promise<{
37
+ postMessage(message: unknown): void;
38
+ }[]>;
39
+ };
40
+ };
41
+ type HandlePushOptions = {
42
+ /** Override what the OS notification shows. Default: the payload's title and body. */
43
+ render?: (payload: PushPayload) => {
44
+ title: string;
45
+ options?: Record<string, unknown>;
46
+ };
47
+ /** Skip the OS notification. Browsers ration silent pushes; leave this on unless you know why. */
48
+ showNotification?: boolean;
49
+ };
50
+ declare function handlePush(event: PushEventLike, options?: HandlePushOptions, scope?: ServiceWorkerScopeLike): Promise<void>;
51
+
52
+ export { type HandlePushOptions, type PushPayload, type RelayMessage, handlePush, isRelayMessage };
package/dist/sw.js ADDED
@@ -0,0 +1,36 @@
1
+ // src/sw/index.ts
2
+ var isRelayMessage = (value) => {
3
+ const message = value;
4
+ return message?.source === "easy-ping" && message.type === "changed";
5
+ };
6
+ async function handlePush(event, options = {}, scope = globalThis) {
7
+ let payload = {};
8
+ try {
9
+ payload = event.data?.json() ?? {};
10
+ } catch {
11
+ payload = {};
12
+ }
13
+ const notificationId = typeof payload.data?.notificationId === "string" ? payload.data.notificationId : void 0;
14
+ if (options.showNotification !== false) {
15
+ const rendered = options.render?.(payload) ?? {
16
+ title: payload.title ?? "Notification",
17
+ options: {
18
+ body: payload.body ?? "",
19
+ data: payload.data ?? {},
20
+ ...notificationId ? { tag: notificationId } : {}
21
+ }
22
+ };
23
+ await scope.registration.showNotification(rendered.title, rendered.options);
24
+ }
25
+ const message = {
26
+ source: "easy-ping",
27
+ type: "changed",
28
+ ...notificationId ? { notificationId } : {}
29
+ };
30
+ const tabs = await scope.clients.matchAll({ type: "window", includeUncontrolled: true });
31
+ for (const tab of tabs) tab.postMessage(message);
32
+ }
33
+
34
+ export { handlePush, isRelayMessage };
35
+ //# sourceMappingURL=sw.js.map
36
+ //# sourceMappingURL=sw.js.map
package/dist/sw.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sw/index.ts"],"names":[],"mappings":";AAwBO,IAAM,cAAA,GAAiB,CAAC,KAAA,KAA0C;AACvE,EAAA,MAAM,OAAA,GAAU,KAAA;AAChB,EAAA,OAAO,OAAA,EAAS,MAAA,KAAW,WAAA,IAAe,OAAA,CAAQ,IAAA,KAAS,SAAA;AAC7D;AAuBA,eAAsB,WACpB,KAAA,EACA,OAAA,GAA6B,EAAC,EAC9B,QAAgC,UAAA,EACjB;AACf,EAAA,IAAI,UAAuB,EAAC;AAC5B,EAAA,IAAI;AACF,IAAA,OAAA,GAAW,KAAA,CAAM,IAAA,EAAM,IAAA,EAAK,IAAqB,EAAC;AAAA,EACpD,CAAA,CAAA,MAAQ;AACN,IAAA,OAAA,GAAU,EAAC;AAAA,EACb;AAEA,EAAA,MAAM,cAAA,GACJ,OAAO,OAAA,CAAQ,IAAA,EAAM,mBAAmB,QAAA,GAAW,OAAA,CAAQ,KAAK,cAAA,GAAiB,MAAA;AAEnF,EAAA,IAAI,OAAA,CAAQ,qBAAqB,KAAA,EAAO;AACtC,IAAA,MAAM,QAAA,GAAW,OAAA,CAAQ,MAAA,GAAS,OAAO,CAAA,IAAK;AAAA,MAC5C,KAAA,EAAO,QAAQ,KAAA,IAAS,cAAA;AAAA,MACxB,OAAA,EAAS;AAAA,QACP,IAAA,EAAM,QAAQ,IAAA,IAAQ,EAAA;AAAA,QACtB,IAAA,EAAM,OAAA,CAAQ,IAAA,IAAQ,EAAC;AAAA,QACvB,GAAI,cAAA,GAAiB,EAAE,GAAA,EAAK,cAAA,KAAmB;AAAC;AAClD,KACF;AACA,IAAA,MAAM,MAAM,YAAA,CAAa,gBAAA,CAAiB,QAAA,CAAS,KAAA,EAAO,SAAS,OAAO,CAAA;AAAA,EAC5E;AAEA,EAAA,MAAM,OAAA,GAAwB;AAAA,IAC5B,MAAA,EAAQ,WAAA;AAAA,IACR,IAAA,EAAM,SAAA;AAAA,IACN,GAAI,cAAA,GAAiB,EAAE,cAAA,KAAmB;AAAC,GAC7C;AACA,EAAA,MAAM,IAAA,GAAO,MAAM,KAAA,CAAM,OAAA,CAAQ,QAAA,CAAS,EAAE,IAAA,EAAM,QAAA,EAAU,mBAAA,EAAqB,IAAA,EAAM,CAAA;AACvF,EAAA,KAAA,MAAW,GAAA,IAAO,IAAA,EAAM,GAAA,CAAI,WAAA,CAAY,OAAO,CAAA;AACjD","file":"sw.js","sourcesContent":["/**\n * The service-worker half of the push relay (RFC 0006 idea 4).\n *\n * import { handlePush } from \"easy-ping/sw\";\n * self.addEventListener(\"push\", (event) => event.waitUntil(handlePush(event)));\n *\n * Shows the OS notification the push was sent for, then tells every open tab\n * on this origin that the inbox changed, so the bell refreshes without a poll.\n * Nothing here imports service-worker types: the globals are taken\n * structurally so the file compiles under the DOM lib.\n */\n\nexport type PushPayload = {\n title?: string;\n body?: string;\n data?: Record<string, unknown>;\n};\n\nexport type RelayMessage = {\n source: \"easy-ping\";\n type: \"changed\";\n notificationId?: string;\n};\n\nexport const isRelayMessage = (value: unknown): value is RelayMessage => {\n const message = value as Partial<RelayMessage> | null;\n return message?.source === \"easy-ping\" && message.type === \"changed\";\n};\n\ntype PushEventLike = { data: { json(): unknown } | null };\n\ntype ServiceWorkerScopeLike = {\n registration: {\n showNotification(title: string, options?: Record<string, unknown>): Promise<void>;\n };\n clients: {\n matchAll(options?: {\n type?: \"window\";\n includeUncontrolled?: boolean;\n }): Promise<{ postMessage(message: unknown): void }[]>;\n };\n};\n\nexport type HandlePushOptions = {\n /** Override what the OS notification shows. Default: the payload's title and body. */\n render?: (payload: PushPayload) => { title: string; options?: Record<string, unknown> };\n /** Skip the OS notification. Browsers ration silent pushes; leave this on unless you know why. */\n showNotification?: boolean;\n};\n\nexport async function handlePush(\n event: PushEventLike,\n options: HandlePushOptions = {},\n scope: ServiceWorkerScopeLike = globalThis as unknown as ServiceWorkerScopeLike,\n): Promise<void> {\n let payload: PushPayload = {};\n try {\n payload = (event.data?.json() as PushPayload) ?? {};\n } catch {\n payload = {};\n }\n\n const notificationId =\n typeof payload.data?.notificationId === \"string\" ? payload.data.notificationId : undefined;\n\n if (options.showNotification !== false) {\n const rendered = options.render?.(payload) ?? {\n title: payload.title ?? \"Notification\",\n options: {\n body: payload.body ?? \"\",\n data: payload.data ?? {},\n ...(notificationId ? { tag: notificationId } : {}),\n },\n };\n await scope.registration.showNotification(rendered.title, rendered.options);\n }\n\n const message: RelayMessage = {\n source: \"easy-ping\",\n type: \"changed\",\n ...(notificationId ? { notificationId } : {}),\n };\n const tabs = await scope.clients.matchAll({ type: \"window\", includeUncontrolled: true });\n for (const tab of tabs) tab.postMessage(message);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-ping",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Self-hosted, type-safe notifications for TypeScript apps. Your database, your users.",
5
5
  "license": "MIT",
6
6
  "author": "Teklu Moges",
@@ -139,6 +139,11 @@
139
139
  "types": "./dist/browser.d.ts",
140
140
  "import": "./dist/browser.js",
141
141
  "require": "./dist/browser.cjs"
142
+ },
143
+ "./sw": {
144
+ "types": "./dist/sw.d.ts",
145
+ "import": "./dist/sw.js",
146
+ "require": "./dist/sw.cjs"
142
147
  }
143
148
  },
144
149
  "peerDependencies": {
@@ -162,9 +167,10 @@
162
167
  "mysql2": "^3.24.4",
163
168
  "postgres": "^3.4.9",
164
169
  "react": "^19.2.8",
165
- "tsup": "^8.3.0",
170
+ "tsup": "^8.5.1",
166
171
  "typescript": "^5.7.0",
167
- "vitest": "^2.1.0"
172
+ "vite": "^8.3.0",
173
+ "vitest": "^5.0.1"
168
174
  },
169
175
  "engines": {
170
176
  "node": ">=20"
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/tokens.ts","../src/core/handler.ts"],"names":["encoder","route"],"mappings":"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","file":"chunk-BECMTNK7.js","sourcesContent":["import { decodeBase64Url, decodeBase64UrlBytes, encodeBase64Url } from \"./base64url\";\n\nexport type TokenClaims = {\n /** Subject — the user the token acts for. */\n uid: string;\n /** What the token is allowed to do. Verification requires an exact match. */\n purpose: string;\n /** Expiry, epoch seconds. */\n exp: number;\n /** Issued at, epoch seconds. Set by signToken; lets a route refuse a token older than a later change. */\n iat?: number;\n /** Free-form, purpose-specific. */\n data?: Record<string, string>;\n};\n\nconst encoder = new TextEncoder();\n\n// Changing this invalidates every outstanding token. Bump it on purpose only.\nconst KEY_SALT = encoder.encode(\"easy-ping/tokens/v1\");\n\n/** One HMAC key per purpose, derived with HKDF, so an unsubscribe key signs nothing else. */\nasync function hmacKey(secret: string, purpose: string): Promise<CryptoKey> {\n const master = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), \"HKDF\", false, [\n \"deriveKey\",\n ]);\n return crypto.subtle.deriveKey(\n { name: \"HKDF\", hash: \"SHA-256\", salt: KEY_SALT, info: encoder.encode(purpose) },\n master,\n { name: \"HMAC\", hash: \"SHA-256\", length: 256 },\n false,\n [\"sign\", \"verify\"],\n );\n}\n\nexport async function signToken(\n secret: string,\n claims: TokenClaims,\n now: Date = new Date(),\n): Promise<string> {\n const stamped: TokenClaims = { ...claims, iat: claims.iat ?? Math.floor(now.getTime() / 1000) };\n const body = encodeBase64Url(JSON.stringify(stamped));\n const signature = await crypto.subtle.sign(\n \"HMAC\",\n await hmacKey(secret, claims.purpose),\n encoder.encode(body),\n );\n return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;\n}\n\n/** null for every failure mode. Distinguishing them tells an attacker which part to fix. */\nexport async function verifyToken(\n secret: string,\n token: string,\n purpose: string,\n now: Date = new Date(),\n): Promise<TokenClaims | null> {\n const separator = token.lastIndexOf(\".\");\n if (separator <= 0) return null;\n\n const body = token.slice(0, separator);\n const signature = decodeBase64UrlBytes(token.slice(separator + 1));\n if (!signature) return null;\n\n // Verified under the expected purpose's key before the body is parsed. subtle.verify is constant-time.\n const valid = await crypto.subtle.verify(\n \"HMAC\",\n await hmacKey(secret, purpose),\n signature,\n encoder.encode(body),\n );\n if (!valid) return null;\n\n const decoded = decodeBase64Url(body);\n if (!decoded) return null;\n\n let claims: TokenClaims;\n try {\n claims = JSON.parse(decoded) as TokenClaims;\n } catch {\n return null;\n }\n\n if (typeof claims?.uid !== \"string\" || typeof claims.exp !== \"number\") return null;\n if (claims.purpose !== purpose) return null;\n if (claims.exp * 1000 <= now.getTime()) return null;\n if (claims.iat !== undefined && typeof claims.iat !== \"number\") return null;\n\n if (claims.data !== undefined) {\n if (typeof claims.data !== \"object\" || claims.data === null || Array.isArray(claims.data)) {\n return null;\n }\n for (const value of Object.values(claims.data)) {\n if (typeof value !== \"string\") return null;\n }\n }\n\n return claims;\n}\n\nexport const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;\n/** Ninety days. A link that must outlive this should be re-issued, not made immortal. */\nexport const MAX_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;\n\nexport const expiresIn = (seconds: number, now: Date = new Date()): number =>\n Math.floor(now.getTime() / 1000) + Math.min(Math.max(seconds, 0), MAX_TOKEN_TTL_SECONDS);\n\nexport type SignRequest = {\n uid: string;\n purpose: string;\n data?: Record<string, string>;\n /** Defaults to 30 days, capped at 90. */\n ttlSeconds?: number;\n};\n\n/** What a plugin gets instead of the secret: signs only its own declared purposes. */\nexport function createScopedSigner(\n secret: string,\n pluginId: string,\n purposes: ReadonlySet<string>,\n onViolation: (message: string) => Error,\n): (request: SignRequest) => Promise<string> {\n return (request) => {\n if (!purposes.has(request.purpose)) {\n throw onViolation(\n `plugin \"${pluginId}\" tried to sign a token for purpose \"${request.purpose}\", ` +\n \"which none of its signed routes declare.\",\n );\n }\n return signToken(secret, {\n uid: request.uid,\n purpose: request.purpose,\n exp: expiresIn(request.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS),\n ...(request.data ? { data: request.data } : {}),\n });\n };\n}\n","import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { verifyToken } from \"./tokens\";\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\n// Per-user data: no shared caching, no sniffing.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/** Constant time over SHA-256 digests, so neither content nor length leaks. Works on edge runtimes. */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/** Reads a JSON object body with a streaming byte cap. Malformed JSON is 400; an empty body is `{}`. */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/** CSRF: a cross-site form can neither send `application/json` without a preflight nor forge `Origin`. */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n async function resolveUserId(request: Request): Promise<string | null | \"error\"> {\n try {\n return await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return \"error\";\n }\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded so an overlapping scheduler does not hold a request open for the whole backlog.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /** Plugin routes first; auth is enforced here so a plugin cannot forget it. */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // unseenCount rides on the first page only: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // +1ms: Postgres keeps microseconds, an ISO string milliseconds; without it markSeen misses the newest row.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // 404, not 403: a 403 confirms the row exists. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n return json({ updated: await deps.adapter.markAllRead(userId) });\n }\n\n return empty(404);\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n return await route(request);\n } catch (error) {\n // A thrown driver error must not escape: unhandled rejection on Node, stack trace elsewhere.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/backoff.ts","../src/core/runner.ts"],"names":[],"mappings":"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","file":"chunk-GJNLOQJS.js","sourcesContent":["/** Exponential backoff with ±20% jitter, floored by the sweep interval. RFC 0001 §4. */\nconst SCHEDULE_MS = [30_000, 120_000, 480_000, 1_920_000] as const;\n\nexport type Backoff = \"exponential\" | ((attempt: number) => number);\n\nexport function backoffMs(attempt: number, strategy: Backoff = \"exponential\"): number {\n if (typeof strategy === \"function\") return Math.max(0, strategy(attempt));\n\n const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;\n const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 30_000;\n return Math.round(base * (0.8 + Math.random() * 0.4));\n}\n\nexport const nextAttemptAt = (attempt: number, strategy?: Backoff, now = new Date()): Date =>\n new Date(now.getTime() + backoffMs(attempt, strategy));\n","import type { ClaimedDelivery, DatabaseAdapter, DeliveryOutcome, DeliveryRelease } from \"./adapter\";\nimport { type Backoff, nextAttemptAt } from \"./backoff\";\nimport type { ChannelsConfig, Worker } from \"./config\";\nimport type { NotificationDefinitions } from \"./definition\";\nimport type { Logger } from \"./errors\";\nimport type { AnyPlugin } from \"./plugin\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type RunnerDeps = {\n adapter: DatabaseAdapter;\n definitions: NotificationDefinitions;\n channels: ChannelsConfig;\n plugins: readonly AnyPlugin[];\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n logger: Logger;\n leaseMs: number;\n batchSize: number;\n backoff: Backoff;\n};\n\nexport type SweepResult = {\n claimed: number;\n sent: number;\n failed: number;\n /** Deliveries that had nothing to do. Not errors; see DeliveryOutcome. */\n skipped: number;\n};\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nasync function withTimeout<T>(\n run: (signal: AbortSignal) => Promise<T>,\n ms: number,\n label: string,\n): Promise<T> {\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n // Abort first: racing a promise leaves the original request running.\n controller.abort();\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n });\n\n try {\n return await Promise.race([run(controller.signal), timeout]);\n } finally {\n clearTimeout(timer);\n }\n}\n\n/** Persisted to `last_error`, which admin tooling reads: addresses do not go in. */\nexport const redactErrorMessage = (text: string) =>\n text.replace(/[^\\s\"'<>()[\\]{},;]+@[^\\s\"'<>()[\\]{},;]+/g, \"[redacted-email]\");\n\nconst message = (error: unknown) =>\n redactErrorMessage(error instanceof Error ? error.message : String(error));\n\nexport function createRunner(deps: RunnerDeps) {\n async function deliverOne(\n delivery: ClaimedDelivery,\n recipient: Recipient | undefined,\n ): Promise<DeliveryOutcome> {\n if (!recipient) {\n return { result: \"failed\", error: \"recipient could not be resolved\", retryable: false };\n }\n\n if (delivery.channel === \"inApp\") {\n // The notification row is the delivery. Nothing to transmit.\n return { result: \"sent\" };\n }\n\n if (delivery.channel === \"email\") {\n const provider = deps.channels.email?.provider;\n if (!provider) {\n return { result: \"failed\", error: \"no email provider configured\", retryable: false };\n }\n // Captured before the closure below: narrowing does not survive into it.\n const to = recipient.email;\n if (!to) {\n return { result: \"failed\", error: \"recipient has no email address\", retryable: false };\n }\n\n const email = deps.definitions[delivery.notification.type]?.email;\n if (!email) {\n return {\n result: \"failed\",\n error: `notification type \"${delivery.notification.type}\" has no email template`,\n retryable: false,\n };\n }\n\n try {\n const payload = delivery.notification.payload as never;\n const html = await email.template(payload);\n\n await withTimeout(\n (signal) =>\n provider.send({\n to,\n subject: email.subject(payload),\n html,\n // Delivery is at-least-once; the provider dedupes on this.\n idempotencyKey: delivery.id,\n signal,\n }),\n provider.timeoutMs ?? 30_000,\n `${provider.name} send`,\n );\n\n return { result: \"sent\" };\n } catch (error) {\n return {\n result: \"failed\",\n error: message(error),\n retryable: provider.isRetryable(error),\n };\n }\n }\n\n const owner = deps.plugins.find(\n (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver,\n );\n if (!owner?.hooks?.deliver) {\n return {\n result: \"failed\",\n error: `no plugin delivers channel \"${delivery.channel}\"`,\n retryable: false,\n };\n }\n\n try {\n return await withTimeout(\n (signal) =>\n Promise.resolve(\n // biome-ignore lint/style/noNonNullAssertion: guarded directly above\n owner.hooks!.deliver!({\n channel: delivery.channel,\n deliveryId: delivery.id,\n attempt: delivery.attempts + 1,\n recipient,\n notification: {\n id: delivery.notificationId,\n userId: delivery.notification.userId,\n type: delivery.notification.type,\n payload: delivery.notification.payload,\n },\n signal,\n }),\n ),\n 30_000,\n `plugin \"${owner.id}\" deliver`,\n );\n } catch (error) {\n return { result: \"failed\", error: message(error), retryable: true };\n }\n }\n\n async function notifyAfterDeliver(delivery: ClaimedDelivery, outcome: DeliveryOutcome) {\n const isTerminal =\n outcome.result !== \"failed\" ||\n !outcome.retryable ||\n delivery.attempts + 1 >= delivery.maxAttempts;\n\n for (const plugin of deps.plugins) {\n const hook = plugin.hooks?.afterDeliver;\n if (!hook) continue;\n try {\n await hook({\n deliveryId: delivery.id,\n notificationId: delivery.notificationId,\n userId: delivery.notification.userId,\n channel: delivery.channel,\n outcome,\n attempt: delivery.attempts + 1,\n isTerminal,\n });\n } catch (error) {\n // Fail open: the message is already sent. RFC 0004 §5.\n deps.logger.error(`plugin \"${plugin.id}\" afterDeliver threw; ignoring`, {\n deliveryId: delivery.id,\n error,\n });\n }\n }\n }\n\n async function runOnce(\n options: { limit?: number; channels?: readonly Channel[]; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const claimToken = crypto.randomUUID();\n\n const claimed = await deps.adapter.claimPendingDeliveries({\n limit: options.limit ?? deps.batchSize,\n leaseMs: deps.leaseMs,\n claimToken,\n ...(options.channels ? { channels: options.channels } : {}),\n ...(options.ids ? { ids: options.ids } : {}),\n });\n\n if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n const userIds = [...new Set(claimed.map((row) => row.notification.userId))];\n const recipients = await deps.getRecipients(userIds);\n const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));\n\n const releases: DeliveryRelease[] = [];\n let sent = 0;\n let failed = 0;\n let skipped = 0;\n\n for (const delivery of claimed) {\n const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));\n\n if (outcome.result === \"sent\") sent += 1;\n else if (outcome.result === \"skipped\") skipped += 1;\n else failed += 1;\n\n releases.push({\n id: delivery.id,\n claimToken,\n outcome,\n ...(outcome.result === \"failed\" && outcome.retryable\n ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) }\n : {}),\n });\n\n await notifyAfterDeliver(delivery, outcome);\n }\n\n await deps.adapter.releaseDeliveries(releases);\n return { claimed: claimed.length, sent, failed, skipped };\n }\n\n /** Drains until empty. Pass `ids` to bound it to one send. */\n async function drain(\n options: { maxSweeps?: number; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const maxSweeps = options.maxSweeps ?? 50;\n const total: SweepResult = { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n for (let sweep = 0; sweep < maxSweeps; sweep += 1) {\n const result = await runOnce(options.ids ? { ids: options.ids } : {});\n total.claimed += result.claimed;\n total.sent += result.sent;\n total.failed += result.failed;\n total.skipped += result.skipped;\n if (result.claimed === 0) break;\n }\n\n return total;\n }\n\n let warnedMissingWaitUntil = false;\n\n function scheduleDeferred(\n waitUntil?: (promise: Promise<unknown>) => void,\n ids?: readonly string[],\n ): void {\n if (waitUntil) {\n waitUntil(\n drain({ maxSweeps: 10, ...(ids ? { ids } : {}) }).catch((error) =>\n deps.logger.error(\"deferred sweep failed\", { error }),\n ),\n );\n return;\n }\n\n if (!warnedMissingWaitUntil) {\n warnedMissingWaitUntil = true;\n deps.logger.warn(\n \"delivery.mode is 'deferred' but no delivery.waitUntil was supplied — \" +\n \"falling back to the cron sweep. Notifications are not lost, but they \" +\n \"will not be delivered until POST /cron runs.\",\n );\n }\n }\n\n function startWorker(options: { intervalMs?: number; batchSize?: number } = {}): Worker {\n const intervalMs = options.intervalMs ?? 1_000;\n let stopped = false;\n\n const loop = (async () => {\n while (!stopped) {\n try {\n const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });\n // Only idle when there was nothing to do; otherwise keep draining.\n if (result.claimed === 0) await sleep(intervalMs);\n } catch (error) {\n deps.logger.error(\"worker sweep failed\", { error });\n await sleep(intervalMs);\n }\n }\n })();\n\n return {\n stop: async () => {\n stopped = true;\n // Let the in-flight sweep finish and release its leases.\n await loop;\n },\n };\n }\n\n return { runOnce, drain, scheduleDeferred, startWorker };\n}\n\nexport type Runner = ReturnType<typeof createRunner>;\n"]}