@growth-labs/push 0.4.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/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/package.json +9 -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/dist/db/schema.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
18
18
|
baseColumn: never;
|
|
19
19
|
identity: undefined;
|
|
20
20
|
generated: undefined;
|
|
21
|
-
},
|
|
21
|
+
}, {}, {
|
|
22
|
+
length: number | undefined;
|
|
23
|
+
}>;
|
|
22
24
|
endpoint: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
23
25
|
name: "endpoint";
|
|
24
26
|
tableName: "gl_push_subscriptions";
|
|
@@ -35,7 +37,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
35
37
|
baseColumn: never;
|
|
36
38
|
identity: undefined;
|
|
37
39
|
generated: undefined;
|
|
38
|
-
},
|
|
40
|
+
}, {}, {
|
|
41
|
+
length: number | undefined;
|
|
42
|
+
}>;
|
|
39
43
|
keyP256dh: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
40
44
|
name: "key_p256dh";
|
|
41
45
|
tableName: "gl_push_subscriptions";
|
|
@@ -52,7 +56,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
52
56
|
baseColumn: never;
|
|
53
57
|
identity: undefined;
|
|
54
58
|
generated: undefined;
|
|
55
|
-
},
|
|
59
|
+
}, {}, {
|
|
60
|
+
length: number | undefined;
|
|
61
|
+
}>;
|
|
56
62
|
keyAuth: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
57
63
|
name: "key_auth";
|
|
58
64
|
tableName: "gl_push_subscriptions";
|
|
@@ -69,7 +75,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
69
75
|
baseColumn: never;
|
|
70
76
|
identity: undefined;
|
|
71
77
|
generated: undefined;
|
|
72
|
-
},
|
|
78
|
+
}, {}, {
|
|
79
|
+
length: number | undefined;
|
|
80
|
+
}>;
|
|
73
81
|
topics: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
74
82
|
name: "topics";
|
|
75
83
|
tableName: "gl_push_subscriptions";
|
|
@@ -86,7 +94,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
86
94
|
baseColumn: never;
|
|
87
95
|
identity: undefined;
|
|
88
96
|
generated: undefined;
|
|
89
|
-
},
|
|
97
|
+
}, {}, {
|
|
98
|
+
length: number | undefined;
|
|
99
|
+
}>;
|
|
90
100
|
createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
91
101
|
name: "created_at";
|
|
92
102
|
tableName: "gl_push_subscriptions";
|
|
@@ -103,7 +113,7 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
103
113
|
baseColumn: never;
|
|
104
114
|
identity: undefined;
|
|
105
115
|
generated: undefined;
|
|
106
|
-
},
|
|
116
|
+
}, {}, {}>;
|
|
107
117
|
lastDeliveryAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
108
118
|
name: "last_delivery_at";
|
|
109
119
|
tableName: "gl_push_subscriptions";
|
|
@@ -120,7 +130,7 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
120
130
|
baseColumn: never;
|
|
121
131
|
identity: undefined;
|
|
122
132
|
generated: undefined;
|
|
123
|
-
},
|
|
133
|
+
}, {}, {}>;
|
|
124
134
|
failureCount: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
125
135
|
name: "failure_count";
|
|
126
136
|
tableName: "gl_push_subscriptions";
|
|
@@ -137,7 +147,7 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
137
147
|
baseColumn: never;
|
|
138
148
|
identity: undefined;
|
|
139
149
|
generated: undefined;
|
|
140
|
-
},
|
|
150
|
+
}, {}, {}>;
|
|
141
151
|
userAgent: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
142
152
|
name: "user_agent";
|
|
143
153
|
tableName: "gl_push_subscriptions";
|
|
@@ -154,7 +164,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
154
164
|
baseColumn: never;
|
|
155
165
|
identity: undefined;
|
|
156
166
|
generated: undefined;
|
|
157
|
-
},
|
|
167
|
+
}, {}, {
|
|
168
|
+
length: number | undefined;
|
|
169
|
+
}>;
|
|
158
170
|
country: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
159
171
|
name: "country";
|
|
160
172
|
tableName: "gl_push_subscriptions";
|
|
@@ -171,7 +183,9 @@ export declare const pushSubscriptions: import("drizzle-orm/sqlite-core").SQLite
|
|
|
171
183
|
baseColumn: never;
|
|
172
184
|
identity: undefined;
|
|
173
185
|
generated: undefined;
|
|
174
|
-
},
|
|
186
|
+
}, {}, {
|
|
187
|
+
length: number | undefined;
|
|
188
|
+
}>;
|
|
175
189
|
};
|
|
176
190
|
dialect: "sqlite";
|
|
177
191
|
}>;
|
package/dist/db/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW5B,CAAA"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,cAAc,CAAA;AAQlE,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,gBAAgB,CA0DnE"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
1
2
|
import { pushOptionsSchema } from './options.js';
|
|
3
|
+
const ENTRYPOINT_EXTENSION = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
4
|
+
function resolveEntrypoint(path) {
|
|
5
|
+
return fileURLToPath(new URL(`${path}${ENTRYPOINT_EXTENSION}`, import.meta.url));
|
|
6
|
+
}
|
|
2
7
|
export default function push(options) {
|
|
3
8
|
const resolved = pushOptionsSchema.parse(options);
|
|
4
9
|
const virtualConfig = {
|
|
@@ -36,17 +41,17 @@ export default function push(options) {
|
|
|
36
41
|
},
|
|
37
42
|
});
|
|
38
43
|
addMiddleware({
|
|
39
|
-
entrypoint: '
|
|
44
|
+
entrypoint: resolveEntrypoint('./middleware/push'),
|
|
40
45
|
order: 'pre',
|
|
41
46
|
});
|
|
42
47
|
injectRoute({
|
|
43
48
|
pattern: resolved.routes.subscribe,
|
|
44
|
-
entrypoint: '
|
|
49
|
+
entrypoint: resolveEntrypoint('./routes/subscribe'),
|
|
45
50
|
prerender: false,
|
|
46
51
|
});
|
|
47
52
|
injectRoute({
|
|
48
53
|
pattern: resolved.routes.unsubscribe,
|
|
49
|
-
entrypoint: '
|
|
54
|
+
entrypoint: resolveEntrypoint('./routes/unsubscribe'),
|
|
50
55
|
prerender: false,
|
|
51
56
|
});
|
|
52
57
|
},
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,OAAO,EAAoB,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAElE,MAAM,oBAAoB,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAA;AAE5E,SAAS,iBAAiB,CAAC,IAAY;IACtC,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,oBAAoB,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AACjF,CAAC;AAED,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAoB;IAChD,MAAM,QAAQ,GAAG,iBAAiB,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;IAEjD,MAAM,aAAa,GAAG;QACrB,cAAc,EAAE,QAAQ,CAAC,cAAc;QACvC,SAAS,EAAE,QAAQ,CAAC,SAAS;QAC7B,YAAY,EAAE,QAAQ,CAAC,YAAY;QACnC,QAAQ,EAAE,QAAQ,CAAC,QAAQ;QAC3B,KAAK,EAAE,QAAQ,CAAC,KAAK;QACrB,SAAS,EAAE,QAAQ,CAAC,SAAS;QAC7B,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,OAAO,EAAE,QAAQ,CAAC,OAAO;KACzB,CAAA;IAED,MAAM,eAAe,GAAG,iCAAiC,CAAA;IACzD,MAAM,uBAAuB,GAAG,KAAK,eAAe,EAAE,CAAA;IAEtD,OAAO;QACN,IAAI,EAAE,mBAAmB;QACzB,KAAK,EAAE;YACN,oBAAoB,EAAE,CAAC,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,EAAE,EAAE;gBACtE,YAAY,CAAC;oBACZ,IAAI,EAAE;wBACL,OAAO,EAAE;4BACR;gCACC,IAAI,EAAE,yBAAyB;gCAC/B,SAAS,CAAC,EAAE;oCACX,IAAI,EAAE,KAAK,eAAe;wCAAE,OAAO,uBAAuB,CAAA;gCAC3D,CAAC;gCACD,IAAI,CAAC,EAAE;oCACN,IAAI,EAAE,KAAK,uBAAuB,EAAE,CAAC;wCACpC,OAAO,kBAAkB,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE,CAAA;oCACzD,CAAC;gCACF,CAAC;6BACD;yBACD;qBACD;iBACD,CAAC,CAAA;gBAEF,aAAa,CAAC;oBACb,UAAU,EAAE,iBAAiB,CAAC,mBAAmB,CAAC;oBAClD,KAAK,EAAE,KAAK;iBACZ,CAAC,CAAA;gBAEF,WAAW,CAAC;oBACX,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS;oBAClC,UAAU,EAAE,iBAAiB,CAAC,oBAAoB,CAAC;oBACnD,SAAS,EAAE,KAAK;iBAChB,CAAC,CAAA;gBAEF,WAAW,CAAC;oBACX,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,WAAW;oBACpC,UAAU,EAAE,iBAAiB,CAAC,sBAAsB,CAAC;oBACrD,SAAS,EAAE,KAAK;iBAChB,CAAC,CAAA;YACH,CAAC;SACD;KACD,CAAA;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/push",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -52,9 +52,11 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"dist",
|
|
55
|
-
"src
|
|
56
|
-
"src
|
|
57
|
-
"src
|
|
55
|
+
"src/**/*.ts",
|
|
56
|
+
"src/**/*.astro",
|
|
57
|
+
"src/**/*.css",
|
|
58
|
+
"src/**/*.md",
|
|
59
|
+
"src/**/*.sql",
|
|
58
60
|
"README.md"
|
|
59
61
|
],
|
|
60
62
|
"publishConfig": {
|
|
@@ -62,16 +64,16 @@
|
|
|
62
64
|
"registry": "https://registry.npmjs.org/"
|
|
63
65
|
},
|
|
64
66
|
"peerDependencies": {
|
|
65
|
-
"astro": "^6.
|
|
67
|
+
"astro": "^6.1.10"
|
|
66
68
|
},
|
|
67
69
|
"dependencies": {
|
|
68
70
|
"@block65/webcrypto-web-push": "^1.0.0",
|
|
69
|
-
"drizzle-orm": "^0.
|
|
71
|
+
"drizzle-orm": "^0.45.2",
|
|
70
72
|
"zod": "^3.23.0"
|
|
71
73
|
},
|
|
72
74
|
"devDependencies": {
|
|
73
75
|
"@cloudflare/workers-types": "^4.0.0",
|
|
74
|
-
"astro": "^6.
|
|
76
|
+
"astro": "^6.1.10",
|
|
75
77
|
"typescript": "^5.7.0",
|
|
76
78
|
"vitest": "^3.0.0"
|
|
77
79
|
},
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const pushSubscriptions = sqliteTable('gl_push_subscriptions', {
|
|
4
|
+
id: text('id').primaryKey(),
|
|
5
|
+
endpoint: text('endpoint').notNull().unique(),
|
|
6
|
+
keyP256dh: text('key_p256dh').notNull(),
|
|
7
|
+
keyAuth: text('key_auth').notNull(),
|
|
8
|
+
topics: text('topics'),
|
|
9
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
10
|
+
lastDeliveryAt: integer('last_delivery_at', { mode: 'timestamp' }),
|
|
11
|
+
failureCount: integer('failure_count').notNull().default(0),
|
|
12
|
+
userAgent: text('user_agent'),
|
|
13
|
+
country: text('country'),
|
|
14
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import type { AstroIntegration } from 'astro'
|
|
3
|
+
import { type PushOptions, pushOptionsSchema } from './options.js'
|
|
4
|
+
|
|
5
|
+
const ENTRYPOINT_EXTENSION = import.meta.url.endsWith('.ts') ? '.ts' : '.js'
|
|
6
|
+
|
|
7
|
+
function resolveEntrypoint(path: string): string {
|
|
8
|
+
return fileURLToPath(new URL(`${path}${ENTRYPOINT_EXTENSION}`, import.meta.url))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function push(options: PushOptions): AstroIntegration {
|
|
12
|
+
const resolved = pushOptionsSchema.parse(options)
|
|
13
|
+
|
|
14
|
+
const virtualConfig = {
|
|
15
|
+
vapidPublicKey: resolved.vapidPublicKey,
|
|
16
|
+
d1Binding: resolved.d1Binding,
|
|
17
|
+
queueBinding: resolved.queueBinding,
|
|
18
|
+
defaults: resolved.defaults,
|
|
19
|
+
queue: resolved.queue,
|
|
20
|
+
turnstile: resolved.turnstile,
|
|
21
|
+
routes: resolved.routes,
|
|
22
|
+
cleanup: resolved.cleanup,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const virtualModuleId = 'virtual:growth-labs/push/config'
|
|
26
|
+
const resolvedVirtualModuleId = `\0${virtualModuleId}`
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: '@growth-labs/push',
|
|
30
|
+
hooks: {
|
|
31
|
+
'astro:config:setup': ({ addMiddleware, injectRoute, updateConfig }) => {
|
|
32
|
+
updateConfig({
|
|
33
|
+
vite: {
|
|
34
|
+
plugins: [
|
|
35
|
+
{
|
|
36
|
+
name: 'growth-labs-push-config',
|
|
37
|
+
resolveId(id) {
|
|
38
|
+
if (id === virtualModuleId) return resolvedVirtualModuleId
|
|
39
|
+
},
|
|
40
|
+
load(id) {
|
|
41
|
+
if (id === resolvedVirtualModuleId) {
|
|
42
|
+
return `export default ${JSON.stringify(virtualConfig)}`
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
addMiddleware({
|
|
51
|
+
entrypoint: resolveEntrypoint('./middleware/push'),
|
|
52
|
+
order: 'pre',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
injectRoute({
|
|
56
|
+
pattern: resolved.routes.subscribe,
|
|
57
|
+
entrypoint: resolveEntrypoint('./routes/subscribe'),
|
|
58
|
+
prerender: false,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
injectRoute({
|
|
62
|
+
pattern: resolved.routes.unsubscribe,
|
|
63
|
+
entrypoint: resolveEntrypoint('./routes/unsubscribe'),
|
|
64
|
+
prerender: false,
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import config from 'virtual:growth-labs/push/config'
|
|
2
|
+
import { defineMiddleware } from 'astro/middleware'
|
|
3
|
+
|
|
4
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
5
|
+
;(context.locals as Record<string, unknown>).push = {
|
|
6
|
+
vapidPublicKey: config.vapidPublicKey,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return next()
|
|
10
|
+
})
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const pushOptionsSchema = z.object({
|
|
4
|
+
vapidPublicKey: z.string().min(1),
|
|
5
|
+
vapidPrivateKey: z.string().min(1),
|
|
6
|
+
vapidSubject: z.string().min(1),
|
|
7
|
+
|
|
8
|
+
d1Binding: z.string().default('SITE_DB'),
|
|
9
|
+
queueBinding: z.string().default('PUSH_QUEUE'),
|
|
10
|
+
|
|
11
|
+
defaults: z
|
|
12
|
+
.object({
|
|
13
|
+
icon: z.string().optional(),
|
|
14
|
+
ttl: z.number().int().positive().default(86400),
|
|
15
|
+
urgency: z.enum(['very-low', 'low', 'normal', 'high']).default('normal'),
|
|
16
|
+
})
|
|
17
|
+
.default({}),
|
|
18
|
+
|
|
19
|
+
queue: z
|
|
20
|
+
.object({
|
|
21
|
+
batchSize: z.number().int().positive().default(100),
|
|
22
|
+
maxRetries: z.number().int().nonnegative().default(3),
|
|
23
|
+
})
|
|
24
|
+
.default({}),
|
|
25
|
+
|
|
26
|
+
turnstile: z.object({
|
|
27
|
+
secretKey: z.string().min(1),
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
routes: z
|
|
31
|
+
.object({
|
|
32
|
+
subscribe: z.string().default('/api/push/subscribe'),
|
|
33
|
+
unsubscribe: z.string().default('/api/push/unsubscribe'),
|
|
34
|
+
})
|
|
35
|
+
.default({}),
|
|
36
|
+
|
|
37
|
+
cleanup: z
|
|
38
|
+
.object({
|
|
39
|
+
staleAfterDays: z.number().int().positive().default(90),
|
|
40
|
+
})
|
|
41
|
+
.default({}),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export type PushOptions = z.input<typeof pushOptionsSchema>
|
|
45
|
+
export type ResolvedPushOptions = z.output<typeof pushOptionsSchema>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { env } from 'cloudflare:workers'
|
|
2
|
+
import config from 'virtual:growth-labs/push/config'
|
|
3
|
+
import type { APIRoute } from 'astro'
|
|
4
|
+
import { trackPushSubscription } from '../utils/analytics.js'
|
|
5
|
+
import { findSubscriptionByEndpoint, saveSubscription } from '../utils/subscriptions.js'
|
|
6
|
+
import { verifyTurnstile } from '../utils/turnstile.js'
|
|
7
|
+
|
|
8
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
9
|
+
const body = (await request.json()) as Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
const turnstileToken = body.turnstileToken as string | undefined
|
|
12
|
+
if (!turnstileToken) {
|
|
13
|
+
return new Response(JSON.stringify({ error: 'Missing Turnstile token' }), {
|
|
14
|
+
status: 400,
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const envMap = env as Record<string, unknown>
|
|
20
|
+
const turnstileSecret =
|
|
21
|
+
(envMap[config.turnstile.secretKey] as string) ?? (envMap.TURNSTILE_SECRET as string)
|
|
22
|
+
const turnstileValid = await verifyTurnstile(
|
|
23
|
+
turnstileToken,
|
|
24
|
+
turnstileSecret,
|
|
25
|
+
request.headers.get('CF-Connecting-IP'),
|
|
26
|
+
)
|
|
27
|
+
if (!turnstileValid) {
|
|
28
|
+
return new Response(JSON.stringify({ error: 'Bot detected' }), {
|
|
29
|
+
status: 403,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sub = (body.subscription ?? {}) as Record<string, unknown>
|
|
35
|
+
const endpoint = sub.endpoint as string | undefined
|
|
36
|
+
const keys = sub.keys as Record<string, string> | undefined
|
|
37
|
+
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Invalid subscription' }), {
|
|
39
|
+
status: 400,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const db = envMap[config.d1Binding] as D1Database
|
|
45
|
+
const existing = await findSubscriptionByEndpoint(db, endpoint)
|
|
46
|
+
if (existing) {
|
|
47
|
+
await saveSubscription(db, {
|
|
48
|
+
...existing,
|
|
49
|
+
keyP256dh: keys.p256dh,
|
|
50
|
+
keyAuth: keys.auth,
|
|
51
|
+
failureCount: 0,
|
|
52
|
+
lastDeliveryAt: null,
|
|
53
|
+
})
|
|
54
|
+
return new Response(JSON.stringify({ ok: true, updated: true }), {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await saveSubscription(db, {
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
endpoint,
|
|
63
|
+
keyP256dh: keys.p256dh,
|
|
64
|
+
keyAuth: keys.auth,
|
|
65
|
+
topics: null,
|
|
66
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
67
|
+
lastDeliveryAt: null,
|
|
68
|
+
failureCount: 0,
|
|
69
|
+
userAgent: request.headers.get('User-Agent'),
|
|
70
|
+
country: request.headers.get('CF-IPCountry') ?? null,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
trackPushSubscription('subscribed', {
|
|
74
|
+
country: request.headers.get('CF-IPCountry') ?? undefined,
|
|
75
|
+
userAgent: request.headers.get('User-Agent') ?? undefined,
|
|
76
|
+
}).catch(() => {})
|
|
77
|
+
|
|
78
|
+
return new Response(JSON.stringify({ ok: true, created: true }), {
|
|
79
|
+
status: 201,
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { env } from 'cloudflare:workers'
|
|
2
|
+
import config from 'virtual:growth-labs/push/config'
|
|
3
|
+
import type { APIRoute } from 'astro'
|
|
4
|
+
import { trackPushSubscription } from '../utils/analytics.js'
|
|
5
|
+
import { deleteSubscriptionByEndpoint } from '../utils/subscriptions.js'
|
|
6
|
+
|
|
7
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
8
|
+
const body = (await request.json()) as Record<string, unknown>
|
|
9
|
+
const endpoint = body.endpoint as string | undefined
|
|
10
|
+
|
|
11
|
+
if (!endpoint) {
|
|
12
|
+
return new Response(JSON.stringify({ error: 'Missing endpoint' }), {
|
|
13
|
+
status: 400,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const db = (env as Record<string, unknown>)[config.d1Binding] as D1Database
|
|
19
|
+
const deleted = await deleteSubscriptionByEndpoint(db, endpoint)
|
|
20
|
+
|
|
21
|
+
if (deleted) {
|
|
22
|
+
trackPushSubscription('unsubscribed').catch(() => {})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Response(JSON.stringify({ ok: true, deleted }), {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
})
|
|
29
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Push notification payload — what the user sees */
|
|
2
|
+
export interface PushPayload {
|
|
3
|
+
title: string
|
|
4
|
+
body: string
|
|
5
|
+
icon?: string
|
|
6
|
+
image?: string
|
|
7
|
+
url: string
|
|
8
|
+
action?: {
|
|
9
|
+
title: string
|
|
10
|
+
url: string
|
|
11
|
+
}
|
|
12
|
+
tag?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Per-notification delivery options */
|
|
16
|
+
export interface PushSendOptions {
|
|
17
|
+
ttl?: number
|
|
18
|
+
urgency?: 'very-low' | 'low' | 'normal' | 'high'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A push subscription as stored in D1 / received from the browser */
|
|
22
|
+
export interface PushSubscriptionRecord {
|
|
23
|
+
id: string
|
|
24
|
+
endpoint: string
|
|
25
|
+
keyP256dh: string
|
|
26
|
+
keyAuth: string
|
|
27
|
+
topics: string | null
|
|
28
|
+
createdAt: number
|
|
29
|
+
lastDeliveryAt: number | null
|
|
30
|
+
failureCount: number
|
|
31
|
+
userAgent: string | null
|
|
32
|
+
country: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Queue message shape for batch sends.
|
|
37
|
+
*
|
|
38
|
+
* VAPID credentials are NOT included. The Queue consumer Worker reads them
|
|
39
|
+
* from its own env bindings (Cloudflare Secrets). This keeps the private key
|
|
40
|
+
* in exactly one place and avoids serializing it into every Queue message.
|
|
41
|
+
*
|
|
42
|
+
* `siteId` identifies which site enqueued the message. The realm consumer
|
|
43
|
+
* uses it with `siteConfigLookup` to apply per-site overrides (icon URL,
|
|
44
|
+
* VAPID identity, etc.). Sites in single-tenant mode can pass any string.
|
|
45
|
+
*/
|
|
46
|
+
export interface PushQueueMessage {
|
|
47
|
+
siteId: string
|
|
48
|
+
subscriptions: PushSubscriptionRecord[]
|
|
49
|
+
payload: PushPayload
|
|
50
|
+
options: PushSendOptions
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-site config override applied at consumer time. Returned from
|
|
55
|
+
* SitePushConfigLookup. All fields are optional — only the ones present
|
|
56
|
+
* will override the consumer context for that message.
|
|
57
|
+
*/
|
|
58
|
+
export interface SitePushConfig {
|
|
59
|
+
defaults?: {
|
|
60
|
+
icon?: string
|
|
61
|
+
ttl?: number
|
|
62
|
+
urgency?: 'very-low' | 'low' | 'normal' | 'high'
|
|
63
|
+
}
|
|
64
|
+
vapid?: {
|
|
65
|
+
subject?: string
|
|
66
|
+
publicKey?: string
|
|
67
|
+
privateKey?: string
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-message lookup. The realm consumer calls it with the message's
|
|
73
|
+
* `siteId`. Returns the per-site config to apply, or `null` to skip the
|
|
74
|
+
* override and use the consumer's context as-is.
|
|
75
|
+
*/
|
|
76
|
+
export type SitePushConfigLookup = (
|
|
77
|
+
siteId: string,
|
|
78
|
+
env: Record<string, unknown>,
|
|
79
|
+
) => Promise<SitePushConfig | null>
|
|
80
|
+
|
|
81
|
+
/** Result of a single push send attempt */
|
|
82
|
+
export interface PushSendResult {
|
|
83
|
+
subscriptionId: string
|
|
84
|
+
success: boolean
|
|
85
|
+
statusCode?: number
|
|
86
|
+
error?: string
|
|
87
|
+
expired?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Batch send result */
|
|
91
|
+
export interface PushBatchResult {
|
|
92
|
+
sent: number
|
|
93
|
+
failed: number
|
|
94
|
+
expired: number
|
|
95
|
+
results: PushSendResult[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the middleware puts on context.locals */
|
|
99
|
+
export interface PushLocals {
|
|
100
|
+
push: {
|
|
101
|
+
vapidPublicKey: string
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Serializable config exported by the virtual module */
|
|
106
|
+
export interface PushConfig {
|
|
107
|
+
vapidPublicKey: string
|
|
108
|
+
d1Binding: string
|
|
109
|
+
queueBinding: string
|
|
110
|
+
defaults: {
|
|
111
|
+
icon?: string
|
|
112
|
+
ttl: number
|
|
113
|
+
urgency: 'very-low' | 'low' | 'normal' | 'high'
|
|
114
|
+
}
|
|
115
|
+
queue: {
|
|
116
|
+
batchSize: number
|
|
117
|
+
maxRetries: number
|
|
118
|
+
}
|
|
119
|
+
turnstile: {
|
|
120
|
+
secretKey: string
|
|
121
|
+
}
|
|
122
|
+
routes: {
|
|
123
|
+
subscribe: string
|
|
124
|
+
unsubscribe: string
|
|
125
|
+
}
|
|
126
|
+
cleanup: {
|
|
127
|
+
staleAfterDays: number
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { PushBatchResult, PushPayload } from '../types.js'
|
|
2
|
+
|
|
3
|
+
/** Module name kept in a variable so TypeScript doesn't try to resolve it at compile time */
|
|
4
|
+
const ANALYTICS_MODULE = '@growth-labs/analytics'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side analytics helper for push events.
|
|
8
|
+
*
|
|
9
|
+
* Uses optional peer dependency on @growth-labs/analytics.
|
|
10
|
+
* If analytics is not installed, events are silently dropped.
|
|
11
|
+
* This follows the project convention: optional peer dependency + dynamic import with try/catch.
|
|
12
|
+
*/
|
|
13
|
+
export async function trackPushEvent(
|
|
14
|
+
eventName: string,
|
|
15
|
+
data: Record<string, unknown>,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
const analytics = (await import(/* @vite-ignore */ ANALYTICS_MODULE)) as Record<string, unknown>
|
|
19
|
+
if (analytics && typeof analytics.trackServerEvent === 'function') {
|
|
20
|
+
await (
|
|
21
|
+
analytics.trackServerEvent as (name: string, data: Record<string, unknown>) => Promise<void>
|
|
22
|
+
)(eventName, data)
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// @growth-labs/analytics not installed — silently skip
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Track a push notification send completion.
|
|
31
|
+
* Call after a batch send completes (in queue consumer or direct send).
|
|
32
|
+
*/
|
|
33
|
+
export async function trackPushSend(payload: PushPayload, result: PushBatchResult): Promise<void> {
|
|
34
|
+
await trackPushEvent('push_notification_sent', {
|
|
35
|
+
title: payload.title,
|
|
36
|
+
tag: payload.tag,
|
|
37
|
+
url: payload.url,
|
|
38
|
+
sent: result.sent,
|
|
39
|
+
failed: result.failed,
|
|
40
|
+
expired: result.expired,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Track a push subscription event (subscribe or unsubscribe).
|
|
46
|
+
* Call from route handlers after successful subscription changes.
|
|
47
|
+
*/
|
|
48
|
+
export async function trackPushSubscription(
|
|
49
|
+
action: 'subscribed' | 'unsubscribed',
|
|
50
|
+
metadata: { endpoint?: string; country?: string; userAgent?: string } = {},
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await trackPushEvent(`push_${action}`, {
|
|
53
|
+
country: metadata.country,
|
|
54
|
+
userAgent: metadata.userAgent,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Track a push cleanup event.
|
|
60
|
+
* Call after subscription cleanup runs.
|
|
61
|
+
*/
|
|
62
|
+
export async function trackPushCleanup(removed: number): Promise<void> {
|
|
63
|
+
await trackPushEvent('push_cleanup', { removed })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit a client-side analytics event using the gl:* CustomEvent pattern.
|
|
68
|
+
* This is meant to be called from browser-side code (service worker or page).
|
|
69
|
+
*
|
|
70
|
+
* Packages emit CustomEvent on document with gl: prefix. These are local
|
|
71
|
+
* browser signals; consumers call window.glAnalytics.track(...) when an event
|
|
72
|
+
* should be persisted to WAE.
|
|
73
|
+
*
|
|
74
|
+
* Usage in service worker notificationclick handler:
|
|
75
|
+
* ```javascript
|
|
76
|
+
* // In the page after notification click navigation
|
|
77
|
+
* document.dispatchEvent(new CustomEvent('gl:push_notification_clicked', {
|
|
78
|
+
* detail: { tag: 'article-123', url: '/articles/123' }
|
|
79
|
+
* }))
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* This function is a convenience wrapper for use in page scripts.
|
|
83
|
+
*/
|
|
84
|
+
export function emitPushAnalyticsEvent(
|
|
85
|
+
eventName: string,
|
|
86
|
+
detail: Record<string, unknown> = {},
|
|
87
|
+
): void {
|
|
88
|
+
// Check for browser environment — this function is a no-op on the server
|
|
89
|
+
const doc =
|
|
90
|
+
typeof globalThis !== 'undefined'
|
|
91
|
+
? (globalThis as unknown as { document?: { dispatchEvent(event: Event): boolean } }).document
|
|
92
|
+
: undefined
|
|
93
|
+
if (doc) {
|
|
94
|
+
doc.dispatchEvent(new CustomEvent(`gl:${eventName}`, { detail }))
|
|
95
|
+
}
|
|
96
|
+
}
|