nanoeffects 0.29.0 → 0.31.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 (127) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
  3. package/dist/actions.d.ts +3 -3
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
  8. package/dist/context.d.ts +2 -2
  9. package/dist/dev.d.ts +49 -3
  10. package/dist/dev.js +2 -1
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/emit-key-C6FL7Ewn.js +1 -0
  14. package/dist/guard.d.ts +59 -0
  15. package/dist/guard.js +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +1 -1
  19. package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
  20. package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
  21. package/dist/notrace/actions.d.ts +2 -2
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +1 -1
  24. package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/redux.d.ts +1 -1
  27. package/dist/observable.d.ts +1 -1
  28. package/dist/oop.d.ts +1 -1
  29. package/dist/oop.js +1 -1
  30. package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  31. package/dist/policies.d.ts +2 -2
  32. package/dist/policies.js +1 -1
  33. package/dist/production/dev.js +1 -1
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.js +1 -1
  36. package/dist/redux.d.ts +1 -1
  37. package/dist/scope.d.ts +2 -2
  38. package/dist/signal.d.ts +22 -2
  39. package/dist/signal.js +1 -1
  40. package/dist/site-DLvUoXs3.js +1 -0
  41. package/dist/svelte.d.ts +1 -1
  42. package/dist/trace.d.ts +151 -2
  43. package/dist/trace.js +3 -1
  44. package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
  45. package/dist/vue.d.ts +1 -1
  46. package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
  47. package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  48. package/docs/README.md +62 -31
  49. package/docs/actions.md +44 -27
  50. package/docs/cancel-stale-requests.md +2 -0
  51. package/docs/chat-example.md +3 -1
  52. package/docs/chat-widget.md +2 -0
  53. package/docs/context.md +322 -9
  54. package/docs/debounce-and-abort.md +3 -1
  55. package/docs/debugging-loops.md +318 -0
  56. package/docs/effects.md +255 -138
  57. package/docs/errors.md +4 -2
  58. package/docs/examples.md +60 -0
  59. package/docs/guide.md +39 -22
  60. package/docs/hmr.md +35 -1
  61. package/docs/loop-guard.md +109 -0
  62. package/docs/migrating-from-effector.md +4 -2
  63. package/docs/migrating-from-mitt.md +6 -4
  64. package/docs/migrating-from-rxjs.md +2 -0
  65. package/docs/migrating-from-saga.md +2 -0
  66. package/docs/motivation.md +14 -21
  67. package/docs/observable.md +2 -0
  68. package/docs/oop.md +3 -1
  69. package/docs/package.md +4 -1
  70. package/docs/pass-the-signal.md +346 -0
  71. package/docs/plugins.md +22 -5
  72. package/docs/policies.md +73 -46
  73. package/docs/prevent-double-submit.md +3 -1
  74. package/docs/query-libraries.md +3 -1
  75. package/docs/react.md +42 -13
  76. package/docs/recipes.md +528 -0
  77. package/docs/ru/README.md +62 -0
  78. package/docs/ru/actions.md +412 -0
  79. package/docs/ru/cancel-stale-requests.md +106 -0
  80. package/docs/ru/chat-example.md +259 -0
  81. package/docs/ru/chat-widget.md +131 -0
  82. package/docs/ru/context.md +402 -0
  83. package/docs/ru/debounce-and-abort.md +105 -0
  84. package/docs/ru/debugging-loops.md +318 -0
  85. package/docs/ru/effects.md +538 -0
  86. package/docs/ru/errors.md +36 -0
  87. package/docs/ru/examples.md +60 -0
  88. package/docs/ru/guide.md +530 -0
  89. package/docs/ru/hmr.md +83 -0
  90. package/docs/ru/loop-guard.md +109 -0
  91. package/docs/ru/migrating-from-effector.md +339 -0
  92. package/docs/ru/migrating-from-mitt.md +99 -0
  93. package/docs/ru/migrating-from-rxjs.md +197 -0
  94. package/docs/ru/migrating-from-saga.md +97 -0
  95. package/docs/ru/motivation.md +47 -0
  96. package/docs/ru/observable.md +233 -0
  97. package/docs/ru/oop.md +431 -0
  98. package/docs/ru/package.md +42 -0
  99. package/docs/ru/pass-the-signal.md +346 -0
  100. package/docs/ru/plugins.md +398 -0
  101. package/docs/ru/policies.md +201 -0
  102. package/docs/ru/prevent-double-submit.md +122 -0
  103. package/docs/ru/query-libraries.md +392 -0
  104. package/docs/ru/react.md +309 -0
  105. package/docs/ru/recipes.md +528 -0
  106. package/docs/ru/semantics.md +19 -0
  107. package/docs/ru/server.md +204 -0
  108. package/docs/ru/starter.md +118 -0
  109. package/docs/ru/stores.md +673 -0
  110. package/docs/ru/svelte.md +78 -0
  111. package/docs/ru/tracing.md +466 -0
  112. package/docs/ru/typescript.md +170 -0
  113. package/docs/ru/vue.md +92 -0
  114. package/docs/semantics.md +2 -0
  115. package/docs/server.md +2 -0
  116. package/docs/starter.md +2 -0
  117. package/docs/stores.md +2 -0
  118. package/docs/svelte.md +3 -1
  119. package/docs/tracing.md +58 -1
  120. package/docs/typescript.md +2 -0
  121. package/docs/vue.md +3 -1
  122. package/llms-full.txt +2087 -418
  123. package/llms.txt +11 -8
  124. package/package.json +23 -6
  125. package/dist/context-DtUnzC-1.js +0 -1
  126. package/docs/scope.md +0 -278
  127. package/docs/stability.md +0 -90
