@growth-labs/push 0.1.3

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.
Files changed (87) hide show
  1. package/README.md +106 -0
  2. package/dist/client/push-optin.d.ts +24 -0
  3. package/dist/client/push-optin.d.ts.map +1 -0
  4. package/dist/client/push-optin.js +122 -0
  5. package/dist/client/push-optin.js.map +1 -0
  6. package/dist/client/push-sw.d.ts +3 -0
  7. package/dist/client/push-sw.d.ts.map +1 -0
  8. package/dist/client/push-sw.js +36 -0
  9. package/dist/client/push-sw.js.map +1 -0
  10. package/dist/db/schema.d.ts +178 -0
  11. package/dist/db/schema.d.ts.map +1 -0
  12. package/dist/db/schema.js +14 -0
  13. package/dist/db/schema.js.map +1 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +56 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/middleware/push.d.ts +2 -0
  19. package/dist/middleware/push.d.ts.map +1 -0
  20. package/dist/middleware/push.js +10 -0
  21. package/dist/middleware/push.js.map +1 -0
  22. package/dist/options.d.ts +108 -0
  23. package/dist/options.d.ts.map +1 -0
  24. package/dist/options.js +36 -0
  25. package/dist/options.js.map +1 -0
  26. package/dist/routes/subscribe.d.ts +3 -0
  27. package/dist/routes/subscribe.d.ts.map +1 -0
  28. package/dist/routes/subscribe.js +68 -0
  29. package/dist/routes/subscribe.js.map +1 -0
  30. package/dist/routes/unsubscribe.d.ts +3 -0
  31. package/dist/routes/unsubscribe.d.ts.map +1 -0
  32. package/dist/routes/unsubscribe.js +24 -0
  33. package/dist/routes/unsubscribe.js.map +1 -0
  34. package/dist/types.d.ts +90 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/utils/analytics.d.ts +47 -0
  39. package/dist/utils/analytics.d.ts.map +1 -0
  40. package/dist/utils/analytics.js +78 -0
  41. package/dist/utils/analytics.js.map +1 -0
  42. package/dist/utils/cleanup.d.ts +12 -0
  43. package/dist/utils/cleanup.d.ts.map +1 -0
  44. package/dist/utils/cleanup.js +18 -0
  45. package/dist/utils/cleanup.js.map +1 -0
  46. package/dist/utils/composer.d.ts +95 -0
  47. package/dist/utils/composer.d.ts.map +1 -0
  48. package/dist/utils/composer.js +98 -0
  49. package/dist/utils/composer.js.map +1 -0
  50. package/dist/utils/index.d.ts +10 -0
  51. package/dist/utils/index.d.ts.map +1 -0
  52. package/dist/utils/index.js +10 -0
  53. package/dist/utils/index.js.map +1 -0
  54. package/dist/utils/queue.d.ts +40 -0
  55. package/dist/utils/queue.d.ts.map +1 -0
  56. package/dist/utils/queue.js +69 -0
  57. package/dist/utils/queue.js.map +1 -0
  58. package/dist/utils/scheduled.d.ts +105 -0
  59. package/dist/utils/scheduled.d.ts.map +1 -0
  60. package/dist/utils/scheduled.js +75 -0
  61. package/dist/utils/scheduled.js.map +1 -0
  62. package/dist/utils/send.d.ts +23 -0
  63. package/dist/utils/send.d.ts.map +1 -0
  64. package/dist/utils/send.js +99 -0
  65. package/dist/utils/send.js.map +1 -0
  66. package/dist/utils/subscriptions.d.ts +21 -0
  67. package/dist/utils/subscriptions.d.ts.map +1 -0
  68. package/dist/utils/subscriptions.js +84 -0
  69. package/dist/utils/subscriptions.js.map +1 -0
  70. package/dist/utils/turnstile.d.ts +5 -0
  71. package/dist/utils/turnstile.d.ts.map +1 -0
  72. package/dist/utils/turnstile.js +22 -0
  73. package/dist/utils/turnstile.js.map +1 -0
  74. package/dist/utils/vapid.d.ts +9 -0
  75. package/dist/utils/vapid.d.ts.map +1 -0
  76. package/dist/utils/vapid.js +23 -0
  77. package/dist/utils/vapid.js.map +1 -0
  78. package/package.json +77 -0
  79. package/src/client/push-optin.ts +189 -0
  80. package/src/client/push-sw.d.ts +3 -0
  81. package/src/client/push-sw.d.ts.map +1 -0
  82. package/src/client/push-sw.js +25 -0
  83. package/src/client/push-sw.js.map +1 -0
  84. package/src/client/push-sw.ts +45 -0
  85. package/src/components/PushOptIn.astro +53 -0
  86. package/src/components/index.ts +1 -0
  87. package/src/virtual.d.ts +5 -0
