fix(analytics): send download stats via sendBeacon (v1.0.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user