@lunora/notify 1.0.0-alpha.3 → 1.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.d.mts +83 -7
- package/dist/index.d.ts +83 -7
- package/dist/index.mjs +1 -8
- package/dist/packem_shared/FCM_ENV_KEYS-D0tvRTLQ.mjs +1 -0
- package/dist/packem_shared/buildEngine-Jlzf2Jzn.mjs +1 -0
- package/dist/packem_shared/createNotify-iBbs9Bew.mjs +1 -0
- package/dist/packem_shared/d1SubscriptionStore-BwssRgeI.mjs +1 -0
- package/dist/packem_shared/defineNotify-DLEy53HJ.mjs +1 -0
- package/dist/packem_shared/enqueuePushBroadcast-DMwYIM0o.mjs +1 -0
- package/dist/packem_shared/fcmId-CLRyQJVw.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-BxKdyxjC.mjs +1 -0
- package/dist/web.mjs +1 -44
- package/package.json +2 -2
- package/dist/packem_shared/FCM_ENV_KEYS-DY4-A717.mjs +0 -32
- package/dist/packem_shared/buildEngine-DlmjvnNk.mjs +0 -65
- package/dist/packem_shared/createNotify-bDWs4qBm.mjs +0 -191
- package/dist/packem_shared/d1SubscriptionStore-Dv3VPMI_.mjs +0 -107
- package/dist/packem_shared/defineNotify-B6S_47C2.mjs +0 -18
- package/dist/packem_shared/enqueuePushBroadcast-DiK7Hyja.mjs +0 -4
- package/dist/packem_shared/fcmId-B-YPgHi7.mjs +0 -68
- package/dist/packem_shared/memorySubscriptionStore-DhS-YnLe.mjs +0 -46
package/README.md
CHANGED
|
@@ -98,6 +98,35 @@ for (const message of batch.messages) await runPushBroadcastJob(ctx.push, messag
|
|
|
98
98
|
|
|
99
99
|
`SubscriptionStore` implementations: `memorySubscriptionStore()` (non-durable default, tests/dev) and `d1SubscriptionStore(db)` (durable, edge-safe, lazy table creation). Lifecycle: register (upsert), list/filter (by kind or user), status marking, and automatic prune of gone subscriptions on send/broadcast.
|
|
100
100
|
|
|
101
|
+
## Security
|
|
102
|
+
|
|
103
|
+
`ctx.push.register(...)` and the browser `subscribeToPush` helper both accept
|
|
104
|
+
client-supplied data, so the facade enforces two boundaries:
|
|
105
|
+
|
|
106
|
+
- **Endpoint validation (anti-SSRF).** Every later `send`/`broadcast` POSTs to a
|
|
107
|
+
subscription's stored Web Push `endpoint`, so a hostile `endpoint` would turn the
|
|
108
|
+
worker into an SSRF / amplification primitive. `register()` validates the endpoint
|
|
109
|
+
**at storage time** (the durable boundary): it must be an absolute `https:` URL
|
|
110
|
+
with a non-private / non-loopback / non-link-local / non-CGNAT host. To hard-pin
|
|
111
|
+
the boundary to the push services you actually use, set `allowedPushOrigins` on
|
|
112
|
+
`defineNotify` — when present, an endpoint's origin must match one of the listed
|
|
113
|
+
origins **exactly** (no wildcards), which also closes DNS rebinding:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export default defineNotify({
|
|
117
|
+
webPush: (env) => webPushFromEnv(env),
|
|
118
|
+
allowedPushOrigins: ["https://fcm.googleapis.com", "https://updates.push.services.mozilla.com"],
|
|
119
|
+
store: (env) => d1SubscriptionStore(env.DB),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **No secrets on the app facade.** `ctx.push.list()`
|
|
124
|
+
returns the registered devices with the delivery **secrets stripped** — the Web
|
|
125
|
+
Push `keys` (`auth`/`p256dh`) and the FCM `token`, which together with the
|
|
126
|
+
endpoint are enough to deliver arbitrary push to a device. The raw rows are
|
|
127
|
+
reachable only through the internal `SubscriptionStore` (which handlers never
|
|
128
|
+
hold); the broadcast path uses the store directly.
|
|
129
|
+
|
|
101
130
|
## Delivery observability
|
|
102
131
|
|
|
103
132
|
Every send is counted onto `ctx.metrics` and failures onto `ctx.log` for you — codegen threads the request's logger/metrics into `ctx.notify` (`createNotify(notifyConfig, env, { log, metrics })`), so there is nothing to wire. Two low-cardinality metric series feed the durable metric history + trend charts:
|
package/dist/index.d.mts
CHANGED
|
@@ -77,6 +77,14 @@ type RegisterInput = {
|
|
|
77
77
|
interface SubscriptionFilter {
|
|
78
78
|
/** Restrict to a delivery kind. */
|
|
79
79
|
kind?: SubscriptionKind;
|
|
80
|
+
/**
|
|
81
|
+
* Cap the number of rows returned (a `LIMIT`). Applied server-side by the
|
|
82
|
+
* store, so a large audience never materializes wholesale in the isolate.
|
|
83
|
+
* A non-positive/absent value means "no cap"; a fractional value is truncated.
|
|
84
|
+
* `broadcast` deliberately leaves this unset (it must reach every matched
|
|
85
|
+
* device); admin/list reads set it to bound the page.
|
|
86
|
+
*/
|
|
87
|
+
limit?: number;
|
|
80
88
|
/** Restrict to a single owning user. */
|
|
81
89
|
userId?: string | null;
|
|
82
90
|
}
|
|
@@ -175,8 +183,14 @@ interface LunoraPush {
|
|
|
175
183
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
176
184
|
*/
|
|
177
185
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
178
|
-
/**
|
|
179
|
-
|
|
186
|
+
/**
|
|
187
|
+
* List stored subscriptions (optionally filtered), with the delivery
|
|
188
|
+
* **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
|
|
189
|
+
* FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
|
|
190
|
+
* a device, so they never cross the app-facing facade; the raw rows are
|
|
191
|
+
* reachable only through the internal `SubscriptionStore`.
|
|
192
|
+
*/
|
|
193
|
+
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
180
194
|
/** Register (upsert) a device subscription and return the stored record. */
|
|
181
195
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
182
196
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
@@ -213,6 +227,22 @@ type WebPushConfigFactory = (env: NotifyEnv) => WebPushConfig | undefined;
|
|
|
213
227
|
type FcmConfigFactory = (env: NotifyEnv) => FcmConfig | undefined;
|
|
214
228
|
/** Options accepted by `defineNotify`. */
|
|
215
229
|
interface NotifyConfig {
|
|
230
|
+
/**
|
|
231
|
+
* Exact origins (`https://host[:port]`) a client-supplied Web Push `endpoint`
|
|
232
|
+
* may register from. When set (non-empty), `register()` requires the endpoint's
|
|
233
|
+
* origin to be one of these — the strongest anti-SSRF posture, and the way to
|
|
234
|
+
* close DNS rebinding for a facade that accepts client-controlled endpoints.
|
|
235
|
+
*
|
|
236
|
+
* When unset, the default posture applies: an endpoint must be `https:` with a
|
|
237
|
+
* host a STRING classifier does not flag as private / loopback / link-local.
|
|
238
|
+
* That classifier does NOT resolve DNS, so a public hostname resolving to a
|
|
239
|
+
* private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT blocked by it
|
|
240
|
+
* — `register()` also emits a one-shot dev warning in this case. Set this to the
|
|
241
|
+
* push services your app actually uses (e.g. `["https://fcm.googleapis.com",
|
|
242
|
+
* "https://updates.push.services.mozilla.com"]` — exact origins only, no
|
|
243
|
+
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
244
|
+
*/
|
|
245
|
+
allowedPushOrigins?: string[];
|
|
216
246
|
/**
|
|
217
247
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
218
248
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
@@ -410,8 +440,20 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
410
440
|
* Run an enqueued broadcast job on the consumer side, delivering through the push
|
|
411
441
|
* facade (which reuses the engine's retry + circuit-breaker middleware and prunes
|
|
412
442
|
* gone subscriptions).
|
|
443
|
+
*
|
|
444
|
+
* RETRY SEMANTICS: retry is gated on `failed` — the count of TRANSIENT delivery
|
|
445
|
+
* errors (a provider 5xx / network fault worth another attempt). When at least one
|
|
446
|
+
* recipient `failed`, the job is RE-THROWN so the queue does NOT ack it and its
|
|
447
|
+
* normal retry/backoff (and, on exhaustion, dead-letter) applies. A broadcast with
|
|
448
|
+
* zero `failed` resolves and is acked — this includes the all-`pruned` case (every
|
|
449
|
+
* device had unsubscribed: `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL
|
|
450
|
+
* prune, not a failure, so throwing on it would spuriously retry and pressure the
|
|
451
|
+
* DLQ; and the empty audience (zero `total`), which has nothing to retry. Note a
|
|
452
|
+
* retry re-runs the WHOLE broadcast, re-sending to the already-delivered recipients
|
|
453
|
+
* (broadcast is not idempotent) — the accepted cost of getting the transiently
|
|
454
|
+
* failed ones redelivered.
|
|
413
455
|
*/
|
|
414
|
-
declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<
|
|
456
|
+
declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastResult>;
|
|
415
457
|
/**
|
|
416
458
|
* The minimal structural slice of Cloudflare's `D1Database` this store uses. A
|
|
417
459
|
* structural type (rather than importing `@cloudflare/workers-types`) keeps the
|
|
@@ -438,6 +480,15 @@ interface D1StoreOptions {
|
|
|
438
480
|
* backing table is created lazily on first use (`CREATE TABLE IF NOT EXISTS`), so
|
|
439
481
|
* no migration step is required for the subscription table itself.
|
|
440
482
|
*
|
|
483
|
+
* ID SCHEME / LAZY MIGRATION: `id` (the `PRIMARY KEY`, upserted via `ON
|
|
484
|
+
* CONFLICT(id) DO UPDATE`) is a version-prefixed digest of the endpoint/token —
|
|
485
|
+
* currently `wp2_`/`fcm2_` (64-bit FNV-1a; see `normalize.ts`). No table migration
|
|
486
|
+
* runs when the id scheme is revised: a returning device re-registers under its new
|
|
487
|
+
* id and upserts a fresh row, while its old-prefix row (`wp_`/`fcm_`) ages out via
|
|
488
|
+
* the normal gone-pruning on the next failed send. So a table can transiently hold
|
|
489
|
+
* both an old- and new-prefix row for one device — expected, self-healing, and the
|
|
490
|
+
* reason a prefix must NEVER be reused for a different scheme.
|
|
491
|
+
*
|
|
441
492
|
* ```ts
|
|
442
493
|
* export default defineNotify({
|
|
443
494
|
* webPush: (env) => webPushFromEnv(env),
|
|
@@ -453,16 +504,41 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
453
504
|
* (or another backing store) for production so subscriptions survive restarts.
|
|
454
505
|
*/
|
|
455
506
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
456
|
-
/**
|
|
507
|
+
/**
|
|
508
|
+
* Stable store id for a web-push endpoint.
|
|
509
|
+
*
|
|
510
|
+
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
511
|
+
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
|
512
|
+
* returning device simply re-registers under the new id, its stale `wp_` row aging
|
|
513
|
+
* out via normal gone-pruning. A future third revision must mint `wp3_` and repeat
|
|
514
|
+
* the lazy migration — NEVER reuse a prefix.
|
|
515
|
+
*/
|
|
457
516
|
declare const webPushId: (endpoint: string) => string;
|
|
458
|
-
/** Stable store id for an FCM device token. */
|
|
517
|
+
/** Stable store id for an FCM device token. See {@link webPushId} for the `_2` version-prefix contract. */
|
|
459
518
|
declare const fcmId: (token: string) => string;
|
|
519
|
+
/** Options threaded into {@link normalizeRegisterInput} from the notify definition. */
|
|
520
|
+
interface NormalizeOptions {
|
|
521
|
+
/**
|
|
522
|
+
* Exact origins (`https://host[:port]`) a web-push endpoint may register from.
|
|
523
|
+
* When set (non-empty), the endpoint's origin must be one of these — the
|
|
524
|
+
* strongest anti-SSRF posture, and the ONLY way to close DNS rebinding for a
|
|
525
|
+
* facade that accepts client-controlled endpoints.
|
|
526
|
+
*
|
|
527
|
+
* When unset, the default posture applies: `https:` scheme + a host the
|
|
528
|
+
* {@link assertPushEndpoint} STRING classifier does not flag as
|
|
529
|
+
* private/loopback. That classifier does NOT resolve DNS, so a public hostname
|
|
530
|
+
* resolving to a private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT
|
|
531
|
+
* blocked by it — set this allowlist to close that gap.
|
|
532
|
+
*/
|
|
533
|
+
allowedPushOrigins?: string[];
|
|
534
|
+
}
|
|
460
535
|
/**
|
|
461
536
|
* Normalise a `register(...)` input into a {@link StoredSubscription}. Validates
|
|
462
537
|
* the shape (a web-push subscription needs `endpoint` + `keys.{p256dh,auth}`; an
|
|
463
|
-
* FCM entry needs a non-empty `token`)
|
|
538
|
+
* FCM entry needs a non-empty `token`), enforces the anti-SSRF endpoint boundary
|
|
539
|
+
* (see {@link assertPushEndpoint}), and stamps `createdAt`/`lastSeenAt`.
|
|
464
540
|
*/
|
|
465
|
-
declare const normalizeRegisterInput: (input: RegisterInput, now?: number) => StoredSubscription;
|
|
541
|
+
declare const normalizeRegisterInput: (input: RegisterInput, now?: number, options?: NormalizeOptions) => StoredSubscription;
|
|
466
542
|
/**
|
|
467
543
|
* The provider `to` target for a stored subscription: the W3C Push subscription
|
|
468
544
|
* (JSON-stringified) for web-push, or the raw device token for FCM. Matches the
|
package/dist/index.d.ts
CHANGED
|
@@ -77,6 +77,14 @@ type RegisterInput = {
|
|
|
77
77
|
interface SubscriptionFilter {
|
|
78
78
|
/** Restrict to a delivery kind. */
|
|
79
79
|
kind?: SubscriptionKind;
|
|
80
|
+
/**
|
|
81
|
+
* Cap the number of rows returned (a `LIMIT`). Applied server-side by the
|
|
82
|
+
* store, so a large audience never materializes wholesale in the isolate.
|
|
83
|
+
* A non-positive/absent value means "no cap"; a fractional value is truncated.
|
|
84
|
+
* `broadcast` deliberately leaves this unset (it must reach every matched
|
|
85
|
+
* device); admin/list reads set it to bound the page.
|
|
86
|
+
*/
|
|
87
|
+
limit?: number;
|
|
80
88
|
/** Restrict to a single owning user. */
|
|
81
89
|
userId?: string | null;
|
|
82
90
|
}
|
|
@@ -175,8 +183,14 @@ interface LunoraPush {
|
|
|
175
183
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
176
184
|
*/
|
|
177
185
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
178
|
-
/**
|
|
179
|
-
|
|
186
|
+
/**
|
|
187
|
+
* List stored subscriptions (optionally filtered), with the delivery
|
|
188
|
+
* **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
|
|
189
|
+
* FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
|
|
190
|
+
* a device, so they never cross the app-facing facade; the raw rows are
|
|
191
|
+
* reachable only through the internal `SubscriptionStore`.
|
|
192
|
+
*/
|
|
193
|
+
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
180
194
|
/** Register (upsert) a device subscription and return the stored record. */
|
|
181
195
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
182
196
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
@@ -213,6 +227,22 @@ type WebPushConfigFactory = (env: NotifyEnv) => WebPushConfig | undefined;
|
|
|
213
227
|
type FcmConfigFactory = (env: NotifyEnv) => FcmConfig | undefined;
|
|
214
228
|
/** Options accepted by `defineNotify`. */
|
|
215
229
|
interface NotifyConfig {
|
|
230
|
+
/**
|
|
231
|
+
* Exact origins (`https://host[:port]`) a client-supplied Web Push `endpoint`
|
|
232
|
+
* may register from. When set (non-empty), `register()` requires the endpoint's
|
|
233
|
+
* origin to be one of these — the strongest anti-SSRF posture, and the way to
|
|
234
|
+
* close DNS rebinding for a facade that accepts client-controlled endpoints.
|
|
235
|
+
*
|
|
236
|
+
* When unset, the default posture applies: an endpoint must be `https:` with a
|
|
237
|
+
* host a STRING classifier does not flag as private / loopback / link-local.
|
|
238
|
+
* That classifier does NOT resolve DNS, so a public hostname resolving to a
|
|
239
|
+
* private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT blocked by it
|
|
240
|
+
* — `register()` also emits a one-shot dev warning in this case. Set this to the
|
|
241
|
+
* push services your app actually uses (e.g. `["https://fcm.googleapis.com",
|
|
242
|
+
* "https://updates.push.services.mozilla.com"]` — exact origins only, no
|
|
243
|
+
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
244
|
+
*/
|
|
245
|
+
allowedPushOrigins?: string[];
|
|
216
246
|
/**
|
|
217
247
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
218
248
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
@@ -410,8 +440,20 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
410
440
|
* Run an enqueued broadcast job on the consumer side, delivering through the push
|
|
411
441
|
* facade (which reuses the engine's retry + circuit-breaker middleware and prunes
|
|
412
442
|
* gone subscriptions).
|
|
443
|
+
*
|
|
444
|
+
* RETRY SEMANTICS: retry is gated on `failed` — the count of TRANSIENT delivery
|
|
445
|
+
* errors (a provider 5xx / network fault worth another attempt). When at least one
|
|
446
|
+
* recipient `failed`, the job is RE-THROWN so the queue does NOT ack it and its
|
|
447
|
+
* normal retry/backoff (and, on exhaustion, dead-letter) applies. A broadcast with
|
|
448
|
+
* zero `failed` resolves and is acked — this includes the all-`pruned` case (every
|
|
449
|
+
* device had unsubscribed: `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL
|
|
450
|
+
* prune, not a failure, so throwing on it would spuriously retry and pressure the
|
|
451
|
+
* DLQ; and the empty audience (zero `total`), which has nothing to retry. Note a
|
|
452
|
+
* retry re-runs the WHOLE broadcast, re-sending to the already-delivered recipients
|
|
453
|
+
* (broadcast is not idempotent) — the accepted cost of getting the transiently
|
|
454
|
+
* failed ones redelivered.
|
|
413
455
|
*/
|
|
414
|
-
declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<
|
|
456
|
+
declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastResult>;
|
|
415
457
|
/**
|
|
416
458
|
* The minimal structural slice of Cloudflare's `D1Database` this store uses. A
|
|
417
459
|
* structural type (rather than importing `@cloudflare/workers-types`) keeps the
|
|
@@ -438,6 +480,15 @@ interface D1StoreOptions {
|
|
|
438
480
|
* backing table is created lazily on first use (`CREATE TABLE IF NOT EXISTS`), so
|
|
439
481
|
* no migration step is required for the subscription table itself.
|
|
440
482
|
*
|
|
483
|
+
* ID SCHEME / LAZY MIGRATION: `id` (the `PRIMARY KEY`, upserted via `ON
|
|
484
|
+
* CONFLICT(id) DO UPDATE`) is a version-prefixed digest of the endpoint/token —
|
|
485
|
+
* currently `wp2_`/`fcm2_` (64-bit FNV-1a; see `normalize.ts`). No table migration
|
|
486
|
+
* runs when the id scheme is revised: a returning device re-registers under its new
|
|
487
|
+
* id and upserts a fresh row, while its old-prefix row (`wp_`/`fcm_`) ages out via
|
|
488
|
+
* the normal gone-pruning on the next failed send. So a table can transiently hold
|
|
489
|
+
* both an old- and new-prefix row for one device — expected, self-healing, and the
|
|
490
|
+
* reason a prefix must NEVER be reused for a different scheme.
|
|
491
|
+
*
|
|
441
492
|
* ```ts
|
|
442
493
|
* export default defineNotify({
|
|
443
494
|
* webPush: (env) => webPushFromEnv(env),
|
|
@@ -453,16 +504,41 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
453
504
|
* (or another backing store) for production so subscriptions survive restarts.
|
|
454
505
|
*/
|
|
455
506
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
456
|
-
/**
|
|
507
|
+
/**
|
|
508
|
+
* Stable store id for a web-push endpoint.
|
|
509
|
+
*
|
|
510
|
+
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
511
|
+
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
|
512
|
+
* returning device simply re-registers under the new id, its stale `wp_` row aging
|
|
513
|
+
* out via normal gone-pruning. A future third revision must mint `wp3_` and repeat
|
|
514
|
+
* the lazy migration — NEVER reuse a prefix.
|
|
515
|
+
*/
|
|
457
516
|
declare const webPushId: (endpoint: string) => string;
|
|
458
|
-
/** Stable store id for an FCM device token. */
|
|
517
|
+
/** Stable store id for an FCM device token. See {@link webPushId} for the `_2` version-prefix contract. */
|
|
459
518
|
declare const fcmId: (token: string) => string;
|
|
519
|
+
/** Options threaded into {@link normalizeRegisterInput} from the notify definition. */
|
|
520
|
+
interface NormalizeOptions {
|
|
521
|
+
/**
|
|
522
|
+
* Exact origins (`https://host[:port]`) a web-push endpoint may register from.
|
|
523
|
+
* When set (non-empty), the endpoint's origin must be one of these — the
|
|
524
|
+
* strongest anti-SSRF posture, and the ONLY way to close DNS rebinding for a
|
|
525
|
+
* facade that accepts client-controlled endpoints.
|
|
526
|
+
*
|
|
527
|
+
* When unset, the default posture applies: `https:` scheme + a host the
|
|
528
|
+
* {@link assertPushEndpoint} STRING classifier does not flag as
|
|
529
|
+
* private/loopback. That classifier does NOT resolve DNS, so a public hostname
|
|
530
|
+
* resolving to a private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT
|
|
531
|
+
* blocked by it — set this allowlist to close that gap.
|
|
532
|
+
*/
|
|
533
|
+
allowedPushOrigins?: string[];
|
|
534
|
+
}
|
|
460
535
|
/**
|
|
461
536
|
* Normalise a `register(...)` input into a {@link StoredSubscription}. Validates
|
|
462
537
|
* the shape (a web-push subscription needs `endpoint` + `keys.{p256dh,auth}`; an
|
|
463
|
-
* FCM entry needs a non-empty `token`)
|
|
538
|
+
* FCM entry needs a non-empty `token`), enforces the anti-SSRF endpoint boundary
|
|
539
|
+
* (see {@link assertPushEndpoint}), and stamps `createdAt`/`lastSeenAt`.
|
|
464
540
|
*/
|
|
465
|
-
declare const normalizeRegisterInput: (input: RegisterInput, now?: number) => StoredSubscription;
|
|
541
|
+
declare const normalizeRegisterInput: (input: RegisterInput, now?: number, options?: NormalizeOptions) => StoredSubscription;
|
|
466
542
|
/**
|
|
467
543
|
* The provider `to` target for a stored subscription: the W3C Push subscription
|
|
468
544
|
* (JSON-stringified) for web-push, or the raw device token for FCM. Matches the
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { defineNotify, isNotifyDefinition } from './packem_shared/defineNotify-B6S_47C2.mjs';
|
|
3
|
-
export { createNotify } from './packem_shared/createNotify-bDWs4qBm.mjs';
|
|
4
|
-
export { buildEngine, routingPushProvider } from './packem_shared/buildEngine-DlmjvnNk.mjs';
|
|
5
|
-
export { enqueuePushBroadcast, runPushBroadcastJob } from './packem_shared/enqueuePushBroadcast-DiK7Hyja.mjs';
|
|
6
|
-
export { d1SubscriptionStore } from './packem_shared/d1SubscriptionStore-Dv3VPMI_.mjs';
|
|
7
|
-
export { memorySubscriptionStore } from './packem_shared/memorySubscriptionStore-DhS-YnLe.mjs';
|
|
8
|
-
export { fcmId, isGoneError, normalizeRegisterInput, targetOf, webPushId } from './packem_shared/fcmId-B-YPgHi7.mjs';
|
|
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-D0tvRTLQ.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-DLEy53HJ.mjs";import{createNotify as p}from"./packem_shared/createNotify-iBbs9Bew.mjs";import{buildEngine as d,routingPushProvider as x}from"./packem_shared/buildEngine-Jlzf2Jzn.mjs";import{enqueuePushBroadcast as c,runPushBroadcastJob as P}from"./packem_shared/enqueuePushBroadcast-DMwYIM0o.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-BwssRgeI.mjs";import{memorySubscriptionStore as N}from"./packem_shared/memorySubscriptionStore-BxKdyxjC.mjs";import{fcmId as g,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-CLRyQJVw.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,d as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,c as enqueuePushBroadcast,i as fcmFromEnv,g as fcmId,y as isGoneError,u as isNotifyDefinition,N as memorySubscriptionStore,v as normalizeRegisterInput,x as routingPushProvider,P as runPushBroadcastJob,B as targetOf,f as webPushFromEnv,F as webPushId};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const s={privateKey:"VAPID_PRIVATE_KEY",publicKey:"VAPID_PUBLIC_KEY",subject:"VAPID_SUBJECT"},v={accessToken:"FCM_ACCESS_TOKEN",projectId:"FCM_PROJECT_ID"},t=(c,o)=>{const e=c[o];return typeof e=="string"&&e!==""?e:void 0},E=(c,o)=>{const e=t(c,s.publicKey),i=t(c,s.privateKey),r=t(c,s.subject);if(!(e===void 0||i===void 0||r===void 0))return{vapidPrivateKey:i,vapidPublicKey:e,vapidSubject:r,...o}},_=(c,o)=>{const e=t(c,v.projectId);return e===void 0?void 0:{accessToken:t(c,v.accessToken),projectId:e,...o}};export{v as FCM_ENV_KEYS,s as WEB_PUSH_ENV_KEYS,_ as fcmFromEnv,E as webPushFromEnv};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createNotification as s}from"@visulima/notification";import{retryMiddleware as a,circuitBreakerMiddleware as c}from"@visulima/notification/middleware";import{fcmProvider as u}from"@visulima/notification/providers/fcm";import{webPushProvider as d}from"@visulima/notification/providers/web-push";const i=e=>{if(typeof e!="string")return typeof e=="object"&&e!==null&&"endpoint"in e;if(!e.startsWith("{"))return!1;try{const t=JSON.parse(e);return typeof t.endpoint=="string"&&typeof t.keys=="object"}catch{return!1}},f=e=>{const t=o=>{const r=i(o)?e.webPush:e.fcm;if(r===void 0)throw new Error(i(o)?"@lunora/notify: received a web-push target but no `webPush` channel is configured":"@lunora/notify: received an FCM token target but no `fcm` channel is configured");return r};return{channel:"push",id:"lunora-push-router",initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:o=>{const r=Array.isArray(o.to)?o.to[0]:o.to;return t(r).send(o)}}},w=e=>{const t=e.webPush===void 0?void 0:d(e.webPush),o=e.fcm===void 0?void 0:u(e.fcm),r={};(t!==void 0||o!==void 0)&&(r.push=f({fcm:o,webPush:t})),e.chat!==void 0&&(r.chat=e.chat),e.inApp!==void 0&&(r.inapp=e.inApp),e.webhook!==void 0&&(r.webhook=e.webhook);const n=s(r);return n.use(a()).use(c()),n};export{w as buildEngine,f as routingPushProvider};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as v}from"@lunora/errors";import{buildEngine as R}from"./buildEngine-Jlzf2Jzn.mjs";import{memorySubscriptionStore as x}from"./memorySubscriptionStore-BxKdyxjC.mjs";import{normalizeRegisterInput as C,targetOf as W,isGoneError as B}from"./fcmId-CLRyQJVw.mjs";const w=(t,s)=>typeof t=="function"?t(s):t,b=t=>t.successful?void 0:t.errorMessages.join("; "),L=(t,s)=>t.successful?"accepted":B(s)?"gone":"failed",_=async(t,s,l)=>{const a=Array.from({length:t.length});let f=0;const g=async()=>{for(;f<t.length;){const c=f;f+=1,a[c]=await l(t[c])}};return await Promise.all(Array.from({length:Math.min(s,t.length)},()=>g())),a},j=(t,s)=>({chat:w(t.chat,s),fcm:w(t.fcm,s),inApp:w(t.inApp,s),webhook:w(t.webhook,s),webPush:w(t.webPush,s)}),E=new WeakMap,Q=(t,s)=>{let l=E.get(t);l===void 0&&(l=new WeakMap,E.set(t,l));let a=l.get(s);return a===void 0&&(a={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},l.set(s,a)),a},F=(t,s,l={})=>{const a=Q(t,s);let f;l.engine===void 0?(a.engine??=R(j(t,s)),f=a.engine):f=l.engine,a.store??=t.store?.(s);let{store:g}=a;g===void 0&&(a.fallbackStore??=x(),!l.silent&&!a.warnedNoStore&&(a.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=a.fallbackStore);const c=g,T=Math.max(1,l.concurrency??10),{log:$,metrics:S}=l,y=(e,o,n,i=1)=>{S?.count("notify.send",i,{channel:e,provider:o??e,status:n})},m=(e,o,n)=>{$?.warn(`notify ${e} delivery failed`,{channel:e,provider:o??e,status:"failed",...n})},P=(e,o)=>{S?.count("notify.skipped",1,{channel:e,reason:o})},I=()=>{const e=t.allowedPushOrigins!==void 0&&t.allowedPushOrigins.length>0;l.silent||e||a.warnedNoPushOriginAllowlist||(a.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — the endpoint host is validated by a string classifier that does NOT resolve DNS, so a public hostname resolving to a private/internal IP (e.g. `https://127.0.0.1.nip.io/…`) is NOT blocked. Set `allowedPushOrigins` to the exact push-service origins to close DNS rebinding."))},D=async e=>(await c.list(e)).map(({keys:o,token:n,...i})=>i),M=async e=>{if(typeof e!="string")return e;const o=await c.get(e);if(o===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return o},N=async(e,o,n)=>{let i,u,d;try{i=await f.sendToChannel("push",{...o,to:W(e)}),u=b(i),d=L(i,u)}catch(r){d="failed",u=r instanceof Error?r.message:String(r)}try{d==="accepted"?await c.markStatus(e.id,"ok"):d==="gone"?await c.delete(e.id):await c.markStatus(e.id,"failed",u)}catch{}return d==="failed"&&m("push",e.kind,{error:u,subscriptionId:e.id,userId:e.userId??null}),n&&y("push",e.kind,d),{error:u,receipt:i,status:d}},O={broadcast:async(e,o)=>{const n=await c.list(o);n.length===0&&P("push","no-subscriptions-matched");const i=await _(n,T,async r=>{const{error:p,status:h}=await N(r,e,!1);return{error:p,kind:r.kind,status:h,subscription:r}}),u=new Map;for(const{kind:r,status:p}of i){const h=`${r}\0${p}`,A=u.get(h);A===void 0?u.set(h,{count:1,kind:r,status:p}):A.count+=1}for(const{count:r,kind:p,status:h}of u.values())y("push",p,h,r);const d=i.map(({error:r,status:p,subscription:h})=>p==="accepted"?{id:h.id,status:"ok"}:p==="gone"?{error:r,id:h.id,status:"expired"}:{error:r,id:h.id,status:"failed"});return{failed:d.filter(r=>r.status==="failed").length,outcomes:d,pruned:d.filter(r=>r.status==="expired").length,sent:d.filter(r=>r.status==="ok").length,total:d.length}},list:e=>D(e),register:e=>("token"in e||I(),c.put(C(e,void 0,{allowedPushOrigins:t.allowedPushOrigins}))),send:async(e,o)=>{const{error:n,receipt:i}=await N(await M(e),o,!0);if(i===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${n??"unknown error"}`);return i},unregister:e=>c.delete(e)},k=async(e,o)=>{if(f.getProvider(e)===void 0)throw P(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const n=await f.sendToChannel(e,o),i=n.successful?"accepted":"failed";return y(e,n.provider,i),i==="failed"&&m(e,n.provider,{error:b(n)}),n};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:O,send:async e=>{const o=await f.send(e);for(const n of o){const i=n.channel??"unknown",u=n.successful?"accepted":"failed";y(i,n.provider,u),u==="failed"&&m(i,n.provider,{error:b(n)})}return o},webhook:e=>k("webhook",e)},push:O}};export{F as createNotify};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as _}from"@lunora/errors";import{legacyIdFor as h}from"./fcmId-CLRyQJVw.mjs";const o=a=>{const i={createdAt:a.created_at,id:a.id,kind:a.kind,lastSeenAt:a.last_seen_at,userId:a.user_id};if(a.endpoint!==null&&(i.endpoint=a.endpoint),a.p256dh!==null&&a.auth!==null&&(i.keys={auth:a.auth,p256dh:a.p256dh}),a.token!==null&&(i.token=a.token),a.last_status!==null&&(i.lastStatus=a.last_status),a.last_error!==null&&(i.lastError=a.last_error),a.metadata!==null)try{i.metadata=JSON.parse(a.metadata)}catch{}return i},S=/^[A-Za-z_]\w*$/u,I=(a,i={})=>{const r=i.tableName??"lunora_push_subscriptions";if(!S.test(r))throw new _("BAD_REQUEST",`@lunora/notify: d1SubscriptionStore tableName "${r}" is not a bare SQL identifier`);let s;const d=()=>(s===void 0&&(s=a.prepare(`CREATE TABLE IF NOT EXISTS ${r} (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(()=>a.prepare(`CREATE INDEX IF NOT EXISTS ${r}_user_id_idx ON ${r} (user_id)`).run()).then(()=>a.prepare(`CREATE INDEX IF NOT EXISTS ${r}_kind_idx ON ${r} (kind)`).run()).then(()=>{}),s.catch(()=>{s=void 0})),s),l=async t=>{await d();const n=await a.prepare(`SELECT * FROM ${r} WHERE id = ?1`).bind(t).first();return n===null?void 0:o(n)};return{delete:async t=>{await d(),await a.prepare(`DELETE FROM ${r} WHERE id = ?1`).bind(t).run()},get:l,list:async t=>{await d();const n=[],e=[];t?.kind!==void 0&&(e.push(t.kind),n.push(`kind = ?${e.length.toString()}`)),t?.userId!==void 0&&(t.userId===null?n.push("user_id IS NULL"):(e.push(t.userId),n.push(`user_id = ?${e.length.toString()}`)));const E=n.length===0?"":` WHERE ${n.join(" AND ")}`;let u="";t?.limit!==void 0&&t.limit>0&&(e.push(Math.trunc(t.limit)),u=` LIMIT ?${e.length.toString()}`);const{results:p}=await a.prepare(`SELECT * FROM ${r}${E}${u}`).bind(...e).all();return p.map(T=>o(T))},markStatus:async(t,n,e)=>{await d(),await a.prepare(`UPDATE ${r} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(t,n,e??null,Date.now()).run()},put:async t=>{await d(),await a.prepare(`INSERT INTO ${r} (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 n=h(t);return n!==void 0&&n!==t.id&&await a.prepare(`DELETE FROM ${r} WHERE id = ?1`).bind(n).run(),await l(t.id)??t}}};export{I as d1SubscriptionStore};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=o=>{if(o.webPush!==void 0&&typeof o.webPush!="function"&&typeof o.webPush!="object")throw new TypeError("defineNotify: `webPush` must be a WebPushConfig object or an `(env) => WebPushConfig` function");if(o.fcm!==void 0&&typeof o.fcm!="function"&&typeof o.fcm!="object")throw new TypeError("defineNotify: `fcm` must be an FcmConfig object or an `(env) => FcmConfig` function");if(o.store!==void 0&&typeof o.store!="function")throw new TypeError("defineNotify: `store` must be a function `(env) => SubscriptionStore` when provided");if(o.allowedPushOrigins!==void 0&&(!Array.isArray(o.allowedPushOrigins)||o.allowedPushOrigins.some(i=>typeof i!="string")))throw new TypeError('defineNotify: `allowedPushOrigins` must be an array of origin strings (e.g. ["https://fcm.googleapis.com"]) when provided');if(o.webPush===void 0&&o.fcm===void 0)throw new TypeError("defineNotify: configure at least one push channel — `webPush` and/or `fcm`");return{...o,isLunoraNotify:!0}},n=o=>typeof o=="object"&&o!==null&&o.isLunoraNotify===!0;export{e as defineNotify,n as isNotifyDefinition};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as a}from"@lunora/errors";const e=(o,r)=>o.send({...r,type:"lunora.push.broadcast"}),s=async(o,r)=>{const t=await o.broadcast(r.payload,r.filter);if(t.failed>0)throw new a("INTERNAL",`@lunora/notify: push broadcast had ${t.failed.toString()} transient failure(s) of ${t.total.toString()} subscription(s) (${t.sent.toString()} sent, ${t.pruned.toString()} pruned) — throwing so the queue retries`);return t};export{e as enqueuePushBroadcast,s as runPushBroadcastJob};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as u}from"@lunora/errors";const k=/^\d{1,3}$/u,E=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,I=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,S=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,v=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,A=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,W=/^\[|\]$/gu,N=/\.$/u,l=t=>{const e=t.split(".");if(e.length!==4)return;const n=e.map(r=>k.test(r)?Number(r):-1);if(!n.some(r=>r<0||r>255))return[n[0],n[1],n[2],n[3]]},f=([t,e])=>t===0||t===10||t===127||t===100&&e>=64&&e<=127||t===169&&e===254||t===172&&e>=16&&e<=31||t===192&&e===168||t>=224,p=(t,e)=>{const n=Number.parseInt(t??"",16),r=Number.parseInt(e??"",16);return!Number.isFinite(n)||!Number.isFinite(r)?!0:f([Math.floor(n/256),n%256,Math.floor(r/256),r%256])},R=t=>{const e=t.toLowerCase(),n=E.exec(e);if(n)return p(n[1],n[2]);const r=I.exec(e);if(r){const o=l(r[1]??"");return o===void 0||f(o)}const i=S.exec(e);if(i){const o=l(i[1]??"");return o===void 0||f(o)}const s=v.exec(e);return s?p(s[1],s[2]):A.test(e)||e.startsWith("2002:")||e.startsWith("2001:0:")?!0:e==="::"||e==="::1"||e.startsWith("fc")||e.startsWith("fd")||e.startsWith("fe8")||e.startsWith("fe9")||e.startsWith("fea")||e.startsWith("feb")},_=t=>t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal")||t.endsWith(".home.arpa"),D=t=>t.replaceAll(W,"").replace(N,"").toLowerCase(),O=t=>{const e=D(t);if(e.includes(":"))return R(e);const n=l(e);return n===void 0?_(e):f(n)},d=t=>t.toString(16).padStart(4,"0"),g=t=>{let e=8997,n=33826,r=40164,i=52210;for(let s=0;s<t.length;s+=1){const o=t.codePointAt(s)??0;e^=o&65535,n^=o>>>16&65535;const a=e*435,w=n*435,b=r*435+e*256,y=i*435+n*256,c=w+(a>>>16),h=b+(c>>>16),$=y+(h>>>16);e=a&65535,n=c&65535,r=h&65535,i=$&65535}return d(i)+d(r)+d(n)+d(e)},P=t=>`wp2_${g(t)}`,B=t=>`fcm2_${g(t)}`,m=t=>{let e=2166136261;for(let n=0;n<t.length;n+=1)e^=t.codePointAt(n)??0,e=Math.imul(e,16777619);return(e>>>0).toString(16).padStart(8,"0")},U=t=>`wp_${m(t)}`,x=t=>`fcm_${m(t)}`,q=t=>t.kind==="fcm"?t.token===void 0?void 0:x(t.token):t.endpoint===void 0?void 0:U(t.endpoint),F=t=>{if(typeof t!="string")return t??{};try{return JSON.parse(t)}catch(e){throw new u("BAD_REQUEST",`@lunora/notify: register() web-push subscription is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}},T=(t,e)=>{let n;try{n=new URL(t)}catch{throw new u("BAD_REQUEST",`@lunora/notify: register() web-push \`endpoint\` must be an absolute https URL (got "${t}")`)}if(n.protocol!=="https:")throw new u("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 u("FORBIDDEN",`@lunora/notify: register() web-push endpoint origin "${n.origin}" is not in the configured allowedPushOrigins allowlist`);return}if(O(n.hostname))throw new u("FORBIDDEN",`@lunora/notify: register() web-push endpoint host "${n.hostname}" is a private/internal address; configure allowedPushOrigins to permit a specific origin`)},C=(t,e=Date.now(),n={})=>{if("token"in t){const{token:a}=t;if(typeof a!="string"||a==="")throw new u("BAD_REQUEST","@lunora/notify: register() fcm input requires a non-empty `token`");return{createdAt:e,id:B(a),kind:"fcm",lastSeenAt:e,metadata:t.metadata,token:a,userId:t.userId??null}}const r=F(t.subscription),{endpoint:i}=r,s=r.keys?.p256dh,o=r.keys?.auth;if(typeof i!="string"||i===""||typeof s!="string"||typeof o!="string")throw new u("BAD_REQUEST","@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");return T(i,n.allowedPushOrigins),{createdAt:e,endpoint:i,id:P(i),keys:{auth:o,p256dh:s},kind:"web-push",lastSeenAt:e,metadata:t.metadata,userId:t.userId??null}},z=t=>t.kind==="fcm"?t.token??"":JSON.stringify({endpoint:t.endpoint,keys:t.keys}),L=/\bhttp\s*4(?:04|10)\b/iu,Q=/\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu,J=/\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu,G=t=>t===void 0?!1:L.test(t)||Q.test(t)||J.test(t);export{B as fcmId,G as isGoneError,x as legacyFcmId,q as legacyIdFor,U as legacyWebPushId,C as normalizeRegisterInput,z as targetOf,P as webPushId};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{legacyIdFor as i}from"./fcmId-CLRyQJVw.mjs";const d=(e,t)=>t===void 0?!0:!(t.kind!==void 0&&e.kind!==t.kind||t.userId!==void 0&&(e.userId??null)!==t.userId),l=()=>{const e=new Map;return{delete:t=>(e.delete(t),Promise.resolve()),get:t=>Promise.resolve(e.get(t)),list:t=>{const r=[];for(const o of e.values())d(o,t)&&r.push(o);const s=t?.limit!==void 0&&t.limit>0?r.slice(0,Math.trunc(t.limit)):r;return Promise.resolve(s)},markStatus:(t,r,s)=>{const o=e.get(t);return o!==void 0&&e.set(t,{...o,lastError:s,lastSeenAt:Date.now(),lastStatus:r}),Promise.resolve()},put:t=>{const r=i(t);r!==void 0&&r!==t.id&&e.delete(r);const s=e.get(t.id),o=s===void 0?t:{...s,...t,createdAt:s.createdAt};return e.set(o.id,o),Promise.resolve(o)}}};export{l as memorySubscriptionStore};
|
package/dist/web.mjs
CHANGED
|
@@ -1,44 +1 @@
|
|
|
1
|
-
const
|
|
2
|
-
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
|
3
|
-
const normalized = (base64 + padding).replaceAll("-", "+").replaceAll("_", "/");
|
|
4
|
-
const raw = atob(normalized);
|
|
5
|
-
const output = new Uint8Array(raw.length);
|
|
6
|
-
for (let index = 0; index < raw.length; index += 1) {
|
|
7
|
-
output[index] = raw.codePointAt(index) ?? 0;
|
|
8
|
-
}
|
|
9
|
-
return output;
|
|
10
|
-
};
|
|
11
|
-
const browserGlobals = globalThis;
|
|
12
|
-
const isPushSupported = () => browserGlobals.navigator?.serviceWorker !== void 0 && browserGlobals.PushManager !== void 0;
|
|
13
|
-
const subscribeToPush = async (options) => {
|
|
14
|
-
if (!isPushSupported()) {
|
|
15
|
-
throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager)");
|
|
16
|
-
}
|
|
17
|
-
let registration;
|
|
18
|
-
if (options.serviceWorkerUrl === void 0) {
|
|
19
|
-
registration = await navigator.serviceWorker.ready;
|
|
20
|
-
} else {
|
|
21
|
-
const registerOptions = options.scope === void 0 ? void 0 : { scope: options.scope };
|
|
22
|
-
registration = await navigator.serviceWorker.register(options.serviceWorkerUrl, registerOptions);
|
|
23
|
-
}
|
|
24
|
-
const permission = await Notification.requestPermission();
|
|
25
|
-
if (permission !== "granted") {
|
|
26
|
-
throw new Error(`@lunora/notify: notification permission was not granted (got "${permission}")`);
|
|
27
|
-
}
|
|
28
|
-
const existing = await registration.pushManager.getSubscription();
|
|
29
|
-
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
30
|
-
applicationServerKey: urlBase64ToUint8Array(options.vapidPublicKey),
|
|
31
|
-
userVisibleOnly: true
|
|
32
|
-
});
|
|
33
|
-
return subscription.toJSON();
|
|
34
|
-
};
|
|
35
|
-
const unsubscribeFromPush = async () => {
|
|
36
|
-
if (!isPushSupported()) {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
const registration = await navigator.serviceWorker.ready;
|
|
40
|
-
const subscription = await registration.pushManager.getSubscription();
|
|
41
|
-
return subscription === null ? false : subscription.unsubscribe();
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export { isPushSupported, subscribeToPush, unsubscribeFromPush };
|
|
1
|
+
const l=r=>{const i="=".repeat((4-r.length%4)%4),n=(r+i).replaceAll("-","+").replaceAll("_","/"),e=atob(n),a=new Uint8Array(e.length);for(let s=0;s<e.length;s+=1)a[s]=e.codePointAt(s)??0;return a},c=(r,i)=>r.length===i.length&&r.every((n,e)=>n===i[e]),t=(r,i)=>{const n=r.options.applicationServerKey;return n===null?!1:c(new Uint8Array(n),l(i))},o=globalThis,u=()=>o.navigator?.serviceWorker!==void 0&&o.PushManager!==void 0,p=async r=>{if(!u())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager)");let i;if(r.serviceWorkerUrl===void 0)i=await navigator.serviceWorker.ready;else{const a=r.scope===void 0?void 0:{scope:r.scope};i=await navigator.serviceWorker.register(r.serviceWorkerUrl,a)}const n=await Notification.requestPermission();if(n!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${n}")`);const e=await i.pushManager.getSubscription();return e!==null&&!t(e,r.vapidPublicKey)&&await e.unsubscribe(),((e!==null&&t(e,r.vapidPublicKey)?e:null)??await i.pushManager.subscribe({applicationServerKey:l(r.vapidPublicKey),userVisibleOnly:!0})).toJSON()},g=async()=>{if(!u())return!1;const r=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return r===null?!1:r.unsubscribe()};export{u as isPushSupported,p as subscribeToPush,g as unsubscribeFromPush};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/notify",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.5",
|
|
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.9",
|
|
53
53
|
"@visulima/notification": "1.0.5"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
const WEB_PUSH_ENV_KEYS = {
|
|
2
|
-
privateKey: "VAPID_PRIVATE_KEY",
|
|
3
|
-
publicKey: "VAPID_PUBLIC_KEY",
|
|
4
|
-
subject: "VAPID_SUBJECT"
|
|
5
|
-
};
|
|
6
|
-
const FCM_ENV_KEYS = {
|
|
7
|
-
accessToken: "FCM_ACCESS_TOKEN",
|
|
8
|
-
projectId: "FCM_PROJECT_ID"
|
|
9
|
-
};
|
|
10
|
-
const readString = (env, key) => {
|
|
11
|
-
const value = env[key];
|
|
12
|
-
return typeof value === "string" && value !== "" ? value : void 0;
|
|
13
|
-
};
|
|
14
|
-
const webPushFromEnv = (env, overrides) => {
|
|
15
|
-
const vapidPublicKey = readString(env, WEB_PUSH_ENV_KEYS.publicKey);
|
|
16
|
-
const vapidPrivateKey = readString(env, WEB_PUSH_ENV_KEYS.privateKey);
|
|
17
|
-
const vapidSubject = readString(env, WEB_PUSH_ENV_KEYS.subject);
|
|
18
|
-
if (vapidPublicKey === void 0 || vapidPrivateKey === void 0 || vapidSubject === void 0) {
|
|
19
|
-
return void 0;
|
|
20
|
-
}
|
|
21
|
-
return { vapidPrivateKey, vapidPublicKey, vapidSubject, ...overrides };
|
|
22
|
-
};
|
|
23
|
-
const fcmFromEnv = (env, overrides) => {
|
|
24
|
-
const projectId = readString(env, FCM_ENV_KEYS.projectId);
|
|
25
|
-
if (projectId === void 0) {
|
|
26
|
-
return void 0;
|
|
27
|
-
}
|
|
28
|
-
const accessToken = readString(env, FCM_ENV_KEYS.accessToken);
|
|
29
|
-
return { accessToken, projectId, ...overrides };
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export { FCM_ENV_KEYS, WEB_PUSH_ENV_KEYS, fcmFromEnv, webPushFromEnv };
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { createNotification } from '@visulima/notification';
|
|
2
|
-
import { retryMiddleware, circuitBreakerMiddleware } from '@visulima/notification/middleware';
|
|
3
|
-
import { fcmProvider } from '@visulima/notification/providers/fcm';
|
|
4
|
-
import { webPushProvider } from '@visulima/notification/providers/web-push';
|
|
5
|
-
|
|
6
|
-
const isWebPushTarget = (target) => {
|
|
7
|
-
if (typeof target !== "string") {
|
|
8
|
-
return typeof target === "object" && target !== null && "endpoint" in target;
|
|
9
|
-
}
|
|
10
|
-
if (!target.startsWith("{")) {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
try {
|
|
14
|
-
const parsed = JSON.parse(target);
|
|
15
|
-
return typeof parsed.endpoint === "string" && typeof parsed.keys === "object";
|
|
16
|
-
} catch {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
const routingPushProvider = (options) => {
|
|
21
|
-
const pick = (target) => {
|
|
22
|
-
const provider = isWebPushTarget(target) ? options.webPush : options.fcm;
|
|
23
|
-
if (provider === void 0) {
|
|
24
|
-
throw new Error(
|
|
25
|
-
isWebPushTarget(target) ? "@lunora/notify: received a web-push target but no `webPush` channel is configured" : "@lunora/notify: received an FCM token target but no `fcm` channel is configured"
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
return provider;
|
|
29
|
-
};
|
|
30
|
-
return {
|
|
31
|
-
channel: "push",
|
|
32
|
-
id: "lunora-push-router",
|
|
33
|
-
initialize: async () => {
|
|
34
|
-
await options.webPush?.initialize();
|
|
35
|
-
await options.fcm?.initialize();
|
|
36
|
-
},
|
|
37
|
-
isAvailable: () => (options.webPush ?? options.fcm) !== void 0,
|
|
38
|
-
send: (payload) => {
|
|
39
|
-
const target = Array.isArray(payload.to) ? payload.to[0] : payload.to;
|
|
40
|
-
return pick(target).send(payload);
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
};
|
|
44
|
-
const buildEngine = (resolved) => {
|
|
45
|
-
const webPush = resolved.webPush === void 0 ? void 0 : webPushProvider(resolved.webPush);
|
|
46
|
-
const fcm = resolved.fcm === void 0 ? void 0 : fcmProvider(resolved.fcm);
|
|
47
|
-
const providers = {};
|
|
48
|
-
if (webPush !== void 0 || fcm !== void 0) {
|
|
49
|
-
providers.push = routingPushProvider({ fcm, webPush });
|
|
50
|
-
}
|
|
51
|
-
if (resolved.chat !== void 0) {
|
|
52
|
-
providers.chat = resolved.chat;
|
|
53
|
-
}
|
|
54
|
-
if (resolved.inApp !== void 0) {
|
|
55
|
-
providers.inapp = resolved.inApp;
|
|
56
|
-
}
|
|
57
|
-
if (resolved.webhook !== void 0) {
|
|
58
|
-
providers.webhook = resolved.webhook;
|
|
59
|
-
}
|
|
60
|
-
const engine = createNotification(providers);
|
|
61
|
-
engine.use(retryMiddleware()).use(circuitBreakerMiddleware());
|
|
62
|
-
return engine;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export { buildEngine, routingPushProvider };
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { buildEngine } from './buildEngine-DlmjvnNk.mjs';
|
|
3
|
-
import { memorySubscriptionStore } from './memorySubscriptionStore-DhS-YnLe.mjs';
|
|
4
|
-
import { normalizeRegisterInput, targetOf, isGoneError } from './fcmId-B-YPgHi7.mjs';
|
|
5
|
-
|
|
6
|
-
const resolveMaybeFactory = (value, env) => typeof value === "function" ? value(env) : value;
|
|
7
|
-
const receiptError = (receipt) => receipt.successful ? void 0 : receipt.errorMessages.join("; ");
|
|
8
|
-
const pushDeliveryStatus = (receipt, error) => {
|
|
9
|
-
if (receipt.successful) {
|
|
10
|
-
return "accepted";
|
|
11
|
-
}
|
|
12
|
-
return isGoneError(error) ? "gone" : "failed";
|
|
13
|
-
};
|
|
14
|
-
const mapWithConcurrency = async (items, limit, task) => {
|
|
15
|
-
const results = Array.from({ length: items.length });
|
|
16
|
-
let cursor = 0;
|
|
17
|
-
const worker = async () => {
|
|
18
|
-
while (cursor < items.length) {
|
|
19
|
-
const index = cursor;
|
|
20
|
-
cursor += 1;
|
|
21
|
-
results[index] = await task(items[index]);
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
25
|
-
return results;
|
|
26
|
-
};
|
|
27
|
-
const resolveProviders = (definition, env) => {
|
|
28
|
-
return {
|
|
29
|
-
chat: resolveMaybeFactory(definition.chat, env),
|
|
30
|
-
fcm: resolveMaybeFactory(definition.fcm, env),
|
|
31
|
-
inApp: resolveMaybeFactory(definition.inApp, env),
|
|
32
|
-
webhook: resolveMaybeFactory(definition.webhook, env),
|
|
33
|
-
webPush: resolveMaybeFactory(definition.webPush, env)
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
const runtimeCache = /* @__PURE__ */ new WeakMap();
|
|
37
|
-
const runtimeFor = (definition, env) => {
|
|
38
|
-
let byEnv = runtimeCache.get(definition);
|
|
39
|
-
if (byEnv === void 0) {
|
|
40
|
-
byEnv = /* @__PURE__ */ new WeakMap();
|
|
41
|
-
runtimeCache.set(definition, byEnv);
|
|
42
|
-
}
|
|
43
|
-
let runtime = byEnv.get(env);
|
|
44
|
-
if (runtime === void 0) {
|
|
45
|
-
runtime = { warnedNoStore: false };
|
|
46
|
-
byEnv.set(env, runtime);
|
|
47
|
-
}
|
|
48
|
-
return runtime;
|
|
49
|
-
};
|
|
50
|
-
const createNotify = (definition, env, options = {}) => {
|
|
51
|
-
const runtime = runtimeFor(definition, env);
|
|
52
|
-
let engine;
|
|
53
|
-
if (options.engine === void 0) {
|
|
54
|
-
runtime.engine ??= buildEngine(resolveProviders(definition, env));
|
|
55
|
-
engine = runtime.engine;
|
|
56
|
-
} else {
|
|
57
|
-
engine = options.engine;
|
|
58
|
-
}
|
|
59
|
-
let store = definition.store?.(env);
|
|
60
|
-
if (store === void 0) {
|
|
61
|
-
runtime.fallbackStore ??= memorySubscriptionStore();
|
|
62
|
-
if (!options.silent && !runtime.warnedNoStore) {
|
|
63
|
-
runtime.warnedNoStore = true;
|
|
64
|
-
console.warn(
|
|
65
|
-
"@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production."
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
store = runtime.fallbackStore;
|
|
69
|
-
}
|
|
70
|
-
const subscriptionStore = store;
|
|
71
|
-
const concurrency = Math.max(1, options.concurrency ?? 10);
|
|
72
|
-
const { log, metrics } = options;
|
|
73
|
-
const countSend = (channel, provider, status, count = 1) => {
|
|
74
|
-
metrics?.count("notify.send", count, { channel, provider: provider ?? channel, status });
|
|
75
|
-
};
|
|
76
|
-
const warnFailedSend = (channel, provider, fields) => {
|
|
77
|
-
log?.warn(`notify ${channel} delivery failed`, { channel, provider: provider ?? channel, status: "failed", ...fields });
|
|
78
|
-
};
|
|
79
|
-
const observeSkip = (channel, reason) => {
|
|
80
|
-
metrics?.count("notify.skipped", 1, { channel, reason });
|
|
81
|
-
};
|
|
82
|
-
const resolveSubscription = async (target) => {
|
|
83
|
-
if (typeof target !== "string") {
|
|
84
|
-
return target;
|
|
85
|
-
}
|
|
86
|
-
const found = await subscriptionStore.get(target);
|
|
87
|
-
if (found === void 0) {
|
|
88
|
-
throw new LunoraError("BAD_REQUEST", `@lunora/notify: no registered subscription with id "${target}"`);
|
|
89
|
-
}
|
|
90
|
-
return found;
|
|
91
|
-
};
|
|
92
|
-
const deliver = async (subscription, payload, countInline) => {
|
|
93
|
-
const receipt = await engine.sendToChannel("push", { ...payload, to: targetOf(subscription) });
|
|
94
|
-
const error = receiptError(receipt);
|
|
95
|
-
const status = pushDeliveryStatus(receipt, error);
|
|
96
|
-
if (status === "accepted") {
|
|
97
|
-
await subscriptionStore.markStatus(subscription.id, "ok");
|
|
98
|
-
} else if (status === "gone") {
|
|
99
|
-
await subscriptionStore.delete(subscription.id);
|
|
100
|
-
} else {
|
|
101
|
-
await subscriptionStore.markStatus(subscription.id, "failed", error);
|
|
102
|
-
}
|
|
103
|
-
if (status === "failed") {
|
|
104
|
-
warnFailedSend("push", subscription.kind, { error, subscriptionId: subscription.id, userId: subscription.userId ?? null });
|
|
105
|
-
}
|
|
106
|
-
if (countInline) {
|
|
107
|
-
countSend("push", subscription.kind, status);
|
|
108
|
-
}
|
|
109
|
-
return { receipt, status };
|
|
110
|
-
};
|
|
111
|
-
const push = {
|
|
112
|
-
broadcast: async (payload, filter) => {
|
|
113
|
-
const subscriptions = await subscriptionStore.list(filter);
|
|
114
|
-
if (subscriptions.length === 0) {
|
|
115
|
-
observeSkip("push", "no-subscriptions-matched");
|
|
116
|
-
}
|
|
117
|
-
const rows = await mapWithConcurrency(subscriptions, concurrency, async (subscription) => {
|
|
118
|
-
const { receipt, status } = await deliver(subscription, payload, false);
|
|
119
|
-
return { kind: subscription.kind, receipt, status, subscription };
|
|
120
|
-
});
|
|
121
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
122
|
-
for (const { kind, status } of rows) {
|
|
123
|
-
const key = `${kind}\0${status}`;
|
|
124
|
-
const bucket = buckets.get(key);
|
|
125
|
-
if (bucket === void 0) {
|
|
126
|
-
buckets.set(key, { count: 1, kind, status });
|
|
127
|
-
} else {
|
|
128
|
-
bucket.count += 1;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
for (const { count, kind, status } of buckets.values()) {
|
|
132
|
-
countSend("push", kind, status, count);
|
|
133
|
-
}
|
|
134
|
-
const outcomes = rows.map(({ receipt, status, subscription }) => {
|
|
135
|
-
if (status === "accepted") {
|
|
136
|
-
return { id: subscription.id, status: "ok" };
|
|
137
|
-
}
|
|
138
|
-
const error = receiptError(receipt);
|
|
139
|
-
return status === "gone" ? { error, id: subscription.id, status: "expired" } : { error, id: subscription.id, status: "failed" };
|
|
140
|
-
});
|
|
141
|
-
return {
|
|
142
|
-
failed: outcomes.filter((outcome) => outcome.status === "failed").length,
|
|
143
|
-
outcomes,
|
|
144
|
-
pruned: outcomes.filter((outcome) => outcome.status === "expired").length,
|
|
145
|
-
sent: outcomes.filter((outcome) => outcome.status === "ok").length,
|
|
146
|
-
total: outcomes.length
|
|
147
|
-
};
|
|
148
|
-
},
|
|
149
|
-
list: (filter) => subscriptionStore.list(filter),
|
|
150
|
-
register: (input) => subscriptionStore.put(normalizeRegisterInput(input)),
|
|
151
|
-
send: async (target, payload) => {
|
|
152
|
-
const { receipt } = await deliver(await resolveSubscription(target), payload, true);
|
|
153
|
-
return receipt;
|
|
154
|
-
},
|
|
155
|
-
unregister: (id) => subscriptionStore.delete(id)
|
|
156
|
-
};
|
|
157
|
-
const sendToChannel = async (channel, payload) => {
|
|
158
|
-
if (engine.getProvider(channel) === void 0) {
|
|
159
|
-
observeSkip(channel, "channel-not-configured");
|
|
160
|
-
throw new LunoraError("BAD_REQUEST", `@lunora/notify: the "${channel}" channel is not configured in defineNotify(...)`);
|
|
161
|
-
}
|
|
162
|
-
const receipt = await engine.sendToChannel(channel, payload);
|
|
163
|
-
const status = receipt.successful ? "accepted" : "failed";
|
|
164
|
-
countSend(channel, receipt.provider, status);
|
|
165
|
-
if (status === "failed") {
|
|
166
|
-
warnFailedSend(channel, receipt.provider, { error: receiptError(receipt) });
|
|
167
|
-
}
|
|
168
|
-
return receipt;
|
|
169
|
-
};
|
|
170
|
-
const notify = {
|
|
171
|
-
chat: (payload) => sendToChannel("chat", payload),
|
|
172
|
-
inApp: (payload) => sendToChannel("inapp", payload),
|
|
173
|
-
push,
|
|
174
|
-
send: async (message) => {
|
|
175
|
-
const receipts = await engine.send(message);
|
|
176
|
-
for (const receipt of receipts) {
|
|
177
|
-
const channel = receipt.channel ?? "unknown";
|
|
178
|
-
const status = receipt.successful ? "accepted" : "failed";
|
|
179
|
-
countSend(channel, receipt.provider, status);
|
|
180
|
-
if (status === "failed") {
|
|
181
|
-
warnFailedSend(channel, receipt.provider, { error: receiptError(receipt) });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return receipts;
|
|
185
|
-
},
|
|
186
|
-
webhook: (payload) => sendToChannel("webhook", payload)
|
|
187
|
-
};
|
|
188
|
-
return { notify, push };
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
export { createNotify };
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const rowToSubscription = (row) => {
|
|
4
|
-
const subscription = {
|
|
5
|
-
createdAt: row.created_at,
|
|
6
|
-
id: row.id,
|
|
7
|
-
kind: row.kind,
|
|
8
|
-
lastSeenAt: row.last_seen_at,
|
|
9
|
-
userId: row.user_id
|
|
10
|
-
};
|
|
11
|
-
if (row.endpoint !== null) {
|
|
12
|
-
subscription.endpoint = row.endpoint;
|
|
13
|
-
}
|
|
14
|
-
if (row.p256dh !== null && row.auth !== null) {
|
|
15
|
-
subscription.keys = { auth: row.auth, p256dh: row.p256dh };
|
|
16
|
-
}
|
|
17
|
-
if (row.token !== null) {
|
|
18
|
-
subscription.token = row.token;
|
|
19
|
-
}
|
|
20
|
-
if (row.last_status !== null) {
|
|
21
|
-
subscription.lastStatus = row.last_status;
|
|
22
|
-
}
|
|
23
|
-
if (row.last_error !== null) {
|
|
24
|
-
subscription.lastError = row.last_error;
|
|
25
|
-
}
|
|
26
|
-
if (row.metadata !== null) {
|
|
27
|
-
try {
|
|
28
|
-
subscription.metadata = JSON.parse(row.metadata);
|
|
29
|
-
} catch {
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return subscription;
|
|
33
|
-
};
|
|
34
|
-
const IDENTIFIER_PATTERN = /^[A-Za-z_]\w*$/u;
|
|
35
|
-
const d1SubscriptionStore = (database, options = {}) => {
|
|
36
|
-
const table = options.tableName ?? "lunora_push_subscriptions";
|
|
37
|
-
if (!IDENTIFIER_PATTERN.test(table)) {
|
|
38
|
-
throw new LunoraError("BAD_REQUEST", `@lunora/notify: d1SubscriptionStore tableName "${table}" is not a bare SQL identifier`);
|
|
39
|
-
}
|
|
40
|
-
let schemaReady;
|
|
41
|
-
const ensureSchema = () => {
|
|
42
|
-
if (schemaReady === void 0) {
|
|
43
|
-
schemaReady = database.prepare(
|
|
44
|
-
`CREATE TABLE IF NOT EXISTS ${table} (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)`
|
|
45
|
-
).run().then(() => void 0);
|
|
46
|
-
schemaReady.catch(() => {
|
|
47
|
-
schemaReady = void 0;
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return schemaReady;
|
|
51
|
-
};
|
|
52
|
-
const put = async (subscription) => {
|
|
53
|
-
await ensureSchema();
|
|
54
|
-
await database.prepare(
|
|
55
|
-
`INSERT INTO ${table} (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, last_status = ?11, last_error = ?12`
|
|
56
|
-
).bind(
|
|
57
|
-
subscription.id,
|
|
58
|
-
subscription.kind,
|
|
59
|
-
subscription.endpoint ?? null,
|
|
60
|
-
subscription.keys?.p256dh ?? null,
|
|
61
|
-
subscription.keys?.auth ?? null,
|
|
62
|
-
subscription.token ?? null,
|
|
63
|
-
subscription.userId ?? null,
|
|
64
|
-
subscription.metadata === void 0 ? null : JSON.stringify(subscription.metadata),
|
|
65
|
-
subscription.createdAt,
|
|
66
|
-
subscription.lastSeenAt,
|
|
67
|
-
subscription.lastStatus ?? null,
|
|
68
|
-
subscription.lastError ?? null
|
|
69
|
-
).run();
|
|
70
|
-
return subscription;
|
|
71
|
-
};
|
|
72
|
-
const get = async (id) => {
|
|
73
|
-
await ensureSchema();
|
|
74
|
-
const row = await database.prepare(`SELECT * FROM ${table} WHERE id = ?1`).bind(id).first();
|
|
75
|
-
return row === null ? void 0 : rowToSubscription(row);
|
|
76
|
-
};
|
|
77
|
-
const remove = async (id) => {
|
|
78
|
-
await ensureSchema();
|
|
79
|
-
await database.prepare(`DELETE FROM ${table} WHERE id = ?1`).bind(id).run();
|
|
80
|
-
};
|
|
81
|
-
const list = async (filter) => {
|
|
82
|
-
await ensureSchema();
|
|
83
|
-
const clauses = [];
|
|
84
|
-
const bindings = [];
|
|
85
|
-
if (filter?.kind !== void 0) {
|
|
86
|
-
bindings.push(filter.kind);
|
|
87
|
-
clauses.push(`kind = ?${bindings.length.toString()}`);
|
|
88
|
-
}
|
|
89
|
-
if (filter?.userId !== void 0) {
|
|
90
|
-
bindings.push(filter.userId);
|
|
91
|
-
clauses.push(filter.userId === null ? "user_id IS NULL" : `user_id = ?${bindings.length.toString()}`);
|
|
92
|
-
if (filter.userId === null) {
|
|
93
|
-
bindings.pop();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`;
|
|
97
|
-
const { results } = await database.prepare(`SELECT * FROM ${table}${where}`).bind(...bindings).all();
|
|
98
|
-
return results.map((row) => rowToSubscription(row));
|
|
99
|
-
};
|
|
100
|
-
const markStatus = async (id, status, error) => {
|
|
101
|
-
await ensureSchema();
|
|
102
|
-
await database.prepare(`UPDATE ${table} SET last_status = ?2, last_error = ?3, last_seen_at = ?4 WHERE id = ?1`).bind(id, status, error ?? null, Date.now()).run();
|
|
103
|
-
};
|
|
104
|
-
return { delete: remove, get, list, markStatus, put };
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
export { d1SubscriptionStore };
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
const defineNotify = (config) => {
|
|
2
|
-
if (config.webPush !== void 0 && typeof config.webPush !== "function" && typeof config.webPush !== "object") {
|
|
3
|
-
throw new TypeError("defineNotify: `webPush` must be a WebPushConfig object or an `(env) => WebPushConfig` function");
|
|
4
|
-
}
|
|
5
|
-
if (config.fcm !== void 0 && typeof config.fcm !== "function" && typeof config.fcm !== "object") {
|
|
6
|
-
throw new TypeError("defineNotify: `fcm` must be an FcmConfig object or an `(env) => FcmConfig` function");
|
|
7
|
-
}
|
|
8
|
-
if (config.store !== void 0 && typeof config.store !== "function") {
|
|
9
|
-
throw new TypeError("defineNotify: `store` must be a function `(env) => SubscriptionStore` when provided");
|
|
10
|
-
}
|
|
11
|
-
if (config.webPush === void 0 && config.fcm === void 0) {
|
|
12
|
-
throw new TypeError("defineNotify: configure at least one push channel — `webPush` and/or `fcm`");
|
|
13
|
-
}
|
|
14
|
-
return { ...config, isLunoraNotify: true };
|
|
15
|
-
};
|
|
16
|
-
const isNotifyDefinition = (value) => typeof value === "object" && value !== null && value.isLunoraNotify === true;
|
|
17
|
-
|
|
18
|
-
export { defineNotify, isNotifyDefinition };
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const fnv1a = (input) => {
|
|
4
|
-
let hash = 2166136261;
|
|
5
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
6
|
-
hash ^= input.codePointAt(index) ?? 0;
|
|
7
|
-
hash = Math.imul(hash, 16777619);
|
|
8
|
-
}
|
|
9
|
-
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
10
|
-
};
|
|
11
|
-
const webPushId = (endpoint) => `wp_${fnv1a(endpoint)}`;
|
|
12
|
-
const fcmId = (token) => `fcm_${fnv1a(token)}`;
|
|
13
|
-
const parseSubscription = (subscription) => {
|
|
14
|
-
if (typeof subscription !== "string") {
|
|
15
|
-
return subscription ?? {};
|
|
16
|
-
}
|
|
17
|
-
try {
|
|
18
|
-
return JSON.parse(subscription);
|
|
19
|
-
} catch (error) {
|
|
20
|
-
throw new LunoraError(
|
|
21
|
-
"BAD_REQUEST",
|
|
22
|
-
`@lunora/notify: register() web-push subscription is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
const normalizeRegisterInput = (input, now = Date.now()) => {
|
|
27
|
-
if ("token" in input) {
|
|
28
|
-
const { token } = input;
|
|
29
|
-
if (typeof token !== "string" || token === "") {
|
|
30
|
-
throw new LunoraError("BAD_REQUEST", "@lunora/notify: register() fcm input requires a non-empty `token`");
|
|
31
|
-
}
|
|
32
|
-
return { createdAt: now, id: fcmId(token), kind: "fcm", lastSeenAt: now, metadata: input.metadata, token, userId: input.userId ?? null };
|
|
33
|
-
}
|
|
34
|
-
const subscription = parseSubscription(input.subscription);
|
|
35
|
-
const { endpoint } = subscription;
|
|
36
|
-
const p256dh = subscription.keys?.p256dh;
|
|
37
|
-
const auth = subscription.keys?.auth;
|
|
38
|
-
if (typeof endpoint !== "string" || endpoint === "" || typeof p256dh !== "string" || typeof auth !== "string") {
|
|
39
|
-
throw new LunoraError("BAD_REQUEST", "@lunora/notify: register() web-push subscription requires `endpoint` and `keys.{p256dh, auth}`");
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
createdAt: now,
|
|
43
|
-
endpoint,
|
|
44
|
-
id: webPushId(endpoint),
|
|
45
|
-
keys: { auth, p256dh },
|
|
46
|
-
kind: "web-push",
|
|
47
|
-
lastSeenAt: now,
|
|
48
|
-
metadata: input.metadata,
|
|
49
|
-
userId: input.userId ?? null
|
|
50
|
-
};
|
|
51
|
-
};
|
|
52
|
-
const targetOf = (subscription) => {
|
|
53
|
-
if (subscription.kind === "fcm") {
|
|
54
|
-
return subscription.token ?? "";
|
|
55
|
-
}
|
|
56
|
-
return JSON.stringify({ endpoint: subscription.endpoint, keys: subscription.keys });
|
|
57
|
-
};
|
|
58
|
-
const WEB_PUSH_GONE_PATTERN = /\bhttp\s*4(?:04|10)\b/iu;
|
|
59
|
-
const FCM_GONE_PATTERN = /\b(?:unregistered|not[\s-]?registered|registration-token-not-registered)\b/iu;
|
|
60
|
-
const GONE_TEXT_FALLBACK = /\bsubscription (?:is )?(?:gone|expired|no longer valid)\b/iu;
|
|
61
|
-
const isGoneError = (message) => {
|
|
62
|
-
if (message === void 0) {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
return WEB_PUSH_GONE_PATTERN.test(message) || FCM_GONE_PATTERN.test(message) || GONE_TEXT_FALLBACK.test(message);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export { fcmId, isGoneError, normalizeRegisterInput, targetOf, webPushId };
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
const matches = (subscription, filter) => {
|
|
2
|
-
if (filter === void 0) {
|
|
3
|
-
return true;
|
|
4
|
-
}
|
|
5
|
-
if (filter.kind !== void 0 && subscription.kind !== filter.kind) {
|
|
6
|
-
return false;
|
|
7
|
-
}
|
|
8
|
-
if (filter.userId !== void 0 && (subscription.userId ?? null) !== filter.userId) {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
return true;
|
|
12
|
-
};
|
|
13
|
-
const memorySubscriptionStore = () => {
|
|
14
|
-
const map = /* @__PURE__ */ new Map();
|
|
15
|
-
return {
|
|
16
|
-
delete: (id) => {
|
|
17
|
-
map.delete(id);
|
|
18
|
-
return Promise.resolve();
|
|
19
|
-
},
|
|
20
|
-
get: (id) => Promise.resolve(map.get(id)),
|
|
21
|
-
list: (filter) => {
|
|
22
|
-
const result = [];
|
|
23
|
-
for (const subscription of map.values()) {
|
|
24
|
-
if (matches(subscription, filter)) {
|
|
25
|
-
result.push(subscription);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return Promise.resolve(result);
|
|
29
|
-
},
|
|
30
|
-
markStatus: (id, status, error) => {
|
|
31
|
-
const existing = map.get(id);
|
|
32
|
-
if (existing !== void 0) {
|
|
33
|
-
map.set(id, { ...existing, lastError: error, lastSeenAt: Date.now(), lastStatus: status });
|
|
34
|
-
}
|
|
35
|
-
return Promise.resolve();
|
|
36
|
-
},
|
|
37
|
-
put: (subscription) => {
|
|
38
|
-
const existing = map.get(subscription.id);
|
|
39
|
-
const merged = existing === void 0 ? subscription : { ...existing, ...subscription, createdAt: existing.createdAt };
|
|
40
|
-
map.set(merged.id, merged);
|
|
41
|
-
return Promise.resolve(merged);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export { memorySubscriptionStore };
|