@lunora/notify 1.0.0-alpha.5 → 1.0.0-alpha.51
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 +104 -19
- package/dist/index.d.mts +464 -50
- package/dist/index.d.ts +464 -50
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs +1 -0
- package/dist/packem_shared/buildEngine-vTV4f7XO.mjs +1 -0
- package/dist/packem_shared/createNotify-jsDkDuk9.mjs +1 -0
- package/dist/packem_shared/d1SubscriptionStore-DoInbQak.mjs +1 -0
- package/dist/packem_shared/defineNotify-DVPpVkU0.mjs +1 -0
- package/dist/packem_shared/enqueuePushBroadcast-BnuMQfuG.mjs +1 -0
- package/dist/packem_shared/fcmId-B89fNAgc.mjs +1 -0
- package/dist/packem_shared/memorySubscriptionStore-C7hyd6ii.mjs +1 -0
- package/dist/packem_shared/normalize-BdOnBfM2.mjs +1 -0
- package/dist/web.d.mts +32 -18
- package/dist/web.d.ts +32 -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-iBbs9Bew.mjs +0 -1
- package/dist/packem_shared/d1SubscriptionStore-BwssRgeI.mjs +0 -1
- package/dist/packem_shared/defineNotify-DLEy53HJ.mjs +0 -1
- package/dist/packem_shared/enqueuePushBroadcast-DMwYIM0o.mjs +0 -1
- package/dist/packem_shared/fcmId-CLRyQJVw.mjs +0 -1
- package/dist/packem_shared/memorySubscriptionStore-BxKdyxjC.mjs +0 -1
package/dist/index.d.mts
CHANGED
|
@@ -75,14 +75,56 @@ type RegisterInput = {
|
|
|
75
75
|
};
|
|
76
76
|
/** Filter narrowing which stored subscriptions a `list`/`broadcast` targets. */
|
|
77
77
|
interface SubscriptionFilter {
|
|
78
|
+
/**
|
|
79
|
+
* Keyset pagination cursor: return only rows with `id` strictly GREATER
|
|
80
|
+
* than this value, ordered ascending by `id`. `id` is a stable,
|
|
81
|
+
* content-derived hash (see `webPushId`/`fcmId`), so ordering by it is
|
|
82
|
+
* immune to concurrent inserts/deletes elsewhere in the table — a page
|
|
83
|
+
* already walked never re-delivers or skips a row when another device
|
|
84
|
+
* registers mid-broadcast (the reviewer-flagged "stable under concurrent
|
|
85
|
+
* registers" property). `broadcastPage`/`broadcast` set this internally to
|
|
86
|
+
* walk pages; a direct `list()` caller may also page through results with
|
|
87
|
+
* it.
|
|
88
|
+
*
|
|
89
|
+
* OPTIONAL for a reason: `SubscriptionStore` is implementable outside this
|
|
90
|
+
* package. An external store that does not support cursoring may ignore
|
|
91
|
+
* `after` entirely and keep returning its (from-the-top) unpaged result —
|
|
92
|
+
* `broadcastPage` defensively re-filters whatever the store returns down
|
|
93
|
+
* to `id > after` itself, so a non-cursoring store can never cause a
|
|
94
|
+
* double-send or an infinite page-walk (each page's result only ever
|
|
95
|
+
* contains ids the previous page didn't already deliver), but it also
|
|
96
|
+
* cannot deliver the FULL matched audience beyond whatever the store's own
|
|
97
|
+
* (unpaged) response window happens to contain — implement `after`
|
|
98
|
+
* (ordered ascending by `id`, exclusive) to get real, complete pagination
|
|
99
|
+
* over a large audience.
|
|
100
|
+
*/
|
|
101
|
+
after?: string;
|
|
78
102
|
/** Restrict to a delivery kind. */
|
|
79
103
|
kind?: SubscriptionKind;
|
|
80
104
|
/**
|
|
81
105
|
* Cap the number of rows returned (a `LIMIT`). Applied server-side by the
|
|
82
106
|
* store, so a large audience never materializes wholesale in the isolate.
|
|
83
107
|
* A non-positive/absent value means "no cap"; a fractional value is truncated.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
108
|
+
*
|
|
109
|
+
* For `list`/admin reads this bounds the returned page as before. For
|
|
110
|
+
* `broadcast`, this is now an OVERALL cap on the total number of
|
|
111
|
+
* subscriptions reached across every internally-walked page (still
|
|
112
|
+
* deliberately left unset by default — it must reach every matched
|
|
113
|
+
* device); the PER-PAGE batch size is a separate, independent knob (see
|
|
114
|
+
* `defineNotify`'s `broadcastPageSize`, default 250) so a caller
|
|
115
|
+
* that sets `limit` to bound the audience doesn't also have to reason
|
|
116
|
+
* about page sizing.
|
|
117
|
+
*
|
|
118
|
+
* The non-positive sentinel means something different at each layer: at
|
|
119
|
+
* the STORE layer (`list`, and the `d1-store`/`memory-store` fetch-size
|
|
120
|
+
* hint) a non-positive `limit` means "no cap" — fetch everything. At the
|
|
121
|
+
* `broadcast`/`broadcastPage` layer, where `limit` is an AUDIENCE cap, a
|
|
122
|
+
* non-positive value instead means "no deliveries" — `broadcast({ limit: 0
|
|
123
|
+
* })` reaches nobody, not everybody. This asymmetry is deliberate: the two
|
|
124
|
+
* layers answer different questions ("how many rows to fetch" vs. "how
|
|
125
|
+
* many recipients to reach"), and unifying them would either break `list`
|
|
126
|
+
* callers relying on "no cap" or reintroduce the over-delivery this
|
|
127
|
+
* distinction fixes (see `broadcastPage`'s doc comment).
|
|
86
128
|
*/
|
|
87
129
|
limit?: number;
|
|
88
130
|
/** Restrict to a single owning user. */
|
|
@@ -96,13 +138,69 @@ interface SubscriptionFilter {
|
|
|
96
138
|
interface SubscriptionStore {
|
|
97
139
|
/** Remove a subscription by id (idempotent). */
|
|
98
140
|
delete: (id: string) => Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Remove a subscription by id ONLY if it is owned by `userId`, and report
|
|
143
|
+
* whether it was.
|
|
144
|
+
*
|
|
145
|
+
* Separate from {@link SubscriptionStore.delete} because the caller-facing
|
|
146
|
+
* `unregister` must not be a read followed by a write: between a `get` that
|
|
147
|
+
* checks the owner and a `delete` that acts on it, a re-registration can
|
|
148
|
+
* replace the row, so the check passes for one owner and the removal lands on
|
|
149
|
+
* another's subscription.
|
|
150
|
+
*
|
|
151
|
+
* **The predicate and the removal must be ONE operation.** A store that
|
|
152
|
+
* cannot do that atomically should say so in its own documentation rather
|
|
153
|
+
* than implement this as a get-then-delete, which reintroduces the race this
|
|
154
|
+
* method exists to remove. Both shipped stores manage it: the in-memory one
|
|
155
|
+
* because a `Map` check-and-delete has no await between the two, and the D1
|
|
156
|
+
* one with a single `DELETE … WHERE id = ? AND user_id = ? RETURNING id`.
|
|
157
|
+
*
|
|
158
|
+
* `userId` is `null` for an anonymous subscription, and matches only a row
|
|
159
|
+
* that is itself unowned.
|
|
160
|
+
* @param id The subscription id.
|
|
161
|
+
* @param userId The owner the row must carry, or `null` for unowned.
|
|
162
|
+
* @returns `true` when a row was removed.
|
|
163
|
+
*/
|
|
164
|
+
deleteOwned: (id: string, userId: string | null) => Promise<boolean>;
|
|
99
165
|
/** Read a subscription by id, or `undefined`. */
|
|
100
166
|
get: (id: string) => Promise<StoredSubscription | undefined>;
|
|
101
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* List subscriptions, optionally filtered. When `filter.after` is set,
|
|
169
|
+
* results are keyset-paginated: only rows with `id` strictly greater than
|
|
170
|
+
* `filter.after` are returned, ordered ascending by `id`. Implementing
|
|
171
|
+
* `after` is OPTIONAL (see {@link SubscriptionFilter.after}) — a store
|
|
172
|
+
* that ignores it may keep returning its unpaged result.
|
|
173
|
+
*/
|
|
102
174
|
list: (filter?: SubscriptionFilter) => Promise<StoredSubscription[]>;
|
|
103
175
|
/** Record the latest delivery outcome for a subscription (best-effort). */
|
|
104
176
|
markStatus: (id: string, status: SubscriptionStatus, error?: string) => Promise<void>;
|
|
105
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Insert or update a subscription (upsert by id), refusing to move an
|
|
179
|
+
* existing row to a DIFFERENT owner.
|
|
180
|
+
*
|
|
181
|
+
* The ownership predicate is the same one {@link SubscriptionStore.deleteOwned}
|
|
182
|
+
* carries, and for the same reason: the id is derived from the endpoint or the
|
|
183
|
+
* FCM token, so it is a **caller-controlled key**. An unguarded upsert let any
|
|
184
|
+
* caller who could guess or observe another user's endpoint re-register it
|
|
185
|
+
* under their own `userId` with keys of their choosing — the victim's device
|
|
186
|
+
* then fails every send (an encryption failure is not a gone signal, so it is
|
|
187
|
+
* never pruned either) and the attacker can `unregister` it as their own. The
|
|
188
|
+
* `unregister` guard alone closed exactly half of that (CWE-639).
|
|
189
|
+
*
|
|
190
|
+
* A row with no owner is claimable (the device signed in), and a row the caller
|
|
191
|
+
* already owns is theirs to refresh — that is the routine service-worker
|
|
192
|
+
* re-registration. Anything else must REJECT (`FORBIDDEN`), not silently
|
|
193
|
+
* no-op: unlike `unregister` there is nothing safe to return, since a caller
|
|
194
|
+
* gets the stored record back and it would be someone else's delivery keys.
|
|
195
|
+
* A shared device that legitimately changes hands is handled by its current
|
|
196
|
+
* owner calling `unregister` first.
|
|
197
|
+
*
|
|
198
|
+
* **The predicate and the write must be ONE operation**, as for `deleteOwned`:
|
|
199
|
+
* between a read that checks the owner and the write that acts on it, another
|
|
200
|
+
* registration can replace the row. The D1 store puts the predicate in the
|
|
201
|
+
* `ON CONFLICT … DO UPDATE`'s own `WHERE`; the in-memory one has no `await`
|
|
202
|
+
* between the two.
|
|
203
|
+
*/
|
|
106
204
|
put: (subscription: StoredSubscription) => Promise<StoredSubscription>;
|
|
107
205
|
}
|
|
108
206
|
/** Per-recipient outcome from a fan-out `broadcast`. */
|
|
@@ -127,6 +225,18 @@ interface BroadcastResult {
|
|
|
127
225
|
/** Total subscriptions attempted. */
|
|
128
226
|
total: number;
|
|
129
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Result of `broadcastPage` — one bounded page of a fan-out, plus the cursor
|
|
230
|
+
* to fetch the next page. `nextCursor` is `undefined` when this was the last
|
|
231
|
+
* page (or the store doesn't support cursoring — see
|
|
232
|
+
* {@link SubscriptionFilter.after}'s documented unpaged fallback).
|
|
233
|
+
*/
|
|
234
|
+
interface BroadcastPageResult {
|
|
235
|
+
/** Cursor for the next page (pass as `filter.after`), or `undefined` when done. */
|
|
236
|
+
nextCursor?: string;
|
|
237
|
+
/** The delivery outcome for just this page. */
|
|
238
|
+
result: BroadcastResult;
|
|
239
|
+
}
|
|
130
240
|
/**
|
|
131
241
|
* The compact, stable delivery-status vocabulary emitted on notify observability
|
|
132
242
|
* signals — the `status` dimension on the `notify.send` metric and the failure
|
|
@@ -134,7 +244,7 @@ interface BroadcastResult {
|
|
|
134
244
|
*
|
|
135
245
|
* - `accepted` — the provider took the message (a `Receipt.successful` send).
|
|
136
246
|
* - `failed` — a provider error; the log line carries the `error` text.
|
|
137
|
-
* - `gone` — the endpoint is unregistered (404/410, FCM `
|
|
247
|
+
* - `gone` — the endpoint is unregistered (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) and pruned; push-only.
|
|
138
248
|
*
|
|
139
249
|
* Web Push and FCM give no delivery/open receipts, so the vocabulary stops at the
|
|
140
250
|
* send attempt: a `delivered`/`opened` status would be a lie for these channels.
|
|
@@ -179,24 +289,90 @@ interface LunoraPush {
|
|
|
179
289
|
/**
|
|
180
290
|
* Fan-out a push to every stored subscription matching `filter` (default: all).
|
|
181
291
|
* Reuses the engine's retry/circuit-breaker middleware; prunes subscriptions
|
|
182
|
-
* the push service reports as gone (
|
|
292
|
+
* the push service reports as gone (Web Push 404/410, FCM's `NOT_FOUND` for a dead token). The `to`
|
|
183
293
|
* target is derived from each subscription, so it is omitted from the payload.
|
|
294
|
+
*
|
|
295
|
+
* Internally walks the audience in bounded pages (via {@link LunoraPush.broadcastPage},
|
|
296
|
+
* keyset-paginated on the subscription `id`) so the audience ROWS are never
|
|
297
|
+
* materialized wholesale in the isolate — see `defineNotify`'s
|
|
298
|
+
* `broadcastPageSize`. The returned `outcomes` ARE whole-audience, though: one
|
|
299
|
+
* `{ id, status }` per recipient accumulates across every page, so this call is
|
|
300
|
+
* bounded in rows held but not in outcomes reported. It also processes the WHOLE
|
|
301
|
+
* matched audience in one request/queue message; use {@link LunoraPush.broadcastPage}
|
|
302
|
+
* directly (as `runPushBroadcastPage` does) to bound a single queue message —
|
|
303
|
+
* and its result — to one page.
|
|
184
304
|
*/
|
|
185
305
|
broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
|
|
306
|
+
/**
|
|
307
|
+
* Fan-out a push to ONE bounded page of stored subscriptions matching
|
|
308
|
+
* `filter` (page size: `defineNotify`'s `broadcastPageSize`, default
|
|
309
|
+
* 250, capped by `filter.limit` when set). Same delivery semantics as
|
|
310
|
+
* {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
|
|
311
|
+
* scoped to a single page; returns the page's own {@link BroadcastResult}
|
|
312
|
+
* plus a `nextCursor` to fetch the next page (`undefined` when done).
|
|
313
|
+
* Backs `runPushBroadcastPage` so one queue message does bounded work
|
|
314
|
+
* regardless of audience size — most app code should call
|
|
315
|
+
* {@link LunoraPush.broadcast} instead.
|
|
316
|
+
*/
|
|
317
|
+
broadcastPage: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastPageResult>;
|
|
186
318
|
/**
|
|
187
319
|
* List stored subscriptions (optionally filtered), with the delivery
|
|
188
320
|
* **secrets** stripped — the Web Push `keys` (RFC 8291 `auth`/`p256dh`) and the
|
|
189
321
|
* FCM `token`. Those, plus the endpoint, are enough to deliver arbitrary push to
|
|
190
|
-
* a device, so
|
|
322
|
+
* a device, so no READ on this facade returns them; the raw rows are otherwise
|
|
191
323
|
* reachable only through the internal `SubscriptionStore`.
|
|
324
|
+
*
|
|
325
|
+
* {@link LunoraPush.register} is the one exception, and deliberately so: it
|
|
326
|
+
* echoes back the record the caller just supplied, so it discloses nothing
|
|
327
|
+
* the caller did not already hold and never another device's row.
|
|
192
328
|
*/
|
|
193
329
|
list: (filter?: SubscriptionFilter) => Promise<PushSubscriptionDevice[]>;
|
|
194
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* Register (upsert) a device subscription and return the stored record (the
|
|
332
|
+
* caller's own row, secrets included).
|
|
333
|
+
*
|
|
334
|
+
* Owner-scoped, exactly as {@link LunoraPush.unregister} is: registering an
|
|
335
|
+
* endpoint that is already another user's row is REFUSED (`FORBIDDEN`) rather
|
|
336
|
+
* than re-owning it. Pass `ctx.auth?.userId` so the check has something to
|
|
337
|
+
* separate; an app that registers every device anonymously gets no separation
|
|
338
|
+
* from it (every row is unowned, and unowned rows stay claimable). A device
|
|
339
|
+
* that legitimately changes hands — one browser profile, two accounts —
|
|
340
|
+
* unregisters as its current owner first.
|
|
341
|
+
*/
|
|
195
342
|
register: (input: RegisterInput) => Promise<StoredSubscription>;
|
|
196
343
|
/** Send a push to a single stored subscription (by id or record); `to` is derived from it. */
|
|
197
344
|
send: (target: StoredSubscription | string, payload: PushContent) => Promise<Receipt>;
|
|
198
|
-
/**
|
|
199
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Remove ONE of `owner`'s subscriptions by id (idempotent).
|
|
347
|
+
*
|
|
348
|
+
* `owner` is not optional, and the removal happens only when the stored row
|
|
349
|
+
* carries that same owner. A subscription id is derived from the endpoint
|
|
350
|
+
* (`webPushId`) or the FCM token, so it is a **caller-controlled key**: the
|
|
351
|
+
* intended call is a mutation forwarding `subscribeToPush`'s
|
|
352
|
+
* `replacedEndpoint` after a VAPID rotation, and nothing about that argument
|
|
353
|
+
* proves the browser sending it ever held the subscription it names.
|
|
354
|
+
* Deleting by id alone let any caller that could guess or observe another
|
|
355
|
+
* user's endpoint silence that device's notifications (CWE-639).
|
|
356
|
+
*
|
|
357
|
+
* A row belonging to someone else is left alone SILENTLY rather than
|
|
358
|
+
* refused, so the call cannot be used to probe which endpoints exist — the
|
|
359
|
+
* same answer, and the same absence of a write, as an id that was never
|
|
360
|
+
* registered.
|
|
361
|
+
*
|
|
362
|
+
* `{ userId: null }` (or `undefined`, which normalises to it) addresses the
|
|
363
|
+
* anonymous rows — those registered with no `userId`. An app that registers
|
|
364
|
+
* every device anonymously therefore gets no separation from this check;
|
|
365
|
+
* pass `ctx.auth?.userId` and register with it to get any.
|
|
366
|
+
*/
|
|
367
|
+
unregister: (id: string, owner: PushOwner) => Promise<void>;
|
|
368
|
+
}
|
|
369
|
+
/** Who a {@link LunoraPush.unregister} call is acting as. */
|
|
370
|
+
interface PushOwner {
|
|
371
|
+
/**
|
|
372
|
+
* The authenticated caller (`ctx.auth?.userId`), or `null`/`undefined` for
|
|
373
|
+
* an anonymous registration. Required — see {@link LunoraPush.unregister}.
|
|
374
|
+
*/
|
|
375
|
+
userId: string | null | undefined;
|
|
200
376
|
}
|
|
201
377
|
/** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
|
|
202
378
|
type PushContent = Omit<PushPayload, "to">;
|
|
@@ -207,7 +383,7 @@ type PushContent = Omit<PushPayload, "to">;
|
|
|
207
383
|
* single-channel convenience senders for the edge-safe channels.
|
|
208
384
|
*/
|
|
209
385
|
interface LunoraNotify {
|
|
210
|
-
/**
|
|
386
|
+
/** Post to a chat channel (Slack/Discord/Teams/Telegram). */
|
|
211
387
|
chat: (payload: ChatPayload) => Promise<Receipt>;
|
|
212
388
|
/** Deliver an in-app inbox notification. */
|
|
213
389
|
inApp: (payload: InAppPayload) => Promise<Receipt>;
|
|
@@ -215,7 +391,7 @@ interface LunoraNotify {
|
|
|
215
391
|
push: LunoraPush;
|
|
216
392
|
/** Deliver a multi-channel message (one payload per channel). */
|
|
217
393
|
send: (message: NotificationMessage) => Promise<Receipt[]>;
|
|
218
|
-
/**
|
|
394
|
+
/** Send an outbound webhook. */
|
|
219
395
|
webhook: (payload: WebhookPayload) => Promise<Receipt>;
|
|
220
396
|
}
|
|
221
397
|
/**
|
|
@@ -243,11 +419,32 @@ interface NotifyConfig {
|
|
|
243
419
|
* wildcards) to hard-pin the boundary and close DNS rebinding.
|
|
244
420
|
*/
|
|
245
421
|
allowedPushOrigins?: string[];
|
|
422
|
+
/**
|
|
423
|
+
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
424
|
+
* subscription store (default 250, minimum 1). Each page is fetched,
|
|
425
|
+
* delivered, and counted before the next page's store round trip, so a huge
|
|
426
|
+
* audience is never materialized wholesale in the isolate. Also the
|
|
427
|
+
* per-message bound `push.broadcastPage` (and `runPushBroadcastPage`) uses.
|
|
428
|
+
*
|
|
429
|
+
* Declared here, and not only on `createNotify`'s third argument, because
|
|
430
|
+
* this file is the only handle an app has: the sole production constructor is
|
|
431
|
+
* codegen's fixed `createNotify(definition, env, { log, metrics })`, so a knob
|
|
432
|
+
* that lives only on those options is unsettable by every Lunora app —
|
|
433
|
+
* while {@link SubscriptionFilter.limit}'s own docs point at it as the way to
|
|
434
|
+
* size pages.
|
|
435
|
+
*/
|
|
436
|
+
broadcastPageSize?: number;
|
|
246
437
|
/**
|
|
247
438
|
* Optional chat provider factory (Slack/Discord/Teams/Telegram). Wire with a
|
|
248
439
|
* provider from `@visulima/notification/providers/*`. Edge-safe (fetch-based).
|
|
249
440
|
*/
|
|
250
441
|
chat?: (env: NotifyEnv) => unknown;
|
|
442
|
+
/**
|
|
443
|
+
* Max concurrent sends during a `push.broadcast` (default 10, minimum 1).
|
|
444
|
+
* Same reasoning as {@link NotifyConfig.broadcastPageSize}: this is where an
|
|
445
|
+
* app can reach it.
|
|
446
|
+
*/
|
|
447
|
+
concurrency?: number;
|
|
251
448
|
/** FCM (Firebase Cloud Messaging HTTP v1) config. Edge-safe — supply an OAuth2 token. */
|
|
252
449
|
fcm?: FcmConfig | FcmConfigFactory;
|
|
253
450
|
/** Optional in-app inbox provider factory. Edge-safe. */
|
|
@@ -333,7 +530,21 @@ declare const defineNotify: (config: NotifyConfig) => NotifyDefinition;
|
|
|
333
530
|
declare const isNotifyDefinition: (value: unknown) => value is NotifyDefinition;
|
|
334
531
|
/** Options for {@link createNotify}. */
|
|
335
532
|
interface CreateNotifyOptions {
|
|
336
|
-
/**
|
|
533
|
+
/**
|
|
534
|
+
* Page size for `push.broadcast`'s internal keyset pagination over the
|
|
535
|
+
* subscription store (default {@link DEFAULT_BROADCAST_PAGE_SIZE}, 250).
|
|
536
|
+
*
|
|
537
|
+
* A test/tuning seam only. **Apps set `broadcastPageSize` on `defineNotify`
|
|
538
|
+
* instead** — the sole production call is codegen's fixed
|
|
539
|
+
* `createNotify(definition, env, { log, metrics })`, so nothing an app writes
|
|
540
|
+
* reaches this object. Set here it wins over the definition's value.
|
|
541
|
+
*/
|
|
542
|
+
broadcastPageSize?: number;
|
|
543
|
+
/**
|
|
544
|
+
* Max concurrent sends during a `broadcast` (default 10). Test/tuning seam;
|
|
545
|
+
* apps set `concurrency` on `defineNotify` — see
|
|
546
|
+
* {@link CreateNotifyOptions.broadcastPageSize}.
|
|
547
|
+
*/
|
|
337
548
|
concurrency?: number;
|
|
338
549
|
/**
|
|
339
550
|
* Override the assembled `@visulima/notification` engine. Advanced/testing
|
|
@@ -377,6 +588,11 @@ declare const createNotify: (definition: NotifyDefinition, env: NotifyEnv, optio
|
|
|
377
588
|
};
|
|
378
589
|
/** Options for {@link routingPushProvider}. */
|
|
379
590
|
interface RoutingPushOptions {
|
|
591
|
+
/**
|
|
592
|
+
* The definition's exact-origin allowlist, when configured. Its presence
|
|
593
|
+
* disables the send-time rebinding re-check (see {@link assertPushTargetResolvable}).
|
|
594
|
+
*/
|
|
595
|
+
allowedPushOrigins?: string[];
|
|
380
596
|
fcm?: Provider<unknown, PushPayload>;
|
|
381
597
|
webPush?: Provider<unknown, PushPayload>;
|
|
382
598
|
}
|
|
@@ -390,6 +606,8 @@ interface RoutingPushOptions {
|
|
|
390
606
|
declare const routingPushProvider: (options: RoutingPushOptions) => Provider<unknown, PushPayload>;
|
|
391
607
|
/** A resolved, ready-to-wire set of channel configs (edge-safe channels only). */
|
|
392
608
|
interface ResolvedProviders {
|
|
609
|
+
/** The definition's `allowedPushOrigins`, threaded to the push router's send-time SSRF guard. */
|
|
610
|
+
allowedPushOrigins?: string[];
|
|
393
611
|
chat?: Provider;
|
|
394
612
|
fcm?: FcmConfig;
|
|
395
613
|
inApp?: Provider;
|
|
@@ -407,53 +625,147 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
|
|
|
407
625
|
* A broadcast job body — the JSON-serialisable payload enqueued for off-request
|
|
408
626
|
* fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
|
|
409
627
|
* `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
|
|
628
|
+
* `filter.after`, when set, resumes a broadcast partway through (see
|
|
629
|
+
* {@link runPushBroadcastPage}'s continuation semantics).
|
|
410
630
|
*/
|
|
411
631
|
interface PushBroadcastJob {
|
|
412
|
-
/** Subscription filter (which devices/users to target). */
|
|
632
|
+
/** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
|
|
413
633
|
filter?: SubscriptionFilter;
|
|
414
634
|
/** The push payload to deliver (the `to` target is derived per subscription). */
|
|
415
635
|
payload: PushContent;
|
|
636
|
+
/**
|
|
637
|
+
* Redeliver to exactly these subscription ids instead of walking a page —
|
|
638
|
+
* an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
|
|
639
|
+
* consumer when it re-enqueues a page's transient failures; `filter` is
|
|
640
|
+
* ignored on such a job. See {@link runPushBroadcastPage}.
|
|
641
|
+
*/
|
|
642
|
+
retryIds?: string[];
|
|
416
643
|
/** Discriminator so a shared queue can multiplex message kinds. */
|
|
417
644
|
type: "lunora.push.broadcast";
|
|
418
645
|
}
|
|
419
|
-
/**
|
|
646
|
+
/**
|
|
647
|
+
* One page's outcome plus the ids that need redelivering.
|
|
648
|
+
*
|
|
649
|
+
* The consumer MUST act on BOTH fields: `nextFilter` continues the broadcast and
|
|
650
|
+
* `failedIds` redelivers the recipients this page missed. Acking a message while
|
|
651
|
+
* ignoring either silently drops part of the audience.
|
|
652
|
+
*/
|
|
653
|
+
interface PushBroadcastPageOutcome {
|
|
654
|
+
/**
|
|
655
|
+
* Subscriptions that failed transiently on this run (gone/pruned devices are
|
|
656
|
+
* NOT here — they are deleted, not retried). Re-enqueue a job carrying these
|
|
657
|
+
* as `retryIds` to redeliver to just them.
|
|
658
|
+
*/
|
|
659
|
+
failedIds: string[];
|
|
660
|
+
/**
|
|
661
|
+
* The filter for the CONTINUATION job, or `undefined` when the broadcast is
|
|
662
|
+
* finished (no further pages, or `filter.limit` is spent). Enqueue it
|
|
663
|
+
* verbatim — it carries the next page's cursor AND, when the job set
|
|
664
|
+
* `filter.limit`, the REMAINING budget.
|
|
665
|
+
*
|
|
666
|
+
* This replaces the raw `nextCursor` the runner used to return. Rebuilding
|
|
667
|
+
* the filter at the call site (`{ ...job.filter, after: nextCursor }`)
|
|
668
|
+
* forwarded the ORIGINAL `limit` to every message, so a `limit` documented
|
|
669
|
+
* as an overall audience cap (see {@link SubscriptionFilter.limit}, which
|
|
670
|
+
* `broadcast` honours as one) became a per-message cap and the walk reached
|
|
671
|
+
* the entire audience anyway.
|
|
672
|
+
*/
|
|
673
|
+
nextFilter?: SubscriptionFilter;
|
|
674
|
+
/** This page's delivery result. */
|
|
675
|
+
result: BroadcastResult;
|
|
676
|
+
}
|
|
677
|
+
/** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
|
|
420
678
|
interface QueueProducerLike {
|
|
421
679
|
send: (body: PushBroadcastJob) => Promise<void>;
|
|
422
680
|
}
|
|
423
681
|
/**
|
|
424
682
|
* Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
|
|
425
|
-
* queue instead of blocking the request. Pair with {@link
|
|
426
|
-
* the queue consumer
|
|
683
|
+
* queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
|
|
684
|
+
* the queue consumer — see its doc comment for how a large audience continues
|
|
685
|
+
* across MULTIPLE messages (one bounded page per message), not one.
|
|
427
686
|
*
|
|
428
687
|
* ```ts
|
|
429
|
-
* //
|
|
430
|
-
*
|
|
688
|
+
* // lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
689
|
+
* // `ctx.push` and `ctx.queues` exist.
|
|
690
|
+
* import { internalAction, v } from "./_generated/server";
|
|
691
|
+
* import { enqueuePushBroadcast, runPushBroadcastPage } from "@lunora/notify";
|
|
431
692
|
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* }
|
|
693
|
+
* export const deliverPage = internalAction
|
|
694
|
+
* .input({ job: v.any() })
|
|
695
|
+
* .action(async ({ args: { job }, ctx }) => {
|
|
696
|
+
* const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
697
|
+
*
|
|
698
|
+
* if (nextFilter !== undefined) {
|
|
699
|
+
* // More pages remain — enqueue the continuation. Each message still
|
|
700
|
+
* // does only ONE bounded page of work. Pass `nextFilter` VERBATIM:
|
|
701
|
+
* // it carries the cursor and the remaining `limit` budget.
|
|
702
|
+
* await enqueuePushBroadcast(ctx.queues.push, { filter: nextFilter, payload: job.payload });
|
|
703
|
+
* }
|
|
704
|
+
*
|
|
705
|
+
* if (failedIds.length > 0) {
|
|
706
|
+
* // Redeliver ONLY the recipients that failed — never the whole page.
|
|
707
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
708
|
+
* }
|
|
709
|
+
* });
|
|
710
|
+
*
|
|
711
|
+
* // lunora/queues.ts — the consumer itself has NO ctx.push / ctx.queues: a
|
|
712
|
+
* // `QueueRunContext` is exactly `{ env, log, run }` (handler signature
|
|
713
|
+
* // `(context, batch)`, in that order). It hands each message to the action above.
|
|
714
|
+
* export const push = defineQueue<PushBroadcastJob>({
|
|
715
|
+
* handler: async (context, batch) => {
|
|
716
|
+
* for (const message of batch.messages) {
|
|
717
|
+
* await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
718
|
+
* message.ack();
|
|
719
|
+
* }
|
|
720
|
+
* },
|
|
721
|
+
* });
|
|
722
|
+
*
|
|
723
|
+
* // in a mutation/action, to start it:
|
|
724
|
+
* await enqueuePushBroadcast(ctx.queues.push, { payload: { body: "…", title: "New drop" } });
|
|
436
725
|
* ```
|
|
437
726
|
*/
|
|
438
727
|
declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBroadcastJob, "type">) => Promise<void>;
|
|
439
728
|
/**
|
|
440
|
-
* Run an enqueued broadcast job on the consumer side,
|
|
441
|
-
*
|
|
442
|
-
* gone
|
|
729
|
+
* Run ONE bounded page of an enqueued broadcast job on the consumer side,
|
|
730
|
+
* delivering through the push facade's {@link LunoraPush.broadcastPage} (which
|
|
731
|
+
* reuses the engine's retry + circuit-breaker middleware and prunes gone
|
|
732
|
+
* subscriptions).
|
|
733
|
+
*
|
|
734
|
+
* RETRY / CONTINUATION SEMANTICS:
|
|
443
735
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
736
|
+
* - A job processes exactly ONE bounded page (see `defineNotify`'s
|
|
737
|
+
* `broadcastPageSize`, default 250, or `job.filter.limit` when smaller),
|
|
738
|
+
* keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
|
|
739
|
+
* — so per-message work is bounded regardless of total audience size.
|
|
740
|
+
* - A page NEVER throws for a partial failure. Throwing discarded the page's
|
|
741
|
+
* continuation, which is the only way the broadcast advances: one device that
|
|
742
|
+
* fails permanently (a rotated VAPID keypair leaves a stale device answering
|
|
743
|
+
* `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
|
|
744
|
+
* every already-delivered recipient on each retry, dead-letter, and leave
|
|
745
|
+
* every LATER page unreached. The page's `nextFilter` and its `failedIds`
|
|
746
|
+
* both come back instead.
|
|
747
|
+
* - The CALLER re-enqueues: `filter: nextFilter` while more pages remain, and a
|
|
748
|
+
* `retryIds: failedIds` job when any recipient failed. `@lunora/notify` cannot
|
|
749
|
+
* do it itself — it has no `@lunora/queue` dependency (the seam stays
|
|
750
|
+
* structural) and no reference to the producer that enqueued this message. See
|
|
751
|
+
* the consumer example on {@link enqueuePushBroadcast}.
|
|
752
|
+
* - `job.filter.limit` is spent across messages, not re-granted to each one:
|
|
753
|
+
* `nextFilter` carries the REMAINING budget and is `undefined` once it runs
|
|
754
|
+
* out, so `limit` caps the whole audience here exactly as it does on
|
|
755
|
+
* {@link LunoraPush.broadcast}.
|
|
756
|
+
* - A `retryIds` job redelivers to exactly those ids and throws only while ALL
|
|
757
|
+
* of them still fail, so the queue's backoff/dead-letter bounds a device that
|
|
758
|
+
* never recovers. Once any recipient recovers the run resolves and reports the
|
|
759
|
+
* rest in `failedIds`, so the narrower retry never re-sends to a device this
|
|
760
|
+
* message already reached. A recipient that turns out to be GONE — a 404/410
|
|
761
|
+
* receipt, or an id whose row no longer exists at all — counts as `pruned` here
|
|
762
|
+
* too, never as a failure: there is nothing left to redeliver to, so retrying it
|
|
763
|
+
* could only burn the queue's budget and dead-letter an unsubscribe.
|
|
764
|
+
* - Gone subscriptions (Web Push 404/410, FCM's `NOT_FOUND` for a dead token) are pruned by the page and
|
|
765
|
+
* never appear in `failedIds` — an all-`pruned` page is a success, not a
|
|
766
|
+
* failure, as is an empty page.
|
|
455
767
|
*/
|
|
456
|
-
declare const
|
|
768
|
+
declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
|
|
457
769
|
/**
|
|
458
770
|
* The minimal structural slice of Cloudflare's `D1Database` this store uses. A
|
|
459
771
|
* structural type (rather than importing `@cloudflare/workers-types`) keeps the
|
|
@@ -505,7 +817,21 @@ declare const d1SubscriptionStore: (database: D1Like, options?: D1StoreOptions)
|
|
|
505
817
|
*/
|
|
506
818
|
declare const memorySubscriptionStore: () => SubscriptionStore;
|
|
507
819
|
/**
|
|
508
|
-
* Stable store id for a web-push endpoint
|
|
820
|
+
* Stable store id for a web-push endpoint — `fnv1a64Hex` of the (long) endpoint,
|
|
821
|
+
* so re-registering the same device upserts rather than duplicates.
|
|
822
|
+
*
|
|
823
|
+
* The digest comes from the canonical `shared/fnv1a`, not a local copy. This id
|
|
824
|
+
* is a PERSISTED primary key: a digest that drifts in one copy silently re-keys
|
|
825
|
+
* every existing subscription — the old row goes dark and the device
|
|
826
|
+
* re-registers as a duplicate — so the implementation must have exactly one
|
|
827
|
+
* home. `shared/fnv1a`'s is bit-verified against a BigInt reference in
|
|
828
|
+
* `packages/replica/__tests__/apply-diff.test.ts`.
|
|
829
|
+
*
|
|
830
|
+
* Widened from the previous 32-bit FNV-1a (8 hex): at 100K devices a 32-bit key
|
|
831
|
+
* collides with ~68% probability (birthday bound), and a collision silently
|
|
832
|
+
* overwrites another device's row under the store's `ON CONFLICT(id) DO UPDATE` —
|
|
833
|
+
* so the wrong user gets the push and the victim goes dark. 64 bits drops that to
|
|
834
|
+
* negligible at any realistic device count.
|
|
509
835
|
*
|
|
510
836
|
* The `wp2_` prefix is a version tag (see also {@link fcmId}'s `fcm2_`): it marks
|
|
511
837
|
* the 64-bit-id revision so the pre-existing 32-bit `wp_` rows stay readable and a
|
|
@@ -526,9 +852,10 @@ interface NormalizeOptions {
|
|
|
526
852
|
*
|
|
527
853
|
* When unset, the default posture applies: `https:` scheme + a host the
|
|
528
854
|
* {@link assertPushEndpoint} STRING classifier does not flag as
|
|
529
|
-
* private/loopback
|
|
530
|
-
*
|
|
531
|
-
*
|
|
855
|
+
* private/loopback, plus a resolved-address re-check at send time. Setting
|
|
856
|
+
* this allowlist replaces both with an exact-origin match — the hard
|
|
857
|
+
* guarantee, and the only one that also covers an internal push service you
|
|
858
|
+
* deliberately want to reach.
|
|
532
859
|
*/
|
|
533
860
|
allowedPushOrigins?: string[];
|
|
534
861
|
}
|
|
@@ -536,7 +863,8 @@ interface NormalizeOptions {
|
|
|
536
863
|
* Normalise a `register(...)` input into a {@link StoredSubscription}. Validates
|
|
537
864
|
* the shape (a web-push subscription needs `endpoint` + `keys.{p256dh,auth}`; an
|
|
538
865
|
* FCM entry needs a non-empty `token`), enforces the anti-SSRF endpoint boundary
|
|
539
|
-
* (see {@link assertPushEndpoint}),
|
|
866
|
+
* (see {@link assertPushEndpoint}), validates `metadata` (see
|
|
867
|
+
* {@link validateMetadata}), and stamps `createdAt`/`lastSeenAt`.
|
|
540
868
|
*/
|
|
541
869
|
declare const normalizeRegisterInput: (input: RegisterInput, now?: number, options?: NormalizeOptions) => StoredSubscription;
|
|
542
870
|
/**
|
|
@@ -550,11 +878,97 @@ declare const targetOf: (subscription: StoredSubscription) => string;
|
|
|
550
878
|
* (the browser/device unsubscribed) and should be pruned — as opposed to a
|
|
551
879
|
* transient failure worth retrying.
|
|
552
880
|
*
|
|
553
|
-
* Gates on STRUCTURED signals first:
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
881
|
+
* Gates on STRUCTURED signals first: an `HTTP 404/410` status (both providers
|
|
882
|
+
* answer one for a dead endpoint/token, though FCM's is usually replaced by its
|
|
883
|
+
* error body before it reaches here) or, for FCM only, an
|
|
884
|
+
* `UNREGISTERED`/`NOT_REGISTERED` code or the `NOT_FOUND` prose that is the one
|
|
885
|
+
* signal its provider actually forwards (see {@link FCM_GONE_PATTERN}). The
|
|
886
|
+
* free-text {@link GONE_TEXT_FALLBACK} is a tightened last resort only, so a
|
|
887
|
+
* transient error that happens to contain `expired` (a cert/session expiry) can
|
|
888
|
+
* never permanently drop a valid subscription.
|
|
889
|
+
*
|
|
890
|
+
* `kind` scopes the PROVIDER-SPECIFIC patterns to the provider that emits them.
|
|
891
|
+
* The web-push provider echoes the push service's response body into
|
|
892
|
+
* `HTTP ${status}: ${body}`, so a 4xx whose prose merely contains "not
|
|
893
|
+
* registered" matched the FCM-only codes and permanently deleted a live
|
|
894
|
+
* subscription. Omit `kind` (the third-party/unknown-provider case) to test
|
|
895
|
+
* every pattern, as before.
|
|
896
|
+
*/
|
|
897
|
+
declare const isGoneError: (message: string | undefined, kind?: StoredSubscription["kind"]) => boolean;
|
|
898
|
+
export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions, type D1Like, type D1PreparedLike, type D1StoreOptions,
|
|
899
|
+
/**
|
|
900
|
+
* `@lunora/notify`
|
|
901
|
+
*
|
|
902
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
903
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
904
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
905
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
906
|
+
* → `ctx.flags`).
|
|
907
|
+
*
|
|
908
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
909
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
910
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
911
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
912
|
+
*
|
|
913
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
914
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
915
|
+
* @packageDocumentation
|
|
916
|
+
*/
|
|
917
|
+
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 SubscriptionStatus, type SubscriptionStore,
|
|
918
|
+
/**
|
|
919
|
+
* `@lunora/notify`
|
|
920
|
+
*
|
|
921
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
922
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
923
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
924
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
925
|
+
* → `ctx.flags`).
|
|
926
|
+
*
|
|
927
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
928
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
929
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
930
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
931
|
+
*
|
|
932
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
933
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
934
|
+
* @packageDocumentation
|
|
935
|
+
*/
|
|
936
|
+
WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1SubscriptionStore, defineNotify, enqueuePushBroadcast,
|
|
937
|
+
/**
|
|
938
|
+
* `@lunora/notify`
|
|
939
|
+
*
|
|
940
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
941
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
942
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
943
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
944
|
+
* → `ctx.flags`).
|
|
945
|
+
*
|
|
946
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
947
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
948
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
949
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
950
|
+
*
|
|
951
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
952
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
953
|
+
* @packageDocumentation
|
|
954
|
+
*/
|
|
955
|
+
fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
|
|
956
|
+
/**
|
|
957
|
+
* `@lunora/notify`
|
|
958
|
+
*
|
|
959
|
+
* Multi-channel notifications for Lunora, wrapping the `@visulima/notification`
|
|
960
|
+
* engine. `defineNotify` in `lunora/notify.ts` configures the edge-safe channels
|
|
961
|
+
* (Web Push + FCM, plus chat / in-app inbox / webhook); codegen wires `ctx.notify`
|
|
962
|
+
* and its `ctx.push` alias onto every handler ctx from it (mirroring `defineFlags`
|
|
963
|
+
* → `ctx.flags`).
|
|
964
|
+
*
|
|
965
|
+
* Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
|
|
966
|
+
* Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
|
|
967
|
+
* deliberately **not** on the edge facade — route heavy fan-out through
|
|
968
|
+
* `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
|
|
969
|
+
*
|
|
970
|
+
* - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
|
|
971
|
+
* - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
|
|
972
|
+
* @packageDocumentation
|
|
558
973
|
*/
|
|
559
|
-
|
|
560
|
-
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 };
|
|
974
|
+
webPushFromEnv, webPushId };
|