Skip to content

Commit

Permalink
feat: add react native web support
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert27 committed Dec 16, 2024
1 parent e15e40d commit c309c37
Show file tree
Hide file tree
Showing 10 changed files with 3,013 additions and 905 deletions.
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);

this._dispatcher = dispatcher;
}

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

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);
});
});
86 changes: 71 additions & 15 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";

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 ["", ""];
}
}
}
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;

0 comments on commit c309c37

Please sign in to comment.