├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── conclude.d.ts ├── package-lock.json ├── package.json ├── src ├── combinators.js ├── conclude.js └── effects.js └── tests ├── combinators.test.js ├── conclude.test.js └── effects.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /tests 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dmitry Maevsky 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 | # ⤕ Conclure JS 2 | Brings cancellation and testability to your async flows. 3 | 4 | It is a tiny (core is < 200 lines of code), zero dependencies generator runner. 5 | 6 | Just grep and replace: 7 | - `async` -> `function*` 8 | - `await` -> `yield` 9 | - `Promise.(all|race|allSettled|any)` -> `Conclude.(all|race|allSettled|any)` 10 | 11 | ```js 12 | import { conclude } from 'conclure'; 13 | import * as Conclude from 'conclure/combinators'; 14 | 15 | // An example of a multi-step async flow that a user might want to cancel at any time 16 | function* fetchItem(item) { 17 | const { contentsUrl } = yield item.fetchMetaData(); 18 | const res = yield fetch(contentsUrl); 19 | return res.text(); 20 | }; 21 | 22 | const loadAll = items => Conclude.all(items.map(fetchItem)); 23 | 24 | const cancel = conclude(loadAll(myDocs), (err, contents) => { 25 | if (err) console.error(err); 26 | else console.log(contents); 27 | }); 28 | 29 | /// later... 30 | cancel(); 31 | 32 | ``` 33 | You can yield/conclude iterators, promises, and effects interchangeably, so you can gradually introduce cancellation and testability to your async flows. 34 | 35 | 36 | ## Design concepts and rationale 37 | You should avoid Promises for two major reasons: 38 | - Promises are *greedy*: once created, cannot be cancelled 39 | - `await promise` **always** inserts a *tick* into your async flow, even if the promise is already resolved or can be resolved synchronously. 40 | 41 | You can see a `Promise` as a particular type of an iterator for which the JS VM provides a built-in runner, a quite poorly designed one nonetheless. 42 | 43 | **⤕ Conclure JS** is a custom generator runner that 44 | - allows you to cancel your async flows 45 | - ensures that sync flows always resolve synchronously 46 | - delivers better testability through the use of *effects* as popularized by [redux-saga](https://redux-saga.js.org/docs/basics/DeclarativeEffects.html). 47 | 48 | ### Terminology and semantics 49 | An async flow may be represented by *any* of the three base concepts: 50 | - a promise (e.g. a result of an async function) 51 | - an iterator (e.g. a result of a generator function) 52 | - an effect: a declarative (lazy) function call, [redux-saga style](https://redux-saga.js.org/docs/basics/DeclarativeEffects.html) 53 | 54 | You can `yield` or `return` a flow from a generator function. Conclure's runner will *conclude* the flow that will either 55 | - produce a *result*: promise resolves / iterator returns / CPS callback is called with (null, result), or 56 | - fail with an *error*: promise rejects / iterator throws / CPS callback is called with (error) 57 | 58 | The runner returns the concluded value to the generator function via `.next(result)` or `.throw(error)` 59 | 60 | The return value of the generator function yielding the flow - an iterator - becomes the flow's *parent*. 61 | 62 | A flow may have multiple parents - different generators yielding the same flow. Conclure ensures that in this case the flow only runs once, but the results are delivered to all parents once concluded. 63 | 64 | The root flow may be concluded by calling `conclude` explicitly, which itself is a CPS function, in the same vein as you would attach a `then` handler to a Promise outside of an async function. You may have multiple root flows. 65 | 66 | `conclude` returns a `cancel` function that cancels the top-level flow. A child flow would then be cancelled if **all** of its parents are cancelled. 67 | 68 | Unlike redux-saga, Conclure does not call `.return` with some "magic" value on the iterator. It simply attempts to cancel the currently pending operation and stops iterating the iterator. 69 | 70 | A flow is considered *finished* when it is either *concluded* (with a *result* or an *error*) or *cancelled*. 71 | 72 | You can also attach *weak* watchers to a flow using `whenFinished(flow, callback)`. The callback will be called with `{ cancelled, error, result }` when the flow has finished. 73 | 74 | In case the flow concludes with a result or an error, the weak watchers are called *before* the result is delivered to the flow's parents, so the callback passed to `whenFinished` is roughly equivalent to the `finally` block of a redux-saga generator. However, it can be attached to promises and effects as well, and enables perfectly valid edge cases, when a flow is cancelled synchronously while the generator is running. 75 | 76 | Check out some examples in the Recipes section below. 77 | 78 | ### Effects 79 | ```js 80 | import { call, cps, cps_no_cancel, delay } from 'conclure/effects'; 81 | ``` 82 | An effect is simply an abstracted declarative (lazy) function call: it is a simple object `{ [TYPE], context, fn, args }` which may come in two flavors: `CALL` or `CPS`. 83 | 84 | - A `CALL` effect, when concluded, will call `fn.apply(context, args)` and conclude the result. Create a `CALL` effect using `call(fn, ...args)`. If `fn` requires `this`, you can pass the context as `call([context, fn], ...args)`. 85 | 86 | - A `CPS` effect, when concluded, will call `fn.call(context, ...args, callback)`, and resolve or reject when the callback is called. `fn` **must** return a cancellation. Create a `CPS` effect using `cps(fn, ...args)`. If `fn` requires `this`, you can pass the context as `cps([context, fn], ...args)`. 87 | 88 | To call third-party CPS functions that do not return a cancellation, use the `cps_no_cancel` effect instead. 89 | 90 | **`delay(ms)`** 91 | 92 | `delay` is a CPS function. However, when called without the second callback argument it returns a cps effect on itself. When concluded, it introduces a delay of `ms` milliseconds into the flow. 93 | 94 | ### Combinators 95 | ```js 96 | import * as Conclude from 'conclure/combinators'; 97 | ``` 98 | `Conclude.[all|any|race|allSettled]` combinators would do the same thing as their `Promise` counterparts, except that they operate on all types of flows supported by ConclureJS: promises, iterators, or effects. All other values are concluded as themselves. The payload argument may be an `Iterable` or an object. 99 | 100 | Combinator conclude behavior summary: 101 | 102 | | Combinator | Flow `k` produces `result` | Flow `k` fails with `error` | All flows conclude 103 | |---|---|---|---| 104 | |`all([])`|*continue*|Fail with `error`|Return all `results` 105 | |`all({})`|*continue*|Fail with `{[k]: error}`|Return `{ [k in payload]: results[k] }` 106 | |`any([])`|Return `result`|*continue*|Fail with all `errors` 107 | |`any({})`|Return `{[k]: result}`|*continue*|Fail with `{ [k in payload]: errors[k] }` 108 | |`race([])`|Return `result`|Fail with `error`|*noop* 109 | |`race({})`|Return `{[k]: result}`|Fail with `{[k]: error}`|*noop* 110 | |`allSettled([])`|*continue*|*continue*|Return `[{ result: results[k], error: errors[k] }]` for all `k` 111 | |`allSettled({})`|*continue*|*continue*|Return `{ [k in payload]: { result: results[k], error: errors[k] } }` 112 | 113 | All the combinators are implemented as CPS functions. Same as `delay`, when called without the callback argument, each combinator returns a cps effect on itself. 114 | 115 | **IMPORTANT** 116 | - If a combinator can conclude synchronously, it is guaranteed to do so! 117 | - If some of the flows are still running when a combinator concludes they will be automatically cancelled 118 | 119 | Refer to the [API reference](https://github.com/dmaevsky/conclure/blob/master/conclude.d.ts) for more details. 120 | 121 | ### Typical use cases and recipes 122 | 1. Abortable fetch 123 | ```js 124 | export function* abortableFetch(url, options) { 125 | const controller = new AbortController(); 126 | 127 | const promise = fetch(url, { ...options, signal: controller.signal }); 128 | whenFinished(promise, ({ cancelled }) => cancelled && controller.abort()); 129 | 130 | const res = yield promise; 131 | if (!res.ok) throw new Error(res.statusText); 132 | 133 | const contentType = res.headers.get('Content-Type'); 134 | 135 | return contentType && contentType.indexOf('application/json') !== -1 136 | ? res.json() 137 | : res.text(); 138 | } 139 | ``` 140 | 141 | 2. Caching flow results 142 | ```js 143 | const withCache = (fn, expiry = 0, cache = new Map()) => function(key, ...args) { 144 | if (cache.has(key)) { 145 | return cache.get(key); 146 | } 147 | 148 | const it = fn(key, ...args); 149 | cache.set(key, it); 150 | 151 | whenFinished(it, ({ cancelled, error, result }) => { 152 | if (cancelled || error || !expiry) cache.delete(key); 153 | else setTimeout(() => cache.delete(key), expiry); 154 | }); 155 | 156 | return it; 157 | } 158 | 159 | const cachedFetch = withCache(abortableFetch, 10000); 160 | ``` 161 | 162 | 3. Show a spinner while a flow is running 163 | ```js 164 | function withSpinner(flow) { 165 | const it = call(() => { 166 | showSpinner(); 167 | return flow; 168 | }); 169 | whenFinished(it, () => hideSpinner()); 170 | return it; 171 | } 172 | 173 | conclude(withSpinner(cachedFetch(FILE_URL)), (err, res) => console.log({ err, res })); 174 | ``` 175 | -------------------------------------------------------------------------------- /conclude.d.ts: -------------------------------------------------------------------------------- 1 | type Continuation = (error: unknown | null, result?: TResult) => void 2 | type Cancellation = () => void; 3 | 4 | type FinishedState = { 5 | cancelled?: boolean; 6 | error?: unknown; 7 | result?: TResult; 8 | } 9 | 10 | type CPS = [...unknown[], Continuation]; 11 | type CPSFunction = (...args: CPS) => Cancellation; 12 | type CPSFunctionNoCancel = (...args: CPS) => unknown; 13 | type CALLFunction = (...args: unknown[]) => TResult; 14 | 15 | declare const TYPE = '@@conclude-effect'; 16 | 17 | type EffectType = 'CALL' | 'CPS' | 'CPS_NO_CANCEL'; 18 | type EffectTarget = 19 | T extends 'CPS' ? CPSFunction : 20 | T extends 'CPS_NO_CANCEL' ? CPSFunctionNoCancel : 21 | (...args: unknown[]) => TResult; 22 | 23 | type Effect = { 24 | [TYPE]: T; 25 | context: object; 26 | fn: EffectTarget; 27 | args: any[]; 28 | }; 29 | 30 | type Flow = Promise | Iterator | Effect; 31 | 32 | type CallableTarget = T | [object, string | T]; 33 | 34 | declare module 'conclure' { 35 | export function isIterator(obj: any): obj is Iterator; 36 | export function isPromise(obj: any): obj is Promise; 37 | export function isEffect(effect: any): effect is Effect; 38 | 39 | export function isFlow(flow: any): flow is Flow; 40 | 41 | export function inProgress(it: Flow): boolean; 42 | export function finished(it: Flow): boolean; 43 | export function getResult(it: Flow): FinishedState; 44 | 45 | export function conclude(it: TResult | Flow, callback: Continuation): Cancellation; 46 | 47 | export function whenFinished(it: TResult | Flow, callback: (state: FinishedState) => void): Cancellation; 48 | } 49 | 50 | declare module 'conclure/effects' { 51 | export function cps(fn: CallableTarget>, ...args: unknown[]): Effect 52 | export function cps_no_cancel(fn: CallableTarget>, ...args: unknown[]): Effect 53 | export function call(fn: CallableTarget>, ...args: unknown[]): Effect; 54 | 55 | export function delay(ms: number, callback: Continuation): Cancellation; 56 | export function delay(ms: number): Effect; 57 | } 58 | 59 | declare module 'conclure/combinators' { 60 | type CombinatorResult = T extends any[] ? unknown[] : Record 61 | 62 | export function all(payload: T, callback: Continuation>): Cancellation; 63 | export function all(payload: T): Effect, 'CPS'>; 64 | 65 | export function any(payload: T, callback: Continuation>): Cancellation; 66 | export function any(payload: T): Effect, 'CPS'>; 67 | 68 | export function race(payload: T, callback: Continuation>): Cancellation; 69 | export function race(payload: T): Effect, 'CPS'>; 70 | 71 | export function allSettled(payload: T, callback: Continuation>): Cancellation; 72 | export function allSettled(payload: T): Effect, 'CPS'>; 73 | } 74 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conclure", 3 | "version": "2.2.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "conclure", 9 | "version": "2.2.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "utap": "^0.1.2" 13 | } 14 | }, 15 | "node_modules/chalk": { 16 | "version": "5.1.2", 17 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.1.2.tgz", 18 | "integrity": "sha512-E5CkT4jWURs1Vy5qGJye+XwCkNj7Od3Af7CP6SujMetSMkLs8Do2RWJK5yx1wamHV/op8Rz+9rltjaTQWDnEFQ==", 19 | "dev": true, 20 | "engines": { 21 | "node": "^12.17.0 || ^14.13 || >=16.0.0" 22 | }, 23 | "funding": { 24 | "url": "https://github.com/chalk/chalk?sponsor=1" 25 | } 26 | }, 27 | "node_modules/utap": { 28 | "version": "0.1.2", 29 | "resolved": "https://registry.npmjs.org/utap/-/utap-0.1.2.tgz", 30 | "integrity": "sha512-ZkbgRBhlJzfYOQGzD4+Rd1eWQsZdyJljdNZDgHfy4Fv7y1C0bQalG3aaNTgmXsQf/vxT0LXfnu1TEUwA7HCThw==", 31 | "dev": true, 32 | "dependencies": { 33 | "chalk": "^5.1.2" 34 | }, 35 | "bin": { 36 | "utap": "src/utap.js" 37 | } 38 | } 39 | }, 40 | "dependencies": { 41 | "chalk": { 42 | "version": "5.1.2", 43 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.1.2.tgz", 44 | "integrity": "sha512-E5CkT4jWURs1Vy5qGJye+XwCkNj7Od3Af7CP6SujMetSMkLs8Do2RWJK5yx1wamHV/op8Rz+9rltjaTQWDnEFQ==", 45 | "dev": true 46 | }, 47 | "utap": { 48 | "version": "0.1.2", 49 | "resolved": "https://registry.npmjs.org/utap/-/utap-0.1.2.tgz", 50 | "integrity": "sha512-ZkbgRBhlJzfYOQGzD4+Rd1eWQsZdyJljdNZDgHfy4Fv7y1C0bQalG3aaNTgmXsQf/vxT0LXfnu1TEUwA7HCThw==", 51 | "dev": true, 52 | "requires": { 53 | "chalk": "^5.1.2" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conclure", 3 | "version": "2.2.0", 4 | "description": "Generator runner", 5 | "type": "module", 6 | "main": "src/conclude.js", 7 | "module": "src/conclude.js", 8 | "exports": { 9 | ".": "./src/conclude.js", 10 | "./effects": "./src/effects.js", 11 | "./combinators": "./src/combinators.js" 12 | }, 13 | "types": "conclude.d.ts", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/dmaevsky/conclure.git" 17 | }, 18 | "scripts": { 19 | "test": "find tests -type f -name '*.test.js' -exec echo \\# utap-src:{} \\; -exec node {} \\; | utap" 20 | }, 21 | "keywords": [ 22 | "generator", 23 | "runner", 24 | "saga", 25 | "yield", 26 | "promise", 27 | "async", 28 | "await" 29 | ], 30 | "author": "Dmitry Maevsky", 31 | "license": "MIT", 32 | "devDependencies": { 33 | "utap": "^0.1.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/combinators.js: -------------------------------------------------------------------------------- 1 | import { conclude, isPromise } from './conclude.js'; 2 | import { cps } from './effects.js'; 3 | 4 | const noop = () => {}; 5 | 6 | const returnResults = (_, results, callback) => callback(null, results); 7 | const throwErrors = (errors, _, callback) => callback(errors); 8 | 9 | const afterOne = { 10 | all: (error, result) => ({ error, result, stop: error }), 11 | any: (error, result) => ({ error, result, stop: !error }), 12 | race: (error, result) => ({ error, result, stop: true }), 13 | allSettled: (error, result) => ({ result: error ? { error } : { result }, stop: false }), 14 | }; 15 | 16 | const afterAll = { 17 | all: returnResults, 18 | any: throwErrors, 19 | race: noop, 20 | allSettled: returnResults 21 | }; 22 | 23 | const combinator = pattern => Object.assign(function (payload, callback) { 24 | if (!callback) return cps(combinators[pattern], payload); 25 | 26 | const results = Array.isArray(payload) ? [] : {}; 27 | const errors = Array.isArray(payload) ? [] : {}; 28 | 29 | let count = Object.keys(payload).length; 30 | 31 | if (count === 0) { 32 | afterAll[pattern](errors, results, callback); 33 | return noop; 34 | } 35 | 36 | let stopKey = undefined; 37 | const cancellations = {} 38 | 39 | const cancelOthers = () => { 40 | for (let k in cancellations) { 41 | if (k !== stopKey) cancellations[k](); 42 | } 43 | } 44 | 45 | for (let k in payload) { 46 | if (stopKey !== undefined) { 47 | // Prevent unhandled rejections when stopped synchronously 48 | if (isPromise(payload[k])) payload[k].catch(noop); 49 | continue; 50 | } 51 | 52 | cancellations[k] = conclude(payload[k], (err, res) => { 53 | const { stop, error, result } = afterOne[pattern](err, res); 54 | 55 | if (stop) { 56 | stopKey = k; 57 | cancelOthers(); 58 | 59 | if (error) callback(Array.isArray(payload) ? error : { [k]: error }); 60 | else callback(null, Array.isArray(payload) ? result : { [k]: result }); 61 | } 62 | else { 63 | results[k] = result; 64 | errors[k] = error; 65 | 66 | if (--count === 0) { 67 | afterAll[pattern](errors, results, callback); 68 | } 69 | } 70 | }); 71 | } 72 | return stopKey !== undefined ? noop : cancelOthers; 73 | }, { combinator: pattern }); 74 | 75 | const combinators = Object.keys(afterOne).reduce((acc, pattern) => Object.assign(acc, { [pattern]: combinator(pattern) }), {}); 76 | 77 | export const { all, any, race, allSettled } = combinators; 78 | -------------------------------------------------------------------------------- /src/conclude.js: -------------------------------------------------------------------------------- 1 | const TYPE = '@@conclude-effect'; 2 | 3 | export function isIterator(obj) { 4 | return !!obj && (typeof obj === 'object' || typeof obj === 'function') 5 | && typeof obj.next === 'function' 6 | && typeof obj.throw === 'function'; 7 | } 8 | 9 | export function isPromise(obj) { 10 | return !!obj && (typeof obj === 'object' || typeof obj === 'function') 11 | && typeof obj.then === 'function' 12 | && typeof obj.catch === 'function'; 13 | } 14 | 15 | export const isEffect = effect => Boolean(effect && effect[TYPE]); 16 | 17 | export const isFlow = it => [isPromise, isEffect, isIterator].find(is => is(it)); 18 | 19 | const runners = new Map([ 20 | [isPromise, runPromise], 21 | [isEffect, runEffect], 22 | [isIterator, runIterator], 23 | ]); 24 | 25 | const noop = () => {}; 26 | 27 | const running = Symbol.for('@@conclude-running'); 28 | const resultCache = Symbol.for('@@conclude-result'); 29 | const finishWatchers = Symbol.for('@@conclude-watchers'); 30 | 31 | export const inProgress = it => running in it; 32 | export const finished = it => resultCache in it; 33 | export const getResult = it =>it[resultCache]; 34 | 35 | export function whenFinished(it, callback) { 36 | if (!isFlow(it)) { 37 | callback({ result: it }); 38 | return noop; 39 | } 40 | 41 | if (resultCache in it) { 42 | callback(it[resultCache]); 43 | return noop; 44 | } 45 | 46 | let watchers = it[finishWatchers]; 47 | 48 | if (!watchers) watchers = it[finishWatchers] = new Set([callback]); 49 | else watchers.add(callback); 50 | 51 | return () => watchers.delete(callback); 52 | } 53 | 54 | function finalize(it, payload) { 55 | it[resultCache] = payload; 56 | delete it[running]; 57 | 58 | for (let cb of it[finishWatchers] || []) cb(payload); 59 | delete it[finishWatchers]; 60 | } 61 | 62 | export function conclude(it, callback) { 63 | const flowType = isFlow(it); 64 | 65 | if (!flowType) { 66 | callback(null, it); 67 | return noop; 68 | } 69 | 70 | if (resultCache in it) { 71 | const { result, error, cancelled } = it[resultCache]; 72 | 73 | if (cancelled) return noop; 74 | 75 | if (error) callback(error); 76 | else callback(null, result); 77 | 78 | return noop; 79 | } 80 | 81 | if (running in it) { 82 | const subscribe = it[running]; 83 | return subscribe(callback); 84 | } 85 | 86 | const subscribers = new Set(); 87 | 88 | const onConclude = (error, result) => { 89 | finalize(it, { error, result }); 90 | 91 | for (let cb of subscribers) cb(error, result); 92 | } 93 | 94 | function subscribe(cb) { 95 | subscribers.add(cb); 96 | 97 | return function unsubscribe() { 98 | subscribers.delete(cb); 99 | 100 | if (subscribers.size === 0 && !(resultCache in it)) { 101 | finalize(it, { cancelled: true }); 102 | cancel(); 103 | } 104 | } 105 | } 106 | 107 | it[running] = subscribe; 108 | 109 | const unsubscribe = subscribe(callback); 110 | 111 | const cancel = runners.get(flowType)(it, onConclude); 112 | 113 | return unsubscribe; 114 | } 115 | 116 | function runPromise(promise, callback) { 117 | let cancelled = false; 118 | 119 | promise 120 | .then(result => !cancelled && callback(null, result)) 121 | .catch(error => !cancelled && callback(error)); 122 | 123 | return () => cancelled = true; 124 | } 125 | 126 | function runEffect({ [TYPE]: type, context, fn, args }, callback) { 127 | try { 128 | switch (type) { 129 | case 'CPS': 130 | return fn.call(context, ...args, callback); 131 | 132 | case 'CPS_NO_CANCEL': 133 | let cancelled = false; 134 | fn.call(context, ...args, (error, result) => !cancelled && callback(error, result)); 135 | 136 | return () => cancelled = true; 137 | 138 | case 'CALL': 139 | const result = fn.apply(context, args); 140 | return conclude(result, callback); 141 | 142 | default: 143 | throw new Error('Unknown effect type ' + type); 144 | } 145 | } 146 | catch (error) { 147 | callback(error); 148 | return noop; 149 | } 150 | } 151 | 152 | function runIterator(it, callback) { 153 | let cancel, step = 0; 154 | 155 | const setCancel = (j, fn) => { 156 | if (j >= step) cancel = fn; 157 | } 158 | 159 | function iterate(error, result) { 160 | try { 161 | let cancelled = false; 162 | setCancel(++step, () => cancelled = true); 163 | 164 | const { value, done } = error 165 | ? it.throw(error) 166 | : it.next(result); 167 | 168 | if (cancelled) return; 169 | 170 | setCancel(step, conclude(value, done ? callback : iterate)); 171 | } 172 | catch (err) { 173 | callback(err); 174 | } 175 | } 176 | 177 | iterate(); 178 | return () => cancel(); 179 | } 180 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | export const TYPE = '@@conclude-effect'; 2 | 3 | const makeEffect = (type, fn, ...args) => { 4 | let context = null; 5 | 6 | if (Array.isArray(fn)) { 7 | [context, fn] = fn; 8 | if (typeof fn !== 'function') fn = context[fn]; 9 | } 10 | 11 | return { 12 | [TYPE]: type, 13 | context, fn, args 14 | }; 15 | } 16 | 17 | export const cps = makeEffect.bind(null, 'CPS'); 18 | export const cps_no_cancel = makeEffect.bind(null, 'CPS_NO_CANCEL'); 19 | export const call = makeEffect.bind(null, 'CALL'); 20 | 21 | export function delay(ms, callback) { 22 | if (!callback) return cps(delay, ms); 23 | 24 | const timeout = setTimeout(() => callback(null), ms); 25 | return () => clearTimeout(timeout); 26 | } 27 | -------------------------------------------------------------------------------- /tests/combinators.test.js: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | 4 | import { conclude, inProgress, getResult, whenFinished } from '../src/conclude.js'; 5 | import { delay, call } from '../src/effects.js'; 6 | import * as Conclude from '../src/combinators.js'; 7 | 8 | test('all', async () => { 9 | let r = null; 10 | const promise = Promise.resolve(); 11 | 12 | function* processItem(item) { 13 | yield promise; 14 | return item.toUpperCase(); 15 | } 16 | 17 | function* run(data) { 18 | return Conclude.all(data.map(processItem)); 19 | } 20 | 21 | const flow = run(['foo', 'bar', 'baz']); 22 | conclude(flow, (error, result) => r = { error, result }); 23 | 24 | assert(inProgress(flow)); 25 | await promise; 26 | assert(!inProgress(flow)); 27 | 28 | assert.deepEqual(r, { error: null, result: ['FOO', 'BAR', 'BAZ']}); 29 | }); 30 | 31 | test('all with the same flow twice', async () => { 32 | const promise = Promise.resolve(42); 33 | let result; 34 | 35 | conclude(Conclude.all([promise, promise]), (_, r) => result = r); 36 | 37 | await promise; 38 | assert.deepEqual(result, [42, 42]); 39 | }); 40 | 41 | test('race', async () => { 42 | const promise = Promise.resolve(42); 43 | 44 | const flow = Conclude.race({ 45 | slow: delay(1000), 46 | fast: promise 47 | }); 48 | 49 | conclude(flow, e => e); 50 | 51 | assert(inProgress(flow)); 52 | await promise; 53 | assert(!inProgress(flow)); 54 | 55 | assert.deepEqual(getResult(flow).result, { fast: 42 }); 56 | }); 57 | 58 | test('allSettled', () => new Promise(resolve => { 59 | function* g() { 60 | const results = yield Conclude.allSettled([ 61 | Promise.resolve(42), 62 | Promise.reject('OOPS') 63 | ]); 64 | 65 | assert.deepEqual(results, [ 66 | { result: 42 }, 67 | { error: 'OOPS' } 68 | ]); 69 | } 70 | conclude(g(), resolve); 71 | })); 72 | 73 | test('any', () => new Promise(resolve => { 74 | function* g() { 75 | const result = yield Conclude.any([ 76 | Promise.resolve(42), 77 | Promise.reject('OOPS') 78 | ]); 79 | 80 | assert.equal(result, 42); 81 | } 82 | conclude(g(), resolve); 83 | })); 84 | 85 | test('all throwing sync', () => new Promise(resolve => { 86 | const boom = () => { throw 'BOOM'; } 87 | 88 | function* g() { 89 | try { 90 | yield Conclude.all({ 91 | sync: call(boom), 92 | async: Promise.reject('I will be cancelled anyway') 93 | }); 94 | } 95 | catch (err) { 96 | assert.deepEqual(err, { sync: 'BOOM' }); 97 | } 98 | } 99 | conclude(g(), resolve); 100 | })); 101 | 102 | test('all, cancelling before completion', () => new Promise(resolve => { 103 | const promises = [ 104 | Promise.resolve(42), 105 | Promise.reject('boom') 106 | ]; 107 | 108 | function* g() { 109 | yield 5; 110 | yield Conclude.all(promises); 111 | } 112 | 113 | const cancel = conclude(g(), () => assert.fail('Flow is NOT cancelled')); 114 | 115 | let count = 2; 116 | 117 | whenFinished(promises[0], ({ cancelled }) => cancelled && --count === 0 && resolve()); 118 | whenFinished(promises[1], ({ cancelled }) => cancelled && --count === 0 && resolve()); 119 | 120 | cancel(); 121 | })); 122 | 123 | test('combinator tag', () => { 124 | for (let pattern in Conclude) { 125 | const effect = Conclude[pattern]([Promise.resolve()]); 126 | assert.equal(effect.fn.combinator, pattern); 127 | } 128 | }); 129 | -------------------------------------------------------------------------------- /tests/conclude.test.js: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | 4 | import { conclude, finished, inProgress, getResult, whenFinished } from '../src/conclude.js'; 5 | 6 | test('conclude generator sync', () => { 7 | let r = null; 8 | 9 | function* g() { 10 | yield 88; 11 | return 42; 12 | } 13 | 14 | const it = g(); 15 | conclude(it, (error, result) => r = { error, result }); 16 | 17 | assert(!inProgress(it)); 18 | assert(finished(it)); 19 | 20 | assert.deepEqual(r, { error: null, result: 42 }); 21 | }); 22 | 23 | test('throwing sync', () => new Promise(resolve => { 24 | function* boom() { 25 | throw 'BOOM'; 26 | } 27 | 28 | function* g() { 29 | try { 30 | yield boom(); 31 | } 32 | catch (err) { 33 | assert.equal(err, 'BOOM'); 34 | } 35 | } 36 | conclude(g(), resolve); 37 | })); 38 | 39 | test('simultaneous conclude', async () => { 40 | let r1 = null, r2 = null; 41 | const promise = Promise.resolve(); 42 | 43 | function* g() { 44 | yield promise; 45 | return 42; 46 | } 47 | 48 | const it = g(); 49 | 50 | conclude(it, (error, result) => r1 = { error, result }); 51 | conclude(it, (error, result) => r2 = { error, result }); 52 | 53 | assert.equal(r1, null); 54 | assert.equal(r2, null); 55 | 56 | assert(inProgress(it)); 57 | 58 | await promise; 59 | 60 | assert(finished(it)); 61 | assert.deepEqual(r1, { error: null, result: 42 }); 62 | assert.deepEqual(r2, { error: null, result: 42 }); 63 | }); 64 | 65 | test('simultaneous conclude, throwing', async () => { 66 | let r1 = null, r2 = null; 67 | const promise = Promise.resolve(); 68 | 69 | function* g() { 70 | yield promise; 71 | throw new Error('BOOM!'); 72 | } 73 | 74 | const it = g(); 75 | 76 | conclude(it, (error, result) => r1 = { error, result }); 77 | conclude(it, (error, result) => r2 = { error, result }); 78 | 79 | assert.equal(r1, null); 80 | assert.equal(r2, null); 81 | 82 | assert(inProgress(it)); 83 | 84 | await promise; 85 | 86 | assert(finished(it)); 87 | assert.equal(r1.error.message, 'BOOM!'); 88 | assert.equal(r2.error.message, 'BOOM!'); 89 | }); 90 | 91 | test('simultaneous conclude, cancelling', async () => { 92 | let r1 = null, r2 = null, isCancelled = false; 93 | const promise = Promise.resolve(); 94 | 95 | function* g() { 96 | yield promise; 97 | return 42; 98 | } 99 | 100 | const it = g(); 101 | whenFinished(it, ({ cancelled }) => isCancelled = Boolean(cancelled)); 102 | 103 | assert(!inProgress(it)); 104 | 105 | const [cancel1, cancel2] = [ 106 | conclude(it, (error, result) => r1 = { error, result }), 107 | conclude(it, (error, result) => r2 = { error, result }) 108 | ]; 109 | 110 | assert.equal(r1, null); 111 | assert.equal(r2, null); 112 | assert(!isCancelled); 113 | 114 | assert(inProgress(it)); 115 | 116 | cancel1(); 117 | cancel2(); 118 | 119 | assert(isCancelled); 120 | assert(finished(it)); 121 | 122 | await promise; 123 | 124 | assert.equal(r1, null); 125 | assert.equal(r2, null); 126 | }); 127 | 128 | test('simultaneous conclude, cancelling one', async () => { 129 | let r1 = null, r2 = null, isCancelled = false; 130 | const promise = Promise.resolve(); 131 | 132 | function* g() { 133 | yield promise; 134 | return 42; 135 | } 136 | 137 | const it = g(); 138 | whenFinished(it, ({ cancelled }) => isCancelled = Boolean(cancelled)); 139 | 140 | assert(!inProgress(it)); 141 | 142 | const [cancel1] = [ 143 | conclude(it, (error, result) => r1 = { error, result }), 144 | conclude(it, (error, result) => r2 = { error, result }) 145 | ]; 146 | 147 | assert.equal(r1, null); 148 | assert.equal(r2, null); 149 | assert(!isCancelled); 150 | 151 | assert(inProgress(it)); 152 | 153 | cancel1(); 154 | 155 | await promise; 156 | 157 | assert(!inProgress(it)); 158 | assert(finished(it)); 159 | 160 | assert(!isCancelled); 161 | assert.equal(r1, null); 162 | assert.deepEqual(r2, { error: null, result: 42 }); 163 | }); 164 | 165 | test('yielding a rejected promise', async () => { 166 | let r = null; 167 | const promise = Promise.reject(new Error('OOPS')); 168 | 169 | function* g() { 170 | yield promise; 171 | return 42; 172 | } 173 | 174 | const it = g(); 175 | 176 | assert(!inProgress(it)); 177 | 178 | conclude(it, (error, result) => r = { error, result }); 179 | 180 | assert.equal(r, null); 181 | assert(inProgress(it)); 182 | 183 | await promise.catch(e => e); 184 | 185 | assert(!inProgress(it)); 186 | assert(finished(it)); 187 | 188 | assert.equal(r.error.message, 'OOPS'); 189 | }); 190 | 191 | test('returning a rejected promise and cancelling', async () => { 192 | let r = null; 193 | const promise = Promise.reject(new Error('OOPS')); 194 | 195 | function* g() { 196 | return promise; 197 | } 198 | 199 | const it = g(); 200 | 201 | assert(!inProgress(it)); 202 | 203 | const cancel = conclude(it, (error, result) => r = { error, result }); 204 | 205 | assert(inProgress(it)); 206 | cancel(); 207 | assert(finished(it)); 208 | 209 | await promise.catch(e => e); 210 | 211 | assert.deepEqual(getResult(it), { cancelled: true }); 212 | }); 213 | 214 | test('sync self cancellation while generator is running', async () => { 215 | let r = null; 216 | let cancel; 217 | const promise = Promise.resolve().then(() => cancel); 218 | 219 | function* g() { 220 | const selfCancel = yield promise; 221 | selfCancel(); 222 | return 42; 223 | } 224 | 225 | const it = g(); 226 | 227 | assert(!inProgress(it)); 228 | 229 | cancel = conclude(it, (error, result) => r = { error, result }); 230 | 231 | assert(inProgress(it)); 232 | await promise; 233 | assert(finished(it)); 234 | 235 | assert.deepEqual(getResult(it), { cancelled: true }); 236 | }); 237 | -------------------------------------------------------------------------------- /tests/effects.test.js: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | 4 | import { conclude, inProgress, getResult } from '../src/conclude.js'; 5 | import { cps, cps_no_cancel, call } from '../src/effects.js'; 6 | 7 | test('cps', async () => { 8 | let r = null; 9 | let promise; 10 | 11 | function plus42(n, callback) { 12 | return conclude(promise = Promise.resolve(n + 42), callback); 13 | } 14 | 15 | function* run(input) { 16 | return cps(plus42, input); 17 | } 18 | 19 | const it = run(100); 20 | conclude(it, (error, result) => r = { error, result }); 21 | 22 | assert(inProgress(it)); 23 | await promise; 24 | assert(!inProgress(it)); 25 | 26 | assert.deepEqual(r, { error: null, result: 142 }); 27 | }); 28 | 29 | test('call', async () => { 30 | let r = null; 31 | let promise; 32 | 33 | function plus42(n) { 34 | return promise = Promise.resolve(n + 42); 35 | } 36 | 37 | function* run(input) { 38 | return call(plus42, input); 39 | } 40 | 41 | const it = run(100); 42 | conclude(it, (error, result) => r = { error, result }); 43 | 44 | assert(inProgress(it)); 45 | await promise; 46 | assert(!inProgress(it)); 47 | 48 | assert.deepEqual(r, { error: null, result: 142 }); 49 | }); 50 | 51 | test('cps_no_cancel', async () => { 52 | let r = null; 53 | let promise; 54 | 55 | function plus42(n, callback) { 56 | promise = Promise.resolve(n + 42).then(r => callback(null, r)); 57 | } 58 | 59 | const it = cps_no_cancel(plus42, 100); 60 | conclude(it, (error, result) => r = { error, result }); 61 | 62 | assert(inProgress(it)); 63 | await promise; 64 | assert(!inProgress(it)); 65 | 66 | assert.deepEqual(r, { error: null, result: 142 }); 67 | }); 68 | 69 | test('cps_no_cancel, cancelling', async () => { 70 | let r = null; 71 | let promise; 72 | 73 | function plus42(n, callback) { 74 | promise = Promise.resolve(n + 42).then(r => callback(null, r)); 75 | } 76 | 77 | const it = cps_no_cancel(plus42, 100); 78 | const cancel = conclude(it, (error, result) => r = { error, result }); 79 | 80 | assert(inProgress(it)); 81 | cancel(); 82 | assert(!inProgress(it)); 83 | 84 | await promise; 85 | 86 | assert.equal(r, null); 87 | assert.deepEqual(getResult(it), { cancelled: true }); 88 | }); 89 | 90 | test('call, throwing', () => new Promise(resolve => { 91 | const boom = () => { throw 'BOOM'; } 92 | 93 | function* g() { 94 | try { 95 | yield call(boom); 96 | } 97 | catch (err) { 98 | assert.equal(err, 'BOOM'); 99 | } 100 | } 101 | conclude(g(), resolve); 102 | })); 103 | --------------------------------------------------------------------------------