├── .babelrc.js ├── .eslintrc.js ├── .github ├── dependabot.yaml └── workflows │ └── test.yaml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENCE ├── README.md ├── example ├── .env ├── .gitignore ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── server.js ├── src │ ├── App.css │ ├── App.tsx │ ├── Comments.tsx │ ├── Likes.tsx │ ├── index.css │ ├── index.tsx │ └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock ├── jest.config.js ├── package.json ├── rollup.config.js ├── scripts ├── link.sh └── unlink.sh ├── src ├── SSEContext.ts ├── __mocks__ │ └── source.mock.ts ├── __tests__ │ ├── SSEContext.test.ts │ ├── createSourceManager.test.ts │ └── useSSE.test.ts ├── createSourceManager.ts ├── index.ts ├── source.ts └── useSSE.ts ├── tsconfig.json ├── tsconfig.typings.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | const isESModules = process.env.BABEL_ENV === 'esm'; 2 | const isRollup = process.env.BABEL_ENV === 'rollup'; 3 | const plugins = []; 4 | 5 | if (!isRollup) { 6 | plugins.push([ 7 | '@babel/plugin-transform-runtime', 8 | { 9 | useESModules: isESModules, 10 | }, 11 | ]); 12 | } 13 | 14 | module.exports = { 15 | presets: [ 16 | [ 17 | '@babel/preset-env', 18 | { 19 | targets: ['>0.25%', 'not dead', 'not ie <= 11', 'not op_mini all'], 20 | modules: !isESModules && !isRollup ? 'commonjs' : false, 21 | useBuiltIns: false, 22 | }, 23 | ], 24 | [ 25 | '@babel/preset-typescript', 26 | { 27 | isTSX: false, 28 | allExtensions: false, 29 | }, 30 | ], 31 | ], 32 | plugins, 33 | }; 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, jest: true }, 4 | plugins: ['prettier'], 5 | extends: ['airbnb', 'prettier'], 6 | overrides: [ 7 | { 8 | files: ['*.ts'], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { project: './tsconfig.json' }, 11 | plugins: ['@typescript-eslint', 'prettier'], 12 | extends: ['airbnb-typescript', 'prettier', 'prettier/@typescript-eslint'], 13 | rules: { 14 | 'import/prefer-default-export': 0, 15 | 'react/no-children-prop': [ 16 | 1, 17 | { 18 | allowFunctions: true, 19 | }, 20 | ], 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "saturday" 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ci: 13 | name: CI 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Install 20 | run: yarn 21 | 22 | - name: Lint 23 | run: yarn lint 24 | 25 | - name: Build 26 | run: yarn build 27 | 28 | - name: Test 29 | run: yarn test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .yarn-integrity 7 | *.tgz 8 | 9 | .eslintcache 10 | .vscode/ 11 | node_modules/ 12 | dist/ 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.0.0](https://github.com/samouss/react-hooks-sse/compare/v1.0.0...v2.0.0) (2020-04-24) 2 | 3 | ### Breaking changes 4 | 5 | #### `initialState` is required 6 | 7 | It follows the default implementation of [`React.Reducer`](https://reactjs.org/docs/hooks-reference.html#usereducer) which requires the initial state. To better reflect this change the signature of the hook also changed. The `eventName` **and** `initialState` are now positional parameters. It also helps to infer the state with TypeScript. 8 | 9 | ```diff 10 | const state = useSSE( 11 | 'comments', 12 | + { 13 | + count: null, 14 | + } 15 | - { 16 | - initialState: { 17 | - count: null, 18 | - }, 19 | - } 20 | ); 21 | ``` 22 | 23 | #### `stateReducer` default implementation 24 | 25 | The signature of the default `stateReducer` was incorrect. The new implementation is now compliant with its signature. By default we assume that the data that comes from the server is the same as the state. You can override this behavior with the type parameter `T`. 26 | 27 | ```diff 28 | const state = useSSE( 29 | 'comments', 30 | { 31 | count: null, 32 | }, 33 | { 34 | stateReducer(state: S, action: Action) { 35 | - return changes; 36 | + return changes.data; 37 | }, 38 | } 39 | ); 40 | ``` 41 | 42 | ### Features 43 | 44 | #### External source 45 | 46 | You can now provide a custom source to the `SSEProvider`. 47 | 48 | ```jsx 49 | import React from 'react'; 50 | import { SSEProvider } from 'react-hooks-sse'; 51 | import { createCustomSource } from 'custom-source'; 52 | 53 | const App = () => ( 54 | createCustomSource()}> 55 | {/* ... */} 56 | 57 | ); 58 | ``` 59 | 60 | You can find more information in the [documentation](README.md#SSEProvider). 61 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Samuel Vaillant 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Hooks SSE 2 | 3 | [![npm version](https://badge.fury.io/js/react-hooks-sse.svg)](https://badge.fury.io/js/react-hooks-sse) [![Build Status](https://travis-ci.org/samouss/react-hooks-sse.svg?branch=master)](https://travis-ci.org/samouss/react-hooks-sse) 4 | 5 | ## Installation 6 | 7 | ``` 8 | yarn add react-hooks-sse 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```jsx 14 | import React from 'react'; 15 | import { useSSE, SSEProvider } from 'react-hooks-sse'; 16 | 17 | const Comments = () => { 18 | const state = useSSE('comments', { 19 | count: null 20 | }); 21 | 22 | return state.count ? state.count : '...'; 23 | }; 24 | 25 | const App = () => ( 26 | 27 |

Subscribe & update to SSE event

28 | 29 |
30 | ); 31 | ``` 32 | 33 | > Checkout [the example](/example) on the project 34 | 35 | ## API 36 | 37 | #### `SSEProvider` 38 | 39 | The provider manages subscriptions to the SSE server. You can subscribe multiple times to the same event or on different events. The source is lazy, it is created only when one of the hooks is called. The source is destroyed when no more hooks are registered. It is automatically re-created when a new hook is added. 40 | 41 | #### Usage 42 | 43 | ```jsx 44 | import React from 'react'; 45 | import { SSEProvider } from 'react-hooks-sse'; 46 | 47 | const App = () => ( 48 | 49 | {/* ... */} 50 | 51 | ); 52 | ``` 53 | 54 | #### `endpoint: string` 55 | 56 | > The value is required when `source` is omitted. 57 | 58 | The SSE endpoint to target. It uses the default source [`EventSource`][EventSource]. 59 | 60 | ```jsx 61 | import React from 'react'; 62 | import { SSEProvider } from 'react-hooks-sse'; 63 | 64 | const App = () => ( 65 | 66 | {/* ... */} 67 | 68 | ); 69 | ``` 70 | 71 | #### `source: () => Source` 72 | 73 | > The value is required when `endpoint` is omitted. 74 | 75 | You can provide custom source to the provider. The main use cases are: 76 | 77 | - provide additional options to [`EventSource`][EventSource] e.g. [`withCredentials: true`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource#Parameters) 78 | - provide a custom source to control the network request e.g. set `Authorization` header 79 | 80 | Here is the interface that a source has to implement: 81 | 82 | ```ts 83 | interface Event { 84 | data: any; 85 | } 86 | 87 | interface Listener { 88 | (event: Event): void; 89 | } 90 | 91 | interface Source { 92 | addEventListener(name: string, listener: Listener): void; 93 | removeEventListener(name: string, listener: Listener): void; 94 | close(): void; 95 | } 96 | ``` 97 | 98 | The source is lazy, it is created only when a hook is added. That's why we provide a function to create a source not a source directly. 99 | 100 | ```jsx 101 | import React from 'react'; 102 | import { SSEProvider } from 'react-hooks-sse'; 103 | import { createCustomSource } from 'custom-source'; 104 | 105 | const App = () => ( 106 | createCustomSource()}> 107 | {/* ... */} 108 | 109 | ); 110 | ``` 111 | 112 | ---- 113 | 114 | #### `useSSE(eventName: string, initialState: S, options?: Options)` 115 | 116 | The component that uses the hook must be scoped under a [`SSEProvider`](#SSEProvider) to have access to the source. Once the hook is created none of the options can be updated (at the moment). You have to unmout/remount the component to update the options. 117 | 118 | #### Usage 119 | 120 | ```jsx 121 | const state = useSSE('comments', { 122 | count: null 123 | }); 124 | ``` 125 | 126 | #### `eventName: string` 127 | 128 | The name of the event that you want to listen. 129 | 130 | ```jsx 131 | const state = useSSE('comments', { 132 | count: null 133 | }); 134 | ``` 135 | 136 | #### `initialState: S` 137 | 138 | The initial state to use on the first render. 139 | 140 | ```jsx 141 | const state = useSSE('comments', { 142 | count: null 143 | }); 144 | ``` 145 | 146 | #### `options?: Options` 147 | 148 | The options to control how the data is consumed from the source. 149 | 150 | ```ts 151 | type Action = { event: Event; data: T }; 152 | type StateReducer = (state: S, changes: Action) => S; 153 | type Parser = (data: any) => T; 154 | 155 | export type Options = { 156 | stateReducer?: StateReducer; 157 | parser?: Parser; 158 | }; 159 | ``` 160 | 161 | #### `options.stateReducer?: (state: S, changes: Action) => S` 162 | 163 | The reducer to control how the state should be updated. 164 | 165 | ```ts 166 | type Action = { 167 | // event is provided by the source 168 | event: Event; 169 | // data is provided by the parser 170 | data: T; 171 | }; 172 | 173 | const state = useSSE( 174 | 'comments', 175 | { 176 | count: null, 177 | }, 178 | { 179 | stateReducer(state: S, action: Action) { 180 | return changes.data; 181 | }, 182 | } 183 | ); 184 | ``` 185 | 186 | #### `options.parser?: (data: any) => T` 187 | 188 | The parser to control how the event from the server is provided to the reducer. 189 | 190 | ```jsx 191 | const state = useSSE( 192 | 'comments', 193 | { 194 | count: null, 195 | }, 196 | { 197 | parser(input: any): T { 198 | return JSON.parse(input); 199 | }, 200 | } 201 | ); 202 | ``` 203 | 204 | ## Run example 205 | 206 | ``` 207 | yarn start:server 208 | ``` 209 | 210 | ``` 211 | yarn start:example 212 | ``` 213 | 214 | ## Run the build 215 | 216 | ``` 217 | yarn build 218 | ``` 219 | 220 | ## Run the test 221 | 222 | ``` 223 | yarn test 224 | ``` 225 | 226 | [EventSource]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource 227 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "^16.9.0", 7 | "@types/react-dom": "^16.9.6", 8 | "cors": "^2.8.5", 9 | "express": "^4.20.0", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2", 12 | "react-hooks-sse": "^2.1.0", 13 | "react-scripts": "^5.0.1", 14 | "typescript": "^4.7.2", 15 | "uuid": "^7.0.3" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samouss/react-hooks-sse/4787203e498b7072572bcb38c8967105c904a435/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | React Hooks SSE - Example 18 | 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const uuid = require('uuid'); 3 | const cors = require('cors'); 4 | const express = require('express'); 5 | 6 | const server = express(); 7 | const emitter = new EventEmitter(); 8 | 9 | server.use(cors()); 10 | 11 | const state = { 12 | likes: 10, 13 | comments: 3, 14 | }; 15 | 16 | server.get('/sse', (req, res) => { 17 | res.writeHead(200, { 18 | 'Content-Type': 'text/event-stream', 19 | 'Cache-Control': 'no-cache', 20 | Connection: 'keep-alive', 21 | }); 22 | 23 | const listener = (event, data) => { 24 | res.write(`id: ${uuid.v4()}\n`); 25 | res.write(`event: ${event}\n`); 26 | res.write(`data: ${JSON.stringify(data)}\n\n`); 27 | }; 28 | 29 | emitter.addListener('push', listener); 30 | 31 | req.on('close', () => { 32 | emitter.removeListener('push', listener); 33 | }); 34 | }); 35 | 36 | server.listen(8080, () => { 37 | console.log('Listen on port 8080...'); 38 | }); 39 | 40 | setInterval(() => { 41 | state.likes += Math.floor(Math.random() * 10) + 1; 42 | 43 | emitter.emit('push', 'likes', { 44 | value: state.likes, 45 | }); 46 | }, 2500); 47 | 48 | setTimeout(() => { 49 | setInterval(() => { 50 | state.comments += Math.floor(Math.random() * 10) + 1; 51 | 52 | emitter.emit('push', 'comments', { 53 | value: state.comments, 54 | }); 55 | }, 2500); 56 | }, 1250); 57 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { SSEProvider } from 'react-hooks-sse'; 3 | import Likes from './Likes'; 4 | import Comments from './Comments'; 5 | import './App.css'; 6 | 7 | const App = () => { 8 | const [showLikes, setShowLikes] = useState(true); 9 | const [showComments, setShowComments] = useState(false); 10 | 11 | return ( 12 |
13 |

React hook SSE

14 | 15 |
16 | 19 | {showLikes && } 20 |
21 |
22 |
23 | 26 | {showComments && } 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /example/src/Comments.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSSE } from 'react-hooks-sse'; 3 | 4 | const Comments = () => { 5 | const last = useSSE('comments', { 6 | value: null, 7 | }); 8 | 9 | return ( 10 |

11 | 12 | 💬 13 | {' '} 14 | {last.value ? last.value : '...'} 15 |

16 | ); 17 | }; 18 | 19 | export default Comments; 20 | -------------------------------------------------------------------------------- /example/src/Likes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSSE } from 'react-hooks-sse'; 3 | 4 | type State = { 5 | count: number | null; 6 | lastChange: number | null; 7 | }; 8 | 9 | type Message = { 10 | value: number; 11 | }; 12 | 13 | const Likes = () => { 14 | const state = useSSE( 15 | 'likes', 16 | { 17 | count: null, 18 | lastChange: null, 19 | }, 20 | { 21 | stateReducer(prevState, action) { 22 | return { 23 | count: action.data.value, 24 | lastChange: 25 | prevState.count !== null 26 | ? action.data.value - prevState.count 27 | : null, 28 | }; 29 | }, 30 | parser(input) { 31 | return JSON.parse(input); 32 | }, 33 | } 34 | ); 35 | 36 | return ( 37 |

38 | 39 | 👍 40 | {' '} 41 | {state.count ? state.count : '...'} 42 | {state.lastChange !== null && ` (+${state.lastChange})`} 43 |

44 | ); 45 | }; 46 | 47 | export default Likes; 48 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | watchPlugins: [ 3 | 'jest-watch-typeahead/filename', 4 | 'jest-watch-typeahead/testname', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks-sse", 3 | "version": "2.1.0", 4 | "description": "React Hook for SSE", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/es/index.js", 7 | "types": "dist/typings/index.d.ts", 8 | "license": "MIT", 9 | "files": [ 10 | "dist", 11 | "src", 12 | "README", 13 | "LICENCE" 14 | ], 15 | "keywords": [ 16 | "react", 17 | "hooks", 18 | "sse" 19 | ], 20 | "scripts": { 21 | "build": "yarn type-check && yarn build:typings && yarn build:cjs && yarn build:esm && yarn build:umd", 22 | "build:cjs": "babel src --out-dir dist/cjs --extensions .ts --ignore 'src/**/__mocks__/**/*','src/**/__tests__/**/*'", 23 | "build:esm": "BABEL_ENV=esm babel src --out-dir dist/es --extensions .ts --ignore 'src/**/__mocks__/**/*','src/**/__tests__/**/*'", 24 | "build:umd": "BABEL_ENV=rollup rollup -c rollup.config.js", 25 | "build:typings": "yarn type-check --project tsconfig.typings.json", 26 | "watch": "yarn build:cjs --watch", 27 | "lint": "eslint src --ext .ts", 28 | "test": "jest", 29 | "type-check": "tsc", 30 | "start:server": "(cd example && yarn && node server.js)", 31 | "start:example": "(cd example && yarn && yarn start)", 32 | "source:link": "scripts/link.sh", 33 | "source:unlink": "scripts/unlink.sh" 34 | }, 35 | "dependencies": { 36 | "@babel/runtime": "^7.1.5" 37 | }, 38 | "devDependencies": { 39 | "@babel/cli": "^7.15.7", 40 | "@babel/core": "^7.15.8", 41 | "@babel/plugin-transform-runtime": "^7.18.0", 42 | "@babel/preset-env": "^7.15.8", 43 | "@babel/preset-typescript": "^7.15.0", 44 | "@rollup/plugin-babel": "^6.0.3", 45 | "@rollup/plugin-commonjs": "^28.0.1", 46 | "@rollup/plugin-node-resolve": "^15.0.1", 47 | "@types/jest": "^29.2.0", 48 | "@types/react": "^18.0.9", 49 | "@types/react-dom": "^18.0.5", 50 | "@types/react-test-renderer": "^18.0.0", 51 | "@typescript-eslint/eslint-plugin": "^5.26.0", 52 | "@typescript-eslint/parser": "^5.26.0", 53 | "babel-jest": "^29.0.1", 54 | "eslint": "^7.32.0", 55 | "eslint-config-airbnb": "^19.0.4", 56 | "eslint-config-airbnb-typescript": "^12.3.1", 57 | "eslint-config-prettier": "^7.2.0", 58 | "eslint-plugin-import": "^2.23.4", 59 | "eslint-plugin-jsx-a11y": "^6.4.1", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "eslint-plugin-react": "^7.20.3", 62 | "eslint-plugin-react-hooks": "^4.2.0", 63 | "jest": "^29.2.2", 64 | "jest-watch-typeahead": "^2.0.0", 65 | "prettier": "^3.0.0", 66 | "react": "^18.2.0", 67 | "react-test-renderer": "^18.2.0", 68 | "rollup": "^2.60.2", 69 | "rollup-plugin-terser": "^7.0.2", 70 | "typescript": "^5.0.4" 71 | }, 72 | "peerDependencies": { 73 | "react": ">= 16.7.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import babel from '@rollup/plugin-babel'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | 6 | const clean = x => x.filter(Boolean); 7 | const configuration = ({ minify }) => ({ 8 | input: 'src/index.ts', 9 | output: { 10 | file: `dist/umd/ReactHookSSE${minify ? '.min' : ''}.js`, 11 | format: 'umd', 12 | name: 'ReactHookSSE', 13 | sourcemap: true, 14 | globals: { 15 | react: 'React', 16 | }, 17 | }, 18 | external: ['react'], 19 | plugins: clean([ 20 | babel({ 21 | exclude: 'node_modules/**', 22 | extensions: ['.ts'], 23 | babelHelpers: 'bundled', 24 | }), 25 | resolve({ 26 | extensions: ['.ts'], 27 | }), 28 | commonjs(), 29 | minify && terser(), 30 | ]), 31 | }); 32 | 33 | export default [ 34 | configuration({ 35 | minify: false, 36 | }), 37 | configuration({ 38 | minify: true, 39 | }), 40 | ]; 41 | -------------------------------------------------------------------------------- /scripts/link.sh: -------------------------------------------------------------------------------- 1 | (cd node_modules/react && yarn link) 2 | yarn link 3 | (cd example && yarn && yarn link react-hooks-sse && yarn link react) 4 | -------------------------------------------------------------------------------- /scripts/unlink.sh: -------------------------------------------------------------------------------- 1 | (cd example && yarn unlink react-hooks-sse && yarn unlink react) 2 | yarn unlink 3 | (cd node_modules/react && yarn unlink) 4 | -------------------------------------------------------------------------------- /src/SSEContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SourceManager, createSourceManager } from './createSourceManager'; 3 | import { Source } from './source'; 4 | 5 | export const SSEContext = React.createContext(null); 6 | 7 | export const SSEConsumer = SSEContext.Consumer; 8 | 9 | type WithSource = { source: () => Source }; 10 | type WithEndpoint = { endpoint: string }; 11 | type Props = React.PropsWithChildren; 12 | 13 | const isPropsWithSource = (_: WithSource | WithEndpoint): _ is WithSource => 14 | 'source' in _; 15 | 16 | const createDefaultSource = (endpoint: string) => (): Source => 17 | new window.EventSource(endpoint); 18 | 19 | export const SSEProvider: React.FC = ({ children, ...props }) => { 20 | const [source] = React.useState(() => 21 | createSourceManager( 22 | !isPropsWithSource(props) 23 | ? createDefaultSource(props.endpoint) 24 | : props.source 25 | ) 26 | ); 27 | 28 | return React.createElement( 29 | SSEContext.Provider, 30 | { 31 | value: source, 32 | }, 33 | children 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/__mocks__/source.mock.ts: -------------------------------------------------------------------------------- 1 | import { Source } from '../source'; 2 | 3 | export type SourceMock = Source & { 4 | simulate(event: string, value: any): void; 5 | }; 6 | 7 | export const createSourceMock = () => { 8 | const state = new Map void>>(); 9 | const source: SourceMock = { 10 | close: jest.fn(), 11 | addEventListener: jest.fn((name, listener) => { 12 | const listeners = state.get(name) || []; 13 | 14 | state.set(name, listeners.concat(listener)); 15 | }), 16 | removeEventListener: jest.fn((name, listener) => { 17 | const listeners = state.get(name) || []; 18 | 19 | state.set( 20 | name, 21 | listeners.filter(x => x !== listener) 22 | ); 23 | }), 24 | simulate: (eventName: string, value: any) => { 25 | const listeners = state.get(eventName) || []; 26 | listeners.forEach(listener => listener(value)); 27 | }, 28 | }; 29 | 30 | return { 31 | fn: jest.fn(() => source), 32 | source, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/__tests__/SSEContext.test.ts: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import TestRenderer from 'react-test-renderer'; 3 | import { createSourceMock } from '../__mocks__/source.mock'; 4 | import { createSourceManager } from '../createSourceManager'; 5 | import { SSEProvider, SSEConsumer } from '../SSEContext'; 6 | 7 | // TODO 8 | jest.mock('../createSourceManager', () => ({ 9 | createSourceManager: jest.fn(), 10 | })); 11 | 12 | describe('SSEContext', () => { 13 | beforeEach(() => { 14 | (createSourceManager as jest.Mock).mockReset(); 15 | }); 16 | 17 | it('expect to create a source manager with `endpoint`', () => { 18 | TestRenderer.create( 19 | createElement(SSEProvider, { 20 | endpoint: 'http://localhost/sse', 21 | }) 22 | ); 23 | 24 | expect(createSourceManager).toHaveBeenCalledWith(expect.any(Function)); 25 | }); 26 | 27 | it('expect to create a source manager with `source`', () => { 28 | const { fn } = createSourceMock(); 29 | 30 | TestRenderer.create( 31 | createElement(SSEProvider, { 32 | source: fn, 33 | }) 34 | ); 35 | 36 | expect(createSourceManager).toHaveBeenCalledWith(fn); 37 | }); 38 | 39 | it('expect to create a source manager only once across render', () => { 40 | const createProviderElement = () => 41 | createElement(SSEProvider, { 42 | endpoint: 'http://localhost/sse', 43 | }); 44 | 45 | const renderer = TestRenderer.create(createProviderElement()); 46 | 47 | expect(createSourceManager).toHaveBeenCalledTimes(1); 48 | 49 | renderer.update(createProviderElement()); 50 | renderer.update(createProviderElement()); 51 | renderer.update(createProviderElement()); 52 | 53 | expect(createSourceManager).toHaveBeenCalledTimes(1); 54 | }); 55 | 56 | it('expect to expose a source manager through the context', () => { 57 | const manager = {}; 58 | 59 | (createSourceManager as jest.Mock).mockImplementationOnce(() => manager); 60 | 61 | TestRenderer.create( 62 | createElement( 63 | SSEProvider, 64 | { 65 | endpoint: 'http://localhost/sse', 66 | }, 67 | createElement(SSEConsumer, { 68 | children(context) { 69 | expect(context).toBe(manager); 70 | return null; 71 | }, 72 | }) 73 | ) 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/__tests__/createSourceManager.test.ts: -------------------------------------------------------------------------------- 1 | import { createSourceMock } from '../__mocks__/source.mock'; 2 | import { createSourceManager } from '../createSourceManager'; 3 | 4 | describe('createSourceManager', () => { 5 | describe('addEventListener', () => { 6 | it('expect to create a source', () => { 7 | const { fn } = createSourceMock(); 8 | const manager = createSourceManager(fn); 9 | const event = 'event'; 10 | const listener = () => {}; 11 | 12 | manager.addEventListener(event, listener); 13 | 14 | expect(fn).toHaveBeenCalledTimes(1); 15 | }); 16 | 17 | it('expect to create a listener on the source', () => { 18 | const { fn, source } = createSourceMock(); 19 | const manager = createSourceManager(fn); 20 | const event = 'event'; 21 | const listener = jest.fn(); 22 | 23 | manager.addEventListener(event, listener); 24 | 25 | expect(listener).toHaveBeenCalledTimes(0); 26 | 27 | // Simulate SSE server 28 | source.simulate('event', { data: 'Apple' }); 29 | 30 | expect(listener).toHaveBeenCalledTimes(1); 31 | expect(listener).toHaveBeenCalledWith({ data: 'Apple' }); 32 | }); 33 | 34 | it('expect to create a source only once a listener is added', () => { 35 | const { fn } = createSourceMock(); 36 | const manager = createSourceManager(fn); 37 | const event = 'event'; 38 | const listener = () => {}; 39 | 40 | expect(fn).toHaveBeenCalledTimes(0); 41 | 42 | manager.addEventListener(event, listener); 43 | 44 | expect(fn).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('expect to create a source only once with multiple listeners', () => { 48 | const { fn } = createSourceMock(); 49 | const manager = createSourceManager(fn); 50 | const event = 'event'; 51 | const listener = () => {}; 52 | 53 | manager.addEventListener(event, listener); 54 | manager.addEventListener(event, listener); 55 | manager.addEventListener(event, listener); 56 | 57 | expect(fn).toHaveBeenCalledTimes(1); 58 | }); 59 | }); 60 | 61 | describe('removeEventListener', () => { 62 | it('expect to remove a listener on the source', () => { 63 | const { fn, source } = createSourceMock(); 64 | const manager = createSourceManager(fn); 65 | const event = 'event'; 66 | const listener = jest.fn(); 67 | 68 | manager.addEventListener(event, listener); 69 | 70 | // Simulate SSE server 71 | source.simulate('event', { data: 'Apple' }); 72 | 73 | expect(listener).toHaveBeenCalledTimes(1); 74 | 75 | manager.removeEventListener(event, listener); 76 | 77 | expect(listener).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | it('expect to close the source if the listener is the last one of all events', () => { 81 | const { fn, source } = createSourceMock(); 82 | const manager = createSourceManager(fn); 83 | const event = 'event'; 84 | const listener = () => {}; 85 | 86 | manager.addEventListener(event, listener); 87 | manager.removeEventListener(event, listener); 88 | 89 | expect(source.close).toHaveBeenCalled(); 90 | }); 91 | 92 | it('expect to not close the source if more listeners remain', () => { 93 | const { fn, source } = createSourceMock(); 94 | const manager = createSourceManager(fn); 95 | 96 | const event0 = 'event0'; 97 | const listener0 = () => {}; 98 | 99 | const event1 = 'event'; 100 | const listener1 = () => {}; 101 | 102 | manager.addEventListener(event0, listener0); 103 | manager.addEventListener(event1, listener1); 104 | manager.removeEventListener(event0, listener0); 105 | 106 | expect(source.close).not.toHaveBeenCalled(); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/__tests__/useSSE.test.ts: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, createElement, createContext } from 'react'; 2 | import TestRenderer, { act } from 'react-test-renderer'; 3 | import { SourceMock, createSourceMock } from '../__mocks__/source.mock'; 4 | import { SourceManager } from '../createSourceManager'; 5 | import { Options, useSSE } from '../useSSE'; 6 | 7 | describe('useSSE', () => { 8 | const createFakeContext = (source: SourceMock) => 9 | createContext(source); 10 | 11 | type State = { 12 | value: string; 13 | }; 14 | 15 | type Props = Options & { 16 | eventName: string; 17 | initialState: State; 18 | children?: (state: State) => ReactNode; 19 | }; 20 | 21 | const FakeApp: FC = ({ 22 | eventName, 23 | initialState, 24 | children, 25 | ...options 26 | }) => { 27 | const state = useSSE(eventName, initialState, options); 28 | 29 | if (!children || typeof children !== 'function') { 30 | return null; 31 | } 32 | 33 | return createElement('div', {}, children(state)); 34 | }; 35 | 36 | it('expect to throw an error outside of `SSEContext`', () => { 37 | const eventName = 'push'; 38 | const initialState = { 39 | value: 'Apple', 40 | }; 41 | 42 | expect(() => 43 | TestRenderer.create( 44 | createElement(FakeApp, { 45 | eventName, 46 | initialState, 47 | }) 48 | ) 49 | ).toThrowErrorMatchingInlineSnapshot( 50 | `"Could not find an SSE context; You have to wrap useSSE() in a ."` 51 | ); 52 | }); 53 | 54 | it('expect to `useSSE` with the default options', () => { 55 | const { source } = createSourceMock(); 56 | const context = createFakeContext(source); 57 | const children = jest.fn(); 58 | const eventName = 'push'; 59 | const initialState = { 60 | value: 'Apple', 61 | }; 62 | 63 | TestRenderer.create( 64 | createElement(FakeApp, { 65 | eventName, 66 | initialState, 67 | context, 68 | children(...args): ReactNode { 69 | return children(...args); 70 | }, 71 | }) 72 | ); 73 | 74 | expect(children).toHaveBeenCalledWith({ 75 | value: 'Apple', 76 | }); 77 | }); 78 | 79 | describe('subscription', () => { 80 | it('expect to register a listener on mount', () => { 81 | const { source } = createSourceMock(); 82 | const context = createFakeContext(source); 83 | const eventName = 'push'; 84 | const initialState = { 85 | value: 'Apple', 86 | }; 87 | 88 | act(() => { 89 | TestRenderer.create( 90 | createElement(FakeApp, { 91 | eventName, 92 | initialState, 93 | context, 94 | }) 95 | ); 96 | }); 97 | 98 | expect(source.addEventListener).toHaveBeenCalledTimes(1); 99 | expect(source.addEventListener).toHaveBeenCalledWith( 100 | eventName, 101 | expect.any(Function) 102 | ); 103 | }); 104 | 105 | it('expect to not register a listener on update', () => { 106 | const { source } = createSourceMock(); 107 | const context = createFakeContext(source); 108 | const eventName = 'push'; 109 | const initialState = { 110 | value: 'Apple', 111 | }; 112 | 113 | const renderer = TestRenderer.create(createElement('div')); 114 | const mockCreateElement = jest.fn(() => 115 | createElement(FakeApp, { 116 | eventName, 117 | initialState, 118 | context, 119 | }) 120 | ); 121 | 122 | const createFakeApp = () => mockCreateElement(); 123 | 124 | act(() => { 125 | renderer.update(createFakeApp()); 126 | }); 127 | 128 | expect(mockCreateElement).toHaveBeenCalledTimes(1); 129 | expect(source.addEventListener).toHaveBeenCalledTimes(1); 130 | 131 | act(() => { 132 | renderer.update(createFakeApp()); 133 | renderer.update(createFakeApp()); 134 | renderer.update(createFakeApp()); 135 | }); 136 | 137 | expect(mockCreateElement).toHaveBeenCalledTimes(4); 138 | expect(source.addEventListener).toHaveBeenCalledTimes(1); 139 | }); 140 | 141 | it('expect to remove the listener on unmount', () => { 142 | const { source } = createSourceMock(); 143 | const context = createFakeContext(source); 144 | const renderer = TestRenderer.create(createElement('div')); 145 | const eventName = 'push'; 146 | const initialState = { 147 | value: 'Apple', 148 | }; 149 | 150 | act(() => { 151 | renderer.update( 152 | createElement(FakeApp, { 153 | eventName, 154 | initialState, 155 | context, 156 | }) 157 | ); 158 | }); 159 | 160 | act(() => { 161 | renderer.unmount(); 162 | }); 163 | 164 | expect(source.removeEventListener).toHaveBeenCalledTimes(1); 165 | expect(source.removeEventListener).toHaveBeenCalledWith( 166 | eventName, 167 | expect.any(Function) 168 | ); 169 | }); 170 | }); 171 | 172 | describe('updater', () => { 173 | it('expect to parse the value with the default JSON parser', () => { 174 | const { source } = createSourceMock(); 175 | const context = createFakeContext(source); 176 | const children = jest.fn(); 177 | const eventName = 'push'; 178 | const initialState = { 179 | value: 'Apple', 180 | }; 181 | 182 | act(() => { 183 | TestRenderer.create( 184 | createElement(FakeApp, { 185 | eventName, 186 | initialState, 187 | context, 188 | children(...args): ReactNode { 189 | return children(...args); 190 | }, 191 | }) 192 | ); 193 | }); 194 | 195 | act(() => { 196 | // Simulate SSE server 197 | source.simulate(eventName, { 198 | data: JSON.stringify({ 199 | value: 'iPhone', 200 | }), 201 | }); 202 | }); 203 | 204 | expect(children).toHaveBeenLastCalledWith({ 205 | value: 'iPhone', 206 | }); 207 | }); 208 | 209 | it('expect to parse the value with the given parser', () => { 210 | const { source } = createSourceMock(); 211 | const context = createFakeContext(source); 212 | const children = jest.fn(); 213 | const eventName = 'push'; 214 | const initialState = { 215 | value: 'Apple', 216 | }; 217 | 218 | const parser = (input: any) => { 219 | const data = JSON.parse(input); 220 | data.value += ' XS'; 221 | return data; 222 | }; 223 | 224 | act(() => { 225 | TestRenderer.create( 226 | createElement(FakeApp, { 227 | eventName, 228 | initialState, 229 | parser, 230 | context, 231 | children(...args): ReactNode { 232 | return children(...args); 233 | }, 234 | }) 235 | ); 236 | }); 237 | 238 | act(() => { 239 | // Simulate SSE server 240 | source.simulate(eventName, { 241 | data: JSON.stringify({ 242 | value: 'iPhone', 243 | }), 244 | }); 245 | }); 246 | 247 | expect(children).toHaveBeenLastCalledWith({ 248 | value: 'iPhone XS', 249 | }); 250 | }); 251 | 252 | it('expect to call the stateReducer with state, data and event', () => { 253 | const { source } = createSourceMock(); 254 | const context = createFakeContext(source); 255 | const eventName = 'push'; 256 | const stateReducer = jest.fn((_, action) => action); 257 | const initialState = { 258 | value: 'Apple', 259 | }; 260 | 261 | act(() => { 262 | TestRenderer.create( 263 | createElement(FakeApp, { 264 | eventName, 265 | initialState, 266 | stateReducer, 267 | context, 268 | }) 269 | ); 270 | }); 271 | 272 | act(() => { 273 | // Simulate SSE server 274 | source.simulate(eventName, { 275 | data: JSON.stringify({ 276 | value: 'Apple', 277 | }), 278 | }); 279 | }); 280 | 281 | expect(stateReducer).toHaveBeenLastCalledWith( 282 | { 283 | value: 'Apple', 284 | }, 285 | { 286 | data: { 287 | value: 'Apple', 288 | }, 289 | event: { 290 | data: JSON.stringify({ 291 | value: 'Apple', 292 | }), 293 | }, 294 | } 295 | ); 296 | 297 | act(() => { 298 | // Simulate SSE server 299 | source.simulate(eventName, { 300 | data: JSON.stringify({ 301 | value: 'Apple iPhone XS', 302 | }), 303 | }); 304 | }); 305 | 306 | expect(stateReducer).toHaveBeenLastCalledWith( 307 | { 308 | data: { 309 | value: 'Apple', 310 | }, 311 | event: { 312 | data: JSON.stringify({ 313 | value: 'Apple', 314 | }), 315 | }, 316 | }, 317 | { 318 | data: { 319 | value: 'Apple iPhone XS', 320 | }, 321 | event: { 322 | data: JSON.stringify({ 323 | value: 'Apple iPhone XS', 324 | }), 325 | }, 326 | } 327 | ); 328 | }); 329 | 330 | it('expect to return the value from the default stateReducer', () => { 331 | const { source } = createSourceMock(); 332 | const context = createFakeContext(source); 333 | const children = jest.fn(); 334 | const eventName = 'push'; 335 | const initialState = { 336 | value: 'Apple', 337 | }; 338 | 339 | act(() => { 340 | TestRenderer.create( 341 | createElement(FakeApp, { 342 | eventName, 343 | initialState, 344 | context, 345 | children(...args): ReactNode { 346 | return children(...args); 347 | }, 348 | }) 349 | ); 350 | }); 351 | 352 | act(() => { 353 | // Simulate SSE server 354 | source.simulate(eventName, { 355 | data: JSON.stringify({ 356 | value: 'Apple iPhone', 357 | }), 358 | }); 359 | }); 360 | 361 | expect(children).toHaveBeenLastCalledWith({ 362 | value: 'Apple iPhone', 363 | }); 364 | }); 365 | 366 | it('expect to return the value from the given stateReducer', () => { 367 | const children = jest.fn(); 368 | const eventName = 'push'; 369 | const initialState = { 370 | value: 'first', 371 | previous: null, 372 | }; 373 | 374 | const stateReducer = (state: any, action: any) => ({ 375 | value: action.data.value, 376 | previous: state.value, 377 | }); 378 | 379 | const { source } = createSourceMock(); 380 | const context = createFakeContext(source); 381 | 382 | act(() => { 383 | TestRenderer.create( 384 | createElement(FakeApp, { 385 | eventName, 386 | initialState, 387 | stateReducer, 388 | context, 389 | children(...args): ReactNode { 390 | return children(...args); 391 | }, 392 | }) 393 | ); 394 | }); 395 | 396 | expect(children).toHaveBeenLastCalledWith({ 397 | value: 'first', 398 | previous: null, 399 | }); 400 | 401 | act(() => { 402 | // Simulate SSE server 403 | source.simulate(eventName, { 404 | data: JSON.stringify({ 405 | value: 'second', 406 | }), 407 | }); 408 | }); 409 | 410 | expect(children).toHaveBeenLastCalledWith({ 411 | value: 'second', 412 | previous: 'first', 413 | }); 414 | }); 415 | }); 416 | }); 417 | -------------------------------------------------------------------------------- /src/createSourceManager.ts: -------------------------------------------------------------------------------- 1 | import { Source, Listener } from './source'; 2 | 3 | type State = { 4 | source: Source | null; 5 | listenersByName: Map>; 6 | }; 7 | 8 | export type SourceManager = { 9 | addEventListener(name: string, listener: Listener): void; 10 | removeEventListener(name: string, listener: Listener): void; 11 | }; 12 | 13 | export const createSourceManager = ( 14 | createSource: () => Source 15 | ): SourceManager => { 16 | const state: State = { 17 | source: null, 18 | listenersByName: new Map(), 19 | }; 20 | 21 | return { 22 | addEventListener(name, listener) { 23 | if (!state.listenersByName.size) { 24 | state.source = createSource(); 25 | } 26 | 27 | if (!state.source) { 28 | throw new Error("The source doesn't exist"); 29 | } 30 | 31 | const listeners = state.listenersByName.get(name) || new Set(); 32 | 33 | listeners.add(listener); 34 | 35 | state.listenersByName.set(name, listeners); 36 | 37 | state.source.addEventListener(name, listener); 38 | }, 39 | removeEventListener(name, listener) { 40 | if (!state.source) { 41 | throw new Error("The source doesn't exist"); 42 | } 43 | 44 | const listeners = state.listenersByName.get(name) || new Set(); 45 | 46 | listeners.delete(listener); 47 | 48 | if (!listeners.size) { 49 | state.listenersByName.delete(name); 50 | } 51 | 52 | state.source.removeEventListener(name, listener); 53 | 54 | if (!state.listenersByName.size) { 55 | state.source.close(); 56 | state.source = null; 57 | } 58 | }, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SSEContext'; 2 | export * from './useSSE'; 3 | export * from './source'; 4 | -------------------------------------------------------------------------------- /src/source.ts: -------------------------------------------------------------------------------- 1 | export interface Event { 2 | data: any; 3 | } 4 | 5 | export interface Listener { 6 | (event: Event): void; 7 | } 8 | 9 | export interface Source { 10 | addEventListener(name: string, listener: Listener): void; 11 | removeEventListener(name: string, listener: Listener): void; 12 | close(): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/useSSE.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useReducer, useEffect } from 'react'; 2 | import { Listener, Event } from './source'; 3 | import { SSEContext } from './SSEContext'; 4 | 5 | type Action = { event: Event; data: T }; 6 | type StateReducer = (state: S, changes: Action) => S; 7 | type Parser = (data: any) => T; 8 | 9 | export type Options = { 10 | stateReducer?: StateReducer; 11 | parser?: Parser; 12 | context?: typeof SSEContext; 13 | }; 14 | 15 | export function useSSE( 16 | eventName: string, 17 | initialState: S, 18 | options?: Options 19 | ): S { 20 | const { 21 | stateReducer = (_: S, action: Action) => action.data, 22 | parser = (data: any) => JSON.parse(data), 23 | context = SSEContext, 24 | } = options || {}; 25 | 26 | const source = useContext(context); 27 | const [state, dispatch] = useReducer>( 28 | stateReducer, 29 | initialState 30 | ); 31 | 32 | if (!source) { 33 | throw new Error( 34 | 'Could not find an SSE context; You have to wrap useSSE() in a .' 35 | ); 36 | } 37 | 38 | useEffect(() => { 39 | const listener: Listener = event => { 40 | const data = parser(event.data); 41 | 42 | dispatch({ 43 | event, 44 | data, 45 | }); 46 | }; 47 | 48 | source.addEventListener(eventName, listener); 49 | 50 | return () => { 51 | source.removeEventListener(eventName, listener); 52 | }; 53 | }, []); 54 | 55 | return state; 56 | } 57 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "ESNEXT", 5 | "module": "es2015", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "noEmit": true, 9 | "strict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "allowSyntheticDefaultImports": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/**/*.test.ts", "src/**/*.mock.ts"], 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "dist/typings" 9 | } 10 | } 11 | --------------------------------------------------------------------------------