Files
deepdrft/DeepDrftPublic/Interop/session/lifecycle.ts
T
daniel-c-harvey 2af0d8650b 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.
2026-06-26 21:11:43 -04:00

95 lines
4.1 KiB
TypeScript

/**
* 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 };