@m13v/seo-components 0.38.5 → 0.40.0
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.
|
File without changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { capturePostHogServer } from "./posthog-capture";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic email-gated download redirect handler.
|
|
6
|
+
*
|
|
7
|
+
* Companion to `createNewsletterHandler`. The newsletter flow captures the
|
|
8
|
+
* email and emails the user a tokenized download link
|
|
9
|
+
* (e.g. `/api/download?token=<HMAC>`). This factory wires up the GET endpoint
|
|
10
|
+
* that link points at. It:
|
|
11
|
+
*
|
|
12
|
+
* 1. Reads the token from either the configured cookie or the `?token=`
|
|
13
|
+
* query param.
|
|
14
|
+
* 2. Calls the caller's `verifyToken` (HMAC checks live in the consumer
|
|
15
|
+
* because the signing secret is app-specific).
|
|
16
|
+
* 3. On invalid token: 302 to `gateRedirect` (defaults to
|
|
17
|
+
* `/?gate=required&from=download`).
|
|
18
|
+
* 4. On valid token:
|
|
19
|
+
* - resolves the download URL via `resolveDownloadUrl` (e.g. latest
|
|
20
|
+
* GitHub release `.dmg` asset),
|
|
21
|
+
* - fires a server-side PostHog event (default
|
|
22
|
+
* `download_link_clicked_server`), ground-truth, ad-blocker proof,
|
|
23
|
+
* - fires the optional `onClick` callback (DB insert lives here),
|
|
24
|
+
* - 302s to the resolved URL.
|
|
25
|
+
*
|
|
26
|
+
* Bot UAs (LinkedIn / Slack / Twitter link unfurls) are detected and the
|
|
27
|
+
* PostHog event + onClick callback are suppressed for them so funnel stats
|
|
28
|
+
* stay clean. The 302 still fires so previews render.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const BOT_UA_RE = /bot|crawler|spider|Twitterbot|LinkedInBot|Slackbot|facebookexternalhit|Discordbot|TelegramBot|WhatsApp|Applebot|Googlebot|Bingbot|YandexBot|DuckDuckBot|redditbot|Pinterest|Embedly|Snapchat|axios\/|python-requests|python-urllib|node-fetch|^node$|dataminr|Anthill|^Mozilla\/5\.0$|Chrome\/70\.|Nexus 5X Build\/MMB29P|AI-Innovation-Radar|^AFMMainUI|LinkResolver|link-resolver|^Mozilla\/5\.0 \(compatible\)$|^curl\//i;
|
|
32
|
+
|
|
33
|
+
export interface DownloadHandlerOk {
|
|
34
|
+
ok: true;
|
|
35
|
+
email: string;
|
|
36
|
+
}
|
|
37
|
+
export interface DownloadHandlerFail {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: string;
|
|
40
|
+
}
|
|
41
|
+
export type DownloadHandlerVerifyResult = DownloadHandlerOk | DownloadHandlerFail;
|
|
42
|
+
|
|
43
|
+
export interface DownloadHandlerClickInfo {
|
|
44
|
+
email: string;
|
|
45
|
+
version: string | null;
|
|
46
|
+
url: string;
|
|
47
|
+
userAgent: string;
|
|
48
|
+
referer: string;
|
|
49
|
+
ip: string | null;
|
|
50
|
+
country: string | null;
|
|
51
|
+
source: "cookie" | "query";
|
|
52
|
+
isBot: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DownloadHandlerConfig {
|
|
56
|
+
/** Site slug, used as a PostHog property (e.g. "claude-meter"). */
|
|
57
|
+
site: string;
|
|
58
|
+
/** Cookie name to read the install token from. */
|
|
59
|
+
cookieName: string;
|
|
60
|
+
/**
|
|
61
|
+
* Verifies the token (HMAC or otherwise). Returns the captured email on
|
|
62
|
+
* success. The signing secret stays in the consumer app because it is
|
|
63
|
+
* app-specific.
|
|
64
|
+
*/
|
|
65
|
+
verifyToken: (token: string | undefined | null) => DownloadHandlerVerifyResult;
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the final destination URL (e.g. latest GitHub release .dmg).
|
|
68
|
+
* Called only when the token is valid, so misses cost nothing on bounces.
|
|
69
|
+
*/
|
|
70
|
+
resolveDownloadUrl: () => Promise<{ url: string; version: string | null }>;
|
|
71
|
+
/**
|
|
72
|
+
* Builds the Set-Cookie header value to stamp the install token when the
|
|
73
|
+
* request arrived via `?token=` (so subsequent installs on the same browser
|
|
74
|
+
* skip the URL token). If omitted, no cookie is stamped on the query path.
|
|
75
|
+
*/
|
|
76
|
+
buildTokenCookie?: (email: string) => string;
|
|
77
|
+
/**
|
|
78
|
+
* Where to bounce on missing / bad token. Defaults to
|
|
79
|
+
* `/?gate=required&from=download`. Relative so it works behind proxies
|
|
80
|
+
* where `req.url` resolves to the internal binding.
|
|
81
|
+
*/
|
|
82
|
+
gateRedirect?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Optional DB-logging hook fired AFTER the destination URL is resolved and
|
|
85
|
+
* the PostHog event is sent, but BEFORE the 302 response is returned.
|
|
86
|
+
* Errors are swallowed so a logging miss can't break the download path.
|
|
87
|
+
* Not called for bots.
|
|
88
|
+
*/
|
|
89
|
+
onClick?: (info: DownloadHandlerClickInfo) => Promise<void> | void;
|
|
90
|
+
/** PostHog event name. Defaults to `download_link_clicked_server`. */
|
|
91
|
+
posthogEvent?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createDownloadHandler(config: DownloadHandlerConfig) {
|
|
95
|
+
const {
|
|
96
|
+
site,
|
|
97
|
+
cookieName,
|
|
98
|
+
verifyToken,
|
|
99
|
+
resolveDownloadUrl,
|
|
100
|
+
buildTokenCookie,
|
|
101
|
+
gateRedirect = "/?gate=required&from=download",
|
|
102
|
+
onClick,
|
|
103
|
+
posthogEvent = "download_link_clicked_server",
|
|
104
|
+
} = config;
|
|
105
|
+
|
|
106
|
+
return async function GET(req: NextRequest): Promise<Response> {
|
|
107
|
+
const cookieToken = req.cookies.get(cookieName)?.value;
|
|
108
|
+
const queryToken = req.nextUrl.searchParams.get("token") ?? undefined;
|
|
109
|
+
|
|
110
|
+
let verdict = verifyToken(cookieToken);
|
|
111
|
+
let acceptedFrom: "cookie" | "query" | null = verdict.ok ? "cookie" : null;
|
|
112
|
+
if (!verdict.ok && queryToken) {
|
|
113
|
+
const queryVerdict = verifyToken(queryToken);
|
|
114
|
+
if (queryVerdict.ok) {
|
|
115
|
+
verdict = queryVerdict;
|
|
116
|
+
acceptedFrom = "query";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!verdict.ok) {
|
|
121
|
+
return new Response(null, {
|
|
122
|
+
status: 302,
|
|
123
|
+
headers: {
|
|
124
|
+
Location: gateRedirect,
|
|
125
|
+
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
|
126
|
+
"x-install-gate": verdict.reason ?? "missing",
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { url, version } = await resolveDownloadUrl();
|
|
132
|
+
|
|
133
|
+
const ua = req.headers.get("user-agent") || "";
|
|
134
|
+
const isBot = !ua || BOT_UA_RE.test(ua);
|
|
135
|
+
const referer = req.headers.get("referer") || "";
|
|
136
|
+
const xff = req.headers.get("x-forwarded-for") || "";
|
|
137
|
+
const ip = xff.split(",")[0]?.trim() || null;
|
|
138
|
+
const country = req.headers.get("x-vercel-ip-country") || req.headers.get("cf-ipcountry") || null;
|
|
139
|
+
const email = verdict.email;
|
|
140
|
+
|
|
141
|
+
if (!isBot) {
|
|
142
|
+
let host: string | undefined;
|
|
143
|
+
try {
|
|
144
|
+
host = req.headers.get("host") || undefined;
|
|
145
|
+
} catch {
|
|
146
|
+
host = undefined;
|
|
147
|
+
}
|
|
148
|
+
await capturePostHogServer({
|
|
149
|
+
event: posthogEvent,
|
|
150
|
+
distinctId: email,
|
|
151
|
+
host,
|
|
152
|
+
properties: {
|
|
153
|
+
email,
|
|
154
|
+
site,
|
|
155
|
+
version,
|
|
156
|
+
source: acceptedFrom,
|
|
157
|
+
referer,
|
|
158
|
+
country,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (onClick) {
|
|
163
|
+
try {
|
|
164
|
+
await onClick({
|
|
165
|
+
email,
|
|
166
|
+
version,
|
|
167
|
+
url,
|
|
168
|
+
userAgent: ua,
|
|
169
|
+
referer,
|
|
170
|
+
ip,
|
|
171
|
+
country,
|
|
172
|
+
source: acceptedFrom ?? "cookie",
|
|
173
|
+
isBot,
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error("[download-handler] onClick threw:", err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const headers = new Headers({
|
|
182
|
+
Location: url,
|
|
183
|
+
"Cache-Control": "private, max-age=0, s-maxage=0",
|
|
184
|
+
"x-install-gate": "ok",
|
|
185
|
+
"x-install-gate-source": acceptedFrom ?? "unknown",
|
|
186
|
+
});
|
|
187
|
+
if (acceptedFrom === "query" && buildTokenCookie) {
|
|
188
|
+
headers.append("Set-Cookie", buildTokenCookie(email));
|
|
189
|
+
}
|
|
190
|
+
return new Response(null, { status: 302, headers });
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -255,17 +255,17 @@ export function createNewsletterHandler(config: NewsletterConfig) {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// 4. Server-side PostHog capture (ground truth, not ad-blocked).
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
258
|
+
// Awaited (not `void`) so the fetch completes before the response is
|
|
259
|
+
// returned. On Cloud Run / serverless runtimes the container can be
|
|
260
|
+
// frozen the moment the response is sent, killing fire-and-forget
|
|
261
|
+
// fetches. Adds ~50-150ms latency, worth it for reliable funnel data.
|
|
262
262
|
let host: string | undefined;
|
|
263
263
|
try {
|
|
264
264
|
host = req.headers.get("host") || new URL(siteUrl).hostname;
|
|
265
265
|
} catch {
|
|
266
266
|
host = undefined;
|
|
267
267
|
}
|
|
268
|
-
|
|
268
|
+
await capturePostHogServer({
|
|
269
269
|
event: "newsletter_subscribed_server",
|
|
270
270
|
distinctId: email,
|
|
271
271
|
host,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextRequest } from "next/server";
|
|
2
|
+
import { capturePostHogServer } from "./posthog-capture";
|
|
2
3
|
|
|
3
4
|
/* ------------------------------------------------------------------ */
|
|
4
5
|
/* Resend Inbound webhook handler */
|
|
@@ -103,6 +104,26 @@ export interface ResendInboundConfig {
|
|
|
103
104
|
resendId: string;
|
|
104
105
|
timestamp: string;
|
|
105
106
|
}) => Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Optional: fire server-side PostHog events for delivery lifecycle
|
|
109
|
+
* (`email_sent_server`, `email_delivered_server`, `email_opened_server`,
|
|
110
|
+
* `email_clicked_server`, `email_bounced_server`, `email_complained_server`,
|
|
111
|
+
* `email_delayed_server`). When set, the webhook receives ALL delivery
|
|
112
|
+
* events for the connected Resend workspace, so use `fromDomainFilter` to
|
|
113
|
+
* scope to this site only.
|
|
114
|
+
*/
|
|
115
|
+
posthog?: {
|
|
116
|
+
/** Site slug attached to the PostHog event (e.g. "claude-meter"). */
|
|
117
|
+
site: string;
|
|
118
|
+
/**
|
|
119
|
+
* Only fire PostHog events when the email's `from` address ends in this
|
|
120
|
+
* domain (e.g. "claude-meter.com"). Critical because Resend webhooks are
|
|
121
|
+
* registered at workspace level: every delivery event for every domain
|
|
122
|
+
* in the account hits every webhook. Without this filter, claude-meter's
|
|
123
|
+
* PostHog gets polluted by fazm / mediar / vipassana delivery events.
|
|
124
|
+
*/
|
|
125
|
+
fromDomainFilter: string;
|
|
126
|
+
};
|
|
106
127
|
/**
|
|
107
128
|
* Skip forwarding (only persist + return 200). Useful for tests, or for
|
|
108
129
|
* sites where the DB log is the only consumer.
|
|
@@ -145,6 +166,7 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
|
|
|
145
166
|
apiKeyEnv = "RESEND_API_KEY",
|
|
146
167
|
onInbound,
|
|
147
168
|
onDeliveryEvent,
|
|
169
|
+
posthog,
|
|
148
170
|
skipForward = false,
|
|
149
171
|
ignoredSenders = DEFAULT_IGNORED_SENDERS,
|
|
150
172
|
ignoredSubjects = DEFAULT_IGNORED_SUBJECTS,
|
|
@@ -220,18 +242,55 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
|
|
|
220
242
|
}
|
|
221
243
|
}
|
|
222
244
|
|
|
223
|
-
async function handleDelivery(payload: ResendInboundPayload) {
|
|
245
|
+
async function handleDelivery(payload: ResendInboundPayload, host?: string) {
|
|
224
246
|
const status = DELIVERY_STATUS_MAP[payload.type];
|
|
225
|
-
if (!status
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
if (!status) return;
|
|
248
|
+
|
|
249
|
+
if (onDeliveryEvent) {
|
|
250
|
+
try {
|
|
251
|
+
await onDeliveryEvent({
|
|
252
|
+
type: payload.type,
|
|
253
|
+
status,
|
|
254
|
+
resendId: payload.data.email_id,
|
|
255
|
+
timestamp: payload.created_at,
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error(`${tag} onDeliveryEvent error`, err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Optional PostHog fire — only when configured AND the email originated
|
|
263
|
+
// from our domain (Resend webhooks are workspace-level, so filtering is
|
|
264
|
+
// mandatory to avoid cross-site pollution).
|
|
265
|
+
if (posthog) {
|
|
266
|
+
const fromAddr = parseEmail(payload.data.from || "");
|
|
267
|
+
const matchesDomain = fromAddr.endsWith(`@${posthog.fromDomainFilter.toLowerCase()}`);
|
|
268
|
+
if (!matchesDomain) {
|
|
269
|
+
console.log(
|
|
270
|
+
`${tag} posthog skip, from domain mismatch:`,
|
|
271
|
+
fromAddr,
|
|
272
|
+
"expected:",
|
|
273
|
+
posthog.fromDomainFilter,
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const recipient = payload.data.to?.[0]?.toLowerCase() || payload.data.email_id;
|
|
278
|
+
await capturePostHogServer({
|
|
279
|
+
event: `email_${status}_server`,
|
|
280
|
+
distinctId: recipient,
|
|
281
|
+
host,
|
|
282
|
+
properties: {
|
|
283
|
+
email: recipient,
|
|
284
|
+
site: posthog.site,
|
|
285
|
+
resend_email_id: payload.data.email_id,
|
|
286
|
+
type: payload.type,
|
|
287
|
+
status,
|
|
288
|
+
subject: payload.data.subject || null,
|
|
289
|
+
from: fromAddr,
|
|
290
|
+
timestamp: payload.created_at,
|
|
291
|
+
component: "createResendInboundHandler",
|
|
292
|
+
},
|
|
232
293
|
});
|
|
233
|
-
} catch (err) {
|
|
234
|
-
console.error(`${tag} onDeliveryEvent error`, err);
|
|
235
294
|
}
|
|
236
295
|
}
|
|
237
296
|
|
|
@@ -257,11 +316,18 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
|
|
|
257
316
|
|
|
258
317
|
console.log(`${tag}`, payload.type, payload.data?.email_id);
|
|
259
318
|
|
|
319
|
+
let host: string | undefined;
|
|
320
|
+
try {
|
|
321
|
+
host = req.headers.get("host") || undefined;
|
|
322
|
+
} catch {
|
|
323
|
+
host = undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
260
326
|
try {
|
|
261
327
|
if (payload.type === "email.received") {
|
|
262
328
|
await handleInbound(payload, apiKey);
|
|
263
329
|
} else if (DELIVERY_STATUS_MAP[payload.type]) {
|
|
264
|
-
await handleDelivery(payload);
|
|
330
|
+
await handleDelivery(payload, host);
|
|
265
331
|
}
|
|
266
332
|
} catch (err) {
|
|
267
333
|
console.error(`${tag} handler error`, err);
|
package/src/server.ts
CHANGED
|
@@ -48,6 +48,13 @@ export type { BookCallRedirectConfig } from "./lib/book-call-redirect";
|
|
|
48
48
|
export { createDmShortLinkRedirectHandler } from "./lib/dm-short-link-redirect";
|
|
49
49
|
export type { DmShortLinkRedirectConfig } from "./lib/dm-short-link-redirect";
|
|
50
50
|
|
|
51
|
+
export { createDownloadHandler } from "./lib/download-route";
|
|
52
|
+
export type {
|
|
53
|
+
DownloadHandlerConfig,
|
|
54
|
+
DownloadHandlerVerifyResult,
|
|
55
|
+
DownloadHandlerClickInfo,
|
|
56
|
+
} from "./lib/download-route";
|
|
57
|
+
|
|
51
58
|
export { createResendInboundHandler } from "./lib/resend-inbound-route";
|
|
52
59
|
export type {
|
|
53
60
|
ResendInboundConfig,
|