├── .github └── workflows │ ├── preview.yml │ ├── publish.yml │ └── verify.yaml ├── .gitignore ├── .projectile ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── Changelog.md ├── LICENSE ├── README.md ├── deno.json ├── docs ├── actions.mdx ├── async-rosetta-stone.mdx ├── collections.mdx ├── context.mdx ├── docs.ts ├── errors.mdx ├── events.mdx ├── installation.mdx ├── operations.mdx ├── processes.mdx ├── resources.mdx ├── scope.mdx ├── spawn.mdx ├── structure.json ├── thinking-in-effection.mdx ├── tutorial.mdx └── typescript.mdx ├── lib ├── abort-signal.ts ├── all.ts ├── async.ts ├── call.ts ├── channel.ts ├── context.ts ├── deps.ts ├── each.ts ├── ensure.ts ├── events.ts ├── instructions.ts ├── lazy.ts ├── lift.ts ├── main.ts ├── mod.ts ├── pause.ts ├── queue.ts ├── race.ts ├── result.ts ├── run.ts ├── run │ ├── create.ts │ ├── frame.ts │ ├── scope.ts │ ├── task.ts │ ├── types.ts │ └── value.ts ├── scoped.ts ├── shift-sync.ts ├── signal.ts ├── sleep.ts ├── types.ts └── with-resolvers.ts ├── mod.ts ├── tasks ├── build-jsr.ts └── build-npm.ts └── test ├── abort-signal.test.ts ├── action.test.ts ├── all.test.ts ├── call.test.ts ├── channel.test.ts ├── context.test.ts ├── each.test.ts ├── ensure.test.ts ├── events.test.ts ├── lift.test.ts ├── main.test.ts ├── main ├── fail.exit.ts ├── fail.unexpected.ts ├── just.suspend.ts ├── node.mjs ├── ok.daemon.ts ├── ok.exit.ts └── ok.implicit.ts ├── queue.test.ts ├── race.test.ts ├── resource.test.ts ├── run.test.ts ├── scope.test.ts ├── scoped.test.ts ├── signal.test.ts ├── spawn.test.ts ├── suite.ts └── with-resolvers.test.ts /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: preview 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | preview: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: setup deno 18 | uses: denoland/setup-deno@v2 19 | with: 20 | deno-version: v2.x 21 | 22 | - name: Get Version 23 | id: vars 24 | # this will pull the last tag off the current branch 25 | run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^effection-v//')-pr+$(git rev-parse HEAD) 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20.x 31 | registry-url: https://registry.npmjs.com 32 | 33 | - name: Build NPM 34 | run: deno task build:npm ${{steps.vars.outputs.version}} 35 | 36 | - name: Publish Preview Versions 37 | run: npx pkg-pr-new publish './build/npm' 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "effection-v*" 7 | 8 | permissions: 9 | contents: read 10 | id-token: write 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: setup deno 21 | uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: v2.x 24 | 25 | - name: Get Version 26 | id: vars 27 | run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^effection-v//') 28 | 29 | - name: Setup Node 30 | uses: actions/setup-node@v4.1.0 31 | with: 32 | node-version: 18.x 33 | registry-url: https://registry.npmjs.com 34 | 35 | - name: Build NPM 36 | run: deno task build:npm ${{steps.vars.outputs.version}} 37 | 38 | - name: Publish NPM 39 | run: npm publish --access=public 40 | working-directory: ./build/npm 41 | env: 42 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 43 | 44 | publish-jsr: 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: checkout 49 | uses: actions/checkout@v4 50 | 51 | - name: setup deno 52 | uses: denoland/setup-deno@v2 53 | with: 54 | deno-version: v2.x 55 | 56 | - name: Get Version 57 | id: vars 58 | run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^effection-v//') 59 | 60 | - name: Build JSR 61 | run: deno task build:jsr ${{steps.vars.outputs.version}} 62 | 63 | - name: Publish JSR 64 | run: npx jsr publish --allow-dirty --token=${{secrets.JSR_TOKEN}} 65 | -------------------------------------------------------------------------------- /.github/workflows/verify.yaml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: 4 | push: 5 | branches: v[3-9] 6 | pull_request: 7 | branches: v[3-9] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: setup deno 21 | uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: v2.x 24 | 25 | - name: Setup Node 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: 20.x 29 | registry-url: https://registry.npmjs.com 30 | 31 | - name: format 32 | run: deno fmt --check 33 | 34 | - name: lint 35 | run: deno lint 36 | 37 | - name: test 38 | run: deno task test 39 | - name: test:node 40 | run: deno task test:node 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !website/yarn.lock 2 | .DS_Store 3 | 4 | # Local Netlify folder 5 | .netlify 6 | /build/ -------------------------------------------------------------------------------- /.projectile: -------------------------------------------------------------------------------- 1 | -/dist 2 | - /node_modules 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at oss@frontside.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present Charles Lowell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/effection.svg)](https://www.npmjs.com/package/effection) 2 | [![bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/effection)](https://bundlephobia.com/result?p=effection) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![Created by Frontside](https://img.shields.io/badge/created%20by-frontside-26abe8.svg)](https://frontside.com) 5 | [![Chat on Discord](https://img.shields.io/discord/700803887132704931?Label=Discord)](https://discord.gg/Ug5nWH8) 6 | 7 | # Effection 8 | 9 | Structured concurrency and effects for JavaScript. 10 | 11 | ## Why use Effection? 12 | 13 | Effection leverages the idea of [structured concurrency][structured concurrency] 14 | to ensure that you don't leak any resources, effects, and that cancellation is 15 | always properly handled. It helps you build concurrent code that feels rock 16 | solid _at scale_, and it does all of this while feeling like normal JavaScript. 17 | 18 | [Learn how to use Effection in your own project](https://frontside.com/effection) 19 | 20 | ## Platforms 21 | 22 | Effection runs on all major JavaScript platforms including NodeJs, Browser, and 23 | Deno. It is published on both [npm][npm-effection] and [deno.land][deno-land-effection]. 24 | 25 | ## Contributing to Website 26 | 27 | Go to [website's readme](www) to learn how to contribute to the website. 28 | 29 | ## Development 30 | 31 | [Deno][] is the primary tool used for development, testing, and packaging. 32 | 33 | ### Testing 34 | 35 | To run tests: 36 | 37 | ```text 38 | $ deno task test 39 | ``` 40 | 41 | ### Building NPM Packages 42 | 43 | If you want to build a development version of the NPM package so that you can 44 | link it locally, you can use the `build:npm` script and passing it a version 45 | number. for example: 46 | 47 | ``` text 48 | $ deno task build:npm 3.0.0-mydev-snapshot.0 49 | Task build:npm deno run -A tasks/build-npm.ts "3.0.0-mydev-snapshot.0" 50 | [dnt] Transforming... 51 | [dnt] Running npm install... 52 | 53 | up to date, audited 1 package in 162ms 54 | 55 | found 0 vulnerabilities 56 | [dnt] Building project... 57 | [dnt] Emitting ESM package... 58 | [dnt] Emitting script package... 59 | [dnt] Complete! 60 | ``` 61 | 62 | Now, the built npm package can be found in the `build/npm` directory. 63 | 64 | [structured concurrency]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ 65 | [discord]: https://discord.gg/Ug5nWH8 66 | [Deno]: https://deno.land 67 | [npm-effection]: https://www.npmjs.com/package/effection 68 | [deno-land-effection]: https://deno.land/x/effection 69 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effection/effection", 3 | "exports": "./mod.ts", 4 | "license": "ISC", 5 | "publish": { 6 | "include": ["lib", "mod.ts", "README.md"] 7 | }, 8 | "lock": false, 9 | "tasks": { 10 | "test": "deno test --allow-run=deno", 11 | "test:node": "deno task build:npm 0.0.0 && node test/main/node.mjs hello world", 12 | "build:jsr": "deno run -A tasks/build-jsr.ts", 13 | "build:npm": "deno run -A tasks/build-npm.ts" 14 | }, 15 | "lint": { 16 | "rules": { 17 | "exclude": ["prefer-const", "require-yield"] 18 | }, 19 | "exclude": [ 20 | "build", 21 | "website", 22 | "www", 23 | "packages" 24 | ] 25 | }, 26 | "fmt": { 27 | "exclude": [ 28 | "build", 29 | "website", 30 | "www", 31 | "packages", 32 | "CODE_OF_CONDUCT.md", 33 | "README.md" 34 | ] 35 | }, 36 | "test": { 37 | "exclude": [ 38 | "build", 39 | "packages" 40 | ] 41 | }, 42 | "compilerOptions": { 43 | "lib": [ 44 | "deno.ns", 45 | "esnext", 46 | "dom", 47 | "dom.iterable" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/async-rosetta-stone.mdx: -------------------------------------------------------------------------------- 1 | When we say "Effection is Structured Concurrency and Effects for 2 | JavaScript", we mean "JavaScript" seriously. You shouldn't have to 3 | learn an entirely new way of programming just to achieve structured 4 | concurrency. That's why the Effection APIs mirror ordinary JavaScript 5 | APIs so closely. That way, if you know how to do it in JavaScript, you 6 | know how to do it in Effection. 7 | 8 | The congruence between vanilla JavaScript constructs and their Effection 9 | counterparts is reflected in the “Async Rosetta Stone.” 10 | 11 | | Async/Await | Effection | 12 | | ------------------------- | ----------------- | 13 | | `await` | `yield*` | 14 | | `async function` | `function*` | 15 | | `Promise` | `Operation` | 16 | | `new Promise()` | `action()` | 17 | | `Promise.withResolvers()` | `withResolvers()` | 18 | | `for await` | `for yield* each` | 19 | | `AsyncIterable` | `Stream` | 20 | | `AsyncIterator` | `Subscription` | 21 | 22 | ## `await` \<=> `yield*` 23 | 24 | Pause a computation and resume it when the value represented by the right hand 25 | side becomes available. 26 | 27 | Continue once a promise has settled: 28 | 29 | ```javascript 30 | await promise; 31 | ``` 32 | 33 | Continue when operation is complete. 34 | 35 | ```js 36 | yield* operation; 37 | ``` 38 | 39 | ## `async function` \<=> `function*` 40 | 41 | Compose a set of computations together with logic defined by JavaScript syntax: 42 | 43 | Count down from 5 to 1 with an async function: 44 | 45 | ```js 46 | async function countdown() { 47 | for (let i = 5; i > 1; i--) { 48 | console.log(`${i}`); 49 | await sleep(1000); 50 | } 51 | console.log('blastoff!'); 52 | } 53 | ``` 54 | 55 | Count down from 5 to 1 with a generator function: 56 | 57 | ```js 58 | import { sleep } from 'effection'; 59 | 60 | function* countdown() { 61 | for (let i = 5; i > 1; i--) { 62 | console.log(`${i}`); 63 | yield* sleep(1000); 64 | } 65 | console.log('blastoff!'); 66 | } 67 | ``` 68 | 69 | Both will print: 70 | 71 | ``` 72 | 5 73 | 4 74 | 3 75 | 2 76 | 1 77 | blastoff! 78 | ``` 79 | 80 | To call an async function within an operation use [`call()`][call]: 81 | 82 | ```js 83 | import { call } from 'effection'; 84 | 85 | yield* call(async function() { 86 | return "hello world"; 87 | }); 88 | ``` 89 | 90 | To run an operation from an async function use [`run()`][run] or [`Scope.run`][scope-run]: 91 | 92 | ```js 93 | import { run } from 'effection'; 94 | 95 | await run(function*() { 96 | return "hello world"; 97 | }); 98 | ``` 99 | 100 | ## `Promise` \<=> `Operation` 101 | 102 | The `Promise` type serves roughly the same purpose as the `Operation`. It is a 103 | abstract value that you can use to pause a computation, and resume when the 104 | value has been computed. 105 | 106 | To use a promise: 107 | 108 | ```js 109 | let result = await promise; 110 | ``` 111 | 112 | To use an operation: 113 | 114 | ```js 115 | let result = yield* operation; 116 | ``` 117 | 118 | To convert from a promise to an operation, use [`until()`][until] 119 | 120 | ```js 121 | import { until } from 'effection'; 122 | 123 | let operation = until(promise); 124 | ``` 125 | 126 | to convert from an operation to a promise, use [`run()`][run] or [`Scope.run`][scope-run] 127 | 128 | ```js 129 | import { run } from 'effection'; 130 | 131 | let promise = run(operation); 132 | ``` 133 | 134 | ## `new Promise()` \<=> `action()` 135 | 136 | Construct a reference to a computation that can be resolved with a callback. 137 | In the case of `Promise()` the value will resolve in the next tick of the run 138 | loop. 139 | 140 | Create a promise that resolves in ten seconds: 141 | 142 | ```js 143 | new Promise((resolve) => { setTimeout(resolve, 10000) }); 144 | ``` 145 | 146 | Create an Operation that resolves in ten seconds: 147 | 148 | ```js 149 | import { action } from 'effection'; 150 | 151 | action(function*(resolve) { setTimeout(resolve, 10000) }); 152 | ``` 153 | 154 | A key difference is that the promise body will be executing immediately, but the 155 | action body is only executed when the action is evaluated. Also, it is executed 156 | anew every time the action is evaluated. 157 | 158 | ## `Promise.withResolvers()` \<=> `withResolvers()` 159 | 160 | Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with 161 | a `Promise`, use the `Promise.withResolvers()` function: 162 | 163 | ```ts 164 | async function main() { 165 | let { promise, resolve } = Promise.withResolvers(); 166 | 167 | setTimeout(resolve, 1000); 168 | 169 | await promise; 170 | 171 | console.log("done!") 172 | } 173 | ``` 174 | 175 | In effection: 176 | 177 | ```ts 178 | import { withResolvers } from "effection"; 179 | 180 | function* main() { 181 | let { operation, resolve } = withResolvers(); 182 | 183 | setTimeout(resolve, 1000); 184 | 185 | yield* operation; 186 | 187 | console.log("done!"); 188 | }; 189 | ``` 190 | 191 | ## `Promise.withResolvers()` \<=> `withResolvers()` 192 | 193 | Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with 194 | a `Promise`, use the `Promise.withResolvers()` function: 195 | 196 | ```ts 197 | async function main() { 198 | let { promise, resolve } = Promise.withResolvers(); 199 | 200 | setTimeout(resolve, 1000); 201 | 202 | await promise; 203 | 204 | console.log("done!") 205 | } 206 | ``` 207 | 208 | In effection: 209 | 210 | ```ts 211 | import { withResolvers } from "effection"; 212 | 213 | function* main() { 214 | let { operation, resolve } = withResolvers(); 215 | 216 | setTimeout(resolve, 1000); 217 | 218 | yield* operation; 219 | 220 | console.log("done!"); 221 | }; 222 | ``` 223 | 224 | ## `for await` \<=> `for yield* each` 225 | 226 | Loop over an AsyncIterable with `for await`: 227 | 228 | ```js 229 | for await (let item of iterable) { 230 | //item logic 231 | } 232 | ``` 233 | 234 | Loop over a `Stream` with `for yield* each` 235 | 236 | ```js 237 | import { each } from 'effection'; 238 | 239 | for (let item of yield* each(stream)) { 240 | // item logic 241 | yield* each.next(); 242 | } 243 | ``` 244 | 245 | See the definition of [`each()`][each] for more detail. 246 | 247 | ## `AsyncIterable` \<=> `Stream` 248 | 249 | A recipe for instantiating a sequence of items that can arrive over time. It is not 250 | the sequence itself, just how to create it. 251 | 252 | Use an `AsyncIterable` to create an `AsyncIterator`: 253 | 254 | ```js 255 | let iterator = asyncIterable[Symbol.asyncIterator](); 256 | ``` 257 | 258 | Use a `Stream` to create a `Subscription`: 259 | 260 | ```js 261 | let subscription = yield* stream; 262 | ``` 263 | 264 | To convert an `AsyncIterable` to a `Stream` use the [`stream()`][stream] 265 | function. 266 | 267 | ```js 268 | import { stream } from 'effection'; 269 | 270 | let itemStream = stream(asyncIterable); 271 | ``` 272 | 273 | ## `AsyncIterator` \<=> `Subscription` 274 | 275 | A stateful sequence of items that can be evaluated one at a time. 276 | 277 | Access the next item in an async iterator: 278 | 279 | ```js 280 | let next = await iterator.next(); 281 | if (next.done) { 282 | return next.value; 283 | } else { 284 | console.log(next.value) 285 | } 286 | ``` 287 | 288 | Access the next item in a subscription: 289 | 290 | ```js 291 | let next = yield* subscription.next(); 292 | if (next.done) { 293 | return next.value; 294 | } else { 295 | console.log(next.value); 296 | } 297 | ``` 298 | 299 | To convert an `AsyncIterator` to a `Subscription`, use the 300 | [`subscribe()`][subscribe] function. 301 | 302 | ```js 303 | let subscription = subscribe(asyncIterator); 304 | ``` 305 | 306 | [call]: /api/v3/call 307 | [until]: /api/v3/until 308 | [run]: /api/v3/run 309 | [scope-run]: /api/v3/Scope#interface_Scope-methods 310 | [each]: /api/v3/each 311 | [stream]: /api/v3/stream 312 | [subscribe]: /api/v3/subscribe 313 | -------------------------------------------------------------------------------- /docs/context.mdx: -------------------------------------------------------------------------------- 1 | Effection contexts store ambient values that are needed by the operations in your application in order to run. Some of the most common 2 | use cases of `Context` are 3 | 4 | 5 | * **Configuration**: most apps require values that come from the environment such as environment variables or configuration files. Use context to easily retrieve such values from within any operation. 6 | * **Client APIs**: many APIs provide client libraries to connect with them. With an Effection context, you can create a client instance once and share it between all child operations. 7 | * **Services**: Database connections, Websockets, and many other types of APIs involve a handle to a stateful object that needs to be 8 | destroyed accordingly to a set lifecycle. Combined with [resource api][resources], Effection context allows you to share a service between child operations and also to guarantee that it is disposed of properly when it is no longer needed. 9 | 10 | ## The problem 11 | 12 | Usually, you can pass information from a parent operation to a child operation 13 | via function argument or lexical scope. But passing function arguments can become 14 | verbose and inconvenient when you have to pass them through many operations in 15 | the middle, or if many operations need the same information. Likewise, passing information 16 | via lexical scope is only possible if you define the child operation in the body of the 17 | parent operation. `Context` lets the parent operation make some information available to any 18 | operation in the tree below it-no matter how deep-without passing it explicitly through function 19 | arguments or lexical scope. 20 | 21 | > 💁 If you're familiar with React Context, you already know most of 22 | > what you need to know about Effection Context. The biggest difference 23 | > is the API but general concepts are the same. 24 | 25 | ## What is argument drilling? 26 | 27 | Passing argument to operations is a convenient way to make data from parent operation available to the child operation. 28 | 29 | But passing arguments can become inconvenient when the child operation is nested deeply in the tree of operations, or 30 | the same arguments need to be passed to many operations. This situation is called "argument drilling". 31 | 32 | Wouldn't it be great if you could access information in a deeply nested operation from a parent operation without 33 | modifying operations in the middle? That's exactly what Effection Context does. 34 | 35 | ## Context: an alternative to passing arguments 36 | 37 | Context makes a value available to any child process within a tree of processes. 38 | 39 | ``` typescript 40 | import { createContext, main } from 'effection'; 41 | 42 | // 1. create the context 43 | const GithubTokenContext = createContext("token"); 44 | 45 | await main(function* () { 46 | // 2. set the context value 47 | yield* GithubTokenContext.set("gha-1234567890"); 48 | 49 | yield* fetchGithubData(); 50 | }) 51 | 52 | function* fetchGithubData() { 53 | yield* fetchRepositories(); 54 | } 55 | 56 | function* fetchRepositories() { 57 | // 3. use the context value in a child operation 58 | const token = yield* GithubTokenContext.expect(); 59 | // or 60 | // const token = yield* GithubTokenContext.get(); 61 | 62 | console.log(token); 63 | // -> gha-1234567890 64 | } 65 | ``` 66 | 67 | ## Context: overriding nested context 68 | 69 | Context is attached to the scope of the parent operation. That means that the operation and _all of its children_ will see that same context. 70 | 71 | However, if a child operation sets its own value for the context, it will _not_ affect the value of any of its ancestors. 72 | 73 |
74 | ![Parent sets value to A, Child overrides value to B and all children below get B](/assets/images/overriding-context.svg) 75 |
76 | 77 | 78 | ## Using Context 79 | 80 | To use context in your operations, you need to do the following 3 steps: 81 | 1. **Create** a context. 82 | 2. **Set** the context value. 83 | 3. **Yield** the context value. 84 | 85 | ### Step 1: Create a context. 86 | 87 | Effection provides a function for creating a context appropriately called `createContext`. This function will return 88 | a reference that you use to identify the context value in the scope. 89 | 90 | ``` javascript 91 | import { createContext } from 'effection' 92 | 93 | const MyValueContext = createContext("my-value"); 94 | ``` 95 | 96 | ### Step 2: Set the context value. 97 | 98 | ``` javascript 99 | await main(function* () { 100 | yield* MyValueContext.set("Hello World!"); 101 | }); 102 | ``` 103 | 104 | ### Step 3: Yield the context value. 105 | 106 | ```javascript 107 | 108 | await main(function* () { 109 | yield* MyValueContext.set("Hello World!"); 110 | 111 | yield* logMyValue(); 112 | }); 113 | 114 | function* logMyValue() { 115 | const value = yield* MyValueContext.expect(); 116 | // or 117 | // const value = yield* MyValueContext.get(); 118 | 119 | console.log(value); 120 | } 121 | ``` 122 | 123 | [scope]: /docs/guides/scope 124 | [resources]: /docs/guides/resources 125 | [React Context]: https://react.dev/learn/passing-data-deeply-with-context 126 | -------------------------------------------------------------------------------- /docs/docs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | all, 3 | call, 4 | type Operation, 5 | resource, 6 | type Task, 7 | useScope, 8 | } from "effection"; 9 | import structure from "./structure.json" with { type: "json" }; 10 | 11 | import { basename } from "https://deno.land/std@0.205.0/path/posix/basename.ts"; 12 | 13 | import remarkGfm from "npm:remark-gfm@3.0.1"; 14 | import rehypePrismPlus from "npm:rehype-prism-plus@1.5.1"; 15 | 16 | import { evaluate } from "npm:@mdx-js/mdx@2.3.0"; 17 | 18 | import { Fragment, jsx, jsxs } from "revolution/jsx-runtime"; 19 | 20 | export interface DocModule { 21 | default: () => JSX.Element; 22 | frontmatter: { 23 | id: string; 24 | title: string; 25 | }; 26 | } 27 | 28 | export interface Docs { 29 | all(): Operation; 30 | getDoc(id?: string): Operation; 31 | } 32 | 33 | export interface Topic { 34 | name: string; 35 | items: DocMeta[]; 36 | } 37 | 38 | export interface DocMeta { 39 | id: string; 40 | title: string; 41 | filename: string; 42 | topics: Topic[]; 43 | next?: DocMeta; 44 | prev?: DocMeta; 45 | } 46 | 47 | export interface Doc extends DocMeta { 48 | MDXContent: () => JSX.Element; 49 | } 50 | 51 | export function loadDocs(): Operation { 52 | return resource(function* (provide) { 53 | let loaders: Map> | undefined = undefined; 54 | 55 | let scope = yield* useScope(); 56 | 57 | function* load() { 58 | let tasks = new Map>(); 59 | let entries = Object.entries(structure); 60 | 61 | let topics: Topic[] = []; 62 | 63 | for (let [name, contents] of entries) { 64 | let topic: Topic = { name, items: [] }; 65 | topics.push(topic); 66 | 67 | let current: DocMeta | undefined = void (0); 68 | for (let i = 0; i < contents.length; i++) { 69 | let prev: DocMeta | undefined = current; 70 | let [filename, title] = contents[i]; 71 | let meta: DocMeta = current = { 72 | id: basename(filename, ".mdx"), 73 | title, 74 | filename, 75 | topics, 76 | prev, 77 | }; 78 | if (prev) { 79 | prev.next = current; 80 | } 81 | topic.items.push(current); 82 | 83 | tasks.set( 84 | meta.id, 85 | scope.run(function* () { 86 | let location = new URL(filename, import.meta.url); 87 | let source = yield* call(Deno.readTextFile(location)); 88 | let mod = yield* call(evaluate(source, { 89 | jsx, 90 | jsxs, 91 | jsxDEV: jsx, 92 | Fragment, 93 | remarkPlugins: [ 94 | remarkGfm, 95 | ], 96 | rehypePlugins: [ 97 | [rehypePrismPlus, { showLineNumbers: true }], 98 | ], 99 | })); 100 | 101 | return { 102 | ...meta, 103 | MDXContent: () => mod.default({}), 104 | } as Doc; 105 | }), 106 | ); 107 | } 108 | } 109 | return tasks; 110 | } 111 | 112 | yield* provide({ 113 | *all() { 114 | if (!loaders) { 115 | loaders = yield* load(); 116 | } 117 | return yield* all([...loaders.values()]); 118 | }, 119 | *getDoc(id) { 120 | if (id) { 121 | if (!loaders) { 122 | loaders = yield* load(); 123 | } 124 | let task = loaders.get(id); 125 | if (task) { 126 | return yield* task; 127 | } 128 | } 129 | }, 130 | }); 131 | }); 132 | } 133 | -------------------------------------------------------------------------------- /docs/errors.mdx: -------------------------------------------------------------------------------- 1 | We have previously discussed how correctness and proper handling of failure 2 | cases is why we wrote Effection in the first place. In this chapter we will 3 | take a more in-depth look at how Effection handles failures and how you can 4 | react to failure conditions. 5 | 6 | ## Tasks as Promises 7 | 8 | Whenever you call `run` or use the `spawn` operation, Effection 9 | creates a [`Task`][task] for you. This value is a handle that lets you 10 | both respond to the operation's outcome as well as stop its execution. 11 | 12 | As we have seen, a Task is not only an Operation that yields the 13 | result of the operation it is running, it is also a promise that can 14 | be used to integrate Effection code into promise based or async/await 15 | code. That promise will resolve when the operation completes successfully: 16 | 17 | ``` typescript 18 | import { run, sleep } from 'effection'; 19 | 20 | async function runExample() { 21 | let value = await run(function*() { 22 | yield* sleep(1000); 23 | return "world!"; 24 | }); 25 | 26 | console.log("hello", value); 27 | } 28 | ``` 29 | 30 | By the same token, if a task's operation results in an error, then the 31 | task's promise also becomes rejected: 32 | 33 | ``` typescript 34 | import { run } from 'effection'; 35 | 36 | async function runExample() { 37 | try { 38 | await run(function*() { 39 | throw new Error('boom'); 40 | }); 41 | } catch(err) { 42 | console.log("got error", err.message) // => "got error boom" 43 | } 44 | } 45 | ``` 46 | 47 | However, when an operation is halted, it will never produce a value, 48 | nor will it ever raise an error. And, because it will produce neither 49 | a positive nor negative outcome, it is an error to `await` the result 50 | of a halted operation. 51 | 52 | When this happens, the promise is rejected with a special halt error. 53 | In this example, we show a very long running task that is stopped in the 54 | middle of its work even though it would eventually return a value if we 55 | waited long enough. 56 | 57 | ``` typescript 58 | import { run, sleep } from 'effection'; 59 | 60 | async function runExample() { 61 | // this task takes a long time to return 62 | let task = run(function*() { 63 | yield* sleep(10_000_000); 64 | return "hello world"; 65 | }); 66 | 67 | await task.halt(); 68 | 69 | try { 70 | let value = await task // 💥 throws "halted" error; 71 | 72 | // will never reach here because halted tasks do not produce values 73 | console.log(value); 74 | } catch(err) { 75 | console.log(err.message) // => "halted" 76 | } 77 | } 78 | ``` 79 | 80 | An important point is that `task.halt()` is an operation in its own 81 | right and will only fail if there is a failure in the teardown. Thus 82 | it is not an error to await the `task.halt()` operation; it is only an 83 | error to await the outcome of the operation which has been halted. 84 | 85 | ## Error propagation 86 | 87 | One of the key principles of structured concurrency is that when a child fails, 88 | the parent should fail as well. In Effection, when we spawn a task, that task 89 | becomes linked to its parent. When the child operation fails, it will 90 | also cause its parent to fail. 91 | 92 | This is similar to the intuition you've built up about how synchronous code 93 | works: if an error is thrown in a function, that error propagates up the stack 94 | and causes the entire stack to fail, until someone catches the error. 95 | 96 | One of the innovations of async/await code over plain promises and 97 | callbacks, is that you can use regular error handling with 98 | `try/catch`, instead of using special error handling constructs. This 99 | makes asynchronous code look and feel more like regular synchronous 100 | code. The same is true in Effection where we can use a regular `try/catch` 101 | to deal with errors. 102 | 103 | ``` typescript 104 | import { main, sleep } from 'effection'; 105 | 106 | function* tickingBomb() { 107 | yield* sleep(1000); 108 | throw new Error('boom'); 109 | } 110 | 111 | await main(function*() { 112 | try { 113 | yield* tickingBomb() 114 | } catch(err) { 115 | console.log("it blew up:", err.message); 116 | } 117 | }); 118 | ``` 119 | 120 | However, note that something interesting happens when we instead `spawn` the 121 | `tickingBomb` operation: 122 | 123 | ``` typescript 124 | import { main, suspend } from 'effection'; 125 | import { tickingBomb } from './ticking-bomb'; 126 | 127 | await main(function*() { 128 | yield* spawn(tickingBomb); 129 | try { 130 | yield* suspend(); // sleep forever 131 | } catch(err) { 132 | console.log("it blew up:", err.message); 133 | } 134 | }); 135 | ``` 136 | 137 | You might be surprised that we do *not* enter the catch handler here. Instead, 138 | our entire main task just fails. This is by design! We are only allowed to 139 | catch errors thrown by whatever we yield to directly, _not_ by any spawned 140 | children or resources running in the background. This makes error handling more 141 | predictable, since our catch block will not receive errors from any background 142 | task, we're better able to specify which errors we actually want to deal with. 143 | 144 | ## Error boundary 145 | 146 | If we do want to catch an error from a spawned task (or from a [Resource][]) then 147 | we need to introduce an intermediate task which allows us to bring the error into 148 | the foreground. We call this pattern an "error boundary": 149 | 150 | ``` typescript 151 | import { main, call, spawn, suspend } from 'effection'; 152 | import { tickingBomb } from './ticking-bomb'; 153 | 154 | main(function*() { 155 | try { 156 | yield* call(function*() { // error boundary 157 | yield* spawn(tickingBomb); // will blow up in the background 158 | yield* suspend(); // sleep forever 159 | }); 160 | } catch(err) { 161 | console.log("it blew up:", err.message); 162 | } 163 | }); 164 | ``` 165 | 166 | [Resource]: ./resources 167 | [task]: /api/v3/Task 168 | -------------------------------------------------------------------------------- /docs/events.mdx: -------------------------------------------------------------------------------- 1 | Asynchronous code often needs to interact with evented code. Using 2 | `async/await` this can be quite challenging. Evented code often needs 3 | to be synchronous, because the timing of when to subscribe and 4 | unsubscribe is very critical, otherwise race conditions can occur 5 | where events get missed. Effection has convenient apis to ensure that you never 6 | run into these problems. 7 | 8 | ## Single events 9 | 10 | The simplest operation for working with events that Effection provides 11 | is the [`once()`][once] operation. This operation works with any 12 | [`EventTarget`][event-target] and blocks until one of its events 13 | occurs. For example, consider that we wanted to wait for the `open` 14 | event on a `WebSocket`, we could do something like this: 15 | 16 | ``` javascript 17 | import { main, once } from 'effection'; 18 | 19 | await main(function*() { 20 | let socket = new WebSocket('ws://localhost:1234'); 21 | 22 | yield* once(socket, 'open'); 23 | 24 | console.log('socket is open!'); 25 | }); 26 | ``` 27 | 28 | The `once` operation returns the argument passed to the event handler. For 29 | example we could use this to grab the [`code`][wscode] that the socket closed 30 | with: 31 | 32 | ``` javascript 33 | import { main, once } from 'effection'; 34 | 35 | await main(function*() { 36 | let socket = new WebSocket('ws://localhost:1234'); 37 | 38 | yield* once(socket, 'open'); 39 | 40 | console.log('socket is open!'); 41 | 42 | let closeEvent = yield* once(socket, 'close'); 43 | console.log('socket closed with code', closeEvent.code); 44 | }); 45 | ``` 46 | 47 | ## Recurring events 48 | 49 | If you've been following the chapter on [streams and 50 | subscriptions](./collections), you may already have a feeling that it 51 | is not a good idea to repeatedly call `once(socket, 'message')` to 52 | grab the messages sent to a WebSocket. The risk here is that we miss 53 | messages if we're not very careful. 54 | 55 | Instead we can use [`on()`][on]. [`on()`][on] is a very convenient 56 | function which takes an [event target][event-target] and the name of 57 | an event, and returns a Stream of values corresponding to the occurences of 58 | that event. 59 | 60 | ``` javascript 61 | import { main, on, each } from 'effection'; 62 | 63 | await main(function*() { 64 | let socket = new WebSocket('ws://localhost:1234'); 65 | 66 | for (let value of yield* each(on(socket, 'message'))) { 67 | console.log('message:', message.data); 68 | yield* each.next(); 69 | } 70 | }); 71 | ``` 72 | 73 | [wscode]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 74 | [childprocess]: https://nodejs.org/api/child_process.html#child_process_class_childprocess 75 | [once]: /api/v3/once 76 | [on]: /api/v3/on 77 | [event-target]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget 78 | -------------------------------------------------------------------------------- /docs/installation.mdx: -------------------------------------------------------------------------------- 1 | Effection can be used in any JavaScript environment including Deno, Node and browsers. Effection packages are side-effect free and do not pollute your environment in any way, making it compatible with most JavaScript runtimes. 2 | If you encounter obstacles integrating with your environment, please create a [GitHub Issue in the Effection repository](https://github.com/thefrontside/effection/issues/new). 3 | 4 | ## Node.js & Browser 5 | 6 |

