diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..90af05c --- /dev/null +++ b/src/events.ts @@ -0,0 +1,30 @@ +type Callback = (data?: EventData) => void; +type EventData = unknown; + +export type Event = "beacon" | "new_page_id"; + +const subscribers: Partial> = {}; +const eventData: Partial> = {}; + +export function subscribe(event: Event, callback: Callback): void { + if (!subscribers[event]) { + subscribers[event] = []; + } + + subscribers[event].push(callback); + + // Ensure previous event data is available to new subscribers + if (eventData[event] !== undefined) { + callback(eventData[event]); + } +} + +export function emit(event: Event, data?: EventData): void { + eventData[event] = data; + + if (!subscribers[event]) { + return; + } + + subscribers[event].forEach((callback) => callback(data)); +} diff --git a/src/global.ts b/src/global.ts index 366fff1..bfeec76 100644 --- a/src/global.ts +++ b/src/global.ts @@ -1,8 +1,9 @@ -import { UserConfig } from "./config"; -import { LogEventRecord } from "./logger"; +import type { UserConfig } from "./config"; +import type { Event } from "./events"; +import type { LogEventRecord } from "./logger"; export type Command = [CommandFunction, ...CommandArg[]]; -type CommandFunction = "addData" | "init" | "mark" | "markLoadTime" | "measure" | "send"; +type CommandFunction = "addData" | "init" | "mark" | "markLoadTime" | "measure" | "on" | "send"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type CommandArg = any; type PerfMarkFn = typeof performance.mark; @@ -22,6 +23,7 @@ export interface LuxGlobal extends UserConfig { mark: (...args: Parameters) => ReturnType | void; markLoadTime?: (time?: number) => void; measure: (...args: Parameters) => ReturnType | void; + on: (event: Event, callback: (data?: unknown) => void) => void; /** Timestamp representing when the LUX snippet was evaluated */ ns?: number; send: () => void; diff --git a/src/lux.ts b/src/lux.ts index 533ddc3..045bbbd 100644 --- a/src/lux.ts +++ b/src/lux.ts @@ -12,6 +12,7 @@ import { SESSION_COOKIE_NAME } from "./cookie"; import * as CustomData from "./custom-data"; import { onVisible, isVisible, wasPrerendered, wasRedirected } from "./document"; import { getNodeSelector } from "./dom"; +import * as Events from "./events"; import Flags, { addFlag } from "./flags"; import { Command, LuxGlobal } from "./global"; import { getTrackingParams } from "./integrations/tracking"; @@ -1689,6 +1690,8 @@ LUX = (function () { function _sendBeacon(url: string) { new Image().src = url; + + Events.emit("beacon", url); } // INTERACTION METRICS @@ -1826,12 +1829,18 @@ LUX = (function () { // (because they get sent at different times). Each "page view" (including SPA) should have a // unique gSyncId. function createSyncId(inSampleBucket = false): string { + let syncId: string; + if (inSampleBucket) { // "00" matches all sample rates - return Number(new Date()) + "00000"; + syncId = Number(new Date()) + "00000"; + } else { + syncId = Number(new Date()) + padStart(String(round(100000 * Math.random())), 5, "0"); } - return Number(new Date()) + padStart(String(round(100000 * Math.random())), 5, "0"); + Events.emit("new_page_id", syncId); + + return syncId; } // Unique ID (also known as Session ID) @@ -2031,6 +2040,7 @@ LUX = (function () { globalLux.measure = _measure; globalLux.init = _init; globalLux.markLoadTime = _markLoadTime; + globalLux.on = Events.subscribe; globalLux.send = () => { logger.logEvent(LogEvent.SendCalled); beacon.send(); diff --git a/src/snippet.ts b/src/snippet.ts index 1d5c996..ad97ffb 100644 --- a/src/snippet.ts +++ b/src/snippet.ts @@ -1,4 +1,4 @@ -import { Command, LuxGlobal } from "./global"; +import type { Command, LuxGlobal } from "./global"; import { performance } from "./performance"; import scriptStartTime from "./start-marker"; import { msSinceNavigationStart } from "./timing"; @@ -20,6 +20,7 @@ LUX.init = () => LUX.cmd(["init"]); LUX.mark = _mark; LUX.markLoadTime = () => LUX.cmd(["markLoadTime", msSinceNavigationStart()]); LUX.measure = _measure; +LUX.on = (event, callback) => LUX.cmd(["on", event, callback]); LUX.send = () => LUX.cmd(["send"]); LUX.ns = scriptStartTime; diff --git a/tests/integration/events.spec.ts b/tests/integration/events.spec.ts new file mode 100644 index 0000000..0121c10 --- /dev/null +++ b/tests/integration/events.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; +import { getSearchParam } from "../helpers/lux"; +import RequestInterceptor from "../request-interceptor"; + +test.describe("LUX events", () => { + test("new_page_id", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto( + "/default.html?injectScript=LUX.auto=false;LUX.on('new_page_id', (id) => window.page_id = id);", + { waitUntil: "networkidle" }, + ); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + + const firstPageId = await page.evaluate(() => window.page_id); + const firstBeacon = luxRequests.getUrl(0)!; + expect(firstPageId).toEqual(getSearchParam(firstBeacon, "sid")); + + await luxRequests.waitForMatchingRequest(() => + page.evaluate(() => { + LUX.init(); + LUX.send(); + }), + ); + + const secondBeacon = luxRequests.getUrl(1)!; + const secondPageId = await page.evaluate(() => window.page_id); + expect(secondPageId).toEqual(getSearchParam(secondBeacon, "sid")); + }); + + test("beacon", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto( + "/default.html?injectScript=LUX.auto=false;LUX.on('beacon', (url) => window.beacon_url = url);", + { waitUntil: "networkidle" }, + ); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + + const beacon = luxRequests.getUrl(0)!; + let beaconUrl = await page.evaluate(() => window.beacon_url); + + // We don't encode the Delivery Type parameter before sending the beacon, but Chromium seems to + // check that everything is encoded before making the actual request. This is a small hack to + // allow us to compare the strings. + beaconUrl = beaconUrl.replace("dt(empty string)_", "dt(empty%20string)_"); + + expect(beaconUrl).toEqual(beacon.href); + }); +}); diff --git a/tests/integration/unload.spec.ts b/tests/integration/unload.spec.ts index 041b056..146de3d 100644 --- a/tests/integration/unload.spec.ts +++ b/tests/integration/unload.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from "@playwright/test"; import Flags from "../../src/flags"; +import { setPageHidden } from "../helpers/browsers"; import { hasFlag } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; -import { setPageHidden } from "../helpers/browsers"; test.describe("LUX unload behaviour", () => { test("not automatically sending a beacon when the user navigates away from a page with LUX.auto = false", async ({