@@ -0,0 +1,84 @@
1
+ /** Insert or update a subscription */
2
+ export async function saveSubscription(db, sub) {
3
+ await db
4
+ .prepare(`INSERT INTO gl_push_subscriptions
5
+ (id, endpoint, key_p256dh, key_auth, topics, created_at, last_delivery_at, failure_count, user_agent, country)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7
+ ON CONFLICT (endpoint) DO UPDATE SET
8
+ key_p256dh = excluded.key_p256dh,
9
+ key_auth = excluded.key_auth,
10
+ failure_count = excluded.failure_count,
11
+ last_delivery_at = excluded.last_delivery_at`)
12
+ .bind(sub.id, sub.endpoint, sub.keyP256dh, sub.keyAuth, sub.topics, sub.createdAt, sub.lastDeliveryAt, sub.failureCount, sub.userAgent, sub.country)
13
+ .run();
14
+ }
15
+ /** Find a subscription by its endpoint URL */
16
+ export async function findSubscriptionByEndpoint(db, endpoint) {
17
+ const result = await db
18
+ .prepare('SELECT * FROM gl_push_subscriptions WHERE endpoint = ?')
19
+ .bind(endpoint)
20
+ .first();
21
+ return result ? mapRow(result) : null;
22
+ }
23
+ /** Delete a subscription by endpoint. Returns true if a row was deleted. */
24
+ export async function deleteSubscriptionByEndpoint(db, endpoint) {
25
+ const result = await db
26
+ .prepare('DELETE FROM gl_push_subscriptions WHERE endpoint = ?')
27
+ .bind(endpoint)
28
+ .run();
29
+ return (result.meta?.changes ?? 0) > 0;
30
+ }
31
+ /** Delete a subscription by ID */
32
+ export async function deleteSubscriptionById(db, id) {
33
+ await db.prepare('DELETE FROM gl_push_subscriptions WHERE id = ?').bind(id).run();
34
+ }
35
+ /** Get subscriptions with offset-based pagination. Used by enqueuePushBatchAll. */
36
+ export async function getAllSubscriptions(db, options = {}) {
37
+ const limit = options.limit ?? 1000;
38
+ const offset = options.offset ?? 0;
39
+ const result = await db
40
+ .prepare('SELECT * FROM gl_push_subscriptions ORDER BY created_at ASC LIMIT ? OFFSET ?')
41
+ .bind(limit, offset)
42
+ .all();
43
+ return (result.results ?? []).map(mapRow);
44
+ }
45
+ /** Count total subscriptions */
46
+ export async function countSubscriptions(db) {
47
+ const result = await db
48
+ .prepare('SELECT COUNT(*) as count FROM gl_push_subscriptions')
49
+ .first();
50
+ return result?.count ?? 0;
51
+ }
52
+ /** Mark a subscription as successfully delivered */
53
+ export async function markDelivered(db, id) {
54
+ await db
55
+ .prepare(`UPDATE gl_push_subscriptions
56
+ SET last_delivery_at = ?, failure_count = 0
57
+ WHERE id = ?`)
58
+ .bind(Math.floor(Date.now() / 1000), id)
59
+ .run();
60
+ }
61
+ /** Increment failure count for a subscription */
62
+ export async function incrementFailureCount(db, id) {
63
+ await db
64
+ .prepare(`UPDATE gl_push_subscriptions
65
+ SET failure_count = failure_count + 1
66
+ WHERE id = ?`)
67
+ .bind(id)
68
+ .run();
69
+ }
70
+ function mapRow(row) {
71
+ return {
72
+ id: row.id,
73
+ endpoint: row.endpoint,
74
+ keyP256dh: row.key_p256dh,
75
+ keyAuth: row.key_auth,
76
+ topics: row.topics,
77
+ createdAt: row.created_at,
78
+ lastDeliveryAt: row.last_delivery_at,
79
+ failureCount: row.failure_count,
80
+ userAgent: row.user_agent,
81
+ country: row.country,
82
+ };
83
+ }
84
+ //# sourceMappingURL=subscriptions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscriptions.js","sourceRoot":"","sources":["../../src/utils/subscriptions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Verify a Turnstile token server-side.
3
+ */
4
+ export declare function verifyTurnstile(token: string, secretKey: string, remoteIp: string | null): Promise<boolean>;
5
+ //# sourceMappingURL=turnstile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnstile.d.ts","sourceRoot":"","sources":["../../src/utils/turnstile.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAsB,eAAe,CACpC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,GACrB,OAAO,CAAC,OAAO,CAAC,CAiBlB"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Verify a Turnstile token server-side.
3
+ */
4
+ export async function verifyTurnstile(token, secretKey, remoteIp) {
5
+ try {
6
+ const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
7
+ method: 'POST',
8
+ headers: { 'Content-Type': 'application/json' },
9
+ body: JSON.stringify({
10
+ secret: secretKey,
11
+ response: token,
12
+ remoteip: remoteIp,
13
+ }),
14
+ });
15
+ const result = (await response.json());
16
+ return result.success;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ //# sourceMappingURL=turnstile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnstile.js","sourceRoot":"","sources":["../../src/utils/turnstile.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACpC,KAAa,EACb,SAAiB,EACjB,QAAuB;IAEvB,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,2DAA2D,EAAE;YACzF,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACpB,MAAM,EAAE,SAAS;gBACjB,QAAQ,EAAE,KAAK;gBACf,QAAQ,EAAE,QAAQ;aAClB,CAAC;SACF,CAAC,CAAA;QAEF,MAAM,MAAM,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAyB,CAAA;QAC9D,OAAO,MAAM,CAAC,OAAO,CAAA;IACtB,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,KAAK,CAAA;IACb,CAAC;AACF,CAAC"}
@@ -0,0 +1,9 @@
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 declare function generateVapidKeys(): Promise<{
6
+ publicKey: string;
7
+ privateKey: string;
8
+ }>;
9
+ //# sourceMappingURL=vapid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vapid.d.ts","sourceRoot":"","sources":["../../src/utils/vapid.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC;IAClD,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CAClB,CAAC,CAcD"}
@@ -0,0 +1,23 @@
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() {
6
+ const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
7
+ 'sign',
8
+ 'verify',
9
+ ]);
10
+ const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
11
+ const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
12
+ const publicKey = uint8ArrayToUrlSafeBase64(new Uint8Array(publicRaw));
13
+ return {
14
+ publicKey,
15
+ privateKey: JSON.stringify(privateJwk),
16
+ };
17
+ }
18
+ /** Convert a Uint8Array to URL-safe base64 (no padding) */
19
+ function uint8ArrayToUrlSafeBase64(bytes) {
20
+ const binary = String.fromCharCode(...bytes);
21
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
+ }
23
+ //# sourceMappingURL=vapid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vapid.js","sourceRoot":"","sources":["../../src/utils/vapid.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB;IAItC,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE;QAC7F,MAAM;QACN,QAAQ;KACR,CAAC,CAAA;IAEF,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,CAAA;IAC3E,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,SAAS,CAAC,CAAA;IACzE,MAAM,SAAS,GAAG,yBAAyB,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC,CAAA;IAEtE,OAAO;QACN,SAAS;QACT,UAAU,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;KACtC,CAAA;AACF,CAAC;AAED,2DAA2D;AAC3D,SAAS,yBAAyB,CAAC,KAAiB;IACnD,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,CAAA;IAC5C,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AAC/E,CAAC"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@growth-labs/push",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./components": {
11
+ "types": "./src/components/index.ts",
12
+ "import": "./src/components/index.ts"
13
+ },
14
+ "./components/*": {
15
+ "types": "./src/components/*.astro",
16
+ "import": "./src/components/*.astro"
17
+ },
18
+ "./utils": {
19
+ "types": "./dist/utils/index.d.ts",
20
+ "import": "./dist/utils/index.js"
21
+ },
22
+ "./utils/*": {
23
+ "types": "./dist/utils/*.d.ts",
24
+ "import": "./dist/utils/*.js"
25
+ },
26
+ "./client/*": {
27
+ "types": "./dist/client/*.d.ts",
28
+ "import": "./dist/client/*.js"
29
+ },
30
+ "./db": {
31
+ "types": "./dist/db/schema.d.ts",
32
+ "import": "./dist/db/schema.js"
33
+ },
34
+ "./middleware": {
35
+ "types": "./dist/middleware/push.d.ts",
36
+ "import": "./dist/middleware/push.js",
37
+ "default": "./dist/middleware/push.js"
38
+ },
39
+ "./routes/*": {
40
+ "types": "./dist/routes/*.d.ts",
41
+ "import": "./dist/routes/*.js",
42
+ "default": "./dist/routes/*.js"
43
+ },
44
+ "./virtual": {
45
+ "types": "./src/virtual.d.ts"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "src/components",
51
+ "src/client",
52
+ "src/virtual.d.ts",
53
+ "README.md"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org/"
58
+ },
59
+ "peerDependencies": {
60
+ "astro": "^6.0.0"
61
+ },
62
+ "dependencies": {
63
+ "@block65/webcrypto-web-push": "^1.0.0",
64
+ "drizzle-orm": "^0.36.0",
65
+ "zod": "^3.23.0"
66
+ },
67
+ "devDependencies": {
68
+ "@cloudflare/workers-types": "^4.0.0",
69
+ "astro": "^6.0.0",
70
+ "typescript": "^5.7.0",
71
+ "vitest": "^3.0.0"
72
+ },
73
+ "scripts": {
74
+ "build": "tsc",
75
+ "test": "vitest run"
76
+ }
77
+ }
@@ -0,0 +1,189 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+
4
+ interface PushOptInContainer extends HTMLElement {
5
+ hidden: boolean
6
+ querySelector<E extends Element>(selectors: string): E | null
7
+ querySelectorAll<E extends Element>(selectors: string): NodeListOf<E>
8
+ }
9
+
10
+ interface PushOptInDependencies {
11
+ navigator: {
12
+ serviceWorker: {
13
+ register(url: string): Promise<ServiceWorkerRegistration>
14
+ ready: Promise<ServiceWorkerRegistration>
15
+ getRegistration(url?: string): Promise<ServiceWorkerRegistration | undefined>
16
+ }
17
+ }
18
+ window: Window & {
19
+ turnstile?: {
20
+ render(
21
+ container: HTMLElement,
22
+ options: {
23
+ sitekey: string
24
+ callback(token: string): void
25
+ 'error-callback'(): void
26
+ },
27
+ ): void
28
+ }
29
+ }
30
+ document: Document & {
31
+ dispatchEvent(event: CustomEvent): boolean
32
+ }
33
+ }
34
+
35
+ function getDependencies(): PushOptInDependencies {
36
+ return globalThis as unknown as PushOptInDependencies
37
+ }
38
+
39
+ function urlSafeBase64ToUint8Array(base64: string): Uint8Array {
40
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
41
+ const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))
42
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0))
43
+ }
44
+
45
+ function getTurnstileToken(
46
+ windowObject: PushOptInDependencies['window'],
47
+ siteKey: string,
48
+ container: HTMLElement,
49
+ ): Promise<string> {
50
+ return new Promise((resolve, reject) => {
51
+ container.hidden = false
52
+
53
+ if (typeof windowObject.turnstile === 'undefined') {
54
+ reject(new Error('Turnstile script not loaded'))
55
+ return
56
+ }
57
+
58
+ windowObject.turnstile.render(container, {
59
+ sitekey: siteKey,
60
+ callback: (token: string) => {
61
+ container.hidden = true
62
+ resolve(token)
63
+ },
64
+ 'error-callback': () => {
65
+ container.hidden = true
66
+ reject(new Error('Turnstile challenge failed'))
67
+ },
68
+ })
69
+ })
70
+ }
71
+
72
+ export function initPushOptIn(root: ParentNode, deps = getDependencies()): void {
73
+ const containers = root.querySelectorAll<PushOptInContainer>('.gl-push-optin')
74
+
75
+ containers.forEach((container) => {
76
+ const btn = container.querySelector('[data-push-action="toggle"]') as HTMLButtonElement | null
77
+ const unsupported = container.querySelector(
78
+ '[data-push-state="unsupported"]',
79
+ ) as HTMLElement | null
80
+ const turnstileContainer = container.querySelector(
81
+ '[data-push-turnstile]',
82
+ ) as HTMLElement | null
83
+
84
+ if (!btn || !unsupported || !turnstileContainer) {
85
+ return
86
+ }
87
+
88
+ const button = btn
89
+
90
+ const vapidPublicKey = container.getAttribute('data-vapid-public-key') ?? ''
91
+ const subscribeUrl = container.getAttribute('data-subscribe-url') ?? '/api/push/subscribe'
92
+ const unsubscribeUrl = container.getAttribute('data-unsubscribe-url') ?? '/api/push/unsubscribe'
93
+ const swUrl = container.getAttribute('data-sw-url') ?? '/sw.js'
94
+ const turnstileSiteKey = container.getAttribute('data-turnstile-site-key') ?? ''
95
+
96
+ if (!('serviceWorker' in deps.navigator) || !('PushManager' in deps.window)) {
97
+ unsupported.hidden = false
98
+ return
99
+ }
100
+
101
+ button.hidden = false
102
+
103
+ function showState(state: string) {
104
+ container.querySelectorAll('[data-push-state]').forEach((el) => {
105
+ ;(el as HTMLElement).hidden = el.getAttribute('data-push-state') !== state
106
+ })
107
+ button.hidden = state === 'unsupported'
108
+ button.disabled = state === 'subscribing' || state === 'denied'
109
+ }
110
+
111
+ async function checkState() {
112
+ const registration = await deps.navigator.serviceWorker.getRegistration(swUrl)
113
+ if (!registration) {
114
+ showState('prompt')
115
+ return
116
+ }
117
+
118
+ const subscription = await registration.pushManager.getSubscription()
119
+ showState(subscription ? 'subscribed' : 'prompt')
120
+ }
121
+
122
+ button.addEventListener('click', async () => {
123
+ try {
124
+ // Register before awaiting `ready` so the first click cannot stall forever.
125
+ await deps.navigator.serviceWorker.register(swUrl)
126
+ const registration = await deps.navigator.serviceWorker.ready
127
+ const existing = await registration.pushManager.getSubscription()
128
+
129
+ if (existing) {
130
+ await existing.unsubscribe()
131
+ await fetch(unsubscribeUrl, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ endpoint: existing.endpoint }),
135
+ })
136
+ deps.document.dispatchEvent(
137
+ new CustomEvent('gl:push_unsubscribed', {
138
+ detail: { endpoint: existing.endpoint },
139
+ }),
140
+ )
141
+ showState('prompt')
142
+ return
143
+ }
144
+
145
+ showState('subscribing')
146
+
147
+ const turnstileToken = await getTurnstileToken(
148
+ deps.window,
149
+ turnstileSiteKey,
150
+ turnstileContainer,
151
+ )
152
+
153
+ const subscription = await registration.pushManager.subscribe({
154
+ userVisibleOnly: true,
155
+ applicationServerKey: urlSafeBase64ToUint8Array(vapidPublicKey) as BufferSource,
156
+ })
157
+
158
+ const response = await fetch(subscribeUrl, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ subscription: subscription.toJSON(),
163
+ turnstileToken,
164
+ }),
165
+ })
166
+
167
+ if (!response.ok) {
168
+ throw new Error('Server rejected subscription')
169
+ }
170
+
171
+ deps.document.dispatchEvent(
172
+ new CustomEvent('gl:push_subscribed', {
173
+ detail: { endpoint: subscription.endpoint },
174
+ }),
175
+ )
176
+ showState('subscribed')
177
+ } catch (err) {
178
+ if (Notification.permission === 'denied') {
179
+ showState('denied')
180
+ } else {
181
+ showState('error')
182
+ console.error('[gl-push]', err)
183
+ }
184
+ }
185
+ })
186
+
187
+ checkState()
188
+ })
189
+ }
@@ -0,0 +1,3 @@
1
+ export declare function handlePush(event: PushEvent): void
2
+ export declare function handleNotificationClick(event: NotificationEvent): void
3
+ //# sourceMappingURL=push-sw.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,25 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,45 @@
1
+ /// <reference lib="webworker" />
2
+ declare const self: ServiceWorkerGlobalScope
3
+
4
+ export function handlePush(event: PushEvent): void {
5
+ const data = event.data?.json() ?? {}
6
+
7
+ const options = {
8
+ body: data.body ?? '',
9
+ icon: data.icon,
10
+ image: data.image,
11
+ tag: data.tag,
12
+ data: {
13
+ url: data.url,
14
+ actionUrl: data.action?.url,
15
+ tag: data.tag,
16
+ },
17
+ actions: data.action ? [{ action: 'primary', title: data.action.title }] : [],
18
+ } satisfies NotificationOptions & Record<string, unknown>
19
+
20
+ event.waitUntil(self.registration.showNotification(data.title ?? 'Notification', options))
21
+ }
22
+
23
+ export function handleNotificationClick(event: NotificationEvent): void {
24
+ event.notification.close()
25
+
26
+ const data = event.notification.data ?? {}
27
+
28
+ const url = event.action === 'primary' && data.actionUrl ? data.actionUrl : data.url
29
+
30
+ if (url) {
31
+ // Notify all visible clients so they can emit gl:push_notification_clicked
32
+ event.waitUntil(
33
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
34
+ for (const client of windowClients) {
35
+ client.postMessage({
36
+ type: 'gl:push_notification_clicked',
37
+ tag: data.tag,
38
+ url,
39
+ })
40
+ }
41
+ return self.clients.openWindow(url)
42
+ }),
43
+ )
44
+ }
45
+ }
@@ -0,0 +1,53 @@
1
+ ---
2
+ interface Props {
3
+ /** Turnstile site key (public, not the secret) */
4
+ turnstileSiteKey: string
5
+ /** Custom subscribe endpoint. Default: from virtual module config */
6
+ subscribeUrl?: string
7
+ /** Custom unsubscribe endpoint. Default: from virtual module config */
8
+ unsubscribeUrl?: string
9
+ /** Service worker URL. Consumer must register their own SW. */
10
+ serviceWorkerUrl?: string
11
+ /** CSS class for the container */
12
+ class?: string
13
+ }
14
+
15
+ import config from 'virtual:growth-labs/push/config'
16
+
17
+ const {
18
+ turnstileSiteKey,
19
+ subscribeUrl = config.routes.subscribe,
20
+ unsubscribeUrl = config.routes.unsubscribe,
21
+ serviceWorkerUrl = '/sw.js',
22
+ class: className,
23
+ } = Astro.props
24
+
25
+ const vapidPublicKey = Astro.locals.push?.vapidPublicKey ?? ''
26
+ ---
27
+
28
+ <div
29
+ class:list={['gl-push-optin', className]}
30
+ data-vapid-public-key={vapidPublicKey}
31
+ data-subscribe-url={subscribeUrl}
32
+ data-unsubscribe-url={unsubscribeUrl}
33
+ data-sw-url={serviceWorkerUrl}
34
+ data-turnstile-site-key={turnstileSiteKey}
35
+ >
36
+ <button class="gl-push-btn" data-push-action="toggle" type="button" hidden>
37
+ <span data-push-state="prompt">Enable notifications</span>
38
+ <span data-push-state="subscribing" hidden>Subscribing…</span>
39
+ <span data-push-state="subscribed" hidden>Notifications on</span>
40
+ <span data-push-state="denied" hidden>Notifications blocked</span>
41
+ <span data-push-state="error" hidden>Something went wrong</span>
42
+ </button>
43
+ <p class="gl-push-unsupported" data-push-state="unsupported" hidden>
44
+ Push notifications are not supported in this browser.
45
+ </p>
46
+ <div class="gl-push-turnstile" data-push-turnstile hidden></div>
47
+ </div>
48
+
49
+ <script>
50
+ import { initPushOptIn } from '../client/push-optin.ts'
51
+
52
+ initPushOptIn(document)
53
+ </script>
@@ -0,0 +1 @@
1
+ export { default as PushOptIn } from './PushOptIn.astro'
@@ -0,0 +1,5 @@
1
+ declare module 'virtual:growth-labs/push/config' {
2
+ import type { PushConfig } from './types.js'
3
+ const config: PushConfig
4
+ export default config
5
+ }