@lunora/notify 1.0.0-alpha.8 → 1.0.0-alpha.80
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 +410 -102
- package/dist/index.d.ts +410 -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-ClocmjsQ.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,9 +120,20 @@ 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.
|
|
126
|
+
*
|
|
127
|
+
* The non-positive sentinel means something different at each layer: at
|
|
128
|
+
* the STORE layer (`list`, and the `d1-store`/`memory-store` fetch-size
|
|
129
|
+
* hint) a non-positive `limit` means "no cap" — fetch everything. At the
|
|
130
|
+
* `broadcast`/`broadcastPage` layer, where `limit` is an AUDIENCE cap, a
|
|
131
|
+
* non-positive value instead means "no deliveries" — `broadcast({ limit: 0
|
|
132
|
+
* })` reaches nobody, not everybody. This asymmetry is deliberate: the two
|
|
133
|
+
* layers answer different questions ("how many rows to fetch" vs. "how
|
|
134
|
+
* many recipients to reach"), and unifying them would either break `list`
|
|
135
|
+
* callers relying on "no cap" or reintroduce the over-delivery this
|
|
136
|
+
* distinction fixes (see `broadcastPage`'s doc comment).
|
|
117
137
|
*/
|
|
118
138
|
limit?: number;
|
|
119
139
|
/** Restrict to a single owning user. */
|
|
@@ -125,8 +145,39 @@ interface SubscriptionFilter {
|
|
|
125
145
|
* and a D1-backed store (durable, edge-safe).
|
|
126
146
|
*/
|
|
127
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>;
|
|
128
155
|
/** Remove a subscription by id (idempotent). */
|
|
129
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>;
|
|
130
181
|
/** Read a subscription by id, or `undefined`. */
|
|
131
182
|
get: (id: string) => Promise<StoredSubscription | undefined>;
|
|
132
183
|
/**
|
|
@@ -139,7 +190,33 @@ interface SubscriptionStore {
|
|
|
139
190
|
list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
|
|
140
191
|
/** Record the latest delivery outcome for a subscription (best-effort). */
|
|
141
192
|
markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
|
|
142
|
-
/**
|
|
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
|
+
*/
|
|
143
220
|
put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
|
|
144
221
|
}
|
|
145
222
|
/** Per-recipient outcome from a fan-out `broadcast`. */
|
|
@@ -183,7 +260,7 @@ interface BroadcastPageResult {
|
|
|
183
260
|
*
|
|
184
261
|
* - `accepted` — the provider took the message (a `Receipt.successful` send).
|
|
185
262
|
* - `failed` — a provider error; the log line carries the `error` text.
|
|
186
|
-
* - `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.
|
|
187
264
|
*
|
|
188
265
|
* Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
|
|
189
266
|
* send attempt: a `delivered`/`opened` status would be a lie for these channels.
|
|
@@ -228,26 +305,28 @@ interface LunoraPush {
|
|
|
228
305
|
/**
|
|
229
306
|
* Fan-out a push to every stored subscription matching `filter` (default: all).
|
|
230
307
|
* Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
|
|
231
|
-
* 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`
|
|
232
309
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
233
310
|
*
|
|
234
311
|
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
235
|
-
* keyset-paginated on the subscription `id`) so
|
|
236
|
-
* materialized wholesale in the isolate — see `
|
|
237
|
-
* `broadcastPageSize`.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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.
|
|
241
320
|
*/
|
|
242
321
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
243
322
|
/**
|
|
244
323
|
* Fan-out a push to ONE bounded page of stored subscriptions matching
|
|
245
|
-
* `filter` (page size: `
|
|
324
|
+
* `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
|
|
246
325
|
* 250, capped by `filter.limit` when set). Same delivery semantics as
|
|
247
326
|
* {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
|
|
248
327
|
* scoped to a single page; returns the page's own {@link BroadcastResult}
|
|
249
328
|
* plus a `nextCursor` to fetch the next page (`undefined` when done).
|
|
250
|
-
* Backs `
|
|
329
|
+
* Backs `runPushBroadcastPage` so one queue message does bounded work
|
|
251
330
|
* regardless of audience size — most app code should call
|
|
252
331
|
* {@link LunoraPush.broadcast} instead.
|
|
253
332
|
*/
|
|
@@ -256,18 +335,66 @@ interface LunoraPush {
|
|
|
256
335
|
* List stored subscriptions (optionally filtered), with the delivery
|
|
257
336
|
* **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
|
|
258
337
|
* FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
|
|
259
|
-
* a device, so
|
|
338
|
+
* a device, so no READ on this facade returns them; the raw rows are otherwise
|
|
260
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.
|
|
261
344
|
*/
|
|
262
345
|
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
263
|
-
/**
|
|
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
|
+
*/
|
|
264
358
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
265
359
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
266
360
|
send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
|
|
267
|
-
/**
|
|
268
|
-
|
|
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>;
|
|
269
384
|
}
|
|
270
|
-
/**
|
|
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;
|
|
392
|
+
}
|
|
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
|
+
*/
|
|
271
398
|
type PushContent = Omit<PushPayload, "to">;
|
|
272
399
|
/**
|
|
273
400
|
* The multi-channel notification facade — spliced onto ctx as `ctx.notify`.
|
|
@@ -276,7 +403,7 @@ type PushContent = Omit<PushPayload, "to">;
|
|
|
276
403
|
* single-channel convenience senders for the edge-safe channels.
|
|
277
404
|
*/
|
|
278
405
|
interface LunoraNotify {
|
|
279
|
-
/**
|
|
406
|
+
/** Post to a chat channel (Slack/Discord/Teams/Telegram). */
|
|
280
407
|
chat: (payload: ChatPayload) => Promise<Receipt>;
|
|
281
408
|
/** Deliver an in-app inbox notification. */
|
|
282
409
|
inApp: (payload: InAppPayload) => Promise<Receipt>;
|
|
@@ -284,7 +411,7 @@ interface LunoraNotify {
|
|
|
284
411
|
push: LunoraPush;
|
|
285
412
|
/** Deliver a multi-channel message (one payload per channel). */
|
|
286
413
|
send: (message: NotificationMessage) => Promise<Receipt[]>;
|
|
287
|
-
/**
|
|
414
|
+
/** Send an outbound webhook. */
|
|
288
415
|
webhook: (payload: WebhookPayload) => Promise<Receipt>;
|
|
289
416
|
}
|
|
290
417
|
/**
|
|
@@ -312,11 +439,32 @@ interface NotifyConfig {
|
|
|
312
439
|
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
313
440
|
*/
|
|
314
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;
|
|
315
457
|
/**
|
|
316
458
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
317
459
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
318
460
|
*/
|
|
319
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;
|
|
320
468
|
/** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
|
|
321
469
|
fcm?: FcmConfig | FcmConfigFactory;
|
|
322
470
|
/** Optional in-app inbox provider factory. Edge-safe. */
|
|
@@ -405,14 +553,18 @@ interface CreateNotifyOptions {
|
|
|
405
553
|
/**
|
|
406
554
|
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
407
555
|
* subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
* (
|
|
412
|
-
*
|
|
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.
|
|
413
561
|
*/
|
|
414
562
|
broadcastPageSize?: number;
|
|
415
|
-
/**
|
|
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
|
+
*/
|
|
416
568
|
concurrency?: number;
|
|
417
569
|
/**
|
|
418
570
|
* Override the assembled `@visulima/notification` engine. Advanced/testing
|
|
@@ -456,7 +608,19 @@ declare const createNotify: (definition: NotifyDefinition, env: NotifyEnv, optio
|
|
|
456
608
|
};
|
|
457
609
|
/** Options for {@link routingPushProvider}. */
|
|
458
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[];
|
|
459
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;
|
|
460
624
|
webPush?: Provider<unknown, PushPayload>;
|
|
461
625
|
}
|
|
462
626
|
/**
|
|
@@ -469,6 +633,8 @@ interface RoutingPushOptions {
|
|
|
469
633
|
declare const routingPushProvider: (options: RoutingPushOptions) => Provider<unknown, PushPayload>;
|
|
470
634
|
/** A resolved, ready-to-wire set of channel configs (edge-safe channels only). */
|
|
471
635
|
interface ResolvedProviders {
|
|
636
|
+
/** The definition's `allowedPushOrigins`, threaded to the push router's send-time SSRF guard. */
|
|
637
|
+
allowedPushOrigins?: string[];
|
|
472
638
|
chat?: Provider;
|
|
473
639
|
fcm?: FcmConfig;
|
|
474
640
|
inApp?: Provider;
|
|
@@ -487,45 +653,102 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
|
|
|
487
653
|
* fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
|
|
488
654
|
* `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
|
|
489
655
|
* `filter.after`, when set, resumes a broadcast partway through (see
|
|
490
|
-
* {@link
|
|
656
|
+
* {@link runPushBroadcastPage}'s continuation semantics).
|
|
491
657
|
*/
|
|
492
658
|
interface PushBroadcastJob {
|
|
493
659
|
/** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
|
|
494
660
|
filter?: SubscriptionFilter;
|
|
495
661
|
/** The push payload to deliver (the `to` target is derived per subscription). */
|
|
496
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[];
|
|
497
670
|
/** Discriminator so a shared queue can multiplex message kinds. */
|
|
498
671
|
type: "lunora.push.broadcast";
|
|
499
672
|
}
|
|
500
|
-
/**
|
|
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. */
|
|
501
705
|
interface QueueProducerLike {
|
|
502
706
|
send: (body: PushBroadcastJob) => Promise<void>;
|
|
503
707
|
}
|
|
504
708
|
/**
|
|
505
709
|
* Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
|
|
506
|
-
* queue instead of blocking the request. Pair with {@link
|
|
710
|
+
* queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
|
|
507
711
|
* the queue consumer — see its doc comment for how a large audience continues
|
|
508
712
|
* across MULTIPLE messages (one bounded page per message), not one.
|
|
509
713
|
*
|
|
510
714
|
* ```ts
|
|
511
|
-
* //
|
|
512
|
-
*
|
|
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";
|
|
513
719
|
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* 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);
|
|
518
724
|
*
|
|
519
|
-
* if (
|
|
725
|
+
* if (nextFilter !== undefined) {
|
|
520
726
|
* // More pages remain — enqueue the continuation. Each message still
|
|
521
|
-
* // does only ONE bounded page of work.
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
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 });
|
|
526
735
|
* }
|
|
527
|
-
* }
|
|
528
|
-
*
|
|
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" } });
|
|
529
752
|
* ```
|
|
530
753
|
*/
|
|
531
754
|
declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
|
|
@@ -535,43 +758,48 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
|
|
|
535
758
|
* reuses the engine's retry + circuit-breaker middleware and prunes gone
|
|
536
759
|
* subscriptions).
|
|
537
760
|
*
|
|
538
|
-
* RETRY / CONTINUATION SEMANTICS
|
|
539
|
-
* broadcast job used to process the WHOLE audience in one message, which could
|
|
540
|
-
* exceed Worker CPU/wall limits for a large audience and made a retry re-run
|
|
541
|
-
* everything):
|
|
761
|
+
* RETRY / CONTINUATION SEMANTICS:
|
|
542
762
|
*
|
|
543
|
-
* - A job
|
|
544
|
-
*
|
|
763
|
+
* - A job processes exactly ONE bounded page (see `defineNotify`'s
|
|
764
|
+
* `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
|
|
545
765
|
* keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
|
|
546
766
|
* — so per-message work is bounded regardless of total audience size.
|
|
547
|
-
* -
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* - A
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
|
|
568
|
-
|
|
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>;
|
|
569
796
|
/**
|
|
570
797
|
* The minimal structural slice of Cloudflare's `D1Database` this store uses. A
|
|
571
798
|
* structural type (rather than importing `@cloudflare/workers-types`) keeps the
|
|
572
799
|
* store runtime-agnostic and trivially fakeable in tests.
|
|
573
800
|
*/
|
|
574
801
|
interface D1Like {
|
|
802
|
+
batch: (statements: D1PreparedLike[]) => Promise<unknown>;
|
|
575
803
|
prepare: (query: string) => D1PreparedLike;
|
|
576
804
|
}
|
|
577
805
|
interface D1PreparedLike {
|
|
@@ -587,27 +815,6 @@ interface D1StoreOptions {
|
|
|
587
815
|
/** Table name (default `lunora_push_subscriptions`). Must be a bare identifier. */
|
|
588
816
|
tableName?: string;
|
|
589
817
|
}
|
|
590
|
-
/**
|
|
591
|
-
* A D1-backed {@link SubscriptionStore}. Edge-safe (D1 is a Worker binding). The
|
|
592
|
-
* backing table is created lazily on first use (`CREATE TABLE IF NOT EXISTS`), so
|
|
593
|
-
* no migration step is required for the subscription table itself.
|
|
594
|
-
*
|
|
595
|
-
* ID SCHEME / LAZY MIGRATION: `id` (the `PRIMARY KEY`, upserted via `ON
|
|
596
|
-
* CONFLICT(id) DO UPDATE`) is a version-prefixed digest of the endpoint/token —
|
|
597
|
-
* currently `wp2_`/`fcm2_` (64-bit FNV-1a; see `normalize.ts`). No table migration
|
|
598
|
-
* runs when the id scheme is revised: a returning device re-registers under its new
|
|
599
|
-
* id and upserts a fresh row, while its old-prefix row (`wp_`/`fcm_`) ages out via
|
|
600
|
-
* the normal gone-pruning on the next failed send. So a table can transiently hold
|
|
601
|
-
* both an old- and new-prefix row for one device — expected, self-healing, and the
|
|
602
|
-
* reason a prefix must NEVER be reused for a different scheme.
|
|
603
|
-
*
|
|
604
|
-
* ```ts
|
|
605
|
-
* export default defineNotify({
|
|
606
|
-
* webPush: (env) => webPushFromEnv(env),
|
|
607
|
-
* store: (env) => d1SubscriptionStore(env.DB),
|
|
608
|
-
* });
|
|
609
|
-
* ```
|
|
610
|
-
*/
|
|
611
818
|
declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions) => SubscriptionStore;
|
|
612
819
|
/**
|
|
613
820
|
* An in-memory {@link SubscriptionStore} — the zero-dependency default. Suitable
|
|
@@ -617,7 +824,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
617
824
|
*/
|
|
618
825
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
619
826
|
/**
|
|
620
|
-
* 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.
|
|
621
842
|
*
|
|
622
843
|
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
623
844
|
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
|
@@ -638,9 +859,10 @@ interface NormalizeOptions {
|
|
|
638
859
|
*
|
|
639
860
|
* When unset, the default posture applies: `https:` scheme + a host the
|
|
640
861
|
* {@link assertPushEndpoint} STRING classifier does not flag as
|
|
641
|
-
* private/loopback
|
|
642
|
-
*
|
|
643
|
-
*
|
|
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.
|
|
644
866
|
*/
|
|
645
867
|
allowedPushOrigins?: string[];
|
|
646
868
|
}
|
|
@@ -663,11 +885,97 @@ declare const targetOf: (subscription: StoredSubscription) => string;
|
|
|
663
885
|
* (the browser/device unsubscribed) and should be pruned — as opposed to a
|
|
664
886
|
* transient failure worth retrying.
|
|
665
887
|
*
|
|
666
|
-
* Gates on STRUCTURED signals first:
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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
|
|
671
980
|
*/
|
|
672
|
-
|
|
673
|
-
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 };
|