@lunora/notify 1.0.0-alpha.34 → 1.0.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,15 +39,29 @@ Generate a VAPID keypair once: `npx web-push generate-vapid-keys`.
39
39
  ```ts
40
40
  import { subscribeToPush } from "@lunora/notify/web";
41
41
 
42
- const subscription = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
- await client.mutation("registerDevice", { subscription });
42
+ const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
43
+ await client.mutation("registerDevice", { replacedEndpoint, subscription });
44
44
  ```
45
45
 
46
+ `replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
47
+ subscription is dropped and a new one minted, and the new one has a new endpoint
48
+ — hence a new store id — so it never upserts over the old row. Every send to that
49
+ row now answers `403 VapidPkHashMismatch`, which is (correctly) not a "gone"
50
+ signal, so nothing prunes it either. Forward it and unregister it:
51
+
46
52
  ```ts
47
53
  // lunora/registerDevice.ts (a mutation — storage write is fine here)
48
- export const registerDevice = mutation.input({ subscription: v.any() }).mutation(async ({ ctx, args: { subscription } }) => {
49
- await ctx.push.register({ subscription, userId: ctx.auth?.userId });
50
- });
54
+ import { webPushId } from "@lunora/notify";
55
+
56
+ export const registerDevice = mutation
57
+ .input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
58
+ .mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
59
+ if (replacedEndpoint !== undefined) {
60
+ await ctx.push.unregister(webPushId(replacedEndpoint));
61
+ }
62
+
63
+ await ctx.push.register({ subscription, userId: ctx.auth?.userId });
64
+ });
51
65
  ```
52
66
 
53
67
  ## Send (from an action)
@@ -87,12 +101,13 @@ await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body
87
101
  // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
88
102
  // `ctx.push` and `ctx.queues` exist.
89
103
  export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
90
- const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
104
+ const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
91
105
 
92
- // One message = ONE bounded page. Discarding `nextCursor` delivers only the
106
+ // One message = ONE bounded page. Discarding `nextFilter` delivers only the
93
107
  // first page (default 250 devices) and reports success for the whole audience.
94
- if (nextCursor !== undefined) {
95
- await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: { ...job.filter, after: nextCursor } });
108
+ // Pass it verbatim — it carries the cursor AND the remaining `filter.limit`.
109
+ if (nextFilter !== undefined) {
110
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: nextFilter });
96
111
  }
97
112
 
98
113
  // Redeliver ONLY the recipients that failed — a retry of the whole page would
@@ -144,9 +159,11 @@ client-supplied data, so the facade enforces two boundaries:
144
159
  - **No secrets on the app facade.** `ctx.push.list()`
145
160
  returns the registered devices with the delivery **secrets stripped** — the Web
146
161
  Push `keys` (`auth`/`p256dh`) and the FCM `token`, which together with the
147
- endpoint are enough to deliver arbitrary push to a device. The raw rows are
148
- reachable only through the internal `SubscriptionStore` (which handlers never
149
- hold); the broadcast path uses the store directly.
162
+ endpoint are enough to deliver arbitrary push to a device. Every other facade
163
+ read is projected the same way; the broadcast path uses the store directly. The
164
+ one place a handler does see a raw row is the return of `ctx.push.register(...)`,
165
+ which echoes back the record the caller just supplied — nothing it did not
166
+ already hold, and never another device's.
150
167
 
151
168
  ## Delivery observability
152
169
 
package/dist/index.d.mts CHANGED
@@ -267,11 +267,15 @@ interface LunoraPush {
267
267
  * List stored subscriptions (optionally filtered), with the delivery
268
268
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
269
269
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
270
- * a device, so they never cross the app-facing facade; the raw rows are
270
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
271
271
  * reachable only through the internal `SubscriptionStore`.
272
+ *
273
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
274
+ * echoes back the record the caller just supplied, so it discloses nothing
275
+ * the caller did not already hold and never another device's row.
272
276
  */
273
277
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
274
- /** Register (upsert) a device subscription and return the stored record. */
278
+ /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
275
279
  register: (input: RegisterInput) => Promise<StoredSubscription>;
276
280
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
277
281
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
@@ -550,17 +554,33 @@ interface PushBroadcastJob {
550
554
  /**
551
555
  * One page's outcome plus the ids that need redelivering.
552
556
  *
553
- * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
557
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
554
558
  * `failedIds` redelivers the recipients this page missed. Acking a message while
555
559
  * ignoring either silently drops part of the audience.
556
560
  */
557
- interface PushBroadcastPageOutcome extends BroadcastPageResult {
561
+ interface PushBroadcastPageOutcome {
558
562
  /**
559
563
  * Subscriptions that failed transiently on this run (gone/pruned devices are
560
564
  * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
561
565
  * as `retryIds` to redeliver to just them.
562
566
  */
563
567
  failedIds: string[];
568
+ /**
569
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
570
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
571
+ * verbatim — it carries the next page's cursor AND, when the job set
572
+ * `filter.limit`, the REMAINING budget.
573
+ *
574
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
575
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
576
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
577
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
578
+ * `broadcast` honours as one) became a per-message cap and the walk reached
579
+ * the entire audience anyway.
580
+ */
581
+ nextFilter?: SubscriptionFilter;
582
+ /** This page's delivery result. */
583
+ result: BroadcastResult;
564
584
  }
565
585
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
566
586
  interface QueueProducerLike {
@@ -581,15 +601,13 @@ interface QueueProducerLike {
581
601
  * export const deliverPage = internalAction
582
602
  * .input({ job: v.any() })
583
603
  * .action(async ({ args: { job }, ctx }) => {
584
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
604
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
585
605
  *
586
- * if (nextCursor !== undefined) {
606
+ * if (nextFilter !== undefined) {
587
607
  * // More pages remain — enqueue the continuation. Each message still
588
- * // does only ONE bounded page of work.
589
- * await enqueuePushBroadcast(ctx.queues.push, {
590
- * filter: { ...job.filter, after: nextCursor },
591
- * payload: job.payload,
592
- * });
608
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
609
+ * // it carries the cursor and the remaining `limit` budget.
610
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
593
611
  * }
594
612
  *
595
613
  * if (failedIds.length > 0) {
@@ -628,21 +646,26 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
628
646
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
629
647
  * — so per-message work is bounded regardless of total audience size.
630
648
  * - A page NEVER throws for a partial failure. Throwing discarded the page's
631
- * `nextCursor`, which is the only way the broadcast advances: one device that
649
+ * continuation, which is the only way the broadcast advances: one device that
632
650
  * fails permanently (a rotated VAPID keypair leaves a stale device answering
633
651
  * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
634
652
  * every already-delivered recipient on each retry, dead-letter, and leave
635
- * every LATER page unreached. The page's `nextCursor` and its `failedIds`
653
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
636
654
  * both come back instead.
637
- * - The CALLER re-enqueues: `filter.after:
638
- * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
639
- * any recipient failed. `@lunora/notify` cannot do it itself — it has no
640
- * `@lunora/queue` dependency (the seam stays structural) and no reference to
641
- * the producer that enqueued this message. See the consumer example on
642
- * {@link enqueuePushBroadcast}.
643
- * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
644
- * of them still fails, so the queue's backoff/dead-letter bounds it. That
645
- * message contains no already-delivered recipient, so nothing is re-sent.
655
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
656
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
657
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
658
+ * structural) and no reference to the producer that enqueued this message. See
659
+ * the consumer example on {@link enqueuePushBroadcast}.
660
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
661
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
662
+ * out, so `limit` caps the whole audience here exactly as it does on
663
+ * {@link LunoraPush.broadcast}.
664
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
665
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
666
+ * never recovers. Once any recipient recovers the run resolves and reports the
667
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
668
+ * message already reached.
646
669
  * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
