From 5673e6f9d59a0fc5d361dc446f9573748dcbbde4 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 7 Jul 2026 20:00:59 +0800 Subject: [PATCH] fix(analytics): send download stats via sendBeacon (v1.0.4) Co-authored-by: Cursor --- package.json | 2 +- src/lib/trackDownload.test.ts | 54 ++++++++++++++++++++++++++++++++--- src/lib/trackDownload.ts | 22 ++++++++++++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index d9d6864..d0c7bf9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/lib/trackDownload.test.ts b/src/lib/trackDownload.test.ts index 545ed34..3dbebd4 100644 --- a/src/lib/trackDownload.test.ts +++ b/src/lib/trackDownload.test.ts @@ -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" })); }); }); diff --git a/src/lib/trackDownload.ts b/src/lib/trackDownload.ts index 6ffcbca..5da4707 100644 --- a/src/lib/trackDownload.ts +++ b/src/lib/trackDownload.ts @@ -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