@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 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
- args: { subscription: v.any() },
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
- args: { title: v.string(), body: v.string() },
63
- handler: async (ctx, { title, body }) => {
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
- // consumer (lunora/queues.ts)
94
- for (const message of batch.messages) {
95
- const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
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: message.body.payload, retryIds: failedIds });
101
+ await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
110
102
  }
103
+ });
111
104
 
112
- message.ack();
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
- * `CreateNotifyOptions`'s `broadcastPageSize`, default 250) so a caller
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 `CreateNotifyOptions`'s
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: `CreateNotifyOptions`'s `broadcastPageSize`, default
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
- * Each page is fetched, delivered, and counted independently before the
420
- * next page's store round trip, so a huge audience is never materialized
421
- * wholesale in the isolate. Also the per-message bound `push.broadcastPage`
422
- * (and so `runPushBroadcastPage`) uses. A test/tuning seam most apps never
423
- * need to set this.
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
- /** Max concurrent sends during a `broadcast` (default 10). */
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
- * // in a mutation/action:
552
- * await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
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
- * // in lunora/queues.ts consumer:
555
- * export const push = defineQueue({ async handler(batch, ctx) {
556
- * for (const message of batch.messages) {
557
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
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
- * payload: message.body.payload,
564
- * filter: { ...message.body.filter, after: nextCursor },
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: message.body.payload, retryIds: failedIds });
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
- * message.ack();
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 `CreateNotifyOptions`'s
588
- * page-size option, default 250, or `job.filter.limit` when smaller),
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 (the `lunora/queues.ts` consumer) re-enqueues: `filter.after:
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
- * `CreateNotifyOptions`'s `broadcastPageSize`, default 250) so a caller
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 `CreateNotifyOptions`'s
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: `CreateNotifyOptions`'s `broadcastPageSize`, default
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
- * Each page is fetched, delivered, and counted independently before the
420
- * next page's store round trip, so a huge audience is never materialized
421
- * wholesale in the isolate. Also the per-message bound `push.broadcastPage`
422
- * (and so `runPushBroadcastPage`) uses. A test/tuning seam most apps never
423
- * need to set this.
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
- /** Max concurrent sends during a `broadcast` (default 10). */
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
- * // in a mutation/action:
552
- * await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
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
- * // in lunora/queues.ts consumer:
555
- * export const push = defineQueue({ async handler(batch, ctx) {
556
- * for (const message of batch.messages) {
557
- * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
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
- * payload: message.body.payload,
564
- * filter: { ...message.body.filter, after: nextCursor },
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: message.body.payload, retryIds: failedIds });
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
- * message.ack();
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 `CreateNotifyOptions`'s
588
- * page-size option, default 250, or `job.filter.limit` when smaller),
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 (the `lunora/queues.ts` consumer) re-enqueues: `filter.after:
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-CUi2k7pP.mjs";import{createNotify as p}from"./packem_shared/createNotify-BDE42tvH.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-s-AJH4hS.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-FY8yANEM.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-Dwh_R5xe.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
1
+ import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as p}from"./packem_shared/createNotify-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};
@@ -1 +1 @@
1
- import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-Dwh_R5xe.mjs";const c=e=>/^[A-Z_]\w*$/i.test(e),E=e=>{const i={createdAt:e.created_at,id:e.id,kind:e.kind,lastSeenAt:e.last_seen_at,userId:e.user_id};if(e.endpoint!==null&&(i.endpoint=e.endpoint),e.p256dh!==null&&e.auth!==null&&(i.keys={auth:e.auth,p256dh:e.p256dh}),e.token!==null&&(i.token=e.token),e.last_status!==null&&(i.lastStatus=e.last_status),e.last_error!==null&&(i.lastError=e.last_error),e.metadata!==null)try{i.metadata=JSON.parse(e.metadata)}catch{}return i},O=(e,i={})=>{const n=i.tableName??"lunora_push_subscriptions";if(!c(n))throw new p("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${n}" is not a bare SQL identifier`);let r;const s=()=>(r===void 0&&(r=e.prepare(`CREATE TABLE IF NOT EXISTS ${n} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, endpoint TEXT, p256dh TEXT, auth TEXT, token TEXT, user_id TEXT, metadata TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, last_status TEXT, last_error TEXT)`).run().then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_user_id_idx ON ${n} (user_id)`).run()).then(()=>e.prepare(`CREATE INDEX IF NOT EXISTS ${n}_kind_idx ON ${n} (kind)`).run()).then(()=>{}),r.catch(()=>{r=void 0})),r),l=async t=>{await s();const a=await e.prepare(`SELECT * FROM ${n} WHERE id = ?1`).bind(t).first();return a===null?void 0:E(a)};return{delete:async t=>{await s(),await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await s();const a=[],d=[];t?.kind!==void 0&&(d.push(t.kind),a.push(`kind = ?${d.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?a.push("user_id IS NULL"):(d.push(t.userId),a.push(`user_id = ?${d.length.toString()}`))),t?.after!==void 0&&(d.push(t.after),a.push(`id > ?${d.length.toString()}`));const o=a.length===0?"":` WHERE ${a.join(" AND ")}`,T=" ORDER BY id ASC";let u="";t?.limit!==void 0&&t.limit>0&&(d.push(Math.trunc(t.limit)),u=` LIMIT ?${d.length.toString()}`);const{results:_}=await e.prepare(`SELECT * FROM ${n}${o}${T}${u}`).bind(...d).all();return _.map(h=>E(h))},markStatus:async(t,a,d)=>{await s(),await e.prepare(`UPDATE ${n} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,a,d??null,Date.now()).run()},put:async t=>{await s(),await e.prepare(`INSERT INTO ${n} (id, kind, endpoint, p256dh, auth, token, user_id, metadata, created_at, last_seen_at, last_status, last_error) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET kind = ?2, endpoint = ?3, p256dh = ?4, auth = ?5, token = ?6, user_id = ?7, metadata = ?8, last_seen_at = ?10`).bind(t.id,t.kind,t.endpoint??null,t.keys?.p256dh??null,t.keys?.auth??null,t.token??null,t.userId??null,t.metadata===void 0?null:JSON.stringify(t.metadata),t.createdAt,t.lastSeenAt,t.lastStatus??null,t.lastError??null).run();const a=S(t);return a!==void 0&&a!==t.id&&await e.prepare(`DELETE FROM ${n} WHERE id = ?1`).bind(a).run(),await l(t.id)??t}}};export{O as d1SubscriptionStore};
1
+ import{LunoraError as p}from"@lunora/errors";import{legacyIdFor as S}from"./fcmId-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}},r=e=>typeof e=="object"&&e!==null&&e.isLunoraNotify===!0;export{t as defineNotify,r as isNotifyDefinition};
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},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)},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
+ 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-Dwh_R5xe.mjs";const i=(t,e)=>t.id<e.id?-1:t.id>e.id?1:0,a=(t,e)=>e===void 0?!0:!(e.kind!==void 0&&t.kind!==e.kind||e.userId!==void 0&&(t.userId??null)!==e.userId),c=()=>{const t=new Map;return{delete:e=>(t.delete(e),Promise.resolve()),get:e=>Promise.resolve(t.get(e)),list:e=>{const r=[];for(const d of t.values())a(d,e)&&r.push(d);r.sort(i);const o=e?.after===void 0?r:r.filter(d=>d.id>e.after),s=e?.limit!==void 0&&e.limit>0?o.slice(0,Math.trunc(e.limit)):o;return Promise.resolve(s)},markStatus:(e,r,o)=>{const s=t.get(e);return s!==void 0&&t.set(e,{...s,lastError:o,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:e=>{const r=n(e);r!==void 0&&r!==e.id&&t.delete(r);const o=t.get(e.id),s=o===void 0?e:{...o,...e,createdAt:o.createdAt};return t.set(s.id,s),Promise.resolve(s)}}};export{c as memorySubscriptionStore};
1
+ import{legacyIdFor as n}from"./fcmId-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.30",
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.26",
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};