├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── CreateStopStartDelete.gif ├── EmitMessages.gif ├── PlannedResponse.gif └── socketcast_logo.png ├── babel.config.js ├── jest.config.js ├── package.json ├── src ├── ServerManager │ ├── ServerAbstract.ts │ ├── ServerManager.ts │ ├── SocketEvent.js │ └── type.ts ├── components │ ├── EventConfig.tsx │ ├── PlannedResponseCreator │ │ ├── PRUnit.tsx │ │ ├── PlannedResponseCreator.tsx │ │ ├── type.ts │ │ └── utils.ts │ ├── ScrollAnchor.tsx │ ├── ServerForm.tsx │ ├── Sidebar.tsx │ ├── StreamDisplay.tsx │ ├── StreamInput.tsx │ ├── StreamTab.tsx │ ├── app.tsx │ └── input │ │ └── InputText.tsx ├── index.html ├── index.tsx ├── main.ts ├── store │ ├── actions │ │ ├── actionTypes.ts │ │ ├── messagesActions.ts │ │ ├── navigationActions.ts │ │ └── serversActions.ts │ ├── reducers │ │ ├── index.ts │ │ ├── messagesReducer.ts │ │ ├── navigationReducer.ts │ │ └── serversReducer.ts │ └── store.ts └── styles │ ├── app.scss │ └── hack-font │ ├── fonts │ ├── hack-bold-subset.woff │ ├── hack-bold-subset.woff2 │ ├── hack-bold.woff │ ├── hack-bold.woff2 │ ├── hack-bolditalic-subset.woff │ ├── hack-bolditalic-subset.woff2 │ ├── hack-bolditalic.woff │ ├── hack-bolditalic.woff2 │ ├── hack-italic-subset.woff │ ├── hack-italic-subset.woff2 │ ├── hack-italic.woff │ ├── hack-italic.woff2 │ ├── hack-regular-subset.woff │ ├── hack-regular-subset.woff2 │ ├── hack-regular.woff │ └── hack-regular.woff2 │ ├── hack-subset.css │ ├── hack-subset.css.in │ ├── hack.css │ └── hack.css.in ├── tests └── components │ ├── PlannedResponseCreator │ └── utils.test.ts │ └── ServerManager │ └── SocketEvent.test.js ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-typescript'], 3 | // parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | project: './tsconfig.json', 6 | }, 7 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | dist 4 | coverage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 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 | 2 |

3 | 7 |

8 | 9 |

10 | GitHub 11 | last commit 12 | Repo stars 13 |

