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",
|
"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": {
|
||||||
|
|||||||
@@ -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" }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user