Skip to content

Commit aaaf6d1

Browse files
yusintoLaunchDarklyReleaseBot
andauthored
chore: Add react-native EventSource (#318)
This adds a local customised copy of [react-native-sse](https://github.com/binaryminds/react-native-sse). I have modified this to be in TypeScript and added minimal extra logic to work with our use case. --------- Co-authored-by: LaunchDarklyReleaseBot <[email protected]>
1 parent a3ec167 commit aaaf6d1

File tree

12 files changed

+498
-38
lines changed

12 files changed

+498
-38
lines changed

packages/sdk/react-native/src/platform.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type {
33
Crypto,
44
Encoding,
5+
EventName,
56
EventSource,
67
EventSourceInitDict,
78
Hasher,
@@ -13,17 +14,20 @@ import type {
1314
Requests,
1415
Response,
1516
SdkData,
16-
} from '@launchdarkly/js-sdk-common';
17+
} from '@launchdarkly/js-client-sdk-common';
1718

1819
import { name, version } from '../package.json';
1920
import { btoa, uuidv4 } from './polyfills';
21+
import RNEventSource from './react-native-sse';
2022

2123
class PlatformRequests implements Requests {
22-
createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource {
23-
throw new Error('todo');
24+
createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource {
25+
// TODO: add retry logic
26+
return new RNEventSource<EventName>(url, eventSourceInitDict);
2427
}
2528

2629
fetch(url: string, options?: Options): Promise<Response> {
30+
// @ts-ignore
2731
return fetch(url, options);
2832
}
2933
}
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/**
2+
* Ripped from https://github.com/binaryminds/react-native-sse
3+
* These changes are made from the above repo at fork-time:
4+
* 1. converted to ts and fix ts related errors.
5+
* 2. added onopen, onclose, onerror, onretrying functions.
6+
* 3. modified dispatch to work with functions added in 2.
7+
* 4. replaced all for of loops with foreach
8+
*/
9+
import type { EventSourceEvent, EventSourceListener, EventSourceOptions, EventType } from './types';
10+
11+
const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE'];
12+
13+
const defaultOptions: EventSourceOptions = {
14+
body: undefined,
15+
debug: false,
16+
headers: {},
17+
method: 'GET',
18+
pollingInterval: 5000,
19+
timeout: 0,
20+
timeoutBeforeConnection: 500,
21+
withCredentials: false,
22+
};
23+
24+
export default class EventSource<E extends string = never> {
25+
ERROR = -1;
26+
CONNECTING = 0;
27+
OPEN = 1;
28+
CLOSED = 2;
29+
30+
private lastEventId: undefined | string;
31+
private lastIndexProcessed = 0;
32+
private eventType: undefined | EventType<E>;
33+
private status = this.CONNECTING;
34+
private eventHandlers: any = {
35+
open: [],
36+
message: [],
37+
error: [],
38+
close: [],
39+
};
40+
41+
private method: string;
42+
private timeout: number;
43+
private timeoutBeforeConnection: number;
44+
private withCredentials: boolean;
45+
private headers: Record<string, any>;
46+
private body: any;
47+
private debug: boolean;
48+
private url: string;
49+
private xhr: XMLHttpRequest = new XMLHttpRequest();
50+
private pollTimer: any;
51+
private pollingInterval: number;
52+
53+
constructor(url: string, options?: EventSourceOptions) {
54+
const opts = {
55+
...defaultOptions,
56+
...options,
57+
};
58+
59+
this.url = url;
60+
this.method = opts.method!;
61+
this.timeout = opts.timeout!;
62+
this.timeoutBeforeConnection = opts.timeoutBeforeConnection!;
63+
this.withCredentials = opts.withCredentials!;
64+
this.headers = opts.headers!;
65+
this.body = opts.body;
66+
this.debug = opts.debug!;
67+
this.pollingInterval = opts.pollingInterval!;
68+
69+
this.pollAgain(this.timeoutBeforeConnection, true);
70+
}
71+
72+
private pollAgain(time: number, allowZero: boolean) {
73+
if (time > 0 || allowZero) {
74+
this.logDebug(`[EventSource] Will open new connection in ${time} ms.`);
75+
this.dispatch('retry', { type: 'retry' });
76+
this.pollTimer = setTimeout(() => {
77+
this.open();
78+
}, time);
79+
}
80+
}
81+
82+
open() {
83+
try {
84+
this.lastIndexProcessed = 0;
85+
this.status = this.CONNECTING;
86+
this.xhr.open(this.method, this.url, true);
87+
88+
if (this.withCredentials) {
89+
this.xhr.withCredentials = true;
90+
}
91+
92+
this.xhr.setRequestHeader('Accept', 'text/event-stream');
93+
this.xhr.setRequestHeader('Cache-Control', 'no-cache');
94+
this.xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
95+
96+
if (this.headers) {
97+
Object.entries(this.headers).forEach(([key, value]) => {
98+
this.xhr.setRequestHeader(key, value);
99+
});
100+
}
101+
102+
if (typeof this.lastEventId !== 'undefined') {
103+
this.xhr.setRequestHeader('Last-Event-ID', this.lastEventId);
104+
}
105+
106+
this.xhr.timeout = this.timeout;
107+
108+
this.xhr.onreadystatechange = () => {
109+
if (this.status === this.CLOSED) {
110+
return;
111+
}
112+
113+
this.logDebug(
114+
`[EventSource][onreadystatechange] ReadyState: ${
115+
XMLReadyStateMap[this.xhr.readyState] || 'Unknown'
116+
}(${this.xhr.readyState}), status: ${this.xhr.status}`,
117+
);
118+
119+
if (
120+
this.xhr.readyState !== XMLHttpRequest.DONE &&
121+
this.xhr.readyState !== XMLHttpRequest.LOADING
122+
) {
123+
return;
124+
}
125+
126+
if (this.xhr.status >= 200 && this.xhr.status < 400) {
127+
if (this.status === this.CONNECTING) {
128+
this.status = this.OPEN;
129+
this.dispatch('open', { type: 'open' });
130+
this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.');
131+
}
132+
133+
this.handleEvent(this.xhr.responseText || '');
134+
135+
if (this.xhr.readyState === XMLHttpRequest.DONE) {
136+
this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.');
137+
this.pollAgain(this.pollingInterval, false);
138+
}
139+
} else if (this.xhr.status !== 0) {
140+
this.status = this.ERROR;
141+
this.dispatch('error', {
142+
type: 'error',
143+
message: this.xhr.responseText,
144+
xhrStatus: this.xhr.status,
145+
xhrState: this.xhr.readyState,
146+
});
147+
148+
if (this.xhr.readyState === XMLHttpRequest.DONE) {
149+
this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.');
150+
this.pollAgain(this.pollingInterval, false);
151+
}
152+
}
153+
};
154+
155+
this.xhr.onerror = () => {
156+
if (this.status === this.CLOSED) {
157+
return;
158+
}
159+
160+
this.status = this.ERROR;
161+
this.dispatch('error', {
162+
type: 'error',
163+
message: this.xhr.responseText,
164+
xhrStatus: this.xhr.status,
165+
xhrState: this.xhr.readyState,
166+
});
167+
};
168+
169+
if (this.body) {
170+
this.xhr.send(this.body);
171+
} else {
172+
this.xhr.send();
173+
}
174+
175+
if (this.timeout > 0) {
176+
setTimeout(() => {
177+
if (this.xhr.readyState === XMLHttpRequest.LOADING) {
178+
this.dispatch('error', { type: 'timeout' });
179+
this.close();
180+
}
181+
}, this.timeout);
182+
}
183+
} catch (e: any) {
184+
this.status = this.ERROR;
185+
this.dispatch('error', {
186+
type: 'exception',
187+
message: e.message,
188+
error: e,
189+
});
190+
}
191+
}
192+
193+
private logDebug(...msg: string[]) {
194+
if (this.debug) {
195+
// eslint-disable-next-line no-console
196+
console.debug(...msg);
197+
}
198+
}
199+
200+
private handleEvent(response: string) {
201+
const parts = response.slice(this.lastIndexProcessed).split('\n');
202+
203+
const indexOfDoubleNewline = response.lastIndexOf('\n\n');
204+
if (indexOfDoubleNewline !== -1) {
205+
this.lastIndexProcessed = indexOfDoubleNewline + 2;
206+
}
207+
208+
let data = [];
209+
let retry = 0;
210+
let line = '';
211+
212+
// eslint-disable-next-line no-plusplus
213+
for (let i = 0; i < parts.length; i++) {
214+
line = parts[i].replace(/^(\s|\u00A0)+|(\s|\u00A0)+$/g, '');
215+
if (line.indexOf('event') === 0) {
216+
this.eventType = line.replace(/event:?\s*/, '') as EventType<E>;
217+
} else if (line.indexOf('retry') === 0) {
218+
retry = parseInt(line.replace(/retry:?\s*/, ''), 10);
219+
if (!Number.isNaN(retry)) {
220+
this.pollingInterval = retry;
221+
}
222+
} else if (line.indexOf('data') === 0) {
223+
data.push(line.replace(/data:?\s*/, ''));
224+
} else if (line.indexOf('id:') === 0) {
225+
this.lastEventId = line.replace(/id:?\s*/, '');
226+
} else if (line.indexOf('id') === 0) {
227+
this.lastEventId = undefined;
228+
} else if (line === '') {
229+
if (data.length > 0) {
230+
const eventType = this.eventType || 'message';
231+
const event: any = {
232+
type: eventType,
233+
data: data.join('\n'),
234+
url: this.url,
235+
lastEventId: this.lastEventId,
236+
};
237+
238+
this.dispatch(eventType, event);
239+
240+
data = [];
241+
this.eventType = undefined;
242+
}
243+
}
244+
}
245+
}
246+
247+
addEventListener<T extends EventType<E>>(type: T, listener: EventSourceListener<E, T>): void {
248+
if (this.eventHandlers[type] === undefined) {
249+
this.eventHandlers[type] = [];
250+
}
251+
252+
this.eventHandlers[type].push(listener);
253+
}
254+
255+
removeEventListener<T extends EventType<E>>(type: T, listener: EventSourceListener<E, T>): void {
256+
if (this.eventHandlers[type] !== undefined) {
257+
this.eventHandlers[type] = this.eventHandlers[type].filter(
258+
(handler: EventSourceListener<E, T>) => handler !== listener,
259+
);
260+
}
261+
}
262+
263+
removeAllEventListeners<T extends EventType<E>>(type?: T) {
264+
const availableTypes = Object.keys(this.eventHandlers);
265+
266+
if (type === undefined) {
267+
availableTypes.forEach((eventType) => {
268+
this.eventHandlers[eventType] = [];
269+
});
270+
} else {
271+
if (!availableTypes.includes(type)) {
272+
throw Error(`[EventSource] '${type}' type is not supported event type.`);
273+
}
274+
275+
this.eventHandlers[type] = [];
276+
}
277+
}
278+
279+
dispatch<T extends EventType<E>>(type: T, data: EventSourceEvent<T>) {
280+
this.eventHandlers[type]?.forEach((handler: EventSourceListener<E, T>) => handler(data));
281+
282+
switch (type) {
283+
case 'open':
284+
this.onopen();
285+
break;
286+
case 'close':
287+
this.onclose();
288+
break;
289+
case 'error':
290+
this.onerror();
291+
break;
292+
case 'retry':
293+
this.onretrying();
294+
break;
295+
default:
296+
break;
297+
}
298+
}
299+
300+
close() {
301+
this.status = this.CLOSED;
302+
clearTimeout(this.pollTimer);
303+
if (this.xhr) {
304+
this.xhr.abort();
305+
}
306+
307+
this.dispatch('close', { type: 'close' });
308+
}
309+
310+
onopen() {}
311+
onclose() {}
312+
onerror() {}
313+
onretrying() {}
314+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License
2+
3+
Copyright (c) 2021 Binary Minds
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import EventSource from './EventSource';
2+
3+
export default EventSource;

0 commit comments

Comments
 (0)