diff --git a/@types/global.d.ts b/@types/global.d.ts index d0048f8..da3e08c 100644 --- a/@types/global.d.ts +++ b/@types/global.d.ts @@ -3,7 +3,7 @@ export {}; declare global { interface Window { TS: { - token: string; + token?: string; url?: string; cookieName?: string; getUserId?: () => string; diff --git a/README.md b/README.md index e427171..6f8d892 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,13 @@ window.TS = { import "@topsort/analytics.js"; ``` -The library will automatically start listening for DOM changes and user interactions once it's imported. +The library will automatically start listening for DOM changes and user interactions once it's imported. If the token is not provided at initialization, events are queued until the token is set. To flush the queue, assign the token via **property assignment**: + +```js +window.TS.token = ""; +``` + +> **Important:** Do **not** replace the entire `window.TS` object after the library has loaded (e.g. `window.TS = { token: "..." }`). The library installs an internal setter on the existing `window.TS` reference to detect when the token becomes available. Replacing the object destroys that setter and the queued events will never be sent. ### Option 2: With a Local Script File diff --git a/demo/index.html b/demo/index.html index 4015d58..92abccc 100644 --- a/demo/index.html +++ b/demo/index.html @@ -400,6 +400,23 @@ background: #ffebee; } + .event-entry .api-status.queued { + color: #7c4a00; + background: #fff3cd; + } + + .no-token-notice { + display: none; + padding: 10px 16px; + background: #fff8e1; + border-bottom: 1px solid #ffe082; + font-size: 12px; + color: #7c4a00; + line-height: 1.5; + } + + .no-token-notice.visible { display: block; } + /* Snippet */ #snippet-area { padding: 16px; @@ -448,10 +465,11 @@

analytics.js

Configuration
- + +

Leave empty to start without a token — events will queue and flush once a token is set.

- +

Leave empty for production. Use a staging URL for testing.

@@ -533,6 +551,9 @@

Event Log

Captured topsort CustomEvents 0 +
+ No API token set — events are queued locally and won't be sent to the API. Enter a token in the configuration panel to flush the queue. +
Events will appear here as they are detected by analytics.js.
@@ -555,6 +576,7 @@

Code Snippet

const form = document.getElementById("config-form"); const previewArea = document.getElementById("preview-area"); const eventLog = document.getElementById("event-log"); + const noTokenNotice = document.getElementById("no-token-notice"); const snippetCode = document.getElementById("snippet-code"); const elementCountBadge = document.getElementById("element-count"); const eventCountBadge = document.getElementById("event-count"); @@ -582,7 +604,7 @@

Code Snippet

if (!entry) return; const badge = entry.querySelector(".api-status"); if (!badge) return; - badge.classList.remove("pending"); + badge.classList.remove("pending", "queued"); if (ok) { badge.classList.add("ok"); badge.textContent = `Success (${status})`; @@ -687,11 +709,12 @@

Code Snippet

detailText = parts.join(", ") || "no product/bid"; } + const hasToken = !!(window.TS && window.TS.token); entry.innerHTML = ` ${escapeHtml(time)} ${escapeHtml(detail.type)} ${escapeHtml(detailText)} - Sending… + ${hasToken ? "Sending…" : "Queued (no token)"} `; eventLog.prepend(entry); @@ -700,19 +723,17 @@

Code Snippet

} function ensureAnalyticsLoaded() { - if (analyticsLoaded) return; + if (analyticsLoaded) return true; const token = document.getElementById("token").value.trim(); - if (!token) { - alert("API Token is required to initialize analytics.js."); - return false; - } - const url = document.getElementById("api-url").value.trim(); - window.TS = { token }; + window.TS = {}; + if (token) window.TS.token = token; if (url) window.TS.url = url; + if (!token) noTokenNotice.classList.add("visible"); + patchFetchForEventApiStatus(); // Load the analytics.js script @@ -875,11 +896,24 @@

Code Snippet

eventCountBadge.textContent = "0"; snippetCode.textContent = ""; + noTokenNotice.classList.remove("visible"); + // Reset analytics.js so it can re-observe new elements if (window.TS) window.TS.loaded = false; analyticsLoaded = false; }); + // When the token field is updated after the library is already loaded, + // set it directly on window.TS so the watchForToken setter fires and drains the queue. + document.getElementById("token").addEventListener("change", () => { + if (!analyticsLoaded) return; + const token = document.getElementById("token").value.trim(); + if (token) { + window.TS.token = token; + noTokenNotice.classList.remove("visible"); + } + }); + // Initialize snippet updateSnippet(); diff --git a/src/detector.bootstrap.test.ts b/src/detector.bootstrap.test.ts index 13a3d55..f1665c9 100644 --- a/src/detector.bootstrap.test.ts +++ b/src/detector.bootstrap.test.ts @@ -32,10 +32,12 @@ describe("detector bootstrap", () => { expect(observe).not.toHaveBeenCalled(); }); - test("logs when token is missing", async () => { + test("starts observing and installs token watcher when token is missing", async () => { const observe = vi.fn(); class MutationObserverMock { observe = observe; + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); } Object.defineProperty(window, "MutationObserver", { configurable: true, @@ -43,12 +45,16 @@ describe("detector bootstrap", () => { value: MutationObserverMock, }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); window.TS = {} as typeof window.TS; await import("./detector"); - expect(error).toHaveBeenCalledWith("Missing TS token"); - expect(observe).not.toHaveBeenCalled(); + + // DOM observation starts even without a token + expect(observe).toHaveBeenCalled(); + // A getter/setter is installed on window.TS.token so setting it later drains the queue + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); }); test("registers DOMContentLoaded listener when document is loading", async () => { diff --git a/src/detector.deferred-token.test.ts b/src/detector.deferred-token.test.ts new file mode 100644 index 0000000..649ba0b --- /dev/null +++ b/src/detector.deferred-token.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + document.cookie = ""; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("captures impressions before token is set", async () => { + window.TS = {} as typeof window.TS; + document.body.innerHTML = '
'; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + expect(events).toHaveLength(1); + expect(events[0]?.detail.type).toBe("Impression"); + expect(events[0]?.detail.product).toBe("prod-1"); +}); + +test("captures clicks before token is set", async () => { + window.TS = {} as typeof window.TS; + document.body.innerHTML = '
'; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + const card = document.querySelector("[data-ts-product]"); + card?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const clickEvent = events.find((e) => e.detail.type === "Click"); + expect(clickEvent).toBeDefined(); + expect(clickEvent?.detail.product).toBe("prod-click"); +}); + +test("observes dynamically added elements before token is set", async () => { + window.TS = {} as typeof window.TS; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + const div = document.createElement("div"); + div.dataset.tsProduct = "prod-dynamic"; + document.body.appendChild(div); + await new Promise(process.nextTick); + + expect(events.some((e) => e.detail.product === "prod-dynamic")).toBe(true); +}); + +test("installs getter/setter on window.TS.token when no token is provided", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); + expect(descriptor?.configurable).toBe(true); +}); + +test("does not install token watcher when token is already present", async () => { + window.TS = { token: "existing-token" }; + await import("./detector"); + + // Plain data property — no getter/setter installed + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(descriptor?.get).toBeUndefined(); + expect(descriptor?.value).toBe("existing-token"); +}); + +test("setting the token via watcher makes it readable through the getter", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + window.TS.token = "deferred-token"; + + expect(window.TS.token).toBe("deferred-token"); +}); + +test("setting the token twice updates the value", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + window.TS.token = "token-v1"; + window.TS.token = "token-v2"; + + expect(window.TS.token).toBe("token-v2"); +}); diff --git a/src/detector.ts b/src/detector.ts index 14f2302..7361482 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -130,6 +130,9 @@ function getApiPayload(event: ProductEvent): TopsortEvent { // TODO: batch requests. Unfortunately at the moment only the impressions are batchable. async function processor(data: ProductEvent[]): Promise { + if (!window.TS.token) { + return { done: new Set(), retry: new Set(), pause: true }; + } const r: ProcessorResult = { done: new Set(), retry: new Set(), @@ -337,15 +340,37 @@ function mutationCallback(mutationsList: MutationRecord[]) { } } +function watchForToken(): void { + // NOTE: This setter is installed on the *current* window.TS object reference. + // If the user replaces the entire object (window.TS = { token: "abc" }) the setter + // is lost and the queue will never be drained. Users must assign to the property + // directly: window.TS.token = "abc". + const ts = window.TS; + let token: string | undefined; + Object.defineProperty(ts, "token", { + get(): string | undefined { + return token; + }, + set(value: string) { + token = value; + if (value) { + queue.drain(); + } + }, + configurable: true, + }); + console.warn( + "[Topsort] No API token found. Events will be queued until window.TS.token is set. " + + "Use property assignment (window.TS.token = ''), not object replacement " + + "(window.TS = { token: '' }), or the queue will not be drained.", + ); +} + function start() { if (window.TS?.loaded) { return; } window.TS.loaded = true; - if (!window.TS?.token) { - console.error("Missing TS token"); - return; - } checkChildren(document); const MutationObserverImpl = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; @@ -356,6 +381,9 @@ function start() { subtree: true, attributeFilter: ["data-ts-product", "data-ts-action", "data-ts-items", "data-ts-resolved-bid"], }); + if (!window.TS.token) { + watchForToken(); + } } if (/complete|interactive|loaded/.test(document.readyState)) { diff --git a/src/queue.test.ts b/src/queue.test.ts index 6ff2fc6..896a13c 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -146,3 +146,97 @@ test("marks entries done when processor throws", async () => { expect(failingProcessor).toHaveBeenCalledWith([entry]); expectEmptyQueue(q); }); + +test("pause keeps entries in queue with retry count unchanged and no scheduling", async () => { + const paused = true; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-paused", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + // Entry is still in queue with retry count = 0 (not incremented) + const stored = ( + q as unknown as { _store: { get: () => Array<{ e: Entry; r: number }> } } + )._store.get(); + expect(stored).toHaveLength(1); + expect(stored[0]?.r).toBe(0); + // Released from _processing so drain() can pick it up + expect((q as unknown as { _processing: Set })._processing.size).toBe(0); + // No timer scheduled + expect((q as unknown as { _scheduled: boolean })._scheduled).toBe(false); +}); + +test("drain() triggers immediate processing after pause", async () => { + let paused = true; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-drain", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + expect(processedEvents).toEqual([]); + + paused = false; + q.drain(); + await flushPromises(); + + expect(processedEvents).toEqual([entry]); + expectEmptyQueue(q); +}); + +test("pause does not consume retry budget", async () => { + let paused = true; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-budget", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + expect(pausingProcessor).toHaveBeenCalledTimes(1); + + // Pause multiple times via drain — each call re-enters the processor without incrementing r + q.drain(); + await flushPromises(); + q.drain(); + await flushPromises(); + + expect(pausingProcessor).toHaveBeenCalledTimes(3); + + // Retry count stays 0 — budget is intact + const stored = ( + q as unknown as { _store: { get: () => Array<{ e: Entry; r: number }> } } + )._store.get(); + expect(stored).toHaveLength(1); + expect(stored[0]?.r).toBe(0); + + // Now resume — entry should be processed successfully on first non-pause attempt + paused = false; + q.drain(); + await flushPromises(); + + expect(pausingProcessor).toHaveBeenCalledTimes(4); + expect(processedEvents).toEqual([entry]); + expectEmptyQueue(q); +}); diff --git a/src/queue.ts b/src/queue.ts index b560368..93cc656 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -10,6 +10,12 @@ const WAIT_TIME_MS = 250; export interface ProcessorResult { done: Set; retry: Set; + /** + * When true, the processor is not ready to handle events yet (e.g. no API token). + * All events in the current chunk are released back to the queue without incrementing + * their retry counter, and no further processing is scheduled until {@link Queue.drain} is called. + */ + pause?: boolean; } export interface Entry { @@ -80,6 +86,11 @@ export class Queue { this._processor = processor; } + /** Force-trigger processing immediately, bypassing the scheduled delay. */ + drain(): void { + this._processNow(this._store.get()); + } + append(entry: Entry, opts?: { highPriority: boolean }): void { let entries = this._store.get(); entries.push({ @@ -122,6 +133,14 @@ export class Queue { r.done.add(entry.id); } } + if (r.pause) { + // Processor is not ready — release chunk back to queue without retrying or scheduling. + // Processing resumes only when drain() is called externally (e.g. once a token is set). + for (const entry of chunk) { + this._processing.delete(entry.id); + } + return; + } // Remove entries from processing const newProcessing: string[] = []; for (const entryId of this._processing) {