Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/__tests__/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
});
32 changes: 32 additions & 0 deletions src/__tests__/context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()", () => {
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/publisher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
40 changes: 40 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -248,7 +248,7 @@ export default class Context {
return this._dataProvider;
}

publish(requestOptions?: ClientRequestOptions) {
publish(requestOptions?: PublishOptions) {
this._checkReady(true);

return new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}