fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon for the tab-unload edge. Rename the JS module off telemetry/beacon to session/ lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Anonymous listener id interop (Phase 16 §3, wave 16.3, D5 Option A). Mints a random first-party GUID
|
||||
* on first visit, stores it in localStorage, and reads it back thereafter — one opaque token per
|
||||
* browser-install-until-cleared. No PII, no fingerprinting, no cross-site use: it is a "this browser,
|
||||
* until you clear it" token the server counts distinctly to estimate unique listeners.
|
||||
*
|
||||
* Degrades safely: if localStorage is unavailable (private mode, blocked, partitioned third-party
|
||||
* iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is
|
||||
* the known, accepted direction of error (§3).
|
||||
*
|
||||
* Exposed on window.DeepDrftAnonId; served from js/session/anonid.js, imported once in App.razor
|
||||
* alongside the audio engine and the unload-lifecycle module.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'deepdrft.anonId';
|
||||
|
||||
// crypto.randomUUID is the standard, secure source. A guarded fallback covers older/insecure-context
|
||||
// browsers where it is absent — still a random opaque token, not a fingerprint.
|
||||
function mint(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// RFC4122-ish fallback from getRandomValues; only reached on browsers lacking randomUUID.
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const DeepDrftAnonId = {
|
||||
/**
|
||||
* Read the stored anon id, minting and persisting one on first call. Returns null if localStorage
|
||||
* cannot be read or written (private mode / blocked / partitioned) — telemetry then omits the id.
|
||||
* Both the read and the write are guarded independently: a readable-but-unwritable store still mints
|
||||
* a fresh id each call (acceptable over-count) rather than throwing.
|
||||
*/
|
||||
get: (): string | null => {
|
||||
try {
|
||||
const existing = localStorage.getItem(STORAGE_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const minted = mint();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, minted);
|
||||
} catch {
|
||||
// Read worked, write did not — return the minted value anyway; it just won't persist.
|
||||
}
|
||||
return minted;
|
||||
} catch {
|
||||
// localStorage is entirely unavailable — send no anonId.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftAnonId: typeof DeepDrftAnonId;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftAnonId = DeepDrftAnonId;
|
||||
|
||||
export { DeepDrftAnonId };
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Page-lifecycle unload transport. A thin wrapper over navigator.sendBeacon for the single edge case where
|
||||
* an awaited fetch cannot run: the page is being torn down (tab close, navigation, bfcache freeze, mobile
|
||||
* backgrounding). It exposes a sendBeacon POST plus a page-unload handler that lets the player close an
|
||||
* open play session as the tab goes away. sendBeacon (not fetch) is the load-bearing choice here: it
|
||||
* survives page unload, where a fetch would be cancelled.
|
||||
*
|
||||
* Normal play closes (organic end / track-switch / stop) and all share events do NOT use this module —
|
||||
* they go over a first-party same-origin HttpClient POST from C#, which privacy/tracking heuristics do not
|
||||
* block. This module is named off the former telemetry/beacon path (DeepDrftLifecycle, served from
|
||||
* js/session/lifecycle.js) so even this retained unload fallback is not caught by name-based blockers.
|
||||
*
|
||||
* Exposed on window.DeepDrftLifecycle; imported once in App.razor alongside the audio engine.
|
||||
*/
|
||||
|
||||
// .NET interop type — a DotNetObjectReference the unload handler invokes back into.
|
||||
interface DotNetObjectReference {
|
||||
invokeMethodAsync(methodName: string, ...args: unknown[]): Promise<unknown>;
|
||||
invokeMethod(methodName: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
// Registered unload listeners. Holding the handler lets us detach on dispose so a torn-down player
|
||||
// circuit does not get called into.
|
||||
type UnloadEntry = { dotNetRef: DotNetObjectReference; methodName: string };
|
||||
const unloadHandlers = new Map<string, UnloadEntry>();
|
||||
|
||||
let unloadWired = false;
|
||||
|
||||
// Fire every registered unload handler synchronously. invokeMethod (sync) — not invokeMethodAsync — is
|
||||
// required here: in pagehide/visibilitychange→hidden the event loop will not pump a microtask before the
|
||||
// page is frozen, so an awaited call would never run. The .NET side does only synchronous beacon work.
|
||||
function fireUnloadHandlers(): void {
|
||||
for (const { dotNetRef, methodName } of unloadHandlers.values()) {
|
||||
try {
|
||||
dotNetRef.invokeMethod(methodName);
|
||||
} catch {
|
||||
// A torn-down circuit or a transient interop failure must never block unload.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireUnloadOnce(): void {
|
||||
if (unloadWired) return;
|
||||
unloadWired = true;
|
||||
|
||||
// pagehide is the canonical "page is going away" signal (covers tab close, navigation, and the
|
||||
// bfcache freeze). visibilitychange→hidden additionally covers the mobile case where the tab is
|
||||
// backgrounded and may be discarded without a pagehide. Both funnel to the same close path; the
|
||||
// .NET side is idempotent, so a double-fire closes the session at most once.
|
||||
window.addEventListener('pagehide', fireUnloadHandlers);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') fireUnloadHandlers();
|
||||
});
|
||||
}
|
||||
|
||||
const DeepDrftLifecycle = {
|
||||
/**
|
||||
* Queue a fire-and-forget sendBeacon POST of a small JSON body, for the page-unload edge only. Returns
|
||||
* false if the browser refused to queue the beacon (e.g. over the per-origin byte budget) — callers
|
||||
* ignore it; a dropped telemetry event is acceptable by design.
|
||||
*/
|
||||
send: (url: string, json: string): boolean => {
|
||||
try {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return navigator.sendBeacon(url, blob);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a .NET callback to run on page unload (and on visibility→hidden). Keyed so a given player
|
||||
* registers once and can replace/detach cleanly across its lifecycle.
|
||||
*/
|
||||
registerUnload: (key: string, dotNetRef: DotNetObjectReference, methodName: string): void => {
|
||||
wireUnloadOnce();
|
||||
unloadHandlers.set(key, { dotNetRef, methodName });
|
||||
},
|
||||
|
||||
/** Detach a previously-registered unload callback (player dispose). */
|
||||
unregisterUnload: (key: string): void => {
|
||||
unloadHandlers.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftLifecycle: typeof DeepDrftLifecycle;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftLifecycle = DeepDrftLifecycle;
|
||||
|
||||
export { DeepDrftLifecycle };
|
||||
Reference in New Issue
Block a user