nanoeffects 0.28.0 → 0.30.1

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 (139) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DFlmNJ6h.js +1 -0
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/context-types-DKNc5mkS.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +44 -3
  12. package/dist/dev.js +2 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/emit-key-C6FL7Ewn.js +1 -0
  16. package/dist/guard.d.ts +59 -0
  17. package/dist/guard.js +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/match.d.ts +3 -3
  21. package/dist/match.js +1 -1
  22. package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
  23. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
  24. package/dist/notrace/actions.d.ts +52 -52
  25. package/dist/notrace/actions.js +1 -1
  26. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  27. package/dist/notrace/effects.d.ts +2 -2
  28. package/dist/notrace/index.d.ts +2 -2
  29. package/dist/notrace/index.js +1 -1
  30. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  31. package/dist/notrace/policies.d.ts +1 -1
  32. package/dist/notrace/redux.d.ts +1 -1
  33. package/dist/observable.d.ts +43 -1
  34. package/dist/observable.js +1 -1
  35. package/dist/onAbort-BgMfspK4.js +1 -0
  36. package/dist/oop.d.ts +3 -3
  37. package/dist/oop.js +1 -1
  38. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  39. package/dist/policies.d.ts +2 -2
  40. package/dist/policies.js +1 -1
  41. package/dist/production/dev.js +1 -1
  42. package/dist/react.d.ts +2 -2
  43. package/dist/react.js +1 -1
  44. package/dist/redux.d.ts +1 -1
  45. package/dist/scope.d.ts +9 -9
  46. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  47. package/dist/signal.d.ts +34 -6
  48. package/dist/signal.js +1 -1
  49. package/dist/site-DLvUoXs3.js +1 -0
  50. package/dist/svelte.d.ts +1 -1
  51. package/dist/trace.d.ts +170 -6
  52. package/dist/trace.js +3 -1
  53. package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
  54. package/dist/vue.d.ts +1 -1
  55. package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
  56. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  57. package/docs/README.md +65 -22
  58. package/docs/actions.md +56 -13
  59. package/docs/cancel-stale-requests.md +2 -0
  60. package/docs/chat-example.md +15 -1
  61. package/docs/chat-widget.md +9 -7
  62. package/docs/context.md +352 -8
  63. package/docs/debounce-and-abort.md +3 -1
  64. package/docs/debugging-loops.md +283 -0
  65. package/docs/effects.md +259 -131
  66. package/docs/errors.md +4 -2
  67. package/docs/examples.md +60 -0
  68. package/docs/guide.md +57 -22
  69. package/docs/hmr.md +35 -1
  70. package/docs/loop-guard.md +109 -0
  71. package/docs/migrating-from-effector.md +15 -2
  72. package/docs/migrating-from-mitt.md +6 -4
  73. package/docs/migrating-from-rxjs.md +2 -0
  74. package/docs/migrating-from-saga.md +2 -0
  75. package/docs/motivation.md +14 -21
  76. package/docs/observable.md +45 -0
  77. package/docs/oop.md +20 -1
  78. package/docs/package.md +25 -13
  79. package/docs/pass-the-signal.md +281 -0
  80. package/docs/plugins.md +90 -15
  81. package/docs/policies.md +73 -46
  82. package/docs/prevent-double-submit.md +3 -1
  83. package/docs/query-libraries.md +276 -6
  84. package/docs/react.md +50 -11
  85. package/docs/recipes.md +528 -0
  86. package/docs/ru/README.md +62 -0
  87. package/docs/ru/actions.md +412 -0
  88. package/docs/ru/cancel-stale-requests.md +106 -0
  89. package/docs/ru/chat-example.md +259 -0
  90. package/docs/ru/chat-widget.md +131 -0
  91. package/docs/ru/context.md +401 -0
  92. package/docs/ru/debounce-and-abort.md +105 -0
  93. package/docs/ru/debugging-loops.md +283 -0
  94. package/docs/ru/effects.md +538 -0
  95. package/docs/ru/errors.md +36 -0
  96. package/docs/ru/examples.md +60 -0
  97. package/docs/ru/guide.md +530 -0
  98. package/docs/ru/hmr.md +83 -0
  99. package/docs/ru/loop-guard.md +109 -0
  100. package/docs/ru/migrating-from-effector.md +339 -0
  101. package/docs/ru/migrating-from-mitt.md +99 -0
  102. package/docs/ru/migrating-from-rxjs.md +197 -0
  103. package/docs/ru/migrating-from-saga.md +97 -0
  104. package/docs/ru/motivation.md +47 -0
  105. package/docs/ru/observable.md +233 -0
  106. package/docs/ru/oop.md +431 -0
  107. package/docs/ru/package.md +42 -0
  108. package/docs/ru/pass-the-signal.md +281 -0
  109. package/docs/ru/plugins.md +398 -0
  110. package/docs/ru/policies.md +201 -0
  111. package/docs/ru/prevent-double-submit.md +122 -0
  112. package/docs/ru/query-libraries.md +392 -0
  113. package/docs/ru/react.md +309 -0
  114. package/docs/ru/recipes.md +528 -0
  115. package/docs/ru/semantics.md +19 -0
  116. package/docs/ru/server.md +204 -0
  117. package/docs/ru/starter.md +118 -0
  118. package/docs/ru/stores.md +673 -0
  119. package/docs/ru/svelte.md +78 -0
  120. package/docs/ru/tracing.md +466 -0
  121. package/docs/ru/typescript.md +170 -0
  122. package/docs/ru/vue.md +92 -0
  123. package/docs/semantics.md +12 -0
  124. package/docs/server.md +5 -3
  125. package/docs/starter.md +2 -0
  126. package/docs/stores.md +2 -0
  127. package/docs/svelte.md +3 -1
  128. package/docs/tracing.md +132 -7
  129. package/docs/typescript.md +3 -1
  130. package/docs/vue.md +3 -1
  131. package/llms-full.txt +2718 -545
  132. package/llms.txt +17 -14
  133. package/package.json +25 -8
  134. package/skills/nanoeffects/SKILL.md +2 -2
  135. package/dist/bus-DlpEluzd.js +0 -1
  136. package/dist/context-UcmrAIhP.js +0 -1
  137. package/dist/context-types-hCKgU8sC.d.ts +0 -14
  138. package/docs/scope.md +0 -266
  139. package/docs/stability.md +0 -90