14 | 15 | 16 | ## Motivation 17 | Developers working on real-time data applications and services may need to rapidly test their client applications with WebSocket or SSE servers. Socketcast provides developers with a tool to configure and run server instances that serve stub websocket and SSE data to connected clients. 18 | 19 | ## Features 20 | * Configure, launch, and manage multiple WebSocket or SSE servers 21 | * Send data to connected clients 22 | * Plan out a data stream to send connected clients 23 | * Persistent local storage of server configurations 24 | 25 | ## Getting Started 26 | 27 | 1. Fork and clone the Socketcast Repo 28 | 2. Install node dependencies 29 | ```bash 30 | npm i 31 | ``` 32 | 3. Bundle the application 33 | ```js 34 | npm run build 35 | ``` 36 | 4. Start the application 37 | 38 | ```bash 39 | npm start 40 | ``` 41 | 42 | ## Create Servers and Configure Server Instances 43 | - Create one or many stub servers to run in parallel to test your application. 44 | - Choose the WebSocket or Server Side Events (SSE) protocol to connect over. 45 | - Click start server and voila! You have a functioning server to test your application. 46 | 47 | 51 | 52 | ## Send Data 53 | - Insert data into message input 54 | - Emit data! 55 | 56 | 60 | 61 | 62 | ## Create and send Data Streams to Connected Clients 63 | - Connect a client application by opening a websocket connection to the specified port. 64 | - Open stream planner and construct stream with messages and delays. 65 | - Send the stream! 66 | 67 | 71 | 72 | ## Development 73 | ### Built With 74 | * Electron 75 | * TypeScript 76 | * React 77 | * Redux 78 | * Redux-thunk 79 | * Websockets 80 | * Jest 81 | * Webpack 82 | 83 | 84 | ### Client 85 | Socketcast is an Electron cross platform desktop app. It is built with React. 86 | 87 | ### ServerManager 88 | Socketcast has the ability to launch multiple WebSocket servers and it does so through an abstraction called ServerManager. The client application should never manage any servers directly, but instead interact with ServerManager's exposed API through Redux actions. 89 | 90 | ## What's next? 91 | * Add support for other protocols such as HTTP2 92 | * Create the ability to build URL paths for the client application to subscribe selectively to events 93 | * Add ability to parse authentication headers 94 | * Log debugging information from client 95 | 96 | ## Want to contribute? 97 | Socketcast encourages contributions to this product, pull requests are welcome. 98 | 99 | Fork the repo and create a working branch from master. 100 | If you've added any code that requires testing, add tests. 101 | Check to ensure that all tests pass. 102 | Make sure code is formatted with prettier and follows the Airbnb React/JSX Style Guide. 103 | Create a pull request to master. 104 | 105 | Issues 106 | Please do not hesitate to file issues. Socketcast is based off of community feedback and is always looking for ways to get better. We are interested to hear about your experience and how we can improve Socketcast. 107 | 108 | 109 | ## Authors 110 | * Will Bladon [whbladon](https://github.com/whbladon) 111 | * Chris Docuyanan [cjo2](https://github.com/cjo2) 112 | * Chance Hernandez [ItsChance-BTW](https://github.com/ItsChance-BTW) 113 | * Colin Vandergraaf [colinvandergraaf](https://github.com/colinvandergraaf) 114 | 115 | ## License 116 | [License](https://github.com/oslabs-beta/socketcast/LICENSE) 117 | -------------------------------------------------------------------------------- /assets/CreateStopStartDelete.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/socketcast/f0f1c7d9f7f414ace50f1dd8045ab91b4d68f858/assets/CreateStopStartDelete.gif -------------------------------------------------------------------------------- /assets/EmitMessages.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/socketcast/f0f1c7d9f7f414ace50f1dd8045ab91b4d68f858/assets/EmitMessages.gif -------------------------------------------------------------------------------- /assets/PlannedResponse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/socketcast/f0f1c7d9f7f414ace50f1dd8045ab91b4d68f858/assets/PlannedResponse.gif -------------------------------------------------------------------------------- /assets/socketcast_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/socketcast/f0f1c7d9f7f414ace50f1dd8045ab91b4d68f858/assets/socketcast_logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | module.exports = { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/y4/y327ndqj61jgklgthkpm9tyr0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls and instances between every test 17 | // clearMocks: false, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "json", 77 | // "jsx", 78 | // "ts", 79 | // "tsx", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | // moduleNameMapper: {}, 85 | 86 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 87 | // modulePathIgnorePatterns: [], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | // preset: undefined, 97 | 98 | // Run tests from one or more projects 99 | // projects: undefined, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: undefined, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: undefined, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [ 121 | // "" 122 | // ], 123 | 124 | // Allows you to use a custom runner instead of Jest's default test runner 125 | // runner: "jest-runner", 126 | 127 | // The paths to modules that run some code to configure or set up the testing environment before each test 128 | // setupFiles: [], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | // setupFilesAfterEnv: [], 132 | 133 | // The number of seconds after which a test is considered as slow and reported as such in the results. 134 | // slowTestThreshold: 5, 135 | 136 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 137 | // snapshotSerializers: [], 138 | 139 | // The test environment that will be used for testing 140 | testEnvironment: "node", 141 | 142 | // Options that will be passed to the testEnvironment 143 | // testEnvironmentOptions: {}, 144 | 145 | // Adds a location field to test results 146 | // testLocationInResults: false, 147 | 148 | // The glob patterns Jest uses to detect test files 149 | // testMatch: [ 150 | // "**/__tests__/**/*.[jt]s?(x)", 151 | // "**/?(*.)+(spec|test).[tj]s?(x)" 152 | // ], 153 | 154 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 155 | // testPathIgnorePatterns: [ 156 | // "/node_modules/" 157 | // ], 158 | 159 | // The regexp pattern or array of patterns that Jest uses to detect test files 160 | // testRegex: [], 161 | 162 | // This option allows the use of a custom results processor 163 | // testResultsProcessor: undefined, 164 | 165 | // This option allows use of a custom test runner 166 | // testRunner: "jasmine2", 167 | 168 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 169 | // testURL: "http://localhost", 170 | 171 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 172 | // timers: "real", 173 | 174 | // A map from regular expressions to paths to transformers 175 | // transform: undefined, 176 | 177 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 178 | // transformIgnorePatterns: [ 179 | // "/node_modules/", 180 | // "\\.pnp\\.[^\\/]+$" 181 | // ], 182 | 183 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 184 | // unmockedModulePathPatterns: undefined, 185 | 186 | // Indicates whether each individual test should be reported during the run 187 | // verbose: undefined, 188 | 189 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 190 | // watchPathIgnorePatterns: [], 191 | 192 | // Whether to use watchman for file crawling 193 | // watchman: true, 194 | }; 195 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socketcast", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "start": "npm run build && electron dist/main.js", 9 | "quickstart": "electron dist/main.js", 10 | "test": "jest", 11 | "eslint": "npx eslint ./src --ext .js,.jsx,.ts,.tsx" 12 | }, 13 | "keywords": [ 14 | "websocket", 15 | "websockets" 16 | ], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@material-ui/core": "^4.11.3", 21 | "@material-ui/icons": "^4.11.2", 22 | "cors": "^2.8.5", 23 | "electron": "^11.1.1", 24 | "enzyme": "^3.11.0", 25 | "express": "^4.17.1", 26 | "jest": "^26.6.3", 27 | "normalize.css": "^8.0.1", 28 | "react": "^17.0.1", 29 | "react-dom": "^17.0.1", 30 | "react-highlight": "^0.13.0", 31 | "react-redux": "^7.2.2", 32 | "redux": "^4.0.5", 33 | "redux-thunk": "^2.3.0", 34 | "uuid": "^8.3.2", 35 | "ws": "^7.4.2" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.12.10", 39 | "@babel/preset-env": "^7.12.11", 40 | "@babel/preset-typescript": "^7.12.7", 41 | "@types/cors": "^2.8.9", 42 | "@types/electron-devtools-installer": "^2.2.0", 43 | "@types/express": "^4.17.11", 44 | "@types/node": "^14.14.21", 45 | "@types/react": "^17.0.0", 46 | "@types/react-dom": "^17.0.0", 47 | "@types/react-highlight": "^0.12.2", 48 | "@types/react-redux": "^7.1.15", 49 | "@types/uuid": "^8.3.0", 50 | "@types/ws": "^7.4.0", 51 | "@typescript-eslint/eslint-plugin": "^4.14.0", 52 | "@typescript-eslint/parser": "^4.14.0", 53 | "babel-jest": "^26.6.3", 54 | "css-loader": "^5.0.1", 55 | "electron-devtools-installer": "^3.1.1", 56 | "eslint": "^7.18.0", 57 | "eslint-config-airbnb-typescript": "^12.0.0", 58 | "eslint-plugin-import": "^2.22.1", 59 | "eslint-plugin-jsx-a11y": "^6.4.1", 60 | "eslint-plugin-react": "^7.22.0", 61 | "eslint-plugin-react-hooks": "^4.2.0", 62 | "file-loader": "^6.2.0", 63 | "html-webpack-plugin": "^4.5.1", 64 | "redux-devtools-extension": "^2.13.8", 65 | "sass": "^1.32.4", 66 | "sass-loader": "^10.1.1", 67 | "style-loader": "^2.0.0", 68 | "ts-loader": "^8.0.14", 69 | "typescript": "^4.1.3", 70 | "webpack": "^4.43.0", 71 | "webpack-cli": "^4.3.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ServerManager/ServerAbstract.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/semi */ 2 | class ServerAbstract { 3 | public server: any; 4 | 5 | private broadcast: any 6 | 7 | private stopServer: any 8 | 9 | public name: string 10 | 11 | public id: string 12 | 13 | public port: number 14 | 15 | public protocol: any 16 | 17 | constructor(server: any, 18 | { broadcast, stopServer }: { broadcast: any, stopServer?: any }, 19 | name: string, id: string, port: number, protocol?: string) { 20 | this.id = id; 21 | this.server = server; 22 | this.broadcast = broadcast; 23 | this.name = name 24 | this.port = port 25 | this.stopServer = stopServer; 26 | this.protocol = protocol 27 | } 28 | 29 | broadcastToAll = (message: any) => this.broadcast(message); 30 | 31 | isListening = () => this.server.listening; 32 | 33 | stop = () => { 34 | this.stopServer(); 35 | }; 36 | } 37 | 38 | export default ServerAbstract; 39 | -------------------------------------------------------------------------------- /src/ServerManager/ServerManager.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import express, { Request, Response } from 'express'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import cors from 'cors'; 5 | import * as WebSocket from 'ws'; 6 | 7 | import { ServerConfig, ServerState } from './type'; 8 | import ServerAbstract from './ServerAbstract'; 9 | 10 | enum ServerStatus { 11 | RUNNING = 'RUNNING', 12 | STOPPED = 'STOPPED', 13 | } 14 | 15 | class ServerManager { 16 | private servers: { [index: string]: ServerAbstract }; 17 | 18 | constructor() { 19 | this.servers = {}; 20 | } 21 | 22 | /** 23 | * Returns an object containing our servers. The keys are the 24 | * ID of the server within ServerManager. 25 | * @returns {Array.} 26 | */ 27 | getServers = () => Object.keys(this.servers).map((currentKey: string) => ({ 28 | id: this.servers[currentKey].id, 29 | name: this.servers[currentKey].name, 30 | status: this.servers[currentKey].isListening() ? ServerStatus.RUNNING : ServerStatus.STOPPED, 31 | })); 32 | 33 | /** 34 | * Get an individual ServerAbstract by the id of the server. 35 | * This returns an object that contains a reference the WebSocket server itself. 36 | * @param {string} id 37 | * @ 38 | */ 39 | getServerAbstract = (id: string) => this.servers[id]; 40 | 41 | /** 42 | * Get an individual server's ServerRecord 43 | * @param {string} id 44 | */ 45 | getServer = (id: string) => { 46 | const { name } = this.servers[id]; 47 | return { 48 | id, 49 | name, 50 | status: this.servers[id].isListening() ? ServerStatus.RUNNING : ServerStatus.STOPPED, 51 | }; 52 | }; 53 | 54 | /** 55 | * Create a new WebSocket server based on configuration. 56 | * @param {ServerConfig} config - Configuration object for socket.io server 57 | * @param {string} config.name - Configuration object for socket.io server 58 | * @param {Integer} config.port - Port to run the socket.io server on 59 | * @param {Function} config.onConnection - Callback for when a client successfully 60 | * connects to the server 61 | * @param {Function} config.onMessage - Callback for when a message is sent to our 62 | * server by a client 63 | * @param {Function} config.onError - Callback for when an error happens on the 64 | * server. The error will be the only parameter of the callback. 65 | * @returns {Promise} 66 | */ 67 | createServer = (config: ServerConfig): Promise => { 68 | const { 69 | name, port, onConnection, onMessage, id, onServerClose, protocol, endpoint 70 | } = config; 71 | const app = express(); 72 | const server = new http.Server(app); 73 | const serverId = id || uuidv4(); 74 | app.use(cors()); 75 | 76 | 77 | if (protocol === "websocket") { 78 | const wss = new WebSocket.Server({ server }); 79 | 80 | wss.on('connection', (ws: any, req: object) => { 81 | if (onConnection) onConnection(); 82 | ws.on('message', (msg: string) => { 83 | if (onMessage) onMessage(msg, serverId); 84 | }); 85 | }); 86 | 87 | /** 88 | * TODO Promisify this somehow. The complication is promisifying it is that we 89 | * need to wait messages to be sent to ALL clients. If we don't do this, we assume 90 | * that the message is always successfully emitted at the time it displays on the UI 91 | * (which may not be true) 92 | */ 93 | 94 | const broadcast = (message: string) => { 95 | wss.clients.forEach((client: any) => { 96 | client.send(message); 97 | }); 98 | }; 99 | 100 | const stopServer = () => { 101 | wss.clients.forEach((ws) => { 102 | ws.terminate(); 103 | }); 104 | server.close().once('close', () => { 105 | if (onServerClose) { 106 | onServerClose({ 107 | id: serverId, 108 | name, 109 | port, 110 | protocol: protocol, 111 | status: ServerStatus.STOPPED, 112 | }); 113 | } 114 | }); 115 | }; 116 | 117 | // eslint-disable-next-line max-len 118 | this.servers[serverId] = new ServerAbstract(wss, { broadcast, stopServer }, name, serverId, port, protocol); 119 | 120 | } else { 121 | let clients: any[] = []; 122 | const sseEndpointHandler = (req: Request, res: Response) => { 123 | res.writeHead(200, { 124 | 'Content-Type': 'text/event-stream', 125 | Connection: 'keep-alive', 126 | 'Cache-Control': 'no-cache', 127 | }); 128 | 129 | const clientId = uuidv4(); 130 | clients.push({ id: clientId, res }); 131 | 132 | req.on('close', () => { 133 | clients = clients.filter((client) => client.id !== clientId); 134 | }); 135 | }; 136 | 137 | const broadcast = (message: string) => { 138 | clients.forEach((client) => client.res.write(message)); 139 | }; 140 | 141 | // To stop a server, all connections to clients must be closed. Then, you can close the actual server. 142 | const stopServer = () => { 143 | clients.forEach((client) => client.res.end()); 144 | server.close().once('close', () => { 145 | if (onServerClose) { 146 | onServerClose({ 147 | id: serverId, 148 | name, 149 | port, 150 | status: ServerStatus.STOPPED, 151 | protocol: protocol 152 | }); 153 | } 154 | }); 155 | }; 156 | app.use((endpoint || '/'), sseEndpointHandler); 157 | // eslint-disable-next-line max-len 158 | this.servers[serverId] = new ServerAbstract(server, { broadcast, stopServer }, name, serverId, port); 159 | } 160 | 161 | 162 | 163 | 164 | /** 165 | * We return a promise, but we _cannot_ ever reject this promise because any errors 166 | * that would happen when calling server.listen() will be a Node Event. We choose 167 | * to use a promise, however, because we want to tell our consumers when our server 168 | * is able to listen to an event. 169 | * https://github.com/expressjs/express/issues/2856 170 | */ 171 | return new Promise((resolve: Function, reject) => { 172 | server.listen(port || 3000, () => resolve({ 173 | id, 174 | name, 175 | port, 176 | protocol, 177 | status: ServerStatus.RUNNING, 178 | })); 179 | }); 180 | }; 181 | 182 | // createSSEServer = (config: ServerConfig): Promise => { 183 | // const { 184 | // name, port, onConnection, onError, id, endpoint, onServerClose, 185 | // } = config; 186 | // const app = express(); 187 | // const server = new http.Server(app); 188 | // const serverId = id || uuidv4(); 189 | // let clients: any[] = []; 190 | 191 | // app.use(cors()); 192 | 193 | // const sseEndpointHandler = (req: Request, res: Response) => { 194 | // res.writeHead(200, { 195 | // 'Content-Type': 'text/event-stream', 196 | // Connection: 'keep-alive', 197 | // 'Cache-Control': 'no-cache', 198 | // }); 199 | 200 | // const clientId = uuidv4(); 201 | // clients.push({ 202 | // id: clientId, 203 | // res, 204 | // }); 205 | 206 | // req.on('close', () => { 207 | // clients = clients.filter((client) => client.id !== clientId); 208 | // }); 209 | // }; 210 | 211 | // const broadcast = (message: string) => { 212 | // clients.forEach((client) => client.res.write(message)); 213 | // }; 214 | 215 | // // To stop a server, all connections to clients must be closed. Then, you can close the actual server. 216 | // const stopServer = () => { 217 | // clients.forEach((client) => client.res.end()); 218 | // server.close().once('close', () => { 219 | // if (onServerClose) { 220 | // onServerClose({ 221 | // id: serverId, 222 | // name, 223 | // port, 224 | // status: ServerStatus.STOPPED, 225 | // }); 226 | // } 227 | // }); 228 | // }; 229 | 230 | // app.use((endpoint || '/'), sseEndpointHandler); 231 | 232 | // // eslint-disable-next-line max-len 233 | // this.servers[serverId] = new ServerAbstract(server, { broadcast, stopServer }, name, serverId, port); 234 | 235 | // return new Promise((resolve: Function, reject) => { 236 | // server.listen(port || 3000, () => resolve({ 237 | // id: serverId, 238 | // name, 239 | // port, 240 | // status: ServerStatus.RUNNING, 241 | // })); 242 | // }); 243 | // }; 244 | 245 | // TODO Promisify this. 246 | /** 247 | * Broadcast a message to all the clients that are connected to the socket 248 | * @param {string} id - The id of the server who's clients you want to send messages to 249 | * @param {string} message - stringified message 250 | * @returns {Promise} 251 | */ 252 | broadcastToAll = (id: string, message: string) => { 253 | this.servers[id].broadcastToAll(message); 254 | }; 255 | 256 | // TODO Complete this. 257 | /** 258 | * Disconnect all the connections to the socket and close the server from listening to 259 | * new connections. 260 | * @param id 261 | * @returns {Promise} 262 | */ 263 | stopServer = (id: number) => { 264 | // Close all connections to the sockets 265 | // call server.stop() 266 | this.servers[id].stop(); 267 | }; 268 | } 269 | 270 | // Singleton, there should only be one ServerManager running in our app 271 | export default new ServerManager(); 272 | -------------------------------------------------------------------------------- /src/ServerManager/SocketEvent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing a SocketEvent 3 | */ 4 | class SocketEvent { 5 | /** 6 | * @callback onEventCallback 7 | * @param {string} message - This is the message emitted through the socket.io protocol 8 | */ 9 | 10 | /** 11 | * 12 | * @param {string} eventName - This is the name of the event the server will be opening a 13 | * socket for. This is also the event name you will subscribe and emit. 14 | * @param {onEventCallback} - The callback function that will be invoked 15 | * when a message is received matching the eventName 16 | */ 17 | constructor(eventName, config = {}) { 18 | const { onEvent } = config; 19 | 20 | if (!eventName || !(eventName.constructor === String)) { 21 | throw new Error(`expected parameter eventName to be of type string but got ${eventName.constructor.name} instead.`); 22 | } 23 | 24 | if (onEvent && !(onEvent instanceof Function)) { 25 | throw new Error(`expected parameter config.onEvent to be of type function but got ${onEvent.constructor.name} instead`); 26 | } 27 | 28 | this.eventName = eventName; 29 | this.onEvent = onEvent; 30 | } 31 | } 32 | 33 | module.exports = SocketEvent; 34 | -------------------------------------------------------------------------------- /src/ServerManager/type.ts: -------------------------------------------------------------------------------- 1 | export interface ServerConfig { 2 | name: string, 3 | port: number, 4 | onConnection?: Function, 5 | onMessage?(message: string, id?: string): void, 6 | onServerClose?(record: ServerState): void, 7 | onError?: Function, 8 | id?: string 9 | endpoint?: string 10 | protocol?: string 11 | } 12 | 13 | export interface ServerState { 14 | id: string, 15 | name: string, 16 | port: number, 17 | status: string, 18 | protocol?: string, 19 | } 20 | 21 | export interface MessagesReducerState { 22 | streams?: any 23 | } 24 | 25 | export enum ProtocolType { 26 | WEBSOCKET, 27 | SERVERSIDEEVENTS, 28 | } 29 | -------------------------------------------------------------------------------- /src/components/EventConfig.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent ServerConfig 3 | * @description Unused component - added to configure events 4 | */ 5 | 6 | import React from 'react'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | import { setCurrentEventId } from '@/store/actions/navigationActions'; 9 | function EventConfig() { 10 | const dispatch = useDispatch(); 11 | // @ts-ignore 12 | const currentServerID = useSelector((store) => store.navigation.currentServerId); 13 | // @ts-ignore 14 | const currentServer = useSelector((store) => store.servers.servers[currentServerID]); 15 | const eventsArray: any = []; 16 | if (currentServer) { 17 | currentServer.events.map((event: any) => { 18 | eventsArray.push( 19 |
{ dispatch(setCurrentEventId(event)) }}> 20 | 23 |
24 |
Event = (name: string, serial: int, message: string)
25 |
26 |
27 | ) 28 | }) 29 | } 30 | return ( 31 |
32 |
Event Stream Manager
33 | {eventsArray} 34 |
35 | ); 36 | } 37 | 38 | export default EventConfig; 39 | -------------------------------------------------------------------------------- /src/components/PlannedResponseCreator/PRUnit.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable padded-blocks */ 2 | /* eslint-disable arrow-body-style */ 3 | /* eslint-disable react/no-unknown-property */ 4 | /* eslint-disable @typescript-eslint/semi */ 5 | import React from 'react'; 6 | import { PlannedResponseUnit, PlannedResponseUnitType } from './type'; 7 | import Highlight from 'react-highlight' 8 | 9 | const PRUnit = ({ 10 | pru, index, onMoveUp, onMoveDown, onRemove, 11 | }: { pru: PlannedResponseUnit, index: number, onMoveUp: any, onMoveDown: any, onRemove: any }) => { 12 | 13 | /** 14 | * TODO 15 | * Using ternaries probably is not a good idea here because there may be 16 | * a potential for more than two PlannedResponseUnitTypes 17 | */ 18 | return ( 19 |
20 |
21 | 22 | {pru.type === PlannedResponseUnitType.MESSAGE ? 'Message' : 'Delay'} 23 | 24 | 25 |
26 | 27 | 28 | 29 |
30 | 31 |
32 |
33 | {pru.type === PlannedResponseUnitType.MESSAGE ? 34 | // @ts-ignore 35 | ({pru.message.toString()}) 36 | : pru.time} 37 |
38 |
39 | ); 40 | } 41 | 42 | export default PRUnit; 43 | -------------------------------------------------------------------------------- /src/components/PlannedResponseCreator/PlannedResponseCreator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { playbackResponseUnits } from './utils'; 3 | import { PlannedResponseUnit, PlannedResponseUnitType } from './type'; 4 | import PRUnit from './PRUnit'; 5 | import ScrollAnchor from '../ScrollAnchor' 6 | 7 | const PlannedResponseCreator = (props:any) => { 8 | const [plannedResponse, setPlannedResponse] = useState([]); 9 | const [delay, setDelay] = useState(1) 10 | 11 | const addDelayHandler = (ms: number): void => { 12 | setPlannedResponse([...plannedResponse, { type: PlannedResponseUnitType.DELAY, time: ms }]); 13 | }; 14 | 15 | const addMessageHandler = (message: string): void => { 16 | setPlannedResponse([...plannedResponse, { type: PlannedResponseUnitType.MESSAGE, message }]); 17 | }; 18 | 19 | const resetPlannedResponse = () => setPlannedResponse([]); 20 | 21 | const onMoveUp = (arr: any, ele: object) => { 22 | let index = arr.indexOf(ele) 23 | if (index<1) return 24 | [arr[index-1], arr[index]] = [arr[index], arr[index-1]] 25 | setPlannedResponse(arr.slice()) 26 | } 27 | const onMoveDown = (arr: any, ele: object) => { 28 | let index = arr.indexOf(ele) 29 | if (index === arr.length - 1) return 30 | [arr[index+1], arr[index]] = [arr[index], arr[index+1]] 31 | setPlannedResponse(arr.slice()) 32 | } 33 | const onRemove = (arr:any, ele: object) => { 34 | let index = arr.indexOf(ele) 35 | arr.splice(index, 1) 36 | setPlannedResponse(arr.slice()) 37 | } 38 | 39 | return ( 40 | <> 41 |
42 | {plannedResponse.map((curr, count=0) => ( 43 | {onMoveDown(plannedResponse, curr)}} 45 | onMoveUp={() => {onMoveUp(plannedResponse, curr)}} 46 | onRemove={() => {onRemove(plannedResponse, curr)}} 47 | />)) 48 | } 49 | 50 |
51 | 52 |
53 | 54 | 55 | 56 | {setDelay(e.target.value)}} type="number"> 57 | 58 | 59 |
60 | 61 | ); 62 | }; 63 | 64 | export default PlannedResponseCreator; -------------------------------------------------------------------------------- /src/components/PlannedResponseCreator/type.ts: -------------------------------------------------------------------------------- 1 | export interface PlannedResponseUnit { 2 | type: PlannedResponseUnitType, 3 | time?: number, 4 | message?: string, 5 | id?: string 6 | } 7 | 8 | export enum PlannedResponseUnitType { 9 | MESSAGE, 10 | DELAY, 11 | } 12 | -------------------------------------------------------------------------------- /src/components/PlannedResponseCreator/utils.ts: -------------------------------------------------------------------------------- 1 | import { PlannedResponseUnit, PlannedResponseUnitType } from './type'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const playbackResponseUnits = async ( 5 | responseUnits: PlannedResponseUnit[], 6 | { 7 | onMessage, beforeDelay, afterDelay, onComplete, 8 | }: { 9 | onMessage?: Function, 10 | beforeDelay?: Function, 11 | afterDelay?: Function, 12 | onComplete?: Function, 13 | }, 14 | ) => { 15 | const addDelay = (ms: number): Promise => new Promise((resolve, reject) => { 16 | setTimeout(() => { 17 | resolve(); 18 | }, ms); 19 | }); 20 | 21 | // eslint-disable-next-line no-restricted-syntax 22 | for (const pru of responseUnits) { 23 | if (pru.type === PlannedResponseUnitType.DELAY && pru.time) { 24 | if (beforeDelay) beforeDelay(pru.time); 25 | // eslint-disable-next-line no-await-in-loop 26 | await addDelay(pru.time); 27 | if (afterDelay) afterDelay(pru.time); 28 | } else if (onMessage) onMessage(pru.message); 29 | } 30 | 31 | if (onComplete) onComplete(); 32 | }; 33 | 34 | const example: PlannedResponseUnit[] = [ 35 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 1' }, 36 | { type: PlannedResponseUnitType.DELAY, time: 2000 }, 37 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 2 (2 seconds later)' }, 38 | { type: PlannedResponseUnitType.DELAY, time: 1000 }, 39 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 3 (1 second later)' }, 40 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 4' }, 41 | { type: PlannedResponseUnitType.DELAY, time: 2000 }, 42 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 5 (2 seconds later)' }, 43 | { type: PlannedResponseUnitType.DELAY, time: 1000 }, 44 | { type: PlannedResponseUnitType.MESSAGE, message: 'Message 6 (1 second later)' }, 45 | ]; 46 | 47 | export const examplePlayback = (plannedResponse: any) => { 48 | playbackResponseUnits(plannedResponse, { 49 | beforeDelay: (ms: number) => { 50 | // console.log(`delaying by ${ms}`); 51 | }, 52 | onMessage: (message: string) => { 53 | console.log(`emitting ${message}`); 54 | }, 55 | afterDelay: (ms: number) => { 56 | // console.log(`finished delaying by ${ms}`); 57 | }, 58 | onComplete: () => { 59 | console.log('completed playback'); 60 | }, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/ScrollAnchor.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent StreamDisplay, PlannedResponseCreator 3 | * @description Respositions scrollbar to bottom whenever elements are added 4 | **/ 5 | 6 | import React, { useRef, useEffect } from "react"; 7 | 8 | const AlwaysScrollToBottom = () => { 9 | const elementRef: any = useRef(); 10 | useEffect(() => elementRef.current.scrollIntoView()); 11 | return
; 12 | }; 13 | 14 | export default AlwaysScrollToBottom -------------------------------------------------------------------------------- /src/components/ServerForm.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent app 3 | * @description Holds functionality to create a new server and update server config 4 | **/ 5 | 6 | import React, { useState } from 'react'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | import { serverManagerCreateServer, serverManagerStopServer, serverManagerStartServer, removeServer } from '@/store/actions/serversActions'; 9 | import InputText from './input/InputText'; 10 | import { RootState } from '@/store/reducers'; 11 | 12 | function ServerForm() { 13 | const [name, updateName] = useState('New Server'); 14 | const [port, updatePort] = useState(3000); 15 | const [protocol, updateProtocol] = useState('websocket'); 16 | const currentServerId = useSelector((store: RootState) => store.navigation.currentServerId); 17 | // @ts-ignore 18 | const servers = useSelector((store: RootState) => store.servers.servers) 19 | const dispatch = useDispatch(); 20 | 21 | const createServerHandler = () => { 22 | dispatch(serverManagerCreateServer({ port: +port, name, protocol: protocol })); 23 | updateName('New Server'); 24 | updatePort(3000); 25 | }; 26 | 27 | const startServerHandler = () => { 28 | if (!currentServerId) return 29 | dispatch(serverManagerStartServer({ ...servers[currentServerId] }, currentServerId)) 30 | } 31 | 32 | const buttonDisplay: any = []; 33 | if (!currentServerId) { 34 | buttonDisplay.push( 35 | 42 | ) 43 | } else { 44 | if (servers[currentServerId].status === "RUNNING") { 45 | buttonDisplay.push( 46 | 53 | ) 54 | } else { 55 | buttonDisplay.push( 56 | 59 | ) 60 | buttonDisplay.push( 61 | ) 64 | } 65 | } 66 | 67 | return ( 68 |
69 |
70 | updateName(e.target.value)} 75 | /> 76 | updatePort(e.target.value)} 81 | /> 82 |
83 | Protocol 84 |
85 | updateProtocol('websocket')} 88 | /> 89 | 90 |
91 |
92 | updateProtocol('sse')} 95 | /> 96 | 97 |
98 |
99 | 100 | 101 |
102 | {buttonDisplay} 103 |
104 |
105 | ); 106 | } 107 | 108 | export default ServerForm; 109 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent app 3 | * @description Left pane of application. Displays list of active/inactive servers and holds functionality to set active server in state 4 | */ 5 | 6 | import React from 'react'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | import { setCurrentServerId } from '../store/actions/navigationActions'; 9 | import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; 10 | import { green, red } from '@material-ui/core/colors'; 11 | 12 | function Sidebar() { 13 | const dispatch = useDispatch(); 14 | // @ts-ignore 15 | const servers = useSelector((store) => store.servers.servers); 16 | // @ts-ignore 17 | const currentServerId = useSelector((store: RootState) => store.navigation.currentServerId); 18 | 19 | // Don't delete 20 | // @ts-ignore 21 | const store = useSelector(store => store) 22 | 23 | const displayServers = Object.values(servers).map((server: any) => { 24 | const status = server.status === "RUNNING" ? green[500] : red[500]; 25 | return ( 26 |
dispatch(setCurrentServerId(server.id))}> 27 |
28 | {server.name} 29 | :{server.port} 30 |
31 | 32 |
33 | ); 34 | }); 35 | 36 | return ( 37 |
38 |
39 | 47 |
48 |
49 |
Servers
50 | { 51 | displayServers.length 52 | ? displayServers 53 | :
No configured servers
54 | } 55 |
56 |
57 | 58 |
59 |
60 | ); 61 | } 62 | 63 | export default Sidebar; 64 | -------------------------------------------------------------------------------- /src/components/StreamDisplay.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent StreamTab 3 | * @description Display stream output field 4 | **/ 5 | 6 | import React, {useEffect, useRef, useState} from 'react'; 7 | import { useSelector } from 'react-redux'; 8 | import { RootState } from '@/store/reducers'; 9 | import Highlight from 'react-highlight' 10 | import ScrollAnchor from './ScrollAnchor' 11 | 12 | 13 | function StreamDisplay() { 14 | const plannedResponseBoolean = useSelector( (store: RootState) => store.navigation.plannedResponseBoolean) 15 | 16 | const currentServerId = useSelector( 17 | (store: RootState) => store.navigation.currentServerId, 18 | ); 19 | const outputStream = useSelector( 20 | (store: RootState) => store.messages.streams, 21 | ); 22 | 23 | let counter = 0; 24 | 25 | return ( 26 |
27 |
28 | {currentServerId && outputStream[currentServerId] 29 | && outputStream[currentServerId].map((code: string) => ( 30 |
31 | {code.toString()} 32 | 33 |
34 | ))} 35 |
36 |
37 | ); 38 | } 39 | 40 | export default StreamDisplay; 41 | -------------------------------------------------------------------------------- /src/components/StreamInput.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @parentComponent StreamTab 3 | * @description Holds input boxes for emitting messages or message streams 4 | */ 5 | 6 | import { RootState } from '@/store/reducers'; 7 | import React, { useState } from 'react'; 8 | import { useDispatch, useSelector } from 'react-redux'; 9 | import { serverManagerBroadcastAll } from '../store/actions/serversActions'; 10 | import PlannedResponseCreator from './PlannedResponseCreator/PlannedResponseCreator'; 11 | import { playbackResponseUnits } from './PlannedResponseCreator/utils'; 12 | import { togglePlannedResponse } from '../store/actions/navigationActions' 13 | 14 | function StreamInput() { 15 | const dispatch = useDispatch(); 16 | const currentServerId = useSelector( 17 | (store: RootState) => store.navigation.currentServerId, 18 | ); 19 | const [message, updateMessage] = useState(''); 20 | 21 | const plannedResponseBoolean = useSelector( (store: RootState) => store.navigation.plannedResponseBoolean) 22 | 23 | const emitPlannedResponse = (prus:any) => { 24 | playbackResponseUnits(prus, { 25 | onMessage: (msg: string) => { 26 | dispatch(serverManagerBroadcastAll(currentServerId, msg)); 27 | }, 28 | }); 29 | }; 30 | 31 | return ( 32 | 33 |
34 | 35 | {plannedResponseBoolean && } 36 | 37 |
38 |