├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── package.json ├── src ├── lib.js ├── task.js └── task.ts ├── test ├── lib.spec.js └── util.js ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "src/**" 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - "src**" 14 | - ".github/workflows/ci.yml" 15 | workflow_dispatch: 16 | 17 | jobs: 18 | check: 19 | name: Typecheck 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: 24 | - 16 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Setup node ${{ matrix.node-version }} 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Install dependencies 35 | uses: bahmutov/npm-install@v1 36 | 37 | - name: Typecheck 38 | uses: gozala/typescript-error-reporter-action@v1.0.8 39 | with: 40 | project: ./tsconfig.json 41 | test-node: 42 | name: Test Node 43 | runs-on: ${{ matrix.os }} 44 | 45 | strategy: 46 | matrix: 47 | node-version: 48 | - 16 49 | os: 50 | - ubuntu-latest 51 | 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v2 55 | 56 | - name: Setup Node 57 | uses: actions/setup-node@v2 58 | with: 59 | node-version: ${{ matrix.node-version }} 60 | 61 | - name: Install dependencies 62 | uses: bahmutov/npm-install@v1 63 | 64 | - name: Test 65 | run: yarn test:node 66 | test-web: 67 | name: Test Web 68 | runs-on: ubuntu-latest 69 | 70 | strategy: 71 | matrix: 72 | node-version: 73 | - 16 74 | 75 | steps: 76 | - name: Checkout 77 | uses: actions/checkout@v2 78 | 79 | - name: Setup Node 80 | uses: actions/setup-node@v2 81 | with: 82 | node-version: 16 83 | 84 | - name: Install dependencies 85 | uses: bahmutov/npm-install@v1 86 | 87 | - name: Test 88 | run: yarn test:web 89 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | workflow_dispatch: 6 | 7 | name: release 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: GoogleCloudPlatform/release-please-action@v2 13 | id: release 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | release-type: node 17 | package-name: "vectrie" 18 | # The logic below handles the npm publication: 19 | - uses: actions/checkout@v2 20 | # these if statements ensure that a publication only occurs when 21 | # a new release is created: 22 | if: ${{ steps.release.outputs.release_created }} 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: "16" 26 | registry-url: https://registry.npmjs.org/ 27 | if: ${{ steps.release.outputs.release_created }} 28 | - name: Install 29 | uses: bahmutov/npm-install@v1 30 | if: ${{ steps.release.outputs.release_created }} 31 | - name: Publish 32 | run: npm publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 35 | if: ${{ steps.release.outputs.release_created }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .nyc_output 3 | dist 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [2.3.1](https://www.github.com/Gozala/actor/compare/v2.3.0...v2.3.1) (2022-09-15) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * circular dependency ([#23](https://www.github.com/Gozala/actor/issues/23)) ([66cc340](https://www.github.com/Gozala/actor/commit/66cc340cb4c1c25e225a0d7e99fc07fc59b93b69)) 9 | 10 | ## [2.3.0](https://www.github.com/Gozala/actor/compare/v2.2.1...v2.3.0) (2022-03-09) 11 | 12 | 13 | ### Features 14 | 15 | * ability to join idle forks ([#20](https://www.github.com/Gozala/actor/issues/20)) ([8d5be1c](https://www.github.com/Gozala/actor/commit/8d5be1c636fd178e86eef11d6444ed5ebbb299ca)) 16 | 17 | ### [2.2.1](https://www.github.com/Gozala/actor/compare/v2.2.0...v2.2.1) (2022-02-25) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * sync wait loop ([#16](https://www.github.com/Gozala/actor/issues/16)) ([9138077](https://www.github.com/Gozala/actor/commit/9138077b99163b459e6ae4294791f1a6953b7658)) 23 | 24 | ## [2.2.0](https://www.github.com/Gozala/actor/compare/v2.1.0...v2.2.0) (2022-02-25) 25 | 26 | 27 | ### Features 28 | 29 | * implement loop API ([#14](https://www.github.com/Gozala/actor/issues/14)) ([cf58c56](https://www.github.com/Gozala/actor/commit/cf58c56b9a2a8b9dbf7cd6f118aeb13ac662b91c)) 30 | 31 | ## [2.1.0](https://www.github.com/Gozala/actor/compare/v2.0.0...v2.1.0) (2022-02-20) 32 | 33 | 34 | ### Features 35 | 36 | * add effects and batch APIs ([#11](https://www.github.com/Gozala/actor/issues/11)) ([a600e65](https://www.github.com/Gozala/actor/commit/a600e651e54d396ce9e5b84094020085714eed9a)) 37 | 38 | ## [2.0.0](https://www.github.com/Gozala/actor/compare/v1.0.2...v2.0.0) (2022-02-19) 39 | 40 | 41 | ### ⚠ BREAKING CHANGES 42 | 43 | * get rid of ForkView (#8) 44 | 45 | ### Features 46 | 47 | * get rid of ForkView ([#8](https://www.github.com/Gozala/actor/issues/8)) ([b0df389](https://www.github.com/Gozala/actor/commit/b0df389cd707104c081f18ec9d511a92ac5a6744)) 48 | 49 | ### [1.0.2](https://www.github.com/Gozala/actor/compare/v1.0.1...v1.0.2) (2022-02-16) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * **types:** publish typedefs ([99bbe10](https://www.github.com/Gozala/actor/commit/99bbe106d46fea22e5c91df4d6e47a208e0e27f7)) 55 | 56 | ### [1.0.1](https://www.github.com/Gozala/actor/compare/v1.0.0...v1.0.1) (2022-02-16) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * typo ([9db8cd7](https://www.github.com/Gozala/actor/commit/9db8cd70c7fac9d70f574e788a304fe477e5ae39)) 62 | 63 | ## 1.0.0 (2022-02-16) 64 | 65 | 66 | ### Features 67 | 68 | * **docs:** describe the library ([#5](https://www.github.com/Gozala/actor/issues/5)) ([e32646b](https://www.github.com/Gozala/actor/commit/e32646b5a6e9a40ecfa6054a86d112ca57b144ac)) 69 | 70 | 71 | ### Bug Fixes 72 | 73 | * github actions ([#3](https://www.github.com/Gozala/actor/issues/3)) ([b84b5fd](https://www.github.com/Gozala/actor/commit/b84b5fdec22914f7731f355b3418a0684f19bdd2)) 74 | * the enqueue bug ([3aa4283](https://www.github.com/Gozala/actor/commit/3aa42836057abf4b9f639f532c14a36367e52bba)) 75 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Protocol Labs 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright 2020 Protocol Labs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Library 2 | 3 | Library provides [actor][actor model] based concurrency primitives for managing effects. Design is heavily inpsired by [Elm Task][] & [Elm Process][] _(which provide [Erlang style concurrency][] in typed language)_ and [Rust `std::thread`][rust_threading] system. 4 | 5 | ## Design 6 | 7 | Library uses [cooperative scheduler][] to run concurrent **tasks** _(a.k.a light-weight processes)_ represented via **synchronous** [generators][]. Tasks describe asynchronous operations that may fail, using (synchronous looking) [delimited continuations][]. That is, instead of `await`-ing on a result of async operation, you delegate via `yield*` _(delegating expression)_, which allows scheduler to suspend execution until (async) result is ready and resume task with a value or a thrown exeception. 8 | 9 | ```ts 10 | import * as Task from "actor" 11 | 12 | /** 13 | * @param {URL} url 14 | */ 15 | function* work(url) { 16 | // Task.wait will suspend execution until provided promise is settled, 17 | // resuming it either with a resolved value or throwing an expection. 18 | const response = yield* Task.wait(fetch(url.href)) 19 | const content = yield* Task.wait(response.text()) 20 | 21 | console.log(content) 22 | } 23 | 24 | // Spawns a new task and runs it to completition 25 | Task.spawn(work(new URL("https://gozala.io/work/"))) 26 | ``` 27 | 28 | `Task.wait` is just a helper function included with a library designed to look and behave like familiar `await`. It is not special however, it just utilizes two **control** instruction messages that can be used to obtain reference to `current` task so it can be resumed on external event after `suspend` instruction tells scheduler to task switch up until furter notice. To illustrate here is how `Task.sleep` is implemented in the library 29 | 30 | ```js 31 | /** 32 | * @param {number} duration 33 | * @returns {Task.Task} 34 | */ 35 | export function* sleep(duration) { 36 | // obtain reference to the current task handle 37 | const current = yield* Task.current() 38 | // set a timer to resume this task after given duratino 39 | const id = setTimeout(() => Task.resume(current), duration) 40 | // suspend this task up until timer resumes it. 41 | try { 42 | yield* Task.suspend() 43 | // clear timeout in case task is cancelled in the meantime 44 | } finally { 45 | clearTimeout(id) 46 | } 47 | } 48 | ``` 49 | 50 | > `Task.current()` and `Task.suspend()` are just utility functions that send above mentioned control instructions represented via privade symbols. 51 | 52 | ### Type Safety 53 | 54 | Library is strongly typed and desigend such that [typescript][] is able to infer types of things that computation will resume with (that is things on the left side of `yield*`). 55 | 56 | Additionally it's typed such to help you catch common pitfalls like forgettingn `*` at the end of `yield` (which is very different operation) or when yielding generator function e.g. `yield* work` as opposed to a generator, that is `yield* work()` 57 | 58 | ## API 59 | 60 | ### `Task` 61 | 62 | Task represents a unit of computation that runs concurrently, a light-weight process (in Erlang terms). You can spawn bunch of them and provided cooperative scheduler will interleave their execution. 63 | 64 | Tasks have three type variables: 65 | 66 | - Variable `T` describes return type of succeful computation. 67 | - Variable `X` describes error type of failed computation (type of thrown exceptions) 68 | - Variable `M` describes type of a messages this task may produce. 69 | 70 | > Please note that that TS does not really support exception type checking so `X` is not guaranteed. Yet, we find them to be more practical than treating them as `any` like TS does on `Promise`s. This allows combinators to restrict on type of task they work with. 71 | 72 | ##### `fork(task:Task):Fork` 73 | 74 | Creates a new concurrent task. It is a primary way to activate a task from the outside of context and usually is how you'd start your main task. It returns `Fork` that implements `Future` interface, which is like a lazy promise `Promise` so it's result can be `await`-ed. 75 | 76 | ```js 77 | // arbitrary program 78 | export const entry = async () => { 79 | // Creates a main task and await for it's result 80 | const result = await Task.fork(main) 81 | // prints 0 82 | console.log(result) 83 | } 84 | 85 | function* main() { 86 | console.log("main task is activated") 87 | // your main task 88 | return 0 89 | } 90 | ``` 91 | 92 | You can start new concurrent tasks from other tasks. Note that forked task is detached from from the task that starts it. Fork may outlive it and may fail without affecting that task that started it. 93 | 94 | ```js 95 | function* main() { 96 | const worker = yield* Task.fork(work()) 97 | console.log("prints first") 98 | } 99 | 100 | function* work() { 101 | console.log("prints second") 102 | } 103 | ``` 104 | 105 | ##### `join(fork:Fork): Task` 106 | 107 | When task forks it gets a reference to a `Fork` instance, which can be used to `join` forked task back in. Task that executes `join` gets suspended 108 | until fork is done executing at which point it is resumed with a return value of the forked task. If forked task throws executing `join` will throw that error out. All the messages from the forked task will propagate through 109 | the task it is joined with. 110 | 111 | > You can think of it as fork having it's own `stdout` / `stderr`, but when joined its `stdout` / `stderror` get piped into caller task's `stdout` / `stderr`. 112 | 113 | ```js 114 | function* main() { 115 | // Start a concurrent task doing some work 116 | const worker = yield* Task.fork(work()) 117 | // Concurrently perfrom some other worke in the main task 118 | yield* doSomeOtherWork() 119 | try { 120 | // Now join worker task back in and get it's return value 121 | const value = yield* Task.join(worker) 122 | // ... 123 | } catch (error) { 124 | // If worker crashed it's error is caught here. 125 | } 126 | } 127 | ``` 128 | 129 | ##### `abort(fork:Fork, error:X):Task` 130 | 131 | Forked task may be aborted by another task if it has a reference to it. 132 | 133 | ```js 134 | function* main() { 135 | const worker = yield* Task.fork(work()) 136 | yield* Task.sleep(10) 137 | yield* Task.abort(worker, new Error("die")) 138 | } 139 | 140 | function* work() { 141 | const session = new AbortController() 142 | try { 143 | const response = yield* Task.wait(fetch(URL, session)) 144 | const text = yield* Task.wait(response.text()) 145 | // ... 146 | localStorage.workStatus = "done" 147 | } catch (error) { 148 | if (error.message === "die") { 149 | // do some clenup logic here 150 | localStorage.workStatus = "aborted" 151 | } 152 | } finally { 153 | session.abort() 154 | } 155 | } 156 | ``` 157 | 158 | ##### `exit(fork:Fork, value:T):Task` 159 | 160 | Forked task may be exited by another task if it has a referce to it. 161 | 162 | ```ts 163 | function* main() { 164 | const worker = yield* Task.fork(work()) 165 | const result = yield* doSomethingConcurrently() 166 | // Exit task here in case we''re finished 167 | yield* Task.exit(worker, result) 168 | } 169 | ``` 170 | 171 | ##### `spawn(task:Task):Task` 172 | 173 | Starts concurrent "detached" task. This is a more lightweight alternative to `fork` however only tasks that produce no output (return vaule is `undefined`, can not fail and sends no messages) can be spawned. This invariant is enforced through type system and is in place because spawned tasks (unlike forked ones) 174 | can not be `join`ed, `abort`ed or `exit`ed and there for their errors would end up unhandled. 175 | 176 | > Please note: `Task.spawn(work())` does not start a task, it creates one, which if executed will spawn provided task. Unlike `Task.fork` it does not implement `Future` interface so awaiting on it will have no effect. 177 | 178 | ```js 179 | function* main() { 180 | yield* Task.spawn(work()) 181 | const response = yield* Task.wait(fetch(URL)) 182 | // ... 183 | } 184 | 185 | function* work() { 186 | try { 187 | // your logic here 188 | } catch (error) { 189 | // not allowed to error so you must handle all the errors 190 | } 191 | } 192 | ``` 193 | 194 | ##### `main(task: Task): void` 195 | 196 | Executes a task that produces no output. This is a more light-weight alternative to `Task.fork` for start a main task from the outside. Just like 197 | `Task.spawn` provided `task` is not allowed to fail, or send messages and it's return value is `void` which is enforced by type system. 198 | 199 | > Please note: While library will do it's best to infer the error types based on tasks you'll be runnig from it. However due to typescript limitations it can not be guaranteed that is because if you `throw new Error()` that is not inferred by type checker. 200 | > 201 | > It is authors responsibility to provide type annotations for errors. 202 | 203 | ```js 204 | function* main { 205 | try { 206 | // your programs main loop 207 | while (true) { 208 | 209 | } 210 | } catch(error) { 211 | // not allowed to throw so either recorver or exit 212 | } 213 | } 214 | 215 | Task.main(main()) 216 | ``` 217 | 218 | ### `Task` 219 | 220 | More commonly tasks describe asynchronous operations that may fail, like HTTP request or write to a database. These tasks do not produce any messages which is why they are typed as `Task`. 221 | 222 | These category of tasks seem similar to promises, however there is fundamental difference. `Task` represents anynchronous operation as opposed to resulf of an inflight operation like `Promise`. This subtle difference implies that while tasks describe effects, performing and orchestrating is done elsewhere (usually by central disptach system). This ofter referred to as **managed effects**, as opposed to **side effects** and such systems can usually avoid pitfalls of concurrent code by managing all effects in on place. 223 | 224 | ##### `current(): Task, never>` 225 | 226 | Gets a controller of the currently running task. Controller is usually obtained when task needs to "suspend" execution until some outside event occurs. When that event occurs obtained controller can be used to resume tasks execution (see `suspend` code example for more details) 227 | 228 | ##### `suspend(): Task` 229 | 230 | Suspends the current task, which can later be resumed from another task that hase it's controller or more often from outside event (e.g. `setTimeout` callback) by calling `Task.resume(controller)`. 231 | 232 | > Note: This task never fails, although it may never resume either. However you can utilize `finally` block to do a necessary cleanup in case execution is aborted or cancelled. 233 | 234 | ```js 235 | function* sleep(duration) { 236 | // get a reference to the currently running task, so we can resume it. 237 | const controller = yield* Task.current() 238 | // resume this task when timeout fires 239 | const id = setTimeout(() => Task.resume(controller), duration) 240 | try { 241 | // suspend this task nothing below this line will run until task is 242 | // resumed. 243 | yield* Task.suspend() 244 | } finally { 245 | // if task is aborted finally block will still run which given you 246 | // chance to cleanup. 247 | clearTimeout(id) 248 | } 249 | } 250 | ``` 251 | 252 | ##### `sleep(duration?: number): Task` 253 | 254 | Suspends execution for the given duration (in milliseconds), after which 255 | execution is resumed (unless task is cancelled in the meantime). 256 | 257 | ```js 258 | function* work() { 259 | console.log("I'm going to take small nap") 260 | yield* sleep(200) 261 | console.log("I am back to work") 262 | } 263 | ``` 264 | 265 | ##### `wait(input: PromiseLike|T): Task` 266 | 267 | Provides equivalent of `await` in async functions. Specifically it takes a value that you can await on (that is `Promise|T`) and suspends execution until promise is settled. If promise succeeds execution is resumed with `T` otherwise an error of type `X` is thrown (which is by default unknown since promises do not encode error type). 268 | 269 | It is especially useful when you need to deal with **sometimes async** set of operations without having to check if thing is a promise at each step. 270 | 271 | ```js 272 | function* fetchJSON(url, options) { 273 | const response = yield* wait(fetch(url, options)) 274 | const json = yield* wait(response.json()) 275 | return json 276 | } 277 | ``` 278 | 279 | > Please note: This that execution is suspended even if passed value is not a promise, however scheduler will still resume it in the same tick of the event loop after, after processing other scheduled tasks. This avoids problematic race condititions that can otherwise occur when values are sometimes promises and other times are not. 280 | 281 | ##### `all(tasks: Iterable>): Task` 282 | 283 | Takes iterable of tasks and runs them concurrently, returning an array of results in the same order as provided tasks (not in the order of completion). If any of the tasks fail all other tasks are aborted and error is throw into calling task. 284 | 285 | > This is basically equivalent of `Promise.all` except cancelation logic because tasks unlike promises can be cancelled. 286 | 287 | ### `Effect` 288 | 289 | Effect is anoter `Task` variant which instead of describing asynchronous operations that may succeed or fail describe asynchronous operations that may cause cascade of events and are there for typed as `Task` 290 | 291 | Effects are often comprised of multiple `Task` and represents either chain of events or type of events that may occur as result of execution. 292 | 293 | In both voriants they represent finite set of events and have completion. In that regard (especially first variant) they are more comparable to `Stream` than to an `EventEmitter`. In second variant `M` is ofter a union type which makes them more comparable to `EventEmitter`. Please note that it is best to avoid conceptualzing them thorugh mentioned, imperfect analogies, they are meant to emphasize difference more than similarity. 294 | 295 | ##### `send(message: T): Effect` 296 | 297 | Creates an effect that sends given message (or rather an effect producing given event). 298 | 299 | ```js 300 | function* work(url) { 301 | try { 302 | const response = yield* Task.wait(fetch(url)) 303 | const value = yield* Task.wait(response.json()) 304 | yield* Task.send({ ok:true value }) 305 | } catch (error) { 306 | yield* Task.send({ ok: false, error }) 307 | } 308 | } 309 | ``` 310 | 311 | ##### `effect(task: Task): Effect` 312 | 313 | Turns a task (that never fails or sends messages) into an effect of it's 314 | result. 315 | 316 | ##### `listen(source:{[K in T]: Effect}): Task.Effect<{type:N, [K in T]: M}>` 317 | 318 | Takes several effects and merges them into a single effect of tagged variants so 319 | that their origin could be identified via `type` field. 320 | 321 | ```js 322 | listen({ 323 | read: effect(dbRead), 324 | write: effect(dbWrite), 325 | }) 326 | ``` 327 | 328 | ##### `none():Task.Effect` 329 | 330 | Returns empty `Effect`, that is produces no messages. Kind of like `[]` or `""`, which is useful when you need to interact with an API that tases `Effect`, but in your case you produce `none`. 331 | 332 | 333 | [actor model]: https://en.wikipedia.org/wiki/Actor_model 334 | [elm task]: https://package.elm-lang.org/packages/elm/core/latest/Task 335 | [elm process]: https://package.elm-lang.org/packages/elm/core/latest/Process 336 | [erlang style concurrency]: https://www.erlang.org/doc/getting_started/conc_prog.html 337 | [rust_threading]: https://doc.rust-lang.org/std/thread/index.html 338 | [cooperative scheduler]: https://en.wikipedia.org/wiki/Cooperative_multitasking 339 | [generators]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator 340 | [delimited continuations]: https://en.wikipedia.org/wiki/Delimited_continuation 341 | [typescript]: https://www.typescriptlang.org/ 342 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actor", 3 | "version": "2.3.1", 4 | "description": " Actor based concurrency primitives for managing effects", 5 | "keywords": [ 6 | "actor", 7 | "generators", 8 | "async", 9 | "task", 10 | "effect", 11 | "fx" 12 | ], 13 | "scripts": { 14 | "test": "mocha test/*.spec.js", 15 | "test:web": "playwright-test test/*.spec.js --cov && nyc report", 16 | "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/*.spec.js", 17 | "coverage": "c8 --reporter=html mocha test/test-*.js && npm_config_yes=true npx st -d coverage -p 8080", 18 | "typecheck": "tsc --build", 19 | "prepare": "tsc --build" 20 | }, 21 | "type": "module", 22 | "main": "./src/lib.js", 23 | "types": "./dist/src/lib.d.ts", 24 | "exports": { 25 | ".": { 26 | "import": "./src/lib.js", 27 | "types": "./dist/src/lib.d.ts" 28 | } 29 | }, 30 | "files": [ 31 | "src", 32 | "dist/src" 33 | ], 34 | "c8": { 35 | "exclude": [ 36 | "test/**", 37 | "dist/**" 38 | ], 39 | "reports-dir": ".coverage" 40 | }, 41 | "nyc": { 42 | "exclude": [ 43 | "test/**", 44 | "dist/**" 45 | ], 46 | "report-dir": ".coverage" 47 | }, 48 | "devDependencies": { 49 | "typescript": "^4.5.5", 50 | "@types/mocha": "^9.1.0", 51 | "@types/chai": "^4.3.0", 52 | "mocha": "^9.2.0", 53 | "chai": "^4.3.6", 54 | "nyc": "^15.1.0", 55 | "playwright-test": "^7.2.2", 56 | "c8": "^7.11.0" 57 | }, 58 | "repository": { 59 | "type": "git", 60 | "url": "git://github.com/gozala/actor.git" 61 | }, 62 | "license": "(Apache-2.0 AND MIT)" 63 | } 64 | -------------------------------------------------------------------------------- /src/lib.js: -------------------------------------------------------------------------------- 1 | import * as Task from "./task.js" 2 | export * from "./task.js" 3 | 4 | /** 5 | * Turns a task (that never fails or sends messages) into an effect of it's 6 | * result. 7 | * 8 | * @template T 9 | * @param {Task.Task} task 10 | * @returns {Task.Effect} 11 | */ 12 | export const effect = function* (task) { 13 | const message = yield* task 14 | yield* send(message) 15 | } 16 | 17 | /** 18 | * Gets a handle to the task that invoked it. Useful when task needs to 19 | * suspend execution until some outside event occurs, in which case handle 20 | * can be used resume execution (see `suspend` code example for more details) 21 | * 22 | * @template T, M, X 23 | * @returns {Task.Task, never>} 24 | */ 25 | export function* current() { 26 | return /** @type {Task.Controller} */ (yield CURRENT) 27 | } 28 | 29 | /** 30 | * Suspends the current task (task that invokes it), which can then be 31 | * resumed from another task or an outside event (e.g. `setTimeout` callback) 32 | * by calling the `resume` with an task's handle. 33 | * 34 | * Calling this in almost all cases is preceeded by call to `current()` in 35 | * order to obtain a `handle` which can be passed to `resume` function 36 | * to resume the execution. 37 | * 38 | * Note: This task never fails, although it may never resume either. However 39 | * you can utilize `finally` block to do a necessary cleanup in case execution 40 | * is aborted. 41 | * 42 | * @example 43 | * ```js 44 | * import { current, suspend, resume } from "actor" 45 | * function * sleep(duration) { 46 | * // get a reference to this task so we can resume it. 47 | * const self = yield * current() 48 | * // resume this task when timeout fires 49 | * const id = setTimeout(() => resume(self), duration) 50 | * try { 51 | * // suspend this task nothing below this line will run until task is 52 | * // resumed. 53 | * yield * suspend() 54 | * } finally { 55 | * // if task is aborted finally block will still run which given you 56 | * // chance to cleanup. 57 | * clearTimeout(id) 58 | * } 59 | * } 60 | * ``` 61 | * 62 | * @returns {Task.Task} 63 | */ 64 | export const suspend = function* () { 65 | yield SUSPEND 66 | } 67 | 68 | /** 69 | * Suspends execution for the given duration in milliseconds, after which 70 | * execution is resumed (unless it was aborted in the meantime). 71 | * 72 | * @example 73 | * ```js 74 | * function * demo() { 75 | * console.log("I'm going to take small nap") 76 | * yield * sleep(200) 77 | * console.log("I am back to work") 78 | * } 79 | * ``` 80 | * 81 | * @param {number} [duration] 82 | * @returns {Task.Task} 83 | */ 84 | export function* sleep(duration = 0) { 85 | const task = yield* current() 86 | const id = setTimeout(enqueue, duration, task) 87 | 88 | try { 89 | yield* suspend() 90 | } finally { 91 | clearTimeout(id) 92 | } 93 | } 94 | 95 | /** 96 | * Provides equivalent of `await` in async functions. Specifically it takes 97 | * a value that you can `await` on (that is `Promise|T`) and suspends 98 | * execution until promise is settled. If promise succeeds execution is resumed 99 | * with `T` otherwise an error of type `X` is thrown (which is by default 100 | * `unknown` since promises do not encode error type). 101 | * 102 | * It is useful when you need to deal with potentially async set of operations 103 | * without having to check if thing is a promise at every step. 104 | * 105 | * Please note: This that execution is suspended even if given value is not a 106 | * promise, however scheduler will still resume it in the same tick of the event 107 | * loop after, just processing other scheduled tasks. This avoids problematic 108 | * race condititions that can otherwise occur when values are sometimes promises 109 | * and other times are not. 110 | * 111 | * @example 112 | * ```js 113 | * function * fetchJSON (url, options) { 114 | * const response = yield * wait(fetch(url, options)) 115 | * const json = yield * wait(response.json()) 116 | * return json 117 | * } 118 | * ``` 119 | * 120 | * @template T, [X=unknown] 121 | * @param {Task.Await} input 122 | * @returns {Task.Task} 123 | */ 124 | export const wait = function* (input) { 125 | const task = yield* current() 126 | if (isAsync(input)) { 127 | let failed = false 128 | /** @type {unknown} */ 129 | let output = undefined 130 | input.then( 131 | value => { 132 | failed = false 133 | output = value 134 | enqueue(task) 135 | }, 136 | error => { 137 | failed = true 138 | output = error 139 | enqueue(task) 140 | } 141 | ) 142 | 143 | yield* suspend() 144 | if (failed) { 145 | throw output 146 | } else { 147 | return /** @type {T} */ (output) 148 | } 149 | } else { 150 | // This may seem redundunt but it is not, by enqueuing this task we allow 151 | // scheduler to perform other queued tasks first. This way many race 152 | // conditions can be avoided when values are sometimes promises and other 153 | // times aren't. 154 | // Unlike `await` however this will resume in the same tick. 155 | main(wake(task)) 156 | yield* suspend() 157 | return input 158 | } 159 | } 160 | 161 | /** 162 | * @template T, X, M 163 | * @param {Task.Controller} task 164 | * @returns {Task.Task} 165 | */ 166 | function* wake(task) { 167 | enqueue(task) 168 | } 169 | 170 | /** 171 | * Checks if value value is a promise (or it's lookalike). 172 | * 173 | * @template T 174 | * @param {any} node 175 | * @returns {node is PromiseLike} 176 | */ 177 | 178 | const isAsync = node => 179 | node != null && 180 | typeof (/** @type {{then?:unknown}} */ (node).then) === "function" 181 | 182 | /** 183 | * Task that sends given message (or rather an effect producing this message). 184 | * Please note, that while you could use `yield message` instead, but you'd risk 185 | * having to deal with potential breaking changes if library internals change 186 | * in the future, which in fact may happen as anticipated improvements in 187 | * TS generator inference could enable replace need for `yield *`. 188 | * 189 | * @see https://github.com/microsoft/TypeScript/issues/43632 190 | * 191 | * @template T 192 | * @param {T} message 193 | * @returns {Task.Effect} 194 | */ 195 | export const send = function* (message) { 196 | yield /** @type {Task.Message} */ (message) 197 | } 198 | 199 | /** 200 | * Takes several effects and merges them into a single effect of tagged 201 | * variants so that their source could be identified via `type` field. 202 | * 203 | * @example 204 | * ```js 205 | * listen({ 206 | * read: Task.effect(dbRead), 207 | * write: Task.effect(dbWrite) 208 | * }) 209 | * ``` 210 | * 211 | * @template {string} Tag 212 | * @template T 213 | * @param {{ [K in Tag]: Task.Effect }} source 214 | * @returns {Task.Effect>} 215 | */ 216 | export const listen = function* (source) { 217 | /** @type {Task.Fork>[]} */ 218 | const forks = [] 219 | for (const entry of Object.entries(source)) { 220 | const [name, effect] = /** @type {[Tag, Task.Effect]} */ (entry) 221 | if (effect !== NONE) { 222 | forks.push(yield* fork(tag(effect, name))) 223 | } 224 | } 225 | 226 | yield* group(forks) 227 | } 228 | 229 | /** 230 | * Takes several tasks and creates an effect of them all. 231 | * 232 | * @example 233 | * ```js 234 | * Task.effects([ 235 | * dbRead, 236 | * dbWrite 237 | * ]) 238 | * ``` 239 | * 240 | * @template {string} Tag 241 | * @template T 242 | * @param {Task.Task[]} tasks 243 | * @returns {Task.Effect} 244 | */ 245 | 246 | export const effects = tasks => 247 | tasks.length > 0 ? batch(tasks.map(effect)) : NONE 248 | 249 | /** 250 | * Takes several effects and combines them into a one. 251 | * 252 | * @template T 253 | * @param {Task.Effect[]} effects 254 | * @returns {Task.Effect} 255 | */ 256 | export function* batch(effects) { 257 | const forks = [] 258 | for (const effect of effects) { 259 | forks.push(yield* fork(effect)) 260 | } 261 | 262 | yield* group(forks) 263 | } 264 | 265 | /** 266 | * @template {string} Tag 267 | * @template T 268 | * @typedef {{type: Tag} & {[K in Tag]: T}} Tagged 269 | */ 270 | /** 271 | * Tags an effect by boxing each event with an object that has `type` field 272 | * corresponding to given tag and same named field holding original message 273 | * e.g. given `nums` effect that produces numbers, `tag(nums, "inc")` would 274 | * create an effect that produces events like `{type:'inc', inc:1}`. 275 | * 276 | * @template {string} Tag 277 | * @template T, M, X 278 | * @param {Task.Task} effect 279 | * @param {Tag} tag 280 | * @returns {Task.Task>} 281 | */ 282 | export const tag = (effect, tag) => 283 | // @ts-ignore 284 | effect === NONE 285 | ? NONE 286 | : effect instanceof Tagger 287 | ? new Tagger([...effect.tags, tag], effect.source) 288 | : new Tagger([tag], effect) 289 | 290 | /** 291 | * @template {string} Tag 292 | * @template Success, Failure, Message 293 | * 294 | * @implements {Task.Task>} 295 | * @implements {Task.Controller>} 296 | */ 297 | class Tagger { 298 | /** 299 | * @param {Task.Task} source 300 | * @param {string[]} tags 301 | */ 302 | constructor(tags, source) { 303 | this.tags = tags 304 | this.source = source 305 | /** @type {Task.Controller} */ 306 | this.controller 307 | } 308 | /* c8 ignore next 3 */ 309 | [Symbol.iterator]() { 310 | if (!this.controller) { 311 | this.controller = this.source[Symbol.iterator]() 312 | } 313 | return this 314 | } 315 | /** 316 | * @param {Task.TaskState} state 317 | * @returns {Task.TaskState>} 318 | */ 319 | box(state) { 320 | if (state.done) { 321 | return state 322 | } else { 323 | switch (state.value) { 324 | case SUSPEND: 325 | case CURRENT: 326 | return /** @type {Task.TaskState>} */ ( 327 | state 328 | ) 329 | default: { 330 | // Instead of boxing result at each transform step we perform in-place 331 | // mutation as we know nothing else is accessing this value. 332 | const tagged = /** @type {{ done: false, value: any }} */ (state) 333 | let { value } = tagged 334 | for (const tag of this.tags) { 335 | value = withTag(tag, value) 336 | } 337 | tagged.value = value 338 | return tagged 339 | } 340 | } 341 | } 342 | } 343 | /** 344 | * 345 | * @param {Task.Instruction} instruction 346 | */ 347 | next(instruction) { 348 | return this.box(this.controller.next(instruction)) 349 | } 350 | /** 351 | * 352 | * @param {Failure} error 353 | */ 354 | throw(error) { 355 | return this.box(this.controller.throw(error)) 356 | } 357 | /** 358 | * @param {Success} value 359 | */ 360 | return(value) { 361 | return this.box(this.controller.return(value)) 362 | } 363 | 364 | get [Symbol.toStringTag]() { 365 | return "TaggedEffect" 366 | } 367 | } 368 | 369 | /** 370 | * Returns empty `Effect`, that is produces no messages. Kind of like `[]` or 371 | * `""` but for effects. 372 | * 373 | * @type {() => Task.Effect} 374 | */ 375 | export const none = () => NONE 376 | 377 | /** 378 | * Takes iterable of tasks and runs them concurrently, returning array of 379 | * results in an order of tasks (not the order of completion). If any of the 380 | * tasks fail all the rest are aborted and error is throw into calling task. 381 | * 382 | * > This is basically equivalent of `Promise.all` except cancelation logic 383 | * because tasks unlike promises can be cancelled. 384 | * 385 | * @template T, X 386 | * @param {Iterable>} tasks 387 | * @returns {Task.Task} 388 | */ 389 | export const all = function* (tasks) { 390 | const self = yield* current() 391 | 392 | /** @type {(id:number) => (value:T) => void} */ 393 | const succeed = id => value => { 394 | delete forks[id] 395 | results[id] = value 396 | count -= 1 397 | if (count === 0) { 398 | enqueue(self) 399 | } 400 | } 401 | 402 | /** @type {(error:X) => void} */ 403 | const fail = error => { 404 | for (const handle of forks) { 405 | if (handle) { 406 | enqueue(abort(handle, error)) 407 | } 408 | } 409 | 410 | enqueue(abort(self, error)) 411 | } 412 | 413 | /** @type {Task.Fork[]} */ 414 | let forks = [] 415 | let count = 0 416 | for (const task of tasks) { 417 | forks.push(yield* fork(then(task, succeed(count++), fail))) 418 | } 419 | const results = new Array(count) 420 | 421 | if (count > 0) { 422 | yield* suspend() 423 | } 424 | 425 | return results 426 | } 427 | 428 | /** 429 | * @template {string} Tag 430 | * @template T 431 | * @param {Tag} tag 432 | * @param {Task.Message} value 433 | */ 434 | const withTag = (tag, value) => 435 | /** @type {Tagged} */ 436 | ({ type: tag, [tag]: value }) 437 | 438 | /** 439 | * Kind of like promise.then which is handy when you want to extract result 440 | * from the given task from the outside. 441 | * 442 | * @template T, U, X, M 443 | * @param {Task.Task} task 444 | * @param {(value:T) => U} resolve 445 | * @param {(error:X) => U} reject 446 | * @returns {Task.Task} 447 | */ 448 | export function* then(task, resolve, reject) { 449 | try { 450 | return resolve(yield* task) 451 | } catch (error) { 452 | return reject(/** @type {X} */ (error)) 453 | } 454 | } 455 | 456 | // Special control instructions recognized by a scheduler. 457 | const CURRENT = Symbol("current") 458 | const SUSPEND = Symbol("suspend") 459 | /** @typedef {typeof SUSPEND|typeof CURRENT} Control */ 460 | 461 | /** 462 | * @template M 463 | * @param {Task.Instruction} value 464 | * @returns {value is M} 465 | */ 466 | export const isMessage = value => { 467 | switch (value) { 468 | case SUSPEND: 469 | case CURRENT: 470 | return false 471 | default: 472 | return true 473 | } 474 | } 475 | 476 | /** 477 | * @template M 478 | * @param {Task.Instruction} value 479 | * @returns {value is Control} 480 | */ 481 | export const isInstruction = value => !isMessage(value) 482 | 483 | /** 484 | * @template T, X, M 485 | * @implements {Task.TaskGroup} 486 | */ 487 | class Group { 488 | /** 489 | * @template T, X, M 490 | * @param {Task.Controller|Task.Fork} member 491 | * @returns {Task.Group} 492 | */ 493 | static of(member) { 494 | return ( 495 | /** @type {{group?:Task.TaskGroup}} */ (member).group || MAIN 496 | ) 497 | } 498 | 499 | /** 500 | * @template T, X, M 501 | * @param {(Task.Controller|Task.Fork) & {group?:Task.TaskGroup}} member 502 | * @param {Task.TaskGroup} group 503 | */ 504 | static enqueue(member, group) { 505 | member.group = group 506 | group.stack.active.push(member) 507 | } 508 | /** 509 | * @param {Task.Controller} driver 510 | * @param {Task.Controller[]} [active] 511 | * @param {Set>} [idle] 512 | * @param {Task.Stack} [stack] 513 | */ 514 | constructor( 515 | driver, 516 | active = [], 517 | idle = new Set(), 518 | stack = new Stack(active, idle) 519 | ) { 520 | this.driver = driver 521 | this.parent = Group.of(driver) 522 | this.stack = stack 523 | this.id = ++ID 524 | } 525 | } 526 | 527 | /** 528 | * @template T, X, M 529 | * @implements {Task.Main} 530 | */ 531 | class Main { 532 | constructor() { 533 | this.status = IDLE 534 | this.stack = new Stack() 535 | this.id = /** @type {0} */ (0) 536 | } 537 | } 538 | 539 | /** 540 | * @template T, X, M 541 | */ 542 | class Stack { 543 | /** 544 | * @param {Task.Controller[]} [active] 545 | * @param {Set>} [idle] 546 | */ 547 | constructor(active = [], idle = new Set()) { 548 | this.active = active 549 | this.idle = idle 550 | } 551 | 552 | /** 553 | * 554 | * @param {Task.Stack} stack 555 | * @returns 556 | */ 557 | static size({ active, idle }) { 558 | return active.length + idle.size 559 | } 560 | } 561 | 562 | /** 563 | * Starts a main task. 564 | * 565 | * @param {Task.Task} task 566 | */ 567 | export const main = task => enqueue(task[Symbol.iterator]()) 568 | 569 | /** 570 | * @template T, X, M 571 | * @param {Task.Controller} task 572 | */ 573 | const enqueue = task => { 574 | let group = Group.of(task) 575 | group.stack.active.push(task) 576 | group.stack.idle.delete(task) 577 | 578 | // then walk up the group chain and unblock their driver tasks. 579 | while (group.parent) { 580 | const { idle, active } = group.parent.stack 581 | if (idle.has(group.driver)) { 582 | idle.delete(group.driver) 583 | active.push(group.driver) 584 | } else { 585 | // if driver was not blocked it must have been unblocked by 586 | // other task so stop there. 587 | break 588 | } 589 | 590 | group = group.parent 591 | } 592 | 593 | if (MAIN.status === IDLE) { 594 | MAIN.status = ACTIVE 595 | while (true) { 596 | try { 597 | for (const _message of step(MAIN)) { 598 | } 599 | MAIN.status = IDLE 600 | break 601 | } catch (_error) { 602 | // Top level task may crash and throw an error, but given this is a main 603 | // group we do not want to interupt other unrelated tasks, which is why 604 | // we discard the error and the task that caused it. 605 | MAIN.stack.active.shift() 606 | } 607 | } 608 | } 609 | } 610 | 611 | /** 612 | * @template T, X, M 613 | * @param {Task.Controller} task 614 | */ 615 | export const resume = task => enqueue(task) 616 | 617 | /** 618 | * @template T, X, M 619 | * @param {Task.Group} group 620 | */ 621 | 622 | const step = function* (group) { 623 | const { active } = group.stack 624 | let task = active[0] 625 | group.stack.idle.delete(task) 626 | while (task) { 627 | /** @type {Task.TaskState} */ 628 | let state = INIT 629 | // Keep processing insturctions until task is done, it send suspend request 630 | // or it's has been removed from the active queue. 631 | // ⚠️ Group changes require extra care so please make sure to understand 632 | // the detail here. It occurs when spawned task(s) are joined into a group 633 | // which will change the task driver, that is when `task === active[0]` will 634 | // became false and need to to drop the task immediately otherwise race 635 | // condition will occur due to task been driven by multiple concurrent 636 | // schedulers. 637 | loop: while (!state.done && task === active[0]) { 638 | const instruction = state.value 639 | switch (instruction) { 640 | // if task is suspended we add it to the idle list and break the loop 641 | // to move to a next task. 642 | case SUSPEND: 643 | group.stack.idle.add(task) 644 | break loop 645 | // if task requested a context (which is usually to suspend itself) 646 | // pass back a task reference and continue. 647 | case CURRENT: 648 | state = task.next(task) 649 | break 650 | default: 651 | // otherwise task sent a message which we yield to the driver and 652 | // continue 653 | state = task.next( 654 | yield /** @type {M & Task.Message}*/ (instruction) 655 | ) 656 | break 657 | } 658 | } 659 | 660 | // If task is complete, or got suspended we move to a next task 661 | active.shift() 662 | task = active[0] 663 | group.stack.idle.delete(task) 664 | } 665 | } 666 | 667 | /** 668 | * Executes given task concurrently with a current task (task that spawned it). 669 | * Spawned task is detached from the task that spawned it and it can outlive it 670 | * and / or fail without affecting a task that spawned it. If you need to wait 671 | * on concurrent task completion consider using `fork` instead which can be 672 | * later `joined`. If you just want a to block on task execution you can just 673 | * `yield* work()` directly instead. 674 | * 675 | * @param {Task.Task} task 676 | * @returns {Task.Task} 677 | */ 678 | export function* spawn(task) { 679 | main(task) 680 | } 681 | 682 | /** 683 | * Executes given task concurrently with current task (the task that initiated 684 | * fork). Froked task is detached from the task that created it and it can 685 | * outlive it and / or fail without affecting it. You do however get a handle 686 | * for the fork which could be used to `join` the task, in which case `joining` 687 | * task will block until fork finishes execution. 688 | * 689 | * This is also a primary interface for executing tasks from the outside of the 690 | * task context. Function returns `Fork` which implements `Promise` interface 691 | * so it could be awaited. Please note that calling `fork` does not really do 692 | * anything, it lazily starts execution when you either `await fork(work())` 693 | * from arbitray context or `yield* fork(work())` in anothe task context. 694 | * 695 | * @template T, X, M 696 | * @param {Task.Task} task 697 | * @param {Task.ForkOptions} [options] 698 | * @returns {Task.Fork} 699 | */ 700 | export const fork = (task, options) => new Fork(task, options) 701 | 702 | /** 703 | * Exits task succesfully with a given return value. 704 | * 705 | * @template T, M, X 706 | * @param {Task.Controller} handle 707 | * @param {T} value 708 | * @returns {Task.Task} 709 | */ 710 | export const exit = (handle, value) => conclude(handle, { ok: true, value }) 711 | 712 | /** 713 | * Terminates task execution execution. Only takes task that produces no 714 | * result, if your task has non `void` return type you should use `exit` instead. 715 | * 716 | * @template M, X 717 | * @param {Task.Controller} handle 718 | */ 719 | export const terminate = handle => 720 | conclude(handle, { ok: true, value: undefined }) 721 | 722 | /** 723 | * Aborts given task with an error. Task error type should match provided error. 724 | * 725 | * @template T, M, X 726 | * @param {Task.Controller} handle 727 | * @param {X} [error] 728 | */ 729 | export const abort = (handle, error) => conclude(handle, { ok: false, error }) 730 | 731 | /** 732 | * Aborts given task with an given error. 733 | * 734 | * @template T, M, X 735 | * @param {Task.Controller} handle 736 | * @param {Task.Result} result 737 | * @returns {Task.Task & Task.Controller} 738 | */ 739 | function* conclude(handle, result) { 740 | try { 741 | const task = handle 742 | const state = result.ok 743 | ? task.return(result.value) 744 | : task.throw(result.error) 745 | 746 | if (!state.done) { 747 | if (state.value === SUSPEND) { 748 | const { idle } = Group.of(task).stack 749 | idle.add(task) 750 | } else { 751 | enqueue(task) 752 | } 753 | } 754 | } catch (error) {} 755 | } 756 | 757 | /** 758 | * Groups multiple forks togather and joins joins them with current task. 759 | * 760 | * @template T, X, M 761 | * @param {Task.Fork[]} forks 762 | * @returns {Task.Task} 763 | */ 764 | export function* group(forks) { 765 | // Abort eraly if there'se no work todo. 766 | if (forks.length === 0) return 767 | 768 | const self = yield* current() 769 | /** @type {Task.TaskGroup} */ 770 | const group = new Group(self) 771 | /** @type {Task.Failure|null} */ 772 | let failure = null 773 | 774 | for (const fork of forks) { 775 | const { result } = fork 776 | if (result) { 777 | if (!result.ok && !failure) { 778 | failure = result 779 | } 780 | continue 781 | } 782 | move(fork, group) 783 | } 784 | 785 | // Keep work looping until there is nom more work to be done 786 | try { 787 | if (failure) { 788 | throw failure.error 789 | } 790 | 791 | while (true) { 792 | yield* step(group) 793 | if (Stack.size(group.stack) > 0) { 794 | yield* suspend() 795 | } else { 796 | break 797 | } 798 | } 799 | } catch (error) { 800 | for (const task of group.stack.active) { 801 | yield* abort(task, error) 802 | } 803 | 804 | for (const task of group.stack.idle) { 805 | yield* abort(task, error) 806 | enqueue(task) 807 | } 808 | 809 | throw error 810 | } 811 | } 812 | 813 | /** 814 | * @template T, X, M 815 | * @param {Task.Fork} fork 816 | * @param {Task.TaskGroup} group 817 | */ 818 | const move = (fork, group) => { 819 | const from = Group.of(fork) 820 | if (from !== group) { 821 | const { active, idle } = from.stack 822 | const target = group.stack 823 | fork.group = group 824 | // If it is idle just move from one group to the other 825 | // and update the group task thinks it belongs to. 826 | if (idle.has(fork)) { 827 | idle.delete(fork) 828 | target.idle.add(fork) 829 | } else { 830 | const index = active.indexOf(fork) 831 | // If task is in the job queue, we move it to a target job queue. Moving 832 | // top task in the queue requires extra care so it does not end up 833 | // processed by two groups which would lead to race. For that reason 834 | // `step` loop checks for group changes on each turn. 835 | if (index >= 0) { 836 | active.splice(index, 1) 837 | target.active.push(fork) 838 | } 839 | // otherwise task is complete 840 | } 841 | } 842 | } 843 | 844 | /** 845 | * @template T, X, M 846 | * @param {Task.Fork} fork 847 | * @returns {Task.Task} 848 | */ 849 | export function* join(fork) { 850 | // If fork is still idle activate it. 851 | if (fork.status === IDLE) { 852 | yield* fork 853 | } 854 | 855 | if (!fork.result) { 856 | yield* group([fork]) 857 | } 858 | 859 | const result = /** @type {Task.Result} */ (fork.result) 860 | if (result.ok) { 861 | return result.value 862 | } else { 863 | throw result.error 864 | } 865 | } 866 | 867 | /** 868 | * @template T, X 869 | * @implements {Task.Future} 870 | */ 871 | class Future { 872 | /** 873 | * @param {Task.StateHandler} handler 874 | */ 875 | constructor(handler) { 876 | this.handler = handler 877 | /** 878 | * @abstract 879 | * @type {Task.Result|void} 880 | */ 881 | this.result 882 | } 883 | /** 884 | * @type {Promise} 885 | */ 886 | get promise() { 887 | const { result } = this 888 | const promise = 889 | result == null 890 | ? new Promise((succeed, fail) => { 891 | this.handler.onsuccess = succeed 892 | this.handler.onfailure = fail 893 | }) 894 | : result.ok 895 | ? Promise.resolve(result.value) 896 | : Promise.reject(result.error) 897 | Object.defineProperty(this, "promise", { value: promise }) 898 | return promise 899 | } 900 | 901 | /** 902 | * @template U, [E=never] 903 | * @param {((value:T) => U | PromiseLike)|undefined|null} [onresolve] 904 | * @param {((error:X) => E|PromiseLike)|undefined|null} [onreject] 905 | * @returns {Promise} 906 | */ 907 | then(onresolve, onreject) { 908 | return this.activate().promise.then(onresolve, onreject) 909 | } 910 | /** 911 | * @template [U=never] 912 | * @param {(error:X) => U} onreject 913 | */ 914 | catch(onreject) { 915 | return /** @type {Task.Future} */ ( 916 | this.activate().promise.catch(onreject) 917 | ) 918 | } 919 | /** 920 | * @param {() => void} onfinally 921 | * @returns {Task.Future} 922 | */ 923 | finally(onfinally) { 924 | return /** @type {Task.Future} */ ( 925 | this.activate().promise.finally(onfinally) 926 | ) 927 | } 928 | /** 929 | * @abstract 930 | */ 931 | /* c8 ignore next 3 */ 932 | activate() { 933 | return this 934 | } 935 | } 936 | 937 | /** 938 | * @template T, X, M 939 | * @implements {Task.Fork} 940 | * @implements {Task.Controller} 941 | * @implements {Task.Task, never>} 942 | * @implements {Task.Future} 943 | * @extends {Future} 944 | */ 945 | class Fork extends Future { 946 | /** 947 | * @param {Task.Task} task 948 | * @param {Task.ForkOptions} [options] 949 | * @param {Task.StateHandler} [handler] 950 | * @param {Task.TaskState} [state] 951 | */ 952 | constructor(task, options = BLANK, handler = {}, state = INIT) { 953 | super(handler) 954 | this.id = ++ID 955 | this.name = options.name || "" 956 | /** @type {Task.Task} */ 957 | this.task = task 958 | this.state = state 959 | this.status = IDLE 960 | /** @type {Task.Result} */ 961 | this.result 962 | this.handler = handler 963 | 964 | /** @type {Task.Controller} */ 965 | this.controller 966 | } 967 | 968 | *resume() { 969 | resume(this) 970 | } 971 | 972 | /** 973 | * @returns {Task.Task} 974 | */ 975 | join() { 976 | return join(this) 977 | } 978 | 979 | /** 980 | * @param {X} error 981 | */ 982 | abort(error) { 983 | return abort(this, error) 984 | } 985 | /** 986 | * @param {T} value 987 | */ 988 | exit(value) { 989 | return exit(this, value) 990 | } 991 | get [Symbol.toStringTag]() { 992 | return "Fork" 993 | } 994 | 995 | /** 996 | * @returns {Task.Controller, never, never>} 997 | */ 998 | *[Symbol.iterator]() { 999 | return this.activate() 1000 | } 1001 | 1002 | activate() { 1003 | this.controller = this.task[Symbol.iterator]() 1004 | this.status = ACTIVE 1005 | enqueue(this) 1006 | return this 1007 | } 1008 | 1009 | /** 1010 | * @private 1011 | * @param {any} error 1012 | * @returns {never} 1013 | */ 1014 | panic(error) { 1015 | this.result = { ok: false, error } 1016 | this.status = FINISHED 1017 | const { handler } = this 1018 | if (handler.onfailure) { 1019 | handler.onfailure(error) 1020 | } 1021 | 1022 | throw error 1023 | } 1024 | 1025 | /** 1026 | * @private 1027 | * @param {Task.TaskState} state 1028 | */ 1029 | step(state) { 1030 | this.state = state 1031 | if (state.done) { 1032 | this.result = { ok: true, value: state.value } 1033 | this.status = FINISHED 1034 | const { handler } = this 1035 | if (handler.onsuccess) { 1036 | handler.onsuccess(state.value) 1037 | } 1038 | } 1039 | 1040 | return state 1041 | } 1042 | 1043 | /** 1044 | * @param {unknown} value 1045 | */ 1046 | next(value) { 1047 | try { 1048 | return this.step(this.controller.next(value)) 1049 | } catch (error) { 1050 | return this.panic(error) 1051 | } 1052 | } 1053 | /** 1054 | * @param {T} value 1055 | */ 1056 | return(value) { 1057 | try { 1058 | return this.step(this.controller.return(value)) 1059 | } catch (error) { 1060 | return this.panic(error) 1061 | } 1062 | } 1063 | /** 1064 | * @param {X} error 1065 | */ 1066 | throw(error) { 1067 | try { 1068 | return this.step(this.controller.throw(error)) 1069 | } catch (error) { 1070 | return this.panic(error) 1071 | } 1072 | } 1073 | } 1074 | 1075 | /** 1076 | * @template M 1077 | * @param {Task.Effect} init 1078 | * @param {(message:M) => Task.Effect} next 1079 | * @returns {Task.Task} 1080 | */ 1081 | export const loop = function* (init, next) { 1082 | /** @type {Task.Controller} */ 1083 | const controller = yield* current() 1084 | const group = new Group(controller) 1085 | Group.enqueue(init[Symbol.iterator](), group) 1086 | 1087 | while (true) { 1088 | for (const message of step(group)) { 1089 | Group.enqueue(next(message)[Symbol.iterator](), group) 1090 | } 1091 | 1092 | if (Stack.size(group.stack) > 0) { 1093 | yield* suspend() 1094 | } else { 1095 | break 1096 | } 1097 | } 1098 | } 1099 | 1100 | let ID = 0 1101 | /** @type {Task.Status} */ 1102 | const IDLE = "idle" 1103 | const ACTIVE = "active" 1104 | const FINISHED = "finished" 1105 | /** @type {Task.TaskState} */ 1106 | const INIT = { done: false, value: CURRENT } 1107 | 1108 | const BLANK = {} 1109 | 1110 | /** @type {Task.Effect} */ 1111 | const NONE = (function* none() {})() 1112 | 1113 | /** @type {Task.Main} */ 1114 | const MAIN = new Main() 1115 | -------------------------------------------------------------------------------- /src/task.js: -------------------------------------------------------------------------------- 1 | // This is file is only here to allow importing type namespace 2 | // from JS. 3 | export * from "./lib.js" 4 | -------------------------------------------------------------------------------- /src/task.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib.js" 2 | 3 | import type { Control } from "./lib.js" 4 | 5 | export type Instruction = Message | Control 6 | 7 | export type Await = T | PromiseLike 8 | 9 | export type Result = 10 | | Success 11 | | Failure 12 | 13 | export interface Success { 14 | readonly ok: true 15 | readonly value: T 16 | } 17 | 18 | export interface Failure { 19 | readonly ok: false 20 | readonly error: X 21 | } 22 | 23 | type CompileError = `🚨 ${Reason}` 24 | 25 | /** 26 | * Helper type to guard users against easy to make mistakes. 27 | */ 28 | export type Message = T extends Task 29 | ? CompileError<`You must 'yield * fn()' to delegate task instead of 'yield fn()' which yields generator instead`> 30 | : T extends (...args: any) => Generator 31 | ? CompileError<`You must yield invoked generator as in 'yield * fn()' instead of yielding generator function`> 32 | : T 33 | 34 | /** 35 | * Task is a unit of computation that runs concurrently, a light-weight 36 | * process (in Erlang terms). You can spawn bunch of them and provided 37 | * cooperative scheduler will interleave their execution. 38 | * 39 | * Tasks have three type variables first two describing result of the 40 | * computation `Success` that corresponds to return type and `Failure` 41 | * describing an error type (caused by thrown exceptions). Third type 42 | * varibale `Message` describes type of messages this task may produce. 43 | * 44 | * Please note that that TS does not really check exceptions so `Failure` 45 | * type can not be guaranteed. Yet, we find them more practical that omitting 46 | * them as TS does for `Promise` types. 47 | * 48 | * Our tasks are generators (not the generator functions, but what you get 49 | * invoking them) that are executed by (library provided) provided scheduler. 50 | * Scheduler recognizes two special `Control` instructions yield by generator. 51 | * When scheduler gets `context` instruction it will resume generator with 52 | * a handle that can be used to resume running generator after it is suspended. 53 | * When `suspend` instruction is received scheduler will suspend execution until 54 | * it is resumed by queueing it from the outside event. 55 | */ 56 | export interface Task< 57 | Success extends unknown = unknown, 58 | Failure = Error, 59 | Message extends unknown = never 60 | > { 61 | [Symbol.iterator](): Controller 62 | } 63 | 64 | export interface Controller< 65 | Success extends unknown = unknown, 66 | Failure extends unknown = Error, 67 | Message extends unknown = never 68 | > { 69 | throw(error: Failure): TaskState 70 | return(value: Success): TaskState 71 | next( 72 | value: Task | unknown 73 | ): TaskState 74 | } 75 | 76 | export type TaskState< 77 | Success extends unknown = unknown, 78 | Message = unknown 79 | > = IteratorResult, Success> 80 | 81 | /** 82 | * Effect represents potentially asynchronous operation that results in a set 83 | * of events. It is often comprised of multiple `Task` and represents either 84 | * chain of events or a concurrent set of events (stretched over time). 85 | * `Effect` campares to a `Stream` the same way as `Task` compares to `Promise`. 86 | * It is not representation of an eventual result, but rather representation of 87 | * an operation which if execute will produce certain result. `Effect` can also 88 | * be compared to an `EventEmitter`, because very often their `Event` type 89 | * variable is a union of various event types, unlike `EventEmitter`s however 90 | * `Effect`s have inherent finality to them an in that regard they are more like 91 | * `Stream`s. 92 | * 93 | * You may notice that `Effect`, is just a `Task` which never fails, nor has a 94 | * (meaningful) result. Instead it can produce events (send messages). 95 | */ 96 | export interface Effect extends Task {} 97 | 98 | export type Status = "idle" | "active" | "finished" 99 | 100 | export type Group = Main | TaskGroup 101 | 102 | export interface TaskGroup { 103 | id: number 104 | parent: Group 105 | driver: Controller 106 | stack: Stack 107 | 108 | result?: Result 109 | } 110 | 111 | export interface Main { 112 | id: 0 113 | parent?: null 114 | status: Status 115 | stack: Stack 116 | } 117 | 118 | export interface Stack { 119 | active: Controller[] 120 | idle: Set> 121 | } 122 | 123 | /** 124 | * Like promise but lazy. It corresponds to a task that is activated when 125 | * then method is called. 126 | */ 127 | export interface Future extends PromiseLike { 128 | then( 129 | handle?: (value: Success) => U | PromiseLike, 130 | onrejected?: (error: Failure) => G | PromiseLike 131 | ): Promise 132 | 133 | catch(handle: (error: Failure) => U): Future 134 | 135 | finally(handle: () => void): Future 136 | } 137 | 138 | export interface Fork< 139 | Success extends unknown = unknown, 140 | Failure extends unknown = Error, 141 | Message extends unknown = never 142 | > extends Controller, 143 | Task, never>, 144 | Future { 145 | readonly id: number 146 | 147 | group?: void | TaskGroup 148 | 149 | result?: Result 150 | status: Status 151 | resume(): Task 152 | join(): Task 153 | 154 | abort(error: Failure): Task 155 | exit(value: Success): Task 156 | } 157 | 158 | export interface ForkOptions { 159 | name?: string 160 | } 161 | 162 | export interface StateHandler { 163 | onsuccess?: (value: T) => void 164 | onfailure?: (error: X) => void 165 | } 166 | -------------------------------------------------------------------------------- /test/lib.spec.js: -------------------------------------------------------------------------------- 1 | import * as Task from "../src/lib.js" 2 | import { assert, createLog, inspect } from "./util.js" 3 | 4 | describe("wait", () => { 5 | it("it does wait on non-promise", async () => { 6 | let isSync = true 7 | function* worker() { 8 | const message = yield* Task.wait(5) 9 | assert.equal(isSync, true, "expect to be sync") 10 | return message 11 | } 12 | 13 | const promise = new Promise((resolve, reject) => 14 | inspect(worker()).then(resolve, reject) 15 | ) 16 | 17 | isSync = false 18 | const result = await promise 19 | 20 | assert.deepEqual(result, { ok: true, value: 5, mail: [] }) 21 | }) 22 | 23 | it("does await on promise", async () => { 24 | let isSync = true 25 | function* main() { 26 | const message = yield* Task.wait(Promise.resolve(5)) 27 | assert.equal(isSync, false, "expect to be async") 28 | return message 29 | } 30 | const fork = inspect(main()) 31 | isSync = false 32 | const result = await fork 33 | 34 | assert.deepEqual(result, { 35 | ok: true, 36 | value: 5, 37 | mail: [], 38 | }) 39 | }) 40 | 41 | it("lets you yield", async () => { 42 | function* main() { 43 | /** @type {unknown} */ 44 | const value = yield 5 45 | assert.equal(value, undefined, "return undefined on normal yield") 46 | } 47 | 48 | const result = await inspect(main()) 49 | 50 | assert.deepEqual(result, { 51 | value: undefined, 52 | ok: true, 53 | mail: [5], 54 | }) 55 | }) 56 | 57 | it("does throw on failed promise", async () => { 58 | const boom = new Error("boom!") 59 | function* main() { 60 | const message = yield* Task.wait(Promise.reject(boom)) 61 | return message 62 | } 63 | 64 | const result = await inspect(main()) 65 | 66 | assert.deepEqual(result, { 67 | ok: false, 68 | mail: [], 69 | error: boom, 70 | }) 71 | }) 72 | 73 | it("can catch promise errors", async () => { 74 | const boom = new Error("boom!") 75 | function* main() { 76 | try { 77 | const message = yield* Task.wait(Promise.reject(boom)) 78 | return message 79 | } catch (error) { 80 | return error 81 | } 82 | } 83 | 84 | const result = await inspect(main()) 85 | 86 | assert.deepEqual(result, { 87 | ok: true, 88 | mail: [], 89 | value: boom, 90 | }) 91 | }) 92 | 93 | it("can intercept thrown errors", async () => { 94 | const boom = new Error("boom!") 95 | const fail = () => { 96 | throw boom 97 | } 98 | 99 | function* main() { 100 | fail() 101 | } 102 | 103 | const result = await inspect(main()) 104 | assert.deepEqual(result, { 105 | ok: false, 106 | mail: [], 107 | error: boom, 108 | }) 109 | }) 110 | 111 | it("can catch thrown errors", async () => { 112 | const boom = new Error("boom!") 113 | const fail = () => { 114 | throw boom 115 | } 116 | 117 | function* main() { 118 | try { 119 | fail() 120 | } catch (error) { 121 | return error 122 | } 123 | } 124 | 125 | const result = await inspect(main()) 126 | 127 | assert.deepEqual(result, { 128 | ok: true, 129 | mail: [], 130 | value: boom, 131 | }) 132 | }) 133 | 134 | it("use finally", async () => { 135 | const boom = new Error("boom!") 136 | let finalized = false 137 | function* main() { 138 | try { 139 | const message = yield* Task.wait(Promise.reject(boom)) 140 | return message 141 | } finally { 142 | finalized = true 143 | } 144 | } 145 | 146 | const result = await inspect(main()) 147 | 148 | assert.deepEqual(result, { 149 | ok: false, 150 | mail: [], 151 | error: boom, 152 | }) 153 | 154 | assert.equal(finalized, true) 155 | }) 156 | }) 157 | 158 | describe("messaging", () => { 159 | it("can send message", async () => { 160 | function* main() { 161 | yield* Task.send("one") 162 | yield* Task.send("two") 163 | } 164 | 165 | const result = await inspect(main()) 166 | 167 | assert.deepEqual(result, { 168 | ok: true, 169 | value: undefined, 170 | mail: ["one", "two"], 171 | }) 172 | }) 173 | 174 | it("can send message in finally", async () => { 175 | function* main() { 176 | try { 177 | yield* Task.send("one") 178 | yield* Task.send("two") 179 | } finally { 180 | yield* Task.send("three") 181 | } 182 | } 183 | const result = await inspect(main()) 184 | 185 | assert.deepEqual(result, { 186 | ok: true, 187 | value: undefined, 188 | mail: ["one", "two", "three"], 189 | }) 190 | }) 191 | it("can send message after exception", async () => { 192 | function* main() { 193 | try { 194 | yield* Task.send("one") 195 | yield* Task.send("two") 196 | // yield * Task.wait(Promise.reject('boom')) 197 | throw "boom" 198 | yield* Task.send("three") 199 | } finally { 200 | yield* Task.send("four") 201 | } 202 | } 203 | const result = await inspect(main()) 204 | 205 | assert.deepEqual(result, { 206 | ok: false, 207 | error: "boom", 208 | mail: ["one", "two", "four"], 209 | }) 210 | }) 211 | 212 | it("can send message after rejected promise", async () => { 213 | function* main() { 214 | try { 215 | yield* Task.send("one") 216 | yield* Task.send("two") 217 | yield* Task.wait(Promise.reject("boom")) 218 | yield* Task.send("three") 219 | } finally { 220 | yield* Task.send("four") 221 | } 222 | } 223 | 224 | const result = await inspect(main()) 225 | 226 | assert.deepEqual(result, { 227 | ok: false, 228 | error: "boom", 229 | mail: ["one", "two", "four"], 230 | }) 231 | }) 232 | 233 | it("can send message after rejected promise", async () => { 234 | function* main() { 235 | try { 236 | yield* Task.send("one") 237 | yield* Task.send("two") 238 | yield* Task.wait(Promise.reject("boom")) 239 | yield* Task.send("three") 240 | } finally { 241 | yield* Task.wait(Promise.reject("oops")) 242 | yield* Task.send("four") 243 | } 244 | } 245 | const result = await inspect(main()) 246 | 247 | assert.deepEqual(result, { 248 | ok: false, 249 | error: "oops", 250 | mail: ["one", "two"], 251 | }) 252 | }) 253 | 254 | it("subtasks can send messages", async () => { 255 | function* worker() { 256 | yield* Task.send("c1") 257 | } 258 | 259 | function* main() { 260 | try { 261 | yield* Task.send("one") 262 | yield* Task.send("two") 263 | yield* worker() 264 | yield* Task.send("three") 265 | } finally { 266 | yield* Task.send("four") 267 | yield* Task.wait(Promise.reject("oops")) 268 | yield* Task.send("five") 269 | } 270 | } 271 | 272 | const result = await inspect(main()) 273 | 274 | assert.deepEqual(result, { 275 | ok: false, 276 | error: "oops", 277 | mail: ["one", "two", "c1", "three", "four"], 278 | }) 279 | }) 280 | }) 281 | 282 | describe("subtasks", () => { 283 | it("crashes parent", async () => { 284 | /** 285 | * @param {Task.Await} x 286 | * @param {Task.Await} y 287 | */ 288 | function* worker(x, y) { 289 | return (yield* Task.wait(x)) + (yield* Task.wait(y)) 290 | } 291 | 292 | function* main() { 293 | const one = yield* worker(1, 2) 294 | const two = yield* worker(Promise.reject(5), one) 295 | return two 296 | } 297 | const result = await inspect(main()) 298 | 299 | assert.deepEqual(result, { 300 | ok: false, 301 | error: 5, 302 | mail: [], 303 | }) 304 | }) 305 | 306 | it("fork does not crash parent", async () => { 307 | const { output, log } = createLog() 308 | 309 | /** 310 | * @param {string} id 311 | */ 312 | function* work(id) { 313 | log(`start ${id}`) 314 | yield* Task.send(`${id}#1`) 315 | yield* Task.wait(Promise.reject("boom")) 316 | return 0 317 | } 318 | 319 | function* main() { 320 | yield* Task.fork(work("A")) 321 | yield* Task.wait(Promise.resolve("one")) 322 | yield* Task.fork(work("B")) 323 | return yield* Task.wait(Promise.resolve("two")) 324 | } 325 | 326 | const result = await inspect(main()) 327 | 328 | assert.deepEqual(result, { 329 | ok: true, 330 | value: "two", 331 | mail: [], 332 | }) 333 | assert.deepEqual(output, ["start A", "start B"]) 334 | }) 335 | 336 | it("waiting on forks result crashes parent", async () => { 337 | const { output, log } = createLog() 338 | 339 | /** 340 | * @param {string} id 341 | */ 342 | function* worker(id) { 343 | log(`Start ${id}`) 344 | yield* Task.send(`${id}#1`) 345 | yield* Task.wait(Promise.reject(`${id}!boom`)) 346 | } 347 | 348 | function* main() { 349 | const a = yield* Task.fork(worker("A")) 350 | yield* Task.wait(Promise.resolve("one")) 351 | const b = yield* Task.fork(worker("B")) 352 | yield* Task.send("hi") 353 | yield* Task.group([a, b]) 354 | yield* Task.wait(Promise.resolve("two")) 355 | 356 | return 0 357 | } 358 | 359 | const result = await inspect(main()) 360 | 361 | assert.deepEqual(result, { 362 | ok: false, 363 | error: "A!boom", 364 | mail: ["hi", "B#1"], 365 | }) 366 | assert.deepEqual(output, ["Start A", "Start B"]) 367 | }) 368 | 369 | it("joining failed forks crashes parent", async () => { 370 | const { output, log } = createLog() 371 | 372 | /** 373 | * @param {string} id 374 | */ 375 | function* work(id) { 376 | log(`Start ${id}`) 377 | yield* Task.send(`${id}#1`) 378 | return id 379 | } 380 | 381 | function* main() { 382 | const a = yield* Task.fork(work("A")) 383 | yield* Task.wait(Promise.resolve("one")) 384 | const b = yield* Task.fork(work("B")) 385 | yield* Task.send("hi") 386 | // yield* Task.sleep(20) 387 | 388 | const result = yield* Task.join(b) 389 | assert.deepEqual(result, "B") 390 | 391 | const result2 = yield* Task.join(a) 392 | assert.deepEqual(result2, "A") 393 | } 394 | 395 | const result = await inspect(main()) 396 | 397 | assert.deepEqual(result, { 398 | ok: true, 399 | value: undefined, 400 | mail: ["hi", "B#1"], 401 | }) 402 | assert.deepEqual(output, ["Start A", "Start B"]) 403 | }) 404 | 405 | it("faling group member terminates group", async () => { 406 | const { output, log } = createLog() 407 | const boom = new Error("boom") 408 | function* work(ms = 0, name = "", crash = false) { 409 | log(`${name} on duty`) 410 | if (crash) { 411 | yield* Task.sleep(ms) 412 | throw boom 413 | } 414 | 415 | try { 416 | yield* Task.sleep(ms) 417 | log(`${name} is done`) 418 | } finally { 419 | log(`${name} cancelled`) 420 | } 421 | } 422 | 423 | function* main() { 424 | const a = yield* Task.fork(work(1, "A")) 425 | yield* Task.sleep(2) 426 | const b = yield* Task.fork(work(8, "B")) 427 | const c = yield* Task.fork(work(14, "C")) 428 | const d = yield* Task.fork(work(4, "D", true)) 429 | const e = yield* Task.fork(work(10, "E")) 430 | 431 | try { 432 | yield* Task.group([a, b, c, d, e]) 433 | } catch (error) { 434 | yield* Task.sleep(30) 435 | return { error } 436 | } 437 | } 438 | 439 | assert.deepEqual(await inspect(main()), { 440 | ok: true, 441 | value: { error: boom }, 442 | mail: [], 443 | }) 444 | 445 | assert.deepEqual( 446 | [...output].sort(), 447 | [ 448 | "A on duty", 449 | "B on duty", 450 | "C on duty", 451 | "D on duty", 452 | "E on duty", 453 | "A is done", 454 | "E cancelled", 455 | "A cancelled", 456 | "B cancelled", 457 | "C cancelled", 458 | ].sort() 459 | ) 460 | }) 461 | 462 | it("failed task fails the group", async () => { 463 | const { output, log } = createLog() 464 | const boom = new Error("boom") 465 | 466 | function* fail(error = boom) { 467 | throw error 468 | } 469 | function* work(ms = 0, name = "") { 470 | log(`${name} on duty`) 471 | 472 | try { 473 | yield* Task.sleep(ms) 474 | log(`${name} is done`) 475 | } finally { 476 | log(`${name} cancelled`) 477 | } 478 | } 479 | 480 | function* main() { 481 | const f = yield* Task.fork(fail(boom)) 482 | const a = yield* Task.fork(work(2, "a")) 483 | yield* Task.sleep() 484 | assert.deepEqual(f.result, { ok: false, error: boom }) 485 | yield* Task.group([ 486 | a, 487 | yield* Task.fork(work(4, "b")), 488 | f, 489 | yield* Task.fork(work(2, "c")), 490 | ]) 491 | } 492 | 493 | assert.deepEqual(await inspect(main()), { 494 | ok: false, 495 | error: boom, 496 | mail: [], 497 | }) 498 | await Task.fork(Task.sleep(10)) 499 | assert.deepEqual(output, ["a on duty", "a cancelled"]) 500 | }) 501 | 502 | it("can make empty group", async () => { 503 | function* main() { 504 | return yield* Task.group([]) 505 | } 506 | 507 | assert.deepEqual(await inspect(main()), { 508 | ok: true, 509 | value: undefined, 510 | mail: [], 511 | }) 512 | }) 513 | }) 514 | 515 | describe("concurrency", () => { 516 | it("can run tasks concurrently", async () => { 517 | /** 518 | * @param {string} name 519 | * @param {number} duration 520 | * @param {number} count 521 | */ 522 | function* worker(name, duration, count) { 523 | let n = 0 524 | while (n++ < count) { 525 | yield* Task.sleep(duration) 526 | yield* Task.send(`${name}#${n}`) 527 | } 528 | } 529 | 530 | function* main() { 531 | const a = yield* Task.fork(worker("a", 5, 6)) 532 | yield* Task.sleep(5) 533 | const b = yield* Task.fork(worker("b", 7, 7)) 534 | 535 | yield* Task.group([a, b]) 536 | } 537 | 538 | const result = await inspect(main()) 539 | const { mail } = result 540 | assert.deepEqual( 541 | [...mail].sort(), 542 | [ 543 | "a#1", 544 | "a#2", 545 | "a#3", 546 | "a#4", 547 | "a#5", 548 | "a#6", 549 | "b#1", 550 | "b#2", 551 | "b#3", 552 | "b#4", 553 | "b#5", 554 | "b#6", 555 | "b#7", 556 | ], 557 | "has all the items" 558 | ) 559 | assert.notDeepEqual([...mail].sort(), mail, "messages are not ordered") 560 | }) 561 | 562 | it("can fork and join", async () => { 563 | const { output, log } = createLog() 564 | /** 565 | * @param {string} name 566 | */ 567 | function* work(name) { 568 | log(`> ${name} sleep`) 569 | yield* Task.sleep(5) 570 | log(`< ${name} wake`) 571 | } 572 | 573 | function* main() { 574 | log("Spawn A") 575 | const a = yield* Task.fork(work("A")) 576 | 577 | log("Sleep") 578 | yield* Task.sleep(20) 579 | 580 | log("Spawn B") 581 | const b = yield* Task.fork(work("B")) 582 | 583 | log("Join") 584 | const merge = Task.group([a, b]) 585 | yield* merge 586 | 587 | log("Nap") 588 | yield* Task.sleep(2) 589 | 590 | log("Exit") 591 | } 592 | 593 | await Task.fork(main(), { name: "🤖" }) 594 | 595 | assert.deepEqual( 596 | [...output], 597 | [ 598 | "Spawn A", 599 | "Sleep", 600 | "> A sleep", 601 | "< A wake", 602 | "Spawn B", 603 | "Join", 604 | "> B sleep", 605 | "< B wake", 606 | "Nap", 607 | "Exit", 608 | ] 609 | ) 610 | }) 611 | 612 | it("joining failed task throws", async () => { 613 | const boom = new Error("boom") 614 | function* work() { 615 | throw boom 616 | } 617 | 618 | function* main() { 619 | const worker = yield* Task.fork(work()) 620 | yield* Task.sleep(0) 621 | 622 | yield* Task.join(worker) 623 | } 624 | 625 | const result = await inspect(main()) 626 | assert.deepEqual(result, { 627 | ok: false, 628 | error: boom, 629 | mail: [], 630 | }) 631 | }) 632 | it("spawn can outlive parent", async () => { 633 | const { output, log } = createLog() 634 | const worker = function* () { 635 | log("start fork") 636 | yield* Task.sleep(2) 637 | log("exit fork") 638 | } 639 | 640 | const main = function* () { 641 | log("start main") 642 | yield* Task.spawn(worker()) 643 | log("exit main") 644 | } 645 | 646 | await Task.fork(main()) 647 | // assert.deepEqual(output, ["start main", "exit main", "start fork"]) 648 | 649 | await Task.fork(Task.sleep(20)) 650 | 651 | assert.deepEqual(output, [ 652 | "start main", 653 | "exit main", 654 | "start fork", 655 | "exit fork", 656 | ]) 657 | }) 658 | 659 | it("throws on exit", async () => { 660 | const boom = new Error("boom") 661 | function* work() { 662 | try { 663 | yield* Task.sleep(5) 664 | } finally { 665 | throw boom 666 | } 667 | } 668 | 669 | function* main() { 670 | const worker = yield* Task.fork(work()) 671 | yield* Task.sleep() 672 | yield* Task.exit(worker, undefined) 673 | } 674 | 675 | assert.deepEqual(await inspect(main()), { 676 | ok: true, 677 | value: undefined, 678 | mail: [], 679 | }) 680 | }) 681 | }) 682 | 683 | describe("type level errors", () => { 684 | it("must yield* not yield", async () => { 685 | const main = function* () { 686 | yield Task.sleep(2) 687 | } 688 | 689 | const error = () => { 690 | // @ts-expect-error - Tells you to use yield* 691 | Task.fork(main()) 692 | } 693 | }) 694 | 695 | it("must yield* not yield", async () => { 696 | const worker = function* () {} 697 | const main = function* () { 698 | // @ts-expect-error tels you to use worker() 699 | yield Task.fork(worker) 700 | } 701 | }) 702 | }) 703 | 704 | describe("can abort", () => { 705 | it("can terminate sleeping task", async () => { 706 | let { output, log } = createLog() 707 | function* main() { 708 | log("fork worker") 709 | const task = yield* Task.fork(worker()) 710 | log("nap") 711 | yield* Task.sleep(1) 712 | log("terminate worker") 713 | yield* Task.terminate(task) 714 | log("exit main") 715 | } 716 | 717 | function* worker() { 718 | log("start worker") 719 | yield* Task.sleep(20) 720 | log("wake worker") 721 | } 722 | 723 | const expect = [ 724 | "fork worker", 725 | "nap", 726 | "start worker", 727 | "terminate worker", 728 | "exit main", 729 | ] 730 | 731 | await Task.fork(main()) 732 | assert.deepEqual(output, expect) 733 | await Task.fork(Task.sleep(30)) 734 | 735 | assert.deepEqual(output, expect) 736 | }) 737 | 738 | it("sleeping task can still cleanup", async () => { 739 | let { output, log } = createLog() 740 | function* main() { 741 | log("fork worker") 742 | const task = yield* Task.fork(worker()) 743 | log("nap") 744 | yield* Task.sleep(1) 745 | log("abort worker") 746 | yield* Task.terminate(task) 747 | log("exit main") 748 | } 749 | 750 | function* worker() { 751 | log("start worker") 752 | 753 | const id = setTimeout(() => { 754 | log("timeout fired") 755 | }, 10) 756 | 757 | try { 758 | yield* Task.suspend() 759 | } finally { 760 | clearTimeout(id) 761 | log("can clean up even though aborted") 762 | } 763 | } 764 | 765 | const expect = [ 766 | "fork worker", 767 | "nap", 768 | "start worker", 769 | "abort worker", 770 | "can clean up even though aborted", 771 | "exit main", 772 | ] 773 | 774 | await Task.fork(main()) 775 | await Task.fork(Task.sleep(30)) 776 | 777 | assert.deepEqual(output, expect) 778 | }) 779 | 780 | it("can abort with an error", async () => { 781 | let { output, log } = createLog() 782 | function* main() { 783 | log("fork worker") 784 | const fork = yield* Task.fork(worker()) 785 | log("nap") 786 | yield* Task.sleep(1) 787 | log("abort worker") 788 | yield* Task.abort(fork, new Error("kill")) 789 | log("exit main") 790 | } 791 | 792 | function* worker() { 793 | try { 794 | log("start worker") 795 | yield* Task.sleep(20) 796 | log("wake worker") 797 | } catch (error) { 798 | log(`aborted ${error}`) 799 | } 800 | } 801 | 802 | const expect = [ 803 | "fork worker", 804 | "nap", 805 | "start worker", 806 | "abort worker", 807 | "aborted Error: kill", 808 | "exit main", 809 | ] 810 | 811 | await Task.fork(main()) 812 | assert.deepEqual(output, expect) 813 | await Task.fork(Task.sleep(30)) 814 | 815 | assert.deepEqual(output, expect) 816 | }) 817 | 818 | it("can still do things when aborted", async () => { 819 | let { output, log } = createLog() 820 | function* main() { 821 | log("fork worker") 822 | const fork = yield* Task.fork(worker()) 823 | log("nap") 824 | yield* Task.sleep(1) 825 | log("abort worker") 826 | yield* Task.abort(fork, new Error("kill")) 827 | log("exit main") 828 | } 829 | 830 | function* worker() { 831 | try { 832 | log("start worker") 833 | yield* Task.sleep(20) 834 | log("wake worker") 835 | } catch (error) { 836 | log(`aborted ${error}`) 837 | yield* Task.sleep(2) 838 | log("ok bye") 839 | } 840 | } 841 | 842 | const expect = [ 843 | "fork worker", 844 | "nap", 845 | "start worker", 846 | "abort worker", 847 | "aborted Error: kill", 848 | "exit main", 849 | ] 850 | 851 | await Task.fork(main()) 852 | assert.deepEqual(output, expect) 853 | await Task.fork(Task.sleep(10)) 854 | 855 | assert.deepEqual(output, [...expect, "ok bye"]) 856 | }) 857 | 858 | it("can still suspend after aborted", async () => { 859 | let { output, log } = createLog() 860 | function* main() { 861 | log("fork worker") 862 | const fork = yield* Task.fork(worker()) 863 | log("nap") 864 | yield* Task.sleep(1) 865 | log("abort worker") 866 | yield* Task.abort(fork, new Error("kill")) 867 | log("exit main") 868 | } 869 | 870 | function* worker() { 871 | const task = yield* Task.current() 872 | try { 873 | log("start worker") 874 | yield* Task.sleep(20) 875 | log("wake worker") 876 | } catch (error) { 877 | log(`aborted ${error}`) 878 | setTimeout(Task.resume, 2, task) 879 | log("suspend after abort") 880 | yield* Task.suspend() 881 | log("ok bye now") 882 | } 883 | } 884 | 885 | const expect = [ 886 | "fork worker", 887 | "nap", 888 | "start worker", 889 | "abort worker", 890 | "aborted Error: kill", 891 | "suspend after abort", 892 | "exit main", 893 | ] 894 | 895 | await Task.fork(main()) 896 | assert.deepEqual(output, expect) 897 | await Task.fork(Task.sleep(10)) 898 | 899 | assert.deepEqual(output, [...expect, "ok bye now"]) 900 | }) 901 | 902 | it("can exit the task", async () => { 903 | let { output, log } = createLog() 904 | function* main() { 905 | log("fork worker") 906 | const fork = yield* Task.fork(worker()) 907 | log("nap") 908 | yield* Task.sleep(1) 909 | log("exit worker") 910 | yield* Task.exit(fork, 0) 911 | log("exit main") 912 | } 913 | 914 | function* worker() { 915 | try { 916 | log("start worker") 917 | yield* Task.sleep(20) 918 | log("wake worker") 919 | return 0 920 | } catch (error) { 921 | log(`aborted ${error}`) 922 | return 1 923 | } 924 | } 925 | 926 | const expect = [ 927 | "fork worker", 928 | "nap", 929 | "start worker", 930 | "exit worker", 931 | "exit main", 932 | ] 933 | 934 | await Task.fork(main()) 935 | assert.deepEqual(output, expect) 936 | await Task.fork(Task.sleep(30)) 937 | 938 | assert.deepEqual(output, expect) 939 | }) 940 | }) 941 | 942 | describe("promise", () => { 943 | it("fails promise if task fails", async () => { 944 | function* main() { 945 | throw new Error("boom") 946 | } 947 | 948 | try { 949 | const result = await Task.fork(main()) 950 | assert.fail("should be unreachable") 951 | } catch (error) { 952 | assert.match(String(error), /boom/) 953 | } 954 | }) 955 | 956 | it("can use then", async () => { 957 | function* work() { 958 | yield* Task.sleep(1) 959 | return 0 960 | } 961 | 962 | const result = await Task.fork(work()).then() 963 | assert.deepEqual(result, 0) 964 | }) 965 | 966 | it("can use catch", async () => { 967 | const boom = new Error("boom") 968 | function* work() { 969 | yield* Task.sleep(1) 970 | throw boom 971 | } 972 | 973 | const result = await Task.fork(work()).catch(e => e) 974 | assert.deepEqual(result, boom) 975 | }) 976 | 977 | it("can use finally", async () => { 978 | const boom = new Error("boom") 979 | function* work() { 980 | yield* Task.sleep(1) 981 | return 0 982 | } 983 | 984 | let invoked = false 985 | const result = await Task.fork(work()).finally(() => { 986 | invoked = true 987 | }) 988 | 989 | assert.deepEqual(result, 0) 990 | assert.deepEqual(invoked, true) 991 | }) 992 | 993 | it("has toStringTag", async () => { 994 | const fork = Task.fork(Task.sleep(2)) 995 | assert.deepEqual(String(fork), "[object Fork]") 996 | }) 997 | }) 998 | 999 | describe("tag", () => { 1000 | it("tags effect", async () => { 1001 | function* fx() { 1002 | yield* Task.send(1) 1003 | yield* Task.sleep(2) 1004 | 1005 | yield* Task.send(2) 1006 | } 1007 | 1008 | const result = await inspect(Task.tag(fx(), "fx")) 1009 | assert.deepEqual(result, { 1010 | ok: true, 1011 | value: undefined, 1012 | mail: [ 1013 | { type: "fx", fx: 1 }, 1014 | { type: "fx", fx: 2 }, 1015 | ], 1016 | }) 1017 | }) 1018 | 1019 | it("tags with errors", async () => { 1020 | const error = new Error("boom") 1021 | function* fx() { 1022 | yield* Task.send(1) 1023 | throw error 1024 | } 1025 | 1026 | function* main() { 1027 | yield* Task.tag(fx(), "fx") 1028 | } 1029 | 1030 | const result = await inspect(main()) 1031 | assert.deepEqual(result, { 1032 | ok: false, 1033 | error, 1034 | mail: [{ type: "fx", fx: 1 }], 1035 | }) 1036 | }) 1037 | 1038 | it("can terminate tagged", async () => { 1039 | const { output, log } = createLog() 1040 | function* fx() { 1041 | yield* Task.send(1) 1042 | log("send 1") 1043 | yield* Task.sleep(1) 1044 | yield* Task.send(2) 1045 | log("send 2") 1046 | } 1047 | 1048 | function* main() { 1049 | const fork = yield* Task.fork(Task.tag(fx(), "fx")) 1050 | yield* Task.sleep(1) 1051 | yield* Task.terminate(fork) 1052 | } 1053 | 1054 | const result = await inspect(main()) 1055 | assert.deepEqual(result, { 1056 | ok: true, 1057 | value: undefined, 1058 | mail: [], 1059 | }) 1060 | assert.deepEqual(output, ["send 1"]) 1061 | await Task.fork(Task.sleep(5)) 1062 | 1063 | assert.deepEqual(output, ["send 1"]) 1064 | }) 1065 | 1066 | it("can abort tagged", async () => { 1067 | const { output, log } = createLog() 1068 | function* fx() { 1069 | yield* Task.send(1) 1070 | log("send 1") 1071 | yield* Task.sleep(1) 1072 | yield* Task.send(2) 1073 | log("send 2") 1074 | } 1075 | 1076 | function* main() { 1077 | const tagged = Task.tag(fx(), "fx") 1078 | assert.equal(String(tagged), "[object TaggedEffect]") 1079 | const fork = yield* Task.fork(tagged) 1080 | yield* Task.sleep(1) 1081 | yield* Task.abort(fork, new Error("kill")) 1082 | } 1083 | 1084 | const result = await inspect(main()) 1085 | assert.deepEqual(result, { 1086 | ok: true, 1087 | value: undefined, 1088 | mail: [], 1089 | }) 1090 | assert.deepEqual(output, ["send 1"]) 1091 | await Task.fork(Task.sleep(5)) 1092 | 1093 | assert.deepEqual(output, ["send 1"]) 1094 | }) 1095 | 1096 | it("can double tag", async () => { 1097 | function* fx() { 1098 | yield* Task.send(1) 1099 | yield* Task.sleep(1) 1100 | yield* Task.send(2) 1101 | } 1102 | 1103 | const tagged = Task.tag(Task.tag(fx(), "foo"), "bar") 1104 | 1105 | assert.deepEqual(await inspect(tagged), { 1106 | ok: true, 1107 | value: undefined, 1108 | mail: [ 1109 | { type: "bar", bar: { type: "foo", foo: 1 } }, 1110 | { type: "bar", bar: { type: "foo", foo: 2 } }, 1111 | ], 1112 | }) 1113 | }) 1114 | 1115 | it("tagging none is noop", async () => { 1116 | function* fx() { 1117 | yield* Task.send(1) 1118 | yield* Task.sleep(1) 1119 | yield* Task.send(2) 1120 | } 1121 | 1122 | const tagged = Task.tag(Task.tag(Task.none(), "foo"), "bar") 1123 | assert.deepEqual(await inspect(tagged), { 1124 | ok: true, 1125 | value: undefined, 1126 | mail: [], 1127 | }) 1128 | assert.equal(tagged, Task.none()) 1129 | }) 1130 | }) 1131 | 1132 | describe("effect", () => { 1133 | it("can listen to several fx", async () => { 1134 | /** 1135 | * @param {number} delay 1136 | * @param {number} count 1137 | */ 1138 | function* source(delay, count) { 1139 | let start = Date.now() 1140 | let n = 0 1141 | while (n < count) { 1142 | yield* Task.send(n) 1143 | n++ 1144 | yield* Task.sleep(delay) 1145 | } 1146 | } 1147 | 1148 | const fx = Task.listen({ 1149 | beep: source(3, 5), 1150 | bop: source(5, 3), 1151 | buz: source(2, 2), 1152 | }) 1153 | 1154 | const { mail, ...result } = await inspect(fx) 1155 | assert.deepEqual(result, { ok: true, value: undefined }) 1156 | const inbox = mail.map(m => JSON.stringify(m)) 1157 | 1158 | const expect = [ 1159 | { type: "beep", beep: 0 }, 1160 | { type: "beep", beep: 1 }, 1161 | { type: "beep", beep: 2 }, 1162 | { type: "beep", beep: 3 }, 1163 | { type: "beep", beep: 4 }, 1164 | { type: "bop", bop: 0 }, 1165 | { type: "bop", bop: 1 }, 1166 | { type: "bop", bop: 2 }, 1167 | { type: "buz", buz: 0 }, 1168 | { type: "buz", buz: 1 }, 1169 | ] 1170 | 1171 | assert.notDeepEqual( 1172 | [...inbox].sort(), 1173 | inbox, 1174 | "messages aren not ordered by actors" 1175 | ) 1176 | assert.deepEqual( 1177 | [...inbox].sort(), 1178 | [...expect.map(v => JSON.stringify(v))].sort(), 1179 | "all messages were received" 1180 | ) 1181 | }) 1182 | 1183 | it("can listen to none", async () => { 1184 | assert.deepEqual(await inspect(Task.listen({})), { 1185 | ok: true, 1186 | value: undefined, 1187 | mail: [], 1188 | }) 1189 | }) 1190 | 1191 | it("can produces no messages on empty tasks", async () => { 1192 | const { log, output } = createLog() 1193 | function* work() { 1194 | console.log("start work") 1195 | yield* Task.sleep(2) 1196 | console.log("end work") 1197 | } 1198 | const main = Task.listen({ 1199 | none: work(), 1200 | }) 1201 | 1202 | assert.deepEqual(await inspect(main), { 1203 | ok: true, 1204 | value: undefined, 1205 | mail: [], 1206 | }) 1207 | }) 1208 | 1209 | it("can turn task into effect", async () => { 1210 | function* work() { 1211 | Task.sleep(1) 1212 | return "hi" 1213 | } 1214 | 1215 | const fx = Task.effect(work()) 1216 | 1217 | assert.deepEqual(await inspect(fx), { 1218 | ok: true, 1219 | value: undefined, 1220 | mail: ["hi"], 1221 | }) 1222 | }) 1223 | 1224 | it("can turn multiple tasks into effect", async () => { 1225 | function* fx(msg = "", delay = 1) { 1226 | yield* Task.sleep(delay) 1227 | return msg 1228 | } 1229 | 1230 | const effect = Task.effects([fx("foo", 5), fx("bar", 1), fx("baz", 2)]) 1231 | assert.deepEqual(await inspect(effect), { 1232 | ok: true, 1233 | value: undefined, 1234 | mail: ["bar", "baz", "foo"], 1235 | }) 1236 | }) 1237 | 1238 | it("can turn 0 tasks into effect", async () => { 1239 | const effect = Task.effects([]) 1240 | assert.deepEqual(await inspect(effect), { 1241 | ok: true, 1242 | value: undefined, 1243 | mail: [], 1244 | }) 1245 | }) 1246 | 1247 | it("can batch multiple effects", async () => { 1248 | function* fx(msg = "", delay = 1) { 1249 | yield* Task.sleep(delay) 1250 | yield* Task.send(msg) 1251 | } 1252 | 1253 | const effect = Task.batch([fx("foo", 5), fx("bar", 1), fx("baz", 2)]) 1254 | assert.deepEqual(await inspect(effect), { 1255 | ok: true, 1256 | value: undefined, 1257 | mail: ["bar", "baz", "foo"], 1258 | }) 1259 | }) 1260 | 1261 | it("can loop", async () => { 1262 | const { log, output } = createLog() 1263 | function* step({ n } = { n: 0 }) { 1264 | log(`<< ${n}`) 1265 | while (--n > 0) { 1266 | log(`>> ${n}`) 1267 | yield* Task.sleep(n) 1268 | yield* Task.send({ n }) 1269 | } 1270 | } 1271 | 1272 | const main = await Task.fork(Task.loop(step({ n: 4 }), step)) 1273 | 1274 | assert.notDeepEqual([...output].sort(), output) 1275 | assert.deepEqual( 1276 | [...output].sort(), 1277 | [ 1278 | "<< 4", 1279 | ">> 3", 1280 | ">> 2", 1281 | ">> 1", 1282 | "<< 3", 1283 | ">> 2", 1284 | ">> 1", 1285 | "<< 2", 1286 | ">> 1", 1287 | "<< 1", 1288 | "<< 2", 1289 | ">> 1", 1290 | "<< 1", 1291 | "<< 1", 1292 | "<< 1", 1293 | ].sort() 1294 | ) 1295 | }) 1296 | 1297 | it("can wait in a loop", async () => { 1298 | const { log, output } = createLog() 1299 | const main = Task.loop(Task.send("start"), function* (message) { 1300 | log(`<< ${message}`) 1301 | const result = yield* Task.wait(0) 1302 | log(`>> ${result}`) 1303 | }) 1304 | 1305 | assert.deepEqual(await Task.fork(main), undefined) 1306 | assert.deepEqual(output, ["<< start", ">> 0"]) 1307 | }) 1308 | }) 1309 | 1310 | describe("all operator", () => { 1311 | it("can get all results", async () => { 1312 | const { output, log } = createLog() 1313 | 1314 | /** @type {(d:number, r:string) => Task.Task} */ 1315 | function* work(duration, result) { 1316 | yield* Task.sleep(duration) 1317 | log(result) 1318 | return result 1319 | } 1320 | 1321 | function* main() { 1322 | const result = yield* Task.all([ 1323 | work(2, "a"), 1324 | work(9, "b"), 1325 | work(5, "c"), 1326 | work(0, "d"), 1327 | ]) 1328 | 1329 | return result 1330 | } 1331 | 1332 | const result = await Task.fork(main()) 1333 | assert.deepEqual(result, ["a", "b", "c", "d"]) 1334 | assert.notDeepEqual(result, output) 1335 | assert.deepEqual([...result].sort(), [...output].sort()) 1336 | }) 1337 | 1338 | it("on failur all other tasks are aborted", async () => { 1339 | const { output, log } = createLog() 1340 | 1341 | /** @type {(d:number, n:string, c?:boolean) => Task.Task} */ 1342 | function* work(duration, name, crash = false) { 1343 | yield* Task.sleep(duration) 1344 | log(name) 1345 | if (crash) { 1346 | throw name 1347 | } else { 1348 | return name 1349 | } 1350 | } 1351 | 1352 | function* main() { 1353 | const result = yield* Task.all([ 1354 | work(2, "a"), 1355 | work(9, "b"), 1356 | work(5, "c", true), 1357 | work(0, "d"), 1358 | work(8, "e"), 1359 | ]) 1360 | 1361 | return result 1362 | } 1363 | 1364 | const result = await inspect(main()) 1365 | assert.deepEqual(result, { 1366 | ok: false, 1367 | error: "c", 1368 | mail: [], 1369 | }) 1370 | 1371 | await Task.fork(Task.sleep(20)) 1372 | assert.deepEqual([...output].sort(), ["d", "a", "c"].sort()) 1373 | }) 1374 | 1375 | it("can make all of none", async () => { 1376 | assert.deepEqual(await Task.fork(Task.all([])), []) 1377 | }) 1378 | }) 1379 | 1380 | describe("Fork API", () => { 1381 | it("can use abort method", async () => { 1382 | const { output, log } = createLog() 1383 | const kill = new Error("kill") 1384 | function* work() { 1385 | log("start work") 1386 | yield* Task.sleep(2) 1387 | log("end work") 1388 | } 1389 | 1390 | function* main() { 1391 | const worker = yield* Task.fork(work()) 1392 | yield* Task.sleep(0) 1393 | log("kill") 1394 | yield* worker.abort(kill) 1395 | log("nap") 1396 | yield* Task.sleep(5) 1397 | log("exit") 1398 | } 1399 | 1400 | await Task.fork(main()) 1401 | assert.deepEqual(output, ["start work", "kill", "nap", "exit"]) 1402 | }) 1403 | it("can use exit method", async () => { 1404 | const { output, log } = createLog() 1405 | const kill = new Error("kill") 1406 | function* work() { 1407 | try { 1408 | log("start work") 1409 | yield* Task.sleep(2) 1410 | log("end work") 1411 | } finally { 1412 | log("cancel work") 1413 | } 1414 | } 1415 | 1416 | function* main() { 1417 | const worker = yield* Task.fork(work()) 1418 | yield* Task.sleep(0) 1419 | log("kill") 1420 | yield* worker.exit() 1421 | log("nap") 1422 | yield* Task.sleep(5) 1423 | log("exit") 1424 | } 1425 | 1426 | await Task.fork(main()) 1427 | assert.deepEqual(output, [ 1428 | "start work", 1429 | "kill", 1430 | "cancel work", 1431 | "nap", 1432 | "exit", 1433 | ]) 1434 | }) 1435 | 1436 | it("can use resume method", async () => { 1437 | const { output, log } = createLog() 1438 | function* work() { 1439 | log("suspend work") 1440 | yield* Task.suspend() 1441 | log("resume work") 1442 | } 1443 | 1444 | function* main() { 1445 | const worker = yield* Task.fork(work()) 1446 | yield* Task.sleep(2) 1447 | yield* worker.resume() 1448 | log("exit") 1449 | } 1450 | 1451 | await Task.fork(main()) 1452 | assert.deepEqual(output, ["suspend work", "exit", "resume work"]) 1453 | }) 1454 | 1455 | it("can use join method", async () => { 1456 | function* work() { 1457 | yield* Task.send("a") 1458 | yield* Task.sleep(2) 1459 | yield* Task.send("b") 1460 | return 0 1461 | } 1462 | 1463 | function* main() { 1464 | const worker = yield* Task.fork(work()) 1465 | yield* Task.sleep(0) 1466 | const result = yield* worker.join() 1467 | return result 1468 | } 1469 | 1470 | const result = await inspect(main()) 1471 | assert.deepEqual(result, { 1472 | ok: true, 1473 | value: 0, 1474 | mail: ["b"], 1475 | }) 1476 | }) 1477 | 1478 | it("has toStringTag", async () => { 1479 | function* main() { 1480 | const fork = yield* Task.fork(Task.sleep(2)) 1481 | return String(fork) 1482 | } 1483 | 1484 | assert.deepEqual(await Task.fork(main()), "[object Fork]") 1485 | }) 1486 | 1487 | it("is iterator", async () => { 1488 | function* work() { 1489 | yield* Task.send("a") 1490 | yield* Task.send("b") 1491 | yield* Task.send("c") 1492 | } 1493 | function* main() { 1494 | const fork = yield* Task.fork(work()) 1495 | return [...fork] 1496 | } 1497 | 1498 | assert.deepEqual(await Task.fork(main()), []) 1499 | }) 1500 | 1501 | it("can join non-active fork", async () => { 1502 | function* work() { 1503 | yield* Task.send("hi") 1504 | } 1505 | 1506 | const worker = Task.fork(work()) 1507 | 1508 | function* main() { 1509 | yield* Task.join(worker) 1510 | } 1511 | 1512 | assert.deepEqual(await inspect(main()), { 1513 | mail: ["hi"], 1514 | ok: true, 1515 | value: undefined, 1516 | }) 1517 | }) 1518 | }) 1519 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { assert } from "chai" 2 | import * as Task from "../src/lib.js" 3 | 4 | export { assert } 5 | 6 | export const createLog = () => { 7 | /** @type {string[]} */ 8 | const output = [] 9 | 10 | return { 11 | output, 12 | /** 13 | * @param {string} message 14 | */ 15 | log(message) { 16 | output.push(message) 17 | }, 18 | } 19 | } 20 | 21 | /** 22 | * @template T, X, M 23 | * @param {Task.Task} task 24 | */ 25 | export const inspect = task => Task.fork(inspector(task)) 26 | 27 | /** 28 | * @template T, X, M 29 | * @param {Task.Task} task 30 | * @returns {Task.Task<{ ok: boolean, value?: T, error?: X, mail: M[] }, never>} 31 | */ 32 | export const inspector = function* (task) { 33 | /** @type {M[]} */ 34 | const mail = [] 35 | let input 36 | const controller = task[Symbol.iterator]() 37 | try { 38 | while (true) { 39 | const step = controller.next(input) 40 | if (step.done) { 41 | return { ok: true, value: step.value, mail } 42 | } else { 43 | const instruction = step.value 44 | if (Task.isInstruction(instruction)) { 45 | input = yield instruction 46 | } else { 47 | mail.push(/** @type {M} */ (instruction)) 48 | } 49 | } 50 | } 51 | } catch (error) { 52 | return { ok: false, error: /** @type {X} */ (error), mail } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": true, /* Enable incremental compilation */ 7 | "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./dist", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "ES2020", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist/", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "include": [ 102 | "src", 103 | "test" 104 | ], 105 | "exclude": [ 106 | ] 107 | } 108 | --------------------------------------------------------------------------------