@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.
- package/README.md +106 -0
- package/dist/client/push-optin.d.ts +24 -0
- package/dist/client/push-optin.d.ts.map +1 -0
- package/dist/client/push-optin.js +122 -0
- package/dist/client/push-optin.js.map +1 -0
- package/dist/client/push-sw.d.ts +3 -0
- package/dist/client/push-sw.d.ts.map +1 -0
- package/dist/client/push-sw.js +36 -0
- package/dist/client/push-sw.js.map +1 -0
- package/dist/db/schema.d.ts +178 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +14 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/push.d.ts +2 -0
- package/dist/middleware/push.d.ts.map +1 -0
- package/dist/middleware/push.js +10 -0
- package/dist/middleware/push.js.map +1 -0
- package/dist/options.d.ts +108 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +36 -0
- package/dist/options.js.map +1 -0
- package/dist/routes/subscribe.d.ts +3 -0
- package/dist/routes/subscribe.d.ts.map +1 -0
- package/dist/routes/subscribe.js +68 -0
- package/dist/routes/subscribe.js.map +1 -0
- package/dist/routes/unsubscribe.d.ts +3 -0
- package/dist/routes/unsubscribe.d.ts.map +1 -0
- package/dist/routes/unsubscribe.js +24 -0
- package/dist/routes/unsubscribe.js.map +1 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/analytics.d.ts +47 -0
- package/dist/utils/analytics.d.ts.map +1 -0
- package/dist/utils/analytics.js +78 -0
- package/dist/utils/analytics.js.map +1 -0
- package/dist/utils/cleanup.d.ts +12 -0
- package/dist/utils/cleanup.d.ts.map +1 -0
- package/dist/utils/cleanup.js +18 -0
- package/dist/utils/cleanup.js.map +1 -0
- package/dist/utils/composer.d.ts +95 -0
- package/dist/utils/composer.d.ts.map +1 -0
- package/dist/utils/composer.js +98 -0
- package/dist/utils/composer.js.map +1 -0
- package/dist/utils/index.d.ts +10 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +10 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/queue.d.ts +40 -0
- package/dist/utils/queue.d.ts.map +1 -0
- package/dist/utils/queue.js +69 -0
- package/dist/utils/queue.js.map +1 -0
- package/dist/utils/scheduled.d.ts +105 -0
- package/dist/utils/scheduled.d.ts.map +1 -0
- package/dist/utils/scheduled.js +75 -0
- package/dist/utils/scheduled.js.map +1 -0
- package/dist/utils/send.d.ts +23 -0
- package/dist/utils/send.d.ts.map +1 -0
- package/dist/utils/send.js +99 -0
- package/dist/utils/send.js.map +1 -0
- package/dist/utils/subscriptions.d.ts +21 -0
- package/dist/utils/subscriptions.d.ts.map +1 -0
- package/dist/utils/subscriptions.js +84 -0
- package/dist/utils/subscriptions.js.map +1 -0
- package/dist/utils/turnstile.d.ts +5 -0
- package/dist/utils/turnstile.d.ts.map +1 -0
- package/dist/utils/turnstile.js +22 -0
- package/dist/utils/turnstile.js.map +1 -0
- package/dist/utils/vapid.d.ts +9 -0
- package/dist/utils/vapid.d.ts.map +1 -0
- package/dist/utils/vapid.js +23 -0
- package/dist/utils/vapid.js.map +1 -0
- package/package.json +77 -0
- package/src/client/push-optin.ts +189 -0
- package/src/client/push-sw.d.ts +3 -0
- package/src/client/push-sw.d.ts.map +1 -0
- package/src/client/push-sw.js +25 -0
- package/src/client/push-sw.js.map +1 -0
- package/src/client/push-sw.ts +45 -0
- package/src/components/PushOptIn.astro +53 -0
- package/src/components/index.ts +1 -0
- 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 @@
|
|
|
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 @@
|
|
|
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'
|