nanoeffects 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-jeEqIKgK.d.ts} +34 -32
  2. package/dist/actions.d.ts +18 -18
  3. package/dist/actions.js +1 -1
  4. package/dist/bus-0uYn_kRj.js +1 -0
  5. package/dist/context-DtUnzC-1.js +1 -0
  6. package/dist/context-types-Bme1mzkn.d.ts +31 -0
  7. package/dist/context.d.ts +8 -3
  8. package/dist/context.js +1 -1
  9. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  10. package/dist/dev.d.ts +2 -2
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/match.d.ts +3 -3
  16. package/dist/match.js +1 -1
  17. package/dist/{module-C_oEXw1n.d.ts → module-BWMW83qk.d.ts} +38 -38
  18. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-xpQ_I2h8.d.ts} +34 -32
  19. package/dist/notrace/actions.d.ts +52 -52
  20. package/dist/notrace/actions.js +1 -1
  21. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +2 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-6LhNx0th.d.ts} +1 -1
  26. package/dist/notrace/policies.d.ts +1 -1
  27. package/dist/notrace/redux.d.ts +1 -1
  28. package/dist/observable.d.ts +43 -1
  29. package/dist/observable.js +1 -1
  30. package/dist/onAbort-BgMfspK4.js +1 -0
  31. package/dist/oop.d.ts +3 -3
  32. package/dist/oop.js +1 -1
  33. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BaErevLy.d.ts} +2 -2
  34. package/dist/policies.d.ts +2 -2
  35. package/dist/policies.js +1 -1
  36. package/dist/react.d.ts +2 -2
  37. package/dist/react.js +1 -1
  38. package/dist/redux.d.ts +1 -1
  39. package/dist/scope.d.ts +9 -9
  40. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  41. package/dist/signal.d.ts +34 -6
  42. package/dist/signal.js +1 -1
  43. package/dist/svelte.d.ts +1 -1
  44. package/dist/trace.d.ts +20 -5
  45. package/dist/trace.js +1 -1
  46. package/dist/{types-FV8fuvI9.d.ts → types-D5xsR37j.d.ts} +2 -2
  47. package/dist/vue.d.ts +1 -1
  48. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
  49. package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
  50. package/docs/README.md +13 -1
  51. package/docs/actions.md +27 -1
  52. package/docs/chat-example.md +12 -0
  53. package/docs/chat-widget.md +7 -7
  54. package/docs/context.md +33 -1
  55. package/docs/effects.md +20 -9
  56. package/docs/guide.md +20 -2
  57. package/docs/migrating-from-effector.md +11 -0
  58. package/docs/observable.md +43 -0
  59. package/docs/oop.md +17 -0
  60. package/docs/package.md +22 -13
  61. package/docs/plugins.md +69 -11
  62. package/docs/query-libraries.md +273 -5
  63. package/docs/react.md +12 -2
  64. package/docs/scope.md +14 -2
  65. package/docs/semantics.md +10 -0
  66. package/docs/server.md +3 -3
  67. package/docs/stability.md +2 -2
  68. package/docs/tracing.md +74 -6
  69. package/docs/typescript.md +1 -1
  70. package/llms-full.txt +671 -66
  71. package/llms.txt +7 -7
  72. package/package.json +4 -4
  73. package/skills/nanoeffects/SKILL.md +2 -2
  74. package/dist/bus-DlpEluzd.js +0 -1
  75. package/dist/context-UcmrAIhP.js +0 -1
  76. package/dist/context-types-hCKgU8sC.d.ts +0 -14
package/docs/actions.md CHANGED
@@ -10,6 +10,32 @@ A chat with two entities and three features, jobs under policies and effects, an
10
10
 
11
11
  A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
12
12
 
