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",
"private": true,
"version": "1.0.3",
"version": "1.0.4",
"description": "Marketing landing for TTRPG Player (НРИ Плеер)",
"type": "module",
"scripts": {
+50 -4
View File
@@ -3,10 +3,30 @@ import { afterEach, describe, it, mock } from "node:test";
import {
DEFAULT_LICENSE_TRACKING_BASE,
buildTrackDownloadPayload,
resolveTrackDownloadUrl,
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", () => {
afterEach(() => {
mock.restoreAll();
@@ -26,12 +46,36 @@ describe("trackDownload", () => {
);
});
it("POSTs platform with keepalive and swallows errors", async () => {
const fetchMock = mock.fn(() => Promise.reject(new Error("network down")));
it("builds JSON payload for platform", () => {
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;
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));
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(init.method, "POST");
assert.equal(init.keepalive, true);
assert.deepEqual(init.headers, { "Content-Type": "application/json" });
assert.equal(init.body, JSON.stringify({ platform: "macos" }));
assert.deepEqual(init.headers, {
"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";
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 {
return base.endsWith("/") ? base : `${base}/`;
@@ -21,13 +22,28 @@ export function resolveTrackDownloadUrl(base?: string): string {
return `${normalizeBaseUrl(base ?? resolveLicenseTrackingBase())}v1/track/download`;
}
export function buildTrackDownloadPayload(platform: PlatformId): string {
return JSON.stringify({ platform });
}
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;
void fetch(resolveTrackDownloadUrl(), {
void fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform }),
headers: { "Content-Type": TRACK_DOWNLOAD_CONTENT_TYPE },
body,
keepalive: true,
}).catch(() => {
// fire-and-forget: ignore network errors