@growth-labs/push 0.4.0 → 0.4.2

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.
@@ -0,0 +1,28 @@
1
+ interface CleanupResult {
2
+ removed: number
3
+ }
4
+
5
+ /**
6
+ * Remove stale or high-failure subscriptions.
7
+ * Call from a cron trigger or maintenance script.
8
+ */
9
+ export async function cleanupStaleSubscriptions(
10
+ db: D1Database,
11
+ options: { staleAfterDays: number },
12
+ ): Promise<CleanupResult> {
13
+ const cutoff = Math.floor(Date.now() / 1000) - options.staleAfterDays * 86400
14
+
15
+ const result = await db
16
+ .prepare(
17
+ `DELETE FROM gl_push_subscriptions
18
+ WHERE (last_delivery_at IS NOT NULL AND last_delivery_at < ?)
19
+ OR (last_delivery_at IS NULL AND created_at < ?)
20
+ OR failure_count >= 10`,
21
+ )
22
+ .bind(cutoff, cutoff)
23
+ .run()
24
+
25
+ return {
26
+ removed: result.meta?.changes ?? 0,
27
+ }
28
+ }
@@ -0,0 +1,141 @@
1
+ import type { PushPayload } from '../types.js'
2
+
3
+ /**
4
+ * A push notification template with placeholder support.
5
+ * Placeholders use {{key}} syntax and are replaced at compose time.
6
+ */
7
+ export interface PushTemplate {
8
+ /** Template name for identification */
9
+ name: string
10
+ /** Title with optional {{placeholders}} */
11
+ title: string
12
+ /** Body with optional {{placeholders}} */
13
+ body: string
14
+ /** Default icon URL (can be overridden at compose time) */
15
+ icon?: string
16
+ /** Default image URL with optional {{placeholders}} */
17
+ image?: string
18
+ /** URL pattern with optional {{placeholders}} */
19
+ url: string
20
+ /** Optional action button */
21
+ action?: {
22
+ title: string
23
+ url: string
24
+ }
25
+ /** Optional dedup tag with {{placeholders}} */
26
+ tag?: string
27
+ }
28
+
29
+ /** Variables to interpolate into a template */
30
+ export type TemplateVariables = Record<string, string>
31
+
32
+ /**
33
+ * Replace {{key}} placeholders in a string with values from the variables map.
34
+ * Unknown placeholders are left as-is.
35
+ */
36
+ function interpolate(template: string, variables: TemplateVariables): string {
37
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
38
+ return key in variables ? variables[key] : match
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Compose a PushPayload from a template and variables.
44
+ *
45
+ * Templates define the structure with {{placeholder}} tokens.
46
+ * Variables fill in the dynamic parts at send time.
47
+ *
48
+ * ```typescript
49
+ * const template: PushTemplate = {
50
+ * name: 'new-article',
51
+ * title: 'New on {{siteName}}',
52
+ * body: '{{articleTitle}}',
53
+ * url: 'https://{{domain}}/articles/{{slug}}',
54
+ * icon: '/icons/notification-192.png',
55
+ * tag: 'article-{{slug}}',
56
+ * action: {
57
+ * title: 'Read article',
58
+ * url: 'https://{{domain}}/articles/{{slug}}',
59
+ * },
60
+ * }
61
+ *
62
+ * const payload = composePushPayload(template, {
63
+ * siteName: 'FEDweek',
64
+ * domain: 'fedweek.com',
65
+ * articleTitle: 'Federal Pay Freeze Announced',
66
+ * slug: 'pay-freeze-2026',
67
+ * })
68
+ * ```
69
+ */
70
+ export function composePushPayload(
71
+ template: PushTemplate,
72
+ variables: TemplateVariables = {},
73
+ overrides: Partial<PushPayload> = {},
74
+ ): PushPayload {
75
+ const payload: PushPayload = {
76
+ title: interpolate(template.title, variables),
77
+ body: interpolate(template.body, variables),
78
+ url: interpolate(template.url, variables),
79
+ }
80
+
81
+ if (template.icon) {
82
+ payload.icon = interpolate(template.icon, variables)
83
+ }
84
+
85
+ if (template.image) {
86
+ payload.image = interpolate(template.image, variables)
87
+ }
88
+
89
+ if (template.tag) {
90
+ payload.tag = interpolate(template.tag, variables)
91
+ }
92
+
93
+ if (template.action) {
94
+ payload.action = {
95
+ title: interpolate(template.action.title, variables),
96
+ url: interpolate(template.action.url, variables),
97
+ }
98
+ }
99
+
100
+ return { ...payload, ...overrides }
101
+ }
102
+
103
+ /**
104
+ * Built-in templates for common notification patterns.
105
+ * Consumers can use these directly or define their own.
106
+ */
107
+ export const templates = {
108
+ /** New content published notification */
109
+ newContent: {
110
+ name: 'new-content',
111
+ title: 'New on {{siteName}}',
112
+ body: '{{contentTitle}}',
113
+ url: '{{contentUrl}}',
114
+ tag: 'content-{{contentId}}',
115
+ action: {
116
+ title: 'Read now',
117
+ url: '{{contentUrl}}',
118
+ },
119
+ },
120
+
121
+ /** Breaking news / high-urgency notification */
122
+ breaking: {
123
+ name: 'breaking',
124
+ title: 'Breaking: {{headline}}',
125
+ body: '{{summary}}',
126
+ url: '{{contentUrl}}',
127
+ tag: 'breaking-{{contentId}}',
128
+ action: {
129
+ title: 'Read more',
130
+ url: '{{contentUrl}}',
131
+ },
132
+ },
133
+
134
+ /** Generic notification with just title and body */
135
+ generic: {
136
+ name: 'generic',
137
+ title: '{{title}}',
138
+ body: '{{body}}',
139
+ url: '{{url}}',
140
+ },
141
+ } as const satisfies Record<string, PushTemplate>
@@ -0,0 +1,38 @@
1
+ export {
2
+ emitPushAnalyticsEvent,
3
+ trackPushCleanup,
4
+ trackPushEvent,
5
+ trackPushSend,
6
+ trackPushSubscription,
7
+ } from './analytics.js'
8
+ export { cleanupStaleSubscriptions } from './cleanup.js'
9
+ export {
10
+ composePushPayload,
11
+ type PushTemplate,
12
+ type TemplateVariables,
13
+ templates,
14
+ } from './composer.js'
15
+ export { enqueuePushBatch, enqueuePushBatchAll } from './queue.js'
16
+ export {
17
+ handleScheduledPush,
18
+ type ScheduledContentProvider,
19
+ type ScheduledPushContext,
20
+ type ScheduledPushResult,
21
+ } from './scheduled.js'
22
+ export {
23
+ type SendContext,
24
+ sendPushBatch,
25
+ sendPushNotification,
26
+ } from './send.js'
27
+ export {
28
+ countSubscriptions,
29
+ deleteSubscriptionByEndpoint,
30
+ deleteSubscriptionById,
31
+ findSubscriptionByEndpoint,
32
+ getAllSubscriptions,
33
+ incrementFailureCount,
34
+ markDelivered,
35
+ saveSubscription,
36
+ } from './subscriptions.js'
37
+ export { verifyTurnstile } from './turnstile.js'
38
+ export { generateVapidKeys } from './vapid.js'
@@ -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
+ }