package/docs/effects.md CHANGED
@@ -1,109 +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
- ## Wrap a handler with retry and timeout
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.
27
+
28
+ ## `retry`
29
+
30
+ Runs a function again after an error. Use it when a call fails now and then, for example because of the network.
8
31
 
9
- `retry`, `timeout`, and `poll` have two forms:
32
+ Forms:
10
33
 
11
- - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
12
- - **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.
13
36
 
14
- 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.
15
44
 
16
45
  ```js
17
46
  import { createBus } from "nanoeffects";
18
- import { poll, retry, timeout } from "nanoeffects/effects";
47
+ import { retry } from "nanoeffects/effects";
19
48
  import { latest } from "nanoeffects/policies";
20
49
 
21
50
  const bus = createBus();
22
51
  const loadUser = (id, { signal }) => fetchUser(id, { signal });
23
52
 
24
- // timeout — one try must finish within 5 s
25
- const loadOnce = timeout(5000, loadUser);
26
- // retry — up to 4 attempts with backoff
27
- 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, {
28
55
  times: 4,
29
- delay: (attempt) => 2 ** attempt * 100,
30
56
  shouldRetry: (error) => !isFatal(error),
31
57
  });
32
- // latest — a newer load aborts the older retry chain
33
- bus.on("load", latest(loadWithRetry));
34
58
 
35
- // poll — call refresh every 10 s until the task aborts
36
- 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));
37
61
 
38
- // 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)
39
63
  bus.on("open", async (id, ctx) => {
40
- const load = retry(timeout(5000, loadUser));
41
- const user = await ctx.run(load, id);
64
+ const user = await ctx.run(loadWithRetry, id);
42
65
  ctx.emit("opened", user);
43
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();
44
76
 
45
- // ctx-first form — one call inside a body
46
77
  bus.on("load", async (id, ctx) => {
78
+ // retry with ctx — one call inside the body
47
79
  const user = await retry(
48
80
  ctx,
49
81
  () => fetchUser(id, { signal: ctx.signal }),
50
82
  { times: 4 },
51
83
  );
52
- const data = await timeout(ctx, 5000, ({ signal }) => {
53
- return fetchData(id, { signal });
54
- });
55
- await poll(ctx, () => refresh(), 10_000);
56
- ctx.emit("ready", { user, data });
84
+
85
+ ctx.emit("loaded", user);
57
86
  });
58
87
  ```
59
88
 
60
- 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`
61
90
 
62
- 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.
63
92
 
64
- ### Options for retry, timeout, and poll
93
+ Forms:
65
94
 
66
- **`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.
67
97
 
68
- - `times` — total attempts (default `3`).
69
- - `delay` — number or `(attempt, error) => ms`.
70
- - `shouldRetry(error, attempt)` — stop early.
71
- - `fn` gets the attempt number starting at `1`.
72
- - 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.
73
99
 
