├── .npmrc ├── .gitignore ├── .lintstagedrc ├── .babelrc.js ├── rollup.config.js ├── README.md ├── LICENSE ├── package.json └── src ├── index.js └── index.spec.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "{src,__tests__}/**/*.js": [ 3 | "prettier --single-quote --trailing-comma=all --write", 4 | "git add" 5 | ], 6 | "*.md": [ 7 | "prettier --single-quote --trailing-comma=all --write", 8 | "git add" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { BABEL_ENV, NODE_ENV } = process.env 2 | const modules = BABEL_ENV === 'cjs' || NODE_ENV === 'test' ? 'commonjs' : false 3 | const loose = true 4 | 5 | module.exports = { 6 | presets: [ 7 | ['@babel/env', { 8 | loose, 9 | modules, 10 | }], 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import pkg from './package.json' 3 | 4 | export default { 5 | input: 'src/index.js', 6 | output: [ 7 | { file: pkg.main, format: 'cjs', exports: 'named' }, 8 | { file: pkg.module, format: 'es' }, 9 | ], 10 | external: Object.keys(pkg.dependencies || {}), 11 | plugins: [babel()], 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # callbag-websocket 2 | 3 | Callbag sink and listenable source that connects using [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) interface. 4 | 5 | Read more about Callbag standard [here](https://github.com/callbag/callbag). 6 | 7 | ## Example 8 | 9 | ```js 10 | import websocket from 'callbag-websocket'; 11 | import observe from 'callbag-observe'; 12 | 13 | let ws = websocket('ws://demos.kaazing.com/echo'); 14 | let i = 0; 15 | setInterval(() => { 16 | ws(1, 'msg' + i++); 17 | }, 1000); 18 | 19 | observe(msg => console.log('obs1', msg.data))(ws); 20 | 21 | setTimeout(() => { 22 | observe(msg => console.log('obs2', msg.data))(ws); 23 | }, 2500); 24 | 25 | // OUTPUT: 26 | // obs1 msg0 27 | // obs1 msg1 28 | // obs1 msg2 29 | // obs2 msg2 30 | // obs1 msg3 31 | // obs2 msg3 32 | // ... 33 | ``` 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Łukasz Wojciechowski 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "callbag-websocket", 3 | "version": "1.0.2", 4 | "description": "👜 Callbag source factory from WebSocket.", 5 | "main": "dist/callbag-websocket.js", 6 | "module": "dist/callbag-websocket.es.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "pretest": "npm run build", 12 | "test": "jest", 13 | "precommit": "lint-staged", 14 | "prebuild": "rimraf dist", 15 | "build": "rollup -c", 16 | "prepare": "npm test" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/lwojciechowski/callbag-websocket.git" 21 | }, 22 | "keywords": [ 23 | "callbag", 24 | "callbags", 25 | "sample" 26 | ], 27 | "author": "Łukasz Wojciechowski (https://github.com/lwojciechowski)", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/lwojciechowski/callbag-websocket/issues" 31 | }, 32 | "homepage": "https://github.com/lwojciechowski/callbag-websocket#readme", 33 | "devDependencies": { 34 | "@babel/core": "7.0.0-beta.42", 35 | "@babel/preset-env": "7.0.0-beta.42", 36 | "babel-core": "^7.0.0-bridge.0", 37 | "babel-jest": "^22.4.3", 38 | "husky": "^0.14.3", 39 | "jest": "^22.4.3", 40 | "lint-staged": "^7.0.0", 41 | "prettier": "^1.11.1", 42 | "rimraf": "^2.6.2", 43 | "rollup": "^0.57.1", 44 | "rollup-plugin-babel": "^4.0.0-beta.3" 45 | }, 46 | "dependencies": {} 47 | } 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export const websocketFactory = WebSocket => (url, protocol) => { 2 | const sinks = []; 3 | const buffer = []; 4 | let ws = null; 5 | 6 | function sendToSinks(type, data) { 7 | const zinkz = sinks.slice(0); 8 | 9 | zinkz.forEach(sink => { 10 | if (sinks.indexOf(sink) > -1) { 11 | sink(type, data); 12 | } 13 | }); 14 | } 15 | 16 | function sendToWs(data) { 17 | if (ws && ws.readyState === 1) { 18 | ws.send(data); 19 | } else { 20 | buffer.push(data); 21 | } 22 | } 23 | 24 | function connectWs() { 25 | if (!ws) { 26 | ws = new WebSocket(url, protocol); 27 | 28 | ws.onopen = () => { 29 | let data; 30 | while ((data = buffer.pop())) { 31 | ws.send(data); 32 | } 33 | }; 34 | 35 | ws.onmessage = msg => { 36 | sendToSinks(1, msg); 37 | }; 38 | 39 | ws.onclose = err => { 40 | sendToSinks(2, err.wasClean ? null : err); 41 | }; 42 | } 43 | } 44 | 45 | function disconnectWs() { 46 | if (ws && ws.readyState === 1) { 47 | ws.close(); 48 | ws = null; 49 | } 50 | } 51 | 52 | return (type, data) => { 53 | if (type === 0) { 54 | // Source 55 | const sink = data; 56 | sinks.push(sink); 57 | connectWs(); 58 | 59 | // Source handshake 60 | sink(0, t => { 61 | if (t === 2) { 62 | const i = sinks.indexOf(sink); 63 | if (i > -1) { 64 | sinks.splice(i, 1); 65 | } 66 | 67 | if (sinks.length === 0 && ws.readyState === 1) { 68 | disconnectWs(); 69 | } 70 | } 71 | }); 72 | } else if (typeof type === 'function') { 73 | // Sink 74 | const source = type; 75 | 76 | // Automatically pull from source 77 | source(0, (type, data) => { 78 | if (type === 1) { 79 | sendToWs(data); 80 | } 81 | }); 82 | } else if (type === 1) { 83 | // Manually send data 84 | sendToWs(data); 85 | } else if (type === 2) { 86 | // Manually disconnect 87 | disconnectWs(); 88 | } 89 | }; 90 | }; 91 | 92 | export default /*#__PURE__*/ websocketFactory(WebSocket); 93 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | import { websocketFactory } from '.'; 2 | 3 | let WebSocketMock, webSocketInstanceMock, websocket; 4 | beforeEach(() => { 5 | WebSocketMock = jest.fn(function() { 6 | webSocketInstanceMock = this; 7 | }); 8 | websocket = websocketFactory(WebSocketMock); 9 | }); 10 | 11 | describe('fromWebsocket', () => { 12 | describe('as source', () => { 13 | it('creates new connection with first sink', () => { 14 | const ws = websocket('url', 'protocol'); 15 | expect(WebSocketMock).not.toHaveBeenCalled(); 16 | 17 | ws(0, () => {}); 18 | expect(WebSocketMock).toHaveBeenCalledTimes(1); 19 | expect(WebSocketMock).toHaveBeenCalledWith('url', 'protocol'); 20 | }); 21 | 22 | it('attaches websocket handles on connect', done => { 23 | WebSocketMock = jest.fn(function() { 24 | // let handlers attach 25 | setTimeout(() => { 26 | expect(this.onopen).toBeTruthy(); 27 | expect(this.onmessage).toBeTruthy(); 28 | expect(this.onclose).toBeTruthy(); 29 | done(); 30 | }); 31 | }); 32 | 33 | websocket = websocketFactory(WebSocketMock); 34 | const ws = websocket('url', 'protocol'); 35 | ws(0, () => {}); 36 | }); 37 | 38 | it('sends message to sinks when arrives from websocket', () => { 39 | const ws = websocket('url', 'protocol'); 40 | const sinkMock = jest.fn(); 41 | ws(0, sinkMock); 42 | sinkMock.mockReset(); 43 | webSocketInstanceMock.onmessage('msg'); 44 | expect(sinkMock).toHaveBeenCalledTimes(1); 45 | expect(sinkMock).toHaveBeenCalledWith(1, 'msg'); 46 | }); 47 | 48 | it('removes sink when it sends exit code', () => { 49 | const ws = websocket('url', 'protocol'); 50 | const sinkMock = jest.fn(); 51 | ws(0, sinkMock); 52 | 53 | const sinkChannel = sinkMock.mock.calls[0][1]; 54 | sinkMock.mockReset(); 55 | sinkChannel(2); 56 | 57 | webSocketInstanceMock.onmessage('msg'); 58 | expect(sinkMock).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it('disconnects when last sink removed', () => { 62 | const close = jest.fn(); 63 | WebSocketMock = jest.fn(function() { 64 | this.readyState = 1; 65 | this.close = close; 66 | }); 67 | websocket = websocketFactory(WebSocketMock); 68 | 69 | const ws = websocket('url', 'protocol'); 70 | const sinkMock = jest.fn(); 71 | ws(0, sinkMock); 72 | const sinkChannel = sinkMock.mock.calls[0][1]; 73 | expect(WebSocketMock).toHaveBeenCalledTimes(1); 74 | expect(WebSocketMock).toHaveBeenCalledWith('url', 'protocol'); 75 | sinkChannel(2); 76 | 77 | expect(close).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | it('connects to websocket with another sink after disconnecting', () => { 81 | WebSocketMock = jest.fn(function() { 82 | this.readyState = 1; 83 | this.close = () => {}; 84 | }); 85 | websocket = websocketFactory(WebSocketMock); 86 | 87 | const ws = websocket('url', 'protocol'); 88 | const sinkMock = jest.fn(); 89 | ws(0, sinkMock); 90 | const sinkChannel = sinkMock.mock.calls[0][1]; 91 | sinkChannel(2); 92 | ws(0, sinkMock); 93 | expect(WebSocketMock).toHaveBeenCalledTimes(2); 94 | expect(WebSocketMock).toHaveBeenCalledWith('url', 'protocol'); 95 | }); 96 | }); 97 | 98 | describe('as sink', () => {}); 99 | }); 100 | --------------------------------------------------------------------------------