forked from Mike/freno-dev
nook marketing - utm, analytics, etc
This commit is contained in:
75
src/routes/api/the-nook/_download.ts
Normal file
75
src/routes/api/the-nook/_download.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Download resolution for The Nook, kept out of the route module on purpose.
|
||||
*
|
||||
* Vinxi's route tree-shake (`vinxi/lib/plugins/tree-shake.babel.js`) deletes
|
||||
* every exported declaration in a route file whose name is not in the
|
||||
* `?pick=` list the router asks for — and a route is picked as `pick=GET`.
|
||||
* A helper exported from `download.ts` is therefore removed while `GET`
|
||||
* still references it, which fails at runtime with "is not defined" in dev
|
||||
* and in the production bundle alike. Only HTTP handlers may be exported
|
||||
* from a route; helpers live here, where the transform never runs. Same
|
||||
* reason `_lib.ts` exists.
|
||||
*/
|
||||
|
||||
const APPCast_KEY = "api/TheNook/appcast.xml";
|
||||
|
||||
/** Kept in step with the appcast route's own 5-minute cache. */
|
||||
const RESOLVE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const ENCLOSURE_RE = /<enclosure\b[^>]*\burl="([^"]+)"/gi;
|
||||
|
||||
/**
|
||||
* Picks the newest release artifact out of a Sparkle appcast: the first
|
||||
* enclosure that is a .dmg, else the first .zip, else the first enclosure
|
||||
* of any kind. Enclosures appear in newest-first order.
|
||||
*/
|
||||
export function pickEnclosure(xml: string): string | null {
|
||||
const urls: string[] = [];
|
||||
for (const match of xml.matchAll(ENCLOSURE_RE)) {
|
||||
if (match[1]) urls.push(match[1]);
|
||||
}
|
||||
return (
|
||||
urls.find((u) => /\.dmg(\?|$)/i.test(u)) ??
|
||||
urls.find((u) => /\.zip(\?|$)/i.test(u)) ??
|
||||
urls[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
let cached: { url: string; at: number } | null = null;
|
||||
|
||||
/** Drops the cached enclosure so the next request re-reads the appcast. */
|
||||
export function invalidateDownloadCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Resolves (and briefly caches) the current download URL from the appcast. */
|
||||
export async function resolveLatestDownload(): Promise<string | null> {
|
||||
if (cached && Date.now() - cached.at < RESOLVE_TTL_MS) {
|
||||
return cached.url;
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
}
|
||||
});
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: APPCast_KEY
|
||||
})
|
||||
);
|
||||
if (!response.Body) return null;
|
||||
|
||||
const xml = await response.Body.transformToString();
|
||||
const url = pickEnclosure(xml);
|
||||
if (url) cached = { url, at: Date.now() };
|
||||
return url;
|
||||
}
|
||||
202
src/routes/api/the-nook/download.test.ts
Normal file
202
src/routes/api/the-nook/download.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
// The route's contracts:
|
||||
// 1. Every click is recorded through the shared analytics buffer with the
|
||||
// path, referrer, country and device type the funnel needs.
|
||||
// 2. The response is a no-store 302 to the newest release enclosure, so a
|
||||
// CDN can never serve a later click without it reaching this handler.
|
||||
// 3. An unreadable appcast degrades to 503 — but the click is still logged,
|
||||
// because a failed download is exactly the event worth seeing.
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
const logged: Entry[] = [];
|
||||
let appcastXml = "";
|
||||
let s3ShouldThrow = false;
|
||||
let flushCalls = 0;
|
||||
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
MY_AWS_ACCESS_KEY: "test-key",
|
||||
MY_AWS_SECRET_KEY: "test-secret",
|
||||
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@aws-sdk/client-s3", () => ({
|
||||
S3Client: class {
|
||||
async send() {
|
||||
if (s3ShouldThrow) throw new Error("s3 unavailable");
|
||||
return {
|
||||
Body: appcastXml
|
||||
? { transformToString: async () => appcastXml }
|
||||
: null
|
||||
};
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class {
|
||||
input: unknown;
|
||||
constructor(input: unknown) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("vinxi/http", () => ({
|
||||
getRequestIP: () => null
|
||||
}));
|
||||
|
||||
mock.module("~/server/analytics", () => ({
|
||||
logVisit: async (entry: Entry) => {
|
||||
logged.push(entry);
|
||||
},
|
||||
// The route must flush, not merely buffer: the batch timer is not
|
||||
// guaranteed to survive the response on serverless.
|
||||
flushAnalytics: async () => {
|
||||
flushCalls += 1;
|
||||
},
|
||||
// Mirrors the real enrichment contract: deviceType is derived from the UA.
|
||||
enrichAnalyticsEntry: (entry: Entry) => ({
|
||||
...entry,
|
||||
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
|
||||
? "desktop"
|
||||
: null
|
||||
})
|
||||
}));
|
||||
|
||||
const { GET } = await import("./download");
|
||||
const { pickEnclosure, invalidateDownloadCache } = await import("./_download");
|
||||
|
||||
const DMG = "https://cdn.example/TheNook-0.6.3.dmg";
|
||||
const ZIP = "https://cdn.example/TheNook-0.6.3.zip";
|
||||
|
||||
const appcast = (urls: string[]) =>
|
||||
`<?xml version="1.0"?><rss><channel><item>` +
|
||||
urls
|
||||
.map(
|
||||
(u) =>
|
||||
`<enclosure url="${u}" sparkle:version="1" length="1" type="application/octet-stream"/>`
|
||||
)
|
||||
.join("") +
|
||||
`</item></channel></rss>`;
|
||||
|
||||
function request(headers: Record<string, string> = {}, query = "") {
|
||||
return {
|
||||
request: new Request(
|
||||
`https://nook.freno.me/api/the-nook/download${query}`,
|
||||
{ headers }
|
||||
),
|
||||
nativeEvent: {}
|
||||
} as never;
|
||||
}
|
||||
|
||||
const MAC_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15";
|
||||
|
||||
beforeEach(() => {
|
||||
logged.length = 0;
|
||||
appcastXml = appcast([DMG, ZIP]);
|
||||
s3ShouldThrow = false;
|
||||
flushCalls = 0;
|
||||
invalidateDownloadCache();
|
||||
});
|
||||
|
||||
describe("GET /api/the-nook/download", () => {
|
||||
it("logs the click and 302s to the newest dmg", async () => {
|
||||
const res = await GET(request({ referer: "https://nook.freno.me/" }));
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe(DMG);
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
|
||||
expect(logged.length).toBe(1);
|
||||
expect(logged[0]).toMatchObject({
|
||||
path: "/api/the-nook/download",
|
||||
method: "GET",
|
||||
referrer: "https://nook.freno.me/"
|
||||
});
|
||||
// Flushed within the request, not left to the batch timer.
|
||||
expect(flushCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("derives the device type from the user agent", async () => {
|
||||
await GET(request({ "user-agent": MAC_UA }));
|
||||
expect(logged[0]!.deviceType).toBe("desktop");
|
||||
});
|
||||
|
||||
it("records the edge country, and lets ?c= override it", async () => {
|
||||
await GET(request({ "x-vercel-ip-country": "GB" }));
|
||||
expect(logged[0]!.country).toBe("GB");
|
||||
|
||||
invalidateDownloadCache();
|
||||
await GET(request({ "cf-ipcountry": "DE" }));
|
||||
expect(logged[1]!.country).toBe("DE");
|
||||
|
||||
invalidateDownloadCache();
|
||||
await GET(request({ "x-vercel-ip-country": "GB" }, "?c=JP"));
|
||||
expect(logged[2]!.country).toBe("JP");
|
||||
});
|
||||
|
||||
it("falls back to the forwarded address when no request IP is available", async () => {
|
||||
await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }));
|
||||
expect(logged[0]!.ipAddress).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
it("still logs the click when the appcast cannot be read", async () => {
|
||||
s3ShouldThrow = true;
|
||||
const res = await GET(request());
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(logged.length).toBe(1);
|
||||
});
|
||||
|
||||
it("503s when the appcast carries no enclosure", async () => {
|
||||
appcastXml = "<?xml version='1.0'?><rss><channel></channel></rss>";
|
||||
invalidateDownloadCache();
|
||||
|
||||
const res = await GET(request());
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("captures campaign tags from the click URL and the referer", async () => {
|
||||
await GET(
|
||||
request(
|
||||
{ referer: "https://nook.freno.me/?utm_source=hn&utm_campaign=launch" },
|
||||
"?utm_medium=post"
|
||||
)
|
||||
);
|
||||
|
||||
expect(logged[0]).toMatchObject({
|
||||
utmSource: "hn",
|
||||
utmMedium: "post",
|
||||
utmCampaign: "launch"
|
||||
});
|
||||
});
|
||||
|
||||
it("logs nulls when nothing is tagged", async () => {
|
||||
await GET(request({ referer: "https://nook.freno.me/" }));
|
||||
expect(logged[0]).toMatchObject({
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickEnclosure", () => {
|
||||
it("prefers a dmg over a zip even when the zip comes first", () => {
|
||||
expect(pickEnclosure(appcast([ZIP, DMG]))).toBe(DMG);
|
||||
});
|
||||
|
||||
it("falls back to a zip, then to any enclosure", () => {
|
||||
expect(pickEnclosure(appcast([ZIP]))).toBe(ZIP);
|
||||
expect(pickEnclosure(appcast(["https://cdn.example/notes.txt"]))).toBe(
|
||||
"https://cdn.example/notes.txt"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there is no enclosure", () => {
|
||||
expect(pickEnclosure("<rss><channel></channel></rss>")).toBeNull();
|
||||
});
|
||||
});
|
||||
94
src/routes/api/the-nook/download.ts
Normal file
94
src/routes/api/the-nook/download.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { logVisit, enrichAnalyticsEntry, flushAnalytics } from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { resolveLatestDownload } from "./_download";
|
||||
|
||||
/**
|
||||
* GET /api/the-nook/download
|
||||
*
|
||||
* The measured download door. Both hero buttons point here instead of
|
||||
* resolving the appcast in the browser: this route resolves the latest
|
||||
* release enclosure server-side, records the click through the shared
|
||||
* analytics buffer, then 302s to the DMG.
|
||||
*
|
||||
* Why a redirect and not a direct S3 link: the appcast route and the DMG
|
||||
* route never logged anything, so step 2 of the funnel (download click →
|
||||
* DMG served) was invisible. Every click now lands one row in
|
||||
* VisitorAnalytics under the path below, which is what `topApiCalls`
|
||||
* already surfaces.
|
||||
*
|
||||
* Country is read from the edge headers Vercel/Cloudflare set; the
|
||||
* analytics buffer carries a `country` column that nothing populated
|
||||
* until now. `?c=` is accepted as an override for previews and manual
|
||||
* verification.
|
||||
*
|
||||
* ONLY the handler is exported here: Vinxi's route tree-shake deletes
|
||||
* exported declarations that are not in the router's `pick=` list, so a
|
||||
* helper exported from this file would be removed while `GET` still
|
||||
* referenced it. All resolution helpers live in `./_download.ts`.
|
||||
*/
|
||||
|
||||
function firstHeader(request: Request, ...names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = request.headers.get(name);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(event: APIEvent): Promise<Response> {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const forwarded = firstHeader(request, "x-forwarded-for");
|
||||
const ipAddress =
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null);
|
||||
|
||||
// Best-effort: a failed analytics write must never block a download.
|
||||
// Flushed (not merely buffered) because a download click is low-volume and
|
||||
// the batch timer is not guaranteed to survive the response on serverless.
|
||||
try {
|
||||
const referrer = firstHeader(request, "referer", "referrer");
|
||||
await logVisit(
|
||||
enrichAnalyticsEntry({
|
||||
path: "/api/the-nook/download",
|
||||
method: "GET",
|
||||
referrer,
|
||||
userAgent: firstHeader(request, "user-agent"),
|
||||
ipAddress,
|
||||
// The click carries its own tags when a campaign links straight here;
|
||||
// otherwise the landing page's tags ride along in the referer.
|
||||
...parseUtm(request.url, referrer),
|
||||
country:
|
||||
url.searchParams.get("c") ??
|
||||
firstHeader(request, "x-vercel-ip-country", "cf-ipcountry")
|
||||
})
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error("Failed to log download click:", error);
|
||||
}
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
target = await resolveLatestDownload();
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve Nook download:", error);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return new Response("Download unavailable", {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" }
|
||||
});
|
||||
}
|
||||
|
||||
// No-store is load-bearing: a cached 302 would serve later clicks without
|
||||
// ever reaching this handler, silently dropping them from the funnel.
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: target, "Cache-Control": "no-store" }
|
||||
});
|
||||
}
|
||||
@@ -9,19 +9,14 @@ import { createSignal, Show } from "solid-js";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
|
||||
/* The appcast's first enclosure always names the latest release dmg. */
|
||||
async function downloadNook() {
|
||||
try {
|
||||
const res = await fetch("/api/TheNook/appcast.xml");
|
||||
const xml = await res.text();
|
||||
const url =
|
||||
xml.match(/<enclosure url="([^"]+\.dmg)"/)?.[1] ??
|
||||
xml.match(/<enclosure url="([^"]+\.zip)"/)?.[1];
|
||||
if (!url) throw new Error("no enclosure");
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
console.error("Could not resolve latest Nook download");
|
||||
}
|
||||
/*
|
||||
* Downloads are logged and resolved server-side. `/api/the-nook/download`
|
||||
* records the click in the analytics buffer, then 302s to the latest
|
||||
* release dmg the appcast names. Pointing the button straight at the
|
||||
* enclosure would skip the funnel's second step entirely.
|
||||
*/
|
||||
function downloadNook() {
|
||||
window.location.href = "/api/the-nook/download";
|
||||
}
|
||||
|
||||
export default function NookLanding() {
|
||||
|
||||
Reference in New Issue
Block a user