74
- **`timeout`**
100
+ ```js
101
+ import { createBus } from "nanoeffects";
102
+ import { timeout } from "nanoeffects/effects";
75
103
 
76
- - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
77
- - Rejects even if the starter ignores its signal.
104
+ const bus = createBus();
105
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
78
106
 
79
- **`poll`** (like patronum `interval`)
107
+ // timeout — a wrapper of the handler: it must finish within 5 s
108
+ bus.on("load", timeout(5000, loadUser));
80
109
 
81
- - First call after `ms`. `leading: true` also calls at once.
82
- - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
83
- - `waitForFn: true` starts each pause when the call ends — calls never overlap.
84
- - `fn` gets the call number from `0`.
85
- - 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`
118
+
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.
86
125
 
87
- 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):
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.
88
132
 
89
133
  ```js
90
134
  import { createBus } from "nanoeffects";
91
- import { delay, poll, retry } from "nanoeffects/effects";
135
+ import { poll } from "nanoeffects/effects";
92
136
 
93
137
  const bus = createBus();
94
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
+
95
142
  bus.on("sync", async (_, ctx) => {
96
- retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
97
- poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
98
- 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);
99
145
  });
100
146
  ```
101
147
 
102
- 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.
103
149
 
104
- ## Back off between retries
150
+ ## `backoff`
105
151
 
106
- 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`.
107
153
 
108
154
  The shortest form is a `delay` function. It needs no import:
109
155
 
@@ -143,48 +189,41 @@ const loadWithRetry = retry(loadUser, {
143
189
  - `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
144
190
  - The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
145
191
 
146
- ## Put selected effects on `ctx`
147
-
148
- 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:
149
193
 
150
194
  ```js
151
195
  import { createBus } from "nanoeffects";
152
- import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
153
-
154
- // effectsPlugin — add race, all and delay to the context of every task
155
- const effects = effectsPlugin({ race, all, delay });
156
-
157
- // use returns the same bus, typed with the new fields
158
- const bus = createBus().use(effects);
196
+ import { delay, poll, retry } from "nanoeffects/effects";
159
197
 
160
- bus.on("load", async (id, { race, all, delay }) => {
161
- // race — same as race(ctx, …)
162
- const result = await race({
163
- data: ({ signal }) => fetchData(id, { signal }),
164
- // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
165
- timeout: ({ delay }) => delay(5000),
166
- });
167
- if ("timeout" in result) throw new Error("timed out");
198
+ const bus = createBus();
168
199
 
169
- // all — same as all(ctx, …)
170
- const { user, posts } = await all({
171
- user: ({ signal }) => fetchUser(id, { signal }),
172
- posts: ({ signal }) => fetchPosts(id, { signal }),
173
- });
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
174
204
  });
175
205
  ```
176
206
 
177
- - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
178
- - 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).
179
- - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
180
- - 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`.
181
- - 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";
214
+
215
+ const bus = createBus();
182
216
 
183
- ## Tie a promise to the task signal
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
+ ```
184
223
 
185
- `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
224
+ ## `cancellable`
186
225
 
187
- `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 }`.
188
227
 
189
228
  ```js
190
229
  import { createBus } from "nanoeffects";
@@ -193,7 +232,7 @@ import { latest } from "nanoeffects/policies";
193
232
 
194
233
  const bus = createBus();
195
234
 
196
- // cancellable — promise tied to the task signal; onCancel for cleanups
235
+ // cancellable — a Promise tied to the task signal; onCancel registers a cleanup
197
236
  // latest — a newer wait aborts the older timer