7 | Bundlephobia badge showing effection minified 8 | Bundlephobia badge showing effection gzipped size 9 | Bundlephobia badge showing it's treeshackable 10 |

11 | 12 | Effection is available on [NPM][npm], as well as derived registries such as [Yarn][yarn] and [UNPKG][unpkg]. It comes with TypeScript types and can be consumed as both ESM and CommonJS. 13 | 14 | ```bash 15 | # install with npm 16 | npm install effection 17 | 18 | # install with yarn 19 | yarn add effection 20 | ``` 21 | 22 | ## Deno 23 | 24 | Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: 25 | 26 | ```ts 27 | import { main } from "https://jsr.io/@effection/effection/doc"; 28 | ``` 29 | 30 | > 💡 If you're curious how we keep NPM/YARN and Deno packages in-sync, you can [checkout the blog post on how publish Deno packages to NPM.][deno-npm-publish]. 31 | 32 | [deno-npm-publish]: https://frontside.com/blog/2023-04-27-deno-is-the-easiest-way-to-author-npm-packages/ 33 | [unpkg]: https://unpkg.com/effection 34 | [yarn]: https://yarnpkg.com/package?q=effection&name=effection 35 | [npm]: https://www.npmjs.com/package/effection 36 | [bundlephobia]: https://bundlephobia.com/package/effection 37 | -------------------------------------------------------------------------------- /docs/processes.mdx: -------------------------------------------------------------------------------- 1 | Effection 3.0 does not ship with a process library out of the box, but what it 2 | _does_ do is make writing resources so easy, that you can do it yourself. 3 | 4 | Operating system processes are a perfect candidate for modeling as a resource 5 | because they match [the resource criteria](./resources) which are: 6 | 7 | 1. They are long running 8 | 2. We want to be able to interact with them while they are running. 9 | 10 | This section provides a sketch of how you might create a "command" resource for both 11 | Deno and Node that will run an operating system command, and then make sure that 12 | no matter what happens, that command is shut down properly. 13 | 14 | ```js 15 | // use-command 16 | import { action, resource, spawn, suspend, createSignal } from "effection"; 17 | import { spawn as spawnChild} from "node:child_process"; 18 | 19 | export function useCommand(...args) { 20 | return resource(function*(provide) { 21 | // spawn the OS process 22 | let proc = spawnChild(...args); 23 | 24 | let stdout = createSignal(); 25 | let stderr = createSignal(); 26 | 27 | 28 | // create a task that runs until the process finishes 29 | let exit = yield* spawn(function*() { 30 | // get the exit code 31 | let code = yield* action(function*(resolve) { 32 | proc.on("exit", resolve); 33 | try { 34 | yield* suspend(); 35 | } finally { 36 | proc.off("exit", resolve); 37 | } 38 | }); 39 | 40 | // close stdin, stdout 41 | stdout.close(); 42 | stderr.close(); 43 | 44 | // return the exitcode 45 | return code; 46 | }) 47 | 48 | // connect stdin, stdout to signals 49 | proc.stdout.on("data", stdout.send); 50 | proc.stderr.on("data", stderr.send); 51 | 52 | try { 53 | 54 | // provide the resource handle 55 | yield* provide({ exit, stdout, stderr }); 56 | 57 | } finally { 58 | proc.stdout.off("data", stdout.send); 59 | proc.stderr.off("data", stderr.send); 60 | 61 | proc.kill("SIGINT"); 62 | 63 | // wait until the process is fully exited 64 | yield* exit; 65 | } 66 | }); 67 | } 68 | ``` 69 | 70 | ## using a command 71 | 72 | We can now use this command resource to create a process and stream its stdout 73 | to the console: 74 | 75 | ```js 76 | 77 | import { main, each } from "effection"; 78 | import { useCommand } from "./use-command.mjs"; 79 | 80 | await main(function*() { 81 | let ls = yield* useCommand("ls", ["-lh"]); 82 | 83 | for (let data of yield* each(ls.stdout)) { 84 | console.log(`stdout: ${data}`); 85 | yield* each.next(); 86 | } 87 | 88 | console.log(`exit: ${yield* ls.exit}`) 89 | }); 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/resources.mdx: -------------------------------------------------------------------------------- 1 | The third fundamental Effection operation is [`resource()`][resource]. It can 2 | seem a little complicated at first, but the reason for its existence is 3 | rather simple. Sometimes there are operations which meet the following criteria: 4 | 5 | 1. They are long running 6 | 1. We want to be able to interact with them while they are running 7 | 8 | As an example, let's consider a program that creates a Socket and sends 9 | messages to it while it is open. This is fairly simple to write using regular 10 | operations like this: 11 | 12 | ``` javascript 13 | import { main, once } from 'effection'; 14 | import { Socket } from 'net'; 15 | 16 | await main(function*() { 17 | let socket = new Socket(); 18 | socket.connect(1337, '127.0.0.1'); 19 | 20 | yield* once(socket, 'connect'); 21 | 22 | socket.write('hello'); 23 | 24 | socket.close(); 25 | }); 26 | ``` 27 | 28 | This works, but there are a lot of details we need to remember in order to use 29 | the socket safely. For example, whenever we use a socket, we don't want to have 30 | to remember to close it once we're done. Instead, we'd like that to happen 31 | automatically. Our first attempt to do so might look something like this: 32 | 33 | ``` javascript 34 | import { once, suspend } from 'effection'; 35 | import { Socket } from 'net'; 36 | 37 | export function* useSocket(port, host) { 38 | let socket = new Socket(); 39 | socket.connect(port, host); 40 | 41 | yield* once(socket, 'connect'); 42 | 43 | try { 44 | yield* suspend(); 45 | return socket; 46 | } finally { 47 | socket.close(); 48 | } 49 | } 50 | ``` 51 | 52 | But when we actually try to call our `useSocket` operation, we run into a 53 | problem: because our `useSocket()` operation suspends, it will wait around 54 | forever and never return control back to our main. 55 | 56 | ``` javascript 57 | import { main } from 'effection'; 58 | import { useSocket } from './use-socket'; 59 | 60 | await main(function*() { 61 | let socket = yield* useSocket(1337, '127.0.0.1'); // blocks forever 62 | socket.write('hello'); // we never get here 63 | }); 64 | ``` 65 | 66 | Remember our criteria from before: 67 | 68 | 1. Socket is a long running process 69 | 1. We want to interact with the socket while it is running by sending messages 70 | to it 71 | 72 | This is a good use-case for using a resource operation. Let's look at how we can 73 | rewrite `useSocket()` as a resource. 74 | 75 | ``` javascript 76 | import { once, resource } from 'effection'; 77 | 78 | export function useSocket(port, host) { 79 | return resource(function* (provide) { 80 | let socket = new Socket(); 81 | socket.connect(port, host); 82 | 83 | yield* once(socket, 'connect'); 84 | 85 | try { 86 | yield* provide(socket); 87 | } finally { 88 | socket.close(); 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | Before we unpack what's going on, let's just note that how we call `useSocket()` 95 | has not changed at all, only it now works as expected! 96 | 97 | ``` javascript 98 | import { main } from 'effection'; 99 | import { useSocket } from './use-socket'; 100 | 101 | await main(function*() { 102 | let socket = yield* useSocket(1337, '127.0.0.1'); // waits for the socket to connect 103 | socket.write('hello'); // this works 104 | // once `main` finishes, the socket is closed 105 | }); 106 | ``` 107 | 108 | The body of a resource is used to *initialize* a value and make it 109 | available to the operation from which it was called. It can do any 110 | preparation it needs to, and take as long as it wants, but at some 111 | point, it has to "provide" the value back to the caller. This is done 112 | with the `provide()` function that is passed as an argument into each 113 | resource constructor. This special operation, when yielded to, passes 114 | control _back_ to the caller with our newly minted value as its result. 115 | 116 | 117 | However, its work is not done yet. The `provide()` operation will remain 118 | suspended until the resource passes out of scope, thus making sure that 119 | cleanup is guaranteed. 120 | 121 | >💡A simple mantra to repeat to yourself so that you remember how resources work 122 | > is this: resources ___provide___ values. 123 | 124 | Resources can depend on other resources, so we could use this to make a socket 125 | which sends a heart-beat every 10 seconds. 126 | 127 | ``` javascript 128 | import { main, resource, spawn } from 'effection'; 129 | import { useSocket } from './use-socket'; 130 | 131 | function useHeartSocket(port, host) { 132 | return resource(function* (provide) { 133 | let socket = yield* useSocket(port, host); 134 | 135 | yield* spawn(function*() { 136 | while (true) { 137 | yield* sleep(10_000); 138 | socket.send(JSON.stringify({ type: "heartbeat" })); 139 | } 140 | }); 141 | 142 | yield* provide(socket); 143 | }); 144 | } 145 | 146 | await main(function*() { 147 | let socket = yield* useHeartSocket(1337, '127.0.0.1'); // waits for the socket to connect 148 | socket.write({ hello: 'world' }); // this works 149 | // once `main` finishes: 150 | // 1. the heartbeat is stopped 151 | // 2. the socket is closed 152 | }); 153 | ``` 154 | 155 | The original socket is created, connected, and set up to pulse every ten 156 | seconds, and it's cleaned up just as easily as it was created. 157 | 158 | Note that the [`ensure()`][ensure] operation is an appropriate mechanism to 159 | enact cleanup within resources as well. While a matter of preference, the 160 | `useSocket()` resource above could also have been written as: 161 | 162 | ``` javascript 163 | import { ensure, once, resource } from 'effection'; 164 | 165 | export function useSocket(port, host) { 166 | return resource(function* (provide) { 167 | let socket = new Socket(); 168 | yield* ensure(() => { socket.close(); }); 169 | 170 | socket.connect(port, host); 171 | 172 | yield* once(socket, 'connect'); 173 | yield* provide(socket); 174 | } 175 | } 176 | ``` 177 | 178 | Resources allow us to create powerful, reusable abstractions which are also able 179 | to clean up after themselves. 180 | 181 | [tasks]: /docs/guides/tasks 182 | [operation]: /api/v3/Operation 183 | [resource]: /api/v3/resource 184 | [ensure]: /api/v3/ensure 185 | -------------------------------------------------------------------------------- /docs/spawn.mdx: -------------------------------------------------------------------------------- 1 | Suppose we are using the `fetchWeekDay` function from the introduction to fetch the current weekday in multiple timezones: 2 | 3 | ``` javascript 4 | import { main } from 'effection'; 5 | import { fetchWeekDay } from './fetch-week-day'; 6 | 7 | main(function*() { 8 | let dayUS = yield* fetchWeekDay('est'); 9 | let daySweden = yield* fetchWeekDay('cet'); 10 | console.log(`It is ${dayUS}, in the US and ${daySweden} in Sweden!`); 11 | }); 12 | ``` 13 | 14 | This works, but it slightly inefficient because we are running the fetches one 15 | after the other. How can we run both `fetch` operations at the same time? 16 | 17 | ## Using `async/await` 18 | 19 | If we were just using `async/await` and not using Effection, we might do 20 | something like this to fetch the dates at the same time: 21 | 22 | ``` javascript 23 | async function() { 24 | let dayUS = fetchWeekDay('est'); 25 | let daySweden = fetchWeekDay('cet'); 26 | console.log(`It is ${await dayUS}, in the US and ${await daySweden} in Sweden!`); 27 | } 28 | ``` 29 | 30 | Or we could use a combinator such as `Promise.all`: 31 | 32 | ``` javascript 33 | async function() { 34 | let [dayUS, daySweden] = await Promise.all([fetchWeekDay('est'), fetchWeekDay('cet')]); 35 | console.log(`It is ${dayUS}, in the US and ${daySweden} in Sweden!`); 36 | } 37 | ``` 38 | 39 | ## Dangling Promises 40 | 41 | This works fine as long as both fetches complete successfully, but what happens 42 | when one of them fails? Since there is no connection between the two tasks, a 43 | failure in one of them has no effect on the other. We will happily keep trying 44 | to fetch the US date, even when fetching the Swedish date has already failed! 45 | 46 | For `fetch`, the consequences of this are not so severe, the worst that happens is 47 | that we have a request which is running longer than necessary, but you can imagine 48 | that the more complex the operations we're trying to combine, the more opportunity 49 | for problems there are. 50 | 51 | We call these situations "dangling promises", and most significantly complex 52 | JavaScript applications suffer from this problem. `async/await` fundamentally does 53 | not handle cancellation very well when running multiple operations concurrently. 54 | 55 | ## With Effection 56 | 57 | How does Effection deal with this situation? If we wrote the example using 58 | Effection in the exact same way as the `async/await` example, then we will find 59 | that it doesn't behave the same: 60 | 61 | ``` javascript 62 | import { main } from 'effection'; 63 | import { fetchWeekDay } from './fetch-week-day'; 64 | 65 | main(function*() { 66 | let dayUS = fetchWeekDay('est'); 67 | let daySweden = fetchWeekDay('cet'); 68 | console.log(`It is ${yield* dayUS}, in the US and ${yield* daySweden} in Sweden!`); 69 | }); 70 | ``` 71 | 72 | This is still running one fetch after the other, and is not fetching both at 73 | the same time! 74 | 75 | To understand why, remember that unlike calling an async function to 76 | create a `Promise`, calling a generator function to create an 77 | [`Operation`][Operation] does not do anything by itself. An `Operation` is 78 | is only evaluated when passed to `yield*` or to `run()`. Therefore it isn't 79 | until we `yield*` do we actually start to fetch the dates. 80 | 81 | We could use `run` here to run our operations, and then wait for them, but this 82 | is not the correct way: 83 | 84 | ``` javascript 85 | // THIS IS NOT THE CORRECT WAY! 86 | import { main, run } from 'effection'; 87 | import { fetchWeekDay } from './fetch-week-day'; 88 | 89 | main(function*() { 90 | let dayUS = run(fetchWeekDay('est')); 91 | let daySweden = run(fetchWeekDay('cet')); 92 | console.log(`It is ${yield* dayUS}, in the US and ${yield* daySweden} in Sweden!`); 93 | }); 94 | ``` 95 | 96 | This has the same problem as our `async/await` example: a failure in one fetch 97 | has no effect on the other! 98 | 99 | ## Introducing `spawn` 100 | 101 | The `spawn` operation is Effection's solution to this problem! 102 | 103 | ``` javascript 104 | import { main, spawn } from 'effection'; 105 | import { fetchWeekDay } from './fetch-week-day'; 106 | 107 | main(function*() { 108 | let dayUS = yield* spawn(() => fetchWeekDay('est')); 109 | let daySweden = yield* spawn(() => fetchWeekDay('cet')); 110 | console.log(`It is ${yield* dayUS}, in the US and ${yield* daySweden} in Sweden!`); 111 | }); 112 | ``` 113 | 114 | Like `run` and `main`, `spawn` takes an `Operation` and returns a `Task`. The 115 | difference is that this `Task` becomes a child of the current `Task`. This 116 | means it is impossible for this task to outlive its parent. And it also means 117 | that an error in the task will cause the parent to fail. 118 | 119 | You can think of this as creating a hierarchy like this: 120 | 121 | ``` 122 | +-- main 123 | | 124 | +-- fetchWeekDay('est') 125 | | 126 | +-- fetchWeekDay('cet') 127 | ``` 128 | 129 | When `fetchWeekDay('cet')` fails, since it was spawned by `main`, it will also 130 | cause `main` to fail. When `main` fails it will make sure that none of its 131 | children outlive it, and it will `halt` all of its remaining children. We end 132 | up with a situation like this: 133 | 134 | ``` 135 | +-- main [FAILED] 136 | | 137 | +-- fetchWeekDay('est') [HALTED] 138 | | 139 | +-- fetchWeekDay('cet') [FAILED] 140 | ``` 141 | 142 | Effection tasks are tied to the lifetime of their parent, and it becomes 143 | impossible to create a task whose lifetime is undefined. Because of this, the 144 | behaviour of errors is very clearly defined. An error in a child will also 145 | cause the parent to error, which in turn halts any siblings. 146 | 147 | This idea is called [structured concurrency], and it has profound effects on 148 | the composability of concurrent code. 149 | 150 | ## Using combinators 151 | 152 | We previously showed how we can use the `Promise.all` combinator to implement 153 | the concurrent fetch. Effection also ships with some combinators, for example 154 | we can use the `all` combinator: 155 | 156 | ``` javascript 157 | import { all, main } from 'effection'; 158 | 159 | main(function *() { 160 | let [dayUS, daySweden] = yield* all([fetchWeekDay('est'), fetchWeekDay('cet')]); 161 | console.log(`It is ${dayUS}, in the US and ${daySweden} in Sweden!`); 162 | }); 163 | ``` 164 | 165 | ## Spawning in a Scope 166 | 167 | The `spawn()` operation always runs its operation as a child of the current 168 | operation. Sometimes however, you might want to run an operation as a child of a 169 | _different_ operation. To do this we can use the [`Scope#run()`][scope.run] 170 | method. 171 | 172 | This is often useful when integrating Effection into existing promise or 173 | callback based frameworks. The following example creates a trivial 174 | [`express`][express] server that runs each request as an operation that is 175 | a child of the main operation. 176 | 177 | 178 | ```javascript {5,8} 179 | import express from 'express'; 180 | import { main, suspend, useScope } from "effection"; 181 | 182 | await main(function*() { 183 | let scope = yield* useScope(); 184 | let app = express(); 185 | app.get("/", async (_, res) => { 186 | await scope.run(function*() { 187 | res.send("Hello World!"); 188 | }) 189 | }); 190 | let server = app.listen(); 191 | try { 192 | yield* suspend() 193 | } finally { 194 | server.close(); 195 | } 196 | }); 197 | ``` 198 | 199 | We capture a reference to the main operation's scope in line **(5)** 200 | and then use that scope to run the request as an async function on 201 | line **(8)**. 202 | 203 | You can learn more about this in the [scope guide](./scope). 204 | 205 | [structured concurrency]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ 206 | [express]: https://expressjs.org 207 | [scope.run]: /api/v3/Scope#method_run_0 208 | [Operation]: /api/v3/Operation 209 | -------------------------------------------------------------------------------- /docs/structure.json: -------------------------------------------------------------------------------- 1 | { 2 | "Getting Started": [ 3 | ["installation.mdx", "Installation"], 4 | ["typescript.mdx", "TypeScript"], 5 | ["thinking-in-effection.mdx", "Thinking in Effection"], 6 | ["async-rosetta-stone.mdx", "Async Rosetta Stone"], 7 | ["tutorial.mdx", "Tutorial"] 8 | ], 9 | "Learn Effection": [ 10 | ["operations.mdx", "Operations"], 11 | ["actions.mdx", "Actions and Suspensions"], 12 | ["resources.mdx", "Resources"], 13 | ["spawn.mdx", "Spawn"], 14 | ["collections.mdx", "Streams and Subscriptions"], 15 | ["events.mdx", "Events"], 16 | ["errors.mdx", "Error Handling"], 17 | ["context.mdx", "Context"] 18 | ], 19 | "Advanced": [ 20 | ["scope.mdx", "Scope"], 21 | ["processes.mdx", "Processes"] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /docs/thinking-in-effection.mdx: -------------------------------------------------------------------------------- 1 | When we say that Effection is "Structured Concurrency and Effects for Javascript" we mean three things: 2 | 3 | 1. No operation runs longer than its parent. 4 | 2. Every operation exits fully. 5 | 3. It's just JavaScript, and except for the guarantees derived from (1) and (2), it should feel familiar in every other 6 | way. 7 | 8 | Developing a new intuition about how to leverage Structured Concurrency, while leaning on your existing intuition as a 9 | JavaScript developer will help you get the most out of Effection and have you attempting things that you would never 10 | have even dreamed before. 11 | 12 | ## No operation runs longer than its parent. 13 | 14 | In JavaScript, developers rarely have to think about memory allocation because memory lifetime is bound to the scope of 15 | the function that it was allocated for. When a function is finished, its scope is torn down and all of the memory 16 | allocated to variables in function's scope are released safely. Binding memory management to scope gives developers the 17 | freedom to focus on their application instead of worrying about leaking memory. 18 | 19 | Structured concurrency establishes the same relationship between scope and asynchrony. Every Effection operation is 20 | bound to the lifetime of its parent. Because of this, Effection automatically tears down 21 | child operations when the parent operation completes or is halted. Like with memory management, binding asynchrony to 22 | about the scope frees developers to focus on writing their applications instead of worrying about where and when to run 23 | asynchronous cleanup. 24 | 25 | The key to achieving this freedom is to make the following mental shift about the natural lifetime of an asynchronous 26 | operation. 27 | 28 | **before** 29 | 30 | > An asynchronous operation will run as long as it needs to 31 | 32 | **after** 33 | 34 | > An asynchronous operation runs only as long as it's needed. 35 | 36 | Effection provides this shift. Whenever an operation completes, none of its child operations are left 37 | around to pollute your runtime. 38 | 39 | ## Every operation exits fully. 40 | 41 | We expect synchronous functions to run completely from start to finish. 42 | 43 | ```js {5} showLineNumbers 44 | function main() { 45 | try { 46 | fn() 47 | } finally { 48 | // code here is GUARANTEED to run 49 | } 50 | } 51 | ``` 52 | 53 | Knowing this makes code predictable. Developers can be confident that their functions will either return a result or 54 | throw an error. You can wrap a synchronous function in a `try/catch/finally` block to handle thrown errors. The 55 | `finally` block can be used to perform clean up after completion. 56 | 57 | However, the same guarantee is not provided for async functions. 58 | 59 | ```js {5} showLineNumbers 60 | async function main() { 61 | try { 62 | await new Promise((resolve) => setTimeout(resolve, 100,000)); 63 | } finally { 64 | // code here is NOT GUARANTEED to run 65 | } 66 | } 67 | 68 | await main(); 69 | ``` 70 | 71 | Once an async function begins execution, the code in its `finally{}` blocks may never get 72 | a chance to run, and as a result, it is difficult to write code that always cleans up after itself. 73 | 74 | This hard limitation of the JavaScript runtime is called the [Await Event Horizon][await-event-horizon], 75 | and developers experience its impact on daily basis. For example, the very common [EADDRINUSE][eaddrinuse-error] 76 | error is caused by a caller not being able to execute clean up when a Node.js process is stopped. 77 | 78 | By contrast, Effection _does_ provide this guarantee. 79 | 80 | ```js {7} showLineNumbers 81 | import { main, action } from "effection"; 82 | 83 | await main(function*() { 84 | try { 85 | yield* action(function*(resolve) { setTimeout(resolve, 100,000) }); 86 | } finally { 87 | // code here is GUARANTEED to run 88 | } 89 | }); 90 | ``` 91 | 92 | When executing Effection operations you can expect that 93 | they will run to completion; giving every operation an opportunity to clean up. At first glance, this 94 | might seem like a small detail but it's fundamental to writing composable code. 95 | 96 | ## It's just JavaScript 97 | 98 | Effection is designed to provide Structured Concurrency guarantees using common JavaScript language constructs such as 99 | `let`, `const`, `if`, `for`, `while`, `switch` and `try/catch/finally`. Our goal is to allow JavaScript developers to 100 | leverage what they already know while gaining the guarantees of Structured Concurrency. You can use all of these 101 | constructs in an Effection function and they'll behave as you'd expect. 102 | 103 | However, one of the constructs we avoid in Effection is the syntax and semantics of `async/await`. 104 | This is because `async/await` is [not capable of modeling structured concurrency][await-event-horizon]. Instead, we make 105 | extensive use of [generator functions][generator-functions] which are a core feature of JavaScript and are supported by all browsers 106 | and JavaScript runtimes. 107 | 108 | Finally, we provide a handy Effection Rosetta Stone to show how _Async/Await_ concepts map into Effection APIs. 109 | 110 | [generator-functions]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* 111 | [await-event-horizon]: https://frontside.com/blog/2023-12-11-await-event-horizon/ 112 | [delimited-continuation-repo]: https://github.com/thefrontside/continuation 113 | [eaddrinuse-error]: https://stackoverflow.com/questions/14790910/stop-all-instances-of-node-js-server 114 | -------------------------------------------------------------------------------- /docs/tutorial.mdx: -------------------------------------------------------------------------------- 1 | Let's write our first program using Effection. 2 | 3 | ``` javascript 4 | import { until, useAbortSignal } from "effection"; 5 | 6 | export function* fetchWeekDay(timezone) { 7 | let signal = yield* useAbortSignal(); 8 | 9 | let response = yield* until(fetch(`http://worldclockapi.com/api/json/${timezone}/now`, { signal })); 10 | 11 | let time = yield* until(response.json()); 12 | 13 | return time.dayOfTheWeek; 14 | } 15 | ``` 16 | 17 | To start using Effection, use the `main` function as an entry 18 | point. In this example, we'll use the previously defined 19 | `fetchWeekDay`. 20 | 21 | ```javascript 22 | import { main } from 'effection'; 23 | import { fetchWeekDay } from './fetch-week-day'; 24 | 25 | await main(function*() { 26 | 27 | let dayOfTheWeek = yield* fetchWeekDay('est'); 28 | 29 | console.log(`It is ${dayOfTheWeek}, my friends!`); 30 | }); 31 | ``` 32 | 33 | Even with such a simple program, Effection is still providing critical 34 | power-ups that you don't get with callbacks, 35 | promises, or `async/await`. For example, notice how the Effection 36 | `fetchWeekDay()` operation does not accept an abort signal as a second argument. 37 | That's because there is no need to manually wire up any signal handlers and 38 | trigger an `AbortController` at the right time. 39 | 40 | If you run the above code in NodeJS and hit `CTRL-C` while the request 41 | to `http://worldclockapi.com` is still in progress, it will properly 42 | cancel the in-flight request as a well-behaved HTTP client should. All 43 | without you ever having to think about it. This is because every 44 | Effection operation contains the information on how to dispose of 45 | itself, and so the actual act of cancellation can be automated. 46 | 47 | This has powerful consequences when it comes to composing new 48 | operations out of existing ones. For example, we can add a time out of 49 | 1000 milliseconds to our `fetchWeekDay` operation (or any operation 50 | for that matter) by wrapping it in a `withTimeout` operation. 51 | 52 | ```javascript 53 | import { main, until, race } from 'effection'; 54 | import { fetchWeekDay } from './fetch-week-day'; 55 | 56 | await main(function*() { 57 | let dayOfTheWeek = yield* withTimeout(fetchWeekDay('est'), 1000); 58 | console.log(`It is ${dayOfTheWeek}, my friends!`); 59 | }); 60 | 61 | function withTimeout(operation, delay) { 62 | return race([operation, until(function*() { 63 | yield* sleep(delay); 64 | throw new Error(`timeout!`); 65 | })]); 66 | } 67 | ``` 68 | 69 | If more than 1000 milliseconds passes before the `fetchWeekDay()` 70 | operation completes, then an error will be raised. 71 | 72 | What's important to note however, is that when we actually defined our 73 | `fetchWeekDay()` operation, we never once had to worry about timeouts, 74 | or request cancellation. And in order to achieve both we didn't have 75 | to gum up our API by passing around cancellation tokens or [abort 76 | controllers][abort controller]. We just got it all for free. 77 | 78 | ## Discover more 79 | 80 | This is just the tip of the iceberg when it comes to the seemingly complex 81 | things that Effection can make simple. To find out more, jump 82 | into the conversation in our [Discord server][discord]. We're really 83 | excited about the things that Effection has enabled us to accomplish, 84 | and we'd love to hear your thoughts on it, and how you might see 85 | it working for you. 86 | 87 | [abort controller]: https://developer.mozilla.org/en-US/docs/Web/API/AbortController 88 | [discord]: https://discord.gg/Ug5nWH8 89 | -------------------------------------------------------------------------------- /docs/typescript.mdx: -------------------------------------------------------------------------------- 1 | Effection is written in TypeScript and comes bundled with its own type 2 | definitions. As such, it doesn't require any special setup to use in a 3 | TypeScript project. This section only contains some helpful hints to make 4 | the most out of the Effection related typings in your project. 5 | 6 | ## TL;DR 7 | 8 | Use the `Operation` type for your operations 9 | 10 | ```ts 11 | import type { Operation } from "effection"; 12 | 13 | export function* op(): Operation { 14 | yield* sleep(10); 15 | return 5; 16 | } 17 | ``` 18 | 19 | If you see a weird type like `Generator` or 20 | `Iterable` or `IterableIterator`, 21 | replace it with `Operation`. 22 | 23 | ## Use Operations Everywhere 24 | 25 | The foundation of Effection is the [`Operation`][operation] type. This is the type that 26 | it uses internally to represent all actions and resources, and it is the type 27 | that you should both consume and return from your own functions. 28 | For the most part, TypeScript will infer this for you, and you don't need 29 | to worry about it. However, there are some cases you may want to give it a hint 30 | that what you have is an operation, even though it will work without it. 31 | This is because `Operation` is effectively an 32 | `Iterable`. This means that when you create this 33 | shape naturally in your code, then it will correctly slot into other 34 | operations, but it might be confusing when people look at types in 35 | their IDE. Take the following generator function: 36 | 37 | ```typescript 38 | function* op() { 39 | yield* sleep(10); 40 | return 5; 41 | } 42 | ``` 43 | 44 | The natural inferred return type of this function is `Generator` which 45 | satisfies `Iterable` and so can be used as an operation, 46 | but it loses the higher-level intent of the operation. 47 | Instead, write `op()` like this: 48 | ```typescript 49 | function* op(): Operation { 50 | yield* sleep(10); 51 | return 5; 52 | } 53 | ``` 54 | By pinning the return type to `Operation`, it communicates the intent 55 | of how the generator is to be used beyond just the literal shape of the 56 | generator itself. 57 | 58 | The same applies for Operation values: 59 | 60 | ```ts 61 | const op = { 62 | *[Symbol.iterator]() { 63 | yield* sleep(10); 64 | return 5; 65 | } 66 | } 67 | ``` 68 | Strictly speaking the type of `op` is: 69 | ```ts 70 | { 71 | [Symbol.iterator](): Generator 72 | } 73 | ``` 74 | 75 | While this is technically correct, and it will work if you use it as 76 | an operation, it isn't particularly helpful to your end users. Instead, give 77 | them a leg up, and make their editor's tooltip show that they can evaluate 78 | it and expect to receive a number. 79 | 80 | ```ts 81 | const op: Operation = { 82 | *[Symbol.iterator]() { 83 | yield* sleep(10); 84 | return 5; 85 | } 86 | } 87 | ``` 88 | 89 | Now anybody using your operation won't have any doubt about what it is and how 90 | they can use it. 91 | 92 | [operation]: /api/v3/Operation 93 | -------------------------------------------------------------------------------- /lib/abort-signal.ts: -------------------------------------------------------------------------------- 1 | import type { Operation } from "./types.ts"; 2 | import { resource } from "./instructions.ts"; 3 | 4 | /** 5 | * Create an 6 | * [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) 7 | * bound to the current scope. Whenever that scope is completed, 8 | * errored, or halted, the abort signal will be triggered. 9 | * 10 | * Cancellation and teardown is handled automatically in Effection, 11 | * but in systems where it is not, the lifespan of a transaction needs 12 | * to be explicitly modeled. A very common way to do this is with the 13 | * `AbortSignal`. By creating an abort signal bound to an Effection 14 | * scope, we can pass that signal down to 3rd party consumers that can 15 | * use it to register shutdown callbacks that will be invoked whenever 16 | * the task is done (either by completion, failure, or 17 | * cancellation). An example is the native 18 | * [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch#syntax) 19 | * API which takes a `signal` parameter in order to trigger when the 20 | * HTTP request should be cancelled. This is how you would bind the 21 | * lifetime of the HTTP request to the lifetime of the current task. 22 | * 23 | * @example 24 | * ```javascript 25 | * function* request() { 26 | * let signal = yield* useAbortSignal(); 27 | * return yield* fetch('/some/url', { signal }); 28 | * } 29 | * ``` 30 | */ 31 | export function useAbortSignal(): Operation { 32 | return resource(function* AbortSignal(provide) { 33 | let controller = new AbortController(); 34 | try { 35 | yield* provide(controller.signal); 36 | } finally { 37 | controller.abort(); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /lib/all.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Task, Yielded } from "./types.ts"; 2 | import { spawn } from "./instructions.ts"; 3 | import { call } from "./call.ts"; 4 | 5 | /** 6 | * Block and wait for all of the given operations to complete. Returns 7 | * an array of values that the given operations evaluated to. This has 8 | * the same purpose as 9 | * [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all). 10 | * 11 | * If any of the operations become errored, then `all` will also become errored. 12 | * 13 | * ### Example 14 | * 15 | * ``` javascript 16 | * import { all, expect, main } from 'effection'; 17 | * 18 | * await main(function*() { 19 | * let [google, bing] = yield* all([ 20 | * expect(fetch('http://google.com')), 21 | * expect(fetch('http://bing.com')), 22 | * ]); 23 | * // ... 24 | * }); 25 | * ``` 26 | * 27 | * @param ops a list of operations to wait for 28 | * @returns the list of values that the operations evaluate to, in the order they were given 29 | */ 30 | export function all[] | []>( 31 | ops: T, 32 | ): Operation> { 33 | return call(function* () { 34 | let tasks: Task[] = []; 35 | for (let operation of ops) { 36 | tasks.push(yield* spawn(() => operation)); 37 | } 38 | let results = []; 39 | for (let task of tasks) { 40 | results.push(yield* task); 41 | } 42 | return results as All; 43 | }); 44 | } 45 | 46 | /** 47 | * This type allows you to infer heterogenous operation types. 48 | * e.g. `all([sleep(0), expect(fetch("https://google.com")])` 49 | * will have a type of `Operation<[void, Request]>` 50 | */ 51 | 52 | type All[] | []> = { 53 | -readonly [P in keyof T]: Yielded; 54 | }; 55 | -------------------------------------------------------------------------------- /lib/async.ts: -------------------------------------------------------------------------------- 1 | import type { Stream, Subscription } from "./types.ts"; 2 | 3 | import { call } from "./call.ts"; 4 | 5 | /** 6 | * Convert any [`AsyncIterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator) into an effection {@link Subscription} 7 | * 8 | * This allows you to consume any `AsyncIterator` as a {@link Subscription}. 9 | * 10 | * @param iter - the iterator to convert 11 | * @returns a subscription that will produce each item of `iter` 12 | */ 13 | export function subscribe(iter: AsyncIterator): Subscription { 14 | return { 15 | next: () => call(() => iter.next()), 16 | }; 17 | } 18 | 19 | /** 20 | * Convert any [`AsyncIterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols) into an Effection {@link Stream}. 21 | * 22 | * This allows you to consume any `AsyncIterable` as a {@link Stream}. 23 | * 24 | * @param iterable - the async iterable to convert 25 | * @returns a stream that will produce each item of `iterable` 26 | */ 27 | export function stream(iterable: AsyncIterable): Stream { 28 | return { 29 | *[Symbol.iterator]() { 30 | return subscribe(iterable[Symbol.asyncIterator]()); 31 | }, 32 | }; 33 | } 34 | 35 | interface AsyncIterable { 36 | [Symbol.asyncIterator](): AsyncIterator; 37 | } 38 | -------------------------------------------------------------------------------- /lib/call.ts: -------------------------------------------------------------------------------- 1 | import type { Instruction, Operation } from "./types.ts"; 2 | import { action } from "./instructions.ts"; 3 | import { pause } from "./pause.ts"; 4 | 5 | /** 6 | * A uniform integration type representing anything that can be evaluated 7 | * as a the parameter to {@link call}. 8 | * 9 | * {@link call} converts a `Callable` into an `Operation` which can then be used 10 | * anywhere within Effection. 11 | * 12 | * APIs that accept `Callable` values allow end developers to pass simple 13 | * functions without necessarily needing to know anything about Operations. 14 | * 15 | * ```javascript 16 | * function hello(to: Callable): Operation { 17 | * return function*() { 18 | * return `hello ${yield* call(to)}`; 19 | * } 20 | * } 21 | * 22 | * await run(() => hello(() => "world!")); // => "hello world!" 23 | * await run(() => hello(async () => "world!")); // => "hello world!" 24 | * await run(() => hello(function*() { return "world!" })); "hello world!"; 25 | * ``` 26 | */ 27 | export type Callable = 28 | | Operation 29 | | Promise 30 | | (() => Operation) 31 | | (() => Promise) 32 | | (() => T); 33 | 34 | /** 35 | * Pause the current operation, then run an async function, or operation function in a new scope. The calling operation will be resumed (or errored) 36 | * once call is completed. 37 | * 38 | * `call()` is a uniform integration point for calling async functions, 39 | * and generator functions. 40 | * 41 | * It can be used to invoke an async function: 42 | * 43 | * @example 44 | * ```typescript 45 | * async function* googleSlowly() { 46 | * return yield* call(async function() { 47 | * await new Promise(resolve => setTimeout(resolve, 2000)); 48 | * return await fetch("https://google.com"); 49 | * }); 50 | * } 51 | * ``` 52 | * 53 | * It can be used to run an operation in a separate scope to ensure that any 54 | * resources allocated will be cleaned up: 55 | * 56 | * @example 57 | * ```javascript 58 | * yield* call(function*() { 59 | * let socket = yield* useSocket(); 60 | * return yield* socket.read(); 61 | * }); // => socket is destroyed before returning 62 | * ``` 63 | * 64 | * Because `call()` runs within its own {@link Scope}, it can also be used to 65 | * establish [error boundaries](https://frontside.com/effection/docs/errors). 66 | * 67 | * @example 68 | * ```javascript 69 | * function* myop() { 70 | * let task = yield* spawn(function*() { 71 | * throw new Error("boom!"); 72 | * }); 73 | * yield* task; 74 | * } 75 | * 76 | * function* runner() { 77 | * try { 78 | * yield* myop(); 79 | * } catch (err) { 80 | * // this will never get hit! 81 | * } 82 | * } 83 | * 84 | * function* runner() { 85 | * try { 86 | * yield* call(myop); 87 | * } catch(err) { 88 | * // properly catches `spawn` errors! 89 | * } 90 | * } 91 | * ``` 92 | * 93 | * @param callable the operation, promise, async function, generator funnction, or plain function to call as part of this operation 94 | */ 95 | export function call(callable: () => Operation): Operation; 96 | export function call(callable: () => Promise): Operation; 97 | 98 | /** 99 | * @deprecated Using call with simple functions will be removed in v4. 100 | * To convert simple functions into operations, use @{link lift}. 101 | */ 102 | export function call(callable: () => T): Operation; 103 | 104 | /** 105 | * @deprecated calling bare promises, operations, and constants will 106 | * be removed in v4 use {@link until} instead 107 | * 108 | * before: call(operation); 109 | * after: until(operation); 110 | */ 111 | export function call(callable: Operation): Operation; 112 | 113 | /** 114 | * @deprecated calling bare promises, operations, and constants will 115 | * be removed in v4, always pass a function to call() 116 | * 117 | * before: call(promise); 118 | * after: call(() => promise); 119 | */ 120 | export function call(callable: Promise): Operation; 121 | 122 | export function call(callable: Callable): Operation { 123 | return action(function* (resolve, reject) { 124 | try { 125 | if (typeof callable === "function") { 126 | let fn = callable as () => Operation | Promise | T; 127 | resolve(yield* toop(fn())); 128 | } else { 129 | resolve(yield* toop(callable)); 130 | } 131 | } catch (error) { 132 | reject(error as Error); 133 | } 134 | }); 135 | } 136 | 137 | function toop( 138 | op: Operation | Promise | T, 139 | ): Operation { 140 | if (isPromise(op)) { 141 | return expect(op); 142 | } else if (isIterable(op)) { 143 | let iter = op[Symbol.iterator](); 144 | if (isInstructionIterator(iter)) { 145 | // operation 146 | return op; 147 | } else { 148 | // We are assuming that if an iterator does *not* have `.throw` then 149 | // it must be a built-in iterator and we should return the value as-is. 150 | return bare(op as T); 151 | } 152 | } else { 153 | return bare(op as T); 154 | } 155 | } 156 | 157 | function bare(val: T): Operation { 158 | return { 159 | [Symbol.iterator]() { 160 | return { next: () => ({ done: true, value: val }) }; 161 | }, 162 | }; 163 | } 164 | 165 | function expect(promise: Promise): Operation { 166 | return pause((resolve, reject) => { 167 | promise.then(resolve, reject); 168 | return () => {}; 169 | }); 170 | } 171 | 172 | function isFunc(f: unknown): f is (...args: unknown[]) => unknown { 173 | return typeof f === "function"; 174 | } 175 | 176 | function isPromise(p: unknown): p is Promise { 177 | if (!p) return false; 178 | return isFunc((p as Promise).then); 179 | } 180 | 181 | // iterator must implement both `.next` and `.throw` 182 | // built-in iterators are not considered iterators to `call()` 183 | function isInstructionIterator(it: unknown): it is Iterator { 184 | if (!it) return false; 185 | return isFunc((it as Iterator).next) && 186 | isFunc((it as Iterator).throw); 187 | } 188 | 189 | function isIterable(it: unknown): it is Iterable { 190 | if (!it) return false; 191 | return typeof (it as Iterable)[Symbol.iterator] === "function"; 192 | } 193 | 194 | /** 195 | * It can be used to treat a promise as an operation. This function 196 | * is a replacement to the deprecated `call(promise)` function form. 197 | * 198 | * @example 199 | * ```js 200 | * let response = yield* until(fetch('https://google.com')); 201 | * ``` 202 | * @template {T} 203 | * @param promise 204 | * @returns {Operation} 205 | */ 206 | export function until(promise: PromiseLike): Operation { 207 | return call(async () => await promise); 208 | } 209 | -------------------------------------------------------------------------------- /lib/channel.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Stream } from "./types.ts"; 2 | import { createSignal } from "./signal.ts"; 3 | import { lift } from "./lift.ts"; 4 | 5 | /** 6 | * A broadcast channel that multiple consumers can subscribe to the 7 | * via the same {@link Stream}, and messages sent to the channel are 8 | * received by all consumers. The channel is not buffered, so if there 9 | * are no consumers, the message is dropped. 10 | */ 11 | export interface Channel extends Stream { 12 | /** 13 | * Send a message to all subscribers of this {@link Channel} 14 | */ 15 | send(message: T): Operation; 16 | 17 | /** 18 | * End every subscription to this {@link Channel} 19 | */ 20 | close(value: TClose): Operation; 21 | } 22 | 23 | /** 24 | * Create a new {@link Channel}. Use channels to communicate between operations. 25 | * In order to dispatch messages from outside an operation such as from a 26 | * callback, use {@link Signal}. 27 | * 28 | * See [the guide on Streams and 29 | * Subscriptions](https://frontside.com/effection/docs/guides/collections) 30 | * for more details. 31 | * 32 | * @example 33 | * 34 | * ``` javascript 35 | * import { main, createChannel } from 'effection'; 36 | * 37 | * await main(function*() { 38 | * let channel = createChannel(); 39 | * 40 | * yield* channel.send('too early'); // the channel has no subscribers yet! 41 | * 42 | * let subscription1 = yield* channel; 43 | * let subscription2 = yield* channel; 44 | * 45 | * yield* channel.send('hello'); 46 | * yield* channel.send('world'); 47 | * 48 | * console.log(yield* subscription1.next()); //=> { done: false, value: 'hello' } 49 | * console.log(yield* subscription1.next()); //=> { done: false, value: 'world' } 50 | * console.log(yield* subscription2.next()); //=> { done: false, value: 'hello' } 51 | * console.log(yield* subscription2.next()); //=> { done: false, value: 'world' } 52 | * }); 53 | * ``` 54 | */ 55 | export function createChannel(): Channel { 56 | let signal = createSignal(); 57 | 58 | return { 59 | send: lift(signal.send), 60 | close: lift(signal.close), 61 | [Symbol.iterator]: signal[Symbol.iterator], 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /lib/context.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Operation } from "./types.ts"; 2 | import { create } from "./run/create.ts"; 3 | import { useScope } from "./run/scope.ts"; 4 | 5 | export function createContext(key: string, defaultValue?: T): Context { 6 | let context: Context = create>(`Context`, { key }, { 7 | defaultValue, 8 | *get() { 9 | let scope = yield* useScope(); 10 | return scope.get(context); 11 | }, 12 | *set(value: T) { 13 | let scope = yield* useScope(); 14 | return scope.set(context, value); 15 | }, 16 | expect, 17 | *with(value: T, operation: (value: T) => Operation): Operation { 18 | let scope = yield* useScope(); 19 | let original = scope.hasOwn(context) ? scope.get(context) : undefined; 20 | try { 21 | return yield* operation(scope.set(context, value)); 22 | } finally { 23 | if (typeof original === "undefined") { 24 | scope.delete(context); 25 | } else { 26 | scope.set(context, original); 27 | } 28 | } 29 | }, 30 | [Symbol.iterator]() { 31 | console.warn( 32 | `⚠️ using a context (${key}) directly as an operation is deprecated. Use context.expect() instead`, 33 | ); 34 | context[Symbol.iterator] = expect; 35 | return expect(); 36 | }, 37 | }); 38 | 39 | function* expect() { 40 | let value = yield* context.get(); 41 | if (typeof value === "undefined") { 42 | throw new MissingContextError(`missing required context: '${key}'`); 43 | } else { 44 | return value; 45 | } 46 | } 47 | 48 | return context; 49 | } 50 | 51 | class MissingContextError extends Error { 52 | override name = "MissingContextError"; 53 | } 54 | -------------------------------------------------------------------------------- /lib/deps.ts: -------------------------------------------------------------------------------- 1 | export { assert } from "jsr:@std/assert@1.0.10"; 2 | export * from "jsr:@frontside/continuation@0.1.6"; 3 | -------------------------------------------------------------------------------- /lib/each.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Stream, Subscription } from "./types.ts"; 2 | import { createContext } from "./context.ts"; 3 | import { useScope } from "./run/scope.ts"; 4 | import { resource, spawn } from "./instructions.ts"; 5 | import { withResolvers } from "./with-resolvers.ts"; 6 | 7 | /** 8 | * Consume an effection stream using a simple for-of loop. 9 | * 10 | * Given any stream, you can access its values sequentially using the `each()` 11 | * operation just as you would use `for await of` loop with an async iterable: 12 | * 13 | * ```javascript 14 | * function* logvalues(stream) { 15 | * for (let value of yield* each(stream)) { 16 | * console.log(value); 17 | * yield* each.next() 18 | * } 19 | * } 20 | * ``` 21 | * You must always invoke `each.next` at the end of each iteration of the loop, 22 | * including if the interation ends with a `continue` statement. 23 | * 24 | * Note that just as with async iterators, there is no way to consume the 25 | * `TClose` value of a stream using the `for-each` loop. 26 | * 27 | * @typeParam T - the type of each value in the stream. 28 | * @param stream - the stream to iterate 29 | * @returns an operation to iterate `stream` 30 | */ 31 | export function each(stream: Stream): Operation> { 32 | return { 33 | *[Symbol.iterator]() { 34 | let scope = yield* useScope(); 35 | let stack = scope.hasOwn(EachStack) 36 | ? scope.expect(EachStack) 37 | : yield* EachStack.set([]); 38 | 39 | let loop = yield* resource>(function* (provide) { 40 | let subscription = yield* stream; 41 | let current = yield* subscription.next(); 42 | let { operation: finished, resolve: finish } = withResolvers(); 43 | 44 | yield* spawn(() => provide({ current, subscription, finish })); 45 | 46 | yield* finished; 47 | }); 48 | 49 | stack.push(loop); 50 | 51 | let iterator: Iterator = { 52 | next() { 53 | if (loop.stale) { 54 | let error = new Error( 55 | `for each loop did not use each.next() operation before continuing`, 56 | ); 57 | error.name = "IterationError"; 58 | throw error; 59 | } else { 60 | loop.stale = true; 61 | return loop.current; 62 | } 63 | }, 64 | return() { 65 | stack.pop(); 66 | loop.finish(); 67 | return { done: true, value: void 0 }; 68 | }, 69 | }; 70 | 71 | return { 72 | [Symbol.iterator]: () => iterator, 73 | }; 74 | }, 75 | }; 76 | } 77 | 78 | each.next = function next(): Operation { 79 | return { 80 | name: "each.next()", 81 | *[Symbol.iterator]() { 82 | let stack = yield* EachStack.expect(); 83 | let context = stack[stack.length - 1]; 84 | if (!context) { 85 | let error = new Error(`cannot call next() outside of an iteration`); 86 | error.name = "IterationError"; 87 | throw error; 88 | } 89 | let current = yield* context.subscription.next(); 90 | delete context.stale; 91 | context.current = current; 92 | if (current.done) { 93 | stack.pop(); 94 | } 95 | }, 96 | } as Operation; 97 | }; 98 | 99 | interface EachLoop { 100 | subscription: Subscription; 101 | current: IteratorResult; 102 | finish(): void; 103 | stale?: true; 104 | } 105 | 106 | const EachStack = createContext[]>("each"); 107 | -------------------------------------------------------------------------------- /lib/ensure.ts: -------------------------------------------------------------------------------- 1 | import type { Operation } from "./types.ts"; 2 | import { resource } from "./instructions.ts"; 3 | 4 | /** 5 | * Run the given function or operation when the current operation 6 | * shuts down. This is equivalent to running the function or operation 7 | * in a `finally {}` block, but it can help you avoid rightward drift. 8 | * 9 | * @example 10 | * 11 | * ```javascript 12 | * import { main, ensure } from 'effection'; 13 | * import { createServer } from 'http'; 14 | * 15 | * await main(function*() { 16 | * let server = createServer(...); 17 | * yield* ensure(() => { server.close() }); 18 | * }); 19 | * ``` 20 | * 21 | * Note that you should wrap the function body in braces, so the function 22 | * returns `undefined`. 23 | * 24 | * @example 25 | * 26 | * ```javascript 27 | * import { main, ensure, once } from 'effection'; 28 | * import { createServer } from 'http'; 29 | * 30 | * await main(function*() { 31 | * let server = createServer(...); 32 | * yield* ensure(function* () { 33 | * server.close(); 34 | * yield* once(server, 'closed'); 35 | * }); 36 | * }); 37 | * ``` 38 | * 39 | * Your ensure function should return an operation whenever you need to do 40 | * asynchronous cleanup. Otherwise, you can return `void` 41 | * 42 | * @param fn - a function which returns an {@link Operation} or void 43 | */ 44 | export function ensure(fn: () => Operation | void): Operation { 45 | return resource(function* (provide) { 46 | try { 47 | yield* provide(); 48 | } finally { 49 | let result = fn(); 50 | if (result && typeof result[Symbol.iterator] === "function") { 51 | yield* result; 52 | } 53 | } 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /lib/events.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any ban-types 2 | import { createSignal } from "./signal.ts"; 3 | import { resource } from "./instructions.ts"; 4 | import type { Operation, Stream, Subscription } from "./types.ts"; 5 | 6 | type FN = (...any: any[]) => any; 7 | 8 | type EventTypeFromEventTarget = `on${K}` extends keyof T 9 | ? Parameters>[0] 10 | : Event; 11 | 12 | /** 13 | * @ignore 14 | */ 15 | export type EventList = T extends { 16 | addEventListener(type: infer P, ...args: any): void; 17 | // we basically ignore this but we need it so we always get the first override of addEventListener 18 | addEventListener(type: infer P2, ...args: any): void; 19 | } ? P & string 20 | : never; 21 | 22 | /** 23 | * Create an {@link Operation} that yields the next event to be emitted by an 24 | * [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget). 25 | * 26 | * @param target - the event target to be watched 27 | * @param name - the name of the event to watch. E.g. "click" 28 | * @returns an Operation that yields the next emitted event 29 | */ 30 | export function once< 31 | T extends EventTarget, 32 | K extends EventList | (string & {}), 33 | >(target: T, name: K): Operation> { 34 | return { 35 | *[Symbol.iterator]() { 36 | let subscription = yield* on(target, name); 37 | let next = yield* subscription.next(); 38 | return next.value; 39 | }, 40 | }; 41 | } 42 | 43 | /** 44 | * Create a {@link Stream} of events from any 45 | * [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget). 46 | * 47 | * See the guide on [Streams and Subscriptions](https://frontside.com/effection/docs/collections) 48 | * for details on how to use streams. 49 | * 50 | * @param target - the event target whose events will be streamed 51 | * @param name - the name of the event to stream. E.g. "click" 52 | * @returns a stream that will see one item for each event 53 | */ 54 | export function on< 55 | T extends EventTarget, 56 | K extends EventList | (string & {}), 57 | >(target: T, name: K): Stream, never> { 58 | return resource(function* (provide) { 59 | let signal = createSignal(); 60 | 61 | target.addEventListener(name, signal.send); 62 | 63 | try { 64 | yield* provide( 65 | yield* signal as Operation< 66 | Subscription, never> 67 | >, 68 | ); 69 | } finally { 70 | target.removeEventListener(name, signal.send); 71 | } 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /lib/instructions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Frame, 3 | Instruction, 4 | Operation, 5 | Provide, 6 | Reject, 7 | Resolve, 8 | Result, 9 | Task, 10 | } from "./types.ts"; 11 | 12 | import { reset, shift } from "./deps.ts"; 13 | import { shiftSync } from "./shift-sync.ts"; 14 | import { Err, Ok } from "./result.ts"; 15 | 16 | /** 17 | * Indefinitely pause execution of the current operation. It is typically 18 | * used in conjunction with an {@link action} to mark the boundary 19 | * between setup and teardown. 20 | * 21 | * ```js 22 | * function onEvent(listener, name) { 23 | * return action(function* (resolve) { 24 | * try { 25 | * listener.addEventListener(name, resolve); 26 | * yield* suspend(); 27 | * } finally { 28 | * listener.removeEventListener(name, resolve); 29 | * } 30 | * }); 31 | * } 32 | * ``` 33 | * 34 | * An operation will remain suspended until its enclosing scope is destroyed, 35 | * at which point it proceeds as though return had been called from the point 36 | * of suspension. Once an operation suspends once, further suspend operations 37 | * are ignored. 38 | * 39 | * @returns an operation that suspends the current operation 40 | */ 41 | export function suspend(): Operation { 42 | return instruction(Suspend); 43 | } 44 | 45 | function Suspend(frame: Frame) { 46 | return shiftSync>((k) => { 47 | if (frame.aborted) { 48 | k.tail(Ok(void 0)); 49 | } 50 | }); 51 | } 52 | 53 | /** 54 | * Create an {@link Operation} that can be either resolved (or rejected) with 55 | * a synchronous callback. This is the Effection equivalent of `new Promise()`. 56 | * 57 | * The action body is a function that enters the effect, and returns a function that 58 | * will be called to exit the action.. 59 | * 60 | * For example: 61 | * 62 | * ```js 63 | * let five = yield* action((resolve, reject) => { 64 | * let timeout = setTimeout(() => { 65 | * if (Math.random() > 5) { 66 | * resolve(5) 67 | * } else { 68 | * reject(new Error("bad luck!")); 69 | * } 70 | * }, 1000); 71 | * return () => clearTimeout(timeout); 72 | * }); 73 | * ``` 74 | * 75 | * @typeParam T - type of the action's result. 76 | * @param body - enter and exit the action 77 | * @returns an operation producing the resolved value, or throwing the rejected error 78 | */ 79 | export function action( 80 | enter: (resolve: Resolve, reject: Reject) => () => void, 81 | ): Operation; 82 | 83 | /** 84 | * @deprecated `action()` used with an operation will be removed in v4. 85 | */ 86 | export function action( 87 | operation: (resolve: Resolve, reject: Reject) => Operation, 88 | ): Operation; 89 | export function action( 90 | operation: 91 | | ((resolve: Resolve, reject: Reject) => Operation) 92 | | ((resolve: Resolve, reject: Reject) => () => void), 93 | ): Operation { 94 | return instruction(function Action(frame) { 95 | return shift>(function* (k) { 96 | let settle = yield* reset>>(function* () { 97 | let result = yield* shiftSync>((k) => k.tail); 98 | 99 | let destruction = yield* child.destroy(); 100 | 101 | if (!destruction.ok) { 102 | k.tail(destruction); 103 | } else { 104 | k.tail(result); 105 | } 106 | }); 107 | 108 | let resolve: Resolve = (value) => settle(Ok(value)); 109 | let reject: Reject = (error) => settle(Err(error)); 110 | 111 | let child = frame.createChild(function* () { 112 | let iterable = operation(resolve, reject); 113 | if (typeof iterable === "function") { 114 | try { 115 | yield* suspend(); 116 | } finally { 117 | iterable(); 118 | } 119 | } else { 120 | yield* iterable; 121 | yield* suspend(); 122 | } 123 | }); 124 | 125 | yield* reset(function* () { 126 | let result = yield* child; 127 | if (!result.ok) { 128 | k.tail(result); 129 | } 130 | }); 131 | 132 | child.enter(); 133 | }); 134 | }); 135 | } 136 | 137 | /** 138 | * Run another operation concurrently as a child of the current one. 139 | * 140 | * The spawned operation will begin executing immediately and control will 141 | * return to the caller when it reaches its first suspend point. 142 | * 143 | * ### Example 144 | * 145 | * ```typescript 146 | * import { main, sleep, suspend, spawn } from 'effection'; 147 | * 148 | * await main(function*() { 149 | * yield* spawn(function*() { 150 | * yield* sleep(1000); 151 | * console.log("hello"); 152 | * }); 153 | * yield* spawn(function*() { 154 | * yield* sleep(2000); 155 | * console.log("world"); 156 | * }); 157 | * yield* suspend(); 158 | * }); 159 | * ``` 160 | * 161 | * You should prefer using the spawn operation over calling 162 | * {@link Scope.run} from within Effection code. The reason being that a 163 | * synchronous failure in the spawned operation will not be caught 164 | * until the next yield point when using `run`, which results in lines 165 | * being executed that should not. 166 | * 167 | * ### Example 168 | * 169 | * ```typescript 170 | * import { main, suspend, spawn, useScope } from 'effection'; 171 | * 172 | * await main(function*() { 173 | * yield* useScope(); 174 | * 175 | * scope.run(function*() { 176 | * throw new Error('boom!'); 177 | * }); 178 | * 179 | * console.log('this code will run and probably should not'); 180 | * 181 | * yield* suspend(); // <- error is thrown after this. 182 | * }); 183 | * ``` 184 | * @param operation the operation to run as a child of the current task 185 | * @typeParam T the type that the spawned task evaluates to 186 | * @returns a {@link Task} representing a handle to the running operation 187 | */ 188 | export function spawn(operation: () => Operation): Operation> { 189 | return instruction(function Spawn(frame) { 190 | return shift>>(function (k) { 191 | let child = frame.createChild(operation); 192 | 193 | child.enter(); 194 | 195 | k.tail(Ok(child.getTask())); 196 | 197 | return reset(function* () { 198 | let result = yield* child; 199 | if (!result.ok) { 200 | yield* frame.crash(result.error); 201 | } 202 | }); 203 | }); 204 | }); 205 | } 206 | 207 | /** 208 | * Define an Effection [resource](https://frontside.com/effection/docs/resources) 209 | * 210 | * Resources are a type of operation that passes a value back to its caller 211 | * while still allowing that operation to run in the background. It does this 212 | * by invoking the special `provide()` operation. The caller pauses until the 213 | * resource operation invokes `provide()` at which point the caller resumes with 214 | * passed value. 215 | * 216 | * `provide()` suspends the resource operation until the caller passes out of 217 | * scope. 218 | * 219 | * @example 220 | * ```javascript 221 | * function useWebSocket(url) { 222 | * return resource(function*(provide) { 223 | * let socket = new WebSocket(url); 224 | * yield* once(socket, 'open'); 225 | * 226 | * try { 227 | * yield* provide(socket); 228 | * } finally { 229 | * socket.close(); 230 | * yield* once(socket, 'close'); 231 | * } 232 | * }) 233 | * } 234 | * 235 | * await main(function*() { 236 | * let socket = yield* useWebSocket("wss://example.com"); 237 | * socket.send("hello world"); 238 | * }); 239 | * ``` 240 | * 241 | * @param operation the operation defining the lifecycle of the resource 242 | * @returns an operation yielding the resource 243 | */ 244 | export function resource( 245 | operation: (provide: Provide) => Operation, 246 | ): Operation { 247 | return instruction((frame) => 248 | shift>(function (k) { 249 | function provide(value: T) { 250 | k.tail(Ok(value)); 251 | return suspend(); 252 | } 253 | 254 | let child = frame.createChild(() => operation(provide)); 255 | 256 | child.enter(); 257 | 258 | return reset(function* () { 259 | let result = yield* child; 260 | if (!result.ok) { 261 | k.tail(result); 262 | yield* frame.crash(result.error); 263 | } 264 | }); 265 | }) 266 | ); 267 | } 268 | 269 | /** 270 | * @ignore 271 | */ 272 | export function getframe(): Operation { 273 | return instruction((frame) => 274 | shiftSync>((k) => k.tail(Ok(frame))) 275 | ); 276 | } 277 | 278 | // An optimized iterator that yields the instruction on the first call 279 | // to next, then returns its value on the second. Equivalent to: 280 | // { 281 | // *[Symbol.iterator]() { return yield instruction; } 282 | // } 283 | function instruction(i: Instruction): Operation { 284 | return { 285 | [Symbol.iterator]() { 286 | let entered = false; 287 | return { 288 | next(value) { 289 | if (!entered) { 290 | entered = true; 291 | return { done: false, value: i }; 292 | } else { 293 | return { done: true, value }; 294 | } 295 | }, 296 | throw(error) { 297 | throw error; 298 | }, 299 | }; 300 | }, 301 | }; 302 | } 303 | -------------------------------------------------------------------------------- /lib/lazy.ts: -------------------------------------------------------------------------------- 1 | export function lazy(create: () => T): () => T { 2 | let thunk = () => { 3 | let value = create(); 4 | thunk = () => value; 5 | return value; 6 | }; 7 | return () => thunk(); 8 | } 9 | -------------------------------------------------------------------------------- /lib/lift.ts: -------------------------------------------------------------------------------- 1 | import { shift } from "./deps.ts"; 2 | import type { Operation } from "./types.ts"; 3 | 4 | /** 5 | * Convert a simple function into an {@link Operation} 6 | * 7 | * @example 8 | * ```javascript 9 | * let log = lift((message) => console.log(message)); 10 | * 11 | * export function* run() { 12 | * yield* log("hello world"); 13 | * yield* log("done"); 14 | * } 15 | * ``` 16 | * 17 | * @returns a function returning an operation that invokes `fn` when evaluated 18 | */ 19 | export function lift( 20 | fn: (...args: TArgs) => TReturn, 21 | ): (...args: TArgs) => Operation { 22 | return (...args: TArgs) => { 23 | return ({ 24 | *[Symbol.iterator]() { 25 | return yield () => { 26 | return shift(function* (k) { 27 | k.tail({ ok: true, value: fn(...args) }); 28 | }); 29 | }; 30 | }, 31 | }); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "./context.ts"; 2 | import type { Operation } from "./types.ts"; 3 | import { action } from "./instructions.ts"; 4 | import { run } from "./run.ts"; 5 | import { call } from "./call.ts"; 6 | 7 | /** 8 | * Halt process execution immediately and initiate shutdown. If a message is 9 | * provided, it will be logged to the console after shutdown: 10 | * 11 | * ```js 12 | * if (invalidArgs()) { 13 | * yield* exit(5, "invalid arguments") 14 | * } 15 | * ``` 16 | * @param status - the exit code to use for the process exit 17 | * @param message - message to print to the console before exiting. 18 | * @param returns an operation that exits the program 19 | */ 20 | export function* exit(status: number, message?: string): Operation { 21 | let escape = yield* ExitContext.expect(); 22 | escape({ status, message }); 23 | } 24 | 25 | /** 26 | * Top-level entry point to programs written in Effection. That means that your 27 | * program should only call `main` once, and everything the program does is 28 | * handled from within `main` including an orderly shutdown. Unlike `run`, `main` 29 | * automatically prints errors that occurred to the console. 30 | * 31 | * Use the {@link exit} operation form within to halt program execution 32 | * immediately and initiate shutdown. 33 | * 34 | * The behavior of `main` is slightly different depending on the environment it 35 | * is running in. 36 | * 37 | * ### Deno, Node 38 | * 39 | * When running within Deno or Node, any error which reaches `main` causes the 40 | * entire process to exit with an exit code of `1`. 41 | * 42 | * Additionally, handlers for `SIGINT` are attached to the 43 | * process, so that sending an exit signal to it causes the main task 44 | * to become halted. This means that hitting CTRL-C on an Effection program 45 | * using `main` will cause an orderly shutdown and run all cleanup code. 46 | * 47 | * > Warning! do not call `Deno.exit()` on Deno or `process.exit()` on Node 48 | * > directly, as this will not gracefully shutdown. Instead, use the 49 | * > {@link exit} operation. 50 | * 51 | * ### Browser 52 | * 53 | * When running in a browser, The `main` operation gets shut down on the 54 | * `unload` event. 55 | * 56 | * @param body - an operation to run as the body of the program 57 | * @returns a promise that resolves right after the program exits 58 | */ 59 | 60 | export async function main( 61 | body: (args: string[]) => Operation, 62 | ): Promise { 63 | let hardexit = (_status: number) => {}; 64 | 65 | let result = await run(() => 66 | action(function* (resolve) { 67 | // action will return shutdown immediately upon resolve, so stash 68 | // this function in the exit context so it can be called anywhere. 69 | yield* ExitContext.set(resolve); 70 | 71 | // this will hold the event loop and prevent runtimes such as 72 | // Node and Deno from exiting prematurely. 73 | let interval = setInterval(() => {}, Math.pow(2, 30)); 74 | 75 | try { 76 | let interrupt = { 77 | SIGINT: () => resolve({ status: 130, signal: "SIGINT" }), 78 | SIGTERM: () => resolve({ status: 143, signal: "SIGTERM" }), 79 | }; 80 | 81 | yield* withHost({ 82 | *deno() { 83 | hardexit = (status) => Deno.exit(status); 84 | try { 85 | Deno.addSignalListener("SIGINT", interrupt.SIGINT); 86 | Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); 87 | yield* body(Deno.args.slice()); 88 | } finally { 89 | Deno.removeSignalListener("SIGINT", interrupt.SIGINT); 90 | Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); 91 | } 92 | }, 93 | *node() { 94 | let { default: process } = yield* call(() => 95 | import("node:process") 96 | ); 97 | hardexit = (status) => process.exit(status); 98 | try { 99 | process.on("SIGINT", interrupt.SIGINT); 100 | process.on("SIGTERM", interrupt.SIGTERM); 101 | yield* body(process.argv.slice(2)); 102 | } finally { 103 | process.off("SIGINT", interrupt.SIGINT); 104 | process.off("SIGTERM", interrupt.SIGINT); 105 | } 106 | }, 107 | *browser() { 108 | try { 109 | self.addEventListener("unload", interrupt.SIGINT); 110 | yield* body([]); 111 | } finally { 112 | self.removeEventListener("unload", interrupt.SIGINT); 113 | } 114 | }, 115 | }); 116 | 117 | yield* exit(0); 118 | } catch (error) { 119 | resolve({ status: 1, error: error as Error }); 120 | } finally { 121 | clearInterval(interval); 122 | } 123 | }) 124 | ); 125 | 126 | if (result.message) { 127 | if (result.status === 0) { 128 | console.log(result.message); 129 | } else { 130 | console.error(result.message); 131 | } 132 | } 133 | 134 | if (result.error) { 135 | console.error(result.error); 136 | } 137 | 138 | hardexit(result.status); 139 | } 140 | 141 | const ExitContext = createContext<(exit: Exit) => void>("exit"); 142 | 143 | interface Exit { 144 | status: number; 145 | message?: string; 146 | signal?: string; 147 | error?: Error; 148 | } 149 | 150 | interface HostOperation { 151 | deno(): Operation; 152 | node(): Operation; 153 | browser(): Operation; 154 | } 155 | 156 | function* withHost(op: HostOperation): Operation { 157 | let global = globalThis as Record; 158 | 159 | if (typeof global.Deno !== "undefined") { 160 | return yield* op.deno(); 161 | // this snippet is from the detect-node npm package 162 | // @see https://github.com/iliakan/detect-node/blob/master/index.js 163 | } else if ( 164 | Object.prototype.toString.call( 165 | typeof globalThis.process !== "undefined" ? globalThis.process : 0, 166 | ) === "[object process]" 167 | ) { 168 | return yield* op.node(); 169 | } else { 170 | return yield* op.browser(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /lib/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.ts"; 2 | export * from "./channel.ts"; 3 | export * from "./context.ts"; 4 | export * from "./instructions.ts"; 5 | export * from "./call.ts"; 6 | export * from "./run.ts"; 7 | export * from "./sleep.ts"; 8 | export * from "./async.ts"; 9 | export * from "./abort-signal.ts"; 10 | export * from "./result.ts"; 11 | export * from "./lift.ts"; 12 | export * from "./events.ts"; 13 | export * from "./main.ts"; 14 | export * from "./all.ts"; 15 | export * from "./each.ts"; 16 | export * from "./queue.ts"; 17 | export * from "./signal.ts"; 18 | export * from "./ensure.ts"; 19 | export * from "./race.ts"; 20 | export * from "./with-resolvers.ts"; 21 | export * from "./scoped.ts"; 22 | -------------------------------------------------------------------------------- /lib/pause.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Reject, Resolve, Result } from "./types.ts"; 2 | import { Err, Ok } from "./result.ts"; 3 | import { shift } from "./deps.ts"; 4 | 5 | export function* pause( 6 | install: (resolve: Resolve, reject: Reject) => Resolve, 7 | ): Operation { 8 | let uninstall = () => {}; 9 | try { 10 | return yield function pause_i() { 11 | return shift>(function* (k) { 12 | let resolve = (value: T) => k.tail(Ok(value)); 13 | let reject = (error: Error) => k.tail(Err(error)); 14 | uninstall = install(resolve, reject); 15 | }); 16 | }; 17 | } finally { 18 | if (uninstall) { 19 | uninstall(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/queue.ts: -------------------------------------------------------------------------------- 1 | import type { Resolve, Subscription } from "./types.ts"; 2 | import { pause } from "./pause.ts"; 3 | 4 | /** 5 | * A FIFO queue which can be used to implement the {@link Subscription} 6 | * interface directly. Most of the time, you will use either a {@link Signal} 7 | * or a {@link Channel} as the mechanism, but `Queue` allows you to manage 8 | * a single subscription directly. 9 | * 10 | * @typeParam T the type of the items in the queue 11 | * @typeParam TClose the type of the value that the queue is closed with 12 | */ 13 | export interface Queue extends Subscription { 14 | /** 15 | * Add a value to the queue. The oldest value currently in the queue will 16 | * be the first to be read. 17 | * @param item - the value to add 18 | */ 19 | add(item: T): void; 20 | 21 | /** 22 | * Close the queue. 23 | * @param value - the queue's final value. 24 | */ 25 | close(value: TClose): void; 26 | } 27 | 28 | /** 29 | * Creates a new queue. Queues are unlimited in size and sending a message to a 30 | * queue is always synchronous. 31 | * 32 | * @example 33 | * 34 | * ```javascript 35 | * import { each, main, createQueue } from 'effection'; 36 | * 37 | * await main(function*() { 38 | * let queue = createQueue(); 39 | * queue.send(1); 40 | * queue.send(2); 41 | * queue.send(3); 42 | * 43 | * let next = yield* queue.subscription.next(); 44 | * while (!next.done) { 45 | * console.log("got number", next.value); 46 | * next = yield* queue.subscription.next(); 47 | * } 48 | * }); 49 | * ``` 50 | * 51 | * @typeParam T the type of the items in the queue 52 | * @typeParam TClose the type of the value that the queue is closed with 53 | */ 54 | export function createQueue(): Queue { 55 | type Item = IteratorResult; 56 | 57 | let items: Item[] = []; 58 | let consumers = new Set>(); 59 | 60 | function enqueue(item: Item) { 61 | items.unshift(item); 62 | while (items.length > 0 && consumers.size > 0) { 63 | let [consume] = consumers; 64 | let top = items.pop() as Item; 65 | consume(top); 66 | } 67 | } 68 | 69 | return { 70 | add: (value) => enqueue({ done: false, value }), 71 | close: (value) => enqueue({ done: true, value }), 72 | *next() { 73 | let item = items.pop(); 74 | if (item) { 75 | return item; 76 | } else { 77 | return yield* pause((resolve) => { 78 | consumers.add(resolve); 79 | return () => consumers.delete(resolve); 80 | }); 81 | } 82 | }, 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /lib/race.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Yielded } from "./types.ts"; 2 | import { action, spawn } from "./instructions.ts"; 3 | 4 | /** 5 | * Race the given operations against each other and return the value of 6 | * whichever operation returns first. This has the same purpose as 7 | * [Promise.race](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race). 8 | * 9 | * If an operation become errored first, then `race` will fail with this error. 10 | * After the first operation wins the race, all other operations will become 11 | * halted and therefore cannot throw any further errors. 12 | * 13 | * @example 14 | * 15 | * ```typescript 16 | * import { main, race, fetch } from 'effection'; 17 | * 18 | * await main(function*() { 19 | * let fastest = yield* race([fetch('http://google.com'), fetch('http://bing.com')]); 20 | * // ... 21 | * }); 22 | * ``` 23 | * 24 | * @param operations a list of operations to race against each other 25 | * @returns the value of the fastest operation 26 | */ 27 | export function race>( 28 | operations: readonly T[], 29 | ): Operation> { 30 | return action>(function* (resolve, reject) { 31 | for (let operation of operations) { 32 | yield* spawn(function* () { 33 | try { 34 | resolve((yield* operation) as Yielded); 35 | } catch (error) { 36 | reject(error as Error); 37 | } 38 | }); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /lib/result.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "./types.ts"; 2 | 3 | /** 4 | * @ignore 5 | */ 6 | export const Ok = (value: T): Result => ({ ok: true, value }); 7 | 8 | /** 9 | * @ignore 10 | */ 11 | export const Err = (error: Error): Result => ({ ok: false, error }); 12 | -------------------------------------------------------------------------------- /lib/run.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Task } from "./types.ts"; 2 | import { createFrame } from "./run/frame.ts"; 3 | export * from "./run/scope.ts"; 4 | 5 | /** 6 | * Execute an operation. 7 | * 8 | * Run is an entry point into Effection, and is especially useful when 9 | * embedding Effection code into existing code. However, If you are writing your 10 | * whole program using Effection, you should prefer {@link main}. 11 | * 12 | * @example 13 | * ```javascript 14 | * import { run, useAbortSignal } from 'effection'; 15 | * 16 | * async function fetchExample() { 17 | * await run(function*() { 18 | * let signal = yield* useAbortSignal(); 19 | * let response = yield* call(() => fetch('http://www.example.com', { signal })); 20 | * yield* call(() => response.text()); 21 | * }); 22 | * }); 23 | * ``` 24 | * 25 | * Run will create a new top-level scope for the operation. However, to run an 26 | * operation in an existing scope, you can use {@link Scope.run}. 27 | * 28 | * @param operation the operation to run 29 | * @returns a task representing the running operation. 30 | */ 31 | export function run(operation: () => Operation): Task { 32 | let frame = createFrame({ operation }); 33 | frame.enter(); 34 | return frame.getTask(); 35 | } 36 | -------------------------------------------------------------------------------- /lib/run/create.ts: -------------------------------------------------------------------------------- 1 | export function create( 2 | tag: string, 3 | attrs: Partial, 4 | prototype: Partial, 5 | ): T { 6 | let properties: Record = {}; 7 | for (let [key, value] of Object.entries(attrs)) { 8 | properties[key] = { enumerable: true, value }; 9 | } 10 | return Object.create({ 11 | ...prototype, 12 | [Symbol.toStringTag]: tag, 13 | }, properties); 14 | } 15 | -------------------------------------------------------------------------------- /lib/run/frame.ts: -------------------------------------------------------------------------------- 1 | import type { Frame, Instruction, Operation, Result } from "../types.ts"; 2 | 3 | import { evaluate, shift } from "../deps.ts"; 4 | import { shiftSync } from "../shift-sync.ts"; 5 | import { lazy } from "../lazy.ts"; 6 | import { Err, Ok } from "../result.ts"; 7 | 8 | import type { Exit, FrameResult } from "./types.ts"; 9 | import { createValue } from "./value.ts"; 10 | import { createTask } from "./task.ts"; 11 | import { create } from "./create.ts"; 12 | 13 | let ids = 0; 14 | 15 | export interface FrameOptions { 16 | operation(): Operation; 17 | parent?: Frame["context"]; 18 | } 19 | 20 | export function createFrame(options: FrameOptions): Frame { 21 | return evaluate>(function* () { 22 | let { operation, parent } = options; 23 | let children = new Set(); 24 | let context = Object.create(parent ?? {}); 25 | let thunks: IteratorResult>[] = [{ 26 | done: false, 27 | value: $next(void 0), 28 | }]; 29 | 30 | let crash: Error | undefined = void 0; 31 | 32 | let interrupt = () => {}; 33 | 34 | let [setResults, results] = yield* createValue>(); 35 | 36 | let frame = yield* shiftSync>((k) => { 37 | let self: Frame = create>("Frame", { id: ids++, context }, { 38 | createChild(operation: () => Operation) { 39 | let child = createFrame({ operation, parent: self.context }); 40 | children.add(child); 41 | evaluate(function* () { 42 | yield* child; 43 | children.delete(child); 44 | }); 45 | return child; 46 | }, 47 | getTask() { 48 | let task = createTask(self); 49 | self.getTask = () => task; 50 | return task; 51 | }, 52 | enter() { 53 | k.tail(self); 54 | }, 55 | crash(error: Error) { 56 | abort(error); 57 | return results; 58 | }, 59 | destroy() { 60 | abort(); 61 | return results; 62 | }, 63 | [Symbol.iterator]: results[Symbol.iterator], 64 | }); 65 | 66 | let abort = (reason?: Error) => { 67 | if (!self.aborted) { 68 | self.aborted = true; 69 | crash = reason; 70 | thunks.unshift({ done: false, value: $abort() }); 71 | interrupt(); 72 | } 73 | }; 74 | 75 | return self; 76 | }); 77 | 78 | let iterator = lazy(() => operation()[Symbol.iterator]()); 79 | 80 | let thunk = thunks.pop()!; 81 | 82 | while (!thunk.done) { 83 | let getNext = thunk.value; 84 | try { 85 | let next: IteratorResult = getNext(iterator()); 86 | 87 | if (next.done) { 88 | thunks.unshift({ done: true, value: Ok(next.value) }); 89 | } else { 90 | let instruction = next.value; 91 | 92 | let outcome = yield* shift(function* (k) { 93 | interrupt = () => k.tail({ type: "interrupted" }); 94 | 95 | try { 96 | k.tail({ 97 | type: "settled", 98 | result: yield* instruction(frame), 99 | }); 100 | } catch (error) { 101 | k.tail({ type: "settled", result: Err(error as Error) }); 102 | } 103 | }); 104 | 105 | if (outcome.type === "settled") { 106 | if (outcome.result.ok) { 107 | thunks.unshift({ 108 | done: false, 109 | value: $next(outcome.result.value), 110 | }); 111 | } else { 112 | thunks.unshift({ 113 | done: false, 114 | value: $throw(outcome.result.error), 115 | }); 116 | } 117 | } 118 | } 119 | } catch (error) { 120 | thunks.unshift({ done: true, value: Err(error as Error) }); 121 | } 122 | thunk = thunks.pop()!; 123 | } 124 | 125 | frame.exited = true; 126 | 127 | let result = thunk.value; 128 | 129 | let exit: Exit; 130 | 131 | if (!result.ok) { 132 | exit = { type: "result", result }; 133 | } else if (crash) { 134 | exit = { type: "crashed", error: crash }; 135 | } else if (frame.aborted) { 136 | exit = { type: "aborted" }; 137 | } else { 138 | exit = { type: "result", result }; 139 | } 140 | 141 | let destruction = Ok(void 0); 142 | 143 | while (children.size !== 0) { 144 | for (let child of [...children].reverse()) { 145 | let teardown = yield* child.destroy(); 146 | if (!teardown.ok) { 147 | destruction = teardown; 148 | } 149 | } 150 | } 151 | 152 | if (!destruction.ok) { 153 | setResults({ ok: false, error: destruction.error, exit, destruction }); 154 | } else { 155 | if (exit.type === "aborted") { 156 | setResults({ ok: true, value: void 0, exit, destruction }); 157 | } else if (exit.type === "result") { 158 | let { result } = exit; 159 | if (result.ok) { 160 | setResults({ ok: true, value: void 0, exit, destruction }); 161 | } else { 162 | setResults({ ok: false, error: result.error, exit, destruction }); 163 | } 164 | } else { 165 | setResults({ ok: false, error: exit.error, exit, destruction }); 166 | } 167 | } 168 | }); 169 | } 170 | 171 | type Thunk = ReturnType; 172 | 173 | // deno-lint-ignore no-explicit-any 174 | const $next = (value: any) => 175 | function $next(i: Iterator) { 176 | return i.next(value); 177 | }; 178 | 179 | const $throw = (error: Error) => 180 | function $throw(i: Iterator) { 181 | if (i.throw) { 182 | return i.throw(error); 183 | } else { 184 | throw error; 185 | } 186 | }; 187 | 188 | const $abort = (value?: unknown) => 189 | function $abort(i: Iterator) { 190 | if (i.return) { 191 | return i.return(value as unknown as T); 192 | } else { 193 | return { done: true, value } as IteratorResult; 194 | } 195 | }; 196 | 197 | type InstructionResult = 198 | | { 199 | type: "settled"; 200 | result: Result; 201 | } 202 | | { 203 | type: "interrupted"; 204 | }; 205 | -------------------------------------------------------------------------------- /lib/run/scope.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Frame, Future, Operation, Scope } from "../types.ts"; 2 | import { evaluate } from "../deps.ts"; 3 | import { create } from "./create.ts"; 4 | import { createFrame } from "./frame.ts"; 5 | import { getframe, suspend } from "../instructions.ts"; 6 | 7 | /** 8 | * Get the scope of the currently running {@link Operation}. 9 | * 10 | * @returns an operation yielding the current scope 11 | */ 12 | export function* useScope(): Operation { 13 | let frame = yield* getframe(); 14 | let [scope] = createScope(frame); 15 | return scope; 16 | } 17 | 18 | /** 19 | * Create a new {@link Scope} as a child of `parent`, inheriting all its contexts. 20 | * along with a method to destroy the scope. Whenever the scope is destroyd, all 21 | * tasks and resources it contains will be halted. 22 | * 23 | * This function is used mostly by frameworks as an intergration point to enter 24 | * Effection. 25 | * 26 | * @example 27 | * ```js 28 | * import { createScope, sleep, suspend } from "effection"; 29 | * 30 | * let [scope, destroy] = createScope(); 31 | * 32 | * let delay = scope.run(function*() { 33 | * yield* sleep(1000); 34 | * }); 35 | * scope.run(function*() { 36 | * try { 37 | * yield* suspend(); 38 | * } finally { 39 | * console.log('done!'); 40 | * } 41 | * }); 42 | * await delay; 43 | * await destroy(); // prints "done!"; 44 | * ``` 45 | * 46 | * @param parent scope. If no parent is specified it will be free standing. 47 | * @returns a tuple containing the freshly created scope, along with a function to 48 | * destroy it. 49 | */ 50 | export function createScope(parent?: Scope): [Scope, () => Future]; 51 | 52 | /* @ignore */ 53 | export function createScope(parent: Frame): [Scope, () => Future]; 54 | /* @ignore */ 55 | export function createScope( 56 | parent?: Frame | Scope, 57 | ): [Scope, () => Future] { 58 | let frame = isScopeInternal(parent) 59 | ? parent.frame.createChild(suspend) 60 | : (parent as Frame) ?? createFrame({ operation: suspend }); 61 | 62 | let scope = create("Scope", {}, { 63 | frame, 64 | run(operation: () => Operation) { 65 | if (frame.exited) { 66 | let error = new Error( 67 | `cannot call run() on a scope that has already been exited`, 68 | ); 69 | error.name = "InactiveScopeError"; 70 | throw error; 71 | } 72 | 73 | let child = frame.createChild(operation); 74 | child.enter(); 75 | 76 | evaluate(function* () { 77 | let result = yield* child; 78 | if (!result.ok) { 79 | yield* frame.crash(result.error); 80 | } 81 | }); 82 | 83 | return child.getTask(); 84 | }, 85 | 86 | get spawn() { 87 | let scope = this!; 88 | return function spawn(operation: () => Operation) { 89 | return { 90 | *[Symbol.iterator]() { 91 | return scope.run(operation); 92 | }, 93 | }; 94 | }; 95 | }, 96 | 97 | get(context: Context) { 98 | let { key, defaultValue } = context; 99 | return (frame.context[key] ?? defaultValue) as T | undefined; 100 | }, 101 | set(context: Context, value: T) { 102 | let { key } = context; 103 | frame.context[key] = value; 104 | return value; 105 | }, 106 | expect(context: Context): T { 107 | let value = scope.get(context); 108 | if (typeof value === "undefined") { 109 | let error = new Error(context.key); 110 | error.name = `MissingContextError`; 111 | throw error; 112 | } 113 | return value; 114 | }, 115 | delete(context: Context): boolean { 116 | let { key } = context; 117 | return delete frame.context[key]; 118 | }, 119 | hasOwn(context: Context): boolean { 120 | return !!Reflect.getOwnPropertyDescriptor(frame.context, context.key); 121 | }, 122 | }); 123 | 124 | frame.enter(); 125 | 126 | return [scope, frame.getTask().halt]; 127 | } 128 | 129 | interface ScopeInternal extends Scope { 130 | frame: Frame; 131 | } 132 | 133 | function isScopeInternal(value?: Frame | Scope): value is ScopeInternal { 134 | return !!value && typeof (value as ScopeInternal).run === "function"; 135 | } 136 | -------------------------------------------------------------------------------- /lib/run/task.ts: -------------------------------------------------------------------------------- 1 | import type { Frame, Future, Reject, Resolve, Result, Task } from "../types.ts"; 2 | 3 | import { evaluate } from "../deps.ts"; 4 | import { Err } from "../result.ts"; 5 | import { action } from "../instructions.ts"; 6 | 7 | import type { FrameResult } from "./types.ts"; 8 | import { create } from "./create.ts"; 9 | 10 | export function createTask( 11 | frame: Frame, 12 | ): Task { 13 | let promise: Promise; 14 | 15 | let awaitResult = (resolve: Resolve, reject: Reject) => { 16 | evaluate(function* () { 17 | let result = getResult(yield* frame); 18 | 19 | if (result.ok) { 20 | resolve(result.value); 21 | } else { 22 | reject(result.error); 23 | } 24 | }); 25 | }; 26 | 27 | let getPromise = () => { 28 | promise = new Promise((resolve, reject) => { 29 | awaitResult(resolve, reject); 30 | }); 31 | getPromise = () => promise; 32 | return promise; 33 | }; 34 | 35 | let task = create>("Task", {}, { 36 | *[Symbol.iterator]() { 37 | let frameResult = evaluate | void>(() => frame); 38 | if (frameResult) { 39 | let result = getResult(frameResult); 40 | if (result.ok) { 41 | return result.value; 42 | } else { 43 | throw result.error; 44 | } 45 | } else { 46 | return yield* action(function* (resolve, reject) { 47 | awaitResult(resolve, reject); 48 | }); 49 | } 50 | }, 51 | then: (...args) => getPromise().then(...args), 52 | catch: (...args) => getPromise().catch(...args), 53 | finally: (...args) => getPromise().finally(...args), 54 | halt() { 55 | let haltPromise: Promise; 56 | let getHaltPromise = () => { 57 | haltPromise = new Promise((resolve, reject) => { 58 | awaitHaltResult(resolve, reject); 59 | }); 60 | getHaltPromise = () => haltPromise; 61 | frame.destroy(); 62 | return haltPromise; 63 | }; 64 | let awaitHaltResult = (resolve: Resolve, reject: Reject) => { 65 | evaluate(function* () { 66 | let { destruction } = yield* frame; 67 | if (destruction.ok) { 68 | resolve(); 69 | } else { 70 | reject(destruction.error); 71 | } 72 | }); 73 | }; 74 | return create>("Future", {}, { 75 | *[Symbol.iterator]() { 76 | let result = evaluate | void>(() => frame); 77 | 78 | if (result) { 79 | if (!result.ok) { 80 | throw result.error; 81 | } 82 | } else { 83 | yield* action(function* (resolve, reject) { 84 | awaitHaltResult(resolve, reject); 85 | frame.destroy(); 86 | }); 87 | } 88 | }, 89 | then: (...args) => getHaltPromise().then(...args), 90 | catch: (...args) => getHaltPromise().catch(...args), 91 | finally: (...args) => getHaltPromise().finally(...args), 92 | }); 93 | }, 94 | }); 95 | return task; 96 | } 97 | 98 | function getResult(result: FrameResult): Result { 99 | if (!result.ok) { 100 | return result; 101 | } else if (result.exit.type === "aborted") { 102 | return Err(Error("halted")); 103 | } else if (result.exit.type === "crashed") { 104 | return Err(result.exit.error); 105 | } else { 106 | return result.exit.result; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/run/types.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "../types.ts"; 2 | 3 | export type Exit = { 4 | type: "aborted"; 5 | } | { 6 | type: "crashed"; 7 | error: Error; 8 | } | { 9 | type: "result"; 10 | result: Result; 11 | }; 12 | 13 | /** 14 | * @ignore 15 | */ 16 | export type FrameResult = Result & { 17 | exit: Exit; 18 | destruction: Result; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/run/value.ts: -------------------------------------------------------------------------------- 1 | import { type Computation, reset } from "../deps.ts"; 2 | import type { Resolve } from "../types.ts"; 3 | import { shiftSync } from "../shift-sync.ts"; 4 | 5 | export function* createValue(): Computation<[Resolve, Computation]> { 6 | let result: { value: T } | void = void 0; 7 | let listeners = new Set>(); 8 | 9 | let resolve = yield* reset>(function* () { 10 | let value = yield* shiftSync((k) => k.tail); 11 | 12 | result = { value }; 13 | 14 | for (let listener of listeners) { 15 | listeners.delete(listener); 16 | listener(value); 17 | } 18 | }); 19 | 20 | let event: Computation = { 21 | [Symbol.iterator]() { 22 | if (result) { 23 | return sync(result.value); 24 | } else { 25 | return shiftSync((k) => { 26 | listeners.add(k.tail); 27 | })[Symbol.iterator](); 28 | } 29 | }, 30 | }; 31 | return [resolve, event]; 32 | } 33 | 34 | export interface Queue { 35 | add(item: T): void; 36 | next(): Computation; 37 | } 38 | 39 | export function sync(value: T) { 40 | return { 41 | next() { 42 | return { done: true, value } as const; 43 | }, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /lib/scoped.ts: -------------------------------------------------------------------------------- 1 | import type { Operation } from "./types.ts"; 2 | import { call } from "./call.ts"; 3 | 4 | /** 5 | * Encapsulate an operation so that no effects will persist outside of 6 | * it. All active effects such as concurrent tasks and resources will be 7 | * shut down, and all contexts will be restored to their values outside 8 | * of the scope. 9 | * 10 | * @example 11 | * ```js 12 | * import { useAbortSignal } from "effection"; 13 | 14 | * function* example() { 15 | * let signal = yield* scoped(function*() { 16 | * return yield* useAbortSignal(); 17 | * }); 18 | * return signal.aborted; //=> true 19 | * } 20 | * ``` 21 | * 22 | * @param operation - the operation to be encapsulated 23 | * 24 | * @returns the scoped operation 25 | */ 26 | export function scoped(operation: () => Operation): Operation { 27 | return call(operation); 28 | } 29 | -------------------------------------------------------------------------------- /lib/shift-sync.ts: -------------------------------------------------------------------------------- 1 | import { type Computation, type Continuation, shift } from "./deps.ts"; 2 | 3 | /** 4 | * Create a shift computation where the body of the shift can be resolved 5 | * in a single step. 6 | * 7 | * before: 8 | * ```ts 9 | * yield* shift(function*(k) { return k; }); 10 | * ``` 11 | * after: 12 | * yield* shiftSync(k => k); 13 | */ 14 | export function shiftSync( 15 | block: (resolve: Continuation, reject: Continuation) => void, 16 | ): Computation { 17 | return shift((resolve, reject) => { 18 | return { 19 | [Symbol.iterator]: () => ({ 20 | next() { 21 | let value = block(resolve, reject); 22 | return { done: true, value }; 23 | }, 24 | }), 25 | }; 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /lib/signal.ts: -------------------------------------------------------------------------------- 1 | import type { Stream, Subscription } from "./types.ts"; 2 | 3 | import { createQueue, type Queue } from "./queue.ts"; 4 | import { resource } from "./instructions.ts"; 5 | import { createContext } from "./context.ts"; 6 | import type { Context } from "./types.ts"; 7 | 8 | /** 9 | * Convert plain JavaScript function calls into a {@link Stream} that can 10 | * be consumed within an operation. If no operation is subscribed to a signal's 11 | * stream, then sending messages to it is a no-op. 12 | * 13 | * Signals are particularly suited to be installed as event listeners. 14 | * 15 | * @example 16 | * ```typescript 17 | * import { createSignal, each } from "effection"; 18 | * 19 | * export function* logClicks(function*(button) { 20 | * let clicks = createSignal(); 21 | * 22 | * button.addEventListener("click", clicks.send); 23 | * 24 | * try { 25 | * for (let click of yield* each(clicks)) { 26 | * console.log(`click:`, click); 27 | * yield* each.next(); 28 | * } 29 | * } finally { 30 | * button.removeEventListener("click", clicks.send); 31 | * } 32 | * }) 33 | * ```` 34 | * 35 | * @typeParam T - type of each event sent by this signal 36 | * @typeParam TClose - type of the final event sent by this signal 37 | */ 38 | export interface Signal extends Stream { 39 | /** 40 | * Send a value to all the consumers of this signal. 41 | * 42 | * @param value - the value to send. 43 | */ 44 | send(value: T): void; 45 | 46 | /** 47 | * Send the final value of this signal to all its consumers. 48 | * @param value - the final value. 49 | */ 50 | close(value: TClose): void; 51 | } 52 | 53 | /** 54 | * @ignore 55 | * {@link Context} that contains a {@link Queue} factory to be used when creating a {@link Signal}. 56 | * 57 | * This allows end-users to customize a Signal's Queue. 58 | * 59 | * @example 60 | * ```javascript 61 | * export function useActions(pattern: ActionPattern): Stream { 62 | * return { 63 | * *[Symbol.iterator]() { 64 | * const actions = yield* ActionContext; 65 | * yield* QueueFactory.set(() => createFilterQueue(matcher(pattern)); 66 | * return yield* actions; 67 | * } 68 | * } 69 | * } 70 | * 71 | * function createFilterQueue(predicate: Predicate) { 72 | * let queue = createQueue(); 73 | * 74 | * return { 75 | * ...queue, 76 | * add(value) { 77 | * if (predicate(value)) { 78 | * queue.add(value); 79 | * } 80 | * } 81 | * } 82 | * } 83 | * ``` 84 | */ 85 | export const SignalQueueFactory: Context = createContext( 86 | "Signal.createQueue", 87 | createQueue, 88 | ); 89 | 90 | /** 91 | * Create a new {@link Signal} 92 | * 93 | * Signal should be used when you need to send messages to a stream 94 | * from _outside_ of an operation. The most common case of this is to 95 | * connect a plain, synchronous JavaScript callback to an operation. 96 | * 97 | * @example 98 | * ```javascript 99 | * function* logClicks(button) { 100 | * let clicks = createSignal(); 101 | * try { 102 | * button.addEventListener("click", clicks.send); 103 | * 104 | * for (let click of yield* each(clicks)) { 105 | * console.log("click", click); 106 | * } 107 | * } finally { 108 | * button.removeEventListener("click", clicks.send); 109 | * } 110 | * } 111 | * ``` 112 | * 113 | * Do not use a signal to send messages from within an operation as it could 114 | * result in out-of-scope code being executed. In those cases, you should use a 115 | * {@link Channel}. 116 | */ 117 | export function createSignal(): Signal { 118 | let subscribers = new Set>(); 119 | 120 | let subscribe = resource>(function* (provide) { 121 | let newQueue = yield* SignalQueueFactory.expect(); 122 | let queue = newQueue(); 123 | subscribers.add(queue); 124 | 125 | try { 126 | yield* provide({ next: queue.next }); 127 | } finally { 128 | subscribers.delete(queue); 129 | } 130 | }); 131 | 132 | function send(value: T) { 133 | for (let queue of [...subscribers]) { 134 | queue.add(value); 135 | } 136 | } 137 | 138 | function close(value: TClose) { 139 | for (let queue of [...subscribers]) { 140 | queue.close(value); 141 | } 142 | } 143 | 144 | return { ...subscribe, send, close }; 145 | } 146 | -------------------------------------------------------------------------------- /lib/sleep.ts: -------------------------------------------------------------------------------- 1 | import type { Operation } from "./types.ts"; 2 | import { action } from "./instructions.ts"; 3 | 4 | /** 5 | * Sleep for the given amount of milliseconds. 6 | * 7 | * @example 8 | * ```typescript 9 | * import { main, sleep } from 'effection'; 10 | * 11 | * await main(function*() { 12 | * yield* sleep(2000); 13 | * console.log("Hello lazy world!"); 14 | * }); 15 | * ``` 16 | * 17 | * @param duration - the number of milliseconds to sleep 18 | */ 19 | export function sleep(duration: number): Operation { 20 | return action((resolve) => { 21 | let timeoutId = setTimeout(resolve, duration); 22 | return () => clearTimeout(timeoutId); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /lib/with-resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Err, Ok } from "./result.ts"; 2 | import { action } from "./instructions.ts"; 3 | import type { Operation, Result } from "./types.ts"; 4 | 5 | /** 6 | * The return type of {@link withResolvers}. It contains an operation bundled with 7 | * synchronous functions that determine its outcome. 8 | */ 9 | export interface WithResolvers { 10 | /* 11 | * An {@link Operation} that will either produce a value or raise an 12 | * exception when either `resolve` or `reject` is called. No matter 13 | * how many times this operation is yielded to, it will always 14 | * produce the same effect. 15 | */ 16 | operation: Operation; 17 | 18 | /** 19 | * Cause {@link operation} to produce `value`. If either `resolve` 20 | * or`reject` has been called before, this will have no effect. 21 | * 22 | * @param value - the value to produce 23 | */ 24 | resolve(value: T): void; 25 | 26 | /** 27 | * Cause {@link operation} to raise `Error`. Any calling operation 28 | * waiting on `operation` will. Yielding to `operation` subsequently 29 | * will also raise the same error. * If either `resolve` or`reject` 30 | * has been called before, this will have no effect. 31 | * 32 | * @param error - the error to raise 33 | */ 34 | reject(error: Error): void; 35 | } 36 | 37 | /** 38 | * Create an {link @Operation} and two functions to resolve or reject 39 | * it, corresponding to the two parameters passed to the executor of 40 | * the {@link action} constructor. This is the Effection equivalent of 41 | * [Promise.withResolvers()]{@link 42 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers} 43 | * 44 | * @returns an operation and its resolvers. 45 | */ 46 | 47 | export function withResolvers(): WithResolvers { 48 | let continuations = new Set<(result: Result) => void>(); 49 | let result: Result | undefined = undefined; 50 | 51 | let operation: Operation = action( 52 | (resolve, reject) => { 53 | let settle = (outcome: Result) => { 54 | if (outcome.ok) { 55 | resolve(outcome.value); 56 | } else { 57 | reject(outcome.error); 58 | } 59 | }; 60 | 61 | if (result) { 62 | settle(result); 63 | return () => {}; 64 | } else { 65 | continuations.add(settle); 66 | return () => continuations.delete(settle); 67 | } 68 | }, 69 | ); 70 | 71 | let settle = (outcome: Result) => { 72 | if (!result) { 73 | result = outcome; 74 | } 75 | for (let continuation of continuations) { 76 | continuation(result); 77 | } 78 | }; 79 | 80 | let resolve = (value: T) => settle(Ok(value)); 81 | let reject = (error: Error) => settle(Err(error)); 82 | 83 | return { operation, resolve, reject }; 84 | } 85 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/mod.ts"; 2 | -------------------------------------------------------------------------------- /tasks/build-jsr.ts: -------------------------------------------------------------------------------- 1 | import jsonDeno from "../deno.json" with { type: "json" }; 2 | 3 | let [version] = Deno.args; 4 | if (!version) { 5 | throw new Error("a version argument is required to build the npm package"); 6 | } 7 | 8 | await Deno.writeTextFile( 9 | new URL("../deno.json", import.meta.url), 10 | JSON.stringify({ 11 | ...jsonDeno, 12 | version, 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /tasks/build-npm.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "jsr:@deno/dnt@0.41.3"; 2 | 3 | const outDir = "./build/npm"; 4 | 5 | await emptyDir(outDir); 6 | 7 | let [version] = Deno.args; 8 | if (!version) { 9 | throw new Error("a version argument is required to build the npm package"); 10 | } 11 | 12 | await build({ 13 | entryPoints: ["./mod.ts"], 14 | outDir, 15 | shims: { 16 | deno: false, 17 | }, 18 | test: false, 19 | typeCheck: false, 20 | compilerOptions: { 21 | lib: ["ESNext", "DOM"], 22 | target: "ES2020", 23 | sourceMap: true, 24 | }, 25 | package: { 26 | // package.json properties 27 | name: "effection", 28 | version, 29 | description: "Structured concurrency and effects for JavaScript", 30 | license: "ISC", 31 | author: "engineering@frontside.com", 32 | repository: { 33 | type: "git", 34 | url: "git+https://github.com/thefrontside/effection.git", 35 | }, 36 | bugs: { 37 | url: "https://github.com/thefrontside/effection/issues", 38 | }, 39 | engines: { 40 | node: ">= 16", 41 | }, 42 | sideEffects: false, 43 | }, 44 | }); 45 | 46 | await Deno.copyFile("README.md", `${outDir}/README.md`); 47 | -------------------------------------------------------------------------------- /test/abort-signal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { run, useAbortSignal } from "../mod.ts"; 3 | 4 | describe("useAbortSignal()", () => { 5 | it("aborts whenever it passes out of scope", async () => { 6 | let state = { aborted: false }; 7 | 8 | let abort = () => state.aborted = true; 9 | 10 | let signal = await run(function* () { 11 | let signal = yield* useAbortSignal(); 12 | signal.addEventListener("abort", abort); 13 | expect(signal.aborted).toEqual(false); 14 | return signal; 15 | }); 16 | expect(signal.aborted).toBe(true); 17 | expect(state).toEqual({ aborted: true }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/action.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | 3 | import { action, run, suspend } from "../mod.ts"; 4 | 5 | describe("action", () => { 6 | it("can resolve", async () => { 7 | let didClear = false; 8 | let task = run(() => 9 | action(function* (resolve) { 10 | let timeout = setTimeout(() => resolve(42), 5); 11 | try { 12 | yield* suspend(); 13 | } finally { 14 | didClear = true; 15 | clearTimeout(timeout); 16 | } 17 | }) 18 | ); 19 | 20 | await expect(task).resolves.toEqual(42); 21 | expect(didClear).toEqual(true); 22 | }); 23 | 24 | it("can reject", async () => { 25 | let didClear = false; 26 | let error = new Error("boom"); 27 | let task = run(() => 28 | action(function* (_, reject) { 29 | let timeout = setTimeout(() => reject(error), 5); 30 | try { 31 | yield* suspend(); 32 | } finally { 33 | didClear = true; 34 | clearTimeout(timeout); 35 | } 36 | }) 37 | ); 38 | 39 | await expect(task).rejects.toEqual(error); 40 | expect(didClear).toEqual(true); 41 | }); 42 | 43 | it("can resolve without ever suspending", async () => { 44 | let result = await run(() => 45 | action(function* (resolve) { 46 | resolve("hello"); 47 | }) 48 | ); 49 | 50 | expect(result).toEqual("hello"); 51 | }); 52 | 53 | it("can reject without ever suspending", async () => { 54 | let error = new Error("boom"); 55 | let task = run(() => 56 | action(function* (_, reject) { 57 | reject(error); 58 | }) 59 | ); 60 | await expect(task).rejects.toEqual(error); 61 | }); 62 | 63 | it("can resolve before it suspends", async () => { 64 | expect( 65 | await run(() => 66 | action(function* (resolve) { 67 | resolve("hello"); 68 | yield* suspend(); 69 | }) 70 | ), 71 | ).toEqual("hello"); 72 | }); 73 | 74 | it("can reject before it suspends", async () => { 75 | let error = new Error("boom"); 76 | let task = run(() => 77 | action(function* (_, reject) { 78 | reject(error); 79 | yield* suspend(); 80 | }) 81 | ); 82 | await expect(task).rejects.toEqual(error); 83 | }); 84 | 85 | it("fails if the operation fails", async () => { 86 | let task = run(() => 87 | action(function* () { 88 | throw new Error("boom"); 89 | }) 90 | ); 91 | await expect(task).rejects.toHaveProperty("message", "boom"); 92 | }); 93 | 94 | it("fails if the shutdown fails", async () => { 95 | let error = new Error("boom"); 96 | let task = run(() => 97 | action(function* (resolve) { 98 | let timeout = setTimeout(resolve, 5); 99 | try { 100 | yield* suspend(); 101 | } finally { 102 | clearTimeout(timeout); 103 | // deno-lint-ignore no-unsafe-finally 104 | throw error; 105 | } 106 | }) 107 | ); 108 | await expect(task).rejects.toEqual(error); 109 | }); 110 | 111 | it("does not reach code that should be aborted", async () => { 112 | let didReach = false; 113 | await run(function Main() { 114 | return action(function* MyAction(resolve) { 115 | resolve(10); 116 | yield* suspend(); 117 | didReach = true; 118 | }); 119 | }); 120 | expect(didReach).toEqual(false); 121 | }); 122 | 123 | describe("v4 api", () => { 124 | it("can resolve", async () => { 125 | let didClear = false; 126 | let task = run(() => 127 | action((resolve) => { 128 | let timeout = setTimeout(() => resolve(42), 5); 129 | return () => { 130 | didClear = true; 131 | clearTimeout(timeout); 132 | }; 133 | }) 134 | ); 135 | 136 | await expect(task).resolves.toEqual(42); 137 | expect(didClear).toEqual(true); 138 | }); 139 | 140 | it("can reject", async () => { 141 | let didClear = false; 142 | let error = new Error("boom"); 143 | let task = run(() => 144 | action((_, reject) => { 145 | let timeout = setTimeout(() => reject(error), 5); 146 | return () => { 147 | didClear = true; 148 | clearTimeout(timeout); 149 | }; 150 | }) 151 | ); 152 | 153 | await expect(task).rejects.toEqual(error); 154 | expect(didClear).toEqual(true); 155 | }); 156 | 157 | it("can resolve without ever suspending", async () => { 158 | let result = await run(() => 159 | action((resolve) => { 160 | resolve("hello"); 161 | return () => {}; 162 | }) 163 | ); 164 | 165 | expect(result).toEqual("hello"); 166 | }); 167 | 168 | it("can reject without ever suspending", async () => { 169 | let error = new Error("boom"); 170 | let task = run(() => 171 | action((_, reject) => { 172 | reject(error); 173 | return () => {}; 174 | }) 175 | ); 176 | await expect(task).rejects.toEqual(error); 177 | }); 178 | 179 | it("fails if the operation fails", async () => { 180 | let task = run(() => 181 | action(() => { 182 | throw new Error("boom"); 183 | }) 184 | ); 185 | await expect(task).rejects.toHaveProperty("message", "boom"); 186 | }); 187 | 188 | it("fails if the shutdown fails", async () => { 189 | let error = new Error("boom"); 190 | let task = run(() => 191 | action((resolve) => { 192 | let timeout = setTimeout(resolve, 5); 193 | return () => { 194 | clearTimeout(timeout); 195 | throw error; 196 | }; 197 | }) 198 | ); 199 | await expect(task).rejects.toEqual(error); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/all.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asyncReject, 3 | asyncResolve, 4 | asyncResource, 5 | describe, 6 | expect, 7 | expectType, 8 | it, 9 | syncReject, 10 | syncResolve, 11 | } from "./suite.ts"; 12 | 13 | import { all, call, type Operation, run, sleep } from "../mod.ts"; 14 | 15 | describe("all()", () => { 16 | it("resolves when the given list is empty", async () => { 17 | let result = await run(() => all([])); 18 | 19 | expect(result).toEqual([]); 20 | }); 21 | 22 | it("resolves when all of the given operations resolve", async () => { 23 | let result = await run(() => 24 | all([ 25 | syncResolve("quox"), 26 | asyncResolve(10, "foo"), 27 | asyncResolve(5, "bar"), 28 | asyncResolve(15, "baz"), 29 | ]) 30 | ); 31 | 32 | expect(result).toEqual(["quox", "foo", "bar", "baz"]); 33 | }); 34 | 35 | it("rejects when one of the given operations rejects asynchronously first", async () => { 36 | let result = run(() => 37 | all([ 38 | asyncResolve(10, "foo"), 39 | asyncReject(5, "bar"), 40 | asyncResolve(15, "baz"), 41 | ]) 42 | ); 43 | 44 | await expect(result).rejects.toHaveProperty("message", "boom: bar"); 45 | }); 46 | 47 | it("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { 48 | let result = run(() => all([sleep(0), asyncReject(5, "bar")])); 49 | 50 | await expect(result).rejects.toHaveProperty("message", "boom: bar"); 51 | }); 52 | 53 | it("resolves when all of the given operations resolve synchronously", async () => { 54 | let result = run(() => 55 | all([ 56 | syncResolve("foo"), 57 | syncResolve("bar"), 58 | syncResolve("baz"), 59 | ]) 60 | ); 61 | 62 | await expect(result).resolves.toEqual(["foo", "bar", "baz"]); 63 | }); 64 | 65 | it("rejects when one of the given operations rejects synchronously first", async () => { 66 | let result = run(() => 67 | all([ 68 | syncResolve("foo"), 69 | syncReject("bar"), 70 | syncResolve("baz"), 71 | ]) 72 | ); 73 | 74 | await expect(result).rejects.toHaveProperty("message", "boom: bar"); 75 | }); 76 | 77 | it("rejects when one of the operations reject", async () => { 78 | await run(function* () { 79 | let fooStatus = { status: "pending" }; 80 | let error; 81 | try { 82 | yield* all([ 83 | asyncResource(20, "foo", fooStatus), 84 | asyncReject(10, "bar"), 85 | ]); 86 | } catch (err) { 87 | error = err; 88 | } 89 | 90 | expect(fooStatus.status).toEqual("pending"); 91 | expect(error).toHaveProperty("message", "boom: bar"); 92 | yield* sleep(40); 93 | expect(fooStatus.status).toEqual("pending"); 94 | }); 95 | }); 96 | 97 | it("has a type signature equivalent to Promise.all()", () => { 98 | let resolve = (value: T) => call(() => value); 99 | 100 | expectType>( 101 | all([resolve("hello"), resolve(42), resolve("world")]), 102 | ); 103 | expectType>( 104 | all([resolve("hello"), resolve(42)]), 105 | ); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/call.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | 3 | import { call, run, spawn, suspend, until } from "../mod.ts"; 4 | 5 | describe("call", () => { 6 | it("evaluates an operation function", async () => { 7 | await run(function* () { 8 | function* fn() { 9 | return 10; 10 | } 11 | let result = yield* call(fn); 12 | expect(result).toEqual(10); 13 | }); 14 | }); 15 | 16 | it("evaluates an operation directly", async () => { 17 | await expect(run(() => 18 | call({ 19 | *[Symbol.iterator]() { 20 | return 42; 21 | }, 22 | }) 23 | )).resolves.toEqual(42); 24 | }); 25 | 26 | it("evaluates an async function", async () => { 27 | await expect(run(() => 28 | call(async function () { 29 | await Promise.resolve(); 30 | return 42; 31 | }) 32 | )).resolves.toEqual(42); 33 | }); 34 | 35 | it("evaluates a no-arg async function", async () => { 36 | await expect(run(() => call(() => Promise.resolve(42)))).resolves.toEqual( 37 | 42, 38 | ); 39 | }); 40 | 41 | it("evaluates a promise directly", async () => { 42 | await expect(run(() => call(Promise.resolve(42)))).resolves.toEqual(42); 43 | }); 44 | 45 | it("evaluates a promise directly with `until`", async () => { 46 | await expect(run(() => until(Promise.resolve(42)))).resolves.toEqual(42); 47 | }); 48 | 49 | it("can be used as an error boundary", async () => { 50 | let error = new Error("boom!"); 51 | let result = await run(function* () { 52 | try { 53 | yield* call(function* () { 54 | yield* spawn(function* () { 55 | throw error; 56 | }); 57 | yield* suspend(); 58 | }); 59 | } catch (error) { 60 | return error; 61 | } 62 | }); 63 | expect(result).toEqual(error); 64 | }); 65 | 66 | it("evaluates a vanilla function", async () => { 67 | await expect(run(() => call(() => 42))).resolves.toEqual(42); 68 | }); 69 | 70 | it("evaluates a vanilla function that returns an iterable string", async () => { 71 | await expect(run(() => call(() => "123"))).resolves.toEqual("123"); 72 | }); 73 | 74 | it("evaluates a vanilla function that returns an iterable array", async () => { 75 | await expect(run(() => call(() => [1, 2, 3]))).resolves.toEqual([1, 2, 3]); 76 | }); 77 | 78 | it("evaluates a vanilla function that returns an iterable map", async () => { 79 | let map = new Map(); 80 | map.set("1", 1); 81 | map.set("2", 2); 82 | map.set("3", 3); 83 | await expect(run(() => call(() => map))).resolves.toEqual(map); 84 | }); 85 | 86 | it("evaluates a vanilla function that returns an iterable set", async () => { 87 | let arr = new Set([1, 2, 3]); 88 | await expect(run(() => call(() => arr))).resolves.toEqual(arr); 89 | }); 90 | 91 | it("evaluates a vanilla function that returns an iterator", async () => { 92 | function* eq() { 93 | return "value"; 94 | } 95 | await expect(run(function* () { 96 | return yield* call(() => eq); 97 | })).resolves.toEqual(eq); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/channel.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterEach as $afterEach, 3 | beforeEach as $beforeEach, 4 | describe, 5 | expect, 6 | it as $it, 7 | } from "./suite.ts"; 8 | 9 | import type { Channel, Operation } from "../mod.ts"; 10 | import { createChannel, createScope, sleep, spawn } from "../mod.ts"; 11 | 12 | let [scope, close] = createScope(); 13 | 14 | describe("Channel", () => { 15 | $beforeEach(() => { 16 | [scope, close] = createScope(); 17 | }); 18 | $afterEach(() => close()); 19 | 20 | it("does not use the same event twice when serially subscribed to a channel", function* () { 21 | let input = createChannel(); 22 | 23 | let actual: string[] = []; 24 | function* channel() { 25 | yield* sleep(10); 26 | yield* input.send("one"); 27 | yield* input.send("two"); 28 | } 29 | 30 | function* root() { 31 | yield* spawn(channel); 32 | 33 | let subscription = yield* input; 34 | let result = yield* subscription.next(); 35 | actual.push(result.value as string); 36 | 37 | subscription = yield* input; 38 | result = yield* subscription.next(); 39 | actual.push(result.value as string); 40 | } 41 | 42 | yield* root(); 43 | expect(actual).toEqual(["one", "two"]); 44 | }); 45 | 46 | describe("subscribe", () => { 47 | let channel: Channel; 48 | 49 | beforeEach(function* () { 50 | channel = createChannel(); 51 | }); 52 | 53 | describe("sending a message", () => { 54 | it("receives message on subscription", function* () { 55 | let subscription = yield* channel; 56 | yield* channel.send("hello"); 57 | let result = yield* subscription.next(); 58 | expect(result.done).toEqual(false); 59 | expect(result.value).toEqual("hello"); 60 | }); 61 | }); 62 | 63 | describe("blocking on next", () => { 64 | it("receives message on subscription done", function* () { 65 | let subscription = yield* channel; 66 | let result = yield* spawn(() => subscription.next()); 67 | yield* sleep(10); 68 | yield* channel.send("hello"); 69 | expect(yield* result).toHaveProperty("value", "hello"); 70 | }); 71 | }); 72 | 73 | describe("sending multiple messages", () => { 74 | it("receives messages in order", function* () { 75 | let subscription = yield* channel; 76 | let { send } = channel; 77 | yield* send("hello"); 78 | yield* send("foo"); 79 | yield* send("bar"); 80 | expect(yield* subscription.next()).toHaveProperty("value", "hello"); 81 | expect(yield* subscription.next()).toHaveProperty("value", "foo"); 82 | expect(yield* subscription.next()).toHaveProperty("value", "bar"); 83 | }); 84 | }); 85 | 86 | describe("with split ends", () => { 87 | it("receives message on subscribable end", function* () { 88 | let channel = createChannel(); 89 | 90 | let subscription = yield* channel; 91 | 92 | yield* channel.send("hello"); 93 | 94 | expect(yield* subscription.next()).toEqual({ 95 | done: false, 96 | value: "hello", 97 | }); 98 | }); 99 | }); 100 | 101 | describe("close", () => { 102 | describe("without argument", () => { 103 | it("closes subscriptions", function* () { 104 | let channel = createChannel(); 105 | let subscription = yield* channel; 106 | yield* channel.send("foo"); 107 | yield* channel.close(); 108 | expect(yield* subscription.next()).toEqual({ 109 | done: false, 110 | value: "foo", 111 | }); 112 | expect(yield* subscription.next()).toEqual({ 113 | done: true, 114 | value: undefined, 115 | }); 116 | }); 117 | }); 118 | 119 | describe("with close argument", () => { 120 | it("closes subscriptions with the argument", function* () { 121 | let channel = createChannel(); 122 | let subscription = yield* channel; 123 | yield* channel.send("foo"); 124 | yield* channel.close(12); 125 | 126 | expect(yield* subscription.next()).toEqual({ 127 | done: false, 128 | value: "foo", 129 | }); 130 | expect(yield* subscription.next()).toEqual({ done: true, value: 12 }); 131 | }); 132 | }); 133 | }); 134 | }); 135 | }); 136 | 137 | function beforeEach(op: () => Operation): void { 138 | $beforeEach(() => scope.run(op)); 139 | } 140 | 141 | function it(desc: string, op: () => Operation): void { 142 | $it(desc, () => scope.run(op)); 143 | } 144 | -------------------------------------------------------------------------------- /test/context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | 3 | import { call, createContext, run } from "../mod.ts"; 4 | 5 | const numbers = createContext("number", 3); 6 | 7 | describe("context", () => { 8 | it("has the initial value available at all times", async () => { 9 | expect( 10 | await run(function* () { 11 | return yield* numbers.expect(); 12 | }), 13 | ).toEqual(3); 14 | }); 15 | 16 | it("can delimit the bounds of a context within a single scope", async () => { 17 | let values = await run(function* () { 18 | let before = yield* numbers.get(); 19 | 20 | let within = yield* numbers.with(22, function* () { 21 | return yield* numbers.get(); 22 | }); 23 | 24 | let after = yield* numbers.get(); 25 | return [before, within, after]; 26 | }); 27 | 28 | expect(values).toEqual([3, 22, 3]); 29 | }); 30 | 31 | it("can be set within a given scope, but reverts after", async () => { 32 | let values = await run(function* () { 33 | let before = yield* numbers.expect(); 34 | let within = yield* call(function* () { 35 | yield* numbers.set(22); 36 | return yield* numbers.expect(); 37 | }); 38 | let after = yield* numbers.expect(); 39 | return [before, within, after]; 40 | }); 41 | 42 | expect(values).toEqual([3, 22, 3]); 43 | }); 44 | 45 | it("is safe to get() when context is not defined", async () => { 46 | let result = await run(function* () { 47 | return yield* createContext("missing").get(); 48 | }); 49 | expect(result).toBeUndefined(); 50 | }); 51 | 52 | it("is an error to expect() when context is missing", async () => { 53 | await expect(run(function* () { 54 | yield* createContext("missing").expect(); 55 | })).rejects.toHaveProperty("name", "MissingContextError"); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/each.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { 3 | createChannel, 4 | createQueue, 5 | each, 6 | resource, 7 | run, 8 | spawn, 9 | type Stream, 10 | suspend, 11 | } from "../mod.ts"; 12 | 13 | describe("each", () => { 14 | it("can be used to iterate a stream", async () => { 15 | await run(function* () { 16 | let channel = createChannel(); 17 | let actual = [] as string[]; 18 | yield* spawn(function* () { 19 | for (let value of yield* each(channel)) { 20 | actual.push(value); 21 | yield* each.next(); 22 | } 23 | }); 24 | 25 | yield* channel.send("one"); 26 | yield* channel.send("two"); 27 | yield* channel.send("three"); 28 | 29 | expect(actual).toEqual(["one", "two", "three"]); 30 | }); 31 | }); 32 | 33 | it("can be used to iterate nested streams", async () => { 34 | await run(function* () { 35 | let actual = [] as string[]; 36 | let outer = createChannel(); 37 | let inner = createChannel(); 38 | 39 | yield* spawn(function* () { 40 | for (let value of yield* each(outer)) { 41 | actual.push(value); 42 | for (let value of yield* each(inner)) { 43 | actual.push(value); 44 | yield* each.next(); 45 | } 46 | yield* each.next(); 47 | } 48 | }); 49 | 50 | yield* outer.send("one"); 51 | yield* inner.send("two"); 52 | yield* inner.send("two and a half"); 53 | yield* inner.close(); 54 | yield* outer.send("three"); 55 | yield* inner.send("four"); 56 | yield* inner.close(); 57 | yield* outer.close(); 58 | 59 | expect(actual).toEqual(["one", "two", "two and a half", "three", "four"]); 60 | }); 61 | }); 62 | 63 | it("handles context correctly if you break out of a loop", async () => { 64 | await expect(run(function* () { 65 | let channel = createChannel(); 66 | 67 | yield* spawn(function* () { 68 | for (let _ of yield* each(channel)) { 69 | break; 70 | } 71 | // we're out of the loop, each.next() should be invalid. 72 | yield* each.next(); 73 | }); 74 | 75 | yield* channel.send("hello"); 76 | yield* suspend(); 77 | })).rejects.toHaveProperty("name", "IterationError"); 78 | }); 79 | 80 | it("throws an error if you forget to invoke each.next()", async () => { 81 | await expect(run(function* () { 82 | let channel = createChannel(); 83 | yield* spawn(function* () { 84 | for (let _ of yield* each(channel)) { 85 | _; 86 | } 87 | }); 88 | yield* channel.send("hello"); 89 | yield* suspend(); 90 | })).rejects.toHaveProperty("name", "IterationError"); 91 | }); 92 | 93 | it("throws an error if you invoke each.next() out of context", async () => { 94 | await expect(run(() => each.next())).rejects.toHaveProperty( 95 | "name", 96 | "MissingContextError", 97 | ); 98 | }); 99 | 100 | it("closes the stream after exiting from the loop", async () => { 101 | let state = { status: "pending" }; 102 | let stream: Stream = resource(function* (provide) { 103 | try { 104 | state.status = "active"; 105 | yield* provide(yield* sequence("one", "two")); 106 | } finally { 107 | state.status = "closed"; 108 | } 109 | }); 110 | 111 | await run(function* () { 112 | yield* spawn(function* () { 113 | for (let _ of yield* each(stream)) { 114 | expect(state.status).toEqual("active"); 115 | yield* each.next(); 116 | } 117 | }); 118 | }); 119 | 120 | expect(state.status).toEqual("closed"); 121 | }); 122 | }); 123 | 124 | function sequence(...values: string[]): Stream { 125 | return resource(function* (provide) { 126 | let q = createQueue(); 127 | for (let value of values) { 128 | q.add(value); 129 | } 130 | q.close(); 131 | yield* provide(q); 132 | }); 133 | } 134 | -------------------------------------------------------------------------------- /test/ensure.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | 3 | import { ensure, run, sleep } from "../mod.ts"; 4 | 5 | describe("ensure", () => { 6 | it("runs the given operation at the end of the task", async () => { 7 | let state = "pending"; 8 | 9 | let root = run(function* () { 10 | yield* sleep(10); 11 | yield* ensure(function* () { 12 | state = "started"; 13 | yield* sleep(10); 14 | state = "completed"; 15 | }); 16 | }); 17 | 18 | await run(() => sleep(5)); 19 | expect(state).toEqual("pending"); 20 | await run(() => sleep(10)); 21 | expect(state).toEqual("started"); 22 | await root; 23 | expect(state).toEqual("completed"); 24 | }); 25 | 26 | it("runs the given function at the end of the task", async () => { 27 | let state = "pending"; 28 | 29 | let root = run(function* rootTask() { 30 | yield* sleep(10); 31 | yield* ensure(() => { 32 | state = "completed"; 33 | }); 34 | }); 35 | 36 | await run(() => sleep(5)); 37 | expect(state).toEqual("pending"); 38 | await root; 39 | expect(state).toEqual("completed"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/events.test.ts: -------------------------------------------------------------------------------- 1 | import { on, once } from "../mod.ts"; 2 | import type { Operation, Stream } from "../mod.ts"; 3 | import { describe, expectType, it } from "./suite.ts"; 4 | 5 | describe("events", () => { 6 | describe("types", () => { 7 | const domElement = {} as HTMLElement; 8 | const socket = {} as WebSocket; 9 | 10 | it("should find event from eventTarget", () => { 11 | expectType>(once(socket, "close")); 12 | expectType>(once(domElement, "click")); 13 | 14 | // deno-lint-ignore no-explicit-any 15 | expectType, never>>(on(socket, "message")); 16 | }); 17 | 18 | it("should fall back to event", () => { 19 | expectType>(once(domElement, "mycustomevent")); 20 | 21 | expectType>(on(domElement, "anothercustomevent")); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/lift.test.ts: -------------------------------------------------------------------------------- 1 | import { createSignal, each, lift, run, sleep, spawn } from "../mod.ts"; 2 | import { describe, expect, it } from "./suite.ts"; 3 | 4 | describe("lift", () => { 5 | it("safely does not continue if the call stops the operation", async () => { 6 | let reached = false; 7 | 8 | await run(function* () { 9 | let signal = createSignal(); 10 | 11 | yield* spawn(function* () { 12 | yield* sleep(0); 13 | yield* lift(signal.close)(); 14 | 15 | reached = true; 16 | }); 17 | 18 | for (let _ of yield* each(signal)); 19 | }); 20 | 21 | expect(reached).toBe(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/main.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, useCommand } from "./suite.ts"; 2 | import { run, sleep } from "../mod.ts"; 3 | 4 | describe("main", () => { 5 | it("gracefully shuts down on SIGINT", async () => { 6 | await run(function* () { 7 | let daemon = yield* useCommand("deno", { 8 | stdout: "piped", 9 | args: ["run", "test/main/ok.daemon.ts"], 10 | }); 11 | let stdout = yield* buffer(daemon.stdout); 12 | yield* detect(stdout, "started"); 13 | 14 | daemon.kill("SIGINT"); 15 | 16 | let status = yield* daemon.status; 17 | 18 | expect(status.code).toBe(130); 19 | 20 | yield* detect(stdout, "gracefully stopped"); 21 | }); 22 | }); 23 | 24 | it("gracefully shuts down on SIGTERM", async () => { 25 | await run(function* () { 26 | let daemon = yield* useCommand("deno", { 27 | stdout: "piped", 28 | args: ["run", "test/main/ok.daemon.ts"], 29 | }); 30 | let stdout = yield* buffer(daemon.stdout); 31 | yield* detect(stdout, "started"); 32 | 33 | daemon.kill("SIGTERM"); 34 | 35 | let status = yield* daemon.status; 36 | 37 | expect(status.code).toBe(143); 38 | 39 | yield* detect(stdout, "gracefully stopped"); 40 | }); 41 | }); 42 | 43 | it("exits gracefully on explicit exit()", async () => { 44 | await run(function* () { 45 | let cmd = yield* useCommand("deno", { 46 | stdout: "piped", 47 | args: ["run", "test/main/ok.exit.ts"], 48 | }); 49 | 50 | let stdout = yield* buffer(cmd.stdout); 51 | 52 | yield* detect(stdout, "goodbye.\nOk, computer."); 53 | }); 54 | }); 55 | 56 | it("exits gracefully with 0 on implicit exit", async () => { 57 | await run(function* () { 58 | let cmd = yield* useCommand("deno", { 59 | stdout: "piped", 60 | args: ["run", "test/main/ok.implicit.ts"], 61 | }); 62 | 63 | let stdout = yield* buffer(cmd.stdout); 64 | let status = yield* cmd.status; 65 | 66 | yield* detect(stdout, "goodbye."); 67 | expect(status.code).toEqual(0); 68 | }); 69 | }); 70 | 71 | it("exits gracefully on explicit exit failure exit()", async () => { 72 | await run(function* () { 73 | let cmd = yield* useCommand("deno", { 74 | stdout: "piped", 75 | stderr: "piped", 76 | args: ["run", "test/main/fail.exit.ts"], 77 | }); 78 | let stdout = yield* buffer(cmd.stdout); 79 | let stderr = yield* buffer(cmd.stderr); 80 | let status = yield* cmd.status; 81 | 82 | yield* detect(stdout, "graceful goodbye"); 83 | yield* detect(stderr, "It all went horribly wrong"); 84 | expect(status.code).toEqual(23); 85 | }); 86 | }); 87 | 88 | it("error exits gracefully on unexpected errors", async () => { 89 | await run(function* () { 90 | let cmd = yield* useCommand("deno", { 91 | stdout: "piped", 92 | stderr: "piped", 93 | args: ["run", "test/main/fail.unexpected.ts"], 94 | }); 95 | 96 | let stdout = yield* buffer(cmd.stdout); 97 | let stderr = yield* buffer(cmd.stderr); 98 | let status = yield* cmd.status; 99 | 100 | yield* detect(stdout, "graceful goodbye"); 101 | yield* detect(stderr, "Error: moo"); 102 | expect(status.code).toEqual(1); 103 | }); 104 | }); 105 | 106 | it("works even if suspend is the only operation", async () => { 107 | await run(function* () { 108 | let process = yield* useCommand("deno", { 109 | stdout: "piped", 110 | args: ["run", "test/main/just.suspend.ts"], 111 | }); 112 | let stdout = yield* buffer(process.stdout); 113 | yield* detect(stdout, "started"); 114 | 115 | process.kill("SIGINT"); 116 | 117 | let status = yield* process.status; 118 | 119 | expect(status.code).toBe(130); 120 | 121 | yield* detect(stdout, "gracefully stopped"); 122 | }); 123 | }); 124 | }); 125 | 126 | import type { Operation } from "../lib/types.ts"; 127 | import { resource, spawn } from "../lib/instructions.ts"; 128 | 129 | interface Buffer { 130 | content: string; 131 | } 132 | 133 | function buffer(stream: ReadableStream): Operation { 134 | return resource<{ content: string }>(function* (provide) { 135 | let buff = { content: " " }; 136 | yield* spawn(function* () { 137 | let decoder = new TextDecoder(); 138 | let reader = stream.getReader(); 139 | 140 | try { 141 | let next = yield* reader.read(); 142 | while (!next.done) { 143 | buff.content += decoder.decode(next.value); 144 | next = yield* reader.read(); 145 | } 146 | } finally { 147 | yield* reader.cancel(); 148 | } 149 | }); 150 | 151 | yield* provide(buff); 152 | }); 153 | } 154 | 155 | function* detect( 156 | buffer: Buffer, 157 | text: string, 158 | options: { timeout: number } = { timeout: 1000 }, 159 | ): Operation { 160 | let start = new Date().getTime(); 161 | 162 | while ((new Date().getTime() - start) < options.timeout) { 163 | if (buffer.content.includes(text)) { 164 | return; 165 | } 166 | yield* sleep(10); 167 | } 168 | expect(buffer.content).toMatch(new RegExp(text)); 169 | } 170 | -------------------------------------------------------------------------------- /test/main/fail.exit.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "../../lib/sleep.ts"; 2 | import { exit, main } from "../../lib/main.ts"; 3 | import { spawn, suspend } from "../../lib/instructions.ts"; 4 | 5 | await main(function* () { 6 | yield* spawn(function* () { 7 | try { 8 | yield* suspend(); 9 | } finally { 10 | console.log("graceful goodbye"); 11 | } 12 | }); 13 | 14 | yield* sleep(10); 15 | yield* exit(23, "It all went horribly wrong"); 16 | }); 17 | -------------------------------------------------------------------------------- /test/main/fail.unexpected.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "../../lib/sleep.ts"; 2 | import { main } from "../../lib/main.ts"; 3 | import { spawn, suspend } from "../../lib/instructions.ts"; 4 | 5 | await main(function* () { 6 | yield* spawn(function* () { 7 | try { 8 | yield* suspend(); 9 | } finally { 10 | console.log("graceful goodbye"); 11 | } 12 | }); 13 | 14 | yield* sleep(10); 15 | throw new Error("moo"); 16 | }); 17 | -------------------------------------------------------------------------------- /test/main/just.suspend.ts: -------------------------------------------------------------------------------- 1 | import { suspend } from "../../lib/instructions.ts"; 2 | import { main } from "../../lib/main.ts"; 3 | 4 | await main(function* () { 5 | console.log("started"); 6 | try { 7 | yield* suspend(); 8 | } finally { 9 | console.log("gracefully stopped"); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /test/main/node.mjs: -------------------------------------------------------------------------------- 1 | import { main } from "../../build/npm/esm/mod.js"; 2 | 3 | await main(function* ([hello, world]) { 4 | if (hello !== "hello" && world !== "world") { 5 | throw new Error("arguments were not properly passed to main operation"); 6 | } 7 | console.log("hello world"); 8 | }); 9 | -------------------------------------------------------------------------------- /test/main/ok.daemon.ts: -------------------------------------------------------------------------------- 1 | import { main } from "../../lib/main.ts"; 2 | import { sleep } from "../../lib/sleep.ts"; 3 | 4 | await main(function* () { 5 | console.log(`started: ${Deno.pid}`); 6 | 7 | try { 8 | yield* sleep(100_000_000); 9 | } finally { 10 | console.log(`gracefully stopped: ${Deno.pid}`); 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /test/main/ok.exit.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "../../lib/sleep.ts"; 2 | import { spawn, suspend } from "../../lib/instructions.ts"; 3 | import { exit, main } from "../../lib/main.ts"; 4 | 5 | await main(function* () { 6 | yield* spawn(function* () { 7 | try { 8 | yield* suspend(); 9 | } finally { 10 | console.log("goodbye."); 11 | } 12 | }); 13 | 14 | yield* sleep(10); 15 | 16 | yield* exit(0, "Ok, computer."); 17 | }); 18 | -------------------------------------------------------------------------------- /test/main/ok.implicit.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "../../lib/sleep.ts"; 2 | import { spawn, suspend } from "../../lib/instructions.ts"; 3 | import { main } from "../../lib/main.ts"; 4 | 5 | await main(function* () { 6 | yield* spawn(function* () { 7 | try { 8 | yield* suspend(); 9 | } finally { 10 | console.log("goodbye."); 11 | } 12 | }); 13 | 14 | yield* sleep(10); 15 | }); 16 | -------------------------------------------------------------------------------- /test/queue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { 3 | action, 4 | createQueue, 5 | type Operation, 6 | run, 7 | sleep, 8 | spawn, 9 | } from "../mod.ts"; 10 | 11 | describe("Queue", () => { 12 | it("adds value to an already waiting listener", async () => { 13 | await run(function* () { 14 | let q = createQueue(); 15 | let listener = yield* spawn(() => q.next()); 16 | q.add("hello"); 17 | expect(yield* listener).toEqual({ done: false, value: "hello" }); 18 | }); 19 | }); 20 | 21 | it("only adds value to one listener if there are multiple", async () => { 22 | await run(function* () { 23 | let q = createQueue(); 24 | let listener1 = yield* spawn(() => abortAfter(q.next(), 10)); 25 | let listener2 = yield* spawn(() => abortAfter(q.next(), 10)); 26 | q.add("hello"); 27 | expect([yield* listener1, yield* listener2].filter(Boolean)).toEqual([{ 28 | done: false, 29 | value: "hello", 30 | }]); 31 | }); 32 | }); 33 | 34 | it("queues value if there is no listener", async () => { 35 | await run(function* () { 36 | let q = createQueue(); 37 | q.add("hello"); 38 | expect(yield* q.next()).toEqual({ 39 | done: false, 40 | value: "hello", 41 | }); 42 | }); 43 | }); 44 | 45 | it("can close", async () => { 46 | await run(function* () { 47 | let q = createQueue(); 48 | let listener = yield* spawn(() => q.next()); 49 | q.close(42); 50 | expect(yield* listener).toEqual({ done: true, value: 42 }); 51 | }); 52 | }); 53 | }); 54 | 55 | function abortAfter(op: Operation, ms: number): Operation { 56 | return action(function* (resolve, reject) { 57 | yield* spawn(function* () { 58 | try { 59 | resolve(yield* op); 60 | } catch (error) { 61 | reject(error as Error); 62 | } 63 | }); 64 | yield* sleep(ms); 65 | resolve(); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /test/race.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asyncReject, 3 | asyncResolve, 4 | describe, 5 | expect, 6 | expectType, 7 | it, 8 | syncReject, 9 | syncResolve, 10 | } from "./suite.ts"; 11 | 12 | import { call, type Operation, race, run } from "../mod.ts"; 13 | 14 | describe("race()", () => { 15 | it("resolves when one of the given operations resolves asynchronously first", async () => { 16 | let result = run(() => 17 | race([ 18 | asyncResolve(10, "foo"), 19 | asyncResolve(5, "bar"), 20 | asyncReject(15, "baz"), 21 | ]) 22 | ); 23 | 24 | await expect(result).resolves.toEqual("bar"); 25 | }); 26 | 27 | it("rejects when one of the given operations rejects asynchronously first", async () => { 28 | let result = run(() => 29 | race([ 30 | asyncResolve(10, "foo"), 31 | asyncReject(5, "bar"), 32 | asyncReject(15, "baz"), 33 | ]) 34 | ); 35 | 36 | await expect(result).rejects.toHaveProperty("message", "boom: bar"); 37 | }); 38 | 39 | it("resolves when one of the given operations resolves synchronously first", async () => { 40 | let result = run(() => 41 | race([ 42 | syncResolve("foo"), 43 | syncResolve("bar"), 44 | syncReject("baz"), 45 | ]) 46 | ); 47 | 48 | await expect(result).resolves.toEqual("foo"); 49 | }); 50 | 51 | it("rejects when one of the given operations rejects synchronously first", async () => { 52 | let result = run(() => 53 | race([ 54 | syncReject("foo"), 55 | syncResolve("bar"), 56 | syncReject("baz"), 57 | ]) 58 | ); 59 | 60 | await expect(result).rejects.toHaveProperty("message", "boom: foo"); 61 | }); 62 | 63 | it("has a type signature equivalent to Promise.race()", () => { 64 | let resolve = (value: T) => call(() => value); 65 | 66 | expectType>( 67 | race([resolve("hello"), resolve(42), resolve("world")]), 68 | ); 69 | expectType>( 70 | race([resolve("hello"), resolve(42)]), 71 | ); 72 | expectType>( 73 | race([resolve("hello"), resolve(42), resolve("world"), resolve(true)]), 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/resource.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { 3 | type Operation, 4 | resource, 5 | run, 6 | sleep, 7 | spawn, 8 | suspend, 9 | } from "../mod.ts"; 10 | 11 | type State = { status: string }; 12 | 13 | function createResource(container: State): Operation { 14 | return resource(function* (provide) { 15 | yield* spawn(function* () { 16 | yield* sleep(5); 17 | container.status = "active"; 18 | }); 19 | 20 | yield* sleep(1); 21 | 22 | try { 23 | yield* provide(container); 24 | } finally { 25 | container.status = "finalized"; 26 | } 27 | }); 28 | } 29 | 30 | describe("resource", () => { 31 | it("runs resource in task scope", async () => { 32 | let state = { status: "pending" }; 33 | await run(function* () { 34 | let result = yield* createResource(state); 35 | expect(result).toBe(state); 36 | expect(state.status).toEqual("pending"); 37 | yield* sleep(10); 38 | expect(state.status).toEqual("active"); 39 | }); 40 | expect(state.status).toEqual("finalized"); 41 | }); 42 | 43 | it("throws init error", async () => { 44 | let task = run(function* () { 45 | yield* resource(function* () { 46 | throw new Error("moo"); 47 | }); 48 | yield* suspend(); 49 | }); 50 | 51 | await expect(task).rejects.toHaveProperty("message", "moo"); 52 | }); 53 | 54 | it("raises an error if an error occurs after init", async () => { 55 | let task = run(function* () { 56 | yield* spawn(function* () { 57 | yield* sleep(5); 58 | throw new Error("moo"); 59 | }); 60 | try { 61 | yield* sleep(10); 62 | } catch (error) { 63 | return error; 64 | } 65 | }); 66 | await expect(task).rejects.toHaveProperty("message", "moo"); 67 | }); 68 | 69 | it("terminates resource when task completes", async () => { 70 | let result = await run(function* () { 71 | return yield* createResource({ status: "pending" }); 72 | }); 73 | expect(result.status).toEqual("finalized"); 74 | }); 75 | 76 | it("can halt the resource constructor if the containing task halts", async () => { 77 | let state = { status: "pending" }; 78 | let task = run(function* () { 79 | yield* createResource(state); 80 | yield* suspend(); 81 | }); 82 | await task.halt(); 83 | 84 | expect(state.status).toEqual("pending"); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/run.test.ts: -------------------------------------------------------------------------------- 1 | import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; 2 | import { run, sleep, spawn, suspend } from "../mod.ts"; 3 | import type { Task } from "../mod.ts"; 4 | 5 | describe("run()", () => { 6 | it("can run an operation", async () => { 7 | await expect(run(function* () { 8 | return "hello"; 9 | })).resolves.toEqual("hello"); 10 | }); 11 | 12 | it("can compose multiple promises via generator", async () => { 13 | let result = await run(function* () { 14 | let one = yield* Promise.resolve(12); 15 | let two = yield* Promise.resolve(55); 16 | return one + two; 17 | }); 18 | expect(result).toEqual(67); 19 | }); 20 | 21 | it("can compose operations", async () => { 22 | let result = await run(function* () { 23 | let one = yield* createNumber(12); 24 | let two = yield* createNumber(55); 25 | return one + two; 26 | }); 27 | expect(result).toEqual(67); 28 | }); 29 | 30 | it("rejects generator if subtask promise fails", async () => { 31 | let error = new Error("boom"); 32 | let task = run(function* () { 33 | let one = yield* createNumber(12); 34 | let two = yield* blowUp(); 35 | return one + two; 36 | }); 37 | await expect(task).rejects.toEqual(error); 38 | }); 39 | 40 | it("rejects generator if generator creation fails", async () => { 41 | let task = run(function () { 42 | throw new Error("boom"); 43 | }); 44 | await expect(task).rejects.toHaveProperty("message", "boom"); 45 | }); 46 | 47 | it("can recover from errors in promise", async () => { 48 | let error = new Error("boom"); 49 | let task = run(function* () { 50 | let one = yield* Promise.resolve(12); 51 | let two; 52 | try { 53 | yield* Promise.reject(error); 54 | two = 9; 55 | } catch (_) { 56 | // swallow error and yield in catch block 57 | two = yield* Promise.resolve(8); 58 | } 59 | let three = yield* Promise.resolve(55); 60 | return one + two + three; 61 | }); 62 | await expect(task).resolves.toEqual(75); 63 | }); 64 | 65 | it("can recover from errors in operation", async () => { 66 | let task = run(function* () { 67 | let one = yield* Promise.resolve(12); 68 | let two; 69 | try { 70 | yield* blowUp(); 71 | two = 9; 72 | } catch (_e) { 73 | // swallow error and yield in catch block 74 | two = yield* Promise.resolve(8); 75 | } 76 | let three = yield* Promise.resolve(55); 77 | return one + two + three; 78 | }); 79 | await expect(task).resolves.toEqual(75); 80 | }); 81 | 82 | it("can halt generator", async () => { 83 | let halted = false; 84 | let task = run(function* () { 85 | try { 86 | yield* suspend(); 87 | } finally { 88 | halted = true; 89 | } 90 | }); 91 | 92 | await task.halt(); 93 | 94 | await expect(task).rejects.toHaveProperty("message", "halted"); 95 | expect(halted).toEqual(true); 96 | }); 97 | 98 | it("halts task when halted generator", async () => { 99 | let parent = "running"; 100 | let child = "running"; 101 | let task = run(function* () { 102 | try { 103 | yield* (function* () { 104 | try { 105 | yield* suspend(); 106 | } finally { 107 | child = "halted"; 108 | } 109 | })(); 110 | } finally { 111 | parent = "halted"; 112 | } 113 | }); 114 | 115 | await task.halt(); 116 | 117 | await expect(task).rejects.toHaveProperty("message", "halted"); 118 | expect(child).toEqual("halted"); 119 | expect(parent).toEqual("halted"); 120 | }); 121 | 122 | it("can perform async operations in a finally block", async () => { 123 | let completed = false; 124 | 125 | let task = run(function* () { 126 | try { 127 | yield* suspend(); 128 | } finally { 129 | yield* sleep(10); 130 | completed = true; 131 | } 132 | }); 133 | 134 | await task.halt(); 135 | 136 | expect(completed).toEqual(true); 137 | }); 138 | 139 | it("cannot explicitly suspend in a finally block", async () => { 140 | let done = false; 141 | let task = run(function* () { 142 | try { 143 | yield* suspend(); 144 | } finally { 145 | yield* suspend(); 146 | done = true; 147 | } 148 | }); 149 | 150 | await task.halt(); 151 | expect(done).toEqual(true); 152 | }); 153 | 154 | it("can suspend in yielded finally block", async () => { 155 | let things: string[] = []; 156 | 157 | let task = run(function* () { 158 | try { 159 | yield* (function* () { 160 | try { 161 | yield* suspend(); 162 | } finally { 163 | yield* sleep(5); 164 | things.push("first"); 165 | } 166 | })(); 167 | } finally { 168 | things.push("second"); 169 | } 170 | }); 171 | 172 | await task.halt(); 173 | 174 | await expect(task).rejects.toHaveProperty("message", "halted"); 175 | 176 | expect(things).toEqual(["first", "second"]); 177 | }); 178 | 179 | it("can be halted while in the generator", async () => { 180 | let task = run(function* Main() { 181 | yield* spawn(function* Boomer() { 182 | yield* sleep(2); 183 | throw new Error("boom"); 184 | }); 185 | 186 | yield* suspend(); 187 | }); 188 | 189 | await expect(task).rejects.toHaveProperty("message", "boom"); 190 | }); 191 | 192 | it("can halt itself", async () => { 193 | let task: Task = run(function* () { 194 | yield* sleep(3); 195 | yield* task.halt(); 196 | }); 197 | 198 | await expect(task).rejects.toHaveProperty("message", "halted"); 199 | }); 200 | 201 | it("can halt itself between yield points", async () => { 202 | let task: Task = run(function* () { 203 | yield* sleep(1); 204 | 205 | yield* spawn(function* () { 206 | yield* task.halt(); 207 | }); 208 | 209 | yield* suspend(); 210 | }); 211 | 212 | await expect(task).rejects.toHaveProperty("message", "halted"); 213 | }); 214 | 215 | it("can delay halt if child fails", async () => { 216 | let didRun = false; 217 | let task = run(function* () { 218 | yield* spawn(function* willBoom() { 219 | yield* sleep(5); 220 | throw new Error("boom"); 221 | }); 222 | try { 223 | yield* suspend(); 224 | } finally { 225 | yield* sleep(20); 226 | didRun = true; 227 | } 228 | }); 229 | 230 | await run(() => sleep(10)); 231 | 232 | await expect(task).rejects.toHaveProperty("message", "boom"); 233 | expect(didRun).toEqual(true); 234 | }); 235 | 236 | it("can throw error when child blows up", async () => { 237 | let task = run(function* Main() { 238 | yield* spawn(function* Boomer() { 239 | yield* sleep(5); 240 | throw new Error("boom"); 241 | }); 242 | try { 243 | yield* suspend(); 244 | } finally { 245 | // deno-lint-ignore no-unsafe-finally 246 | throw new Error("bang"); 247 | } 248 | }); 249 | 250 | await expect(task).rejects.toHaveProperty("message", "bang"); 251 | }); 252 | 253 | it("propagates errors", async () => { 254 | try { 255 | await run(function* () { 256 | throw new Error("boom"); 257 | }); 258 | throw new Error("expected error to propagate"); 259 | } catch (error) { 260 | expect((error as Error).message).toEqual("boom"); 261 | } 262 | }); 263 | 264 | it("propagates errors from promises", async () => { 265 | try { 266 | await run(function* () { 267 | yield* Promise.reject(new Error("boom")); 268 | }); 269 | throw new Error("expected error to propagate"); 270 | } catch (error) { 271 | expect((error as Error).message).toEqual("boom"); 272 | } 273 | }); 274 | 275 | it("successfully halts when task fails, but shutdown succeeds ", async () => { 276 | let task = run(function* () { 277 | throw new Error("boom!"); 278 | }); 279 | 280 | await expect(task).rejects.toHaveProperty("message", "boom!"); 281 | await expect(task.halt()).resolves.toBe(undefined); 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /test/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { 3 | createContext, 4 | createScope, 5 | resource, 6 | run, 7 | spawn, 8 | suspend, 9 | useScope, 10 | } from "../mod.ts"; 11 | 12 | describe("Scope", () => { 13 | it("can be used to run actions", async () => { 14 | let [scope] = createScope(); 15 | let t1 = scope.run(function* () { 16 | return 1; 17 | }); 18 | let t2 = scope.run(function* () { 19 | return 2; 20 | }); 21 | expect(await t1).toEqual(1); 22 | expect(await t2).toEqual(2); 23 | }); 24 | 25 | it("can be used to spawn", async () => { 26 | let [scope] = createScope(); 27 | let t1 = run(() => 28 | scope.spawn(function* () { 29 | return 1; 30 | }) 31 | ); 32 | let t2 = run(() => 33 | scope.spawn(function* () { 34 | return 2; 35 | }) 36 | ); 37 | expect(await t1).toEqual(1); 38 | expect(await t2).toEqual(2); 39 | }); 40 | 41 | it("succeeds on close if the frame has errored", async () => { 42 | let error = new Error("boom!"); 43 | let [scope, close] = createScope(); 44 | let bomb = scope.run(function* () { 45 | throw error; 46 | }); 47 | await expect(bomb).rejects.toEqual(error); 48 | await expect(close()).resolves.toBeUndefined(); 49 | }); 50 | 51 | it("errors on close if there is an problem in teardown", async () => { 52 | let error = new Error("boom!"); 53 | let [scope, close] = createScope(); 54 | scope.run(function* () { 55 | try { 56 | yield* suspend(); 57 | } finally { 58 | // deno-lint-ignore no-unsafe-finally 59 | throw error; 60 | } 61 | }); 62 | await expect(close()).rejects.toEqual(error); 63 | }); 64 | 65 | it("still closes open resources whenever something errors", async () => { 66 | let error = new Error("boom!"); 67 | let [scope, close] = createScope(); 68 | let tester: Tester = {}; 69 | 70 | scope.run(function* () { 71 | yield* useTester(tester); 72 | yield* suspend(); 73 | }); 74 | 75 | scope.run(function* () { 76 | throw error; 77 | }); 78 | await expect(close()).resolves.toBeUndefined(); 79 | expect(tester.status).toEqual("closed"); 80 | }); 81 | 82 | it("let's you capture scope from an operation", async () => { 83 | let tester: Tester = {}; 84 | await run(function* () { 85 | let scope = yield* useScope(); 86 | scope.run(function* () { 87 | yield* useTester(tester); 88 | yield* suspend(); 89 | }); 90 | expect(tester.status).toEqual("open"); 91 | }); 92 | expect(tester.status).toEqual("closed"); 93 | }); 94 | 95 | it("has a separate context for each operation it runs", async () => { 96 | let cxt = createContext("number"); 97 | 98 | function* incr() { 99 | let value = yield* cxt.expect(); 100 | return yield* cxt.set(value + 1); 101 | } 102 | 103 | await run(function* () { 104 | let scope = yield* useScope(); 105 | yield* cxt.set(1); 106 | 107 | let first = yield* scope.run(incr); 108 | let second = yield* scope.run(incr); 109 | let third = yield* scope.run(incr); 110 | 111 | expect(yield* cxt.expect()).toEqual(1); 112 | expect(first).toEqual(2); 113 | expect(second).toEqual(2); 114 | expect(third).toEqual(2); 115 | }); 116 | }); 117 | 118 | it("can get and set and delete a context programatically", async () => { 119 | let context = createContext("aString"); 120 | let [scope] = createScope(); 121 | expect(scope.get(context)).toEqual(void 0); 122 | expect(scope.set(context, "Hello World!")).toEqual("Hello World!"); 123 | expect(scope.get(context)).toEqual("Hello World!"); 124 | await expect(scope.run(() => context.expect())).resolves.toEqual( 125 | "Hello World!", 126 | ); 127 | 128 | scope.delete(context); 129 | 130 | expect(scope.get(context)).toEqual(void 0); 131 | }); 132 | 133 | it("can expect() a context and raise an error if not defined", () => { 134 | let [scope] = createScope(); 135 | let msg = createContext("msg.context"); 136 | 137 | expect(() => scope.expect(msg)).toThrow(/msg\.context/); 138 | 139 | scope.set(msg, "hello world"); 140 | 141 | expect(scope.expect(msg)).toEqual("hello world"); 142 | }); 143 | 144 | it("can find out if a value is local to this context", async () => { 145 | let context = createContext("message"); 146 | let [parent] = createScope(); 147 | parent.set(context, "Hello World!"); 148 | 149 | await parent.run(function* () { 150 | let child = yield* spawn(function* () { 151 | let scope = yield* useScope(); 152 | return scope.hasOwn(context); 153 | }); 154 | expect(yield* child).toEqual(false); 155 | }); 156 | 157 | await parent.run(function* () { 158 | let child = yield* spawn(function* () { 159 | let scope = yield* useScope(); 160 | scope.set(context, "Hello Planet!"); 161 | return scope.hasOwn(context); 162 | }); 163 | expect(yield* child).toEqual(true); 164 | }); 165 | }); 166 | 167 | it("propagates uncaught errors within a scope", async () => { 168 | let error = new Error("boom"); 169 | let result = run(function* () { 170 | let scope = yield* useScope(); 171 | scope.run(function* () { 172 | throw error; 173 | }); 174 | yield* suspend(); 175 | }); 176 | await expect(result).rejects.toBe(error); 177 | }); 178 | 179 | it("destroys derived scopes when a scope is destroyed", async () => { 180 | let [parent, destroy] = createScope(); 181 | let [child] = createScope(parent); 182 | 183 | let halted = false; 184 | 185 | child.run(function* () { 186 | try { 187 | yield* suspend(); 188 | } finally { 189 | halted = true; 190 | } 191 | }); 192 | 193 | await destroy(); 194 | expect(halted).toEqual(true); 195 | }); 196 | 197 | it("throws an error if you try to run() with a dead scope", async () => { 198 | let scope = await run(useScope); 199 | 200 | expect(() => scope.run(function* () {})).toThrow("cannot call"); 201 | }); 202 | }); 203 | 204 | interface Tester { 205 | status?: "open" | "closed"; 206 | } 207 | 208 | const useTester = (state: Tester) => 209 | resource(function* (provide) { 210 | try { 211 | state.status = "open"; 212 | yield* provide(state); 213 | } finally { 214 | state.status = "closed"; 215 | } 216 | }); 217 | -------------------------------------------------------------------------------- /test/scoped.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | resource, 4 | run, 5 | scoped, 6 | sleep, 7 | spawn, 8 | suspend, 9 | } from "../mod.ts"; 10 | import { describe, expect, it } from "./suite.ts"; 11 | 12 | describe("scoped", () => { 13 | describe("task", () => { 14 | it("shuts down after completion", () => 15 | run(function* () { 16 | let didEnter = false; 17 | let didExit = false; 18 | 19 | yield* scoped(function* () { 20 | yield* spawn(function* () { 21 | try { 22 | didEnter = true; 23 | yield* suspend(); 24 | } finally { 25 | didExit = true; 26 | } 27 | }); 28 | yield* sleep(0); 29 | }); 30 | 31 | expect(didEnter).toBe(true); 32 | expect(didExit).toBe(true); 33 | })); 34 | 35 | it("shuts down after error", () => 36 | run(function* () { 37 | let didEnter = false; 38 | let didExit = false; 39 | 40 | try { 41 | yield* scoped(function* () { 42 | yield* spawn(function* () { 43 | try { 44 | didEnter = true; 45 | yield* suspend(); 46 | } finally { 47 | didExit = true; 48 | } 49 | }); 50 | yield* sleep(0); 51 | throw new Error("boom!"); 52 | }); 53 | } catch (error) { 54 | expect(error).toMatchObject({ message: "boom!" }); 55 | expect(didEnter).toBe(true); 56 | expect(didExit).toBe(true); 57 | } 58 | })); 59 | 60 | it("delimits error boundaries", () => 61 | run(function* () { 62 | try { 63 | yield* scoped(function* () { 64 | yield* spawn(function* () { 65 | throw new Error("boom!"); 66 | }); 67 | yield* suspend(); 68 | }); 69 | } catch (error) { 70 | expect(error).toMatchObject({ message: "boom!" }); 71 | } 72 | })); 73 | }); 74 | describe("resource", () => { 75 | it("shuts down after completion", () => 76 | run(function* () { 77 | let status = "pending"; 78 | yield* scoped(function* () { 79 | yield* resource(function* (provide) { 80 | try { 81 | status = "open"; 82 | yield* provide(); 83 | } finally { 84 | status = "closed"; 85 | } 86 | }); 87 | yield* sleep(0); 88 | expect(status).toEqual("open"); 89 | }); 90 | expect(status).toEqual("closed"); 91 | })); 92 | 93 | it("shuts down after error", () => 94 | run(function* () { 95 | let status = "pending"; 96 | try { 97 | yield* scoped(function* () { 98 | yield* resource(function* (provide) { 99 | try { 100 | status = "open"; 101 | yield* provide(); 102 | } finally { 103 | status = "closed"; 104 | } 105 | }); 106 | yield* sleep(0); 107 | expect(status).toEqual("open"); 108 | throw new Error("boom!"); 109 | }); 110 | } catch (error) { 111 | expect((error as Error).message).toEqual("boom!"); 112 | expect(status).toEqual("closed"); 113 | } 114 | })); 115 | 116 | it("delimits error boundaries", () => 117 | run(function* () { 118 | try { 119 | yield* scoped(function* () { 120 | yield* resource(function* (provide) { 121 | yield* spawn(function* () { 122 | yield* sleep(0); 123 | throw new Error("boom!"); 124 | }); 125 | yield* provide(); 126 | }); 127 | yield* suspend(); 128 | }); 129 | } catch (error) { 130 | expect(error).toMatchObject({ message: "boom!" }); 131 | } 132 | })); 133 | }); 134 | describe("context", () => { 135 | let context = createContext("greetting", "hi"); 136 | it("is restored after exiting scope", () => 137 | run(function* () { 138 | yield* scoped(function* () { 139 | yield* context.set("hola"); 140 | }); 141 | expect(yield* context.get()).toEqual("hi"); 142 | })); 143 | 144 | it("is restored after erroring", () => 145 | run(function* () { 146 | try { 147 | yield* scoped(function* () { 148 | yield* context.set("hola"); 149 | throw new Error("boom!"); 150 | }); 151 | } catch (error) { 152 | expect(error).toMatchObject({ message: "boom!" }); 153 | } finally { 154 | expect(yield* context.get()).toEqual("hi"); 155 | } 156 | })); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /test/signal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { 3 | createQueue, 4 | createScope, 5 | createSignal, 6 | each, 7 | run, 8 | SignalQueueFactory, 9 | spawn, 10 | } from "../mod.ts"; 11 | 12 | describe("createSignal", () => { 13 | it("should send and receive messages", async () => { 14 | let msgs: string[] = []; 15 | let signal = createSignal(); 16 | let root = run(function* () { 17 | let task = yield* spawn(function* () { 18 | for (let msg of yield* each(signal)) { 19 | msgs.push(msg); 20 | yield* each.next(); 21 | } 22 | }); 23 | 24 | signal.send("msg1"); 25 | signal.send("msg2"); 26 | signal.close(); 27 | yield* task; 28 | }); 29 | 30 | await root; 31 | 32 | expect(msgs).toEqual(["msg1", "msg2"]); 33 | }); 34 | 35 | it("should use custom Queue impl", async () => { 36 | // drop every other message 37 | function createDropQueue() { 38 | let counter = 0; 39 | let queue = createQueue(); 40 | return { 41 | ...queue, 42 | add(value: string) { 43 | counter += 1; 44 | if (counter % 2 === 0) { 45 | return; 46 | } 47 | 48 | queue.add(value); 49 | }, 50 | }; 51 | } 52 | 53 | let msgs: string[] = []; 54 | let [scope] = createScope(); 55 | scope.set(SignalQueueFactory, createDropQueue); 56 | let signal = createSignal(); 57 | 58 | let root = scope.run(function* () { 59 | let task = yield* spawn(function* () { 60 | for (let msg of yield* each(signal)) { 61 | msgs.push(msg); 62 | yield* each.next(); 63 | } 64 | }); 65 | 66 | signal.send("msg1"); 67 | signal.send("msg2"); 68 | signal.send("msg3"); 69 | signal.send("msg4"); 70 | signal.send("msg5"); 71 | signal.close(); 72 | yield* task; 73 | }); 74 | 75 | await root; 76 | 77 | expect(msgs).toEqual(["msg1", "msg3", "msg5"]); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/spawn.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "./suite.ts"; 2 | import { action, run, sleep, spawn, suspend } from "../mod.ts"; 3 | 4 | describe("spawn", () => { 5 | it("can spawn a new child task", async () => { 6 | let root = run(function* () { 7 | let child = yield* spawn(function* () { 8 | let one = yield* Promise.resolve(12); 9 | let two = yield* Promise.resolve(55); 10 | 11 | return one + two; 12 | }); 13 | 14 | return yield* child; 15 | }); 16 | await expect(root).resolves.toEqual(67); 17 | }); 18 | 19 | it("halts child when halted", async () => { 20 | let child; 21 | let root = run(function* () { 22 | child = yield* spawn(function* () { 23 | yield* suspend(); 24 | }); 25 | 26 | yield* suspend(); 27 | }); 28 | 29 | await root.halt(); 30 | 31 | await expect(child).rejects.toHaveProperty("message", "halted"); 32 | }); 33 | 34 | it("halts child when finishing normally", async () => { 35 | let child; 36 | let result = run(function* () { 37 | child = yield* spawn(function* () { 38 | yield* suspend(); 39 | }); 40 | 41 | return 1; 42 | }); 43 | 44 | await expect(result).resolves.toEqual(1); 45 | await expect(child).rejects.toHaveProperty("message", "halted"); 46 | }); 47 | 48 | it("halts child when errored", async () => { 49 | let child; 50 | let root = run(function* () { 51 | child = yield* spawn(function* () { 52 | yield* suspend(); 53 | }); 54 | 55 | throw new Error("boom"); 56 | }); 57 | 58 | await expect(root).rejects.toHaveProperty("message", "boom"); 59 | await expect(child).rejects.toHaveProperty("message", "halted"); 60 | }); 61 | 62 | it("rejects parent when child errors", async () => { 63 | let child; 64 | let error = new Error("moo"); 65 | let root = run(function* () { 66 | child = yield* spawn(function* () { 67 | yield* sleep(1); 68 | throw error; 69 | }); 70 | 71 | yield* suspend(); 72 | }); 73 | 74 | await expect(root).rejects.toEqual(error); 75 | await expect(child).rejects.toEqual(error); 76 | }); 77 | 78 | it("finishes normally when child halts", async () => { 79 | let child; 80 | let root = run(function* () { 81 | child = yield* spawn(() => suspend()); 82 | yield* child.halt(); 83 | 84 | return "foo"; 85 | }); 86 | 87 | await expect(root).resolves.toEqual("foo"); 88 | await expect(child).rejects.toHaveProperty("message", "halted"); 89 | }); 90 | 91 | it("rejects when child errors during completing", async () => { 92 | let child; 93 | let root = run(function* () { 94 | child = yield* spawn(function* () { 95 | try { 96 | yield* suspend(); 97 | } finally { 98 | // deno-lint-ignore no-unsafe-finally 99 | throw new Error("moo"); 100 | } 101 | }); 102 | return "foo"; 103 | }); 104 | 105 | await expect(root).rejects.toHaveProperty("message", "moo"); 106 | await expect(child).rejects.toHaveProperty("message", "moo"); 107 | }); 108 | 109 | it("rejects when child errors during halting", async () => { 110 | let child; 111 | let root = run(function* () { 112 | child = yield* spawn(function* () { 113 | try { 114 | yield* suspend(); 115 | } finally { 116 | // deno-lint-ignore no-unsafe-finally 117 | throw new Error("moo"); 118 | } 119 | }); 120 | yield* suspend(); 121 | return "foo"; 122 | }); 123 | 124 | await expect(root.halt()).rejects.toHaveProperty("message", "moo"); 125 | await expect(child).rejects.toHaveProperty("message", "moo"); 126 | await expect(root.halt()).rejects.toHaveProperty("message", "moo"); 127 | }); 128 | 129 | it("halts when child finishes during asynchronous halt", async () => { 130 | let didFinish = false; 131 | let root = run(function* () { 132 | yield* spawn(function* () { 133 | yield* sleep(5); 134 | }); 135 | try { 136 | yield* suspend(); 137 | } finally { 138 | yield* sleep(20); 139 | didFinish = true; 140 | } 141 | }); 142 | 143 | await root.halt(); 144 | 145 | expect(didFinish).toEqual(true); 146 | }); 147 | 148 | it("runs destructors in reverse order and in series", async () => { 149 | let result: string[] = []; 150 | 151 | await run(function* () { 152 | yield* spawn(function* () { 153 | try { 154 | yield* suspend(); 155 | } finally { 156 | result.push("first start"); 157 | yield* sleep(5); 158 | result.push("first done"); 159 | } 160 | }); 161 | yield* spawn(function* () { 162 | try { 163 | yield* suspend(); 164 | } finally { 165 | result.push("second start"); 166 | yield* sleep(10); 167 | result.push("second done"); 168 | } 169 | }); 170 | }); 171 | 172 | expect(result).toEqual([ 173 | "second start", 174 | "second done", 175 | "first start", 176 | "first done", 177 | ]); 178 | }); 179 | 180 | it("can catch an error spawned inside of an action", async () => { 181 | let error = new Error("boom!"); 182 | let value = await run(function* () { 183 | try { 184 | yield* action(function* TheAction() { 185 | yield* spawn(function* TheBomb() { 186 | yield* sleep(1); 187 | throw error; 188 | }); 189 | yield* sleep(5000); 190 | }); 191 | } catch (err) { 192 | return err; 193 | } 194 | }); 195 | expect(value).toBe(error); 196 | }); 197 | 198 | it("halts children on explicit halt", async () => { 199 | let child; 200 | let root = run(function* () { 201 | child = yield* spawn(function* () { 202 | yield* sleep(20); 203 | return "foo"; 204 | }); 205 | 206 | return 1; 207 | }); 208 | 209 | await root.halt(); 210 | 211 | await expect(child).rejects.toHaveProperty("message", "halted"); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /test/suite.ts: -------------------------------------------------------------------------------- 1 | export * from "https://deno.land/std@0.163.0/testing/bdd.ts"; 2 | export { expect } from "jsr:@std/expect"; 3 | export { expectType } from "https://esm.sh/ts-expect@1.3.0?pin=v123"; 4 | 5 | import { 6 | action, 7 | call, 8 | type Operation, 9 | resource, 10 | sleep, 11 | spawn, 12 | } from "../mod.ts"; 13 | 14 | declare global { 15 | interface Promise extends Operation {} 16 | } 17 | 18 | Object.defineProperty(Promise.prototype, Symbol.iterator, { 19 | get(this: Promise) { 20 | return expect(this)[Symbol.iterator]; 21 | }, 22 | }); 23 | 24 | function expect(promise: Promise): Operation { 25 | return action(function* (resolve, reject) { 26 | promise.then(resolve, reject); 27 | }); 28 | } 29 | 30 | export function* createNumber(value: number): Operation { 31 | yield* sleep(1); 32 | return value; 33 | } 34 | 35 | export function* blowUp(): Operation { 36 | yield* sleep(1); 37 | throw new Error("boom"); 38 | } 39 | 40 | export function* asyncResolve( 41 | duration: number, 42 | value: string, 43 | ): Operation { 44 | yield* sleep(duration); 45 | return value; 46 | } 47 | 48 | export function* asyncReject( 49 | duration: number, 50 | value: string, 51 | ): Operation { 52 | yield* sleep(duration); 53 | throw new Error(`boom: ${value}`); 54 | } 55 | 56 | export function* syncResolve(value: string): Operation { 57 | return value; 58 | } 59 | 60 | export function* syncReject(value: string): Operation { 61 | throw new Error(`boom: ${value}`); 62 | } 63 | 64 | export function asyncResource( 65 | duration: number, 66 | value: string, 67 | status: { status: string }, 68 | ): Operation { 69 | return resource(function* AsyncResource(provide) { 70 | yield* spawn(function* () { 71 | yield* sleep(duration + 10); 72 | status.status = "active"; 73 | }); 74 | yield* sleep(duration); 75 | yield* provide(value); 76 | }); 77 | } 78 | 79 | export function useCommand( 80 | cmd: string, 81 | options?: Deno.CommandOptions, 82 | ): Operation { 83 | return resource(function* (provide) { 84 | let command = new Deno.Command(cmd, options); 85 | let process = command.spawn(); 86 | try { 87 | yield* provide(process); 88 | } finally { 89 | try { 90 | process.kill("SIGINT"); 91 | yield* call(process.status); 92 | } catch (error) { 93 | // if the process already quit, then this error is expected. 94 | // unfortunately there is no way (I know of) to check this 95 | // before calling process.kill() 96 | 97 | if ( 98 | !!error && 99 | !(error as Error).message.includes( 100 | "Child process has already terminated", 101 | ) 102 | ) { 103 | // deno-lint-ignore no-unsafe-finally 104 | throw error; 105 | } 106 | } 107 | } 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /test/with-resolvers.test.ts: -------------------------------------------------------------------------------- 1 | import { run, withResolvers } from "../mod.ts"; 2 | import { describe, expect, it } from "./suite.ts"; 3 | 4 | describe("withResolvers()", () => { 5 | it("resolves", async () => { 6 | let { operation, resolve } = withResolvers(); 7 | resolve("hello"); 8 | await expect(run(() => operation)).resolves.toEqual("hello"); 9 | }); 10 | it("resolves only once", async () => { 11 | let { operation, resolve, reject } = withResolvers(); 12 | resolve("hello"); 13 | reject(new Error("boom!")); 14 | resolve("goodbye"); 15 | await expect(run(() => operation)).resolves.toEqual("hello"); 16 | }); 17 | it("rejects", async () => { 18 | let { operation, reject } = withResolvers(); 19 | reject(new Error("boom!")); 20 | await expect(run(() => operation)).rejects.toMatchObject({ 21 | message: "boom!", 22 | }); 23 | }); 24 | it("rejects only once", async () => { 25 | let { operation, reject } = withResolvers(); 26 | reject(new Error("boom!")); 27 | reject(new Error("bam!")); 28 | await expect(run(() => operation)).rejects.toMatchObject({ 29 | message: "boom!", 30 | }); 31 | }); 32 | }); 33 | --------------------------------------------------------------------------------