diff --git a/www/docs/actions.mdx b/www/docs/actions.mdx index 935e10cf0..17c7c750c 100644 --- a/www/docs/actions.mdx +++ b/www/docs/actions.mdx @@ -113,21 +113,10 @@ async function fetch(url) { } ``` -Consulting the "async rosetta stone" below, we can substitute the async +Consulting the [Async Rosetta Stone](), we can substitute the async constructs for their Effection counterparts to arrive at a line for line translation. -| Async | Effection | -|-----------------|-----------------------| -|`Promise` | `Operation` | -|`new Promise()` | `action()` | -|`await` | `yield*` | -|`async function` | `function*` | -|`AsyncIterable` | `Stream` | -|`AsyncIterator` | `Subscription` | -|`for await` | `for yield* each` | - - ```js function* fetch(url) { return yield* action(function*(resolve, reject) { diff --git a/www/docs/async-rosetta-stone.mdx b/www/docs/async-rosetta-stone.mdx new file mode 100644 index 000000000..02e00834f --- /dev/null +++ b/www/docs/async-rosetta-stone.mdx @@ -0,0 +1,12 @@ + + +| Async/Await | Effection | +| ---------------- | ----------------- | +| `Promise` | `Operation` | +| `new Promise()` | `action()` | +| `await` | `yield*` | +| `async function` | `function*` | +| `AsyncIterable` | `Stream` | +| `AsyncIterator` | `Subscription` | +| `for await` | `for yield* each` | + diff --git a/www/docs/structure.json b/www/docs/structure.json index 4bc3917ae..6102eacd5 100644 --- a/www/docs/structure.json +++ b/www/docs/structure.json @@ -2,18 +2,21 @@ "Getting Started": [ ["introduction.mdx", "Introduction"], ["installation.mdx", "Installation"], + ["typescript.mdx", "TypeScript"], + ["thinking-in-effection.mdx", "Thinking in Effection"], + ["async-rosetta-stone.mdx", "Async Rosetta Stone"] + ], + "Learn Effection": [ ["operations.mdx", "Operations"], ["actions.mdx", "Actions and Suspensions"], ["resources.mdx", "Resources"], ["spawn.mdx", "Spawn"], ["collections.mdx", "Streams and Subscriptions"], ["events.mdx", "Events"], - ["errors.mdx", "Error Handling"], - ["typescript.mdx", "TypeScript"] + ["errors.mdx", "Error Handling"] ], "Advanced": [ ["scope.mdx", "Scope"], - ["testing.mdx", "Testing"], ["processes.mdx", "Processes"] ] } diff --git a/www/docs/testing.mdx b/www/docs/testing.mdx deleted file mode 100644 index 928d3d368..000000000 --- a/www/docs/testing.mdx +++ /dev/null @@ -1,424 +0,0 @@ ->⚠️ These docs have not been updated from version 2 of Effection, and do not -> apply to version 3. The information you find here may be of use, but may -> also be outdated or misleading. - -Effection not only simplifies the process of writing tests, but by -managing all the timing complexity of your setup and teardown, it can -often make new kinds of tests possible that beforehand may have seemed -unachievable. - -Currently, both [Jest][jest] and [Mocha][mocha] are supported out of the box, but -because of the way Effection works, and because of the nature of task -you have to do while testing, it's easy to integrate it with -any test framework you like. - -## Lifecycle - -The reason Effection can so effectively power your tests is because the -life-cycle of a test mirrors that of an Effection Task almost perfectly. - -Consider that almost every test framework executes the following sequence of -operations when running a test: - -1. setup() -2. run() -3. teardown() - -While there are certainly different ways of expressing this -syntactically, it turns out that as a fundamental testing pattern, it -is nearly universal. Notice that, like tests, the cleanup of an -effection operation is intrinsic. We can leverage this fact to -associate an effection task with each test. Then, we run any -operation that is part of the test as a child of that task. After it -is finished, a test's task is halted , thereby halting any sub-tasks -that were running as a part of it. - -This means that any resources being used by the testcase such as servers, -database connections, file handles, etc... can be automatically released without -writing any cleanup logic explicitly into the test itself. In essense, your -tests are able to completely eliminate `teardown` altogether and become: - -1. setup() -2. run() - -## Jest and Mocha - -Effection provides packages for seamless integration between [Jest][jest] and -[Mocha][mocha]. - -To get started, install from NPM - -### Writing Tests - -Let's say we have a test case written without the benefit of effection that -looks like this: - -``` javascript -describe("a server", () => { - let server: Server; - beforeEach(async () => { - server = await startServer({ port: 3500 }); - }); - - it('can be pinged', async () => { - let response = await fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); - - afterEach(async () => { - await server.close(); - }); -}); -``` -:::note Cleanup - -It is critical that we shutdown the server after each test. Otherwise, node -will never exit without a hard stop because it's still bound to port -`3500`, and worse, every subsequent time we try and run our tests they will fail -because port `3500` is not available! -::: - -To re-write this test case using effection, there are two superficial -differences from the vanilla version of the framework. - -1. You `import` all of your test syntax from the effection package, and not -from the global namespace. -2. You use [generator functions instead of async functions][replace-async] to -express all of your test operations. - -Once we make those changes, our test case now looks like this. - -> TODO: tab item - -``` javascript -import { beforeEach, afterEach, it } from '@effection/jest'; - -describe("a server", () => { - let server: Server; - beforeEach(function*() { - server = yield startServer({ port: 3500 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); - - afterEach(function*() { - yield server.close(); - }); -}); -``` - -> TODO: tab item - -``` javascript -import { beforeEach, afterEach, it } from '@effection/mocha'; - -describe("a server", () => { - let server: Server; - beforeEach(function*() { - server = yield startServer({ port: 3500 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); - - afterEach(function*() { - yield server.close(); - }); -}); -``` - -But so far, we've only traded one syntax for another. Now however, we can -begin to leverage the power of Effection to make our test cases not only more -concise, but also more flexible. To do this, we use the fact that each test -case gets its own task that is automatically halted for us after it runs. - -So if we re-cast our server as a [resource][] that is running as a -child of our test-scoped task, then when the test task goes away, so -will our server. - - -> TODO: tab item - -``` javascript -import { ensure } from 'effection'; -import { beforeEach, it } from '@effection/jest'; - -describe("a server", () => { - beforeEach(function*() { - yield { - name: 'ping server', - *init() { - let server = yield startServer({ port: 3500 }); - yield ensure(() => server.close()) - } - } - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -``` - -> TODO: tab item - -``` javascript -import { ensure } from 'effection'; -import { beforeEach, it } from '@effection/mocha'; - -describe("a server", () => { - beforeEach(function*() { - yield { - name: 'ping server', - *init() { - let server = yield startServer({ port: 3500 }); - yield ensure(() => server.close()) - } - } - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -``` - -We don't have to write an `afterEach()` _at all_ anymore, because the resource -knows how to tear itself down no matter where it ends up running. - -This has the added benefit of making your tests more refactorable. For example, -let's say that we decided that since our server is read-only, we can actually -start it once before all of the tests run, so we move it to a _suite_ level -hook. Before, we would have had to remember to convert our teardown hook as -well, but with Effection, we can just move our resource to its new location. - -> TODO: tab item - -``` javascript -import { ensure } from 'effection'; -import { beforeAll, it } from '@effection/jest'; - -describe("a server", () => { - beforeAll(function*() { - yield { - name: 'ping server', - *init() { - let server = yield startServer({ port: 3500 }); - yield ensure(() => server.close()) - } - } - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -``` - -> TODO: tab item - -``` javascript -import { ensure } from 'effection'; -import { before, it } from '@effection/mocha'; - -describe("a server", () => { - before(function*() { - yield { - name: 'ping server', - *init() { - let server = yield startServer({ port: 3500 }); - nyield ensure(() => server.close()) - } - } - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -``` - -It is common to extract such resources into re-usable functions that can be -embedded anywhere into your test suite, and you never have to worry about them -being torn down. For example, we could define our server resource in a single -place: - -``` javascript -//server.js -import { ensure } from 'effection'; - -export function createServer(options) { - let { port = 3500, name = 'ping server' } = options; - return { - name, - *init() { - let server = yield startServer({ port }); - yield ensure(() => server.close()); - } - } -} -``` - -Then we can use it easily in any test case: - -> TODO: tab item - -``` javascript -import { beforeAll, it } from '@effection/jest'; -import { createServer } from './server'; - -describe("a server", () => { - beforeAll(function*() { - yield createServer({ port: 3500 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -`` - -> TODO: tab item - -``` javascript -import { before, it } from '@effection/mocha'; -import { createServer } from './server'; - -describe("a server", () => { - before(function*() { - yield createServer({ port: 3500 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - }); -}); -``` - -### Test Scope - -As hinted at above, there are two separate levels of task in -your tests: _suite-scoped_, and _test-scoped_. Effection creates one -task that has the same lifetime as the beginning and end of your test -suite. Any task spawned within it can potentially last across multiple -test runs. By the same token, the _test-scoped_ task is created before -and halted after every single test. Any tasks spawned within it will -be halted immediately after the test is finished. For example: - - -> TODO: tab item - -``` javascript -import { beforeAll, beforeEach, it } from '@effection/jest'; -import { createServer } from './server'; - -describe("a server", () => { - beforeAll(function*() { - // started once before both `it` blocks - // stopped once after both `it` blocks - yield createServer({ port: 3500 }); - }); - - beforeEach(function*() { - // started before every `it` block - // stopped after every `it` block, - yield createServer({ port: 3501 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - - let response = yield fetch('http://localhost:3501/ping'); - expect(response.ok).toBe(true); - }); - - it('can be ponged', function*() { - let response = yield fetch('http://localhost:3500/pong'); - expect(response.ok).toBe(true); - - let response = yield fetch('http://localhost:3501/pong'); - expect(response.ok).toBe(true); - }); -}); -``` - -> TODO: tab item - -``` javascript -import { before, beforeEach, it } from '@effection/mocha'; -import { createServer } from './server'; - -describe("a server", () => { - before(function*() { - // started once before both `it` blocks - // stopped once after both `it` blocks - yield createServer({ port: 3500 }); - }); - - beforeEach(function*() { - // started before every `it` block - // stopped after every `it` block, - yield createServer({ port: 3501 }); - }); - - it('can be pinged', function*() { - let response = yield fetch('http://localhost:3500/ping'); - expect(response.ok).toBe(true); - - let response = yield fetch('http://localhost:3501/ping'); - expect(response.ok).toBe(true); - }); - - it('can be ponged', function*() { - let response = yield fetch('http://localhost:3500/pong'); - expect(response.ok).toBe(true); - - let response = yield fetch('http://localhost:3501/pong'); - expect(response.ok).toBe(true); - }); -}); -``` - -### Caveats - -:::note Jest - -There is currently a [critical bug in -Jest](https://github.com/facebook/jest/issues/12259) that causes -teardown hooks to be completely ignored if you hit `CTRL-C` while your -tests are running. This is true for any Jest test whether you're using -Effection or not, but you must be careful about interrupting your -tests from the command line while they are running as it can have -unknown consequences. - -If you'd like to see this fixed, please [go to the -issue](https://github.com/facebook/jest/issues/12259) and leave a -comment. - -::: - -## Other Frameworks - -Have a favorite testing tool that you don't see listed here that you -think could benefit from a first class Effection integration? Feel -free to [create an issue for -it](https://github.com/thefrontside/effection/issues/new) or [drop -into discord][discord] and let us know! - -[mocha]: https://mochajs.org -[jest]: https://jestjs.io -[resource]: /docs/guides/resources -[replace-async]: /docs/guides/introduction#replacing-asyncawait -[discord]: https://discord.gg/r6AvtnU diff --git a/www/docs/thinking-in-effection.mdx b/www/docs/thinking-in-effection.mdx new file mode 100644 index 000000000..6ab6b1c67 --- /dev/null +++ b/www/docs/thinking-in-effection.mdx @@ -0,0 +1,113 @@ +When we say that Effection is "Structured Concurrency and Effects for Javascript" we mean three things: + +1. No operation runs longer than its parent. +2. Every operation exits fully. +3. It's just JavaScript, and except for the guarantees derived from (1) and (2), it should feel familiar in every other +way. + +Developing a new intuition about how to leverage Structured Concurrency, while leaning on your existing intuition as a +JavaScript developer will help you get the most out of Effection and have you attempting things that you would never +have even dreamed before. + +## No operation runs longer than its parent. + +In JavaScript, developers rarely have to think about memory allocation because memory lifetime is bound to the scope of +the function that it was allocated for. When a function is finished, its scope is torn down and all of the memory +allocated to variables in function's scope are released safely. Binding memory management to scope gives developers the +freedom to focus on their application instead worrying about leaking memory. + +Structured concurrency establishes the same relationship between scope and asynchrony. Every Effection operation is +bound to the lifetime of its parent. Because of this, Effection automatically tears down +child operations when the parent operation completes or is halted. Like with memory management, binding asynchrony to +scope frees developers to focus on writing their applications instead of worrying about where and when to run +asynchronous cleanup. + +The key to achieving this freedom is to make the following mental shift about the natural lifetime of an asynchronous +operation. + +**before** + +> An asynchronous operation will run as long as it needs to + +**after** + +> An asynchronous operation runs only as long as it's needed. + +Effection provides this shift. Whenever an operation completes, none of its child operations are left +around to pollute your runtime. + +## Every operation exits fully. + +We expect synchronous functions to run completely from start to finish. + +```js {5} showLineNumbers +function main() { + try { + fn() + } finally { + // code here is GUARANTEED to run + } +} +``` + +Knowing this makes code predictable. Developers can be confident that their functions will either return a result or +throw an error. You can wrap a synchronous function in a `try/catch/finally` block to handle thrown errors. The +`finally` block can be used to perform clean up after completion. + +However, the same guarantee is not provided for async functions. + +```js {5} showLineNumbers +async function main() { + try { + await new Promise((resolve) => setTimeout(resolve, 100,000)); + } finally { + // code here is NOT GUARANTEED to run + } +} + +await main(); +``` + +Once an async function begins execution, the code in its `finally{}` blocks may never get +a chance to run, and as a result, it is difficult to write code that always cleans up after itself. + +This hard limitation of the JavaScript runtime is called the [Await Event Horizon][await-event-horizon], +and developers experience its impact on daily basis. For example, the very common [EADDRINUSE][eaddrinuse-error] +error is caused by a caller not being able to execute clean up when a Node.js process is stopped. + +By contrast, Effection _does_ provide this guarantee. + +```js {7} showLineNumbers +import { main, action } from "effection"; + +await main(function*() { + try { + yield* action(function*(resolve) { setTimeout(resolve, 100,000) }); + } finally { + // code here is GUARANTEED to run + } +}); +``` + +When executing Effection operations you can expect that +they will run to completion; giving every operation an opportunity to clean up. At first glance, this +might seem like a small detail but it's fundamental to writing composable code. + +## It's just JavaScript + +Effection is designed to provide Structured Concurrency guarantees using common JavaScript language constructs such as +`let`, `const`, `if`, `for`, `while`, `switch` and `try/catch/finally`. Our goal is to allow JavaScript developers to +leverage what they already know while gaining the guarantees of Structured Concurrency. You can use all of these +constructs in an Effection function and they'll behave as you'd expect. + +However, one of the constructs we avoid in Effection is the syntax and semantics of `async/await`. +This is because `async/await` is [not capable of modeling structured concurrency][await-event-horizon]. Instead, we make +extensive use of [generator functions][generator-functions] which are a core feature of JavaScript and are supported by all browsers +and JavaScript runtimes. + +Finally, we provide a handy Effection Rosetta Stone to show how _Async/Await_ concepts map into Effection APIs. + +[generator-functions]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* +[await-event-horizon]: https://frontside.com/blog/2023-12-11-await-event-horizon/ +[delimited-continuation-repo]: https://github.com/thefrontside/continuation +[eaddrinuse-error]: https://stackoverflow.com/questions/14790910/stop-all-instances-of-node-js-server