├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.test.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | dist 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | jest.config.jest 3 | tsconfig.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Christian Alfoni 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 | # process-control 2 | 3 | Set up asynchronous processes that can be started, restarted, stopped and disposed. 4 | 5 | This project was created to manage complex rendering logic. It allows you to define several processes that can at any time be 6 | started, stopped, restarted or disposed. When you stop a process its current step will finish, but the next step will not 7 | execute. Think of it as using a promise which by default executes synchronously and can be stopped, restarted and disposed (can not be started anymore). 8 | 9 | Big shoutout to [Normatic](http://www.normatic.no/) for open sourcing this tool as a thanks to the open source community for its 10 | contributions, allowing Normatic to build great experiences for their customers! 11 | 12 | ```js 13 | import { Process } from "process-control"; 14 | 15 | const myProcess = new Process({ 16 | // Automatically dispose when the process reaches its end 17 | dispose: true 18 | }) 19 | // Do some work and return a value for the next step 20 | .then(() => 123) 21 | // Do some work and return a promise to hold further execution 22 | .then(() => Promise.resolve()) 23 | // Do work in parallel 24 | .all([() => Promise.resolve(), otherProcess]) 25 | // Compose in an other process instance 26 | .then(anotherProcess); 27 | // Return a function that works like a synchronous promise, meaning that 28 | // calling resolve() will instantly trigger the next step, not on next 29 | // tick as native promises do 30 | .then(() => { 31 | return (resolve, reject) => {} 32 | }) 33 | 34 | /* 35 | Start the process. If you try to start a running process, it will 36 | stop the current one, and once stopped start again. Returns 37 | a promise 38 | */ 39 | myProcess.start(optionalValue); 40 | 41 | /* 42 | Stop the process. Current started process promise will throw an exception. 43 | */ 44 | myProcess.stop(); 45 | 46 | /* 47 | Restart the process. Stops the current process and then starts when it is stopped. 48 | */ 49 | myProcess.restart(optionalValue); 50 | 51 | /* 52 | Disposes the process. Nothing happens when you try to start it. 53 | */ 54 | myProcess.dispose(); 55 | 56 | /* 57 | The state of the process 58 | */ 59 | myProcess.state; 60 | ``` 61 | 62 | **NB!** Be careful composing one process into multiple other processes. As one process might try to stop the composed process while the other expects it to do its work! 63 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "tsx", 12 | "js", 13 | "jsx", 14 | "json", 15 | "node" 16 | ], 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "process-control", 3 | "version": "1.0.6", 4 | "description": "Control async processes", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prepublish": "npm run build", 8 | "build": "tsc", 9 | "test": "jest" 10 | }, 11 | "keywords": [ 12 | "async", 13 | "process", 14 | "control" 15 | ], 16 | "author": "christianalfoni", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/jest": "^23.3.2", 20 | "eslint": "^5.6.0", 21 | "eslint-config-prettier": "^3.1.0", 22 | "jest": "^23.6.0", 23 | "prettier": "^1.14.3", 24 | "rollup": "^0.66.2", 25 | "ts-jest": "^23.10.2", 26 | "typescript": "^3.1.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Process, State, SyncPromise } from "./"; 2 | 3 | describe("PROCESS", () => { 4 | test("should create a process", () => { 5 | expect(new Process()); 6 | }); 7 | test("should run callback in THEN", () => { 8 | let hasRunCallback; 9 | const process = new Process().then(() => { 10 | hasRunCallback = true; 11 | }); 12 | process.start(); 13 | expect(hasRunCallback); 14 | }); 15 | test("should chain process", () => { 16 | let callbackCount = 0; 17 | const process = new Process() 18 | .then(() => { 19 | callbackCount++; 20 | }) 21 | .then(() => { 22 | callbackCount++; 23 | }); 24 | 25 | return process.start().then(() => { 26 | expect(callbackCount).toBe(2); 27 | }); 28 | }); 29 | test("should pass returned value", () => { 30 | const process = new Process() 31 | .then(() => { 32 | return 5; 33 | }) 34 | .then(val => { 35 | return val + 5; 36 | }); 37 | 38 | return process.start().then(val => { 39 | expect(val).toBe(10); 40 | }); 41 | }); 42 | test("should run synchronously", () => { 43 | const order = []; 44 | const process = new Process() 45 | .then(() => { 46 | order.push("A"); 47 | return 5; 48 | }) 49 | .then(val => { 50 | order.push("B"); 51 | return val + 5; 52 | }); 53 | 54 | const started = process.start(); 55 | order.push("C"); 56 | 57 | return started.then(val => { 58 | expect(order).toEqual(["A", "B", "C"]); 59 | }); 60 | }); 61 | test("should handle promises", () => { 62 | const process = new Process() 63 | .then(() => { 64 | return Promise.resolve(5); 65 | }) 66 | .then(val => { 67 | return val + 5; 68 | }); 69 | 70 | return process.start().then(val => { 71 | expect(val).toEqual(10); 72 | }); 73 | }); 74 | 75 | test("should be able to chain processes", () => { 76 | let callbacks = []; 77 | const processA = new Process().then(() => { 78 | callbacks.push("A"); 79 | }); 80 | 81 | const processB = new Process() 82 | .then(_ => { 83 | callbacks.push("B"); 84 | }) 85 | .then(processA); 86 | 87 | return processB.start().then(() => { 88 | expect(callbacks).toEqual(["B", "A"]); 89 | }); 90 | }); 91 | test("should be able to stop process", () => { 92 | expect.assertions(2); 93 | 94 | let callbackCount = 0; 95 | const process = new Process() 96 | .then(() => { 97 | callbackCount++; 98 | return new Promise(resolve => setTimeout(resolve, 10)); 99 | }) 100 | .then(() => { 101 | callbackCount++; 102 | }); 103 | 104 | const test = process.start().catch(reason => { 105 | expect(callbackCount).toBe(1); 106 | expect(reason).toBe(State.STOPPED); 107 | }); 108 | 109 | setTimeout(() => process.stop(), 0); 110 | 111 | return test; 112 | }); 113 | 114 | test("should be able to stop process", () => { 115 | expect.assertions(4); 116 | 117 | let callbackCount = 0; 118 | const process = new Process() 119 | .then( 120 | (): SyncPromise => { 121 | callbackCount++; 122 | return resolve => setTimeout(resolve, 10); 123 | } 124 | ) 125 | .then(() => { 126 | callbackCount++; 127 | }); 128 | 129 | process.start().catch(reason => { 130 | expect(callbackCount).toBe(1); 131 | expect(reason).toBe(State.STOPPED); 132 | }); 133 | 134 | return process.stop().then(() => { 135 | expect(callbackCount).toBe(1); 136 | expect(process.state).toBe(State.IDLE); 137 | }); 138 | }); 139 | test("should be able to start a stopped process", () => { 140 | expect.assertions(3); 141 | 142 | let callbackCount = 0; 143 | const process = new Process() 144 | .then(() => { 145 | callbackCount++; 146 | return resolve => setTimeout(resolve, 10); 147 | }) 148 | .then(() => { 149 | callbackCount++; 150 | }); 151 | 152 | process.start().catch(reason => { 153 | expect(callbackCount).toBe(1); 154 | expect(reason).toBe(State.STOPPED); 155 | }); 156 | 157 | return Promise.resolve() 158 | .then(() => { 159 | return process.stop().catch(() => {}); 160 | }) 161 | .then(() => { 162 | return process.start(); 163 | }) 164 | .then(() => { 165 | expect(callbackCount).toBe(3); 166 | }); 167 | }); 168 | test("should be bable to restart a process", () => { 169 | expect.assertions(3); 170 | 171 | let callbackCount = 0; 172 | const process = new Process() 173 | .then(() => { 174 | callbackCount++; 175 | return resolve => setTimeout(resolve, 10); 176 | }) 177 | .then(() => { 178 | callbackCount++; 179 | }); 180 | 181 | return Promise.all([ 182 | process.start().catch(reason => { 183 | expect(callbackCount).toBe(1); 184 | expect(reason).toBe(State.STOPPED); 185 | }), 186 | Promise.resolve() 187 | .then(() => { 188 | return process.restart(); 189 | }) 190 | .then(() => { 191 | expect(callbackCount).toBe(3); 192 | }) 193 | ]); 194 | }); 195 | test("should be able to dispose a process", () => { 196 | expect.assertions(3); 197 | 198 | let callbackCount = 0; 199 | const process = new Process() 200 | .then(() => { 201 | callbackCount++; 202 | return resolve => setTimeout(resolve, 10); 203 | }) 204 | .then(() => { 205 | callbackCount++; 206 | }); 207 | 208 | return Promise.all([ 209 | process.start().catch(reason => { 210 | expect(callbackCount).toBe(1); 211 | expect(reason).toBe(State.STOPPED); 212 | }), 213 | Promise.resolve() 214 | .then(() => { 215 | return process.dispose(); 216 | }) 217 | .then(() => { 218 | return process.start(); 219 | }) 220 | .catch(() => { 221 | expect(callbackCount).toBe(1); 222 | }) 223 | ]); 224 | }); 225 | test("should be able to merge multiple promises", () => { 226 | let callbackCount = 0; 227 | const process = new Process() 228 | .all([ 229 | () => { 230 | callbackCount++; 231 | return resolve => setTimeout(resolve, 10); 232 | }, 233 | () => { 234 | callbackCount++; 235 | } 236 | ]) 237 | .then(() => { 238 | callbackCount++; 239 | }); 240 | 241 | return process.start().then(() => { 242 | expect(callbackCount).toBe(3); 243 | }); 244 | }); 245 | 246 | test("should be able to merge processes", () => { 247 | let callbackCount = 0; 248 | const processB = new Process().then(() => { 249 | callbackCount++; 250 | return resolve => setTimeout(resolve, 10); 251 | }); 252 | const process = new Process() 253 | .all([ 254 | processB, 255 | () => { 256 | callbackCount++; 257 | } 258 | ]) 259 | .then(() => { 260 | callbackCount++; 261 | }); 262 | 263 | return process.start().then(() => { 264 | expect(callbackCount).toBe(3); 265 | }); 266 | }); 267 | test("should stop merged processes", () => { 268 | let callbackCount = 0; 269 | const processB = new Process() 270 | .then(() => { 271 | return resolve => setTimeout(resolve, 10); 272 | }) 273 | .then(() => { 274 | callbackCount++; 275 | }); 276 | const process = new Process() 277 | .all([ 278 | processB, 279 | () => { 280 | callbackCount++; 281 | } 282 | ]) 283 | .then(() => { 284 | callbackCount++; 285 | }); 286 | setTimeout(() => process.stop(), 0); 287 | return process.start().catch(reason => { 288 | expect(callbackCount).toBe(1); 289 | expect(reason).toBe(State.STOPPED); 290 | }); 291 | }); 292 | test("should be able to automatically dispose", () => { 293 | let callbackCount = 0; 294 | const processB = new Process({ 295 | dispose: true 296 | }) 297 | .then(() => { 298 | return resolve => setTimeout(resolve, 10); 299 | }) 300 | .then(() => { 301 | callbackCount++; 302 | }); 303 | const process = new Process({ 304 | dispose: true 305 | }) 306 | .all([ 307 | processB, 308 | () => { 309 | callbackCount++; 310 | } 311 | ]) 312 | .then(() => { 313 | callbackCount++; 314 | }); 315 | return process.start().then(() => { 316 | expect(callbackCount).toBe(3); 317 | console.log("checking disposed"); 318 | expect(process.state).toBe(State.DISPOSED); 319 | }); 320 | }); 321 | }); 322 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | dispose: boolean; 3 | }; 4 | 5 | export type Next = (err: State, val?: Output) => void; 6 | 7 | export type SyncPromise = ( 8 | resolve: (val: T) => void, 9 | reject: (val: any) => void 10 | ) => void; 11 | 12 | export type Callback = ( 13 | val: Input 14 | ) => Output | Promise | SyncPromise; 15 | 16 | export type Runner = 17 | | Callback 18 | | Process; 19 | 20 | export enum State { 21 | IDLE = "IDLE", 22 | RUNNING = "RUNNING", 23 | STOPPED = "STOPPED", 24 | DISPOSED = "DISPOSED" 25 | } 26 | 27 | export class Process { 28 | private _options: Options; 29 | private _parent: Process; 30 | private _child: Process; 31 | private _run: Runner | Runner[]; 32 | private _currentRun: Promise; 33 | state: State = State.IDLE; 34 | constructor( 35 | options: Options = { dispose: false }, 36 | parent?: Process 37 | ) { 38 | this._options = options; 39 | this._parent = parent; 40 | } 41 | private run(newValue: T, next: Next) { 42 | return (this._currentRun = new Promise((resolve, reject) => { 43 | const proceed = (err, value) => { 44 | if (err) { 45 | reject(err); 46 | next(err); 47 | return; 48 | } 49 | 50 | if (this.state === State.STOPPED || this.state === State.DISPOSED) { 51 | reject(State.STOPPED); 52 | next(State.STOPPED); 53 | return; 54 | } 55 | 56 | let returnedValue; 57 | if (!this._run) { 58 | returnedValue = value; 59 | } else if (Array.isArray(this._run)) { 60 | const allRunner = this._run; 61 | returnedValue = (resolveAll, rejectAll) => { 62 | let resolvedCount = 0; 63 | let rejectedCount = 0; 64 | const checkResolvement = () => { 65 | if (resolvedCount + rejectedCount === allRunner.length) { 66 | if (rejectedCount) { 67 | rejectAll(); 68 | } else { 69 | resolveAll(); 70 | } 71 | } 72 | }; 73 | const addResolved = () => { 74 | resolvedCount++; 75 | checkResolvement(); 76 | }; 77 | const addRejected = () => { 78 | rejectedCount++; 79 | checkResolvement(); 80 | }; 81 | 82 | allRunner.forEach(runner => { 83 | if (runner instanceof Process) { 84 | runner 85 | .start(newValue) 86 | .then(addResolved) 87 | .catch(addRejected); 88 | } else { 89 | const runnerResult = runner(value); 90 | 91 | if (runnerResult instanceof Promise) { 92 | runnerResult.then(addResolved).catch(addRejected); 93 | } else if (typeof runnerResult === "function") { 94 | runnerResult(addResolved, addRejected); 95 | } else { 96 | addResolved(); 97 | } 98 | } 99 | }); 100 | }; 101 | } else if (this._run instanceof Process) { 102 | returnedValue = this._run.start(newValue); 103 | } else { 104 | returnedValue = this._run(value); 105 | } 106 | 107 | if (returnedValue instanceof Promise) { 108 | returnedValue 109 | .then(val => { 110 | if ( 111 | this.state === State.STOPPED || 112 | this.state === State.DISPOSED 113 | ) { 114 | reject(State.STOPPED); 115 | next(State.STOPPED); 116 | return; 117 | } 118 | 119 | resolve(val); 120 | next(null, val); 121 | }) 122 | .catch(err => { 123 | reject(err); 124 | next(err); 125 | }); 126 | } else if (typeof returnedValue === "function") { 127 | returnedValue( 128 | resolvedValue => { 129 | if ( 130 | this.state === State.STOPPED || 131 | this.state === State.DISPOSED 132 | ) { 133 | reject(State.STOPPED); 134 | next(State.STOPPED); 135 | return; 136 | } 137 | 138 | resolve(resolvedValue); 139 | next(null, resolvedValue); 140 | }, 141 | rejectedValue => { 142 | if ( 143 | this.state === State.STOPPED || 144 | this.state === State.DISPOSED 145 | ) { 146 | reject(State.STOPPED); 147 | next(State.STOPPED); 148 | return; 149 | } 150 | 151 | reject(rejectedValue); 152 | next(rejectedValue); 153 | } 154 | ); 155 | } else { 156 | resolve(returnedValue); 157 | next(null, returnedValue); 158 | } 159 | }; 160 | 161 | if (this._parent) { 162 | this._parent.run(newValue, proceed).catch(reject); 163 | } else { 164 | proceed(null, newValue); 165 | } 166 | })); 167 | } 168 | private setState(state: State) { 169 | if (this.state === State.DISPOSED) { 170 | return; 171 | } 172 | 173 | this.state = state; 174 | } 175 | then(runner: Runner): Process { 176 | this._run = runner; 177 | 178 | return (this._child = new Process(this._options, this)); 179 | } 180 | all(runners: Runner[]): Process { 181 | this._run = runners; 182 | 183 | return (this._child = new Process( 184 | this._options, 185 | this 186 | )); 187 | } 188 | start(initialValue?: InitialInput): Promise; 189 | start(initialValue?): Promise { 190 | if (this.state === State.DISPOSED) { 191 | return Promise.resolve(null); 192 | } 193 | 194 | this.state === State.RUNNING; 195 | 196 | return this.run(initialValue, () => { 197 | if (this._options.dispose) { 198 | this.setState(State.DISPOSED); 199 | } else { 200 | this.setState(State.IDLE); 201 | } 202 | }); 203 | } 204 | stop() { 205 | if (this.state === State.DISPOSED || this.state === State.STOPPED) { 206 | return Promise.resolve(); 207 | } 208 | 209 | this.setState(State.STOPPED); 210 | 211 | let stop: Promise = Promise.resolve(); 212 | 213 | if (this._parent) { 214 | stop = this._parent.stop(); 215 | } 216 | 217 | if (this._run instanceof Process) { 218 | const runner = this._run; 219 | 220 | stop = stop.then(() => runner.stop()); 221 | } else if (Array.isArray(this._run)) { 222 | const runners = this._run; 223 | stop = stop.then(() => 224 | Promise.all( 225 | runners.map(runner => { 226 | if (runner instanceof Process) { 227 | return runner.stop(); 228 | } 229 | }) 230 | ) 231 | ); 232 | } 233 | 234 | return this._currentRun 235 | ? this._currentRun 236 | .then(() => stop) 237 | .then(() => { 238 | this.setState(State.IDLE); 239 | }) 240 | .catch(() => { 241 | this.setState(State.IDLE); 242 | }) 243 | : stop 244 | .then(() => { 245 | this.setState(State.IDLE); 246 | }) 247 | .catch(() => { 248 | this.setState(State.IDLE); 249 | }); 250 | } 251 | restart(initialValue?: InitialInput): Promise; 252 | restart(initialValue?): Promise { 253 | return this.stop() 254 | .then(() => this.start(initialValue)) 255 | .catch(() => this.start(initialValue)); 256 | } 257 | dispose() { 258 | if (this.state === State.DISPOSED) { 259 | return Promise.resolve(); 260 | } 261 | 262 | return this.stop() 263 | .then(() => { 264 | this.setState(State.DISPOSED); 265 | }) 266 | .catch(() => { 267 | this.setState(State.DISPOSED); 268 | }); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es2015"], 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "strict": false, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "inlineSources": true, 13 | "types": ["node", "jest"] 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | --------------------------------------------------------------------------------