@lunora/notify 1.0.0-alpha.9 → 1.0.0-alpha.90
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 +115 -20
- package/dist/index.d.mts +399 -102
- package/dist/index.d.ts +399 -102
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs +1 -0
- package/dist/packem_shared/buildEngine-x7r_sr3M.mjs +1 -0
- package/dist/packem_shared/createNotify-hH_Y5OJQ.mjs +1 -0
- package/dist/packem_shared/d1SubscriptionStore-D80r22Vc.mjs +1 -0
- package/dist/packem_shared/defineNotify-DVPpVkU0.mjs +1 -0
- package/dist/packem_shared/enqueuePushBroadcast-C9tO0BZj.mjs +1 -0
- package/dist/packem_shared/fcmId-o_liCQKp.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-Ccr3ErTV.mjs +1 -0
- package/dist/packem_shared/normalize-B5B9QUol.mjs +1 -0
- package/dist/web.d.mts +48 -18
- package/dist/web.d.ts +48 -18
- package/dist/web.mjs +1 -1
- package/package.json +3 -3
- package/dist/packem_shared/FCM_ENV_KEYS-D0tvRTLQ.mjs +0 -1
- package/dist/packem_shared/buildEngine-Jlzf2Jzn.mjs +0 -1
- package/dist/packem_shared/createNotify-jNlehALj.mjs +0 -1
- package/dist/packem_shared/d1SubscriptionStore-67jpK0Vx.mjs +0 -1
- package/dist/packem_shared/defineNotify-DLEy53HJ.mjs +0 -1
- package/dist/packem_shared/enqueuePushBroadcast-tf1LqgzA.mjs +0 -1
- package/dist/packem_shared/fcmId-CFZ8IquF.mjs +0 -1
- package/dist/packem_shared/memorySubscriptionStore-FKZR4-fB.mjs +0 -1
package/dist/index.d.mts
CHANGED
|
@@ -15,6 +15,15 @@ type NotifyEnv = Record<string, unknown>;
|
|
|
15
15
|
type SubscriptionKind = "fcm" | "web-push";
|
|
16
16
|
/** The last-known delivery outcome recorded on a subscription. */
|
|
17
17
|
type SubscriptionStatus = "expired" | "failed" | "ok";
|
|
18
|
+
/** The delivery outcomes of one broadcast page, grouped for {@link SubscriptionStore.applyOutcomes}. */
|
|
19
|
+
interface SubscriptionOutcomes {
|
|
20
|
+
failed: {
|
|
21
|
+
error?: string;
|
|
22
|
+
id: string;
|
|
23
|
+
}[];
|
|
24
|
+
gone: string[];
|
|
25
|
+
ok: string[];
|
|
26
|
+
}
|
|
18
27
|
/**
|
|
19
28
|
* A registered device/browser subscription. Web Push carries a W3C Push API
|
|
20
29
|
* `endpoint` + `keys`; FCM carries a device registration `token`. `id` is a
|
|
@@ -111,7 +120,7 @@ interface SubscriptionFilter {
|
|
|
111
120
|
* subscriptions reached across every internally-walked page (still
|
|
112
121
|
* deliberately left unset by default — it must reach every matched
|
|
113
122
|
* device); the PER-PAGE batch size is a separate, independent knob (see
|
|
114
|
-
* `
|
|
123
|
+
* `defineNotify`'s `broadcastPageSize`, default 250) so a caller
|
|
115
124
|
* that sets `limit` to bound the audience doesn't also have to reason
|
|
116
125
|
* about page sizing.
|
|
117
126
|
*
|
|
@@ -136,8 +145,39 @@ interface SubscriptionFilter {
|
|
|
136
145
|
* and a D1-backed store (durable, edge-safe).
|
|
137
146
|
*/
|
|
138
147
|
interface SubscriptionStore {
|
|
148
|
+
/**
|
|
149
|
+
* Apply a whole broadcast page's delivery outcomes at once (best-effort):
|
|
150
|
+
* `ok` ids are marked ok, `failed` ids marked failed with their error, `gone`
|
|
151
|
+
* ids deleted. OPTIONAL — when absent, `broadcast` calls `markStatus` /
|
|
152
|
+
* `delete` per id.
|
|
153
|
+
*/
|
|
154
|
+
applyOutcomes?: (outcomes: SubscriptionOutcomes) => Promise<void>;
|
|
139
155
|
/** Remove a subscription by id (idempotent). */
|
|
140
156
|
delete: (id: string) => Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Remove a subscription by id ONLY if it is owned by `userId`, and report
|
|
159
|
+
* whether it was.
|
|
160
|
+
*
|
|
161
|
+
* Separate from {@link SubscriptionStore.delete} because the caller-facing
|
|
162
|
+
* `unregister` must not be a read followed by a write: between a `get` that
|
|
163
|
+
* checks the owner and a `delete` that acts on it, a re-registration can
|
|
164
|
+
* replace the row, so the check passes for one owner and the removal lands on
|
|
165
|
+
* another's subscription.
|
|
166
|
+
*
|
|
167
|
+
* **The predicate and the removal must be ONE operation.** A store that
|
|
168
|
+
* cannot do that atomically should say so in its own documentation rather
|
|
169
|
+
* than implement this as a get-then-delete, which reintroduces the race this
|
|
170
|
+
* method exists to remove. Both shipped stores manage it: the in-memory one
|
|
171
|
+
* because a `Map` check-and-delete has no await between the two, and the D1
|
|
172
|
+
* one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
|
|
173
|
+
*
|
|
174
|
+
* `userId` is `null` for an anonymous subscription, and matches only a row
|
|
175
|
+
* that is itself unowned.
|
|
176
|
+
* @param id The subscription id.
|
|
177
|
+
* @param userId The owner the row must carry, or `null` for unowned.
|
|
178
|
+
* @returns `true` when a row was removed.
|
|
179
|
+
*/
|
|
180
|
+
deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
|
|
141
181
|
/** Read a subscription by id, or `undefined`. */
|
|
142
182
|
get: (id: string) => Promise<StoredSubscription | undefined>;
|
|
143
183
|
/**
|
|
@@ -150,7 +190,33 @@ interface SubscriptionStore {
|
|
|
150
190
|
list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
|
|
151
191
|
/** Record the latest delivery outcome for a subscription (best-effort). */
|
|
152
192
|
markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
|
|
153
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Insert or update a subscription (upsert by id), refusing to move an
|
|
195
|
+
* existing row to a DIFFERENT owner.
|
|
196
|
+
*
|
|
197
|
+
* The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
|
|
198
|
+
* carries, and for the same reason: the id is derived from the endpoint or the
|
|
199
|
+
* FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
|
|
200
|
+
* caller who could guess or observe another user's endpoint re-register it
|
|
201
|
+
* under their own `userId` with keys of their choosing — the victim's device
|
|
202
|
+
* then fails every send (an encryption failure is not a gone signal, so it is
|
|
203
|
+
* never pruned either) and the attacker can `unregister` it as their own. The
|
|
204
|
+
* `unregister` guard alone closed exactly half of that (CWE-639).
|
|
205
|
+
*
|
|
206
|
+
* A row with no owner is claimable (the device signed in), and a row the caller
|
|
207
|
+
* already owns is theirs to refresh — that is the routine service-worker
|
|
208
|
+
* re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
|
|
209
|
+
* no-op: unlike `unregister` there is nothing safe to return, since a caller
|
|
210
|
+
* gets the stored record back and it would be someone else's delivery keys.
|
|
211
|
+
* A shared device that legitimately changes hands is handled by its current
|
|
212
|
+
* owner calling `unregister` first.
|
|
213
|
+
*
|
|
214
|
+
* **The predicate and the write must be ONE operation**, as for `deleteOwned`:
|
|
215
|
+
* between a read that checks the owner and the write that acts on it, another
|
|
216
|
+
* registration can replace the row. The D1 store puts the predicate in the
|
|
217
|
+
* `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
|
|
218
|
+
* between the two.
|
|
219
|
+
*/
|
|
154
220
|
put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
|
|
155
221
|
}
|
|
156
222
|
/** Per-recipient outcome from a fan-out `broadcast`. */
|
|
@@ -194,7 +260,7 @@ interface BroadcastPageResult {
|
|
|
194
260
|
*
|
|
195
261
|
* - `accepted` — the provider took the message (a `Receipt.successful` send).
|
|
196
262
|
* - `failed` — a provider error; the log line carries the `error` text.
|
|
197
|
-
* - `gone` — the endpoint is unregistered (404/410, FCM `
|
|
263
|
+
* - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
|
|
198
264
|
*
|
|
199
265
|
* Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
|
|
200
266
|
* send attempt: a `delivered`/`opened` status would be a lie for these channels.
|
|
@@ -239,26 +305,28 @@ interface LunoraPush {
|
|
|
239
305
|
/**
|
|
240
306
|
* Fan-out a push to every stored subscription matching `filter` (default: all).
|
|
241
307
|
* Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
|
|
242
|
-
* the push service reports as gone (
|
|
308
|
+
* the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
|
|
243
309
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
244
310
|
*
|
|
245
311
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
246
|
-
* keyset-paginated on the subscription `id`) so
|
|
247
|
-
* materialized wholesale in the isolate — see `
|
|
248
|
-
* `broadcastPageSize`.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
312
|
+
* keyset-paginated on the subscription `id`) so the audience ROWS are never
|
|
313
|
+
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
314
|
+
* `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
|
|
315
|
+
* `{ id, status }` per recipient accumulates across every page, so this call is
|
|
316
|
+
* bounded in rows held but not in outcomes reported. It also processes the WHOLE
|
|
317
|
+
* matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
318
|
+
* directly (as `runPushBroadcastPage` does) to bound a single queue message —
|
|
319
|
+
* and its result — to one page.
|
|
252
320
|
*/
|
|
253
321
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
254
322
|
/**
|
|
255
323
|
* Fan-out a push to ONE bounded page of stored subscriptions matching
|
|
256
|
-
* `filter` (page size: `
|
|
324
|
+
* `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
|
|
257
325
|
* 250, capped by `filter.limit` when set). Same delivery semantics as
|
|
258
326
|
* {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
|
|
259
327
|
* scoped to a single page; returns the page's own {@link BroadcastResult}
|
|
260
328
|
* plus a `nextCursor` to fetch the next page (`undefined` when done).
|
|
261
|
-
* Backs `
|
|
329
|
+
* Backs `runPushBroadcastPage` so one queue message does bounded work
|
|
262
330
|
* regardless of audience size — most app code should call
|
|
263
331
|
* {@link LunoraPush.broadcast} instead.
|
|
264
332
|
*/
|
|
@@ -267,18 +335,66 @@ interface LunoraPush {
|
|
|
267
335
|
* List stored subscriptions (optionally filtered), with the delivery
|
|
268
336
|
* **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
|
|
269
337
|
* FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
|
|
270
|
-
* a device, so
|
|
338
|
+
* a device, so no READ on this facade returns them; the raw rows are otherwise
|
|
271
339
|
* reachable only through the internal `SubscriptionStore`.
|
|
340
|
+
*
|
|
341
|
+
* {@link LunoraPush.register} is the one exception, and deliberately so: it
|
|
342
|
+
* echoes back the record the caller just supplied, so it discloses nothing
|
|
343
|
+
* the caller did not already hold and never another device's row.
|
|
272
344
|
*/
|
|
273
345
|
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
274
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Register (upsert) a device subscription and return the stored record (the
|
|
348
|
+
* caller's own row, secrets included).
|
|
349
|
+
*
|
|
350
|
+
* Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
|
|
351
|
+
* endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
|
|
352
|
+
* than re-owning it. Pass `ctx.auth?.userId` so the check has something to
|
|
353
|
+
* separate; an app that registers every device anonymously gets no separation
|
|
354
|
+
* from it (every row is unowned, and unowned rows stay claimable). A device
|
|
355
|
+
* that legitimately changes hands — one browser profile, two accounts —
|
|
356
|
+
* unregisters as its current owner first.
|
|
357
|
+
*/
|
|
275
358
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
276
359
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
277
360
|
send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
|
|
278
|
-
/**
|
|
279
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Remove ONE of `owner`'s subscriptions by id (idempotent).
|
|
363
|
+
*
|
|
364
|
+
* `owner` is not optional, and the removal happens only when the stored row
|
|
365
|
+
* carries that same owner. A subscription id is derived from the endpoint
|
|
366
|
+
* (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
|
|
367
|
+
* intended call is a mutation forwarding `subscribeToPush`'s
|
|
368
|
+
* `replacedEndpoint` after a VAPID rotation, and nothing about that argument
|
|
369
|
+
* proves the browser sending it ever held the subscription it names.
|
|
370
|
+
* Deleting by id alone let any caller that could guess or observe another
|
|
371
|
+
* user's endpoint silence that device's notifications (CWE-639).
|
|
372
|
+
*
|
|
373
|
+
* A row belonging to someone else is left alone SILENTLY rather than
|
|
374
|
+
* refused, so the call cannot be used to probe which endpoints exist — the
|
|
375
|
+
* same answer, and the same absence of a write, as an id that was never
|
|
376
|
+
* registered.
|
|
377
|
+
*
|
|
378
|
+
* `{ userId: null }` (or `undefined`, which normalises to it) addresses the
|
|
379
|
+
* anonymous rows — those registered with no `userId`. An app that registers
|
|
380
|
+
* every device anonymously therefore gets no separation from this check;
|
|
381
|
+
* pass `ctx.auth?.userId` and register with it to get any.
|
|
382
|
+
*/
|
|
383
|
+
unregister: (id: string, owner: PushOwner) => Promise<void>;
|
|
384
|
+
}
|
|
385
|
+
/** Who a {@link LunoraPush.unregister} call is acting as. */
|
|
386
|
+
interface PushOwner {
|
|
387
|
+
/**
|
|
388
|
+
* The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
|
|
389
|
+
* an anonymous registration. Required — see {@link LunoraPush.unregister}.
|
|
390
|
+
*/
|
|
391
|
+
userId: string | null | undefined;
|
|
280
392
|
}
|
|
281
|
-
/**
|
|
393
|
+
/**
|
|
394
|
+
* A push payload without its `to` target — the facade derives `to` from the
|
|
395
|
+
* stored subscription. Its `ttl` / `urgency` override the channel defaults per
|
|
396
|
+
* message on every push transport (Web Push, FCM, APNs, Expo).
|
|
397
|
+
*/
|
|
282
398
|
type PushContent = Omit<PushPayload, "to">;
|
|
283
399
|
/**
|
|
284
400
|
* The multi-channel notification facade — spliced onto ctx as `ctx.notify`.
|
|
@@ -287,7 +403,7 @@ type PushContent = Omit<PushPayload, "to">;
|
|
|
287
403
|
* single-channel convenience senders for the edge-safe channels.
|
|
288
404
|
*/
|
|
289
405
|
interface LunoraNotify {
|
|
290
|
-
/**
|
|
406
|
+
/** Post to a chat channel (Slack/Discord/Teams/Telegram). */
|
|
291
407
|
chat: (payload: ChatPayload) => Promise<Receipt>;
|
|
292
408
|
/** Deliver an in-app inbox notification. */
|
|
293
409
|
inApp: (payload: InAppPayload) => Promise<Receipt>;
|
|
@@ -295,7 +411,7 @@ interface LunoraNotify {
|
|
|
295
411
|
push: LunoraPush;
|
|
296
412
|
/** Deliver a multi-channel message (one payload per channel). */
|
|
297
413
|
send: (message: NotificationMessage) => Promise<Receipt[]>;
|
|
298
|
-
/**
|
|
414
|
+
/** Send an outbound webhook. */
|
|
299
415
|
webhook: (payload: WebhookPayload) => Promise<Receipt>;
|
|
300
416
|
}
|
|
301
417
|
/**
|
|
@@ -323,11 +439,32 @@ interface NotifyConfig {
|
|
|
323
439
|
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
324
440
|
*/
|
|
325
441
|
allowedPushOrigins?: string[];
|
|
442
|
+
/**
|
|
443
|
+
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
444
|
+
* subscription store (default 250, minimum 1). Each page is fetched,
|
|
445
|
+
* delivered, and counted before the next page's store round trip, so a huge
|
|
446
|
+
* audience is never materialized wholesale in the isolate. Also the
|
|
447
|
+
* per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
|
|
448
|
+
*
|
|
449
|
+
* Declared here, and not only on `createNotify`'s third argument, because
|
|
450
|
+
* this file is the only handle an app has: the sole production constructor is
|
|
451
|
+
* codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
|
|
452
|
+
* that lives only on those options is unsettable by every Lunora app —
|
|
453
|
+
* while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
|
|
454
|
+
* size pages.
|
|
455
|
+
*/
|
|
456
|
+
broadcastPageSize?: number;
|
|
326
457
|
/**
|
|
327
458
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
328
459
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
329
460
|
*/
|
|
330
461
|
chat?: (env: NotifyEnv) => unknown;
|
|
462
|
+
/**
|
|
463
|
+
* Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
|
|
464
|
+
* Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
|
|
465
|
+
* app can reach it.
|
|
466
|
+
*/
|
|
467
|
+
concurrency?: number;
|
|
331
468
|
/** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
|
|
332
469
|
fcm?: FcmConfig | FcmConfigFactory;
|
|
333
470
|
/** Optional in-app inbox provider factory. Edge-safe. */
|
|
@@ -416,14 +553,18 @@ interface CreateNotifyOptions {
|
|
|
416
553
|
/**
|
|
417
554
|
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
418
555
|
* subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* (
|
|
423
|
-
*
|
|
556
|
+
*
|
|
557
|
+
* A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
|
|
558
|
+
* instead** — the sole production call is codegen's fixed
|
|
559
|
+
* `createNotify(definition, env, { log, metrics })`, so nothing an app writes
|
|
560
|
+
* reaches this object. Set here it wins over the definition's value.
|
|
424
561
|
*/
|
|
425
562
|
broadcastPageSize?: number;
|
|
426
|
-
/**
|
|
563
|
+
/**
|
|
564
|
+
* Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
|
|
565
|
+
* apps set `concurrency` on `defineNotify` — see
|
|
566
|
+
* {@link CreateNotifyOptions.broadcastPageSize}.
|
|
567
|
+
*/
|
|
427
568
|
concurrency?: number;
|
|
428
569
|
/**
|
|
429
570
|
* Override the assembled `@visulima/notification` engine. Advanced/testing
|
|
@@ -467,7 +608,19 @@ declare const createNotify: (definition: NotifyDefinition, env: NotifyEnv, optio
|
|
|
467
608
|
};
|
|
468
609
|
/** Options for {@link routingPushProvider}. */
|
|
469
610
|
interface RoutingPushOptions {
|
|
611
|
+
/**
|
|
612
|
+
* The definition's exact-origin allowlist, when configured. Its presence
|
|
613
|
+
* disables the send-time rebinding re-check (see {@link assertPushTargetResolvable}).
|
|
614
|
+
*/
|
|
615
|
+
allowedPushOrigins?: string[];
|
|
470
616
|
fcm?: Provider<unknown, PushPayload>;
|
|
617
|
+
/**
|
|
618
|
+
* Backoff base for the in-router group retry, in ms (default 250 — the
|
|
619
|
+
* engine's own). Only a TEST double passes anything else, for the same
|
|
620
|
+
* reason {@link ResilienceOptions.retryBaseDelay} exists: a mock that fails
|
|
621
|
+
* on purpose would otherwise spend the real ~1 s of backoff per partial.
|
|
622
|
+
*/
|
|
623
|
+
retryBaseDelay?: number;
|
|
471
624
|
webPush?: Provider<unknown, PushPayload>;
|
|
472
625
|
}
|
|
473
626
|
/**
|
|
@@ -480,6 +633,8 @@ interface RoutingPushOptions {
|
|
|
480
633
|
declare const routingPushProvider: (options: RoutingPushOptions) => Provider<unknown, PushPayload>;
|
|
481
634
|
/** A resolved, ready-to-wire set of channel configs (edge-safe channels only). */
|
|
482
635
|
interface ResolvedProviders {
|
|
636
|
+
/** The definition's `allowedPushOrigins`, threaded to the push router's send-time SSRF guard. */
|
|
637
|
+
allowedPushOrigins?: string[];
|
|
483
638
|
chat?: Provider;
|
|
484
639
|
fcm?: FcmConfig;
|
|
485
640
|
inApp?: Provider;
|
|
@@ -498,45 +653,102 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
|
|
|
498
653
|
* fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
|
|
499
654
|
* `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
|
|
500
655
|
* `filter.after`, when set, resumes a broadcast partway through (see
|
|
501
|
-
* {@link
|
|
656
|
+
* {@link runPushBroadcastPage}'s continuation semantics).
|
|
502
657
|
*/
|
|
503
658
|
interface PushBroadcastJob {
|
|
504
659
|
/** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
|
|
505
660
|
filter?: SubscriptionFilter;
|
|
506
661
|
/** The push payload to deliver (the `to` target is derived per subscription). */
|
|
507
662
|
payload: PushContent;
|
|
663
|
+
/**
|
|
664
|
+
* Redeliver to exactly these subscription ids instead of walking a page —
|
|
665
|
+
* an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
|
|
666
|
+
* consumer when it re-enqueues a page's transient failures; `filter` is
|
|
667
|
+
* ignored on such a job. See {@link runPushBroadcastPage}.
|
|
668
|
+
*/
|
|
669
|
+
retryIds?: string[];
|
|
508
670
|
/** Discriminator so a shared queue can multiplex message kinds. */
|
|
509
671
|
type: "lunora.push.broadcast";
|
|
510
672
|
}
|
|
511
|
-
/**
|
|
673
|
+
/**
|
|
674
|
+
* One page's outcome plus the ids that need redelivering.
|
|
675
|
+
*
|
|
676
|
+
* The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
|
|
677
|
+
* `failedIds` redelivers the recipients this page missed. Acking a message while
|
|
678
|
+
* ignoring either silently drops part of the audience.
|
|
679
|
+
*/
|
|
680
|
+
interface PushBroadcastPageOutcome {
|
|
681
|
+
/**
|
|
682
|
+
* Subscriptions that failed transiently on this run (gone/pruned devices are
|
|
683
|
+
* NOT here — they are deleted, not retried). Re-enqueue a job carrying these
|
|
684
|
+
* as `retryIds` to redeliver to just them.
|
|
685
|
+
*/
|
|
686
|
+
failedIds: string[];
|
|
687
|
+
/**
|
|
688
|
+
* The filter for the CONTINUATION job, or `undefined` when the broadcast is
|
|
689
|
+
* finished (no further pages, or `filter.limit` is spent). Enqueue it
|
|
690
|
+
* verbatim — it carries the next page's cursor AND, when the job set
|
|
691
|
+
* `filter.limit`, the REMAINING budget.
|
|
692
|
+
*
|
|
693
|
+
* This replaces the raw `nextCursor` the runner used to return. Rebuilding
|
|
694
|
+
* the filter at the call site (`{ ...job.filter, after: nextCursor }`)
|
|
695
|
+
* forwarded the ORIGINAL `limit` to every message, so a `limit` documented
|
|
696
|
+
* as an overall audience cap (see {@link SubscriptionFilter.limit}, which
|
|
697
|
+
* `broadcast` honours as one) became a per-message cap and the walk reached
|
|
698
|
+
* the entire audience anyway.
|
|
699
|
+
*/
|
|
700
|
+
nextFilter?: SubscriptionFilter;
|
|
701
|
+
/** This page's delivery result. */
|
|
702
|
+
result: BroadcastResult;
|
|
703
|
+
}
|
|
704
|
+
/** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
|
|
512
705
|
interface QueueProducerLike {
|
|
513
706
|
send: (body: PushBroadcastJob) => Promise<void>;
|
|
514
707
|
}
|
|
515
708
|
/**
|
|
516
709
|
* Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
|
|
517
|
-
* queue instead of blocking the request. Pair with {@link
|
|
710
|
+
* queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
|
|
518
711
|
* the queue consumer — see its doc comment for how a large audience continues
|
|
519
712
|
* across MULTIPLE messages (one bounded page per message), not one.
|
|
520
713
|
*
|
|
521
714
|
* ```ts
|
|
522
|
-
* //
|
|
523
|
-
*
|
|
715
|
+
* // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
716
|
+
* // `ctx.push` and `ctx.queues` exist.
|
|
717
|
+
* import { internalAction, v } from "./_generated/server";
|
|
718
|
+
* import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
|
|
524
719
|
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* const {
|
|
720
|
+
* export const deliverPage = internalAction
|
|
721
|
+
* .input({ job: v.any() })
|
|
722
|
+
* .action(async ({ args: { job }, ctx }) => {
|
|
723
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
529
724
|
*
|
|
530
|
-
* if (
|
|
725
|
+
* if (nextFilter !== undefined) {
|
|
531
726
|
* // More pages remain — enqueue the continuation. Each message still
|
|
532
|
-
* // does only ONE bounded page of work.
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
727
|
+
* // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
|
|
728
|
+
* // it carries the cursor and the remaining `limit` budget.
|
|
729
|
+
* await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
|
|
730
|
+
* }
|
|
731
|
+
*
|
|
732
|
+
* if (failedIds.length > 0) {
|
|
733
|
+
* // Redeliver ONLY the recipients that failed — never the whole page.
|
|
734
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
537
735
|
* }
|
|
538
|
-
* }
|
|
539
|
-
*
|
|
736
|
+
* });
|
|
737
|
+
*
|
|
738
|
+
* // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
|
|
739
|
+
* // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
|
|
740
|
+
* // `(context, batch)`, in that order). It hands each message to the action above.
|
|
741
|
+
* export const push = defineQueue<PushBroadcastJob>({
|
|
742
|
+
* handler: async (context, batch) => {
|
|
743
|
+
* for (const message of batch.messages) {
|
|
744
|
+
* await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
745
|
+
* message.ack();
|
|
746
|
+
* }
|
|
747
|
+
* },
|
|
748
|
+
* });
|
|
749
|
+
*
|
|
750
|
+
* // in a mutation/action, to start it:
|
|
751
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
|
|
540
752
|
* ```
|
|
541
753
|
*/
|
|
542
754
|
declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
|
|
@@ -546,43 +758,48 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
546
758
|
* reuses the engine's retry + circuit-breaker middleware and prunes gone
|
|
547
759
|
* subscriptions).
|
|
548
760
|
*
|
|
549
|
-
* RETRY / CONTINUATION SEMANTICS
|
|
550
|
-
* broadcast job used to process the WHOLE audience in one message, which could
|
|
551
|
-
* exceed Worker CPU/wall limits for a large audience and made a retry re-run
|
|
552
|
-
* everything):
|
|
761
|
+
* RETRY / CONTINUATION SEMANTICS:
|
|
553
762
|
*
|
|
554
|
-
* - A job
|
|
555
|
-
*
|
|
763
|
+
* - A job processes exactly ONE bounded page (see `defineNotify`'s
|
|
764
|
+
* `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
|
|
556
765
|
* keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
|
|
557
766
|
* — so per-message work is bounded regardless of total audience size.
|
|
558
|
-
* -
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
* - A
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
|
|
579
|
-
|
|
767
|
+
* - A page NEVER throws for a partial failure. Throwing discarded the page's
|
|
768
|
+
* continuation, which is the only way the broadcast advances: one device that
|
|
769
|
+
* fails permanently (a rotated VAPID keypair leaves a stale device answering
|
|
770
|
+
* `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
|
|
771
|
+
* every already-delivered recipient on each retry, dead-letter, and leave
|
|
772
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
773
|
+
* both come back instead.
|
|
774
|
+
* - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
|
|
775
|
+
* `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
|
|
776
|
+
* do it itself — it has no `@lunora/queue` dependency (the seam stays
|
|
777
|
+
* structural) and no reference to the producer that enqueued this message. See
|
|
778
|
+
* the consumer example on {@link enqueuePushBroadcast}.
|
|
779
|
+
* - `job.filter.limit` is spent across messages, not re-granted to each one:
|
|
780
|
+
* `nextFilter` carries the REMAINING budget and is `undefined` once it runs
|
|
781
|
+
* out, so `limit` caps the whole audience here exactly as it does on
|
|
782
|
+
* {@link LunoraPush.broadcast}.
|
|
783
|
+
* - A `retryIds` job redelivers to exactly those ids and throws only while ALL
|
|
784
|
+
* of them still fail, so the queue's backoff/dead-letter bounds a device that
|
|
785
|
+
* never recovers. Once any recipient recovers the run resolves and reports the
|
|
786
|
+
* rest in `failedIds`, so the narrower retry never re-sends to a device this
|
|
787
|
+
* message already reached. A recipient that turns out to be GONE — a 404/410
|
|
788
|
+
* receipt, or an id whose row no longer exists at all — counts as `pruned` here
|
|
789
|
+
* too, never as a failure: there is nothing left to redeliver to, so retrying it
|
|
790
|
+
* could only burn the queue's budget and dead-letter an unsubscribe.
|
|
791
|
+
* - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
|
|
792
|
+
* never appear in `failedIds` — an all-`pruned` page is a success, not a
|
|
793
|
+
* failure, as is an empty page.
|
|
794
|
+
*/
|
|
795
|
+
declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
|
|
580
796
|
/**
|
|
581
797
|
* The minimal structural slice of Cloudflare's `D1Database` this store uses. A
|
|
582
798
|
* structural type (rather than importing `@cloudflare/workers-types`) keeps the
|
|
583
799
|
* store runtime-agnostic and trivially fakeable in tests.
|
|
584
800
|
*/
|
|
585
801
|
interface D1Like {
|
|
802
|
+
batch: (statements: D1PreparedLike[]) => Promise<unknown>;
|
|
586
803
|
prepare: (query: string) => D1PreparedLike;
|
|
587
804
|
}
|
|
588
805
|
interface D1PreparedLike {
|
|
@@ -598,27 +815,6 @@ interface D1StoreOptions {
|
|
|
598
815
|
/** Table name (default `lunora_push_subscriptions`). Must be a bare identifier. */
|
|
599
816
|
tableName?: string;
|
|
600
817
|
}
|
|
601
|
-
/**
|
|
602
|
-
* A D1-backed {@link SubscriptionStore}. Edge-safe (D1 is a Worker binding). The
|
|
603
|
-
* backing table is created lazily on first use (`CREATE TABLE IF NOT EXISTS`), so
|
|
604
|
-
* no migration step is required for the subscription table itself.
|
|
605
|
-
*
|
|
606
|
-
* ID SCHEME / LAZY MIGRATION: `id` (the `PRIMARY KEY`, upserted via `ON
|
|
607
|
-
* CONFLICT(id) DO UPDATE`) is a version-prefixed digest of the endpoint/token —
|
|
608
|
-
* currently `wp2_`/`fcm2_` (64-bit FNV-1a; see `normalize.ts`). No table migration
|
|
609
|
-
* runs when the id scheme is revised: a returning device re-registers under its new
|
|
610
|
-
* id and upserts a fresh row, while its old-prefix row (`wp_`/`fcm_`) ages out via
|
|
611
|
-
* the normal gone-pruning on the next failed send. So a table can transiently hold
|
|
612
|
-
* both an old- and new-prefix row for one device — expected, self-healing, and the
|
|
613
|
-
* reason a prefix must NEVER be reused for a different scheme.
|
|
614
|
-
*
|
|
615
|
-
* ```ts
|
|
616
|
-
* export default defineNotify({
|
|
617
|
-
* webPush: (env) => webPushFromEnv(env),
|
|
618
|
-
* store: (env) => d1SubscriptionStore(env.DB),
|
|
619
|
-
* });
|
|
620
|
-
* ```
|
|
621
|
-
*/
|
|
622
818
|
declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions) => SubscriptionStore;
|
|
623
819
|
/**
|
|
624
820
|
* An in-memory {@link SubscriptionStore} — the zero-dependency default. Suitable
|
|
@@ -628,7 +824,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
628
824
|
*/
|
|
629
825
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
630
826
|
/**
|
|
631
|
-
* Stable store id for a web-push endpoint
|
|
827
|
+
* Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
|
|
828
|
+
* so re-registering the same device upserts rather than duplicates.
|
|
829
|
+
*
|
|
830
|
+
* The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
|
|
831
|
+
* is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
|
|
832
|
+
* every existing subscription — the old row goes dark and the device
|
|
833
|
+
* re-registers as a duplicate — so the implementation must have exactly one
|
|
834
|
+
* home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
|
|
835
|
+
* `packages/replica/__tests__/apply-diff.test.ts`.
|
|
836
|
+
*
|
|
837
|
+
* Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
|
|
838
|
+
* collides with ~68% probability (birthday bound), and a collision silently
|
|
839
|
+
* overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
|
|
840
|
+
* so the wrong user gets the push and the victim goes dark. 64 bits drops that to
|
|
841
|
+
* negligible at any realistic device count.
|
|
632
842
|
*
|
|
633
843
|
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
634
844
|
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
|
@@ -649,9 +859,10 @@ interface NormalizeOptions {
|
|
|
649
859
|
*
|
|
650
860
|
* When unset, the default posture applies: `https:` scheme + a host the
|
|
651
861
|
* {@link assertPushEndpoint} STRING classifier does not flag as
|
|
652
|
-
* private/loopback
|
|
653
|
-
*
|
|
654
|
-
*
|
|
862
|
+
* private/loopback, plus a resolved-address re-check at send time. Setting
|
|
863
|
+
* this allowlist replaces both with an exact-origin match — the hard
|
|
864
|
+
* guarantee, and the only one that also covers an internal push service you
|
|
865
|
+
* deliberately want to reach.
|
|
655
866
|
*/
|
|
656
867
|
allowedPushOrigins?: string[];
|
|
657
868
|
}
|
|
@@ -674,11 +885,97 @@ declare const targetOf: (subscription: StoredSubscription) => string;
|
|
|
674
885
|
* (the browser/device unsubscribed) and should be pruned — as opposed to a
|
|
675
886
|
* transient failure worth retrying.
|
|
676
887
|
*
|
|
677
|
-
* Gates on STRUCTURED signals first:
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
888
|
+
* Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
|
|
889
|
+
* answer one for a dead endpoint/token, though FCM's is usually replaced by its
|
|
890
|
+
* error body before it reaches here) or, for FCM only, an
|
|
891
|
+
* `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
|
|
892
|
+
* signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
|
|
893
|
+
* free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
|
|
894
|
+
* transient error that happens to contain `expired` (a cert/session expiry) can
|
|
895
|
+
* never permanently drop a valid subscription.
|
|
896
|
+
*
|
|
897
|
+
* `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
|
|
898
|
+
* The web-push provider echoes the push service's response body into
|
|
899
|
+
* `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
|
|
900
|
+
* registered" matched the FCM-only codes and permanently deleted a live
|
|
901
|
+
* subscription. Omit `kind` (the third-party/unknown-provider case) to test
|
|
902
|
+
* every pattern, as before.
|
|
903
|
+
*/
|
|
904
|
+
declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
|
|
905
|
+
export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
|
|
906
|
+
/**
|
|
907
|
+
* `@lunora/notify`
|
|
908
|
+
*
|
|
909
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
910
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
911
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
912
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
913
|
+
* → `ctx.flags`).
|
|
914
|
+
*
|
|
915
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
916
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
917
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
918
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
919
|
+
*
|
|
920
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
921
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
922
|
+
* @packageDocumentation
|
|
923
|
+
*/
|
|
924
|
+
FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushOwner, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionOutcomes, type SubscriptionStatus, type SubscriptionStore,
|
|
925
|
+
/**
|
|
926
|
+
* `@lunora/notify`
|
|
927
|
+
*
|
|
928
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
929
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
930
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
931
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
932
|
+
* → `ctx.flags`).
|
|
933
|
+
*
|
|
934
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
935
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
936
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
937
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
938
|
+
*
|
|
939
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
940
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
941
|
+
* @packageDocumentation
|
|
942
|
+
*/
|
|
943
|
+
WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast,
|
|
944
|
+
/**
|
|
945
|
+
* `@lunora/notify`
|
|
946
|
+
*
|
|
947
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
948
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
949
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
950
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
951
|
+
* → `ctx.flags`).
|
|
952
|
+
*
|
|
953
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
954
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
955
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
956
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
957
|
+
*
|
|
958
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
959
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
960
|
+
* @packageDocumentation
|
|
961
|
+
*/
|
|
962
|
+
fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
|
|
963
|
+
/**
|
|
964
|
+
* `@lunora/notify`
|
|
965
|
+
*
|
|
966
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
967
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
968
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
969
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
970
|
+
* → `ctx.flags`).
|
|
971
|
+
*
|
|
972
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
973
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
974
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
975
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
976
|
+
*
|
|
977
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
978
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
979
|
+
* @packageDocumentation
|
|
682
980
|
*/
|
|
683
|
-
|
|
684
|
-
export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions, FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore, WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast, fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf, webPushFromEnv, webPushId };
|
|
981
|
+
webPushFromEnv, webPushId };
|