diff --git a/src/__tests__/client.test.js b/src/__tests__/client.test.js index 05b6615..4aacbb7 100644 --- a/src/__tests__/client.test.js +++ b/src/__tests__/client.test.js @@ -976,4 +976,170 @@ describe("Client", () => { done(); }); }); + + describe("publishBeacon", () => { + it("returns false when navigator is undefined", () => { + const client = new Client(clientOptions); + const originalNavigator = global.navigator; + delete global.navigator; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + }); + + expect(result).toBe(false); + global.navigator = originalNavigator; + }); + + it("returns false when sendBeacon is not a function", () => { + const client = new Client(clientOptions); + const originalNavigator = global.navigator; + global.navigator = {}; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + }); + + expect(result).toBe(false); + global.navigator = originalNavigator; + }); + + it("sends beacon with auth in body", async () => { + const client = new Client(clientOptions); + const sendBeaconMock = jest.fn().mockReturnValue(true); + const originalNavigator = global.navigator; + global.navigator = { + sendBeacon: sendBeaconMock, + }; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + }); + + expect(result).toBe(true); + expect(sendBeaconMock).toHaveBeenCalledTimes(1); + expect(sendBeaconMock).toHaveBeenCalledWith( + `${endpoint}/context`, + expect.any(Blob) + ); + + const callArgs = sendBeaconMock.mock.calls[0]; + const blob = callArgs[1]; + expect(blob.type).toBe("application/json"); + + const text = await blob.text(); + const payload = JSON.parse(text); + expect(payload).toEqual({ + units, + hashed: true, + publishedAt, + apiKey, + agent, + environment, + application: "test_app", + applicationVersion: 1000000, + }); + + global.navigator = originalNavigator; + }); + + it("includes goals, exposures, and attributes when provided", async () => { + const client = new Client(clientOptions); + const sendBeaconMock = jest.fn().mockReturnValue(true); + const originalNavigator = global.navigator; + global.navigator = { + sendBeacon: sendBeaconMock, + }; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + goals, + exposures, + attributes, + }); + + expect(result).toBe(true); + expect(sendBeaconMock).toHaveBeenCalledTimes(1); + + const callArgs = sendBeaconMock.mock.calls[0]; + const blob = callArgs[1]; + + const text = await blob.text(); + const payload = JSON.parse(text); + expect(payload).toEqual({ + units, + hashed: true, + publishedAt, + apiKey, + agent, + environment, + application: "test_app", + applicationVersion: 1000000, + goals, + exposures, + attributes, + }); + + global.navigator = originalNavigator; + }); + + it("excludes empty arrays for goals, exposures, and attributes", async () => { + const client = new Client(clientOptions); + const sendBeaconMock = jest.fn().mockReturnValue(true); + const originalNavigator = global.navigator; + global.navigator = { + sendBeacon: sendBeaconMock, + }; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + goals: [], + exposures: [], + attributes: [], + }); + + expect(result).toBe(true); + + const callArgs = sendBeaconMock.mock.calls[0]; + const blob = callArgs[1]; + + const text = await blob.text(); + const payload = JSON.parse(text); + expect(payload.goals).toBeUndefined(); + expect(payload.exposures).toBeUndefined(); + expect(payload.attributes).toBeUndefined(); + + global.navigator = originalNavigator; + }); + + it("returns false when sendBeacon returns false", () => { + const client = new Client(clientOptions); + const sendBeaconMock = jest.fn().mockReturnValue(false); + const originalNavigator = global.navigator; + global.navigator = { + sendBeacon: sendBeaconMock, + }; + + const result = client.publishBeacon({ + units, + hashed: true, + publishedAt, + }); + + expect(result).toBe(false); + expect(sendBeaconMock).toHaveBeenCalledTimes(1); + + global.navigator = originalNavigator; + }); + }); }); diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 0dc3f89..0e200dc 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -3274,6 +3274,38 @@ describe("Context", () => { expect(context.isFinalizing()).toEqual(true); expect(() => context.publish()).toThrow(); }); + + it("should pass useBeacon option to publisher", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish({ useBeacon: true }).then(() => { + expect(publisher.publish).toHaveBeenCalledTimes(1); + const callArgs = publisher.publish.mock.calls[0]; + expect(callArgs[3]).toEqual({ useBeacon: true }); + done(); + }); + }); + + it("should work with useBeacon option and other request options", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish({ useBeacon: true, timeout: 5000 }).then(() => { + expect(publisher.publish).toHaveBeenCalledTimes(1); + const callArgs = publisher.publish.mock.calls[0]; + expect(callArgs[3]).toEqual({ useBeacon: true, timeout: 5000 }); + done(); + }); + }); }); describe("finalize()", () => { diff --git a/src/__tests__/publisher.test.js b/src/__tests__/publisher.test.js index 4d59dbe..68819a7 100644 --- a/src/__tests__/publisher.test.js +++ b/src/__tests__/publisher.test.js @@ -52,5 +52,77 @@ describe("ContextPublisher", () => { expect(resp).toBe(data); }); }); + + it("should use publishBeacon when useBeacon is true and beacon succeeds", async () => { + const publisher = new ContextPublisher(); + + client.publishBeacon.mockReturnValue(true); + + const request = { test: 1 }; + const result = await publisher.publish(request, sdk, context, { useBeacon: true }); + + expect(client.publishBeacon).toHaveBeenCalledTimes(1); + expect(client.publishBeacon).toHaveBeenCalledWith(request); + expect(client.publish).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("should fallback to regular publish when useBeacon is true but beacon fails", async () => { + const publisher = new ContextPublisher(); + + const data = { ok: true }; + client.publishBeacon.mockReturnValue(false); + client.publish.mockReturnValue(Promise.resolve(data)); + + const request = { test: 1 }; + const result = publisher.publish(request, sdk, context, { useBeacon: true, timeout: 1234 }); + + expect(client.publishBeacon).toHaveBeenCalledTimes(1); + expect(client.publishBeacon).toHaveBeenCalledWith(request); + expect(result).toBeInstanceOf(Promise); + expect(client.publish).toHaveBeenCalledTimes(1); + expect(client.publish).toHaveBeenCalledWith(request, { useBeacon: true, timeout: 1234 }); + + const resp = await result; + expect(resp).toBe(data); + }); + + it("should use regular publish when useBeacon is false", async () => { + const publisher = new ContextPublisher(); + + const data = { ok: true }; + client.publish.mockReturnValue(Promise.resolve(data)); + + const request = { test: 1 }; + const result = publisher.publish(request, sdk, context, { useBeacon: false }); + + expect(client.publishBeacon).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Promise); + expect(client.publish).toHaveBeenCalledTimes(1); + expect(client.publish).toHaveBeenCalledWith(request, { useBeacon: false }); + + result.then((resp) => { + expect(resp).toBe(data); + }); + }); + + it("should use regular publish when useBeacon is not specified", async () => { + const publisher = new ContextPublisher(); + + const data = { ok: true }; + client.publish.mockReturnValue(Promise.resolve(data)); + + const request = { test: 1 }; + const result = publisher.publish(request, sdk, context); + + expect(client.publishBeacon).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Promise); + expect(client.publish).toHaveBeenCalledTimes(1); + expect(client.publish).toHaveBeenCalledWith(request, undefined); + + result.then((resp) => { + expect(resp).toBe(data); + }); + }); }); }); diff --git a/src/client.ts b/src/client.ts index 2a1325f..28ff91e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -132,6 +132,46 @@ export default class Client { }); } + publishBeacon(params: PublishParams): boolean { + if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") { + return false; + } + + const body: PublishParams & { + apiKey?: string; + agent?: string; + environment?: string; + application?: string; + applicationVersion?: number; + } = { + units: params.units, + hashed: params.hashed, + publishedAt: params.publishedAt || Date.now(), + apiKey: this._opts.apiKey, + agent: this._opts.agent, + environment: this._opts.environment, + application: getApplicationName(this._opts.application), + applicationVersion: getApplicationVersion(this._opts.application), + }; + + if (Array.isArray(params.goals) && params.goals.length > 0) { + body.goals = params.goals; + } + + if (Array.isArray(params.exposures) && params.exposures.length > 0) { + body.exposures = params.exposures; + } + + if (Array.isArray(params.attributes) && params.attributes.length > 0) { + body.attributes = params.attributes; + } + + const url = `${this._opts.endpoint}/context`; + const blob = new Blob([JSON.stringify(body)], { type: "application/json" }); + + return navigator.sendBeacon(url, blob); + } + request(options: ClientRequestOptions) { let url = `${this._opts.endpoint}${options.path}`; if (options.query) { diff --git a/src/context.ts b/src/context.ts index 424a0b4..25d1984 100644 --- a/src/context.ts +++ b/src/context.ts @@ -3,7 +3,7 @@ import { VariantAssigner } from "./assigner"; import { AudienceMatcher } from "./matcher"; import { insertUniqueSorted } from "./algorithm"; import SDK, { EventLogger, EventName } from "./sdk"; -import { ContextPublisher, PublishParams } from "./publisher"; +import { ContextPublisher, PublishParams, PublishOptions } from "./publisher"; import { ContextDataProvider } from "./provider"; import { ClientRequestOptions } from "./client"; @@ -248,7 +248,7 @@ export default class Context { return this._dataProvider; } - publish(requestOptions?: ClientRequestOptions) { + publish(requestOptions?: PublishOptions) { this._checkReady(true); return new Promise((resolve, reject) => { @@ -785,7 +785,7 @@ export default class Context { } } - private _flush(callback?: (error?: Error) => void, requestOptions?: ClientRequestOptions) { + private _flush(callback?: (error?: Error) => void, requestOptions?: PublishOptions) { if (this._publishTimeout !== undefined) { clearTimeout(this._publishTimeout); delete this._publishTimeout; diff --git a/src/publisher.ts b/src/publisher.ts index 8e0cc64..fadcdb2 100644 --- a/src/publisher.ts +++ b/src/publisher.ts @@ -11,8 +11,18 @@ export type PublishParams = { exposures?: Exposure[]; }; +export type PublishOptions = ClientRequestOptions & { + useBeacon?: boolean; +}; + export class ContextPublisher { - publish(request: PublishParams, sdk: SDK, _: Context, requestOptions?: ClientRequestOptions) { + publish(request: PublishParams, sdk: SDK, _: Context, requestOptions?: PublishOptions) { + if (requestOptions?.useBeacon) { + const success = sdk.getClient().publishBeacon(request); + if (success) { + return Promise.resolve(); + } + } return sdk.getClient().publish(request, requestOptions); } }