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
@@ -0,0 +1,538 @@
1
+ # Эффекты и отмена
2
+
3
+ > **[Уровень C](README.md#виды-проектов)**: сложная бизнес-логика. Сначала прочитайте: [Политики](policies.md).
4
+
5
+ Страница нужна для повторов, таймаутов, ожидания и параллельной работы, а не как первая страница для небольшой шины. Выберите **один** эффект под свою задачу, остальное — справочник.
6
+
7
+ Прерывание лишь поднимает флаг: остановиться должен ваш код. Начните с `retry` и `timeout` на обработчике (см. [Руководство, шаг 5](guide.md#5-retry-и-timeout)).
8
+
9
+ ## Эффекты
10
+
11
+ У каждого эффекта ниже свой раздел с формами вызова, параметрами и примером. `ctx` — контекст задачи.
12
+
13
+ - [`retry`](#retry) — повторить вызов после ошибки.
14
+ - [`timeout`](#timeout) — остановить вызов, который длится слишком долго.
15
+ - [`poll`](#poll) — вызывать функцию с интервалом.
16
+ - [`backoff`](#backoff) — сделать паузу между повторами растущей.
17
+ - [`delay`](#delay) — подождать некоторое время; прерывание заканчивает ожидание.
18
+ - [`cancellable`](#cancellable) — Promise, привязанный к сигналу задачи, для callback и таймеров.
19
+ - [`onAbort`](#onabort) — выполнить код при прерывании задачи.
20
+ - [`ctx.on` и `ctx.off`](#ctxon-и-ctxoff) — подписка на шину внутри задачи.
21
+ - [`take`](#take) — дождаться следующего события внутри обработчика.
22
+ - [`all`](#all) — запустить несколько вызовов вместе; одна ошибка отменяет остальные.
23
+ - [`allSettled`](#allsettled) — дождаться всех, чем бы они ни закончились.
24
+ - [`race`](#race) — взять первый результат и отменить остальные.
25
+ - [`effectsPlugin`](#effectsplugin) — короткая форма эффектов: вызов без `ctx`.
26
+ - [Эффекты вне задачи](#эффекты-вне-задачи) — вызов эффектов там, где нет обработчика.
27
+
28
+ ## `retry`
29
+
30
+ Запускает функцию снова после ошибки. Нужен, когда вызов время от времени падает, например из-за сети.
31
+
32
+ Формы вызова:
33
+
34
+ - `retry(fn, options?)` — обёртка обработчика: принимает функцию `(payload, ctx) => result` и возвращает такую же. Передаётся в `on` рядом с политиками.
35
+ - `retry(ctx, fn, options?)` — вызов внутри тела обработчика.
36
+
37
+ Параметры:
38
+
39
+ - `times` — всего попыток. По умолчанию `3`.
40
+ - `delay` — число или функция `(attempt, error) => ms`. Растущая пауза: [`backoff`](#backoff).
41
+ - `shouldRetry(error, attempt)` — верните `false`, чтобы остановиться раньше.
42
+ - `fn` получает номер попытки, начиная с `1`.
43
+ - При прерывании `retry` отклоняется с причиной прерывания.
44
+
45
+ ```js
46
+ import { createBus } from "nanoeffects";
47
+ import { retry } from "nanoeffects/effects";
48
+ import { latest } from "nanoeffects/policies";
49
+
50
+ const bus = createBus();
51
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
52
+
53
+ // retry — обёртка обработчика: до 4 попыток, но не после фатальной ошибки
54
+ const loadWithRetry = retry(loadUser, {
55
+ times: 4,
56
+ shouldRetry: (error) => !isFatal(error),
57
+ });
58
+
59
+ // latest — новая загрузка прерывает старую цепочку попыток
60
+ bus.on("load", latest(loadWithRetry));
61
+
62
+ // ctx.run — запустить обёрнутую функцию как дочернюю задачу (прерывается вместе с обработчиком)
63
+ bus.on("open", async (id, ctx) => {
64
+ const user = await ctx.run(loadWithRetry, id);
65
+ ctx.emit("opened", user);
66
+ });
67
+ ```
68
+
69
+ Обёртку берите для всего обработчика или всего `ctx.run`. Каждая попытка получает свой дочерний контекст: прерывание вызывающего до него доходит, а прерывание одной попытки до вызывающего не доходит. Для одного вызова в середине тела берите форму с `ctx`:
70
+
71
+ ```js
72
+ import { createBus } from "nanoeffects";
73
+ import { retry } from "nanoeffects/effects";
74
+
75
+ const bus = createBus();
76
+
77
+ bus.on("load", async (id, ctx) => {
78
+ // retry с ctx — один вызов внутри тела обработчика
79
+ const user = await retry(
80
+ ctx,
81
+ () => fetchUser(id, { signal: ctx.signal }),
82
+ { times: 4 },
83
+ );
84
+
85
+ ctx.emit("loaded", user);
86
+ });
87
+ ```
88
+
89
+ ## `timeout`
90
+
91
+ Отклоняется, если работа длится дольше заданного времени, и прерывает её. Нужен, когда запрос может зависнуть.
92
+
93
+ Формы вызова:
94
+
95
+ - `timeout(ms, handler)` — обёртка обработчика.
96
+ - `timeout(ctx, ms, fn)` — вызов внутри тела; `fn` получает контекст своей дочерней задачи.
97
+
98
+ Ошибка — `DOMException` с именем `TimeoutError` (как у `AbortSignal.timeout`). `timeout` отклоняется, даже если функция игнорирует свой сигнал.
99
+
100
+ ```js
101
+ import { createBus } from "nanoeffects";
102
+ import { timeout } from "nanoeffects/effects";
103
+
104
+ const bus = createBus();
105
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
106
+
107
+ // timeout — обёртка обработчика: он должен уложиться в 5 с
108
+ bus.on("load", timeout(5000, loadUser));
109
+
110
+ bus.on("open", async (id, ctx) => {
111
+ // timeout с ctx — один вызов внутри тела; child несёт сигнал запроса
112
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
113
+ ctx.emit("opened", data);
114
+ });
115
+ ```
116
+
117
+ ## `poll`
118
+
119
+ Вызывает функцию снова и снова с интервалом, пока задача не прервана. Нужен, чтобы обновлять данные каждые N секунд. Работает как `interval` в patronum.
120
+
121
+ Формы вызова:
122
+
123
+ - `poll(ms, fn, options?)` — обёртка обработчика.
124
+ - `poll(ctx, fn, ms)` — вызов внутри тела.
125
+
126
+ Параметры:
127
+
128
+ - Первый вызов происходит через `ms`. `leading: true` вызывает ещё и сразу.
129
+ - Следующие тики держат ровный темп независимо от длительности `fn`, поэтому медленный `fn` может пересечься со следующим вызовом. `waitForFn: true` начинает каждую паузу, когда вызов закончился, и вызовы никогда не пересекаются.
130
+ - `fn` получает номер вызова, начиная с `0`.
131
+ - Ошибка из `fn` останавливает опрос и отклоняет Promise. Прерывание завершает его нормально и очищает таймер.
132
+
133
+ ```js
134
+ import { createBus } from "nanoeffects";
135
+ import { poll } from "nanoeffects/effects";
136
+
137
+ const bus = createBus();
138
+
139
+ // poll — обёртка обработчика: вызывать refresh каждые 10 с, пока задача не прервана
140
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
141
+
142
+ bus.on("sync", async (_, ctx) => {
143
+ // poll с ctx — то же внутри тела обработчика
144
+ await poll(ctx, () => refresh(), 10_000);
145
+ });
146
+ ```
147
+
148
+ `retry`, `timeout` и `poll` попадают в трассировку: эффект — это группа, а каждая попытка или вызов — дочерний `run`.
149
+
150
+ ## `backoff`
151
+
152
+ Делает паузу между повторами растущей, с ограничением и джиттером. Нужен, когда backend или сеть падают под нагрузкой: повтор сразу или с фиксированной паузой возвращает всех клиентов в один момент и не даёт backend подняться. `backoff(options)` возвращает функцию `(attempt) => ms`.
153
+
154
+ Самая короткая форма — функция `delay`. Импорт ей не нужен:
155
+
156
+ ```js
157
+ import { retry } from "nanoeffects/effects";
158
+
159
+ const loadWithRetry = retry(loadUser, {
160
+ times: 5,
161
+ delay: (attempt) => 2 ** attempt * 100, // 200, 400, 800, 1600 мс
162
+ });
163
+ ```
164
+
165
+ Нужны ограничение и джиттер? Постройте функцию через `backoff`:
166
+
167
+ ```js
168
+ import { backoff, retry } from "nanoeffects/effects";
169
+
170
+ const loadWithRetry = retry(loadUser, {
171
+ times: 5,
172
+ // backoff — пауза 200, 400, 800, … мс, не больше 5 с, случайная её часть
173
+ delay: backoff({
174
+ base: 200,
175
+ max: 5000,
176
+ jitter: true,
177
+ }),
178
+ });
179
+ ```
180
+
181
+ | Параметр | Значение |
182
+ | -------- | -------- |
183
+ | `base` | пауза перед второй попыткой, в мс (по умолчанию `100`) |
184
+ | `factor` | каждая пауза во столько раз длиннее (по умолчанию `2`) |
185
+ | `max` | самая длинная пауза, в мс (по умолчанию нет) |
186
+ | `jitter` | `true` выбирает случайную паузу от 0 до вычисленной (по умолчанию `false`) |
187
+
188
+ - С `jitter: true` пауза может быть близка к 0, поэтому считайте `base` масштабом, а не минимумом.
189
+ - `retry` считает попытки с 1. `poll` считает вызовы с 0, поэтому у `poll` первая пауза равна `base / factor`.
190
+ - Результат — обычная функция `(attempt) => ms`, так что она подходит и для `delay(ctx, …)`.
191
+
192
+ Пауза может быть функцией везде, где задаётся время:
193
+
194
+ ```js
195
+ import { createBus } from "nanoeffects";
196
+ import { delay, poll, retry } from "nanoeffects/effects";
197
+
198
+ const bus = createBus();
199
+
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)); // замедляется до раза в минуту
203
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 мс
204
+ });
205
+ ```
206
+
207
+ ## `delay`
208
+
209
+ Ждёт заданное время и отклоняется при прерывании. Обычный `setTimeout` продолжил бы работу после прерывания задачи, а `delay` сразу заканчивает ожидание и очищает таймер. `delay(ctx, ms)`, где `ms` может быть функцией.
210
+
211
+ ```js
212
+ import { createBus } from "nanoeffects";
213
+ import { delay } from "nanoeffects/effects";
214
+
215
+ const bus = createBus();
216
+
217
+ bus.on("saved", async (_, ctx) => {
218
+ // delay — ждать 3 с; прерывание сразу заканчивает ожидание и очищает таймер
219
+ await delay(ctx, 3000);
220
+ ctx.emit("toast/hidden");
221
+ });
222
+ ```
223
+
224
+ ## `cancellable`
225
+
226
+ Promise, привязанный к сигналу задачи. Нужен для API на callback, таймерах и обработчиках событий: сигнал сам их не остановит, поэтому `cancellable` при прерывании отклоняет Promise и выполняет вашу очистку. `cancellable(ctx, executor)` работает как `new Promise`, но executor получает `{ resolve, reject, onCancel }`.
227
+
228
+ ```js
229
+ import { createBus } from "nanoeffects";
230
+ import { cancellable } from "nanoeffects/effects";
231
+ import { latest } from "nanoeffects/policies";
232
+
233
+ const bus = createBus();
234
+
235
+ // cancellable — Promise, привязанный к сигналу задачи; onCancel регистрирует очистку
236
+ // latest — новое ожидание прерывает старый таймер
237
+ bus.on(
238
+ "wait",
239
+ latest((_, ctx) => {
240
+ return cancellable(ctx, ({ resolve, onCancel }) => {
241
+ const timer = setTimeout(resolve, 5000);
242
+ onCancel(() => clearTimeout(timer));
243
+ });
244
+ }),
245
+ );
246
+ ```
247
+
248
+ - Если задача прервётся до завершения Promise, очистки выполнятся (последняя зарегистрированная первой), а Promise отклонится с причиной прерывания.
249
+ - Если задача уже прервана, executor не запускается.
250
+ - После завершения Promise подписка на прерывание снимается, а очистки отбрасываются.
251
+
252
+ ## `onAbort`
253
+
254
+ Выполняет код при прерывании задачи: это хук жизненного цикла задачи. Задачу могут прервать, потому что пользователь ушёл или её заменил новый вызов, и всё, что она открыла (сокет, таймер, подписку), тогда нужно закрыть. `onAbort(ctx, fn)` запускает `fn(reason)` при прерывании (сразу, если задача уже прервана) и возвращает функцию отписки.
255
+
256
+ ```js
257
+ import { createBus } from "nanoeffects";
258
+ import { onAbort } from "nanoeffects/effects";
259
+
260
+ const bus = createBus();
261
+
262
+ bus.on("connect", (url, ctx) => {
263
+ const socket = new WebSocket(url);
264
+ // onAbort — закрыть сокет, когда эта задача прервана
265
+ onAbort(ctx, () => socket.close());
266
+ });
267
+ ```
268
+
269
+ ## `ctx.on` и `ctx.off`
270
+
271
+ Подписка на шину внутри задачи. У контекста задачи есть `on` и `off`, как у шины, а подписка, сделанная через `ctx.on`, заканчивается, когда задача прерывается.
272
+
273
+ ```js
274
+ import { createBus } from "nanoeffects";
275
+
276
+ const bus = createBus();
277
+
278
+ bus.on("watch", (_, ctx) => {
279
+ // пропадает, когда эта задача прервана
280
+ const stop = ctx.on("tick", (n) => console.log(n));
281
+
282
+ // или закончить раньше самим: stop() отписывает
283
+ ctx.on("watch/closed", stop);
284
+ });
285
+ ```
286
+
287
+ Будьте внимательны:
288
+
289
+ - **Задача, которая закончилась сама, не прерывается**, поэтому её подписка из `ctx.on` остаётся. Когда работа закончена, отпишитесь функцией, которую вернул `ctx.on`, или вызовите `ctx.abort()`.
290
+ - Чтобы дождаться **одного** события, берите [`take`](#take): он отписывается сам.
291
+ - Для подписок, которые живут столько же, сколько фича, берите [модуль](actions.md#модули): `unmount` снимает их все.
292
+
293
+ Ещё:
294
+
295
+ - После прерывания `ctx.on` ничего не делает.
296
+ - Под `latest` подписки вытесненного запуска заканчиваются вместе с ним.
297
+ - `ctx.off(event)` очищает всех обработчиков события на шине, а не только обработчиков этой задачи.
298
+
299
+ ## `take`
300
+
301
+ Ждёт следующее событие шины внутри обработчика и завершается с его payload. `take(ctx, event, options?)`. `event` — имя события или action creator, как для `bus.on`.
302
+
303
+ ```js
304
+ import { createBus } from "nanoeffects";
305
+ import { createAction } from "nanoeffects/actions";
306
+ import { take, timeout } from "nanoeffects/effects";
307
+
308
+ const bus = createBus();
309
+ const stopRequested = createAction("stop"); // событие с именем; его вызов строит action
310
+
311
+ bus.on("login", async (user, ctx) => {
312
+ const reason = await take(ctx, "logout"); // завершается на следующем logout
313
+ cleanup(user, reason);
314
+
315
+ // action creator работает так же, а тип его payload — тип результата
316
+ const stopped = await take(ctx, stopRequested);
317
+ console.log(stopped);
318
+
319
+ // ждать подходящее событие, но не дольше 5 с
320
+ const id = await timeout(
321
+ ctx,
322
+ 5000,
323
+ take("saved", { predicate: (savedId) => savedId === draft.id }), // take без ctx — готовая функция для группы
324
+ );
325
+ });
326
+ ```
327
+
328
+ - Видит только события, отправленные после вызова, берёт одно и отписывается.
329
+ - С action creator результат имеет тип его payload. Action creator без типа payload (`createAction("saved")`) берёт тип из карты событий шины.
330
+ - `options.predicate` пропускает payload, которые он отклоняет. В TypeScript type guard (`(payload): payload is Admin`) сужает тип результата.
331
+ - `options.signal` останавливает ожидание. Внутри `race`, `all` или `timeout` он не нужен: группа и так останавливает ожидание, так что пишите `take("saved")` без параметров.
332
+ - При прерывании задачи или `options.signal` отписывается и отклоняется с причиной прерывания.
333
+
334
+ ## `all`
335
+
336
+ Запускает несколько вызовов вместе; одна ошибка отменяет остальные. Нужен, чтобы загрузить несколько вещей сразу. `all(ctx, effects)` принимает объект или массив функций, и результат сохраняет эту форму.
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 — запустить оба запроса вместе; одна ошибка отменяет другой. child — контекст своего запроса
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
+ Каждая функция группы получает `child` — контекст своей дочерней задачи. Передавайте `child` первым аргументом любому эффекту, как обработчик передаёт `ctx`: когда группа заканчивается или падает, `child` прерывается и останавливает именно этот эффект. Если ваш API принимает сигнал в параметрах, передайте туда `child`, у него есть `signal`: `(child) => fetchUser(id, child)` (`fetch` читает из параметров только `signal`). То же верно для `allSettled`, `race` и `timeout`.
356
+
357
+ **Ограничьте число одновременных запусков.** `all` и `allSettled` запускают всё сразу, и на длинном списке это шквал запросов. Передайте `{ concurrency }` третьим аргументом: одновременно работает не больше заданного числа, следующий стартует, когда один из выполняющихся завершится:
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 с concurrency — по 5 загрузок за раз, результаты в порядке 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
+ Порядок и форма результата те же, что и без параметра. Если один вызов `all` упадёт, выполняющиеся отменяются, а ожидающие не запускаются. `allSettled` продолжает после ошибки. Когда `ctx` прерывается, оба останавливаются и больше ничего не запускают. Значение, не являющееся положительным целым (`0`, `-1`, `NaN`, `1.5`), выбрасывает `RangeError` до старта чего-либо. `Infinity` означает «без ограничения». У `race` нет `concurrency`: он завершается на первом результате, и вызов, ждущий свободный слот, не запустился бы никогда.
378
+
379
+ ## `allSettled`
380
+
381
+ Ждёт каждый вызов и не падает из-за одного. Нужен, когда частичный ответ лучше, чем никакого. `allSettled(ctx, effects)`. Каждый результат — `{ status: "fulfilled", value }` или `{ status: "rejected", reason }`. Когда вызов падает, ничего не отменяется. Отклонить `allSettled` может только прерывание `ctx`.
382
+
383
+ ```js
384
+ import { createBus } from "nanoeffects";
385
+ import { allSettled } from "nanoeffects/effects";
386
+
387
+ const bus = createBus();
388
+
389
+ bus.on("open", async (id, ctx) => {
390
+ // allSettled — дождаться обоих, чем бы они ни закончились
391
+ const { user, posts } = await allSettled(ctx, {
392
+ user: (child) => fetchUser(id, child),
393
+ posts: (child) => fetchPosts(id, child),
394
+ });
395
+
396
+ if (user.status === "fulfilled") {
397
+ render(user.value);
398
+ }
399
+ });
400
+ ```
401
+
402
+ ## `race`
403
+
404
+ Завершается с первым закончившимся вызовом и отменяет остальные. Нужен, чтобы взять самый быстрый ответ или прервать ожидание. `race(ctx, effects)`.
405
+
406
+ ```js
407
+ import { createBus } from "nanoeffects";
408
+ import { delay, race, take } from "nanoeffects/effects";
409
+
410
+ const bus = createBus();
411
+
412
+ bus.on("save", async (draft, ctx) => {
413
+ // race — побеждает тот, кто закончил первым, другой отменяется
414
+ const result = await race(ctx, {
415
+ waited: delay(1000), // delay без ctx — готовая функция для группы: race передаёт ей свой child
416
+ cancelled: take("cancel"), // take без ctx — то же, ждёт следующий "cancel"
417
+ });
418
+
419
+ if ("waited" in result) {
420
+ ctx.emit("saved", draft);
421
+ }
422
+ });
423
+ ```
424
+
425
+ **`delay` и `take` без `ctx` — готовые функции для группы.** В группе `delay(1000)` и `take("cancel")` равны `(child) => delay(child, 1000)` и `(child) => take(child, "cancel")`: группа вызывает их с child, поэтому ожидание останавливается вместе с группой. Берите эту форму внутри `race`, `all`, `allSettled` и `timeout`, а в обычном коде обработчика пишите `delay(ctx, 1000)`. Они ждут, только когда их вызывает группа: `await delay(1000)` сам по себе не сработает, потому что это функция, а не Promise. В TypeScript такая функция в переменной вне группы не знает событий вашей шины, и их нужно назвать самому: `const cancelled: StarterOf<Events> = take("cancel")` (`StarterOf` экспортирует `nanoeffects/effects`).
426
+
427
+ Обёртки `retry`, `timeout` и `poll` в группе тоже работают: группа вызывает их с child, а обёрнутая функция получает `undefined` вместо payload и child вместо контекста. Нужные данные передайте через замыкание:
428
+
429
+ ```js
430
+ import { createBus } from "nanoeffects";
431
+ import { delay, race, retry } from "nanoeffects/effects";
432
+
433
+ const bus = createBus();
434
+
435
+ bus.on("open", async (id, ctx) => {
436
+ const result = await race(ctx, {
437
+ // retry без ctx — до 3 попыток, каждая с сигналом child
438
+ user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
439
+ // delay без ctx — проигравший отменяется, когда другой заканчивает
440
+ waited: delay(5000),
441
+ });
442
+
443
+ if ("user" in result) {
444
+ render(result.user);
445
+ }
446
+ });
447
+ ```
448
+
449
+ Поэтому у `retry`, `timeout` и `poll` три формы вызова: с `ctx`, как задача (Task) для `bus.on` и `ctx.run` и внутри группы. Формы различаются по аргументам: контекст первым — форма с `ctx`; функция, вызванная с payload и контекстом, — задача; та же функция, вызванная группой с одним аргументом, своим child, — форма для группы. Это не зависит от того, сколько параметров объявляет ваша функция.
450
+
451
+ **`all`, `allSettled` и `race` без `ctx` тоже работают в группе**, поэтому одна группа вкладывается в другую без обёртки. Загрузите две вещи, но сдайтесь через пять секунд:
452
+
453
+ ```js
454
+ import { createBus } from "nanoeffects";
455
+ import { all, delay, race, retry } from "nanoeffects/effects";
456
+
457
+ const bus = createBus();
458
+
459
+ bus.on("open", async (id, ctx) => {
460
+ const result = await race(ctx, {
461
+ // all без ctx — загружает оба ресурса, у каждого до 3 попыток
462
+ loaded: all({
463
+ user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
464
+ posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
465
+ }),
466
+ // delay без ctx — когда он побеждает, весь `all` выше отменяется
467
+ waited: delay(5000),
468
+ });
469
+
470
+ if ("loaded" in result) {
471
+ render(result.loaded.user, result.loaded.posts);
472
+ }
473
+ });
474
+ ```
475
+
476
+ Один аргумент — короткая форма, два — форма с `ctx`: `all(effects)` возвращает функцию для группы, `all(ctx, effects)` запускает группу. Внутренняя группа останавливается вместе с внешней и отменяет свои вызовы. `all(ctx)` с одним контекстом — ошибка: сразу выбрасывается `TypeError`. Форма без `ctx` параметров не принимает: второй аргумент выглядел бы как форма с `ctx`. Там пишите `(child) => all(child, effects, { concurrency: 5 })`. В TypeScript группе, которая лежит в переменной вне группы, нужен тип: `StarterOf<Events>`, а если нужен результат — `StarterOf<Events, Result>`.
477
+
478
+ ## `effectsPlugin`
479
+
480
+ Короткая форма эффектов: `effectsPlugin(effects)` кладёт выбранные эффекты на `ctx`, и обработчик вызывает их, не передавая `ctx` везде. Нужен, когда многие обработчики вызывают одни и те же помощники, чтобы убрать лишний код. Обработчик достаёт их деструктуризацией, как `signal`. В бандл попадают только переданные эффекты ([точки входа пакета](package.md)).
481
+
482
+ ```js
483
+ import { createBus } from "nanoeffects";
484
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
485
+
486
+ // effectsPlugin — добавить race, all и delay в контекст каждой задачи
487
+ const effects = effectsPlugin({ race, all, delay });
488
+
489
+ // use возвращает ту же шину, типизированную новыми полями
490
+ const bus = createBus().use(effects);
491
+
492
+ bus.on("load", async (id, { race, all, delay }) => {
493
+ // race — то же, что race(ctx, …)
494
+ const result = await race({
495
+ data: (child) => fetchData(id, child),
496
+ // функция группы получает свой child с теми же привязанными эффектами, поэтому delay(5000) останавливается вместе с гонкой
497
+ timeout: ({ delay }) => delay(5000),
498
+ });
499
+ if ("timeout" in result) throw new Error("timed out");
500
+
501
+ // all — то же, что all(ctx, …)
502
+ const { user, posts } = await all({
503
+ user: (child) => fetchUser(id, child),
504
+ posts: (child) => fetchPosts(id, child),
505
+ });
506
+ });
507
+ ```
508
+
509
+ - Ключ — это имя, под которым эффект попадает на `ctx`. `race`, `all`, `allSettled`, `take` и `delay` типизированы вручную.
510
+ - Ваша собственная функция тоже подходит, если её первый параметр — `{ signal }` (*эффект*): `effectsPlugin({ fetchJson })` даст `ctx.fetchJson(url)`. TypeScript убирает первый параметр из её типа; для обобщённой функции см. [Сохранение обобщённого эффекта обобщённым](context.md#обобщённый-эффект).
511
+ - Не добавляйте `retry`, `timeout` и `poll`: привязанные к задаче, они путались бы со своими обёртками обработчика.
512
+ - Поля из `createBus({ context })` остаются. TypeScript отклоняет эффект, чьё имя уже занято полем контекста шины, в том числе добавленным более ранним `effectsPlugin`.
513
+ - Свободные формы продолжают работать: `race(ctx, …)`.
514
+
515
+ ## Эффекты вне задачи
516
+
517
+ Эффекты принимают `ctx` первым. В обработчике это `ctx`, который у вас уже есть. Вне задачи (в обычной функции, скрипте или тесте) `ctx` нет: передайте объект с `signal`, а если прерывать не собираетесь — `{}`.
518
+
519
+ ```js
520
+ import { delay, retry } from "nanoeffects/effects";
521
+
522
+ const controller = new AbortController();
523
+
524
+ // вне задачи ctx нет: достаточно объекта с signal
525
+ const context = { signal: controller.signal };
526
+
527
+ // эффекты принимают его как ctx; fetchUser получает тот же signal, поэтому останавливается и сам запрос
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
+ // останавливает ожидающие повторы и паузы
537
+ controller.abort();
538
+ ```
@@ -0,0 +1,36 @@
1
+ # Ошибки и события без подписчиков
2
+
3
+ > **[Уровень B](README.md#виды-проектов)**: SPA с async UI. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ ## Изоляция упавших обработчиков
6
+
7
+ `emit` — обычный цикл по обработчикам (как в nanoevents). Если обработчик выбросил ошибку, остальные обработчики пропускаются, а ошибка доходит до вызывающего `emit`. Отклонённый Promise async-обработчика остаётся необработанным.
8
+
9
+ Передайте `onError`, чтобы изолировать обработчики. Тогда выполняется каждый обработчик, а выброшенные ошибки и отклонения async-обработчиков (в том числе обёрнутых в `latest`, `debounce` и подобные) попадают в `onError`. Если сам `onError` выбросит ошибку, она попадёт в `console.error`.
10
+
11
+ ```js
12
+ import { createBus } from "nanoeffects";
13
+
14
+ // createBus — onError изолирует упавшие обработчики, чтобы остальные всё равно выполнились
15
+ const bus = createBus({
16
+ onError: (error, { type, payload }) => {
17
+ report(error, type, payload);
18
+ },
19
+ });
20
+ ```
21
+
22
+ ## События без подписчиков
23
+
24
+ Если в имени события опечатка (`emit("serch")`), `emit` ничего не делает и не сообщает об ошибке. `onUnhandled` срабатывает, когда у отправленного события нет подписок (подписка на `"*"` считается). Для action с обработчиком не срабатывает.
25
+
26
+ Включайте предупреждение при разработке (`import.meta.env.DEV` — флаг Vite; в другом сборщике подставьте свою проверку):
27
+
28
+ ```js
29
+ import { createBus } from "nanoeffects";
30
+
31
+ const bus = createBus({
32
+ onUnhandled: import.meta.env.DEV
33
+ ? ({ type }) => console.warn(`Nobody listens to "${type}"`)
34
+ : void 0,
35
+ });
36
+ ```
@@ -0,0 +1,60 @@
1
+ # Примеры
2
+
3
+ > **[Уровни A–F](README.md#виды-проектов)**: для любого проекта.
4
+
5
+ Примеры, которые можно запустить в браузере и прочитать. Каждая папка лежит в `examples/` репозитория и проверяется типами вместе с проектом. Два примера разобраны по шагам: [Чат](chat-example.md) и [Виджет чата](chat-widget.md).
6
+
7
+ ## Запуск в браузере
8
+
9
+ Все три — одно и то же поле поиска: печатайте быстро, и стартует один запрос (`debounce`); печатайте снова, пока запрос идёт, и старый прерывается (`latest`). StackBlitz запускает их без установки.
10
+
11
+ | Папка | Что внутри |
12
+ | ------ | ---------- |
13
+ | [`playground`](../../examples/playground) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground) | Обычный DOM и один файл: самая маленькая установка. Страница показывает запросы, поэтому видно, как прерывается старый. |
14
+ | [`playground-react`](../../examples/playground-react) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-react) | Тот же поиск в компоненте React, шина вне React. |
15
+ | [`playground-devtools`](../../examples/playground-devtools) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-devtools) | Тот же поиск, emit и остановленные вызовы уходят в Redux DevTools. |
16
+
17
+ ## Большие проекты
18
+
19
+ Проекты на React и TypeScript показывают, где живёт логика, когда в приложении несколько фич.
20
+
21
+ | Папка | Что внутри |
22
+ | ------ | ---------- |
23
+ | [`chat`](../../examples/chat) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat) | Комнаты, история под `latest`, отправка по порядку через `queue` и `retry`, поиск через `allSettled`, опрос через `poll`. Разобран в [Чате](chat-example.md). |
24
+ | [`pokedex`](../../examples/pokedex) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/pokedex) | Настоящие запросы к PokeAPI: поиск с `debounce`, `latest`, `timeout` и `retry`, способности через `allSettled`. |
25
+ | [`spells`](../../examples/spells) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/spells) | Самый маленький пример: поиск в одном файле, один action с обработчиком. |
26
+ | [`chat-widget`](../../examples/chat-widget) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget) | Чат за кнопкой-лаунчером: открытие подключает один модуль, закрытие отключает. Разобран в [Виджете чата](chat-widget.md). |
27
+ | [`counter`](../../examples/counter) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/counter) | Счётчик из руководства redux-saga: без store и с Redux Toolkit. |
28
+ | [`rickandmorty`](../../examples/rickandmorty) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/rickandmorty) | Персонажи с React Query: запросы — actions, которые получают его сигнал прерывания. |
29
+ | [`trades`](../../examples/trades) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/trades) | Живой поток по WebSocket: `retry`, `latest` и `throttle`. |
30
+
31
+ ## Примеры для backend
32
+
33
+ Небольшие приложения на [Fastify](https://fastify.dev) без базы данных. Нужен Node 18 или новее: выполните `npm install` и `npm start` в папке. Подробнее: [Остановка работы на backend](server.md).
34
+
35
+ | Папка | Что внутри |
36
+ | ------ | ---------- |
37
+ | [`api-server`](../../examples/api-server) | Сервер поверх PokeAPI: `timeout`, `retry`, `allSettled` и прерывание, когда клиент ушёл. |
38
+ | [`job-worker`](../../examples/job-worker) | Воркер очереди: `queue`, `retry`, `timeout`, счётчики по исходам job. |
39
+ | [`ws-server`](../../examples/ws-server) | Сервер WebSocket: по контроллеру на сокет, `latest` с `key`. |
40
+
41
+ ## Рецепты
42
+
43
+ Каждый файл — одна задача в виде короткой программы с комментарием у каждого вызова nanoeffects.
44
+
45
+ | Файл | Задача | Документация |
46
+ | ---- | ------ | ------------ |
47
+ | [`search.ts`](../../examples/search.ts) | Поле поиска, которое ждёт паузы и прерывает старый запрос. | [Политики](policies.md) |
48
+ | [`backoff.ts`](../../examples/backoff.ts) | Повтор ненадёжного запроса с растущей паузой. | [Эффекты](effects.md#backoff) |
49
+ | [`registration-form.ts`](../../examples/registration-form.ts) | Форма регистрации: проверка имени без гонки. | [Форма на React](react.md) |
50
+ | [`scope.ts`](../../examples/scope.ts) | Action со своими эффектами и тест, который подменяет один из них. | [Работа с контекстом](context.md) |
51
+ | [`modules.ts`](../../examples/modules.ts) | Доменная логика без UI, подключается и отключается как единое целое. | [Actions](actions.md) |
52
+ | [`redux.ts`](../../examples/redux.ts) | Actions Redux Toolkit, которые попадают и на шину. | [State managers](stores.md) |
53
+ | [`guide.ts`](../../examples/guide.ts) | Код каждого шага руководства. | [Руководство](guide.md) |
54
+
55
+ ## Проекты на TypeScript
56
+
57
+ | Папка | Что внутри |
58
+ | ------ | ---------- |
59
+ | [`typescript`](../../examples/typescript) | Один раз назвать карту событий и использовать её в модулях и actions. |
60
+ | [`typescript-context`](../../examples/typescript-context) | Дать `ctx` поля вашей шины и типизировать их из одного места. |