647
670
  * never appear in `failedIds` — an all-`pruned` page is a success, not a
648
671
  * failure, as is an empty page.
@@ -760,13 +783,21 @@ declare const targetOf: (subscription: StoredSubscription) => string;
760
783
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
761
784
  * transient failure worth retrying.
762
785
  *
763
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
764
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
765
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
766
- * last resort only, so a transient error that happens to contain `expired`
767
- * (a cert/session expiry) can never permanently drop a valid subscription.
768
- */
769
- declare const isGoneError: (message: string | undefined) => boolean;
786
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
787
+ * answer one for a dead endpoint/token) or, for FCM only, an
788
+ * `UNREGISTERED`/`NOT_REGISTERED` code. The free-text
789
+ * {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a transient
790
+ * error that happens to contain `expired` (a cert/session expiry) can never
791
+ * permanently drop a valid subscription.
792
+ *
793
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
794
+ * The web-push provider echoes the push service's response body into
795
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
796
+ * registered" matched the FCM-only codes and permanently deleted a live
797
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
798
+ * every pattern, as before.
799
+ */
800
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
770
801
  export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
771
802
  /**
772
803
  * `@lunora/notify`
package/dist/index.d.ts CHANGED
@@ -267,11 +267,15 @@ interface LunoraPush {
267
267
  * List stored subscriptions (optionally filtered), with the delivery
268
268
  * **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
269
269
  * FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
270
- * a device, so they never cross the app-facing facade; the raw rows are
270
+ * a device, so no READ on this facade returns them; the raw rows are otherwise
271
271
  * reachable only through the internal `SubscriptionStore`.
272
+ *
273
+ * {@link LunoraPush.register} is the one exception, and deliberately so: it
274
+ * echoes back the record the caller just supplied, so it discloses nothing
275
+ * the caller did not already hold and never another device's row.
272
276
  */
273
277
  list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
274
- /** Register (upsert) a device subscription and return the stored record. */
278
+ /** Register (upsert) a device subscription and return the stored record (the caller's own row, secrets included). */
275
279
  register: (input: RegisterInput) => Promise<StoredSubscription>;
276
280
  /** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
277
281
  send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
@@ -550,17 +554,33 @@ interface PushBroadcastJob {
550
554
  /**
551
555
  * One page's outcome plus the ids that need redelivering.
552
556
  *
553
- * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
557
+ * The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
554
558
  * `failedIds` redelivers the recipients this page missed. Acking a message while
555
559
  * ignoring either silently drops part of the audience.
556
560
  */
557
- interface PushBroadcastPageOutcome extends BroadcastPageResult {
561
+ interface PushBroadcastPageOutcome {
558
562
  /**
559
563
  * Subscriptions that failed transiently on this run (gone/pruned devices are
560
564
  * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
561
565
  * as `retryIds` to redeliver to just them.
562
566
  */
563
567
  failedIds: string[];
568
+ /**
569
+ * The filter for the CONTINUATION job, or `undefined` when the broadcast is
570
+ * finished (no further pages, or `filter.limit` is spent). Enqueue it
571
+ * verbatim — it carries the next page's cursor AND, when the job set
572
+ * `filter.limit`, the REMAINING budget.
573
+ *
574
+ * This replaces the raw `nextCursor` the runner used to return. Rebuilding
575
+ * the filter at the call site (`{ ...job.filter, after: nextCursor }`)
576
+ * forwarded the ORIGINAL `limit` to every message, so a `limit` documented
577
+ * as an overall audience cap (see {@link SubscriptionFilter.limit}, which
578
+ * `broadcast` honours as one) became a per-message cap and the walk reached
579
+ * the entire audience anyway.
580
+ */
581
+ nextFilter?: SubscriptionFilter;
582
+ /** This page's delivery result. */
583
+ result: BroadcastResult;
564
584
  }
565
585
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
566
586
  interface QueueProducerLike {
@@ -581,15 +601,13 @@ interface QueueProducerLike {
581
601
  * export const deliverPage = internalAction
582
602
  * .input({ job: v.any() })
583
603
  * .action(async ({ args: { job }, ctx }) => {
584
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
604
+ * const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
585
605
  *
586
- * if (nextCursor !== undefined) {
606
+ * if (nextFilter !== undefined) {
587
607
  * // More pages remain — enqueue the continuation. Each message still
588
- * // does only ONE bounded page of work.
589
- * await enqueuePushBroadcast(ctx.queues.push, {
590
- * filter: { ...job.filter, after: nextCursor },
591
- * payload: job.payload,
592
- * });
608
+ * // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
609
+ * // it carries the cursor and the remaining `limit` budget.
610
+ * await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
593
611
  * }
594
612
  *
595
613
  * if (failedIds.length > 0) {
@@ -628,21 +646,26 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
628
646
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
629
647
  * — so per-message work is bounded regardless of total audience size.
630
648
  * - A page NEVER throws for a partial failure. Throwing discarded the page's
631
- * `nextCursor`, which is the only way the broadcast advances: one device that
649
+ * continuation, which is the only way the broadcast advances: one device that
632
650
  * fails permanently (a rotated VAPID keypair leaves a stale device answering
633
651
  * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
634
652
  * every already-delivered recipient on each retry, dead-letter, and leave
635
- * every LATER page unreached. The page's `nextCursor` and its `failedIds`
653
+ * every LATER page unreached. The page's `nextFilter` and its `failedIds`
636
654
  * both come back instead.
637
- * - The CALLER re-enqueues: `filter.after:
638
- * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
639
- * any recipient failed. `@lunora/notify` cannot do it itself — it has no
640
- * `@lunora/queue` dependency (the seam stays structural) and no reference to
641
- * the producer that enqueued this message. See the consumer example on
642
- * {@link enqueuePushBroadcast}.
643
- * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
644
- * of them still fails, so the queue's backoff/dead-letter bounds it. That
645
- * message contains no already-delivered recipient, so nothing is re-sent.
655
+ * - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
656
+ * `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
657
+ * do it itself — it has no `@lunora/queue` dependency (the seam stays
658
+ * structural) and no reference to the producer that enqueued this message. See
659
+ * the consumer example on {@link enqueuePushBroadcast}.
660
+ * - `job.filter.limit` is spent across messages, not re-granted to each one:
661
+ * `nextFilter` carries the REMAINING budget and is `undefined` once it runs
662
+ * out, so `limit` caps the whole audience here exactly as it does on
663
+ * {@link LunoraPush.broadcast}.
664
+ * - A `retryIds` job redelivers to exactly those ids and throws only while ALL
665
+ * of them still fail, so the queue's backoff/dead-letter bounds a device that
666
+ * never recovers. Once any recipient recovers the run resolves and reports the
667
+ * rest in `failedIds`, so the narrower retry never re-sends to a device this
668
+ * message already reached.
646
669
  * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
647
670
  * never appear in `failedIds` — an all-`pruned` page is a success, not a
648
671
  * failure, as is an empty page.
@@ -760,13 +783,21 @@ declare const targetOf: (subscription: StoredSubscription) => string;
760
783
  * (the browser/device unsubscribed) and should be pruned — as opposed to a
761
784
  * transient failure worth retrying.
762
785
  *
763
- * Gates on STRUCTURED signals first: a Web Push `HTTP 404/410` status or an FCM
764
- * `UNREGISTERED`/`NOT_REGISTERED` code, both of which the providers surface in
765
- * their failure receipts. The free-text {@link GONE_TEXT_FALLBACK} is a tightened
766
- * last resort only, so a transient error that happens to contain `expired`
767
- * (a cert/session expiry) can never permanently drop a valid subscription.
768
- */
769
- declare const isGoneError: (message: string | undefined) => boolean;
786
+ * Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
787
+ * answer one for a dead endpoint/token) or, for FCM only, an
788
+ * `UNREGISTERED`/`NOT_REGISTERED` code. The free-text
789
+ * {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a transient
790
+ * error that happens to contain `expired` (a cert/session expiry) can never
791
+ * permanently drop a valid subscription.
792
+ *
793
+ * `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
794
+ * The web-push provider echoes the push service's response body into
795
+ * `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
796
+ * registered" matched the FCM-only codes and permanently deleted a live
797
+ * subscription. Omit `kind` (the third-party/unknown-provider case) to test
798
+ * every pattern, as before.
799
+ */
800
+ declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
770
801
  export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
771
802
  /**
772
803
  * `@lunora/notify`
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-BWRG-JIC.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-oDWs9Pom.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-B6v3DLLp.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-BjvQlFZW.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-w_dCcy6d.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-Bt_7V8FU.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
1
+ import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-BARZyA-R.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-zGk_1q8H.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-COksdUlz.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-C3cCfrHL.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-BfaIGuWs.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-hLwzY2K2.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
@@ -1 +1 @@
1
- import{LunoraError as k}from"@lunora/errors";import{createNotification as A}from"@visulima/notification";import{retryMiddleware as E,circuitBreakerMiddleware as I}from"@visulima/notification/middleware";import{fcmProvider as R}from"@visulima/notification/providers/fcm";import{webPushProvider as T}from"@visulima/notification/providers/web-push";import{n as D,p as h,a as _,b as S}from"./ssrf-host-BCpHorGa.mjs";const N=(e,n)=>{if(e.size<n)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},O="https://cloudflare-dns.com/dns-query",u=1,l=28,C=2e3,M=(e,n)=>{if(n===u){const t=h(e);return t===void 0||_(t)}return S(e.toLowerCase())},f=async(e,n,t)=>{try{const r=await fetch(`${O}?name=${encodeURIComponent(e)}&type=${String(n)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(t)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},z=async(e,n=C)=>{const t=D(e);if(t.includes(":")||h(t)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([f(t,u,n),f(t,l,n)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===u||o.type===l)&&M(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},$=e=>{let n=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{n=JSON.parse(e)}catch{return}}const t=n?.endpoint;return typeof t=="string"?t:void 0},d=new Map,j=256,H=async(e,n)=>{if(n!==void 0&&n.length>0)return;let t;try{({hostname:t}=new URL(e))}catch{return}const r=d.get(t),i=r??z(t),o=await i;if(r===void 0&&o.kind!=="unknown"&&(N(d,j),d.set(t,i)),o.kind==="private")throw new k("FORBIDDEN",`@lunora/notify: web-push endpoint host "${t}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`)},L=(e,n)=>{const t=[e.provider,n.provider].filter(i=>i!==void 0),r=[...e.recipients??[],...n.recipients??[]];return{...e,messageId:[e.messageId,n.messageId].join(","),response:[e.response,n.response],sent:e.sent&&n.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),n.timestamp.getTime())),...t.length>0?{provider:t.join(",")}:{},...r.length>0?{recipients:r}:{}}},x=(e,n)=>{if(!e.success||!n.success)return e.success||n.success?e.success?n:e:{error:new AggregateError([e.error,n.error],"@lunora/notify: both push target groups failed"),success:!1};const t=e.data===void 0||n.data===void 0?e.data??n.data:L(e.data,n.data);return t===void 0?{success:!0}:{data:t,success:!0}},B=e=>{const n=t=>{const r=t===void 0?e.fcm:e.webPush;if(r===void 0)throw new Error(t===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return r};return{channel:"push",id:"lunora-push-router",initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async t=>{const r=Array.isArray(t.to)?t.to:[t.to],i=r.map(s=>$(s));for(const s of i)s!==void 0&&await H(s,e.allowedPushOrigins);const o=r.filter((s,a)=>i[a]!==void 0),p=r.filter((s,a)=>i[a]===void 0),c=i.find(s=>s!==void 0);if(p.length===0&&c!==void 0)return n(c).send(t);if(o.length===0)return n(void 0).send(t);const m=s=>({...t,to:s.length===1&&s[0]!==void 0?s[0]:s}),w=n(c),g=n(void 0),v=async(s,a)=>s.send(m(a)),b=await Promise.allSettled([v(w,o),v(g,p)]),[P,y]=b.map(s=>s.status==="fulfilled"?s.value:{error:s.reason,success:!1});return x(P,y)}}},J=e=>{const n=e.webPush===void 0?void 0:T(e.webPush),t=e.fcm===void 0?void 0:R(e.fcm),r={};(n!==void 0||t!==void 0)&&(r.push=B({allowedPushOrigins:e.allowedPushOrigins,fcm:t,webPush:n})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook);const i=A(r);return i.use(E()).use(I()),i};export{J as buildEngine,B as routingPushProvider};
1
+ import{LunoraError as h}from"@lunora/errors";import{createNotification as A}from"@visulima/notification";import{retryMiddleware as E,circuitBreakerMiddleware as I}from"@visulima/notification/middleware";import{fcmProvider as R}from"@visulima/notification/providers/fcm";import{webPushProvider as T}from"@visulima/notification/providers/web-push";import{n as D,p as m,a as _,b as S}from"./ssrf-host-BCpHorGa.mjs";const N=(e,n)=>{if(e.size<n)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},O="https://cloudflare-dns.com/dns-query",u=1,v=28,C=2e3,M=(e,n)=>{if(n===u){const t=m(e);return t===void 0||_(t)}return S(e.toLowerCase())},f=async(e,n,t)=>{try{const r=await fetch(`${O}?name=${encodeURIComponent(e)}&type=${String(n)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(t)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},z=async(e,n=C)=>{const t=D(e);if(t.includes(":")||m(t)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([f(t,u,n),f(t,v,n)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===u||o.type===v)&&M(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},$=e=>{let n=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{n=JSON.parse(e)}catch{return}}const t=n?.endpoint;return typeof t=="string"?t:void 0},d=new Map,j=256,B=async(e,n)=>{if(n!==void 0&&n.length>0)return;let t;try{({hostname:t}=new URL(e))}catch{return}const r=d.get(t),i=r??z(t),o=await i;if(r===void 0&&o.kind!=="unknown"&&(N(d,j),d.set(t,i)),o.kind==="private")throw new h("FORBIDDEN",`@lunora/notify: web-push endpoint host "${t}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`)},H=(e,n)=>{const t=[e.provider,n.provider].filter(i=>i!==void 0),r=[...e.recipients??[],...n.recipients??[]];return{...e,messageId:[e.messageId,n.messageId].join(","),response:[e.response,n.response],sent:e.sent&&n.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),n.timestamp.getTime())),...t.length>0?{provider:t.join(",")}:{},...r.length>0?{recipients:r}:{}}},L=(e,n)=>{if(!e.success||!n.success)return e.success||n.success?e.success?n:e:{error:new AggregateError([e.error,n.error],"@lunora/notify: both push target groups failed"),success:!1};const t=e.data===void 0||n.data===void 0?e.data??n.data:H(e.data,n.data);return t===void 0?{success:!0}:{data:t,success:!0}},U=e=>{const n=t=>{const r=t===void 0?e.fcm:e.webPush;if(r===void 0)throw new Error(t===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return r};return{channel:"push",id:"lunora-push-router",initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async t=>{const r=Array.isArray(t.to)?t.to:[t.to];if(r.length===0)throw new h("BAD_REQUEST","@lunora/notify: push send has no recipients — `to` is an empty array");const i=r.map(s=>$(s));for(const s of i)s!==void 0&&await B(s,e.allowedPushOrigins);const o=r.filter((s,a)=>i[a]!==void 0),p=r.filter((s,a)=>i[a]===void 0),c=i.find(s=>s!==void 0);if(p.length===0&&c!==void 0)return n(c).send(t);if(o.length===0)return n(void 0).send(t);const w=s=>({...t,to:s.length===1&&s[0]!==void 0?s[0]:s}),g=n(c),b=n(void 0),l=async(s,a)=>s.send(w(a)),y=await Promise.allSettled([l(g,o),l(b,p)]),[P,k]=y.map(s=>s.status==="fulfilled"?s.value:{error:s.reason,success:!1});return L(P,k)}}},J=e=>{const n=e.webPush===void 0?void 0:T(e.webPush),t=e.fcm===void 0?void 0:R(e.fcm),r={};(n!==void 0||t!==void 0)&&(r.push=U({allowedPushOrigins:e.allowedPushOrigins,fcm:t,webPush:n})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook);const i=A(r);return i.use(E()).use(I()),i};export{J as buildEngine,U as routingPushProvider};
@@ -0,0 +1 @@
1
+ import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-zGk_1q8H.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-BfaIGuWs.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-hLwzY2K2.mjs";const W=250,w=(o,l)=>typeof o=="function"?o(l):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,l,d)=>o.successful?"accepted":L(l,d)?"gone":"failed",j=async(o,l,d)=>{const u=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,u[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(l,o.length)},()=>g())),u},G=(o,l)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,l),fcm:w(o.fcm,l),inApp:w(o.inApp,l),webhook:w(o.webhook,l),webPush:w(o.webPush,l)}),x=new WeakMap,Q=(o,l)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let u=d.get(l);return u===void 0&&(u={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(l,u)),u},Y=(o,l,d={})=>{const u=Q(o,l);let h;d.engine===void 0?(u.engine??=z(G(o,l)),h=u.engine):h=d.engine,u.store??=o.store?.(l);let{store:g}=u;g===void 0&&(u.fallbackStore??=F(),!d.silent&&!u.warnedNoStore&&(u.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=u.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||u.warnedNoPushOriginAllowlist||(u.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},E=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a,e.kind)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await E(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},O=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await O(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:O,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await E(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:e=>f.delete(e)},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
@@ -1 +1 @@
1
- import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-Bt_7V8FU.mjs";const c=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},O=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!c(n))throw new p("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let r;const s=()=>(r===void 0&&(r=e.prepare(`CREATE TABLE IF NOT EXISTS ${n} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_user_id_idx ON ${n} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_kind_idx ON ${n} (kind)`).run()).then(()=>{}),r.catch(()=>{r=void 0})),r),l=async t=>{await s();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:E(a)};return{delete:async t=>{await s(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await s();const a=[],d=[];t?.kind!==void 0&&(d.push(t.kind),a.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?a.push("user_id IS NULL"):(d.push(t.userId),a.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),a.push(`id > ?${d.length.toString()}`));const o=a.length===0?"":` WHERE ${a.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${o}${T}${u}`).bind(...d).all();return _.map(h=>E(h))},markStatus:async(t,a,d)=>{await s(),await e.prepare(`UPDATE ${n} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,a,d??null,Date.now()).run()},put:async t=>{await s(),await e.prepare(`INSERT INTO ${n} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const a=S(t);return a!==void 0&&a!==t.id&&await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(a).run(),await l(t.id)??t}}};export{O as d1SubscriptionStore};
1
+ import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-hLwzY2K2.mjs";const c=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},O=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!c(n))throw new p("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let r;const s=()=>(r===void 0&&(r=e.prepare(`CREATE TABLE IF NOT EXISTS ${n} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_user_id_idx ON ${n} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_kind_idx ON ${n} (kind)`).run()).then(()=>{}),r.catch(()=>{r=void 0})),r),l=async t=>{await s();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:E(a)};return{delete:async t=>{await s(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await s();const a=[],d=[];t?.kind!==void 0&&(d.push(t.kind),a.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?a.push("user_id IS NULL"):(d.push(t.userId),a.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),a.push(`id > ?${d.length.toString()}`));const o=a.length===0?"":` WHERE ${a.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${o}${T}${u}`).bind(...d).all();return _.map(h=>E(h))},markStatus:async(t,a,d)=>{await s(),await e.prepare(`UPDATE ${n} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,a,d??null,Date.now()).run()},put:async t=>{await s(),await e.prepare(`INSERT INTO ${n} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const a=S(t);return a!==void 0&&a!==t.id&&await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(a).run(),await l(t.id)??t}}};export{O as d1SubscriptionStore};
@@ -0,0 +1 @@
1
+ import{LunoraError as u}from"@lunora/errors";const l=e=>e.filter(t=>t.status==="failed").map(t=>t.id),g=(e,t)=>e.send({...t,type:"lunora.push.broadcast"}),c=(e,t,s)=>{if(t===void 0)return;if(e?.limit===void 0)return{...e,after:t};const r=e.limit-s;return r>0?{...e,after:t,limit:r}:void 0},f=async(e,t,s)=>{const r=[];for(const a of s)try{const o=await e.send(a,t);r.push({id:a,status:o.successful?"ok":"failed"})}catch(o){r.push({error:o instanceof Error?o.message:String(o),id:a,status:"failed"})}const n=l(r),i=r.length-n.length,d={failed:n.length,outcomes:r,pruned:0,sent:i,total:r.length};if(i===0&&n.length>0)throw new u("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${r.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextFilter:void 0,result:d}},p=async(e,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return f(e,t.payload,t.retryIds);const s=await e.broadcastPage(t.payload,t.filter);return{failedIds:l(s.result.outcomes),nextFilter:c(t.filter,s.nextCursor,s.result.total),result:s.result}};export{g as enqueuePushBroadcast,p as runPushBroadcastPage};
@@ -0,0 +1 @@
1
+ import{LunoraError as i}from"@lunora/errors";import{i as _}from"./ssrf-host-BCpHorGa.mjs";const b=2166136261,T=16777619,k=(t,e=b)=>{let n=e;for(let o=0;o<t.length;o+=1)n^=t.charCodeAt(o),n=Math.imul(n,T);return(n>>>0).toString(16).padStart(8,"0")},l=t=>t.toString(16).padStart(4,"0"),E=t=>{let e=8997,n=33826,o=40164,s=52210;for(let r=0;r<t.length;r+=1){e^=t.charCodeAt(r);const a=e*435,d=n*435,c=o*435+e*256,S=s*435+n*256,f=d+(a>>>16),u=c+(f>>>16),A=S+(u>>>16);e=a&65535,n=f&65535,o=u&65535,s=A&65535}return l(s)+l(o)+l(n)+l(e)},g=4096,m=2048,O=2048,p=512,h=(t,e,n)=>{const o=new TextEncoder().encode(t).length;if(o>e)throw new i("BAD_REQUEST",`@lunora/notify: register() \`${n}\` is ${o.toString()} bytes, exceeding the ${e.toString()}-byte cap`)},y=t=>{if(t===void 0)return;const e=typeof t=="object"&&t!==null?Object.getPrototypeOf(t):void 0;if(!(typeof t=="object"&&t!==null&&!Array.isArray(t)&&(e===Object.prototype||e===null)))throw new i("BAD_REQUEST","@lunora/notify: register() `metadata` must be a plain object");let o;try{o=JSON.stringify(t)}catch(r){throw new i("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is not JSON-serialisable: ${r instanceof Error?r.message:String(r)}`)}const s=new TextEncoder().encode(o).length;if(s>g)throw new i("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is ${s.toString()} bytes, exceeding the ${g.toString()}-byte cap`);return t},v=t=>`wp2_${E(t)}`,B=t=>`fcm2_${E(t)}`,w=t=>k(t),N=t=>`wp_${w(t)}`,R=t=>`fcm_${w(t)}`,M=t=>t.kind==="fcm"?t.token===void 0?void 0:R(t.token):t.endpoint===void 0?void 0:N(t.endpoint),D=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new i("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},I=(t,e)=>{let n;try{n=new URL(t)}catch{throw new i("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new i("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must use https (got "${n.protocol}")`);if(e!==void 0&&e.length>0){if(!e.includes(n.origin))throw new i("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(_(n.hostname))throw new i("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},Q=(t,e=Date.now(),n={})=>{if(t.kind===void 0?t.token!==void 0:t.kind==="fcm"){const{token:c}=t;if(typeof c!="string"||c==="")throw new i("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return h(c,O,"token"),{createdAt:e,id:B(c),kind:"fcm",lastSeenAt:e,metadata:y(t.metadata),token:c,userId:t.userId??null}}const s=D(t.subscription),{endpoint:r}=s,a=s.keys?.p256dh,d=s.keys?.auth;if(typeof r!="string"||r===""||typeof a!="string"||typeof d!="string")throw new i("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return h(r,m,"endpoint"),h(d,p,"keys.auth"),h(a,p,"keys.p256dh"),I(r,n.allowedPushOrigins),{createdAt:e,endpoint:r,id:v(r),keys:{auth:d,p256dh:a},kind:"web-push",lastSeenAt:e,metadata:y(t.metadata),userId:t.userId??null}},j=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),$=/\bhttp\s*4(?:04|10)\b/iu,F=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu,P=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,L=(t,e)=>t===void 0?!1:$.test(t)||P.test(t)?!0:e!=="web-push"&&F.test(t);export{B as fcmId,L as isGoneError,R as legacyFcmId,M as legacyIdFor,N as legacyWebPushId,Q as normalizeRegisterInput,j as targetOf,v as webPushId};
@@ -1 +1 @@
1
- import{legacyIdFor as n}from"./fcmId-Bt_7V8FU.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,a=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),c=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())a(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{c as memorySubscriptionStore};
1
+ import{legacyIdFor as n}from"./fcmId-hLwzY2K2.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,a=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),c=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())a(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{c as memorySubscriptionStore};
package/dist/web.d.mts CHANGED
@@ -6,6 +6,28 @@ interface SerializedPushSubscription {
6
6
  p256dh: string;
7
7
  };
8
8
  }
9
+ /**
10
+ * What {@link subscribeToPush} returns: the (new or reused) subscription, plus
11
+ * the endpoint of the one it replaced, when it replaced one.
12
+ */
13
+ interface SubscribeToPushResult {
14
+ /**
15
+ * The endpoint of the subscription this call dropped — set ONLY on the
16
+ * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
+ * new one minted under the current key.
18
+ *
19
+ * Send it to the server and unregister it
20
+ * (`ctx.push.unregister(webPushId(replacedEndpoint))`). The new subscription
21
+ * carries a NEW endpoint, hence a new store id, so it never upserts over the
22
+ * old row — and `403 VapidPkHashMismatch`, which every send to that row now
23
+ * answers, is correctly not a "gone" signal, so nothing prunes it either.
24
+ * Dropped instead of returned, the row is billed a POST and a write on every
25
+ * later broadcast, forever.
26
+ */
27
+ replacedEndpoint?: string;
28
+ /** The active subscription, in the serialisable shape `ctx.push.register` accepts. */
29
+ subscription: SerializedPushSubscription;
30
+ }
9
31
  /** Options for {@link subscribeToPush}. */
10
32
  interface SubscribeToPushOptions {
11
33
  /**
@@ -24,14 +46,20 @@ interface SubscribeToPushOptions {
24
46
  */
25
47
  vapidPublicKey: string;
26
48
  }
27
- /** Whether the current browser supports the Web Push flow (service workers + Push API). */
49
+ /**
50
+ * Whether the current browser supports the Web Push flow (service workers +
51
+ * Push API + the Notifications API). `Notification` is part of the check because
52
+ * {@link subscribeToPush} calls `Notification.requestPermission()` — without it
53
+ * a browser missing the API got a bare `ReferenceError` rather than the
54
+ * "not supported" error this predicate exists to produce.
55
+ */
28
56
  declare const isPushSupported: () => boolean;
29
57
  /**
30
58
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
31
59
  * returning the subscription in serialisable form. Reuses an existing subscription
32
60
  * when present. Throws if push is unsupported or the user denies permission.
33
61
  */
34
- declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SerializedPushSubscription>;
62
+ declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
35
63
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
36
64
  declare const unsubscribeFromPush: () => Promise<boolean>;
37
- export { type SerializedPushSubscription, type SubscribeToPushOptions, isPushSupported, subscribeToPush, unsubscribeFromPush };
65
+ export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.d.ts CHANGED
@@ -6,6 +6,28 @@ interface SerializedPushSubscription {
6
6
  p256dh: string;
7
7
  };
8
8
  }
9
+ /**
10
+ * What {@link subscribeToPush} returns: the (new or reused) subscription, plus
11
+ * the endpoint of the one it replaced, when it replaced one.
12
+ */
13
+ interface SubscribeToPushResult {
14
+ /**
15
+ * The endpoint of the subscription this call dropped — set ONLY on the
16
+ * VAPID-rotation path, where the stale subscription is unsubscribed and a
17
+ * new one minted under the current key.
18
+ *
19
+ * Send it to the server and unregister it
20
+ * (`ctx.push.unregister(webPushId(replacedEndpoint))`). The new subscription
21
+ * carries a NEW endpoint, hence a new store id, so it never upserts over the
22
+ * old row — and `403 VapidPkHashMismatch`, which every send to that row now
23
+ * answers, is correctly not a "gone" signal, so nothing prunes it either.
24
+ * Dropped instead of returned, the row is billed a POST and a write on every
25
+ * later broadcast, forever.
26
+ */
27
+ replacedEndpoint?: string;
28
+ /** The active subscription, in the serialisable shape `ctx.push.register` accepts. */
29
+ subscription: SerializedPushSubscription;
30
+ }
9
31
  /** Options for {@link subscribeToPush}. */
10
32
  interface SubscribeToPushOptions {
11
33
  /**
@@ -24,14 +46,20 @@ interface SubscribeToPushOptions {
24
46
  */
25
47
  vapidPublicKey: string;
26
48
  }
27
- /** Whether the current browser supports the Web Push flow (service workers + Push API). */
49
+ /**
50
+ * Whether the current browser supports the Web Push flow (service workers +
51
+ * Push API + the Notifications API). `Notification` is part of the check because
52
+ * {@link subscribeToPush} calls `Notification.requestPermission()` — without it
53
+ * a browser missing the API got a bare `ReferenceError` rather than the
54
+ * "not supported" error this predicate exists to produce.
55
+ */
28
56
  declare const isPushSupported: () => boolean;
29
57
  /**
30
58
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
31
59
  * returning the subscription in serialisable form. Reuses an existing subscription
32
60
  * when present. Throws if push is unsupported or the user denies permission.
33
61
  */
34
- declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SerializedPushSubscription>;
62
+ declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
35
63
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
36
64
  declare const unsubscribeFromPush: () => Promise<boolean>;
37
- export { type SerializedPushSubscription, type SubscribeToPushOptions, isPushSupported, subscribeToPush, unsubscribeFromPush };
65
+ export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.mjs CHANGED
@@ -1 +1 @@
1
- const l=r=>{const e=atob(r),s=new Uint8Array(e.length);for(let t=0;t<e.length;t+=1)s[t]=e.codePointAt(t)??0;return s},u=r=>{const e=r.replaceAll("-","+").replaceAll("_","/"),s=e+"=".repeat((4-e.length%4)%4);return l(s)},o=r=>u(r),b=(r,e)=>r.length===e.length&&r.every((s,t)=>s===e[t]),g=(r,e)=>{const s=r.options.applicationServerKey;return s===null?!1:b(new Uint8Array(s),o(e))},n=globalThis,a=()=>n.navigator?.serviceWorker!==void 0&&n.PushManager!==void 0,d=async r=>{if(!a())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager)");let e;if(r.serviceWorkerUrl===void 0)e=await navigator.serviceWorker.ready;else{const c=r.scope===void 0?void 0:{scope:r.scope};e=await navigator.serviceWorker.register(r.serviceWorkerUrl,c)}const s=await Notification.requestPermission();if(s!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${s}")`);const t=await e.pushManager.getSubscription();let i=null;return t!==null&&(g(t,r.vapidPublicKey)?i=t:await t.unsubscribe()),(i??await e.pushManager.subscribe({applicationServerKey:o(r.vapidPublicKey),userVisibleOnly:!0})).toJSON()},v=async()=>{if(!a())return!1;const e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return e===null?!1:e.unsubscribe()};export{a as isPushSupported,d as subscribeToPush,v as unsubscribeFromPush};
1
+ const p=r=>{const e=atob(r),t=new Uint8Array(e.length);for(let i=0;i<e.length;i+=1)t[i]=e.codePointAt(i)??0;return t},b=r=>{const e=r.replaceAll("-","+").replaceAll("_","/"),t=e+"=".repeat((4-e.length%4)%4);return p(t)},a=r=>b(r),d=(r,e)=>r.length===e.length&&r.every((t,i)=>t===e[i]),g=(r,e)=>{const t=r.options.applicationServerKey;return t===null?!1:d(new Uint8Array(t),a(e))},s=globalThis,c=()=>s.navigator?.serviceWorker!==void 0&&s.PushManager!==void 0&&s.Notification!==void 0,v=async r=>{if(!c())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager + Notification)");let e;if(r.serviceWorkerUrl===void 0)e=await navigator.serviceWorker.ready;else{const u=r.scope===void 0?void 0:{scope:r.scope};e=await navigator.serviceWorker.register(r.serviceWorkerUrl,u)}const t=await Notification.requestPermission();if(t!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${t}")`);const i=await e.pushManager.getSubscription();let n=null,o;i!==null&&(g(i,r.vapidPublicKey)?n=i:(o=i.endpoint,await i.unsubscribe()));const l=n??await e.pushManager.subscribe({applicationServerKey:a(r.vapidPublicKey),userVisibleOnly:!0});return{replacedEndpoint:o,subscription:l.toJSON()}},f=async()=>{if(!c())return!1;const e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return e===null?!1:e.unsubscribe()};export{c as isPushSupported,v as subscribeToPush,f as unsubscribeFromPush};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.35",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-oDWs9Pom.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-w_dCcy6d.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-Bt_7V8FU.mjs";const W=250,w=(o,u)=>typeof o=="function"?o(u):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,u)=>o.successful?"accepted":L(u)?"gone":"failed",j=async(o,u,d)=>{const l=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,l[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(u,o.length)},()=>g())),l},G=(o,u)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,u),fcm:w(o.fcm,u),inApp:w(o.inApp,u),webhook:w(o.webhook,u),webPush:w(o.webPush,u)}),x=new WeakMap,Q=(o,u)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let l=d.get(u);return l===void 0&&(l={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(u,l)),l},Y=(o,u,d={})=>{const l=Q(o,u);let h;d.engine===void 0?(l.engine??=z(G(o,u)),h=l.engine):h=d.engine,l.store??=o.store?.(u);let{store:g}=l;g===void 0&&(l.fallbackStore??=F(),!d.silent&&!l.warnedNoStore&&(l.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=l.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||l.warnedNoPushOriginAllowlist||(l.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},E=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await E(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},O=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await O(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:O,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await E(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:e=>f.delete(e)},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};
@@ -1 +0,0 @@
1
- import{LunoraError as i}from"@lunora/errors";const u=e=>e.filter(t=>t.status==="failed").map(t=>t.id),h=(e,t)=>e.send({...t,type:"lunora.push.broadcast"}),c=async(e,t,s)=>{const r=[];for(const a of s)try{const o=await e.send(a,t);r.push({id:a,status:o.successful?"ok":"failed"})}catch(o){r.push({error:o instanceof Error?o.message:String(o),id:a,status:"failed"})}const n=u(r),d=r.length-n.length,l={failed:n.length,outcomes:r,pruned:0,sent:d,total:r.length};if(n.length>0)throw new i("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${r.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextCursor:void 0,result:l}},g=async(e,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return c(e,t.payload,t.retryIds);const s=await e.broadcastPage(t.payload,t.filter);return{failedIds:u(s.result.outcomes),nextCursor:s.nextCursor,result:s.result}};export{h as enqueuePushBroadcast,g as runPushBroadcastPage};
@@ -1 +0,0 @@
1
- import{LunoraError as s}from"@lunora/errors";import{i as A}from"./ssrf-host-BCpHorGa.mjs";const b=2166136261,_=16777619,m=(t,e=b)=>{let n=e;for(let o=0;o<t.length;o+=1)n^=t.charCodeAt(o),n=Math.imul(n,_);return(n>>>0).toString(16).padStart(8,"0")},d=t=>t.toString(16).padStart(4,"0"),p=t=>{let e=8997,n=33826,o=40164,r=52210;for(let i=0;i<t.length;i+=1){const a=t.codePointAt(i)??0;e^=a&65535,n^=a>>>16&65535;const c=e*435,y=n*435,E=o*435+e*256,w=r*435+n*256,l=y+(c>>>16),u=E+(l>>>16),S=w+(u>>>16);e=c&65535,n=l&65535,o=u&65535,r=S&65535}return d(r)+d(o)+d(n)+d(e)},f=4096,h=t=>{if(t===void 0)return;const e=typeof t=="object"&&t!==null?Object.getPrototypeOf(t):void 0;if(!(typeof t=="object"&&t!==null&&!Array.isArray(t)&&(e===Object.prototype||e===null)))throw new s("BAD_REQUEST","@lunora/notify: register() `metadata` must be a plain object");let o;try{o=JSON.stringify(t)}catch(i){throw new s("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is not JSON-serialisable: ${i instanceof Error?i.message:String(i)}`)}const r=new TextEncoder().encode(o).length;if(r>f)throw new s("BAD_REQUEST",`@lunora/notify: register() \`metadata\` is ${r.toString()} bytes, exceeding the ${f.toString()}-byte cap`);return t},T=t=>`wp2_${p(t)}`,O=t=>`fcm2_${p(t)}`,g=t=>m(t),k=t=>`wp_${g(t)}`,v=t=>`fcm_${g(t)}`,U=t=>t.kind==="fcm"?t.token===void 0?void 0:v(t.token):t.endpoint===void 0?void 0:k(t.endpoint),R=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new s("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},B=(t,e)=>{let n;try{n=new URL(t)}catch{throw new s("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new s("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must use https (got "${n.protocol}")`);if(e!==void 0&&e.length>0){if(!e.includes(n.origin))throw new s("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(A(n.hostname))throw new s("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},x=(t,e=Date.now(),n={})=>{if("token"in t){const{token:c}=t;if(typeof c!="string"||c==="")throw new s("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return{createdAt:e,id:O(c),kind:"fcm",lastSeenAt:e,metadata:h(t.metadata),token:c,userId:t.userId??null}}const o=R(t.subscription),{endpoint:r}=o,i=o.keys?.p256dh,a=o.keys?.auth;if(typeof r!="string"||r===""||typeof i!="string"||typeof a!="string")throw new s("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return B(r,n.allowedPushOrigins),{createdAt:e,endpoint:r,id:T(r),keys:{auth:a,p256dh:i},kind:"web-push",lastSeenAt:e,metadata:h(t.metadata),userId:t.userId??null}},F=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),D=/\bhttp\s*4(?:04|10)\b/iu,I=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu,N=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,Q=t=>t===void 0?!1:D.test(t)||I.test(t)||N.test(t);export{O as fcmId,Q as isGoneError,v as legacyFcmId,U as legacyIdFor,k as legacyWebPushId,x as normalizeRegisterInput,F as targetOf,T as webPushId};