@lunora/notify 1.0.0-alpha.33 → 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 +29 -12
- package/dist/index.d.mts +62 -31
- package/dist/index.d.ts +62 -31
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/{buildEngine-oDWs9Pom.mjs → buildEngine-zGk_1q8H.mjs} +1 -1
- package/dist/packem_shared/createNotify-BARZyA-R.mjs +1 -0
- package/dist/packem_shared/{d1SubscriptionStore-B7f20Nqr.mjs → d1SubscriptionStore-C3cCfrHL.mjs} +1 -1
- package/dist/packem_shared/enqueuePushBroadcast-COksdUlz.mjs +1 -0
- package/dist/packem_shared/fcmId-hLwzY2K2.mjs +1 -0
- package/dist/packem_shared/{memorySubscriptionStore-DdVxq2zI.mjs → memorySubscriptionStore-BfaIGuWs.mjs} +1 -1
- package/dist/web.d.mts +31 -3
- package/dist/web.d.ts +31 -3
- package/dist/web.mjs +1 -1
- package/package.json +2 -2
- package/dist/packem_shared/createNotify-B27o09vm.mjs +0 -1
- package/dist/packem_shared/enqueuePushBroadcast-B6v3DLLp.mjs +0 -1
- package/dist/packem_shared/fcmId-DNM-rzkd.mjs +0 -1
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
|
-
|
|
49
|
-
|
|
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,
|
|
104
|
+
const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
91
105
|
|
|
92
|
-
// One message = ONE bounded page. Discarding `
|
|
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
|
-
|
|
95
|
-
|
|
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.
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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>;
|
|
@@ -287,7 +291,7 @@ type PushContent = Omit<PushPayload, "to">;
|
|
|
287
291
|
* single-channel convenience senders for the edge-safe channels.
|
|
288
292
|
*/
|
|
289
293
|
interface LunoraNotify {
|
|
290
|
-
/**
|
|
294
|
+
/** Post to a chat channel (Slack/Discord/Teams/Telegram). */
|
|
291
295
|
chat: (payload: ChatPayload) => Promise<Receipt>;
|
|
292
296
|
/** Deliver an in-app inbox notification. */
|
|
293
297
|
inApp: (payload: InAppPayload) => Promise<Receipt>;
|
|
@@ -295,7 +299,7 @@ interface LunoraNotify {
|
|
|
295
299
|
push: LunoraPush;
|
|
296
300
|
/** Deliver a multi-channel message (one payload per channel). */
|
|
297
301
|
send: (message: NotificationMessage) => Promise<Receipt[]>;
|
|
298
|
-
/**
|
|
302
|
+
/** Send an outbound webhook. */
|
|
299
303
|
webhook: (payload: WebhookPayload) => Promise<Receipt>;
|
|
300
304
|
}
|
|
301
305
|
/**
|
|
@@ -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: `
|
|
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
|
|
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,
|
|
604
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
585
605
|
*
|
|
586
|
-
* if (
|
|
606
|
+
* if (nextFilter !== undefined) {
|
|
587
607
|
* // More pages remain — enqueue the continuation. Each message still
|
|
588
|
-
* // does only ONE bounded page of work.
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
*
|
|
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 `
|
|
653
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
636
654
|
* both come back instead.
|
|
637
|
-
* - The CALLER re-enqueues: `filter
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* the
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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:
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* last resort only, so a transient
|
|
767
|
-
* (a cert/session expiry) can never
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
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>;
|
|
@@ -287,7 +291,7 @@ type PushContent = Omit<PushPayload, "to">;
|
|
|
287
291
|
* single-channel convenience senders for the edge-safe channels.
|
|
288
292
|
*/
|
|
289
293
|
interface LunoraNotify {
|
|
290
|
-
/**
|
|
294
|
+
/** Post to a chat channel (Slack/Discord/Teams/Telegram). */
|
|
291
295
|
chat: (payload: ChatPayload) => Promise<Receipt>;
|
|
292
296
|
/** Deliver an in-app inbox notification. */
|
|
293
297
|
inApp: (payload: InAppPayload) => Promise<Receipt>;
|
|
@@ -295,7 +299,7 @@ interface LunoraNotify {
|
|
|
295
299
|
push: LunoraPush;
|
|
296
300
|
/** Deliver a multi-channel message (one payload per channel). */
|
|
297
301
|
send: (message: NotificationMessage) => Promise<Receipt[]>;
|
|
298
|
-
/**
|
|
302
|
+
/** Send an outbound webhook. */
|
|
299
303
|
webhook: (payload: WebhookPayload) => Promise<Receipt>;
|
|
300
304
|
}
|
|
301
305
|
/**
|
|
@@ -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: `
|
|
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
|
|
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,
|
|
604
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
585
605
|
*
|
|
586
|
-
* if (
|
|
606
|
+
* if (nextFilter !== undefined) {
|
|
587
607
|
* // More pages remain — enqueue the continuation. Each message still
|
|
588
|
-
* // does only ONE bounded page of work.
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
*
|
|
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 `
|
|
653
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
636
654
|
* both come back instead.
|
|
637
|
-
* - The CALLER re-enqueues: `filter
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* the
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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:
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* last resort only, so a transient
|
|
767
|
-
* (a cert/session expiry) can never
|
|
768
|
-
|
|
769
|
-
|
|
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-
|
|
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
|
|
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};
|
package/dist/packem_shared/{d1SubscriptionStore-B7f20Nqr.mjs → d1SubscriptionStore-C3cCfrHL.mjs}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-
|
|
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-
|
|
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
|
-
/**
|
|
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<
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
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.
|
|
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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"access": "public"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
52
|
+
"@lunora/errors": "1.0.0-alpha.30",
|
|
53
53
|
"@visulima/notification": "1.0.12"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
@@ -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-DdVxq2zI.mjs";import{normalizeRegisterInput as R,targetOf as B,isGoneError as L}from"./fcmId-DNM-rzkd.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.codePointAt(o)??0,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};
|