package/docs/effects.md CHANGED
@@ -1,119 +1,155 @@
1
1
  # Effects and cancellation
2
2
 
3
- Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
3
+ > **Level C.** Read first: [Policies](policies.md).
4
+
5
+ Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** effect for your job; the rest is reference.
4
6
 
5
7
  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
8
 
7
- ## On this page
9
+ ## Effects
10
+
11
+ Every effect below has its own section with its forms, options and an example. `ctx` is the context of the task.
12
+
13
+ - [`retry`](#retry) — repeat a call after an error.
14
+ - [`timeout`](#timeout) — stop a call that takes too long.
15
+ - [`poll`](#poll) — call a function at an interval.
16
+ - [`backoff`](#backoff) — make the pause between retries grow.
17
+ - [`delay`](#delay) — wait for a time; an abort ends the wait.
18
+ - [`cancellable`](#cancellable) — a Promise tied to the task signal, for callbacks and timers.
19
+ - [`onAbort`](#onabort) — run code when the task is aborted.
20
+ - [`ctx.on` and `ctx.off`](#ctxon-and-ctxoff) — subscribe to the bus inside a task.
21
+ - [`take`](#take) — wait for the next event inside a handler.
22
+ - [`all`](#all) — run several calls together; one failure cancels the rest.
23
+ - [`allSettled`](#allsettled) — wait for all of them, whatever the outcome.
24
+ - [`race`](#race) — take the first result and cancel the rest.
25
+ - [`effectsPlugin`](#effectsplugin) — short forms of effects: call them without `ctx`.
26
+ - [Effects outside a task](#effects-outside-a-task) — call effects where there is no handler.
8
27
 
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)
28
+ ## `retry`
18
29
 
19
- ## Wrap a handler with retry and timeout
30
+ Runs a function again after an error. Use it when a call fails now and then, for example because of the network.
20
31
 
21
- `retry`, `timeout`, and `poll` have two forms:
32
+ Forms:
22
33
 
23
- - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
24
- - **Call inside a body** — take `ctx` first.
34
+ - `retry(fn, options?)` — a wrapper of a handler: takes a function `(payload, ctx) => result` and returns one. Pass it to `on` next to policies.
35
+ - `retry(ctx, fn, options?)` — a call inside the body of a handler.
25
36
 
26
- The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
37
+ Options:
38
+
39
+ - `times` — total attempts. It is `3` by default.
40
+ - `delay` — a number or a function `(attempt, error) => ms`. For a growing pause see [`backoff`](#backoff).
41
+ - `shouldRetry(error, attempt)` — return `false` to stop early.
42
+ - `fn` gets the attempt number, starting at `1`.
43
+ - On abort, `retry` rejects with the abort reason.
27
44
 
28
45
  ```js
29
46
  import { createBus } from "nanoeffects";
30
- import { poll, retry, timeout } from "nanoeffects/effects";
47
+ import { retry } from "nanoeffects/effects";
31
48
  import { latest } from "nanoeffects/policies";
32
49
 
33
50
  const bus = createBus();
34
51
  const loadUser = (id, { signal }) => fetchUser(id, { signal });
35
52
 
36
- // timeout — one try must finish within 5 s
37
- const loadOnce = timeout(5000, loadUser);
38
- // retry — up to 4 attempts with backoff
39
- const loadWithRetry = retry(loadOnce, {
53
+ // retry — a wrapper of the handler: up to 4 attempts, but not after a fatal error
54
+ const loadWithRetry = retry(loadUser, {
40
55
  times: 4,
41
- delay: (attempt) => 2 ** attempt * 100,
42
56
  shouldRetry: (error) => !isFatal(error),
43
57
  });
44
- // latest — a newer load aborts the older retry chain
45
- bus.on("load", latest(loadWithRetry));
46
58
 
47
- // poll — call refresh every 10 s until the task aborts
48
- bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
59
+ // latest — a newer load aborts the older chain of attempts
60
+ bus.on("load", latest(loadWithRetry));
49
61
 
50
- // ctx.run — run a wrapped Task as a child (aborted with the handler)
62
+ // ctx.run — run the wrapped function as a child task (aborted with the handler)
51
63
  bus.on("open", async (id, ctx) => {
52
- const load = retry(timeout(5000, loadUser));
53
- const user = await ctx.run(load, id);
64
+ const user = await ctx.run(loadWithRetry, id);
54
65
  ctx.emit("opened", user);
55
66
  });
67
+ ```
68
+
69
+ Use the wrapper for a whole handler or a whole `ctx.run`. Each attempt gets its own child context: an abort of the caller reaches it, and an abort of one attempt does not reach the caller. For one call in the middle of a body use the form with `ctx`:
70
+
71
+ ```js
72
+ import { createBus } from "nanoeffects";
73
+ import { retry } from "nanoeffects/effects";
74
+
75
+ const bus = createBus();
56
76
 
57
- // ctx-first form — one call inside a body
58
77
  bus.on("load", async (id, ctx) => {
78
+ // retry with ctx — one call inside the body
59
79
  const user = await retry(
60
80
  ctx,
61
81
  () => fetchUser(id, { signal: ctx.signal }),
62
82
  { times: 4 },
63
83
  );
64
- const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
65
- await poll(ctx, () => refresh(), 10_000);
66
- ctx.emit("ready", { user, data });
84
+
85
+ ctx.emit("loaded", user);
67
86
  });
68
87
  ```
69
88
 
70
- Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
89
+ ## `timeout`
71
90
 
72
- Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
91
+ Rejects when the work takes longer than the time you set, and aborts it. Use it when a request can hang.
73
92
 
74
- ### Options for retry, timeout, and poll
93
+ Forms:
75
94
 
76
- **`retry`**
95
+ - `timeout(ms, handler)` — a wrapper of a handler.
96
+ - `timeout(ctx, ms, fn)` — a call inside a body; `fn` gets the context of its own child task.
77
97
 
78
- - `times` — total attempts (default `3`).
79
- - `delay` — number or `(attempt, error) => ms`.
80
- - `shouldRetry(error, attempt)` — stop early.
81
- - `fn` gets the attempt number starting at `1`.
82
- - On abort, rejects with the abort reason.
98
+ The error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`). `timeout` rejects even if the function ignores its signal.
83
99
 
84
- **`timeout`**
100
+ ```js
101
+ import { createBus } from "nanoeffects";
102
+ import { timeout } from "nanoeffects/effects";
85
103
 
86
- - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
87
- - Rejects even if the starter ignores its signal.
104
+ const bus = createBus();
105
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
88
106
 
89
- **`poll`** (like patronum `interval`)
107
+ // timeout — a wrapper of the handler: it must finish within 5 s
108
+ bus.on("load", timeout(5000, loadUser));
90
109
 
91
- - First call after `ms`. `leading: true` also calls at once.
92
- - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
93
- - `waitForFn: true` starts each pause when the call ends — calls never overlap.
94
- - `fn` gets the call number from `0`.
95
- - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
110
+ bus.on("open", async (id, ctx) => {
111
+ // timeout with ctx — one call inside the body; child carries the signal of the request
112
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
113
+ ctx.emit("opened", data);
114
+ });
115
+ ```
116
+
117
+ ## `poll`
96
118
 
97
- A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
119
+ Calls a function again and again at an interval, until the task is aborted. Use it to refresh data every N seconds. It is like `interval` in patronum.
120
+
121
+ Forms:
122
+
123
+ - `poll(ms, fn, options?)` — a wrapper of a handler.
124
+ - `poll(ctx, fn, ms)` — a call inside a body.
125
+
126
+ Options:
127
+
128
+ - The first call happens after `ms`. `leading: true` also calls at once.
129
+ - The next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call. `waitForFn: true` starts each pause when the call ends, so calls never overlap.
130
+ - `fn` gets the call number, starting at `0`.
131
+ - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
98
132
 
99
133
  ```js
100
134
  import { createBus } from "nanoeffects";
101
- import { delay, poll, retry } from "nanoeffects/effects";
135
+ import { poll } from "nanoeffects/effects";
102
136
 
103
137
  const bus = createBus();
104
138
 
139
+ // poll — a wrapper of the handler: call refresh every 10 s until the task aborts
140
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
141
+
105
142
  bus.on("sync", async (_, ctx) => {
106
- retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
107
- poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
108
- await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
143
+ // poll with ctx — the same inside a body
144
+ await poll(ctx, () => refresh(), 10_000);
109
145
  });
110
146
  ```
111
147
 
112
- All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
148
+ `retry`, `timeout` and `poll` are traced: the effect is a group, and each attempt or call is a `run` child.
113
149
 
114
- ## Back off between retries
150
+ ## `backoff`
115
151
 
116
- Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
152
+ Makes the pause between retries grow, with a cap and jitter. Use it when a server or network fails under load: retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. `backoff(options)` returns a function `(attempt) => ms`.
117
153
 
118
154
  The shortest form is a `delay` function. It needs no import:
119
155
 
@@ -153,49 +189,41 @@ const loadWithRetry = retry(loadUser, {
153
189
  - `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
154
190
  - The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
155
191
 
156
- ## Put selected effects on `ctx`
157
-
158
- If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
192
+ A pause can be a function wherever a time is given:
159
193
 
160
194
  ```js
161
195
  import { createBus } from "nanoeffects";
162
- import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
163
-
164
- // effectsPlugin — add race, all and delay to the context of every task
165
- const effects = effectsPlugin({ race, all, delay });
166
-
167
- // use returns the same bus, typed with the new fields
168
- const bus = createBus().use(effects);
196
+ import { delay, poll, retry } from "nanoeffects/effects";
169
197
 
170
- bus.on("load", async (id, { race, all, delay }) => {
171
- // race — same as race(ctx, …)
172
- const result = await race({
173
- // child — the context of this starter; it has the `signal`, so the request stops with the race
174
- data: (child) => fetchData(id, child),
175
- // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
176
- timeout: ({ delay }) => delay(5000),
177
- });
178
- if ("timeout" in result) throw new Error("timed out");
198
+ const bus = createBus();
179
199
 
180
- // all — same as all(ctx, …)
181
- const { user, posts } = await all({
182
- user: (child) => fetchUser(id, child),
183
- posts: (child) => fetchPosts(id, child),
184
- });
200
+ bus.on("sync", async (_, ctx) => {
201
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
202
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
203
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
185
204
  });
186
205
  ```
187
206
 
188
- - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
189
- - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](scope.md#keep-a-generic-effect-generic).
190
- - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
191
- - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
192
- - Free forms keep working: `race(ctx, …)`.
207
+ ## `delay`
208
+
209
+ Waits for the given time and rejects on abort. A plain `setTimeout` would go on after the task is aborted; `delay` ends the wait at once and clears the timer. `delay(ctx, ms)`, where `ms` can be a function.
210
+
211
+ ```js
212
+ import { createBus } from "nanoeffects";
213
+ import { delay } from "nanoeffects/effects";
193
214
 
194
- ## Tie a promise to the task signal
215
+ const bus = createBus();
216
+
217
+ bus.on("saved", async (_, ctx) => {
218
+ // delay — wait 3 s; an abort ends the wait at once and clears the timer
219
+ await delay(ctx, 3000);
220
+ ctx.emit("toast/hidden");
221
+ });
222
+ ```
195
223
 
196
- `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
224
+ ## `cancellable`
197
225
 
198
- `cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
226
+ A Promise tied to the task signal. Use it for APIs that work with callbacks, timers or listeners: the signal cannot stop them by itself, so `cancellable` rejects the Promise on abort and runs your cleanup. `cancellable(ctx, executor)` works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
199
227
 
200
228
  ```js
201
229
  import { createBus } from "nanoeffects";
@@ -204,7 +232,7 @@ import { latest } from "nanoeffects/policies";
204
232
 
205
233
  const bus = createBus();
206
234
 
207
- // cancellable — promise tied to the task signal; onCancel for cleanups
235
+ // cancellable — a Promise tied to the task signal; onCancel registers a cleanup
208
236
  // latest — a newer wait aborts the older timer
209
237
  bus.on(
210
238
  "wait",
@@ -217,13 +245,13 @@ bus.on(
217
245
  );
218
246
  ```
219
247
 
220
- - If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
248
+ - If the task aborts before the Promise settles, cleanups run (last registered first) and the Promise rejects with the abort reason.
221
249
  - If the task is already aborted, the executor does not run.
222
- - After the promise settles, the abort listener is removed and cleanups are dropped.
250
+ - After the Promise settles, the abort listener is removed and cleanups are dropped.
223
251
 
224
- ## Run cleanup on abort
252
+ ## `onAbort`
225
253
 
226
- `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
254
+ Runs code when the task is aborted: a hook of the life of the task. A task may be aborted because the user left or a newer call replaced it, and whatever it opened (a socket, a timer, a listener) must be closed then. `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if the task is already aborted) and returns a function that unsubscribes.
227
255
 
228
256
  ```js
229
257
  import { createBus } from "nanoeffects";
@@ -233,14 +261,14 @@ const bus = createBus();
233
261
 
234
262
  bus.on("connect", (url, ctx) => {
235
263
  const socket = new WebSocket(url);
236
- // onAbort — run cleanup when this task aborts
264
+ // onAbort — close the socket when this task aborts
237
265
  onAbort(ctx, () => socket.close());
238
266
  });
239
267
  ```
240
268
 
241
- ## Wait for the next event with `take`
269
+ ## `ctx.on` and `ctx.off`
242
270
 
243
- A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
271
+ Subscribes to the bus inside a task. A task context has `on` and `off`, like the bus, and a subscription made with `ctx.on` ends when the task aborts.
244
272
 
245
273
  ```js
246
274
  import { createBus } from "nanoeffects";
@@ -248,15 +276,29 @@ import { createBus } from "nanoeffects";
248
276
  const bus = createBus();
249
277
 
250
278
  bus.on("watch", (_, ctx) => {
251
- ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
279
+ // gone when this task aborts
280
+ const stop = ctx.on("tick", (n) => console.log(n));
281
+
282
+ // or end it sooner yourself: stop() unsubscribes
283
+ ctx.on("watch/closed", stop);
252
284
  });
253
285
  ```
254
286
 
255
- - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
256
- - Under `latest`, subscriptions of a superseded run end with that run.
257
- - `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
287
+ Be careful with it:
288
+
289
+ - **A task that finishes by itself is not aborted**, so its `ctx.on` subscription stays. When the work is done, unsubscribe with the function that `ctx.on` returns, or call `ctx.abort()`.
290
+ - To wait for **one** event, use [`take`](#take): it unsubscribes by itself.
291
+ - For listeners that live as long as a feature, use a [module](actions.md#modules): `unmount` removes all of them.
292
+
293
+ Also:
294
+
295
+ - After an abort, `ctx.on` does nothing.
296
+ - Under `latest`, the subscriptions of a superseded run end with that run.
297
+ - `ctx.off(event)` clears every handler of the event on the bus, not only the ones of this task.
298
+
299
+ ## `take`
258
300
 
259
- `take(ctx, event, options?)` waits for the next `event` and resolves with its payload. `event` is the name of an event or an action creator, as for `bus.on`:
301
+ Waits for the next event of the bus inside a handler and resolves with its payload. `take(ctx, event, options?)`. The `event` is the name of an event or an action creator, as for `bus.on`.
260
302
 
261
303
  ```js
262
304
  import { createBus } from "nanoeffects";
@@ -278,22 +320,65 @@ bus.on("login", async (user, ctx) => {
278
320
  const id = await timeout(
279
321
  ctx,
280
322
  5000,
281
- take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx: a starter
323
+ take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx — a ready-made function for the group
282
324
  );
283
325
  });
284
326
  ```
285
327
 
286
- - Sees only events emitted after the call, takes one, and unsubscribes.
328
+ - It sees only events emitted after the call, takes one, and unsubscribes.
287
329
  - With an action creator the result has the payload type of the creator. A creator made without a payload type (`createAction("saved")`) takes it from the event map of the bus.
288
330
  - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
289
- - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the starter, so write `take("saved")` without options.
290
- - On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
331
+ - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the wait, so write `take("saved")` without options.
332
+ - On abort of the task or of `options.signal`, it unsubscribes and rejects with the abort reason.
333
+
334
+ ## `all`
335
+
336
+ Runs several calls together; one failure cancels the rest. Use it to load several things at once. `all(ctx, effects)` takes an object or an array of functions, and the result keeps that shape.
337
+
338
+ ```js
339
+ import { createBus } from "nanoeffects";
340
+ import { all } from "nanoeffects/effects";
341
+
342
+ const bus = createBus();
343
+
344
+ bus.on("open", async (id, ctx) => {
345
+ // all — run both requests together; one failure cancels the other. child is the context of its own request
346
+ const { user, posts } = await all(ctx, {
347
+ user: (child) => fetchUser(id, child),
348
+ posts: (child) => fetchPosts(id, child),
349
+ });
350
+
351
+ ctx.emit("opened", { user, posts });
352
+ });
353
+ ```
354
+
355
+ Every function of the group gets `child`, the context of its own child task. Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the group ends or fails, `child` is aborted and stops exactly this effect. If your API takes the signal in its options, `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `allSettled`, `race` and `timeout`.
356
+
357
+ **Limit how many run at once.** `all` and `allSettled` start everything together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
358
+
359
+ ```js
360
+ import { createBus } from "nanoeffects";
361
+ import { all, retry } from "nanoeffects/effects";
362
+
363
+ const bus = createBus();
364
+
365
+ bus.on("sync", async (ids, ctx) => {
366
+ // all with concurrency — 5 loads at a time, results in the order of ids
367
+ const users = await all(
368
+ ctx,
369
+ ids.map((id) => retry(() => loadUser(id), { times: 2 })),
370
+ { concurrency: 5 },
371
+ );
372
+
373
+ render(users);
374
+ });
375
+ ```
291
376
 
292
- ## Run several starters together
377
+ The result has the same order and shape as without the option. If one call of `all` fails, the running ones are cancelled and the waiting ones are never started. `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 call that waited for a free slot would never run.
293
378
 
294
- `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
379
+ ## `allSettled`
295
380
 
296
- `allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
381
+ Waits for every call and never fails because of one. Use it when a partial answer is better than none. `allSettled(ctx, effects)`. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a call fails; only an abort of `ctx` rejects it.
297
382
 
298
383
  ```js
299
384
  import { createBus } from "nanoeffects";
@@ -302,6 +387,7 @@ import { allSettled } from "nanoeffects/effects";
302
387
  const bus = createBus();
303
388
 
304
389
  bus.on("open", async (id, ctx) => {
390
+ // allSettled — wait for both, whatever the outcome
305
391
  const { user, posts } = await allSettled(ctx, {
306
392
  user: (child) => fetchUser(id, child),
307
393
  posts: (child) => fetchPosts(id, child),
@@ -313,7 +399,9 @@ bus.on("open", async (id, ctx) => {
313
399
  });
314
400
  ```
315
401
 
316
- `race(ctx, effects)` settles with the first starter that finishes and cancels the others.
402
+ ## `race`
403
+
404
+ Settles with the first call that finishes and cancels the others. Use it to take the fastest answer or to cut a wait short. `race(ctx, effects)`.
317
405
 
318
406
  ```js
319
407
  import { createBus } from "nanoeffects";
@@ -322,9 +410,9 @@ import { delay, race, take } from "nanoeffects/effects";
322
410
  const bus = createBus();
323
411
 
324
412
  bus.on("save", async (draft, ctx) => {
325
- // race — the first starter to finish wins, the other is cancelled
413
+ // race — the first to finish wins, the other is cancelled
326
414
  const result = await race(ctx, {
327
- waited: delay(1000), // delay without ctx — a starter: race gives it its own child
415
+ waited: delay(1000), // delay without ctx — a ready-made function for the group: race gives it its own child
328
416
  cancelled: take("cancel"), // take without ctx — the same, waits for the next "cancel"
329
417
  });
330
418
 
@@ -334,9 +422,9 @@ bus.on("save", async (draft, ctx) => {
334
422
  });
335
423
  ```
336
424
 
337
- **`delay` and `take` without `ctx` are ready-made starters.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do. The group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. A starter waits only when a group calls it: do not `await delay(1000)` on its own, it is a function, not a promise. Write `take("cancel")` in the group, as above; a starter stored in a variable outside it does not know the events of your bus, and you name them yourself in TypeScript (`const cancelled: StarterOf<Events> = take("cancel")`; `StarterOf` is exported by `nanoeffects/effects`).
425
+ **`delay` and `take` without `ctx` are ready-made functions for a group.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do: the group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. They wait only when a group calls them: `await delay(1000)` on its own does not wait, because it is a function, not a Promise. In TypeScript, one kept in a variable outside a group does not know the events of your bus; name them yourself: `const cancelled: StarterOf<Events> = take("cancel")` (`StarterOf` is exported by `nanoeffects/effects`).
338
426
 
339
- The wrapper form of `retry`, `timeout` and `poll` is a starter too: a group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
427
+ The wrapper form of `retry`, `timeout` and `poll` works in a group too: the group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
340
428
 
341
429
  ```js
342
430
  import { createBus } from "nanoeffects";
@@ -346,7 +434,7 @@ const bus = createBus();
346
434
 
347
435
  bus.on("open", async (id, ctx) => {
348
436
  const result = await race(ctx, {
349
- // retry without ctx — a starter: up to 3 attempts, each with the signal of the child
437
+ // retry without ctx — up to 3 attempts, each with the signal of the child
350
438
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
351
439
  // delay without ctx — the loser is cancelled when the other one finishes
352
440
  waited: delay(5000),
@@ -358,9 +446,9 @@ bus.on("open", async (id, ctx) => {
358
446
  });
359
447
  ```
360
448
 
361
- The same call with a payload and a context, `(payload, ctx)`, is the Task that `bus.on` and `ctx.run` use, and it does not change. `retry`, `timeout` and `poll` therefore have three call forms: with `ctx`, as a Task, and as a starter. All three stay; [Stability](stability.md#rely-on-these-call-forms) says how they are told apart.
449
+ So `retry`, `timeout` and `poll` have three call forms: with `ctx`, as a Task for `bus.on` and `ctx.run`, and inside a group. The arguments tell them apart: a context first is the `ctx` form; a function called with a payload and a context is a Task; the same function called by a group with one argument, its child, is the group form. This does not depend on how many parameters your function declares.
362
450
 
363
- **`all`, `allSettled` and `race` without `ctx` are starters too**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
451
+ **`all`, `allSettled` and `race` without `ctx` also work in a group**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
364
452
 
365
453
  ```js
366
454
  import { createBus } from "nanoeffects";
@@ -370,7 +458,7 @@ const bus = createBus();
370
458
 
371
459
  bus.on("open", async (id, ctx) => {
372
460
  const result = await race(ctx, {
373
- // all without ctx — a starter: loads both, each with up to 3 attempts
461
+ // all without ctx — loads both, each with up to 3 attempts
374
462
  loaded: all({
375
463
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
376
464
  posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
@@ -385,37 +473,66 @@ bus.on("open", async (id, ctx) => {
385
473
  });
386
474
  ```
387
475
 
388
- One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a starter, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own starters. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. A group starter written in a group needs no type. One kept in a variable outside a group does not know its bus: in TypeScript, annotate it with `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
476
+ One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a function for a group, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own calls. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`; write `(child) => all(child, effects, { concurrency: 5 })` there. In TypeScript, a group kept in a variable outside a group needs a type: `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
477
+
478
+ ## `effectsPlugin`
389
479
 
390
- **Limit how many run at once.** `all` and `allSettled` start every starter together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
480
+ Short forms of effects: `effectsPlugin(effects)` puts chosen effects on `ctx`, so a handler calls them without passing `ctx` around. Use it when many handlers call the same helpers, to cut the boilerplate. A handler destructures them like `signal`. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
391
481
 
392
482
  ```js
393
483
  import { createBus } from "nanoeffects";
394
- import { all, retry } from "nanoeffects/effects";
484
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
395
485
 
396
- const bus = createBus();
486
+ // effectsPlugin — add race, all and delay to the context of every task
487
+ const effects = effectsPlugin({ race, all, delay });
397
488
 
398
- bus.on("sync", async (ids, ctx) => {
399
- // all with concurrency — 5 loads at a time, results in the order of ids
400
- const users = await all(
401
- ctx,
402
- ids.map((id) => retry(() => loadUser(id), { times: 2 })),
403
- { concurrency: 5 },
404
- );
489
+ // use returns the same bus, typed with the new fields
490
+ const bus = createBus().use(effects);
405
491
 
406
- render(users);
492
+ bus.on("load", async (id, { race, all, delay }) => {
493
+ // race — same as race(ctx, …)
494
+ const result = await race({
495
+ data: (child) => fetchData(id, child),
496
+ // a group function gets its own child with the same bound effects, so delay(5000) stops with the race
497
+ timeout: ({ delay }) => delay(5000),
498
+ });
499
+ if ("timeout" in result) throw new Error("timed out");
500
+
501
+ // all — same as all(ctx, …)
502
+ const { user, posts } = await all({
503
+ user: (child) => fetchUser(id, child),
504
+ posts: (child) => fetchPosts(id, child),
505
+ });
407
506
  });
408
507
  ```
409
508
 
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.
509
+ - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
510
+ - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](context.md#keep-a-generic-effect-generic).
511
+ - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
512
+ - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
513
+ - The free forms keep working: `race(ctx, …)`.
411
514
 
515
+ ## Effects outside a task
412
516
 
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`.
517
+ Effects take `ctx` first. Inside a handler it is the `ctx` you already have. Outside a task (in a plain function, a script or a test) there is no `ctx`: pass an object with a `signal`, or `{}` if you never abort.
414
518
 
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.
519
+ ```js
520
+ import { delay, retry } from "nanoeffects/effects";
416
521
 
417
- ## Pass `ctx` outside a task
522
+ const controller = new AbortController();
418
523
 
419
- Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`. The one exception is `delay(ms)` and `take(event)` as starters of a group, which take no `ctx` (see above).
524
+ // outside a task there is no ctx: an object with a signal is enough
525
+ const context = { signal: controller.signal };
420
526
 
421
- For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
527
+ // the effects take it as ctx; fetchUser gets the same signal, so the request itself stops too
528
+ const user = await retry(
529
+ context,
530
+ () => fetchUser("1", { signal: controller.signal }),
531
+ { times: 3 },
532
+ );
533
+
534
+ await delay(context, 500);
535
+
536
+ // stops the pending retries and waits
537
+ controller.abort();
538
+ ```
package/docs/errors.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Handle errors and unheard events
2
2
 
3
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
4
+
3
5
  Defaults match a small bus. Set these when you want isolation or typo warnings.
4
6
 
5
7
  ## Isolate failing handlers
@@ -21,9 +23,9 @@ const bus = createBus({
21
23
 
22
24
  ## Warn when nobody listens
23
25
 
24
- A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for actions, which always have a handler to run.
26
+ A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for an action that has a handler to run. An action without a handler is only an event, so it is called for it.
25
27
 
26
- Use it as a warning in development:
28
+ Use it as a warning in development (`import.meta.env.DEV` is the Vite flag; use your own check in another bundler):
27
29
 
28
30
  ```js
29
31
  import { createBus } from "nanoeffects";