@growth-labs/push 0.2.0 → 0.4.1
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 +6 -2
- package/dist/db/schema.d.ts +24 -10
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/queue.d.ts +18 -2
- package/dist/utils/queue.d.ts.map +1 -1
- package/dist/utils/queue.js +43 -9
- package/dist/utils/queue.js.map +1 -1
- package/dist/utils/scheduled.d.ts +1 -0
- package/dist/utils/scheduled.d.ts.map +1 -1
- package/dist/utils/scheduled.js +1 -1
- package/dist/utils/scheduled.js.map +1 -1
- package/package.json +13 -7
- package/src/db/schema.ts +14 -0
- package/src/index.ts +69 -0
- package/src/middleware/push.ts +10 -0
- package/src/options.ts +45 -0
- package/src/routes/subscribe.ts +82 -0
- package/src/routes/unsubscribe.ts +29 -0
- package/src/types.ts +129 -0
- package/src/utils/analytics.ts +96 -0
- package/src/utils/cleanup.ts +28 -0
- package/src/utils/composer.ts +141 -0
- package/src/utils/index.ts +38 -0
- package/src/utils/queue.ts +173 -0
- package/src/utils/scheduled.ts +152 -0
- package/src/utils/send.ts +140 -0
- package/src/utils/subscriptions.ts +124 -0
- package/src/utils/turnstile.ts +25 -0
- package/src/utils/vapid.ts +28 -0
- package/src/client/push-sw.d.ts.map +0 -1
- package/src/client/push-sw.js +0 -25
- package/src/client/push-sw.js.map +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PushPayload,
|
|
3
|
+
PushQueueMessage,
|
|
4
|
+
PushSendOptions,
|
|
5
|
+
PushSubscriptionRecord,
|
|
6
|
+
SitePushConfig,
|
|
7
|
+
SitePushConfigLookup,
|
|
8
|
+
} from '../types.js'
|
|
9
|
+
import { type SendContext, sendPushBatch } from './send.js'
|
|
10
|
+
import {
|
|
11
|
+
deleteSubscriptionById,
|
|
12
|
+
getAllSubscriptions,
|
|
13
|
+
incrementFailureCount,
|
|
14
|
+
markDelivered,
|
|
15
|
+
} from './subscriptions.js'
|
|
16
|
+
|
|
17
|
+
interface EnqueueContext {
|
|
18
|
+
queue: Queue
|
|
19
|
+
siteId: string
|
|
20
|
+
batchSize?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a known set of subscriptions into batches and enqueue each batch.
|
|
25
|
+
* Each emitted message carries the caller's `siteId` so the realm consumer
|
|
26
|
+
* can apply per-site overrides via `siteConfigLookup`.
|
|
27
|
+
*/
|
|
28
|
+
export async function enqueuePushBatch(
|
|
29
|
+
subscriptions: PushSubscriptionRecord[],
|
|
30
|
+
payload: PushPayload,
|
|
31
|
+
context: EnqueueContext,
|
|
32
|
+
options: PushSendOptions = {},
|
|
33
|
+
): Promise<{ batches: number; totalSubscriptions: number }> {
|
|
34
|
+
const batchSize = context.batchSize ?? 100
|
|
35
|
+
let batches = 0
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < subscriptions.length; i += batchSize) {
|
|
38
|
+
const batch = subscriptions.slice(i, i + batchSize)
|
|
39
|
+
const message: PushQueueMessage = {
|
|
40
|
+
siteId: context.siteId,
|
|
41
|
+
subscriptions: batch,
|
|
42
|
+
payload,
|
|
43
|
+
options,
|
|
44
|
+
}
|
|
45
|
+
await context.queue.send(message)
|
|
46
|
+
batches++
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
batches,
|
|
51
|
+
totalSubscriptions: subscriptions.length,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Page through all subscriptions in D1 and enqueue batches as it goes.
|
|
57
|
+
* Never holds more than one page of subscriptions in memory.
|
|
58
|
+
*/
|
|
59
|
+
export async function enqueuePushBatchAll(
|
|
60
|
+
db: D1Database,
|
|
61
|
+
payload: PushPayload,
|
|
62
|
+
context: EnqueueContext,
|
|
63
|
+
options: PushSendOptions = {},
|
|
64
|
+
): Promise<{ batches: number; totalSubscriptions: number }> {
|
|
65
|
+
const batchSize = context.batchSize ?? 100
|
|
66
|
+
let offset = 0
|
|
67
|
+
let batches = 0
|
|
68
|
+
let total = 0
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
const subs = await getAllSubscriptions(db, { limit: batchSize, offset })
|
|
72
|
+
if (subs.length === 0) break
|
|
73
|
+
|
|
74
|
+
const message: PushQueueMessage = {
|
|
75
|
+
siteId: context.siteId,
|
|
76
|
+
subscriptions: subs,
|
|
77
|
+
payload,
|
|
78
|
+
options,
|
|
79
|
+
}
|
|
80
|
+
await context.queue.send(message)
|
|
81
|
+
|
|
82
|
+
batches++
|
|
83
|
+
total += subs.length
|
|
84
|
+
offset += batchSize
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { batches, totalSubscriptions: total }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface QueueConsumerContext {
|
|
91
|
+
db: D1Database
|
|
92
|
+
vapid: {
|
|
93
|
+
subject: string
|
|
94
|
+
publicKey: string
|
|
95
|
+
privateKey: string
|
|
96
|
+
}
|
|
97
|
+
defaults: {
|
|
98
|
+
icon?: string
|
|
99
|
+
ttl: number
|
|
100
|
+
urgency: 'very-low' | 'low' | 'normal' | 'high'
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Optional realm-level multi-tenant lookup. When provided, the consumer
|
|
104
|
+
* calls it per message using `message.body.siteId` and merges the result
|
|
105
|
+
* over the base context for that batch.
|
|
106
|
+
*/
|
|
107
|
+
siteConfigLookup?: SitePushConfigLookup
|
|
108
|
+
/**
|
|
109
|
+
* Worker env, forwarded to `siteConfigLookup` so the lookup can read
|
|
110
|
+
* KV / D1 / etc. Required when `siteConfigLookup` is set.
|
|
111
|
+
*/
|
|
112
|
+
env?: Record<string, unknown>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyOverride(
|
|
116
|
+
context: QueueConsumerContext,
|
|
117
|
+
override: SitePushConfig | null,
|
|
118
|
+
): { vapid: QueueConsumerContext['vapid']; defaults: QueueConsumerContext['defaults'] } {
|
|
119
|
+
if (!override) return { vapid: context.vapid, defaults: context.defaults }
|
|
120
|
+
return {
|
|
121
|
+
vapid: {
|
|
122
|
+
subject: override.vapid?.subject ?? context.vapid.subject,
|
|
123
|
+
publicKey: override.vapid?.publicKey ?? context.vapid.publicKey,
|
|
124
|
+
privateKey: override.vapid?.privateKey ?? context.vapid.privateKey,
|
|
125
|
+
},
|
|
126
|
+
defaults: {
|
|
127
|
+
icon: override.defaults?.icon ?? context.defaults.icon,
|
|
128
|
+
ttl: override.defaults?.ttl ?? context.defaults.ttl,
|
|
129
|
+
urgency: override.defaults?.urgency ?? context.defaults.urgency,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Queue consumer handler. Process a batch of push notification sends.
|
|
136
|
+
* VAPID credentials come from context — they are NOT in the Queue message.
|
|
137
|
+
* When `context.siteConfigLookup` is configured, per-message overrides are
|
|
138
|
+
* applied for VAPID identity and notification defaults.
|
|
139
|
+
*/
|
|
140
|
+
export async function handlePushQueue(
|
|
141
|
+
batch: MessageBatch<PushQueueMessage>,
|
|
142
|
+
context: QueueConsumerContext,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
for (const message of batch.messages) {
|
|
145
|
+
const override = context.siteConfigLookup
|
|
146
|
+
? await context.siteConfigLookup(message.body.siteId, context.env ?? {})
|
|
147
|
+
: null
|
|
148
|
+
const effective = applyOverride(context, override)
|
|
149
|
+
|
|
150
|
+
const sendContext: SendContext = {
|
|
151
|
+
vapidSubject: effective.vapid.subject,
|
|
152
|
+
vapidPublicKey: effective.vapid.publicKey,
|
|
153
|
+
vapidPrivateKey: effective.vapid.privateKey,
|
|
154
|
+
defaults: effective.defaults,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { subscriptions, payload, options } = message.body
|
|
158
|
+
|
|
159
|
+
const result = await sendPushBatch(subscriptions, payload, sendContext, options)
|
|
160
|
+
|
|
161
|
+
for (const sendResult of result.results) {
|
|
162
|
+
if (sendResult.success) {
|
|
163
|
+
await markDelivered(context.db, sendResult.subscriptionId)
|
|
164
|
+
} else if (sendResult.expired) {
|
|
165
|
+
await deleteSubscriptionById(context.db, sendResult.subscriptionId)
|
|
166
|
+
} else {
|
|
167
|
+
await incrementFailureCount(context.db, sendResult.subscriptionId)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
message.ack()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { PushPayload, PushSendOptions } from '../types.js'
|
|
2
|
+
import { trackPushSend } from './analytics.js'
|
|
3
|
+
import { cleanupStaleSubscriptions } from './cleanup.js'
|
|
4
|
+
import { enqueuePushBatchAll } from './queue.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Context required by the scheduled push handler.
|
|
8
|
+
* The consumer provides this from their cron trigger's env bindings.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScheduledPushContext {
|
|
11
|
+
db: D1Database
|
|
12
|
+
queue: Queue
|
|
13
|
+
siteId: string
|
|
14
|
+
batchSize?: number
|
|
15
|
+
cleanup: {
|
|
16
|
+
staleAfterDays: number
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of a scheduled push run.
|
|
22
|
+
*/
|
|
23
|
+
export interface ScheduledPushResult {
|
|
24
|
+
notification: {
|
|
25
|
+
batches: number
|
|
26
|
+
totalSubscriptions: number
|
|
27
|
+
} | null
|
|
28
|
+
cleanup: {
|
|
29
|
+
removed: number
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A content provider function that returns a push payload if there is
|
|
35
|
+
* something to send, or null if there is nothing to notify about.
|
|
36
|
+
*
|
|
37
|
+
* The consumer implements this to check for new content, scheduled
|
|
38
|
+
* campaigns, or other triggers that should produce a notification.
|
|
39
|
+
*
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const provider: ScheduledContentProvider = async (context) => {
|
|
42
|
+
* const latest = await context.db
|
|
43
|
+
* .prepare('SELECT * FROM articles WHERE published_at > ? AND push_sent = 0 LIMIT 1')
|
|
44
|
+
* .bind(Date.now() / 1000 - 3600)
|
|
45
|
+
* .first()
|
|
46
|
+
*
|
|
47
|
+
* if (!latest) return null
|
|
48
|
+
*
|
|
49
|
+
* await context.db
|
|
50
|
+
* .prepare('UPDATE articles SET push_sent = 1 WHERE id = ?')
|
|
51
|
+
* .bind(latest.id)
|
|
52
|
+
* .run()
|
|
53
|
+
*
|
|
54
|
+
* return {
|
|
55
|
+
* payload: {
|
|
56
|
+
* title: 'New article',
|
|
57
|
+
* body: latest.title as string,
|
|
58
|
+
* url: `https://example.com/articles/${latest.slug}`,
|
|
59
|
+
* },
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export type ScheduledContentProvider = (context: {
|
|
65
|
+
db: D1Database
|
|
66
|
+
}) => Promise<{ payload: PushPayload; options?: PushSendOptions } | null>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Cron-triggered push handler. Wires together content checking, notification
|
|
70
|
+
* enqueuing, and subscription cleanup into a single scheduled invocation.
|
|
71
|
+
*
|
|
72
|
+
* The consumer wires this into their Cloudflare Worker's scheduled handler:
|
|
73
|
+
*
|
|
74
|
+
* ```typescript
|
|
75
|
+
* import { handleScheduledPush } from '@growth-labs/push/utils'
|
|
76
|
+
*
|
|
77
|
+
* export default {
|
|
78
|
+
* async scheduled(event, env) {
|
|
79
|
+
* await handleScheduledPush(
|
|
80
|
+
* {
|
|
81
|
+
* db: env.SITE_DB,
|
|
82
|
+
* queue: env.PUSH_QUEUE,
|
|
83
|
+
* cleanup: { staleAfterDays: 90 },
|
|
84
|
+
* },
|
|
85
|
+
* async ({ db }) => {
|
|
86
|
+
* // Check for unsent content
|
|
87
|
+
* const article = await db
|
|
88
|
+
* .prepare('SELECT * FROM articles WHERE push_sent = 0 LIMIT 1')
|
|
89
|
+
* .first()
|
|
90
|
+
* if (!article) return null
|
|
91
|
+
* return {
|
|
92
|
+
* payload: {
|
|
93
|
+
* title: 'New article',
|
|
94
|
+
* body: article.title as string,
|
|
95
|
+
* url: `https://example.com/${article.slug}`,
|
|
96
|
+
* },
|
|
97
|
+
* }
|
|
98
|
+
* },
|
|
99
|
+
* )
|
|
100
|
+
* },
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* On each invocation:
|
|
105
|
+
* 1. Calls the content provider to check if there's something to send
|
|
106
|
+
* 2. If yes, enqueues push batches for all subscribers via the Queue
|
|
107
|
+
* 3. Always runs subscription cleanup to remove stale/dead subscriptions
|
|
108
|
+
*/
|
|
109
|
+
export async function handleScheduledPush(
|
|
110
|
+
context: ScheduledPushContext,
|
|
111
|
+
contentProvider: ScheduledContentProvider,
|
|
112
|
+
): Promise<ScheduledPushResult> {
|
|
113
|
+
let notification: ScheduledPushResult['notification'] = null
|
|
114
|
+
|
|
115
|
+
// Step 1: Check if there's content to push
|
|
116
|
+
const content = await contentProvider({ db: context.db })
|
|
117
|
+
|
|
118
|
+
// Step 2: Enqueue notifications if content is available
|
|
119
|
+
if (content) {
|
|
120
|
+
const enqueueResult = await enqueuePushBatchAll(
|
|
121
|
+
context.db,
|
|
122
|
+
content.payload,
|
|
123
|
+
{ queue: context.queue, siteId: context.siteId, batchSize: context.batchSize },
|
|
124
|
+
content.options ?? {},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
notification = {
|
|
128
|
+
batches: enqueueResult.batches,
|
|
129
|
+
totalSubscriptions: enqueueResult.totalSubscriptions,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Emit server-side analytics event if @growth-labs/analytics is available
|
|
133
|
+
await trackPushSend(content.payload, {
|
|
134
|
+
sent: enqueueResult.totalSubscriptions,
|
|
135
|
+
failed: 0,
|
|
136
|
+
expired: 0,
|
|
137
|
+
results: [],
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Step 3: Always clean up stale subscriptions
|
|
142
|
+
const cleanupResult = await cleanupStaleSubscriptions(context.db, {
|
|
143
|
+
staleAfterDays: context.cleanup.staleAfterDays,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
notification,
|
|
148
|
+
cleanup: {
|
|
149
|
+
removed: cleanupResult.removed,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { buildPushPayload } from '@block65/webcrypto-web-push'
|
|
2
|
+
import type {
|
|
3
|
+
PushBatchResult,
|
|
4
|
+
PushPayload,
|
|
5
|
+
PushSendOptions,
|
|
6
|
+
PushSendResult,
|
|
7
|
+
PushSubscriptionRecord,
|
|
8
|
+
} from '../types.js'
|
|
9
|
+
|
|
10
|
+
export interface SendContext {
|
|
11
|
+
vapidSubject: string
|
|
12
|
+
vapidPublicKey: string
|
|
13
|
+
vapidPrivateKey: string
|
|
14
|
+
defaults: {
|
|
15
|
+
icon?: string
|
|
16
|
+
ttl: number
|
|
17
|
+
urgency: 'very-low' | 'low' | 'normal' | 'high'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Map our urgency values to what the library supports */
|
|
22
|
+
function mapUrgency(urgency: 'very-low' | 'low' | 'normal' | 'high'): 'low' | 'normal' | 'high' {
|
|
23
|
+
return urgency === 'very-low' ? 'low' : urgency
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Send a push notification to a single subscription.
|
|
28
|
+
* Uses Web Crypto API for VAPID signing and RFC 8291 payload encryption.
|
|
29
|
+
*/
|
|
30
|
+
export async function sendPushNotification(
|
|
31
|
+
subscription: PushSubscriptionRecord,
|
|
32
|
+
payload: PushPayload,
|
|
33
|
+
context: SendContext,
|
|
34
|
+
options: PushSendOptions = {},
|
|
35
|
+
): Promise<PushSendResult> {
|
|
36
|
+
const ttl = options.ttl ?? context.defaults.ttl
|
|
37
|
+
const urgency = options.urgency ?? context.defaults.urgency
|
|
38
|
+
|
|
39
|
+
const resolvedPayload: PushPayload = {
|
|
40
|
+
...payload,
|
|
41
|
+
icon: payload.icon ?? context.defaults.icon,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// buildPushPayload(message, subscription, vapid) per @block65/webcrypto-web-push API
|
|
46
|
+
const pushRequest = await buildPushPayload(
|
|
47
|
+
{
|
|
48
|
+
data: JSON.parse(JSON.stringify(resolvedPayload)),
|
|
49
|
+
options: { ttl, urgency: mapUrgency(urgency) },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
endpoint: subscription.endpoint,
|
|
53
|
+
expirationTime: null,
|
|
54
|
+
keys: {
|
|
55
|
+
p256dh: subscription.keyP256dh,
|
|
56
|
+
auth: subscription.keyAuth,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
subject: context.vapidSubject,
|
|
61
|
+
publicKey: context.vapidPublicKey,
|
|
62
|
+
privateKey: context.vapidPrivateKey,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const response = await fetch(subscription.endpoint, {
|
|
67
|
+
method: pushRequest.method,
|
|
68
|
+
headers: pushRequest.headers,
|
|
69
|
+
body: pushRequest.body,
|
|
70
|
+
} as RequestInit)
|
|
71
|
+
|
|
72
|
+
if (response.status === 201) {
|
|
73
|
+
return { subscriptionId: subscription.id, success: true, statusCode: 201 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (response.status === 404 || response.status === 410) {
|
|
77
|
+
return {
|
|
78
|
+
subscriptionId: subscription.id,
|
|
79
|
+
success: false,
|
|
80
|
+
statusCode: response.status,
|
|
81
|
+
expired: true,
|
|
82
|
+
error: 'Subscription expired or unsubscribed',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (response.status === 429) {
|
|
87
|
+
return {
|
|
88
|
+
subscriptionId: subscription.id,
|
|
89
|
+
success: false,
|
|
90
|
+
statusCode: 429,
|
|
91
|
+
error: 'Rate limited by push service',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
subscriptionId: subscription.id,
|
|
97
|
+
success: false,
|
|
98
|
+
statusCode: response.status,
|
|
99
|
+
error: `Push service returned ${response.status}`,
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return {
|
|
103
|
+
subscriptionId: subscription.id,
|
|
104
|
+
success: false,
|
|
105
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Send a push notification to multiple subscriptions sequentially.
|
|
112
|
+
* For small batches (< 100 subscriptions) called directly.
|
|
113
|
+
* For large subscriber counts, use enqueuePushBatchAll() instead.
|
|
114
|
+
*/
|
|
115
|
+
export async function sendPushBatch(
|
|
116
|
+
subscriptions: PushSubscriptionRecord[],
|
|
117
|
+
payload: PushPayload,
|
|
118
|
+
context: SendContext,
|
|
119
|
+
options: PushSendOptions = {},
|
|
120
|
+
): Promise<PushBatchResult> {
|
|
121
|
+
const results: PushSendResult[] = []
|
|
122
|
+
let sent = 0
|
|
123
|
+
let failed = 0
|
|
124
|
+
let expired = 0
|
|
125
|
+
|
|
126
|
+
for (const sub of subscriptions) {
|
|
127
|
+
const result = await sendPushNotification(sub, payload, context, options)
|
|
128
|
+
results.push(result)
|
|
129
|
+
|
|
130
|
+
if (result.success) {
|
|
131
|
+
sent++
|
|
132
|
+
} else if (result.expired) {
|
|
133
|
+
expired++
|
|
134
|
+
} else {
|
|
135
|
+
failed++
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { sent, failed, expired, results }
|
|
140
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PushSubscriptionRecord } from '../types.js'
|
|
2
|
+
|
|
3
|
+
/** Insert or update a subscription */
|
|
4
|
+
export async function saveSubscription(db: D1Database, sub: PushSubscriptionRecord): Promise<void> {
|
|
5
|
+
await db
|
|
6
|
+
.prepare(
|
|
7
|
+
`INSERT INTO gl_push_subscriptions
|
|
8
|
+
(id, endpoint, key_p256dh, key_auth, topics, created_at, last_delivery_at, failure_count, user_agent, country)
|
|
9
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10
|
+
ON CONFLICT (endpoint) DO UPDATE SET
|
|
11
|
+
key_p256dh = excluded.key_p256dh,
|
|
12
|
+
key_auth = excluded.key_auth,
|
|
13
|
+
failure_count = excluded.failure_count,
|
|
14
|
+
last_delivery_at = excluded.last_delivery_at`,
|
|
15
|
+
)
|
|
16
|
+
.bind(
|
|
17
|
+
sub.id,
|
|
18
|
+
sub.endpoint,
|
|
19
|
+
sub.keyP256dh,
|
|
20
|
+
sub.keyAuth,
|
|
21
|
+
sub.topics,
|
|
22
|
+
sub.createdAt,
|
|
23
|
+
sub.lastDeliveryAt,
|
|
24
|
+
sub.failureCount,
|
|
25
|
+
sub.userAgent,
|
|
26
|
+
sub.country,
|
|
27
|
+
)
|
|
28
|
+
.run()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Find a subscription by its endpoint URL */
|
|
32
|
+
export async function findSubscriptionByEndpoint(
|
|
33
|
+
db: D1Database,
|
|
34
|
+
endpoint: string,
|
|
35
|
+
): Promise<PushSubscriptionRecord | null> {
|
|
36
|
+
const result = await db
|
|
37
|
+
.prepare('SELECT * FROM gl_push_subscriptions WHERE endpoint = ?')
|
|
38
|
+
.bind(endpoint)
|
|
39
|
+
.first()
|
|
40
|
+
|
|
41
|
+
return result ? mapRow(result) : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Delete a subscription by endpoint. Returns true if a row was deleted. */
|
|
45
|
+
export async function deleteSubscriptionByEndpoint(
|
|
46
|
+
db: D1Database,
|
|
47
|
+
endpoint: string,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const result = await db
|
|
50
|
+
.prepare('DELETE FROM gl_push_subscriptions WHERE endpoint = ?')
|
|
51
|
+
.bind(endpoint)
|
|
52
|
+
.run()
|
|
53
|
+
|
|
54
|
+
return (result.meta?.changes ?? 0) > 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Delete a subscription by ID */
|
|
58
|
+
export async function deleteSubscriptionById(db: D1Database, id: string): Promise<void> {
|
|
59
|
+
await db.prepare('DELETE FROM gl_push_subscriptions WHERE id = ?').bind(id).run()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get subscriptions with offset-based pagination. Used by enqueuePushBatchAll. */
|
|
63
|
+
export async function getAllSubscriptions(
|
|
64
|
+
db: D1Database,
|
|
65
|
+
options: { limit?: number; offset?: number } = {},
|
|
66
|
+
): Promise<PushSubscriptionRecord[]> {
|
|
67
|
+
const limit = options.limit ?? 1000
|
|
68
|
+
const offset = options.offset ?? 0
|
|
69
|
+
|
|
70
|
+
const result = await db
|
|
71
|
+
.prepare('SELECT * FROM gl_push_subscriptions ORDER BY created_at ASC LIMIT ? OFFSET ?')
|
|
72
|
+
.bind(limit, offset)
|
|
73
|
+
.all()
|
|
74
|
+
|
|
75
|
+
return (result.results ?? []).map(mapRow)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Count total subscriptions */
|
|
79
|
+
export async function countSubscriptions(db: D1Database): Promise<number> {
|
|
80
|
+
const result = await db
|
|
81
|
+
.prepare('SELECT COUNT(*) as count FROM gl_push_subscriptions')
|
|
82
|
+
.first<{ count: number }>()
|
|
83
|
+
|
|
84
|
+
return result?.count ?? 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mark a subscription as successfully delivered */
|
|
88
|
+
export async function markDelivered(db: D1Database, id: string): Promise<void> {
|
|
89
|
+
await db
|
|
90
|
+
.prepare(
|
|
91
|
+
`UPDATE gl_push_subscriptions
|
|
92
|
+
SET last_delivery_at = ?, failure_count = 0
|
|
93
|
+
WHERE id = ?`,
|
|
94
|
+
)
|
|
95
|
+
.bind(Math.floor(Date.now() / 1000), id)
|
|
96
|
+
.run()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Increment failure count for a subscription */
|
|
100
|
+
export async function incrementFailureCount(db: D1Database, id: string): Promise<void> {
|
|
101
|
+
await db
|
|
102
|
+
.prepare(
|
|
103
|
+
`UPDATE gl_push_subscriptions
|
|
104
|
+
SET failure_count = failure_count + 1
|
|
105
|
+
WHERE id = ?`,
|
|
106
|
+
)
|
|
107
|
+
.bind(id)
|
|
108
|
+
.run()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mapRow(row: Record<string, unknown>): PushSubscriptionRecord {
|
|
112
|
+
return {
|
|
113
|
+
id: row.id as string,
|
|
114
|
+
endpoint: row.endpoint as string,
|
|
115
|
+
keyP256dh: row.key_p256dh as string, // gitleaks:allow - Web Push field name, not literal secret material.
|
|
116
|
+
keyAuth: row.key_auth as string,
|
|
117
|
+
topics: row.topics as string | null,
|
|
118
|
+
createdAt: row.created_at as number,
|
|
119
|
+
lastDeliveryAt: row.last_delivery_at as number | null,
|
|
120
|
+
failureCount: row.failure_count as number,
|
|
121
|
+
userAgent: row.user_agent as string | null,
|
|
122
|
+
country: row.country as string | null,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify a Turnstile token server-side.
|
|
3
|
+
*/
|
|
4
|
+
export async function verifyTurnstile(
|
|
5
|
+
token: string,
|
|
6
|
+
secretKey: string,
|
|
7
|
+
remoteIp: string | null,
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
secret: secretKey,
|
|
15
|
+
response: token,
|
|
16
|
+
remoteip: remoteIp,
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const result = (await response.json()) as { success: boolean }
|
|
21
|
+
return result.success
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a new VAPID key pair using Web Crypto API.
|
|
3
|
+
* Run this once during project setup. Store the private key as a Cloudflare Secret.
|
|
4
|
+
*/
|
|
5
|
+
export async function generateVapidKeys(): Promise<{
|
|
6
|
+
publicKey: string
|
|
7
|
+
privateKey: string
|
|
8
|
+
}> {
|
|
9
|
+
const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
|
10
|
+
'sign',
|
|
11
|
+
'verify',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
|
|
15
|
+
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
|
16
|
+
const publicKey = uint8ArrayToUrlSafeBase64(new Uint8Array(publicRaw))
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
publicKey,
|
|
20
|
+
privateKey: JSON.stringify(privateJwk),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Convert a Uint8Array to URL-safe base64 (no padding) */
|
|
25
|
+
function uint8ArrayToUrlSafeBase64(bytes: Uint8Array): string {
|
|
26
|
+
const binary = String.fromCharCode(...bytes)
|
|
27
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
28
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"push-sw.d.ts","sourceRoot":"","sources":["push-sw.ts"],"names":[],"mappings":"AAGA,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAgBjD;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAUtE"}
|
package/src/client/push-sw.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/// <reference lib="webworker" />
|
|
2
|
-
export function handlePush(event) {
|
|
3
|
-
const data = event.data?.json() ?? {}
|
|
4
|
-
const options = {
|
|
5
|
-
body: data.body ?? '',
|
|
6
|
-
icon: data.icon,
|
|
7
|
-
image: data.image,
|
|
8
|
-
tag: data.tag,
|
|
9
|
-
data: {
|
|
10
|
-
url: data.url,
|
|
11
|
-
actionUrl: data.action?.url,
|
|
12
|
-
},
|
|
13
|
-
actions: data.action ? [{ action: 'primary', title: data.action.title }] : [],
|
|
14
|
-
}
|
|
15
|
-
event.waitUntil(self.registration.showNotification(data.title ?? 'Notification', options))
|
|
16
|
-
}
|
|
17
|
-
export function handleNotificationClick(event) {
|
|
18
|
-
event.notification.close()
|
|
19
|
-
const data = event.notification.data ?? {}
|
|
20
|
-
const url = event.action === 'primary' && data.actionUrl ? data.actionUrl : data.url
|
|
21
|
-
if (url) {
|
|
22
|
-
event.waitUntil(self.clients.openWindow(url))
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
//# sourceMappingURL=push-sw.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"push-sw.js","sourceRoot":"","sources":["push-sw.ts"],"names":[],"mappings":"AAAA,iCAAiC;AAGjC,MAAM,UAAU,UAAU,CAAC,KAAgB;IAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;IAErC,MAAM,OAAO,GAAG;QACf,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE;QACrB,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,IAAI,EAAE;YACL,GAAG,EAAE,IAAI,CAAC,GAAG;YACb,SAAS,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG;SAC3B;QACD,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE;KACrB,CAAA;IAEzD,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAK,IAAI,cAAc,EAAE,OAAO,CAAC,CAAC,CAAA;AAC3F,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,KAAwB;IAC/D,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,CAAA;IAE1B,MAAM,IAAI,GAAG,KAAK,CAAC,YAAY,CAAC,IAAI,IAAI,EAAE,CAAA;IAE1C,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAA;IAEpF,IAAI,GAAG,EAAE,CAAC;QACT,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAA;IAC9C,CAAC;AACF,CAAC"}
|