Skip to content

Commit bd56e18

Browse files
committed
feat: add react native web support
1 parent e15e40d commit bd56e18

10 files changed

+3015
-907
lines changed

package-lock.json

+2,817-848
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+5-5
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@
3030
"aptabase-react-native.podspec"
3131
],
3232
"devDependencies": {
33-
"@vitest/coverage-v8": "0.34.3",
34-
"@types/react": "18.2.22",
3533
"@types/node": "20.5.9",
34+
"@types/react": "18.2.22",
35+
"@vitest/coverage-v8": "2.1.8",
3636
"tsup": "7.2.0",
37-
"vite": "4.4.9",
38-
"vitest": "0.34.3",
39-
"vitest-fetch-mock": "0.2.2"
37+
"vite": "6.0.3",
38+
"vitest": "2.1.8",
39+
"vitest-fetch-mock": "0.4.2"
4040
},
4141
"peerDependencies": {
4242
"react": "*",

src/client.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type { Platform } from "react-native";
1+
import { Platform } from "react-native";
22
import type { AptabaseOptions } from "./types";
33
import type { EnvironmentInfo } from "./env";
4-
import { EventDispatcher } from "./dispatcher";
4+
import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
55
import { newSessionId } from "./session";
66
import { HOSTS, SESSION_TIMEOUT } from "./constants";
77

88
export class AptabaseClient {
9-
private readonly _dispatcher: EventDispatcher;
9+
private readonly _dispatcher: WebEventDispatcher | NativeEventDispatcher;
1010
private readonly _env: EnvironmentInfo;
1111
private _sessionId = newSessionId();
1212
private _lastTouched = new Date();
@@ -21,7 +21,12 @@ export class AptabaseClient {
2121
this._env.appVersion = options.appVersion;
2222
}
2323

24-
this._dispatcher = new EventDispatcher(appKey, baseUrl, env);
24+
const dispatcher =
25+
Platform.OS === "web"
26+
? new WebEventDispatcher(appKey, baseUrl, env)
27+
: new NativeEventDispatcher(appKey, baseUrl, env);
28+
29+
this._dispatcher = dispatcher;
2530
}
2631

2732
public trackEvent(

src/dispatcher.spec.ts

+65-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "vitest-fetch-mock";
2-
import { EventDispatcher } from "./dispatcher";
2+
import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
33
import { beforeEach, describe, expect, it } from "vitest";
4-
import { EnvironmentInfo } from "./env";
4+
import type { EnvironmentInfo } from "./env";
55

66
const env: EnvironmentInfo = {
77
isDebug: false,
@@ -32,11 +32,11 @@ const expectEventsCount = async (
3232
expect(body.length).toEqual(expectedNumOfEvents);
3333
};
3434

35-
describe("EventDispatcher", () => {
36-
let dispatcher: EventDispatcher;
35+
describe("NativeEventDispatcher", () => {
36+
let dispatcher: NativeEventDispatcher;
3737

3838
beforeEach(() => {
39-
dispatcher = new EventDispatcher(
39+
dispatcher = new NativeEventDispatcher(
4040
"A-DEV-000",
4141
"https://localhost:3000",
4242
env
@@ -138,3 +138,63 @@ describe("EventDispatcher", () => {
138138
expectRequestCount(1);
139139
});
140140
});
141+
142+
describe("WebEventDispatcher", () => {
143+
let dispatcher: WebEventDispatcher;
144+
145+
beforeEach(() => {
146+
dispatcher = new WebEventDispatcher(
147+
"A-DEV-000",
148+
"https://localhost:3000",
149+
env
150+
);
151+
fetchMock.resetMocks();
152+
});
153+
154+
it("should send event with correct headers", async () => {
155+
dispatcher.enqueue(createEvent("app_started"));
156+
157+
const request = await fetchMock.requests().at(0);
158+
expect(request).not.toBeUndefined();
159+
expect(request?.url).toEqual("https://localhost:3000/api/v0/event");
160+
expect(request?.headers.get("Content-Type")).toEqual("application/json");
161+
expect(request?.headers.get("App-Key")).toEqual("A-DEV-000");
162+
});
163+
164+
it("should dispatch single event", async () => {
165+
fetchMock.mockResponseOnce("{}");
166+
167+
dispatcher.enqueue(createEvent("app_started"));
168+
169+
expectRequestCount(1);
170+
const body = await fetchMock.requests().at(0)?.json();
171+
expect(body.eventName).toEqual("app_started");
172+
});
173+
174+
it("should dispatch multiple events individually", async () => {
175+
fetchMock.mockResponseOnce("{}");
176+
fetchMock.mockResponseOnce("{}");
177+
178+
dispatcher.enqueue([createEvent("app_started"), createEvent("app_exited")]);
179+
180+
expectRequestCount(2);
181+
const body1 = await fetchMock.requests().at(0)?.json();
182+
const body2 = await fetchMock.requests().at(1)?.json();
183+
expect(body1.eventName).toEqual("app_started");
184+
expect(body2.eventName).toEqual("app_exited");
185+
});
186+
187+
it("should not retry requests that failed with 4xx", async () => {
188+
fetchMock.mockResponseOnce("{}", { status: 400 });
189+
190+
dispatcher.enqueue(createEvent("hello_world"));
191+
192+
expectRequestCount(1);
193+
const body = await fetchMock.requests().at(0)?.json();
194+
expect(body.eventName).toEqual("hello_world");
195+
196+
dispatcher.enqueue(createEvent("hello_world"));
197+
198+
expectRequestCount(2);
199+
});
200+
});

src/dispatcher.ts

+72-16
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { Event } from "./types";
2-
import { EnvironmentInfo } from "./env";
2+
import type { EnvironmentInfo } from "./env";
33

4-
export class EventDispatcher {
5-
private _events: Event[] = [];
6-
private MAX_BATCH_SIZE = 25;
7-
private headers: Headers;
8-
private apiUrl: string;
4+
export abstract class EventDispatcher {
5+
protected _events: Event[] = [];
6+
protected MAX_BATCH_SIZE = 25;
7+
protected headers: Headers;
8+
protected apiUrl: string;
99

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

19-
public enqueue(evt: Event | Event[]) {
20-
if (Array.isArray(evt)) {
21-
this._events.push(...evt);
22-
return;
23-
}
24-
25-
this._events.push(evt);
26-
}
19+
public abstract enqueue(evt: Event | Event[]): void;
2720

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

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

57-
if (res.status < 300) {
50+
if (res.ok) {
5851
return Promise.resolve();
5952
}
6053

@@ -74,4 +67,67 @@ export class EventDispatcher {
7467
throw e;
7568
}
7669
}
70+
71+
protected async _sendEvent(event: Event): Promise<void> {
72+
try {
73+
const res = await fetch(this.apiUrl, {
74+
method: "POST",
75+
headers: this.headers,
76+
credentials: "omit",
77+
body: JSON.stringify(event),
78+
});
79+
80+
if (res.ok) {
81+
return Promise.resolve();
82+
}
83+
84+
const reason = `${res.status} ${await res.text()}`;
85+
if (res.status < 500) {
86+
console.warn(
87+
`Aptabase: Failed to send event because of ${reason}. Will not retry.`
88+
);
89+
return Promise.resolve();
90+
}
91+
92+
throw new Error(reason);
93+
} catch (e) {
94+
console.error(`Aptabase: Failed to send event. Reason: ${e}`);
95+
throw e;
96+
}
97+
}
98+
}
99+
100+
export class WebEventDispatcher extends EventDispatcher {
101+
constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
102+
super(appKey, baseUrl, env);
103+
this.apiUrl = `${baseUrl}/api/v0/event`;
104+
this.headers = new Headers({
105+
"Content-Type": "application/json",
106+
"App-Key": appKey,
107+
// No User-Agent header for web
108+
});
109+
}
110+
111+
public enqueue(evt: Event | Event[]): void {
112+
if (Array.isArray(evt)) {
113+
evt.forEach((event) => this._sendEvent(event));
114+
} else {
115+
this._sendEvent(evt);
116+
}
117+
}
118+
}
119+
120+
export class NativeEventDispatcher extends EventDispatcher {
121+
constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
122+
super(appKey, baseUrl, env);
123+
this.apiUrl = `${baseUrl}/api/v0/events`;
124+
}
125+
126+
public enqueue(evt: Event | Event[]): void {
127+
if (Array.isArray(evt)) {
128+
this._events.push(...evt);
129+
} else {
130+
this._events.push(evt);
131+
}
132+
}
77133
}

src/env.ts

+19-17
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,38 @@ export interface EnvironmentInfo {
1010
appVersion: string;
1111
appBuildNumber: string;
1212
sdkVersion: string;
13-
osName: string;
14-
osVersion: string;
13+
osName: string | undefined;
14+
osVersion: string | undefined;
1515
}
1616

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

2020
const locale = "en-US";
2121

22-
return {
22+
const envInfo: EnvironmentInfo = {
2323
appVersion: version.appVersion,
2424
appBuildNumber: version.appBuildNumber,
2525
isDebug: __DEV__,
2626
locale,
27-
osName,
28-
osVersion,
27+
osName: osName,
28+
osVersion: osVersion,
2929
sdkVersion,
3030
};
31-
}
3231

33-
function getOperatingSystem(): [string, string] {
34-
switch (Platform.OS) {
35-
case "android":
36-
return ["Android", Platform.constants.Release];
37-
case "ios":
38-
if (Platform.isPad) {
39-
return ["iPadOS", Platform.Version];
40-
}
41-
return ["iOS", Platform.Version];
42-
default:
43-
return ["", ""];
32+
return envInfo;
33+
34+
function getOperatingSystem(): [string, string] {
35+
switch (Platform.OS) {
36+
case "android":
37+
return ["Android", Platform.constants.Release];
38+
case "ios":
39+
if (Platform.isPad) {
40+
return ["iPadOS", Platform.Version];
41+
}
42+
return ["iOS", Platform.Version];
43+
default:
44+
return ["", ""];
45+
}
4446
}
4547
}

src/types.d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export type Event = {
2323
systemProps: {
2424
isDebug: boolean;
2525
locale: string;
26-
osName: string;
27-
osVersion: string;
26+
osName: string | undefined;
27+
osVersion: string | undefined;
2828
appVersion: string;
2929
appBuildNumber: string;
3030
sdkVersion: string;

src/validate.spec.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ describe("Validate", () => {
2121
platform: "web" as const,
2222
appKey: "A-DEV-000",
2323
options: undefined,
24-
expected: [false, "This SDK is only supported on Android and iOS"],
24+
expected: [true, ""],
25+
},
26+
{
27+
platform: "windows" as const,
28+
appKey: "A-DEV-000",
29+
options: undefined,
30+
expected: [false, "This SDK is only supported on Android, iOS and web"],
2531
},
2632
{
2733
platform: "ios" as const,

src/validate.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { HOSTS } from "./constants";
33

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

6+
const SUPPORTED_PLATFORMS = ["android", "ios", "web"];
7+
68
export function validate(
79
platform: typeof Platform.OS,
810
appKey: string,
911
options?: AptabaseOptions
1012
): [boolean, string] {
11-
if (platform !== "android" && platform !== "ios") {
12-
return [false, "This SDK is only supported on Android and iOS"];
13+
if (!SUPPORTED_PLATFORMS.includes(platform)) {
14+
return [false, "This SDK is only supported on Android, iOS and web"];
1315
}
1416

1517
const parts = appKey.split("-");

src/version.ts

+15-7
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1-
import { NativeModules } from "react-native";
2-
3-
const { RNAptabaseModule } = NativeModules;
1+
import { Platform, NativeModules } from "react-native";
42

53
type VersionObject = {
64
appVersion: string;
75
appBuildNumber: string;
86
};
97

10-
const Version: VersionObject = {
11-
appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
12-
appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
13-
};
8+
let Version: VersionObject;
9+
10+
if (Platform.OS === "web") {
11+
Version = {
12+
appVersion: "", // can be manually set in AptabaseOptions
13+
appBuildNumber: ""
14+
};
15+
} else {
16+
const { RNAptabaseModule } = NativeModules;
17+
Version = {
18+
appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
19+
appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
20+
};
21+
}
1422

1523
export default Version;

0 commit comments

Comments
 (0)