13
+ ## Pick the word: event, action, job, module
14
+
15
+ Four names, one idea each. Take the first row that fits.
16
+
17
+ | Word | What it is | Use it when |
18
+ | ---- | ---------- | ----------- |
19
+ | Event | A name and a payload; `createAction("x")` with no handler. | The UI or a handler tells that something happened. |
20
+ | Action with a handler | The same, plus work: `createAction("x", handler)`. `emit` returns the result. | The caller needs a value back. |
21
+ | Job | An action with a handler that also emits `pending`, `success`, `fail` and `cancel`. | The UI shows a loading state. |
22
+ | Module | A group of listeners, handlers and tasks, mounted and unmounted with one call. | One domain must start and stop as a unit. |
23
+
24
+ Start with events. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
25
+
26
+ ## On this page
27
+
28
+ - [Pick the word: event, action, job, module](#pick-the-word-event-action-job-module)
29
+ - [Return a value from `emit` with a handler](#return-a-value-from-emit-with-a-handler)
30
+ - [Describe events, handlers and jobs](#describe-events-handlers-and-jobs)
31
+ - [Actions](#actions)
32
+ - [Bind a handler later](#bind-a-handler-later)
33
+ - [Say what a bound handler returns](#say-what-a-bound-handler-returns)
34
+ - [Jobs](#jobs)
35
+ - [Show a loading state with a job](#show-a-loading-state-with-a-job)
36
+ - [Mount domains with modules](#mount-domains-with-modules)
37
+ - [Give an action or a module its own fields](#give-an-action-or-a-module-its-own-fields)
38
+
13
39
  ## Return a value from `emit` with a handler
14
40
 
15
41
  Give `createAction` a handler, and `emit` runs it and returns its result. There is one creator for an event and for an action with work behind it; the handler is the only difference.
@@ -164,7 +190,7 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
164
190
  - `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
165
191
  - `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, give `createAction` the handler.
166
192
  - Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
167
- - A job without a handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent. An action without a handler is an event and does not throw.
193
+ - A job without a handler is not an outcome: `emit` throws `No handler for action "..."` and nothing is sent. An action without a handler is an event and does not throw.
168
194
  - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
169
195
  - The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
170
196
 
@@ -14,6 +14,18 @@ npm run dev
14
14
 
15
15
  `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
16
16
 
17
+ ## On this page
18
+
19
+ - [Read how the server fails](#read-how-the-server-fails)
20
+ - [See how the files are split](#see-how-the-files-are-split)
21
+ - [Load a history that must not race](#load-a-history-that-must-not-race)
22
+ - [Send a message that can fail](#send-a-message-that-can-fail)
23
+ - [Search where one room is slow](#search-where-one-room-is-slow)
24
+ - [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
25
+ - [Poll for new messages](#poll-for-new-messages)
26
+ - [Know where each value lives](#know-where-each-value-lives)
27
+ - [Where to go next](#where-to-go-next)
28
+
17
29
  ## Read how the server fails
18
30
 
19
31
  The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
@@ -30,7 +30,7 @@ It is a level D example: the logic lives in one module, and the page only mounts
30
30
  | `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
31
31
  | `src/chat-module.ts` | The list of what is alive while the chat is open. |
32
32
  | `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
33
- | `src/main.ts` | The launcher: the only file that knows both the module and the panel. |
33
+ | `src/main.ts` | The launcher: the only file that knows both the module and the panel; it listens for `closeRequested` only while the chat is open. |
34
34
 
35
35
  ## Bind everything in one module
36
36
 
@@ -80,7 +80,7 @@ Three details that make the cleanup complete:
80
80
 
81
81
  ## Open and close from the launcher
82
82
 
83
- `app.mount` returns the function that unmounts the module. The launcher puts it together with the one that removes the panel.
83
+ `app.mount` returns the function that unmounts the module. `bus.on` returns the one that unsubscribes. The launcher puts them together with the one that removes the panel into a single `close`.
84
84
 
85
85
  <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
86
86
  ```js
@@ -96,7 +96,10 @@ let close = null; // set while the chat is open
96
96
  const open = () => {
97
97
  const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
98
98
  const stopPanel = renderPanel(document.body);
99
+ // the module emits closeRequested on Escape; nothing does while the chat is closed
100
+ const stopEscape = bus.on(closeRequested, () => close?.());
99
101
  close = () => {
102
+ stopEscape();
100
103
  stopPanel();
101
104
  stopModule(); // everything the module started ends, a running request included
102
105
  close = null;
@@ -110,18 +113,15 @@ button.addEventListener("click", () => {
110
113
  open();
111
114
  }
112
115
  });
113
-
114
- // the launcher stays subscribed: it must hear Escape to close the chat
115
- bus.on(closeRequested, () => close?.());
116
116
  ```
117
117
 
118
- The launcher's own `bus.on` is the only subscription that lives while the chat is closed, and it does nothing then: `close` is `null`.
118
+ While the chat is closed the launcher holds no subscription at all: it only has the click listener of the button, which is a DOM listener, not a listener of the bus.
119
119
 
120
120
  ## Check that closing cleans up
121
121
 
122
122
  1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
123
123
  2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
124
- 3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: only `chat/closeRequested` has a listener (the launcher). Open: `chat/sent`, `chat/messageAdded` and `chat/typing` have one each as well. That is the proof that nothing is left subscribed.
124
+ 3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: every event shows `0`, and so does "all events". Open: each of the four has one listener, and "all events" is `4`. That is the proof that nothing is left subscribed.
125
125
  4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
126
126
  5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
127
127
  6. Open the chat again. The old lines are back, and the polling runs.
package/docs/context.md CHANGED
@@ -21,7 +21,7 @@ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may
21
21
 
22
22
  To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
23
23
 
24
- To give fields to **one** command, action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
24
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
25
25
 
26
26
  ## Type the fields (optional)
27
27
 
@@ -45,6 +45,38 @@ bus.on("save", (draft, { store }) => store.put(draft));
45
45
  - A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
46
46
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
47
47
 
48
+ ## Build a field from the signal of each task
49
+
50
+ Problem: an HTTP client or a database handle should stop when the task is aborted. Without help, every handler passes `ctx.signal` to it by hand, and one forgotten call leaves a request running.
51
+
52
+ `fromSignal(make)` from `nanoeffects/signal` marks a field of `contextPlugin` as a value that is built from the signal of the task. It does not run `make` for the bus: it runs it for each task that reads the field, once, with the `ctx.signal` of that task.
53
+
54
+ ```js
55
+ import { createBus } from "nanoeffects";
56
+ import { contextPlugin } from "nanoeffects/context";
57
+ import { fromSignal } from "nanoeffects/signal";
58
+ import axios from "axios";
59
+
60
+ // contextPlugin — every task gets these fields
61
+ // fromSignal — http is made from the signal of the task that reads ctx.http
62
+ const bus = createBus().use(
63
+ contextPlugin({
64
+ logger: console, // a plain field: the same object for every task
65
+ http: fromSignal((signal) => axios.create({ signal })),
66
+ }),
67
+ );
68
+
69
+ bus.on("user/open", async (id, ctx) => {
70
+ const response = await ctx.http.get(`/users/${id}`); // aborts with the task
71
+ ctx.logger.log(response.data);
72
+ });
73
+ ```
74
+
75
+ - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
76
+ - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
77
+ - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
78
+ - Without `contextPlugin` a `fromSignal` marker is only an object.
79
+
48
80
  ### Name the fields in the event map instead
49
81
 
50
82
  `createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
package/docs/effects.md CHANGED
@@ -4,6 +4,18 @@ Use this page for retry, timeouts, waiting, or parallel work — not as the firs
4
4
 
5
5
  An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
6
6
 
7
+ ## On this page
8
+
9
+ - [Wrap a handler with retry and timeout](#wrap-a-handler-with-retry-and-timeout)
10
+ - [Options for retry, timeout, and poll](#options-for-retry-timeout-and-poll)
11
+ - [Back off between retries](#back-off-between-retries)
12
+ - [Put selected effects on `ctx`](#put-selected-effects-on-ctx)
13
+ - [Tie a promise to the task signal](#tie-a-promise-to-the-task-signal)
14
+ - [Run cleanup on abort](#run-cleanup-on-abort)
15
+ - [Wait for the next event with `take`](#wait-for-the-next-event-with-take)
16
+ - [Run several starters together](#run-several-starters-together)
17
+ - [Pass `ctx` outside a task](#pass-ctx-outside-a-task)
18
+
7
19
  ## Wrap a handler with retry and timeout
8
20
 
9
21
  `retry`, `timeout`, and `poll` have two forms:
@@ -49,9 +61,7 @@ bus.on("load", async (id, ctx) => {
49
61
  () => fetchUser(id, { signal: ctx.signal }),
50
62
  { times: 4 },
51
63
  );
52
- const data = await timeout(ctx, 5000, ({ signal }) => {
53
- return fetchData(id, { signal });
54
- });
64
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
55
65
  await poll(ctx, () => refresh(), 10_000);
56
66
  ctx.emit("ready", { user, data });
57
67
  });
@@ -160,7 +170,8 @@ const bus = createBus().use(effects);
160
170
  bus.on("load", async (id, { race, all, delay }) => {
161
171
  // race — same as race(ctx, …)
162
172
  const result = await race({
163
- data: ({ signal }) => fetchData(id, { signal }),
173
+ // child — the context of this starter; it has the `signal`, so the request stops with the race
174
+ data: (child) => fetchData(id, child),
164
175
  // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
165
176
  timeout: ({ delay }) => delay(5000),
166
177
  });
@@ -168,8 +179,8 @@ bus.on("load", async (id, { race, all, delay }) => {
168
179
 
169
180
  // all — same as all(ctx, …)
170
181
  const { user, posts } = await all({
171
- user: ({ signal }) => fetchUser(id, { signal }),
172
- posts: ({ signal }) => fetchPosts(id, { signal }),
182
+ user: (child) => fetchUser(id, child),
183
+ posts: (child) => fetchPosts(id, child),
173
184
  });
174
185
  });
175
186
  ```
@@ -292,8 +303,8 @@ const bus = createBus();
292
303
 
293
304
  bus.on("open", async (id, ctx) => {
294
305
  const { user, posts } = await allSettled(ctx, {
295
- user: ({ signal }) => fetchUser(id, { signal }),
296
- posts: ({ signal }) => fetchPosts(id, { signal }),
306
+ user: (child) => fetchUser(id, child),
307
+ posts: (child) => fetchPosts(id, child),
297
308
  });
298
309
 
299
310
  if (user.status === "fulfilled") {
@@ -399,7 +410,7 @@ bus.on("sync", async (ids, ctx) => {
399
410
  The result has the same order and shape as without the option. If one starter of `all` fails, the running ones are cancelled and the waiting ones are never called. `allSettled` goes on after a failure. When `ctx` aborts, both stop and start nothing more. A value that is not a positive integer (`0`, `-1`, `NaN`, `1.5`) throws a `RangeError` before anything starts; `Infinity` means no limit. `race` has no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`, so a group starter inside another group takes none; write `(child) => all(child, effects, { concurrency: 5 })` there.
400
411
 
401
412
 
402
- **A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options: `({ signal }) => fetchUser(id, { signal })`. The same holds for `all`, `allSettled` and `timeout`.
413
+ **A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options, and `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `all`, `allSettled` and `timeout`.
403
414
 
404
415
  If `ctx` is `{}` or a bare `{ signal }` (outside a task), `child` has only `signal` and `abort`, so it fits `delay` and `poll` but not `take`, which needs a bus.
405
416
 
package/docs/guide.md CHANGED
@@ -11,6 +11,23 @@ Climb one step at a time: small bus → policies → effects → handlers and mo
11
11
 
12
12
  Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
13
13
 
14
+ ## On this page
15
+
16
+ - [1. Create a bus](#1-create-a-bus)
17
+ - [Listen to every event](#listen-to-every-event)
18
+ - [Pick events with a function](#pick-events-with-a-function)
19
+ - [2. Use the handler context](#2-use-the-handler-context)
20
+ - [3. Abort the previous run with `latest`](#3-abort-the-previous-run-with-latest)
21
+ - [4. Pick another policy when overlap differs](#4-pick-another-policy-when-overlap-differs)
22
+ - [5. Add retry and timeout](#5-add-retry-and-timeout)
23
+ - [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
24
+ - [7. Name events three ways](#7-name-events-three-ways)
25
+ - [8. Bind a handler to an action later](#8-bind-a-handler-to-an-action-later)
26
+ - [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
27
+ - [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
28
+ - [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
29
+ - [Where next](#where-next)
30
+
14
31
  ## 1. Create a bus
15
32
 
16
33
  `on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
@@ -272,10 +289,11 @@ import { all, delay, race, take } from "nanoeffects/effects";
272
289
  const bus = createBus();
273
290
 
274
291
  // all — run together; one failure cancels the rest
292
+ // child — the context of that starter: it has the `signal`, so the request stops with the group
275
293
  bus.on("search", async (id, ctx) => {
276
294
  const { user, posts } = await all(ctx, {
277
- user: ({ signal }) => api.user(id, { signal }),
278
- posts: ({ signal }) => api.posts(id, { signal }),
295
+ user: (child) => api.user(id, child),
296
+ posts: (child) => api.posts(id, child),
279
297
  });
280
298
  ctx.emit("loaded", { user, posts });
281
299
  });
@@ -2,6 +2,17 @@
2
2
 
3
3
  Read this page if your project uses Effector and you want to know where nanoeffects fits. They overlap in one place: events that start effects. Effector also holds state; nanoeffects does not. It maps the names, shows one effect both ways, and lists what has no equivalent.
4
4
 
5
+ ## On this page
6
+
7
+ - [Split the work](#split-the-work)
8
+ - [Find the equivalent](#find-the-equivalent)
9
+ - [Rewrite an effect](#rewrite-an-effect)
10
+ - [Keep the state in Zustand](#keep-the-state-in-zustand)
11
+ - [Rewrite a form](#rewrite-a-form)
12
+ - [Replace the log with a request](#replace-the-log-with-a-request)
13
+ - [Use both in one app](#use-both-in-one-app)
14
+ - [What is different](#what-is-different)
15
+
5
16
  ## Split the work
6
17
 
7
18
  | Job | Use |
@@ -2,6 +2,8 @@
2
2
 
3
3
  Use this page when your app already works with Observables or RxJS and you want a bus event as a stream. If you start from scratch, prefer `bus.on` with policies: they cancel work and show up in traces.
4
4
 
5
+ The same entry has `events`, which needs no stream library: it gives an event to a `for await` loop. See [Read an event with a `for await` loop](#read-an-event-with-a-for-await-loop).
6
+
5
7
  `createObserve(bus)` from `nanoeffects/observable` turns a bus event into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is. Nothing here depends on them.
6
8
 
7
9
  ## Turn an event into a stream
@@ -145,6 +147,47 @@ bus.on(
145
147
 
146
148
  `switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
147
149
 
150
+ ## Read an event with a `for await` loop
151
+
152
+ Problem: a task wants to handle the payloads of one event one after another, in order, and stop when the screen closes. `take` waits for one payload, and a listener has no place for a loop.
153
+
154
+ `events(source, event, options)` from `nanoeffects/observable` returns an async iterator of the payloads. It listens from the call, so a payload that comes before the first read is kept. `source` is a bus or a task context; with a context the loop also ends when the task aborts.
155
+
156
+ ```js
157
+ import { createBus } from "nanoeffects";
158
+ import { events } from "nanoeffects/observable";
159
+
160
+ const bus = createBus();
161
+ const controller = new AbortController();
162
+
163
+ // events — the "query/changed" payloads one by one; keeps the last 3 while the loop is busy
164
+ const queries = events(bus, "query/changed", {
165
+ signal: controller.signal,
166
+ buffer: 3,
167
+ });
168
+
169
+ async function watchQueries() {
170
+ for await (const query of queries) {
171
+ console.log("search for", query); // runs once per payload, in order
172
+ }
173
+ }
174
+
175
+ watchQueries().catch((reason) => console.log("stopped:", reason)); // the abort rejects the loop
176
+ bus.emit("query/changed", "cat");
177
+ controller.abort("screen closed"); // the loop ends with this reason
178
+ ```
179
+
180
+ | Option | What it does | Default |
181
+ | ------ | ------------ | ------- |
182
+ | `signal` | Ends the loop. The waiting read rejects with the abort reason. | none |
183
+ | `buffer` | The most payloads kept while the loop is busy. | `Infinity` |
184
+ | `overflow` | Which payload goes when the buffer is full: `"drop-oldest"` or `"drop-newest"`. | `"drop-oldest"` |
185
+
186
+ - `break`, `return` and a throw inside the loop remove the listener.
187
+ - With no `buffer`, a loop that never reads keeps every payload. Set `buffer` for a slow loop.
188
+ - An event of a bus does not end, so the loop ends only by `break`, the `signal` or the task.
189
+ - Inside a task, `events(ctx, "query/changed")` needs no `signal`: the task ending is the end.
190
+
148
191
  ## Convert back to a stream or a promise
149
192
 
150
193
  | You need | Write |
package/docs/oop.md CHANGED
@@ -9,6 +9,23 @@ Use this page when **your domain logic lives in classes**: a service, a store or
9
9
 
10
10
  If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
11
11
 
12
+ ## On this page
13
+
14
+ - [The problem without it](#the-problem-without-it)
15
+ - [Make an independent emitter with `Emitter`](#make-an-independent-emitter-with-emitter)
16
+ - [Type the events (optional)](#type-the-events-optional)
17
+ - [Give a class the methods of a shared bus](#give-a-class-the-methods-of-a-shared-bus)
18
+ - [Extend the result with no class of your own](#extend-the-result-with-no-class-of-your-own)
19
+ - [Let the class make its bus](#let-the-class-make-its-bus)
20
+ - [End the subscriptions with `dispose`](#end-the-subscriptions-with-dispose)
21
+ - [Stream an event with a `Subject`](#stream-an-event-with-a-subject)
22
+ - [Keep a method of your own](#keep-a-method-of-your-own)
23
+ - [Subscribe a method with `@On` and `@Once`](#subscribe-a-method-with-on-and-once)
24
+ - [Decorate the class with `@WithBus`](#decorate-the-class-with-withbus)
25
+ - [Pass a method by hand](#pass-a-method-by-hand)
26
+ - [Type the instances (optional)](#type-the-instances-optional)
27
+ - [What is not done](#what-is-not-done)
28
+
12
29
  ## The problem without it
13
30
 
14
31
  A class that listens to the bus keeps a reference to it, and keeps every unsubscribe function in a list to call when the object goes away. Forget one and the old object keeps handling events. `extendBus` does both for you.
package/docs/package.md CHANGED
@@ -2,24 +2,33 @@
2
2
 
3
3
  Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
4
4
 
5
+ ## Start with these three
6
+
5
7
  | Import | What is in it |
6
8
  | ------ | ------------- |
7
9
  | `nanoeffects` | `createBus`, the types |
8
10
  | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
9
11
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
10
- | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` |
11
- | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
12
- | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value |
13
- | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
14
- | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` |
15
- | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) |
16
- | `nanoeffects/redux` | `createEffectsMiddleware` |
17
- | `nanoeffects/observable` | `createObserve`, `Subject` |
18
- | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
19
- | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
20
- | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
21
- | `nanoeffects/dev` | `devPlugin` (no-op in production) |
22
- | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
12
+
13
+ ## Add the rest when you need it
14
+
15
+ Each row is optional. The "when" column says what need brings you to it.
16
+
17
+ | Import | What is in it | When |
18
+ | ------ | ------------- | ---- |
19
+ | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
20
+ | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
21
+ | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value | a store or client must be on every task |
22
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
23
+ | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` | you write classes, not functions |
24
+ | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
25
+ | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
26
+ | `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
27
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
28
+ | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
29
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
30
+ | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
31
+ | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) | you want to see what ran and why |
23
32
 
24
33
  The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
25
34
 
package/docs/plugins.md CHANGED
@@ -2,12 +2,52 @@
2
2
 
3
3
  Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one gives you, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
4
4
 
5
+ ## On this page
6
+
7
+ - [Use a plugin](#use-a-plugin)
8
+ - [Pick a plugin by task](#pick-a-plugin-by-task)
9
+ - [Main plugins](#main-plugins)
10
+ - [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
11
+ - [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
12
+ - [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
13
+ - [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
14
+ - [Extra plugins](#extra-plugins)
15
+ - [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
16
+ - [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
17
+ - [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
18
+ - [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
19
+ - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
20
+ - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
21
+ - [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
22
+ - [Put them together](#put-them-together)
23
+ - [Use them in any order](#use-them-in-any-order)
24
+ - [See what is not a plugin](#see-what-is-not-a-plugin)
25
+ - [Write your own plugin](#write-your-own-plugin)
26
+
5
27
  ## Use a plugin
6
28
 
7
29
  A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
8
30
 
9
31
  In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
10
32
 
33
+ ## Pick a plugin by task
34
+
35
+ Find your need in the left column. No row fits: you need no plugin.
36
+
37
+ | I need | Use |
38
+ | ------ | --- |
39
+ | A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
40
+ | `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
41
+ | A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
42
+ | One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
43
+ | Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
44
+ | Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
45
+ | One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
46
+ | A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
47
+ | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
48
+ | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
49
+ | Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
50
+
11
51
  ## Main plugins
12
52
 
13
53
  These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
@@ -33,6 +73,8 @@ const bus = createBus().use(contextPlugin({ store }));
33
73
  bus.on("save", (draft, { store }) => store.put(draft));
34
74
  ```
35
75
 
76
+ A client that must stop with the task, such as an `axios` instance, goes in as `fromSignal((signal) => axios.create({ signal }))` from `nanoeffects/signal`: it is built from the signal of each task, when the task reads it. More in [Build a field from the signal of each task](context.md#build-a-field-from-the-signal-of-each-task).
77
+
36
78
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
37
79
 
38
80
  Effects such as `race`, `all`, `take` and `delay` are bound to the task, so a handler calls `race({ … })` and not `race(ctx, { … })`. Your own functions that take `{ signal }` first work too. More in [Effects](effects.md#put-selected-effects-on-ctx).
@@ -48,7 +90,7 @@ const bus = createBus().use(effects);
48
90
 
49
91
  bus.on("load", async (id, { race, delay }) => {
50
92
  const result = await race({
51
- data: ({ signal }) => api.get(id, { signal }),
93
+ data: (child) => api.get(id, child),
52
94
  timeout: () => delay(5000),
53
95
  });
54
96
  });
@@ -85,7 +127,7 @@ const bus = createBus();
85
127
  // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
86
128
  const app = bus.use(modulePlugin([userModule]));
87
129
 
88
- app.unmount(userModule); // its listeners, bound commands and tasks are gone
130
+ app.unmount(userModule); // its listeners, bound actions and tasks are gone
89
131
  ```
90
132
 
91
133
  ## Extra plugins
@@ -95,7 +137,7 @@ Add one of these when you meet its need. None of them is required.
95
137
  | Plugin | What it gives you | Import | `bus.use` returns |
96
138
  | ------ | ----------------- | ------ | ----------------- |
97
139
  | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
98
- | [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
140
+ | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks, and `on(event, handler, { signal })` that removes the listener, when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
99
141
  | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
100
142
  | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
101
143
  | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) | `traceId` and `traceGroupId` for a trace: the root of an emitter, a flow, a named listener. | `nanoeffects/trace` | The same bus |
@@ -111,10 +153,10 @@ import { createBus } from "nanoeffects";
111
153
  import { createAction } from "nanoeffects/actions";
112
154
  import { scopePlugin } from "nanoeffects/scope";
113
155
 
114
- // scopePlugin — makes the scope of a command work on this bus
156
+ // scopePlugin — makes the scope of an action work on this bus
115
157
  const bus = createBus().use(scopePlugin());
116
158
 
117
- // the last argument: fields of this command only
159
+ // the last argument: fields of this action only
118
160
  const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
119
161
  api,
120
162
  });
@@ -123,25 +165,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
123
165
  bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
124
166
  ```
125
167
 
126
- ### Tie an emit to an outside signal: `signalPlugin`
168
+ ### Tie an emit or a listener to an outside signal: `signalPlugin`
127
169
 
128
- A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and SWR](query-libraries.md).
170
+ A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
129
171
 
130
172
  ```js
131
173
  import { createBus } from "nanoeffects";
132
174
  import { createAction } from "nanoeffects/actions";
133
175
  import { signalPlugin } from "nanoeffects/signal";
134
176
 
135
- // signalPlugin — emit takes { signal } for a command, a job or an action
177
+ // signalPlugin — emit takes { signal } for an action, a job or an action
136
178
  const bus = createBus().use(signalPlugin());
137
179
 
138
180
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
139
181
 
140
182
  const controller = new AbortController();
141
- bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
183
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
142
184
  controller.abort(); // ctx.signal of the handler aborts with it
143
185
  ```
144
186
 
187
+ The same signal can end a listener. `on` and `once` take `{ signal }` as their options, as `addEventListener` does, so code that has a signal keeps no `unbind` function. The listener is removed when the signal aborts; a handler that already runs is not aborted. A signal that is aborted already adds no listener.
188
+
189
+ ```js
190
+ import { createBus } from "nanoeffects";
191
+ import { signalPlugin } from "nanoeffects/signal";
192
+
193
+ // signalPlugin — on and once take { signal } in their options
194
+ const bus = createBus().use(signalPlugin());
195
+
196
+ const controller = new AbortController();
197
+ bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
198
+ controller.abort(); // the listener is gone: bus.get("saved") is empty
199
+ ```
200
+
201
+ Without the plugin the `signal` option is ignored and the listener stays. `off(event, handler)` removes a listener but does not end its link to the signal; call the function that `on` returned for that.
202
+
145
203
  ### Name the origin and the flow of a trace: `tracePlugin`
146
204
 
147
205
  With a tracer on the bus, the plugin lets a trace say which subscription began a chain (`traceId` of `useEmitter` and `bus.emitter`), follow one flow by a `traceGroupId`, and show which listener ran (`bus.on(event, handler, { traceId })`). Without the plugin the options are ignored. More in [Tracing](tracing.md#show-which-subscription-began-a-chain).
@@ -254,7 +312,7 @@ import { scopePlugin } from "nanoeffects/scope";
254
312
  export const bus = createBus()
255
313
  .use(contextPlugin({ store })) // ctx.store on every task
256
314
  .use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
257
- .use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
315
+ .use(scopePlugin()) // scopes for actions, jobs, modules and emit
258
316
  .use(matchPlugin()) // bus.on takes a function that picks the events
259
317
  .use(emitterPlugin()); // bus.emitter(creator)
260
318
 
@@ -295,7 +353,7 @@ These connect the bus to something, but are not used with `bus.use`:
295
353
  - `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
296
354
  - `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
297
355
  - `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
298
- - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
356
+ - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable, and `events(bus, event)` into a `for await` loop. See [Observable](observable.md).
299
357
 
300
358
  ## Write your own plugin
301
359