├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── package.json └── src └── EventSource.js /LICENSE: -------------------------------------------------------------------------------- 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native EventSource (Server-Sent Events) 🚀 2 | 3 | Your missing EventSource implementation for React Native! React-Native-SSE library supports TypeScript. 4 | 5 | ## 💿 Installation 6 | 7 | We use XMLHttpRequest to establish and handle an SSE connection, so you don't need an additional native Android and iOS implementation. It's easy, just install it with your favorite package manager: 8 | 9 | ### Yarn 10 | 11 | ```bash 12 | yarn add react-native-sse 13 | ``` 14 | 15 | ### NPM 16 | 17 | ```bash 18 | npm install --save react-native-sse 19 | ``` 20 | 21 | ## 🎉 Usage 22 | 23 | We are using Server-Sent Events as a convenient way of establishing and handling Mercure connections. It helps us keep data always up-to-date, synchronize data between devices, and improve real-time workflow. Here you have some usage examples: 24 | 25 | ### Import 26 | 27 | ```js 28 | import EventSource from "react-native-sse"; 29 | ``` 30 | 31 | ### Connection and listeners 32 | 33 | ```js 34 | import EventSource from "react-native-sse"; 35 | 36 | const es = new EventSource("https://your-sse-server.com/.well-known/mercure"); 37 | 38 | es.addEventListener("open", (event) => { 39 | console.log("Open SSE connection."); 40 | }); 41 | 42 | es.addEventListener("message", (event) => { 43 | console.log("New message event:", event.data); 44 | }); 45 | 46 | es.addEventListener("error", (event) => { 47 | if (event.type === "error") { 48 | console.error("Connection error:", event.message); 49 | } else if (event.type === "exception") { 50 | console.error("Error:", event.message, event.error); 51 | } 52 | }); 53 | 54 | es.addEventListener("close", (event) => { 55 | console.log("Close SSE connection."); 56 | }); 57 | ``` 58 | 59 | If you want to use Bearer token and/or topics, look at this example (TypeScript): 60 | 61 | ```typescript 62 | import React, { useEffect, useState } from "react"; 63 | import { View, Text } from "react-native"; 64 | import EventSource, { EventSourceListener } from "react-native-sse"; 65 | import "react-native-url-polyfill/auto"; // Use URL polyfill in React Native 66 | 67 | interface Book { 68 | id: number; 69 | title: string; 70 | isbn: string; 71 | } 72 | 73 | const token = "[my-hub-token]"; 74 | 75 | const BookList: React.FC = () => { 76 | const [books, setBooks] = useState([]); 77 | 78 | useEffect(() => { 79 | const url = new URL("https://your-sse-server.com/.well-known/mercure"); 80 | url.searchParams.append("topic", "/book/{bookId}"); 81 | 82 | const es = new EventSource(url, { 83 | headers: { 84 | Authorization: { 85 | toString: function () { 86 | return "Bearer " + token; 87 | }, 88 | }, 89 | }, 90 | }); 91 | 92 | const listener: EventSourceListener = (event) => { 93 | if (event.type === "open") { 94 | console.log("Open SSE connection."); 95 | } else if (event.type === "message") { 96 | const book = JSON.parse(event.data) as Book; 97 | 98 | setBooks((prevBooks) => [...prevBooks, book]); 99 | 100 | console.log(`Received book ${book.title}, ISBN: ${book.isbn}`); 101 | } else if (event.type === "error") { 102 | console.error("Connection error:", event.message); 103 | } else if (event.type === "exception") { 104 | console.error("Error:", event.message, event.error); 105 | } 106 | }; 107 | 108 | es.addEventListener("open", listener); 109 | es.addEventListener("message", listener); 110 | es.addEventListener("error", listener); 111 | 112 | return () => { 113 | es.removeAllEventListeners(); 114 | es.close(); 115 | }; 116 | }, []); 117 | 118 | return ( 119 | 120 | {books.map((book) => ( 121 | 122 | {book.title} 123 | ISBN: {book.isbn} 124 | 125 | ))} 126 | 127 | ); 128 | }; 129 | 130 | export default BookList; 131 | ``` 132 | 133 | ### Usage with React Redux 134 | 135 | Since the listener is a closure it has access only to the component values from the first render. Each subsequent render 136 | has no effect on already defined listeners. 137 | 138 | If you use Redux you can get the actual value directly from the store instance. 139 | 140 | ```typescript 141 | // full example: https://snack.expo.dev/@quiknull/react-native-sse-redux-example 142 | type CustomEvents = "ping"; 143 | 144 | const Example: React.FC = () => { 145 | const name = useSelector((state: RootState) => state.user.name); 146 | 147 | const pingHandler: EventSourceListener = useCallback( 148 | (event) => { 149 | // In Event Source Listeners in connection with redux 150 | // you should read state directly from store object. 151 | console.log("User name from component selector: ", name); // bad 152 | console.log("User name directly from store: ", store.getState().user.name); // good 153 | }, 154 | [] 155 | ); 156 | 157 | useEffect(() => { 158 | const token = "myToken"; 159 | const url = new URL("https://demo.mercure.rocks/.well-known/mercure"); 160 | url.searchParams.append( 161 | "topic", 162 | "https://example.com/my-private-topic" 163 | ); 164 | 165 | const es = new EventSource(url, { 166 | headers: { 167 | Authorization: { 168 | toString: function () { 169 | return "Bearer " + token; 170 | } 171 | } 172 | } 173 | }); 174 | 175 | es.addEventListener("ping", pingHandler); 176 | }, []); 177 | }; 178 | ``` 179 | 180 | ## 📖 Configuration 181 | 182 | ```typescript 183 | new EventSource(url: string | URL, options?: EventSourceOptions); 184 | ``` 185 | 186 | ### Options 187 | 188 | ```typescript 189 | const options: EventSourceOptions = { 190 | method: 'GET', // Request method. Default: GET 191 | timeout: 0, // Time (ms) after which the connection will expire without any activity. Default: 0 (no timeout) 192 | timeoutBeforeConnection: 500, // Time (ms) to wait before initial connection is made. Default: 500 193 | withCredentials: false, // Include credentials in cross-site Access-Control requests. Default: false 194 | headers: {}, // Your request headers. Default: {} 195 | body: undefined, // Your request body sent on connection. Default: undefined 196 | debug: false, // Show console.debug messages for debugging purpose. Default: false 197 | pollingInterval: 5000, // Time (ms) between reconnections. If set to 0, reconnections will be disabled. Default: 5000 198 | lineEndingCharacter: null // Character(s) used to represent line endings in received data. Common values: '\n' for LF (Unix/Linux), '\r\n' for CRLF (Windows), '\r' for CR (older Mac). Default: null (Automatically detect from event) 199 | } 200 | ``` 201 | 202 | ## 🚀 Advanced usage with TypeScript 203 | 204 | Using EventSource you can handle custom events invoked by the server: 205 | 206 | ```typescript 207 | import EventSource, { EventSourceListener, EventSourceEvent } from "react-native-sse"; 208 | 209 | type MyCustomEvents = "ping" | "clientConnected" | "clientDisconnected"; 210 | 211 | const es = new EventSource( 212 | "https://your-sse-server.com/.well-known/hub" 213 | ); 214 | 215 | es.addEventListener("open", (event) => { 216 | console.log("Open SSE connection."); 217 | }); 218 | 219 | es.addEventListener("ping", (event) => { 220 | console.log("Received ping with data:", event.data); 221 | }); 222 | 223 | es.addEventListener("clientConnected", (event) => { 224 | console.log("Client connected:", event.data); 225 | }); 226 | 227 | es.addEventListener("clientDisconnected", (event) => { 228 | console.log("Client disconnected:", event.data); 229 | }); 230 | ``` 231 | 232 | Using one listener for all events: 233 | 234 | ```typescript 235 | import EventSource, { EventSourceListener } from "react-native-sse"; 236 | 237 | type MyCustomEvents = "ping" | "clientConnected" | "clientDisconnected"; 238 | 239 | const es = new EventSource( 240 | "https://your-sse-server.com/.well-known/hub" 241 | ); 242 | 243 | const listener: EventSourceListener = (event) => { 244 | if (event.type === 'open') { 245 | // connection opened 246 | } else if (event.type === 'message') { 247 | // ... 248 | } else if (event.type === 'ping') { 249 | // ... 250 | } 251 | } 252 | es.addEventListener('open', listener); 253 | es.addEventListener('message', listener); 254 | es.addEventListener('ping', listener); 255 | ``` 256 | 257 | Using generic type for one event: 258 | 259 | ```typescript 260 | import EventSource, { EventSourceListener, EventSourceEvent } from "react-native-sse"; 261 | 262 | type MyCustomEvents = "ping" | "clientConnected" | "clientDisconnected"; 263 | 264 | const es = new EventSource( 265 | "https://your-sse-server.com/.well-known/hub" 266 | ); 267 | 268 | const pingListener: EventSourceListener = (event) => { 269 | // ... 270 | } 271 | // or 272 | const pingListener = (event: EventSourceEvent<'ping', MyCustomEvents>) => { 273 | // ... 274 | } 275 | 276 | es.addEventListener('ping', pingListener); 277 | ``` 278 | 279 | `MyCustomEvents` in `EventSourceEvent` is optional, but it's recommended to use it in order to have better type checking. 280 | 281 | ## 🚀 Usage with ChatGPT 282 | 283 | If you want to use ChatGPT with React Native, you can use the following example: 284 | 285 | ```typescript 286 | import { useEffect, useState } from "react"; 287 | import { Text, View } from "react-native"; 288 | import EventSource from "react-native-sse"; 289 | 290 | const OpenAIToken = '[Your OpenAI token]'; 291 | 292 | export default function App() { 293 | const [text, setText] = useState("Loading..."); 294 | 295 | useEffect(() => { 296 | const es = new EventSource( 297 | "https://api.openai.com/v1/chat/completions", 298 | { 299 | headers: { 300 | "Content-Type": "application/json", 301 | Authorization: `Bearer ${OpenAIToken}`, 302 | }, 303 | method: "POST", 304 | // Remember to read the OpenAI API documentation to set the correct body 305 | body: JSON.stringify({ 306 | model: "gpt-3.5-turbo-0125", 307 | messages: [ 308 | { 309 | role: "system", 310 | content: "You are a helpful assistant.", 311 | }, 312 | { 313 | role: "user", 314 | content: "What is the meaning of life?", 315 | }, 316 | ], 317 | max_tokens: 600, 318 | n: 1, 319 | temperature: 0.7, 320 | stream: true, 321 | }), 322 | pollingInterval: 0, // Remember to set pollingInterval to 0 to disable reconnections 323 | } 324 | ); 325 | 326 | es.addEventListener("open", () => { 327 | setText(""); 328 | }); 329 | 330 | es.addEventListener("message", (event) => { 331 | if (event.data !== "[DONE]") { 332 | const data = JSON.parse(event.data); 333 | 334 | if (data.choices[0].delta.content !== undefined) { 335 | setText((text) => text + data.choices[0].delta.content); 336 | } 337 | } 338 | }); 339 | 340 | return () => { 341 | es.removeAllEventListeners(); 342 | es.close(); 343 | }; 344 | }, []); 345 | 346 | return ( 347 | 348 | {text} 349 | 350 | ); 351 | } 352 | ``` 353 | 354 | 355 | --- 356 | 357 | Custom events always emit result with following interface: 358 | 359 | ```typescript 360 | export interface CustomEvent { 361 | type: E; 362 | data: string | null; 363 | lastEventId: string | null; 364 | url: string; 365 | } 366 | ``` 367 | 368 | ## 👏 Contribution 369 | 370 | If you see our library is not working properly, feel free to open an issue or create a pull request with your fixes. 371 | 372 | ## 📄 License 373 | 374 | ``` 375 | The MIT License 376 | 377 | Copyright (c) 2021 Binary Minds 378 | 379 | Permission is hereby granted, free of charge, to any person obtaining a copy 380 | of this software and associated documentation files (the "Software"), to deal 381 | in the Software without restriction, including without limitation the rights 382 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 383 | copies of the Software, and to permit persons to whom the Software is 384 | furnished to do so, subject to the following conditions: 385 | 386 | The above copyright notice and this permission notice shall be included in 387 | all copies or substantial portions of the Software. 388 | 389 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 390 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 391 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 392 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 393 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 394 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 395 | THE SOFTWARE. 396 | ``` 397 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type BuiltInEventType = 'open' | 'message' | 'error' | 'close'; 2 | export type EventType = E | BuiltInEventType; 3 | 4 | export interface MessageEvent { 5 | type: 'message'; 6 | data: string | null; 7 | lastEventId: string | null; 8 | url: string; 9 | } 10 | 11 | export interface OpenEvent { 12 | type: 'open'; 13 | } 14 | 15 | export interface CloseEvent { 16 | type: 'close'; 17 | } 18 | 19 | export interface TimeoutEvent { 20 | type: 'timeout'; 21 | } 22 | 23 | export interface ErrorEvent { 24 | type: 'error'; 25 | message: string; 26 | xhrState: number; 27 | xhrStatus: number; 28 | } 29 | 30 | export interface CustomEvent { 31 | type: E; 32 | data: string | null; 33 | lastEventId: string | null; 34 | url: string; 35 | } 36 | 37 | export interface ExceptionEvent { 38 | type: 'exception'; 39 | message: string; 40 | error: Error; 41 | } 42 | 43 | export interface EventSourceOptions { 44 | method?: string; 45 | timeout?: number; 46 | timeoutBeforeConnection?: number; 47 | withCredentials?: boolean; 48 | headers?: Record; 49 | body?: any; 50 | debug?: boolean; 51 | pollingInterval?: number; 52 | lineEndingCharacter?: string; 53 | } 54 | 55 | type BuiltInEventMap = { 56 | 'message': MessageEvent, 57 | 'open': OpenEvent, 58 | 'close': CloseEvent, 59 | 'error': ErrorEvent | TimeoutEvent | ExceptionEvent, 60 | }; 61 | 62 | export type EventSourceEvent = E extends BuiltInEventType ? BuiltInEventMap[E] : CustomEvent; 63 | export type EventSourceListener = EventType> = ( 64 | event: EventSourceEvent 65 | ) => void; 66 | 67 | declare class EventSource { 68 | constructor(url: URL | string, options?: EventSourceOptions); 69 | open(): void; 70 | close(): void; 71 | addEventListener>(type: T, listener: EventSourceListener): void; 72 | removeEventListener>(type: T, listener: EventSourceListener): void; 73 | removeAllEventListeners>(type?: T): void; 74 | dispatch>(type: T, data: EventSourceEvent): void; 75 | } 76 | 77 | export default EventSource; 78 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const EventSource = require('./src/EventSource') 2 | 3 | module.exports = EventSource; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-sse", 3 | "version": "1.2.1", 4 | "description": "EventSource implementation for React Native. Server-Sent Events (SSE) for iOS and Android.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/binaryminds/react-native-sse.git" 12 | }, 13 | "keywords": [ 14 | "react-native", 15 | "expo", 16 | "event-source", 17 | "sse", 18 | "server-sent-events", 19 | "chatgpt", 20 | "stream", 21 | "ios", 22 | "android" 23 | ], 24 | "author": "BinaryMinds", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/binaryminds/react-native-sse/issues" 28 | }, 29 | "homepage": "https://github.com/binaryminds/react-native-sse#readme" 30 | } -------------------------------------------------------------------------------- /src/EventSource.js: -------------------------------------------------------------------------------- 1 | const XMLReadyStateMap = [ 2 | 'UNSENT', 3 | 'OPENED', 4 | 'HEADERS_RECEIVED', 5 | 'LOADING', 6 | 'DONE', 7 | ]; 8 | 9 | class EventSource { 10 | ERROR = -1; 11 | CONNECTING = 0; 12 | OPEN = 1; 13 | CLOSED = 2; 14 | 15 | CRLF = '\r\n'; 16 | LF = '\n'; 17 | CR = '\r'; 18 | 19 | constructor(url, options = {}) { 20 | this.lastEventId = null; 21 | this.status = this.CONNECTING; 22 | 23 | this.eventHandlers = { 24 | open: [], 25 | message: [], 26 | error: [], 27 | close: [], 28 | }; 29 | 30 | this.method = options.method || 'GET'; 31 | this.timeout = options.timeout ?? 0; 32 | this.timeoutBeforeConnection = options.timeoutBeforeConnection ?? 500; 33 | this.withCredentials = options.withCredentials || false; 34 | this.headers = options.headers || {}; 35 | this.body = options.body || undefined; 36 | this.debug = options.debug || false; 37 | this.interval = options.pollingInterval ?? 5000; 38 | this.lineEndingCharacter = options.lineEndingCharacter || null; 39 | 40 | this._xhr = null; 41 | this._pollTimer = null; 42 | this._lastIndexProcessed = 0; 43 | 44 | if (!url || (typeof url !== 'string' && typeof url.toString !== 'function')) { 45 | throw new SyntaxError('[EventSource] Invalid URL argument.'); 46 | } 47 | 48 | if (typeof url.toString === 'function') { 49 | this.url = url.toString(); 50 | } else { 51 | this.url = url; 52 | } 53 | 54 | this._pollAgain(this.timeoutBeforeConnection, true); 55 | } 56 | 57 | _pollAgain(time, allowZero) { 58 | if (time > 0 || allowZero) { 59 | this._logDebug(`[EventSource] Will open new connection in ${time} ms.`); 60 | this._pollTimer = setTimeout(() => { 61 | this.open(); 62 | }, time); 63 | } 64 | } 65 | 66 | open() { 67 | try { 68 | this.status = this.CONNECTING; 69 | 70 | this._lastIndexProcessed = 0; 71 | 72 | this._xhr = new XMLHttpRequest(); 73 | this._xhr.open(this.method, this.url, true); 74 | 75 | if (this.withCredentials) { 76 | this._xhr.withCredentials = true; 77 | } 78 | 79 | this._xhr.setRequestHeader('Accept', 'text/event-stream'); 80 | this._xhr.setRequestHeader('Cache-Control', 'no-cache'); 81 | this._xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 82 | 83 | if (this.headers) { 84 | for (const [key, value] of Object.entries(this.headers)) { 85 | this._xhr.setRequestHeader(key, value); 86 | } 87 | } 88 | 89 | if (this.lastEventId !== null) { 90 | this._xhr.setRequestHeader('Last-Event-ID', this.lastEventId); 91 | } 92 | 93 | this._xhr.timeout = this.timeout; 94 | 95 | this._xhr.onreadystatechange = () => { 96 | if (this.status === this.CLOSED) { 97 | return; 98 | } 99 | 100 | const xhr = this._xhr; 101 | 102 | this._logDebug(`[EventSource][onreadystatechange] ReadyState: ${XMLReadyStateMap[xhr.readyState] || 'Unknown'}(${xhr.readyState}), status: ${xhr.status}`); 103 | 104 | if (![XMLHttpRequest.DONE, XMLHttpRequest.LOADING].includes(xhr.readyState)) { 105 | return; 106 | } 107 | 108 | if (xhr.status >= 200 && xhr.status < 400) { 109 | if (this.status === this.CONNECTING) { 110 | this.status = this.OPEN; 111 | this.dispatch('open', { type: 'open' }); 112 | this._logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.'); 113 | } 114 | 115 | this._handleEvent(xhr.responseText || ''); 116 | 117 | if (xhr.readyState === XMLHttpRequest.DONE) { 118 | this._logDebug('[EventSource][onreadystatechange][DONE] Operation done.'); 119 | this._pollAgain(this.interval, false); 120 | } 121 | } else if (xhr.status !== 0) { 122 | this.status = this.ERROR; 123 | this.dispatch('error', { 124 | type: 'error', 125 | message: xhr.responseText, 126 | xhrStatus: xhr.status, 127 | xhrState: xhr.readyState, 128 | }); 129 | 130 | if (xhr.readyState === XMLHttpRequest.DONE) { 131 | this._logDebug('[EventSource][onreadystatechange][ERROR] Response status error.'); 132 | this._pollAgain(this.interval, false); 133 | } 134 | } 135 | }; 136 | 137 | this._xhr.onerror = () => { 138 | if (this.status === this.CLOSED) { 139 | return; 140 | } 141 | 142 | this.status = this.ERROR; 143 | this.dispatch('error', { 144 | type: 'error', 145 | message: this._xhr.responseText, 146 | xhrStatus: this._xhr.status, 147 | xhrState: this._xhr.readyState, 148 | }); 149 | }; 150 | 151 | if (this.body) { 152 | this._xhr.send(this.body); 153 | } else { 154 | this._xhr.send(); 155 | } 156 | 157 | if (this.timeout > 0) { 158 | setTimeout(() => { 159 | if (this._xhr.readyState === XMLHttpRequest.LOADING) { 160 | this.dispatch('error', { type: 'timeout' }); 161 | this.close(); 162 | } 163 | }, this.timeout); 164 | } 165 | } catch (e) { 166 | this.status = this.ERROR; 167 | this.dispatch('error', { 168 | type: 'exception', 169 | message: e.message, 170 | error: e, 171 | }); 172 | } 173 | } 174 | 175 | _logDebug(...msg) { 176 | if (this.debug) { 177 | console.debug(...msg); 178 | } 179 | } 180 | 181 | _handleEvent(response) { 182 | if (this.lineEndingCharacter === null) { 183 | const detectedNewlineChar = this._detectNewlineChar(response); 184 | if (detectedNewlineChar !== null) { 185 | this._logDebug(`[EventSource] Automatically detected lineEndingCharacter: ${JSON.stringify(detectedNewlineChar).slice(1, -1)}`); 186 | this.lineEndingCharacter = detectedNewlineChar; 187 | } else { 188 | console.warn("[EventSource] Unable to identify the line ending character. Ensure your server delivers a standard line ending character: \\r\\n, \\n, \\r, or specify your custom character using the 'lineEndingCharacter' option."); 189 | return; 190 | } 191 | } 192 | 193 | const indexOfDoubleNewline = this._getLastDoubleNewlineIndex(response); 194 | if (indexOfDoubleNewline <= this._lastIndexProcessed) { 195 | return; 196 | } 197 | 198 | const parts = response.substring(this._lastIndexProcessed, indexOfDoubleNewline).split(this.lineEndingCharacter); 199 | this._lastIndexProcessed = indexOfDoubleNewline; 200 | 201 | let type = undefined; 202 | let id = null; 203 | let data = []; 204 | let retry = 0; 205 | let line = ''; 206 | 207 | for (let i = 0; i < parts.length; i++) { 208 | line = parts[i].trim(); 209 | if (line.startsWith('event')) { 210 | type = line.replace(/event:?\s*/, ''); 211 | } else if (line.startsWith('retry')) { 212 | retry = parseInt(line.replace(/retry:?\s*/, ''), 10); 213 | if (!isNaN(retry)) { 214 | this.interval = retry; 215 | } 216 | } else if (line.startsWith('data')) { 217 | data.push(line.replace(/data:?\s*/, '')); 218 | } else if (line.startsWith('id')) { 219 | id = line.replace(/id:?\s*/, ''); 220 | if (id !== '') { 221 | this.lastEventId = id; 222 | } else { 223 | this.lastEventId = null; 224 | } 225 | } else if (line === '') { 226 | if (data.length > 0) { 227 | const eventType = type || 'message'; 228 | const event = { 229 | type: eventType, 230 | data: data.join('\n'), 231 | url: this.url, 232 | lastEventId: this.lastEventId, 233 | }; 234 | 235 | this.dispatch(eventType, event); 236 | 237 | data = []; 238 | type = undefined; 239 | } 240 | } 241 | } 242 | } 243 | 244 | _detectNewlineChar(response) { 245 | const supportedLineEndings = [this.CRLF, this.LF, this.CR]; 246 | for (const char of supportedLineEndings) { 247 | if (response.includes(char)) { 248 | return char; 249 | } 250 | } 251 | return null; 252 | } 253 | 254 | _getLastDoubleNewlineIndex(response) { 255 | const doubleLineEndingCharacter = this.lineEndingCharacter + this.lineEndingCharacter; 256 | const lastIndex = response.lastIndexOf(doubleLineEndingCharacter); 257 | if (lastIndex === -1) { 258 | return -1; 259 | } 260 | 261 | return lastIndex + doubleLineEndingCharacter.length; 262 | } 263 | 264 | addEventListener(type, listener) { 265 | if (this.eventHandlers[type] === undefined) { 266 | this.eventHandlers[type] = []; 267 | } 268 | 269 | this.eventHandlers[type].push(listener); 270 | } 271 | 272 | removeEventListener(type, listener) { 273 | if (this.eventHandlers[type] !== undefined) { 274 | this.eventHandlers[type] = this.eventHandlers[type].filter((handler) => handler !== listener); 275 | } 276 | } 277 | 278 | removeAllEventListeners(type) { 279 | const availableTypes = Object.keys(this.eventHandlers); 280 | 281 | if (type === undefined) { 282 | for (const eventType of availableTypes) { 283 | this.eventHandlers[eventType] = []; 284 | } 285 | } else { 286 | if (!availableTypes.includes(type)) { 287 | throw Error(`[EventSource] '${type}' type is not supported event type.`); 288 | } 289 | 290 | this.eventHandlers[type] = []; 291 | } 292 | } 293 | 294 | dispatch(type, data) { 295 | const availableTypes = Object.keys(this.eventHandlers); 296 | 297 | if (!availableTypes.includes(type)) { 298 | return; 299 | } 300 | 301 | for (const handler of Object.values(this.eventHandlers[type])) { 302 | handler(data); 303 | } 304 | } 305 | 306 | close() { 307 | if (this.status !== this.CLOSED) { 308 | this.status = this.CLOSED; 309 | this.dispatch('close', { type: 'close' }); 310 | } 311 | 312 | clearTimeout(this._pollTimer); 313 | if (this._xhr) { 314 | this._xhr.abort(); 315 | } 316 | } 317 | } 318 | 319 | export default EventSource; 320 | --------------------------------------------------------------------------------