@lunora/notify 1.0.0-alpha.30 → 1.0.0-alpha.32
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 +23 -21
- package/dist/index.d.mts +78 -25
- package/dist/index.d.ts +78 -25
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/createNotify-B27o09vm.mjs +1 -0
- package/dist/packem_shared/{d1SubscriptionStore-s-AJH4hS.mjs → d1SubscriptionStore-B7f20Nqr.mjs} +1 -1
- package/dist/packem_shared/{defineNotify-CUi2k7pP.mjs → defineNotify-DVPpVkU0.mjs} +1 -1
- package/dist/packem_shared/{fcmId-Dwh_R5xe.mjs → fcmId-DNM-rzkd.mjs} +1 -1
- package/dist/packem_shared/{memorySubscriptionStore-FY8yANEM.mjs → memorySubscriptionStore-DdVxq2zI.mjs} +1 -1
- package/package.json +2 -2
- package/dist/packem_shared/createNotify-BDE42tvH.mjs +0 -1
package/README.md
CHANGED
|
@@ -45,11 +45,8 @@ await client.mutation("registerDevice", { subscription });
|
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
47
|
// lunora/registerDevice.ts (a mutation — storage write is fine here)
|
|
48
|
-
export const registerDevice = mutation({
|
|
49
|
-
|
|
50
|
-
handler: async (ctx, { subscription }) => {
|
|
51
|
-
await ctx.push.register({ subscription, userId: ctx.auth?.userId });
|
|
52
|
-
},
|
|
48
|
+
export const registerDevice = mutation.input({ subscription: v.any() }).mutation(async ({ ctx, args: { subscription } }) => {
|
|
49
|
+
await ctx.push.register({ subscription, userId: ctx.auth?.userId });
|
|
53
50
|
});
|
|
54
51
|
```
|
|
55
52
|
|
|
@@ -58,12 +55,9 @@ export const registerDevice = mutation({
|
|
|
58
55
|
Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
|
|
59
56
|
|
|
60
57
|
```ts
|
|
61
|
-
export const announce = action({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = await ctx.push.broadcast({ title, body });
|
|
65
|
-
// result: { total, sent, pruned, failed, outcomes }
|
|
66
|
-
},
|
|
58
|
+
export const announce = action.input({ title: v.string(), body: v.string() }).action(async ({ ctx, args: { title, body } }) => {
|
|
59
|
+
const result = await ctx.push.broadcast({ title, body });
|
|
60
|
+
// result: { total, sent, pruned, failed, outcomes }
|
|
67
61
|
});
|
|
68
62
|
```
|
|
69
63
|
|
|
@@ -90,27 +84,35 @@ Move a large broadcast off the request path with `@lunora/queue`:
|
|
|
90
84
|
// producer (mutation/action)
|
|
91
85
|
await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
|
|
92
86
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
// lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
88
|
+
// `ctx.push` and `ctx.queues` exist.
|
|
89
|
+
export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
|
|
90
|
+
const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
|
|
96
91
|
|
|
97
92
|
// One message = ONE bounded page. Discarding `nextCursor` delivers only the
|
|
98
93
|
// first page (default 250 devices) and reports success for the whole audience.
|
|
99
94
|
if (nextCursor !== undefined) {
|
|
100
|
-
await enqueuePushBroadcast(ctx.queues.push, {
|
|
101
|
-
payload: message.body.payload,
|
|
102
|
-
filter: { ...message.body.filter, after: nextCursor },
|
|
103
|
-
});
|
|
95
|
+
await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: { ...job.filter, after: nextCursor } });
|
|
104
96
|
}
|
|
105
97
|
|
|
106
98
|
// Redeliver ONLY the recipients that failed — a retry of the whole page would
|
|
107
99
|
// re-POST everyone it already reached.
|
|
108
100
|
if (failedIds.length > 0) {
|
|
109
|
-
await enqueuePushBroadcast(ctx.queues.push, { payload:
|
|
101
|
+
await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
110
102
|
}
|
|
103
|
+
});
|
|
111
104
|
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
// lunora/queues.ts — a `QueueRunContext` is exactly `{ env, log, run }`: no
|
|
106
|
+
// `ctx.push`, no `ctx.queues`. The consumer hands each message to the action above.
|
|
107
|
+
// (Note the handler signature: `(context, batch)`, in that order.)
|
|
108
|
+
export const push = defineQueue<PushBroadcastJob>({
|
|
109
|
+
handler: async (context, batch) => {
|
|
110
|
+
for (const message of batch.messages) {
|
|
111
|
+
await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
112
|
+
message.ack();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
## Subscription storage
|
package/dist/index.d.mts
CHANGED
|
@@ -111,7 +111,7 @@ interface SubscriptionFilter {
|
|
|
111
111
|
* subscriptions reached across every internally-walked page (still
|
|
112
112
|
* deliberately left unset by default — it must reach every matched
|
|
113
113
|
* device); the PER-PAGE batch size is a separate, independent knob (see
|
|
114
|
-
* `
|
|
114
|
+
* `defineNotify`'s `broadcastPageSize`, default 250) so a caller
|
|
115
115
|
* that sets `limit` to bound the audience doesn't also have to reason
|
|
116
116
|
* about page sizing.
|
|
117
117
|
*
|
|
@@ -244,7 +244,7 @@ interface LunoraPush {
|
|
|
244
244
|
*
|
|
245
245
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
246
246
|
* keyset-paginated on the subscription `id`) so a huge audience is never
|
|
247
|
-
* materialized wholesale in the isolate — see `
|
|
247
|
+
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
248
248
|
* `broadcastPageSize`. This call still processes the WHOLE matched
|
|
249
249
|
* audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
250
250
|
* directly (as `runPushBroadcastPage` does) to bound a single queue message
|
|
@@ -253,7 +253,7 @@ interface LunoraPush {
|
|
|
253
253
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
254
254
|
/**
|
|
255
255
|
* Fan-out a push to ONE bounded page of stored subscriptions matching
|
|
256
|
-
* `filter` (page size: `
|
|
256
|
+
* `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
|
|
257
257
|
* 250, capped by `filter.limit` when set). Same delivery semantics as
|
|
258
258
|
* {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
|
|
259
259
|
* scoped to a single page; returns the page's own {@link BroadcastResult}
|
|
@@ -323,11 +323,32 @@ interface NotifyConfig {
|
|
|
323
323
|
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
324
324
|
*/
|
|
325
325
|
allowedPushOrigins?: string[];
|
|
326
|
+
/**
|
|
327
|
+
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
328
|
+
* subscription store (default 250, minimum 1). Each page is fetched,
|
|
329
|
+
* delivered, and counted before the next page's store round trip, so a huge
|
|
330
|
+
* audience is never materialized wholesale in the isolate. Also the
|
|
331
|
+
* per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
|
|
332
|
+
*
|
|
333
|
+
* Declared here, and not only on `createNotify`'s third argument, because
|
|
334
|
+
* this file is the only handle an app has: the sole production constructor is
|
|
335
|
+
* codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
|
|
336
|
+
* that lives only on those options is unsettable by every Lunora app —
|
|
337
|
+
* while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
|
|
338
|
+
* size pages.
|
|
339
|
+
*/
|
|
340
|
+
broadcastPageSize?: number;
|
|
326
341
|
/**
|
|
327
342
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
328
343
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
329
344
|
*/
|
|
330
345
|
chat?: (env: NotifyEnv) => unknown;
|
|
346
|
+
/**
|
|
347
|
+
* Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
|
|
348
|
+
* Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
|
|
349
|
+
* app can reach it.
|
|
350
|
+
*/
|
|
351
|
+
concurrency?: number;
|
|
331
352
|
/** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
|
|
332
353
|
fcm?: FcmConfig | FcmConfigFactory;
|
|
333
354
|
/** Optional in-app inbox provider factory. Edge-safe. */
|
|
@@ -416,14 +437,18 @@ interface CreateNotifyOptions {
|
|
|
416
437
|
/**
|
|
417
438
|
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
418
439
|
* subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* (
|
|
423
|
-
*
|
|
440
|
+
*
|
|
441
|
+
* A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
|
|
442
|
+
* instead** — the sole production call is codegen's fixed
|
|
443
|
+
* `createNotify(definition, env, { log, metrics })`, so nothing an app writes
|
|
444
|
+
* reaches this object. Set here it wins over the definition's value.
|
|
424
445
|
*/
|
|
425
446
|
broadcastPageSize?: number;
|
|
426
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
|
|
449
|
+
* apps set `concurrency` on `defineNotify` — see
|
|
450
|
+
* {@link CreateNotifyOptions.broadcastPageSize}.
|
|
451
|
+
*/
|
|
427
452
|
concurrency?: number;
|
|
428
453
|
/**
|
|
429
454
|
* Override the assembled `@visulima/notification` engine. Advanced/testing
|
|
@@ -548,31 +573,45 @@ interface QueueProducerLike {
|
|
|
548
573
|
* across MULTIPLE messages (one bounded page per message), not one.
|
|
549
574
|
*
|
|
550
575
|
* ```ts
|
|
551
|
-
* //
|
|
552
|
-
*
|
|
576
|
+
* // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
577
|
+
* // `ctx.push` and `ctx.queues` exist.
|
|
578
|
+
* import { internalAction, v } from "./_generated/server";
|
|
579
|
+
* import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
|
|
553
580
|
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
* const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push,
|
|
581
|
+
* export const deliverPage = internalAction
|
|
582
|
+
* .input({ job: v.any() })
|
|
583
|
+
* .action(async ({ args: { job }, ctx }) => {
|
|
584
|
+
* const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
|
|
558
585
|
*
|
|
559
586
|
* if (nextCursor !== undefined) {
|
|
560
587
|
* // More pages remain — enqueue the continuation. Each message still
|
|
561
588
|
* // does only ONE bounded page of work.
|
|
562
589
|
* await enqueuePushBroadcast(ctx.queues.push, {
|
|
563
|
-
*
|
|
564
|
-
*
|
|
590
|
+
* filter: { ...job.filter, after: nextCursor },
|
|
591
|
+
* payload: job.payload,
|
|
565
592
|
* });
|
|
566
593
|
* }
|
|
567
594
|
*
|
|
568
595
|
* if (failedIds.length > 0) {
|
|
569
596
|
* // Redeliver ONLY the recipients that failed — never the whole page.
|
|
570
|
-
* await enqueuePushBroadcast(ctx.queues.push, { payload:
|
|
597
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
598
|
+
* }
|
|
599
|
+
* });
|
|
600
|
+
*
|
|
601
|
+
* // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
|
|
602
|
+
* // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
|
|
603
|
+
* // `(context, batch)`, in that order). It hands each message to the action above.
|
|
604
|
+
* export const push = defineQueue<PushBroadcastJob>({
|
|
605
|
+
* handler: async (context, batch) => {
|
|
606
|
+
* for (const message of batch.messages) {
|
|
607
|
+
* await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
608
|
+
* message.ack();
|
|
571
609
|
* }
|
|
610
|
+
* },
|
|
611
|
+
* });
|
|
572
612
|
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* }});
|
|
613
|
+
* // in a mutation/action, to start it:
|
|
614
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
|
|
576
615
|
* ```
|
|
577
616
|
*/
|
|
578
617
|
declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
|
|
@@ -584,8 +623,8 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
584
623
|
*
|
|
585
624
|
* RETRY / CONTINUATION SEMANTICS:
|
|
586
625
|
*
|
|
587
|
-
* - A job processes exactly ONE bounded page (see `
|
|
588
|
-
*
|
|
626
|
+
* - A job processes exactly ONE bounded page (see `defineNotify`'s
|
|
627
|
+
* `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
|
|
589
628
|
* keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
|
|
590
629
|
* — so per-message work is bounded regardless of total audience size.
|
|
591
630
|
* - A page NEVER throws for a partial failure. Throwing discarded the page's
|
|
@@ -595,7 +634,7 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
595
634
|
* every already-delivered recipient on each retry, dead-letter, and leave
|
|
596
635
|
* every LATER page unreached. The page's `nextCursor` and its `failedIds`
|
|
597
636
|
* both come back instead.
|
|
598
|
-
* - The CALLER
|
|
637
|
+
* - The CALLER re-enqueues: `filter.after:
|
|
599
638
|
* nextCursor` while more pages remain, and a `retryIds: failedIds` job when
|
|
600
639
|
* any recipient failed. `@lunora/notify` cannot do it itself — it has no
|
|
601
640
|
* `@lunora/queue` dependency (the seam stays structural) and no reference to
|
|
@@ -660,7 +699,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
660
699
|
*/
|
|
661
700
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
662
701
|
/**
|
|
663
|
-
* Stable store id for a web-push endpoint
|
|
702
|
+
* Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
|
|
703
|
+
* so re-registering the same device upserts rather than duplicates.
|
|
704
|
+
*
|
|
705
|
+
* The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
|
|
706
|
+
* is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
|
|
707
|
+
* every existing subscription — the old row goes dark and the device
|
|
708
|
+
* re-registers as a duplicate — so the implementation must have exactly one
|
|
709
|
+
* home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
|
|
710
|
+
* `packages/replica/__tests__/apply-diff.test.ts`.
|
|
711
|
+
*
|
|
712
|
+
* Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
|
|
713
|
+
* collides with ~68% probability (birthday bound), and a collision silently
|
|
714
|
+
* overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
|
|
715
|
+
* so the wrong user gets the push and the victim goes dark. 64 bits drops that to
|
|
716
|
+
* negligible at any realistic device count.
|
|
664
717
|
*
|
|
665
718
|
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
666
719
|
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
package/dist/index.d.ts
CHANGED
|
@@ -111,7 +111,7 @@ interface SubscriptionFilter {
|
|
|
111
111
|
* subscriptions reached across every internally-walked page (still
|
|
112
112
|
* deliberately left unset by default — it must reach every matched
|
|
113
113
|
* device); the PER-PAGE batch size is a separate, independent knob (see
|
|
114
|
-
* `
|
|
114
|
+
* `defineNotify`'s `broadcastPageSize`, default 250) so a caller
|
|
115
115
|
* that sets `limit` to bound the audience doesn't also have to reason
|
|
116
116
|
* about page sizing.
|
|
117
117
|
*
|
|
@@ -244,7 +244,7 @@ interface LunoraPush {
|
|
|
244
244
|
*
|
|
245
245
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
246
246
|
* keyset-paginated on the subscription `id`) so a huge audience is never
|
|
247
|
-
* materialized wholesale in the isolate — see `
|
|
247
|
+
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
248
248
|
* `broadcastPageSize`. This call still processes the WHOLE matched
|
|
249
249
|
* audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
250
250
|
* directly (as `runPushBroadcastPage` does) to bound a single queue message
|
|
@@ -253,7 +253,7 @@ interface LunoraPush {
|
|
|
253
253
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
254
254
|
/**
|
|
255
255
|
* Fan-out a push to ONE bounded page of stored subscriptions matching
|
|
256
|
-
* `filter` (page size: `
|
|
256
|
+
* `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
|
|
257
257
|
* 250, capped by `filter.limit` when set). Same delivery semantics as
|
|
258
258
|
* {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
|
|
259
259
|
* scoped to a single page; returns the page's own {@link BroadcastResult}
|
|
@@ -323,11 +323,32 @@ interface NotifyConfig {
|
|
|
323
323
|
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
324
324
|
*/
|
|
325
325
|
allowedPushOrigins?: string[];
|
|
326
|
+
/**
|
|
327
|
+
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
328
|
+
* subscription store (default 250, minimum 1). Each page is fetched,
|
|
329
|
+
* delivered, and counted before the next page's store round trip, so a huge
|
|
330
|
+
* audience is never materialized wholesale in the isolate. Also the
|
|
331
|
+
* per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
|
|
332
|
+
*
|
|
333
|
+
* Declared here, and not only on `createNotify`'s third argument, because
|
|
334
|
+
* this file is the only handle an app has: the sole production constructor is
|
|
335
|
+
* codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
|
|
336
|
+
* that lives only on those options is unsettable by every Lunora app —
|
|
337
|
+
* while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
|
|
338
|
+
* size pages.
|
|
339
|
+
*/
|
|
340
|
+
broadcastPageSize?: number;
|
|
326
341
|
/**
|
|
327
342
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
328
343
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
329
344
|
*/
|
|
330
345
|
chat?: (env: NotifyEnv) => unknown;
|
|
346
|
+
/**
|
|
347
|
+
* Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
|
|
348
|
+
* Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
|
|
349
|
+
* app can reach it.
|
|
350
|
+
*/
|
|
351
|
+
concurrency?: number;
|
|
331
352
|
/** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
|
|
332
353
|
fcm?: FcmConfig | FcmConfigFactory;
|
|
333
354
|
/** Optional in-app inbox provider factory. Edge-safe. */
|
|
@@ -416,14 +437,18 @@ interface CreateNotifyOptions {
|
|
|
416
437
|
/**
|
|
417
438
|
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
418
439
|
* subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* (
|
|
423
|
-
*
|
|
440
|
+
*
|
|
441
|
+
* A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
|
|
442
|
+
* instead** — the sole production call is codegen's fixed
|
|
443
|
+
* `createNotify(definition, env, { log, metrics })`, so nothing an app writes
|
|
444
|
+
* reaches this object. Set here it wins over the definition's value.
|
|
424
445
|
*/
|
|
425
446
|
broadcastPageSize?: number;
|
|
426
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
|
|
449
|
+
* apps set `concurrency` on `defineNotify` — see
|
|
450
|
+
* {@link CreateNotifyOptions.broadcastPageSize}.
|
|
451
|
+
*/
|
|
427
452
|
concurrency?: number;
|
|
428
453
|
/**
|
|
429
454
|
* Override the assembled `@visulima/notification` engine. Advanced/testing
|
|
@@ -548,31 +573,45 @@ interface QueueProducerLike {
|
|
|
548
573
|
* across MULTIPLE messages (one bounded page per message), not one.
|
|
549
574
|
*
|
|
550
575
|
* ```ts
|
|
551
|
-
* //
|
|
552
|
-
*
|
|
576
|
+
* // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
577
|
+
* // `ctx.push` and `ctx.queues` exist.
|
|
578
|
+
* import { internalAction, v } from "./_generated/server";
|
|
579
|
+
* import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
|
|
553
580
|
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
* const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push,
|
|
581
|
+
* export const deliverPage = internalAction
|
|
582
|
+
* .input({ job: v.any() })
|
|
583
|
+
* .action(async ({ args: { job }, ctx }) => {
|
|
584
|
+
* const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, job);
|
|
558
585
|
*
|
|
559
586
|
* if (nextCursor !== undefined) {
|
|
560
587
|
* // More pages remain — enqueue the continuation. Each message still
|
|
561
588
|
* // does only ONE bounded page of work.
|
|
562
589
|
* await enqueuePushBroadcast(ctx.queues.push, {
|
|
563
|
-
*
|
|
564
|
-
*
|
|
590
|
+
* filter: { ...job.filter, after: nextCursor },
|
|
591
|
+
* payload: job.payload,
|
|
565
592
|
* });
|
|
566
593
|
* }
|
|
567
594
|
*
|
|
568
595
|
* if (failedIds.length > 0) {
|
|
569
596
|
* // Redeliver ONLY the recipients that failed — never the whole page.
|
|
570
|
-
* await enqueuePushBroadcast(ctx.queues.push, { payload:
|
|
597
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
598
|
+
* }
|
|
599
|
+
* });
|
|
600
|
+
*
|
|
601
|
+
* // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
|
|
602
|
+
* // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
|
|
603
|
+
* // `(context, batch)`, in that order). It hands each message to the action above.
|
|
604
|
+
* export const push = defineQueue<PushBroadcastJob>({
|
|
605
|
+
* handler: async (context, batch) => {
|
|
606
|
+
* for (const message of batch.messages) {
|
|
607
|
+
* await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
608
|
+
* message.ack();
|
|
571
609
|
* }
|
|
610
|
+
* },
|
|
611
|
+
* });
|
|
572
612
|
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* }});
|
|
613
|
+
* // in a mutation/action, to start it:
|
|
614
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
|
|
576
615
|
* ```
|
|
577
616
|
*/
|
|
578
617
|
declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
|
|
@@ -584,8 +623,8 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
584
623
|
*
|
|
585
624
|
* RETRY / CONTINUATION SEMANTICS:
|
|
586
625
|
*
|
|
587
|
-
* - A job processes exactly ONE bounded page (see `
|
|
588
|
-
*
|
|
626
|
+
* - A job processes exactly ONE bounded page (see `defineNotify`'s
|
|
627
|
+
* `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
|
|
589
628
|
* keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
|
|
590
629
|
* — so per-message work is bounded regardless of total audience size.
|
|
591
630
|
* - A page NEVER throws for a partial failure. Throwing discarded the page's
|
|
@@ -595,7 +634,7 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
595
634
|
* every already-delivered recipient on each retry, dead-letter, and leave
|
|
596
635
|
* every LATER page unreached. The page's `nextCursor` and its `failedIds`
|
|
597
636
|
* both come back instead.
|
|
598
|
-
* - The CALLER
|
|
637
|
+
* - The CALLER re-enqueues: `filter.after:
|
|
599
638
|
* nextCursor` while more pages remain, and a `retryIds: failedIds` job when
|
|
600
639
|
* any recipient failed. `@lunora/notify` cannot do it itself — it has no
|
|
601
640
|
* `@lunora/queue` dependency (the seam stays structural) and no reference to
|
|
@@ -660,7 +699,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
660
699
|
*/
|
|
661
700
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
662
701
|
/**
|
|
663
|
-
* Stable store id for a web-push endpoint
|
|
702
|
+
* Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
|
|
703
|
+
* so re-registering the same device upserts rather than duplicates.
|
|
704
|
+
*
|
|
705
|
+
* The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
|
|
706
|
+
* is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
|
|
707
|
+
* every existing subscription — the old row goes dark and the device
|
|
708
|
+
* re-registers as a duplicate — so the implementation must have exactly one
|
|
709
|
+
* home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
|
|
710
|
+
* `packages/replica/__tests__/apply-diff.test.ts`.
|
|
711
|
+
*
|
|
712
|
+
* Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
|
|
713
|
+
* collides with ~68% probability (birthday bound), and a collision silently
|
|
714
|
+
* overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
|
|
715
|
+
* so the wrong user gets the push and the victim goes dark. 64 bits drops that to
|
|
716
|
+
* negligible at any realistic device count.
|
|
664
717
|
*
|
|
665
718
|
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
666
719
|
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
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-
|
|
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-B27o09vm.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-B7f20Nqr.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-DdVxq2zI.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-DNM-rzkd.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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
package/dist/packem_shared/{d1SubscriptionStore-s-AJH4hS.mjs → d1SubscriptionStore-B7f20Nqr.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-DNM-rzkd.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 +1 @@
|
|
|
1
|
-
const t=e=>{if(e.webPush!==void 0&&typeof e.webPush!="function"&&typeof e.webPush!="object")throw new TypeError("defineNotify: `webPush` must be a WebPushConfig object or an `(env) => WebPushConfig` function");if(e.fcm!==void 0&&typeof e.fcm!="function"&&typeof e.fcm!="object")throw new TypeError("defineNotify: `fcm` must be an FcmConfig object or an `(env) => FcmConfig` function");if(e.store!==void 0&&typeof e.store!="function")throw new TypeError("defineNotify: `store` must be a function `(env) => SubscriptionStore` when provided");if(e.allowedPushOrigins!==void 0&&(!Array.isArray(e.allowedPushOrigins)||e.allowedPushOrigins.some(o=>typeof o!="string")))throw new TypeError('defineNotify: `allowedPushOrigins` must be an array of origin strings (e.g. ["https://fcm.googleapis.com"]) when provided');if(e.webPush===void 0&&e.fcm===void 0)throw new TypeError("defineNotify: configure at least one push channel — `webPush` and/or `fcm`");return{...e,isLunoraNotify:!0}},
|
|
1
|
+
const t=e=>{if(e.webPush!==void 0&&typeof e.webPush!="function"&&typeof e.webPush!="object")throw new TypeError("defineNotify: `webPush` must be a WebPushConfig object or an `(env) => WebPushConfig` function");if(e.fcm!==void 0&&typeof e.fcm!="function"&&typeof e.fcm!="object")throw new TypeError("defineNotify: `fcm` must be an FcmConfig object or an `(env) => FcmConfig` function");if(e.store!==void 0&&typeof e.store!="function")throw new TypeError("defineNotify: `store` must be a function `(env) => SubscriptionStore` when provided");if(e.allowedPushOrigins!==void 0&&(!Array.isArray(e.allowedPushOrigins)||e.allowedPushOrigins.some(o=>typeof o!="string")))throw new TypeError('defineNotify: `allowedPushOrigins` must be an array of origin strings (e.g. ["https://fcm.googleapis.com"]) when provided');for(const o of["broadcastPageSize","concurrency"]){const r=e[o];if(r!==void 0&&(!Number.isInteger(r)||r<1))throw new TypeError(`defineNotify: \`${o}\` must be a positive integer when provided`)}if(e.webPush===void 0&&e.fcm===void 0)throw new TypeError("defineNotify: configure at least one push channel — `webPush` and/or `fcm`");return{...e,isLunoraNotify:!0}},i=e=>typeof e=="object"&&e!==null&&e.isLunoraNotify===!0;export{t as defineNotify,i as isNotifyDefinition};
|
|
@@ -1 +1 @@
|
|
|
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")},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},
|
|
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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{legacyIdFor as n}from"./fcmId-
|
|
1
|
+
import{legacyIdFor as n}from"./fcmId-DNM-rzkd.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/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.32",
|
|
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.28",
|
|
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 F}from"./buildEngine-oDWs9Pom.mjs";import{memorySubscriptionStore as R}from"./memorySubscriptionStore-FY8yANEM.mjs";import{normalizeRegisterInput as z,targetOf as B,isGoneError as L}from"./fcmId-Dwh_R5xe.mjs";const W=250,w=(s,l)=>typeof s=="function"?s(l):s,S=s=>s.successful?void 0:s.errorMessages.join("; "),U=(s,l)=>s.successful?"accepted":L(l)?"gone":"failed",j=async(s,l,d)=>{const u=Array.from({length:s.length});let h=0;const g=async()=>{for(;h<s.length;){const f=h;h+=1,u[f]=await d(s[f])}};return await Promise.all(Array.from({length:Math.min(l,s.length)},()=>g())),u},G=(s,l)=>({allowedPushOrigins:s.allowedPushOrigins,chat:w(s.chat,l),fcm:w(s.fcm,l),inApp:w(s.inApp,l),webhook:w(s.webhook,l),webPush:w(s.webPush,l)}),x=new WeakMap,Q=(s,l)=>{let d=x.get(s);d===void 0&&(d=new WeakMap,x.set(s,d));let u=d.get(l);return u===void 0&&(u={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(l,u)),u},Y=(s,l,d={})=>{const u=Q(s,l);let h;d.engine===void 0?(u.engine??=F(G(s,l)),h=u.engine):h=d.engine,u.store??=s.store?.(l);let{store:g}=u;g===void 0&&(u.fallbackStore??=R(),!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??10),b=Math.max(1,d.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=s.allowedPushOrigins!==void 0&&s.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,o;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),o=U(n,a)}catch(c){o="failed",a=c instanceof Error?c.message:String(c)}try{o==="accepted"?await f.markStatus(e.id,"ok"):o==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return o==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,o),{error:a,receipt:n,status:o}},D=async(e,t)=>{const r=await j(t,C,async o=>{const{error:c,status:i}=await E(o,e,!1);return{error:c,kind:o.kind,status:i,subscription:o}}),n=new Map;for(const{kind:o,status:c}of r){const i=`${o} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:o,status:c}):m.count+=1}for(const{count:o,kind:c,status:i}of n.values())p("push",c,i,o);const a=r.map(({error:o,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:o,id:i.id,status:"expired"}:{error:o,id:i.id,status:"failed"});return{failed:a.filter(o=>o.status==="failed").length,outcomes:a,pruned:a.filter(o=>o.status==="expired").length,sent:a.filter(o=>o.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}),o=t?.after===void 0?a:a.filter($=>$.id>t.after),c=o.length>n,i=c?o.slice(0,n):o;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 o=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await O(e,o);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(z(e,void 0,{allowedPushOrigins:s.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};
|