Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add react native web support #13

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
3,665 changes: 2,817 additions & 848 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@
"aptabase-react-native.podspec"
],
"devDependencies": {
"@vitest/coverage-v8": "0.34.3",
"@types/react": "18.2.22",
"@types/node": "20.5.9",
"@types/react": "18.2.22",
"@vitest/coverage-v8": "2.1.8",
"tsup": "7.2.0",
"vite": "4.4.9",
"vitest": "0.34.3",
"vitest-fetch-mock": "0.2.2"
"vite": "6.0.3",
"vitest": "2.1.8",
"vitest-fetch-mock": "0.4.2"
},
"peerDependencies": {
"react": "*",
Expand Down
13 changes: 9 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Platform } from "react-native";
import { Platform } from "react-native";
import type { AptabaseOptions } from "./types";
import type { EnvironmentInfo } from "./env";
import { EventDispatcher } from "./dispatcher";
import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
import { newSessionId } from "./session";
import { HOSTS, SESSION_TIMEOUT } from "./constants";

export class AptabaseClient {
private readonly _dispatcher: EventDispatcher;
private readonly _dispatcher: WebEventDispatcher | NativeEventDispatcher;
private readonly _env: EnvironmentInfo;
private _sessionId = newSessionId();
private _lastTouched = new Date();
Expand All @@ -21,7 +21,12 @@ export class AptabaseClient {
this._env.appVersion = options.appVersion;
}

this._dispatcher = new EventDispatcher(appKey, baseUrl, env);
const dispatcher =
Platform.OS === "web"
? new WebEventDispatcher(appKey, baseUrl, env)
: new NativeEventDispatcher(appKey, baseUrl, env);

Comment on lines +24 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we've had trouble in the past with automatically detecting environments
(like in aptabase/aptabase-swift#24 aptabase/aptabase-swift#27 )

I'm a bit reluctant to go this same path.

@Robert27 what are your thoughts and enabling the WebEventDispatcher only if a new, explicit option is set in options: AptabaseOptions

Would avoid breaking existing usages that simply upgrade, and would enable new integrations to explicitly opt in.

this._dispatcher = dispatcher;
}

public trackEvent(
Expand Down
70 changes: 65 additions & 5 deletions src/dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "vitest-fetch-mock";
import { EventDispatcher } from "./dispatcher";
import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
import { beforeEach, describe, expect, it } from "vitest";
import { EnvironmentInfo } from "./env";
import type { EnvironmentInfo } from "./env";

const env: EnvironmentInfo = {
isDebug: false,
Expand Down Expand Up @@ -32,11 +32,11 @@ const expectEventsCount = async (
expect(body.length).toEqual(expectedNumOfEvents);
};

describe("EventDispatcher", () => {
let dispatcher: EventDispatcher;
describe("NativeEventDispatcher", () => {
let dispatcher: NativeEventDispatcher;

beforeEach(() => {
dispatcher = new EventDispatcher(
dispatcher = new NativeEventDispatcher(
"A-DEV-000",
"https://localhost:3000",
env
Expand Down Expand Up @@ -138,3 +138,63 @@ describe("EventDispatcher", () => {
expectRequestCount(1);
});
});

describe("WebEventDispatcher", () => {
let dispatcher: WebEventDispatcher;

beforeEach(() => {
dispatcher = new WebEventDispatcher(
"A-DEV-000",
"https://localhost:3000",
env
);
fetchMock.resetMocks();
});

it("should send event with correct headers", async () => {
dispatcher.enqueue(createEvent("app_started"));

const request = await fetchMock.requests().at(0);
expect(request).not.toBeUndefined();
expect(request?.url).toEqual("https://localhost:3000/api/v0/event");
expect(request?.headers.get("Content-Type")).toEqual("application/json");
expect(request?.headers.get("App-Key")).toEqual("A-DEV-000");
});

it("should dispatch single event", async () => {
fetchMock.mockResponseOnce("{}");

dispatcher.enqueue(createEvent("app_started"));

expectRequestCount(1);
const body = await fetchMock.requests().at(0)?.json();
expect(body.eventName).toEqual("app_started");
});

it("should dispatch multiple events individually", async () => {
fetchMock.mockResponseOnce("{}");
fetchMock.mockResponseOnce("{}");

dispatcher.enqueue([createEvent("app_started"), createEvent("app_exited")]);

expectRequestCount(2);
const body1 = await fetchMock.requests().at(0)?.json();
const body2 = await fetchMock.requests().at(1)?.json();
expect(body1.eventName).toEqual("app_started");
expect(body2.eventName).toEqual("app_exited");
});

it("should not retry requests that failed with 4xx", async () => {
fetchMock.mockResponseOnce("{}", { status: 400 });

dispatcher.enqueue(createEvent("hello_world"));

expectRequestCount(1);
const body = await fetchMock.requests().at(0)?.json();
expect(body.eventName).toEqual("hello_world");

dispatcher.enqueue(createEvent("hello_world"));

expectRequestCount(2);
});
});
88 changes: 72 additions & 16 deletions src/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Event } from "./types";
import { EnvironmentInfo } from "./env";
import type { EnvironmentInfo } from "./env";

export class EventDispatcher {
private _events: Event[] = [];
private MAX_BATCH_SIZE = 25;
private headers: Headers;
private apiUrl: string;
export abstract class EventDispatcher {
protected _events: Event[] = [];
protected MAX_BATCH_SIZE = 25;
protected headers: Headers;
protected apiUrl: string;

constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
this.apiUrl = `${baseUrl}/api/v0/events`;
Expand All @@ -16,14 +16,7 @@ export class EventDispatcher {
});
}

public enqueue(evt: Event | Event[]) {
if (Array.isArray(evt)) {
this._events.push(...evt);
return;
}

this._events.push(evt);
}
public abstract enqueue(evt: Event | Event[]): void;

public async flush(): Promise<void> {
if (this._events.length === 0) {
Expand All @@ -45,7 +38,7 @@ export class EventDispatcher {
}
}

private async _sendEvents(events: Event[]): Promise<void> {
protected async _sendEvents(events: Event[]): Promise<void> {
try {
const res = await fetch(this.apiUrl, {
method: "POST",
Expand All @@ -54,7 +47,7 @@ export class EventDispatcher {
body: JSON.stringify(events),
});

if (res.status < 300) {
if (res.ok) {
return Promise.resolve();
}

Expand All @@ -74,4 +67,67 @@ export class EventDispatcher {
throw e;
}
}

protected async _sendEvent(event: Event): Promise<void> {
try {
const res = await fetch(this.apiUrl, {
method: "POST",
headers: this.headers,
credentials: "omit",
body: JSON.stringify(event),
});

if (res.ok) {
return Promise.resolve();
}

const reason = `${res.status} ${await res.text()}`;
if (res.status < 500) {
console.warn(
`Aptabase: Failed to send event because of ${reason}. Will not retry.`
);
return Promise.resolve();
}

throw new Error(reason);
} catch (e) {
console.error(`Aptabase: Failed to send event. Reason: ${e}`);
throw e;
}
}
}

export class WebEventDispatcher extends EventDispatcher {
constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
super(appKey, baseUrl, env);
this.apiUrl = `${baseUrl}/api/v0/event`;
this.headers = new Headers({
"Content-Type": "application/json",
"App-Key": appKey,
// No User-Agent header for web
});
}

public enqueue(evt: Event | Event[]): void {
if (Array.isArray(evt)) {
evt.forEach((event) => this._sendEvent(event));
} else {
this._sendEvent(evt);
}
}
}

export class NativeEventDispatcher extends EventDispatcher {
constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
super(appKey, baseUrl, env);
this.apiUrl = `${baseUrl}/api/v0/events`;
}

public enqueue(evt: Event | Event[]): void {
if (Array.isArray(evt)) {
this._events.push(...evt);
} else {
this._events.push(evt);
}
}
}
36 changes: 19 additions & 17 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,38 @@ export interface EnvironmentInfo {
appVersion: string;
appBuildNumber: string;
sdkVersion: string;
osName: string;
osVersion: string;
osName: string | undefined;
osVersion: string | undefined;
}

export function getEnvironmentInfo(): EnvironmentInfo {
const [osName, osVersion] = getOperatingSystem();

const locale = "en-US";

return {
const envInfo: EnvironmentInfo = {
appVersion: version.appVersion,
appBuildNumber: version.appBuildNumber,
isDebug: __DEV__,
locale,
osName,
osVersion,
osName: osName,
osVersion: osVersion,
sdkVersion,
};
}

function getOperatingSystem(): [string, string] {
switch (Platform.OS) {
case "android":
return ["Android", Platform.constants.Release];
case "ios":
if (Platform.isPad) {
return ["iPadOS", Platform.Version];
}
return ["iOS", Platform.Version];
default:
return ["", ""];
return envInfo;

function getOperatingSystem(): [string, string] {
switch (Platform.OS) {
case "android":
return ["Android", Platform.constants.Release];
case "ios":
if (Platform.isPad) {
return ["iPadOS", Platform.Version];
}
return ["iOS", Platform.Version];
default:
return ["", ""];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can add web client here as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember correctly I left this empty as the server auto generates the operating system using the User Agent.
I'll double check it later.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, wrong place for a comment, I meant just to add another case in switch and return web client information

}
}
}
4 changes: 2 additions & 2 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export type Event = {
systemProps: {
isDebug: boolean;
locale: string;
osName: string;
osVersion: string;
osName: string | undefined;
osVersion: string | undefined;
appVersion: string;
appBuildNumber: string;
sdkVersion: string;
Expand Down
8 changes: 7 additions & 1 deletion src/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ describe("Validate", () => {
platform: "web" as const,
appKey: "A-DEV-000",
options: undefined,
expected: [false, "This SDK is only supported on Android and iOS"],
expected: [true, ""],
},
{
platform: "windows" as const,
appKey: "A-DEV-000",
options: undefined,
expected: [false, "This SDK is only supported on Android, iOS and web"],
},
{
platform: "ios" as const,
Expand Down
6 changes: 4 additions & 2 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { HOSTS } from "./constants";

import type { AptabaseOptions } from "./types";

const SUPPORTED_PLATFORMS = ["android", "ios", "web"];

export function validate(
platform: typeof Platform.OS,
appKey: string,
options?: AptabaseOptions
): [boolean, string] {
if (platform !== "android" && platform !== "ios") {
return [false, "This SDK is only supported on Android and iOS"];
if (!SUPPORTED_PLATFORMS.includes(platform)) {
return [false, "This SDK is only supported on Android, iOS and web"];
}

const parts = appKey.split("-");
Expand Down
22 changes: 15 additions & 7 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { NativeModules } from "react-native";

const { RNAptabaseModule } = NativeModules;
import { Platform, NativeModules } from "react-native";

type VersionObject = {
appVersion: string;
appBuildNumber: string;
};

const Version: VersionObject = {
appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
};
let Version: VersionObject;

if (Platform.OS === "web") {
Version = {
appVersion: "", // can be manually set in AptabaseOptions
appBuildNumber: ""
};
} else {
const { RNAptabaseModule } = NativeModules;
Version = {
appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
};
}

export default Version;
Loading