198
237
  bus.on(
199
238
  "wait",
@@ -206,13 +245,13 @@ bus.on(
206
245
  );
207
246
  ```
208
247
 
209
- - 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.
210
249
  - If the task is already aborted, the executor does not run.
211
- - 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.
212
251
 
213
- ## Run cleanup on abort
252
+ ## `onAbort`
214
253
 
215
- `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.
216
255
 
217
256
  ```js
218
257
  import { createBus } from "nanoeffects";
@@ -222,14 +261,14 @@ const bus = createBus();
222
261
 
223
262
  bus.on("connect", (url, ctx) => {
224
263
  const socket = new WebSocket(url);
225
- // onAbort — run cleanup when this task aborts
264
+ // onAbort — close the socket when this task aborts
226
265
  onAbort(ctx, () => socket.close());
227
266
  });
228
267
  ```
229
268
 
230
- ## Wait for the next event with `take`
269
+ ## `ctx.on` and `ctx.off`
231
270
 
232
- 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.
233
272
 
234
273
  ```js
235
274
  import { createBus } from "nanoeffects";
@@ -237,15 +276,29 @@ import { createBus } from "nanoeffects";
237
276
  const bus = createBus();
238
277
 
239
278
  bus.on("watch", (_, ctx) => {
240
- 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);
241
284
  });
242
285
  ```
243
286
 
244
- - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
245
- - Under `latest`, subscriptions of a superseded run end with that run.
246
- - `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:
247
294
 
248
- `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`:
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`
300
+
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`.
249
302
 
250
303
  ```js
251
304
  import { createBus } from "nanoeffects";
@@ -267,22 +320,65 @@ bus.on("login", async (user, ctx) => {
267
320
  const id = await timeout(
268
321
  ctx,
269
322
  5000,
270
- 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
271
324
  );
272
325
  });
273
326
  ```
274
327
 
275
- - Sees only events emitted after the call, takes one, and unsubscribes.
328
+ - It sees only events emitted after the call, takes one, and unsubscribes.
276
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.
277
330
  - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
278
- - `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.
279
- - 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`
280
335
 
281
- ## Run several starters together
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
+ ```
282
354
 
283
- `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
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`.
284
356
 
285
- `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.
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
+ ```
376
+
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.
378
+
379
+ ## `allSettled`
380
+
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.
286
382
 
287
383
  ```js
288
384
  import { createBus } from "nanoeffects";
@@ -291,9 +387,10 @@ import { allSettled } from "nanoeffects/effects";
291
387
  const bus = createBus();
292
388
 
293
389
  bus.on("open", async (id, ctx) => {
390
+ // allSettled — wait for both, whatever the outcome
294
391
  const { user, posts } = await allSettled(ctx, {
295
- user: ({ signal }) => fetchUser(id, { signal }),
296
- posts: ({ signal }) => fetchPosts(id, { signal }),
392
+ user: (child) => fetchUser(id, child),
393
+ posts: (child) => fetchPosts(id, child),
297
394
  });
298
395
 
299
396
  if (user.status === "fulfilled") {
@@ -302,7 +399,9 @@ bus.on("open", async (id, ctx) => {
302
399
  });
303
400
  ```
304
401
 
305
- `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)`.
306
405
 
307
406
  ```js
308
407
  import { createBus } from "nanoeffects";
@@ -311,9 +410,9 @@ import { delay, race, take } from "nanoeffects/effects";
311
410
  const bus = createBus();
312
411
 
313
412
  bus.on("save", async (draft, ctx) => {
314
- // race — the first starter to finish wins, the other is cancelled
413
+ // race — the first to finish wins, the other is cancelled
315
414
  const result = await race(ctx, {
316
- 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
317
416
  cancelled: take("cancel"), // take without ctx — the same, waits for the next "cancel"
318
417
  });
319
418
 
@@ -323,9 +422,9 @@ bus.on("save", async (draft, ctx) => {
323
422
  });
324
423
  ```
325
424
 
326
- **`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`).
327
426
 
328
- 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:
329
428
 
330
429
  ```js
331
430
  import { createBus } from "nanoeffects";
@@ -335,7 +434,7 @@ const bus = createBus();
335
434
 
336
435
  bus.on("open", async (id, ctx) => {
337
436
  const result = await race(ctx, {
338
- // 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
339
438
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
340
439
  // delay without ctx — the loser is cancelled when the other one finishes
341
440
  waited: delay(5000),
@@ -347,9 +446,9 @@ bus.on("open", async (id, ctx) => {
347
446
  });
348
447
  ```
349
448
 
350
- 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.
351
450
 
352
- **`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:
353
452
 
354
453
  ```js
355
454
  import { createBus } from "nanoeffects";
@@ -359,7 +458,7 @@ const bus = createBus();
359
458
 
360
459
  bus.on("open", async (id, ctx) => {
361
460
  const result = await race(ctx, {
362
- // 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
363
462
  loaded: all({
364
463
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
365
464
  posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
@@ -374,37 +473,66 @@ bus.on("open", async (id, ctx) => {
374
473
  });
375
474
  ```
376
475
 
377
- 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.
378
477
 
379
- **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:
478
+ ## `effectsPlugin`
479
+
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)).
380
481
 
381
482
  ```js
382
483
  import { createBus } from "nanoeffects";
383
- import { all, retry } from "nanoeffects/effects";
484
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
384
485
 
385
- const bus = createBus();
486
+ // effectsPlugin — add race, all and delay to the context of every task
487
+ const effects = effectsPlugin({ race, all, delay });
386
488
 
387
- bus.on("sync", async (ids, ctx) => {
388
- // all with concurrency — 5 loads at a time, results in the order of ids
389
- const users = await all(
390
- ctx,
391
- ids.map((id) => retry(() => loadUser(id), { times: 2 })),
392
- { concurrency: 5 },
393
- );
489
+ // use returns the same bus, typed with the new fields
490
+ const bus = createBus().use(effects);
394
491
 
395
- 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
+ });
396
506
  });
397
507
  ```
398
508
 
399
- 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, …)`.
400
514
 
515
+ ## Effects outside a task
401
516
 
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`.
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.
403
518
 
404
- 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";
405
521
 
406
- ## Pass `ctx` outside a task
522
+ const controller = new AbortController();
407
523
 
408
- 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 };
409
526
 
410
- 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";