@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/README.md
CHANGED
|
@@ -39,35 +39,90 @@ Generate a VAPID keypair once: `npx web-push generate-vapid-keys`.
|
|
|
39
39
|
```ts
|
|
40
40
|
import { subscribeToPush } from "@lunora/notify/web";
|
|
41
41
|
|
|
42
|
-
const subscription = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
|
|
43
|
-
await client.mutation("registerDevice", { subscription });
|
|
42
|
+
const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorkerUrl: "/sw.js", vapidPublicKey });
|
|
43
|
+
await client.mutation("registerDevice", { replacedEndpoint, subscription });
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
`replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
|
|
47
|
+
subscription is dropped and a new one minted, and the new one has a new endpoint
|
|
48
|
+
— hence a new store id — so it never upserts over the old row. Every send to that
|
|
49
|
+
row now answers `403 VapidPkHashMismatch`, which is (correctly) not a "gone"
|
|
50
|
+
signal, so nothing prunes it either. Forward it and unregister it:
|
|
51
|
+
|
|
46
52
|
```ts
|
|
47
53
|
// lunora/registerDevice.ts (a mutation — storage write is fine here)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
import { webPushId } from "@lunora/notify";
|
|
55
|
+
|
|
56
|
+
export const registerDevice = mutation
|
|
57
|
+
.input({ replacedEndpoint: v.optional(v.string()), subscription: v.any() })
|
|
58
|
+
.mutation(async ({ ctx, args: { replacedEndpoint, subscription } }) => {
|
|
59
|
+
if (replacedEndpoint !== undefined) {
|
|
60
|
+
await ctx.push.unregister(webPushId(replacedEndpoint), { userId: ctx.auth?.userId });
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
await ctx.push.register({ subscription, userId: ctx.auth?.userId });
|
|
52
|
-
}
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`unregister`'s owner argument is **required**, and the row is removed only when
|
|
68
|
+
it carries that same owner. A subscription id is derived from the endpoint, so
|
|
69
|
+
`replacedEndpoint` is a caller-controlled key and nothing about it proves the
|
|
70
|
+
browser that sent it ever held the subscription it names — without the scope,
|
|
71
|
+
anyone who could guess or observe another user's endpoint could silence that
|
|
72
|
+
device. A row owned by someone else is left alone silently, so the call cannot be
|
|
73
|
+
used to probe which endpoints exist. Register with the same `userId` you
|
|
74
|
+
unregister with; devices registered anonymously (`userId` absent) all share the
|
|
75
|
+
one anonymous scope and get no separation from this check.
|
|
76
|
+
|
|
77
|
+
`register` is scoped the same way, because an unguarded upsert closes only half
|
|
78
|
+
of that: re-registering a victim's endpoint under your own `userId` (with keys of
|
|
79
|
+
your choosing) takes their device dark just as effectively, and hands you
|
|
80
|
+
`unregister` over it. An endpoint already registered to another user is
|
|
81
|
+
**refused** (`FORBIDDEN`) rather than re-owned — unowned rows stay claimable (the
|
|
82
|
+
device signed in), and a device that legitimately changes hands unregisters as
|
|
83
|
+
its current owner first.
|
|
84
|
+
|
|
85
|
+
**Unregister on sign-out, or the next account on that browser cannot register.**
|
|
86
|
+
`subscribeToPush` REUSES the browser's existing subscription while the VAPID key
|
|
87
|
+
is unchanged, so the endpoint — and the store id derived from it — is the same
|
|
88
|
+
for every account that signs in on that browser. Without the sign-out call, user
|
|
89
|
+
B's `register` hits user A's row and throws `FORBIDDEN`; since `register` is
|
|
90
|
+
usually fire-and-forget on sign-in, that surfaces as a failed mutation and B
|
|
91
|
+
silently never receives a push. Release the row where you clear the session:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// lunora/registerDevice.ts — the same file as above
|
|
95
|
+
export const unregisterDevice = mutation.input({ endpoint: v.string() }).mutation(async ({ args: { endpoint }, ctx }) => {
|
|
96
|
+
await ctx.push.unregister(webPushId(endpoint), { userId: ctx.auth?.userId });
|
|
53
97
|
});
|
|
54
98
|
```
|
|
55
99
|
|
|
100
|
+
```ts
|
|
101
|
+
// wherever you sign out. `subscription.endpoint` is on the object subscribeToPush returned.
|
|
102
|
+
await client.mutation("unregisterDevice", { endpoint: subscription.endpoint });
|
|
103
|
+
await auth.signOut();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Keep the browser subscription itself (don't call `unsubscribeFromPush`) unless
|
|
107
|
+
the user is turning notifications off: dropping it re-prompts for permission on
|
|
108
|
+
the next sign-in. Note that only the owner can release a row — B cannot
|
|
109
|
+
`unregister` A's — so a sign-out that never runs (the tab was closed, the session
|
|
110
|
+
expired) leaves the next account refused until A signs in again on that browser
|
|
111
|
+
or the row is removed server-side. On a browser several people sign in on, treat
|
|
112
|
+
the sign-out unregister as required, not as cleanup.
|
|
113
|
+
|
|
56
114
|
## Send (from an action)
|
|
57
115
|
|
|
58
116
|
Notification sends are external I/O, so they belong in **actions** (the `notify_send_outside_action` advisor lint enforces this):
|
|
59
117
|
|
|
60
118
|
```ts
|
|
61
|
-
export const announce = action({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = await ctx.push.broadcast({ title, body });
|
|
65
|
-
// result: { total, sent, pruned, failed, outcomes }
|
|
66
|
-
},
|
|
119
|
+
export const announce = action.input({ title: v.string(), body: v.string() }).action(async ({ ctx, args: { title, body } }) => {
|
|
120
|
+
const result = await ctx.push.broadcast({ title, body });
|
|
121
|
+
// result: { total, sent, pruned, failed, outcomes }
|
|
67
122
|
});
|
|
68
123
|
```
|
|
69
124
|
|
|
70
|
-
`broadcast` reuses the engine's retry + circuit-breaker middleware and prunes subscriptions the push service reports as gone (HTTP 404/410
|
|
125
|
+
`broadcast` reuses the engine's retry + circuit-breaker middleware and prunes subscriptions the push service reports as gone (Web Push HTTP 404/410; FCM's `NOT_FOUND` answer for a dead token, plus the `UNREGISTERED`/`NotRegistered` codes a legacy transport sends). A single targeted send:
|
|
71
126
|
|
|
72
127
|
```ts
|
|
73
128
|
await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });
|
|
@@ -90,8 +145,36 @@ Move a large broadcast off the request path with `@lunora/queue`:
|
|
|
90
145
|
// producer (mutation/action)
|
|
91
146
|
await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
|
|
92
147
|
|
|
93
|
-
//
|
|
94
|
-
|
|
148
|
+
// lunora/notify-fanout.ts — an INTERNAL ACTION, because that is where
|
|
149
|
+
// `ctx.push` and `ctx.queues` exist.
|
|
150
|
+
export const deliverPage = internalAction.input({ job: v.any() }).action(async ({ args: { job }, ctx }) => {
|
|
151
|
+
const { failedIds, nextFilter } = await runPushBroadcastPage(ctx.push, job);
|
|
152
|
+
|
|
153
|
+
// One message = ONE bounded page. Discarding `nextFilter` delivers only the
|
|
154
|
+
// first page (default 250 devices) and reports success for the whole audience.
|
|
155
|
+
// Pass it verbatim — it carries the cursor AND the remaining `filter.limit`.
|
|
156
|
+
if (nextFilter !== undefined) {
|
|
157
|
+
await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, filter: nextFilter });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Redeliver ONLY the recipients that failed — a retry of the whole page would
|
|
161
|
+
// re-POST everyone it already reached.
|
|
162
|
+
if (failedIds.length > 0) {
|
|
163
|
+
await enqueuePushBroadcast(ctx.queues.push, { payload: job.payload, retryIds: failedIds });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// lunora/queues.ts — a `QueueRunContext` is exactly `{ env, log, run }`: no
|
|
168
|
+
// `ctx.push`, no `ctx.queues`. The consumer hands each message to the action above.
|
|
169
|
+
// (Note the handler signature: `(context, batch)`, in that order.)
|
|
170
|
+
export const push = defineQueue<PushBroadcastJob>({
|
|
171
|
+
handler: async (context, batch) => {
|
|
172
|
+
for (const message of batch.messages) {
|
|
173
|
+
await message.run(internal.notifyFanout.deliverPage, { job: message.body });
|
|
174
|
+
message.ack();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
95
178
|
```
|
|
96
179
|
|
|
97
180
|
## Subscription storage
|
|
@@ -123,15 +206,17 @@ client-supplied data, so the facade enforces two boundaries:
|
|
|
123
206
|
- **No secrets on the app facade.** `ctx.push.list()`
|
|
124
207
|
returns the registered devices with the delivery **secrets stripped** — the Web
|
|
125
208
|
Push `keys` (`auth`/`p256dh`) and the FCM `token`, which together with the
|
|
126
|
-
endpoint are enough to deliver arbitrary push to a device.
|
|
127
|
-
|
|
128
|
-
|
|
209
|
+
endpoint are enough to deliver arbitrary push to a device. Every other facade
|
|
210
|
+
read is projected the same way; the broadcast path uses the store directly. The
|
|
211
|
+
one place a handler does see a raw row is the return of `ctx.push.register(...)`,
|
|
212
|
+
which echoes back the record the caller just supplied — nothing it did not
|
|
213
|
+
already hold, and never another device's.
|
|
129
214
|
|
|
130
215
|
## Delivery observability
|
|
131
216
|
|
|
132
217
|
Every send is counted onto `ctx.metrics` and failures onto `ctx.log` for you — codegen threads the request's logger/metrics into `ctx.notify` (`createNotify(notifyConfig, env, { log, metrics })`), so there is nothing to wire. Two low-cardinality metric series feed the durable metric history + trend charts:
|
|
133
218
|
|
|
134
|
-
- **`notify.send`** `{ channel, provider, status }` — attempted sends. `status` is `accepted` (the provider took it), `failed`, or `gone` (endpoint unregistered — 404/410
|
|
219
|
+
- **`notify.send`** `{ channel, provider, status }` — attempted sends. `status` is `accepted` (the provider took it), `failed`, or `gone` (endpoint unregistered — Web Push 404/410, or FCM's `NOT_FOUND` for a dead token — and pruned). A single send counts 1; a **broadcast aggregates** into one count per `(provider, status)` bucket (value = the bucket's count), not one per recipient — each `ctx.metrics.count` is a durable write.
|
|
135
220
|
- **`notify.skipped`** `{ channel, reason }` — a send that reached nobody: `no-subscriptions-matched` (empty broadcast) or `channel-not-configured`.
|
|
136
221
|
|
|
137
222
|
A **failed** send also emits one `ctx.log.warn` line carrying the error and, for push, the subscription/user ids — trace-correlated to the enclosing action and durably archived. Successes and prunes stay off the log; failure logs stay per-recipient even in a broadcast (they have no durable write).
|