fix(analytics): send download stats via sendBeacon (v1.0.4)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-07 20:00:59 +08:00
parent e476e10b05
commit 5673e6f9d5
3 changed files with 70 additions and 8 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ttrpg-player-landing", "name": "ttrpg-player-landing",
"private": true, "private": true,
"version": "1.0.3", "version": "1.0.4",
"description": "Marketing landing for TTRPG Player (НРИ Плеер)", "description": "Marketing landing for TTRPG Player (НРИ Плеер)",
"type": "module", "type": "module",
"scripts": { "scripts": {
+50 -4
View File
@@ -3,10 +3,30 @@ import { afterEach, describe, it, mock } from "node:test";
import { import {
DEFAULT_LICENSE_TRACKING_BASE, DEFAULT_LICENSE_TRACKING_BASE,
buildTrackDownloadPayload,
resolveTrackDownloadUrl, resolveTrackDownloadUrl,
trackDownload, trackDownload,
} from "./trackDownload"; } from "./trackDownload";
type BeaconCall = { url: string; blob: Blob };
function installNavigatorMock(sendBeaconImpl: () => boolean): {
calls: BeaconCall[];
} {
const calls: BeaconCall[] = [];
const sendBeacon = (url: string, data: Blob) => {
calls.push({ url, blob: data });
return sendBeaconImpl();
};
Object.defineProperty(globalThis, "navigator", {
value: { sendBeacon },
configurable: true,
});
return { calls };
}
describe("trackDownload", () => { describe("trackDownload", () => {
afterEach(() => { afterEach(() => {
mock.restoreAll(); mock.restoreAll();
@@ -26,12 +46,36 @@ describe("trackDownload", () => {
); );
}); });
it("POSTs platform with keepalive and swallows errors", async () => { it("builds JSON payload for platform", () => {
const fetchMock = mock.fn(() => Promise.reject(new Error("network down"))); assert.equal(
buildTrackDownloadPayload("linux"),
JSON.stringify({ platform: "linux" }),
);
});
it("sends platform via sendBeacon as text/plain", async () => {
const { calls } = installNavigatorMock(() => true);
const fetchMock = mock.fn(() => Promise.resolve(new Response()));
globalThis.fetch = fetchMock; globalThis.fetch = fetchMock;
trackDownload("macos"); trackDownload("macos");
assert.equal(calls.length, 1);
const call = calls[0];
assert.ok(call);
assert.equal(call.url, "https://license.ttrpgplayer.ru/v1/track/download");
assert.equal(call.blob.type.toLowerCase(), "text/plain;charset=utf-8");
assert.equal(await call.blob.text(), JSON.stringify({ platform: "macos" }));
assert.equal(fetchMock.mock.calls.length, 0);
});
it("falls back to fetch keepalive when sendBeacon returns false", async () => {
installNavigatorMock(() => false);
const fetchMock = mock.fn(() => Promise.reject(new Error("network down")));
globalThis.fetch = fetchMock;
trackDownload("windows");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchMock.mock.calls.length, 1); assert.equal(fetchMock.mock.calls.length, 1);
@@ -41,7 +85,9 @@ describe("trackDownload", () => {
assert.equal(url, "https://license.ttrpgplayer.ru/v1/track/download"); assert.equal(url, "https://license.ttrpgplayer.ru/v1/track/download");
assert.equal(init.method, "POST"); assert.equal(init.method, "POST");
assert.equal(init.keepalive, true); assert.equal(init.keepalive, true);
assert.deepEqual(init.headers, { "Content-Type": "application/json" }); assert.deepEqual(init.headers, {
assert.equal(init.body, JSON.stringify({ platform: "macos" })); "Content-Type": "text/plain;charset=UTF-8",
});
assert.equal(init.body, JSON.stringify({ platform: "windows" }));
}); });
}); });
+19 -3
View File
@@ -1,6 +1,7 @@
import type { PlatformId } from "../data/siteContent"; import type { PlatformId } from "../data/siteContent";
export const DEFAULT_LICENSE_TRACKING_BASE = "https://license.ttrpgplayer.ru/"; export const DEFAULT_LICENSE_TRACKING_BASE = "https://license.ttrpgplayer.ru/";
const TRACK_DOWNLOAD_CONTENT_TYPE = "text/plain;charset=UTF-8";
function normalizeBaseUrl(base: string): string { function normalizeBaseUrl(base: string): string {
return base.endsWith("/") ? base : `${base}/`; return base.endsWith("/") ? base : `${base}/`;
@@ -21,13 +22,28 @@ export function resolveTrackDownloadUrl(base?: string): string {
return `${normalizeBaseUrl(base ?? resolveLicenseTrackingBase())}v1/track/download`; return `${normalizeBaseUrl(base ?? resolveLicenseTrackingBase())}v1/track/download`;
} }
export function buildTrackDownloadPayload(platform: PlatformId): string {
return JSON.stringify({ platform });
}
export function trackDownload(platform: PlatformId): void { export function trackDownload(platform: PlatformId): void {
const url = resolveTrackDownloadUrl();
const body = buildTrackDownloadPayload(platform);
if (
typeof navigator !== "undefined" &&
typeof navigator.sendBeacon === "function"
) {
const blob = new Blob([body], { type: TRACK_DOWNLOAD_CONTENT_TYPE });
if (navigator.sendBeacon(url, blob)) return;
}
if (typeof fetch === "undefined") return; if (typeof fetch === "undefined") return;
void fetch(resolveTrackDownloadUrl(), { void fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": TRACK_DOWNLOAD_CONTENT_TYPE },
body: JSON.stringify({ platform }), body,
keepalive: true, keepalive: true,
}).catch(() => { }).catch(() => {
// fire-and-forget: ignore network errors // fire-and-forget: ignore network errors