@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.
@@ -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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
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
- }, object>;
186
+ }, {}, {
187
+ length: number | undefined;
188
+ }>;
175
189
  };
176
190
  dialect: "sqlite";
177
191
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW5B,CAAA"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW5B,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,cAAc,CAAA;AAElE,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,gBAAgB,CA0DnE"}
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: '@growth-labs/push/middleware',
44
+ entrypoint: resolveEntrypoint('./middleware/push'),
40
45
  order: 'pre',
41
46
  });
42
47
  injectRoute({
43
48
  pattern: resolved.routes.subscribe,
44
- entrypoint: '@growth-labs/push/routes/subscribe',
49
+ entrypoint: resolveEntrypoint('./routes/subscribe'),
45
50
  prerender: false,
46
51
  });
47
52
  injectRoute({
48
53
  pattern: resolved.routes.unsubscribe,
49
- entrypoint: '@growth-labs/push/routes/unsubscribe',
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":"AACA,OAAO,EAAoB,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAElE,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,8BAA8B;oBAC1C,KAAK,EAAE,KAAK;iBACZ,CAAC,CAAA;gBAEF,WAAW,CAAC;oBACX,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS;oBAClC,UAAU,EAAE,oCAAoC;oBAChD,SAAS,EAAE,KAAK;iBAChB,CAAC,CAAA;gBAEF,WAAW,CAAC;oBACX,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,WAAW;oBACpC,UAAU,EAAE,sCAAsC;oBAClD,SAAS,EAAE,KAAK;iBAChB,CAAC,CAAA;YACH,CAAC;SACD;KACD,CAAA;AACF,CAAC"}
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.0",
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/components",
56
- "src/client",
57
- "src/virtual.d.ts",
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.0.0"
67
+ "astro": "^6.1.10"
66
68
  },
67
69
  "dependencies": {
68
70
  "@block65/webcrypto-web-push": "^1.0.0",
69
- "drizzle-orm": "^0.36.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.0.0",
76
+ "astro": "^6.1.10",
75
77
  "typescript": "^5.7.0",
76
78
  "vitest": "^3.0.0"
77
79
  },
@@ -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
+ }