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,530 @@
1
+ # Руководство
2
+
3
+ > **[Уровни A–D](README.md#виды-проектов)**: от небольшого приложения до доменной логики вне UI.
4
+
5
+ Установите пакет: `npm install nanoeffects`.
6
+
7
+ | Уровень | Ваш проект | Остановиться после |
8
+ | ------- | ---------- | ------------------ |
9
+ | A | Небольшое приложение, виджет или MVP | §1–2 |
10
+ | B | SPA с async UI | §3–4 |
11
+ | C | Сложная бизнес-логика | §5–6 |
12
+ | D | Доменная логика вне UI | §7–9 |
13
+
14
+ Запускаемый код каждого шага: [`examples/guide.ts`](../../examples/guide.ts).
15
+
16
+ ## На этой странице
17
+
18
+ 1. [Создание шины событий](#1-создание-шины-событий)
19
+ - [Обработчик любого события](#обработчик-любого-события)
20
+ - [Фильтрация событий для подписки](#фильтрация-событий-для-подписки)
21
+ 2. [Контекст обработчика](#2-контекст-обработчика)
22
+ 3. [Прерывание предыдущих запусков](#3-прерывание-предыдущих-запусков)
23
+ 4. [Другие политики: `leading`, `debounce`, `throttle`, `queue`](#4-другие-политики-leading-debounce-throttle-queue)
24
+ 5. [Retry и timeout](#5-retry-и-timeout)
25
+ 6. [Параллельные задачи и ожидание событий](#6-параллельные-задачи-и-ожидание-событий)
26
+ 7. [Способы отправки событий](#7-способы-отправки-событий)
27
+ 8. [Замена обработчика события](#8-замена-обработчика-события)
28
+ 9. [Создание модулей](#9-создание-модулей)
29
+ 10. [Поддержка TypeScript](#10-поддержка-typescript)
30
+ 11. [Работа с ошибками](#11-работа-с-ошибками)
31
+
32
+ - [Что дальше](#что-дальше)
33
+
34
+ ## 1. Создание шины событий
35
+
36
+ `on` подписывается на событие шины и возвращает функцию отписки, `emit` вызывает подписчиков события.
37
+
38
+ ```js
39
+ import { createBus } from "nanoeffects";
40
+
41
+ const bus = createBus(); // шина событий; каждый вызов обработчика — задача
42
+
43
+ const unbind = bus.on("greet", (name) => {
44
+ console.log(`hello ${name}`);
45
+ });
46
+
47
+ bus.emit("greet", "Ada"); // hello Ada
48
+ unbind();
49
+ bus.emit("greet", "Bob"); // никто не слушает
50
+ ```
51
+
52
+ Обработчики запускаются сразу, в порядке подписки.
53
+
54
+ Ещё четыре вызова закрывают почти всё, что нужно от шины:
55
+
56
+ - `bus.once(event, handler)` работает как `on`, но обработчик срабатывает только один раз.
57
+ - `bus.off("greet")` снимает все подписки на это событие.
58
+ - `bus.off()` без аргументов снимает **все** подписки шины, включая `"*"`. `ctx.off` — тот же `off`, поэтому `ctx.off()` тоже очищает всю шину.
59
+ - `bus.get("greet")` возвращает текущие подписки события; `bus.get("greet").length === 0` значит, что событие никто не слушает. `bus.get("*")` возвращает подписки на любое событие, а именованное событие их не включает. `bus.get()` без имени возвращает все подписки всех событий. Метод принимает имя события строкой: для action creator передайте `creator.type`.
60
+
61
+ ```js
62
+ import { createBus } from "nanoeffects";
63
+
64
+ const bus = createBus();
65
+
66
+ // once — сработает только один раз, затем отписка
67
+ bus.once("ready", () => console.log("ready"));
68
+ bus.emit("ready"); // ready
69
+ bus.emit("ready"); // никто не слушает
70
+
71
+ bus.on("tick", () => {});
72
+ bus.off(); // снять все подписки
73
+ ```
74
+
75
+ ### Обработчик любого события
76
+
77
+ `bus.on("*", fn)` срабатывает на каждое событие шины. Подходит для логирования, отладки и пересылки событий. Обработчик получает payload и контекст задачи, а `ctx.type` — имя события.
78
+
79
+ ```js
80
+ import { createBus } from "nanoeffects";
81
+
82
+ const bus = createBus();
83
+
84
+ // "*" — срабатывает на каждое событие, после подписок на это событие
85
+ const unbind = bus.on("*", (payload, ctx) => {
86
+ console.log(ctx.type, payload);
87
+ });
88
+
89
+ bus.emit("greet", "Ada"); // greet Ada
90
+ bus.emit("moved", { x: 1, y: 2 }); // moved { x: 1, y: 2 }
91
+ unbind();
92
+ ```
93
+
94
+ - Событие, которое слушает только `"*"`, считается услышанным, поэтому `onUnhandled` для него не срабатывает.
95
+ - `bus.off("*")` снимает все подписки `"*"`; `bus.off()` очищает всю шину, `"*"` тоже.
96
+
97
+ ### Фильтрация событий для подписки
98
+
99
+ Группу событий, например все `user/*`, можно слушать через `"*"` и проверку `ctx.type`, но проверку нужно писать самому. `matchPlugin` позволяет передать в `on` функцию-фильтр. Она получает `{ type, payload }` каждого события, и обработчик срабатывает, когда она возвращает true.
100
+
101
+ <!-- docs-check: new -->
102
+
103
+ ```js
104
+ // bus.js
105
+ import { createBus } from "nanoeffects";
106
+ import { matchPlugin } from "nanoeffects/match";
107
+
108
+ // matchPlugin — позволяет on, once и off принимать функцию, которая выбирает события
109
+ export const bus = createBus().use(matchPlugin());
110
+ ```
111
+
112
+ ```js
113
+ // user.js
114
+ import { createAction } from "nanoeffects/actions";
115
+
116
+ export const userLoaded = createAction("user/loaded");
117
+ export const userFailed = createAction("user/failed");
118
+ ```
119
+
120
+ ```js
121
+ // user-handlers.js
122
+ import { bus } from "./bus";
123
+
124
+ // функция выбирает события; обработчик получает payload и контекст каждого
125
+ bus.on(
126
+ (action) => action.type.startsWith("user/"),
127
+ (payload, ctx) => {
128
+ console.log(ctx.type, payload);
129
+ },
130
+ );
131
+ ```
132
+
133
+ ```js
134
+ import { bus } from "./bus";
135
+ import { userFailed, userLoaded } from "./user";
136
+
137
+ bus.emit(userLoaded({ id: "1" })); // user/loaded { id: "1" }
138
+ bus.emit("order/placed", 5); // не выбрано
139
+ bus.emit(userFailed("boom")); // user/failed boom
140
+ ```
141
+
142
+ - `once(filter, handler)` срабатывает на первое подходящее событие. `off(filter)` снимает все подписки с этим фильтром, `off(filter, handler)` — одну.
143
+ - Фильтр проверяется на каждом событии шины. Если нужны всего несколько известных событий, подпишитесь на них напрямую: `on(creator, handler)` дешевле.
144
+ - Событие, на которое подписан только такой фильтр, считается услышанным для `onUnhandled`.
145
+ - Работает в любом порядке с другими плагинами, включая `devPlugin`.
146
+
147
+ В TypeScript type guard сужает тип payload обработчика:
148
+
149
+ ```ts
150
+ import { bus } from "./bus";
151
+
152
+ type User = { id: string };
153
+
154
+ // type guard говорит, какое это событие, и обработчик получает его payload с типом
155
+ const isLoaded = (action: {
156
+ type: string;
157
+ payload: unknown;
158
+ }): action is { type: "user/loaded"; payload: User } => action.type === "user/loaded";
159
+
160
+ bus.on(isLoaded, (user) => console.log(user.id)); // user: User
161
+ ```
162
+
163
+ `ctx.on` тоже принимает такую функцию во время выполнения, но его тип её пока не знает.
164
+
165
+ ## 2. Контекст обработчика
166
+
167
+ Второй аргумент — контекст вызова. `ctx.emit` отправляет события из обработчика, а `ctx.signal` отменяет `fetch`: передайте его в запрос.
168
+
169
+ ```js
170
+ import { createBus } from "nanoeffects";
171
+
172
+ const bus = createBus();
173
+
174
+ bus.on("moved", ({ x, y }, ctx) => {
175
+ if (x < 0) {
176
+ ctx.emit("outOfBounds", { x, y });
177
+ }
178
+ });
179
+ ```
180
+
181
+ В `ctx` есть `signal`, `abort`, `emit`, `on` и `run`. Прерванная задача перестаёт отправлять события и не выдаёт результат.
182
+
183
+ ## 3. Прерывание предыдущих запусков
184
+
185
+ Обработчик часто вызывают снова, пока прошлый вызов не закончился (набор текста, двойные клики). **Политика** оборачивает обработчик и решает, что делать с наложением. Начните с `latest`: новый вызов прерывает старый через `signal`.
186
+
187
+ ```js
188
+ import { createBus } from "nanoeffects";
189
+ import { latest } from "nanoeffects/policies";
190
+
191
+ const bus = createBus();
192
+
193
+ // latest — новый запуск прерывает старый
194
+ bus.on(
195
+ "search",
196
+ latest(async (query, { signal }) => {
197
+ await api.search(query, { signal });
198
+ }),
199
+ );
200
+ ```
201
+
202
+ Тот же `on`, та же форма обработчика. Подробнее в [Политиках](policies.md).
203
+
204
+ ## 4. Другие политики: `leading`, `debounce`, `throttle`, `queue`
205
+
206
+ | Политика | Когда вызовы накладываются |
207
+ | -------- | -------------------------- |
208
+ | `latest` | предыдущий вызов прерывается |
209
+ | `leading` | новые вызовы отбрасываются, пока идёт один |
210
+ | `debounce` | запуск после паузы |
211
+ | `throttle` | не чаще одного раза за окно |
212
+ | `queue` | один за другим |
213
+
214
+ ```js
215
+ import { createBus } from "nanoeffects";
216
+ import { debounce, latest, queue, throttle } from "nanoeffects/policies";
217
+
218
+ const bus = createBus();
219
+
220
+ // throttle — не чаще одного раза в 100 мс
221
+ bus.on("scroll", throttle(100, (position) => {
222
+ updateScrollbar(position);
223
+ }));
224
+
225
+ // debounce — запуск после 300 мс тишины
226
+ bus.on("typed", debounce(300, (text) => {
227
+ validate(text);
228
+ }));
229
+
230
+ // queue — одно сохранение за другим, по порядку
231
+ bus.on(
232
+ "save",
233
+ queue(async (draft, { signal }) => {
234
+ await api.save(draft, { signal });
235
+ }),
236
+ );
237
+
238
+ // вложение: дождаться тишины, затем оставить только последний поиск
239
+ const searchLatest = latest(async (query, { signal }) => {
240
+ await api.search(query, { signal });
241
+ });
242
+ bus.on("search", debounce(300, searchLatest));
243
+ ```
244
+
245
+ Все параметры: [Политики](policies.md). Нет шины? Политика работает и как обычная функция: [`withPolicy`: вызов политики вне шины](policies.md#withpolicy-вызов-политики-вне-шины).
246
+
247
+ ## 5. Retry и timeout
248
+
249
+ **Эффект** добавляет async-обработчику то, что ему обычно нужно. `retry` и `timeout` оборачивают обработчик, как политика, поэтому идут в `on` без `ctx` в аргументах.
250
+
251
+ ```js
252
+ import { createBus } from "nanoeffects";
253
+ import { retry, timeout } from "nanoeffects/effects";
254
+ import { latest } from "nanoeffects/policies";
255
+
256
+ const bus = createBus();
257
+
258
+ const search = (query, { signal }) => api.search(query, { signal });
259
+
260
+ // timeout — одна попытка падает через 5 с
261
+ const searchOnce = timeout(5000, search);
262
+ // retry — до 3 попыток
263
+ const searchWithRetry = retry(searchOnce, { times: 3 });
264
+
265
+ // latest — новый введённый запрос прерывает всю цепочку повторов
266
+ bus.on("typed", latest(searchWithRetry));
267
+ ```
268
+
269
+ Когда обработчику нужен результат, `ctx.run` запускает обёрнутую функцию как дочернюю задачу (она прерывается вместе с родителем):
270
+
271
+ ```js
272
+ import { createBus } from "nanoeffects";
273
+ import { retry, timeout } from "nanoeffects/effects";
274
+ import { latest } from "nanoeffects/policies";
275
+
276
+ const bus = createBus();
277
+ const search = (query, { signal }) => api.search(query, { signal });
278
+
279
+ bus.on(
280
+ "search",
281
+ latest(async (query, ctx) => {
282
+ const load = retry(timeout(5000, search), { times: 3 });
283
+ const items = await ctx.run(load, query); // дочерняя задача; прерывается вместе с этой
284
+ ctx.emit("results", items);
285
+ }),
286
+ );
287
+ ```
288
+
289
+ Для одного вызова в теле обработчика: `await retry(ctx, () => api.search(q, { signal: ctx.signal }))`. Подробнее в [Эффектах](effects.md).
290
+
291
+ ## 6. Параллельные задачи и ожидание событий
292
+
293
+ ```js
294
+ import { createBus } from "nanoeffects";
295
+ import { all, delay, race, take } from "nanoeffects/effects";
296
+
297
+ const bus = createBus();
298
+
299
+ // all — запуск вместе; одна ошибка отменяет остальные
300
+ // child — контекст этого запроса: в нём есть `signal`, поэтому запрос останавливается вместе с группой
301
+ bus.on("search", async (id, ctx) => {
302
+ const { user, posts } = await all(ctx, {
303
+ user: (child) => api.user(id, child),
304
+ posts: (child) => api.posts(id, child),
305
+ });
306
+ ctx.emit("loaded", { user, posts });
307
+ });
308
+
309
+ // take — ждать следующее событие с таким именем
310
+ bus.on("typed", async (query, ctx) => {
311
+ const position = await take(ctx, "scroll");
312
+ ctx.emit("scrolled", { query, position });
313
+ });
314
+
315
+ // race — побеждает тот, кто закончил первым, второй отменяется
316
+ bus.on("save", async (draft, ctx) => {
317
+ const result = await race(ctx, {
318
+ cancelled: take("cancel"), // take без ctx — ждёт следующий "cancel"
319
+ waited: delay(1000), // delay без ctx — ждёт одну секунду
320
+ });
321
+
322
+ if ("waited" in result) {
323
+ ctx.emit("saved", draft);
324
+ }
325
+ });
326
+ ```
327
+
328
+ Подробнее в [Эффектах](effects.md).
329
+
330
+ ## 7. Способы отправки событий
331
+
332
+ `emit` и `on` принимают строку, объект `{ type, payload }` (подойдёт Redux-action) или функцию со свойством `type` (action creators и actions).
333
+
334
+ Дальше шина лежит в одном файле, а события — в файлах по имени домена (`move.js`, `math.js`), как в настоящем приложении. Так события можно импортировать где угодно, не импортируя шину.
335
+
336
+ ```js
337
+ // bus.js
338
+ import { createBus } from "nanoeffects";
339
+
340
+ export const bus = createBus();
341
+ ```
342
+
343
+ ```js
344
+ // move.js
345
+ import { createAction } from "nanoeffects/actions";
346
+
347
+ // createAction — называет событие; вызов строит { type, payload }
348
+ export const moved = createAction("moved");
349
+ ```
350
+
351
+ ```js
352
+ // math.js
353
+ import { createAction } from "nanoeffects/actions";
354
+
355
+ // createAction с обработчиком — emit запускает обработчик и возвращает его результат
356
+ export const double = createAction("math/double", (value) => value * 2);
357
+ ```
358
+
359
+ ```js
360
+ import { bus } from "./bus";
361
+ import { moved } from "./move";
362
+ import { double } from "./math";
363
+
364
+ bus.emit("moved", { x: 1, y: 2 });
365
+ bus.emit({ type: "moved", payload: { x: 2, y: 2 } });
366
+
367
+ bus.emit(moved({ x: 3, y: 2 }));
368
+ bus.on(moved, ({ x }) => console.log(x));
369
+
370
+ bus.emit(double(21)); // 42
371
+ ```
372
+
373
+ В простом случае берите строки. Объекты нужны, когда другой код уже строит actions. Передайте `createAction` обработчик, когда нужен результат от `emit`.
374
+
375
+ ## 8. Замена обработчика события
376
+
377
+ **Проблема.** Обработчик action лежит в файле фичи, а тесту нужна подделка, или работа принадлежит другому слою. Если положить обработчик в `createAction`, файл события окажется связан с работой.
378
+
379
+ **Что делать.** Создайте action без обработчика: это событие. Настоящий обработчик привяжите через `setHandler` при создании шины. Позже `actions.handle` привязывает новый обработчик, который главнее, пока вы его не снимете.
380
+
381
+ ```js
382
+ // user.js
383
+ import { createAction } from "nanoeffects/actions";
384
+
385
+ export const getUser = createAction("user/get"); // обработчика пока нет: событие
386
+ ```
387
+
388
+ ```js
389
+ // bus.js
390
+ import { createBus } from "nanoeffects";
391
+ import { actionsPlugin, setHandler } from "nanoeffects/actions";
392
+ import { getUser } from "./user";
393
+
394
+ export const bus = createBus();
395
+
396
+ // setHandler — привязывает настоящий обработчик к action
397
+ const realUser = setHandler(getUser, (id) => api.user(id));
398
+
399
+ // actionsPlugin — реестр привязанных обработчиков этой шины
400
+ export const actions = bus.use(actionsPlugin([realUser]));
401
+ ```
402
+
403
+ ```js
404
+ import { bus, actions } from "./bus";
405
+ import { getUser } from "./user";
406
+
407
+ const user = await bus.emit(getUser("1")); // отвечает настоящий обработчик
408
+
409
+ // actions.handle — новая привязка главнее, например подделка в тесте
410
+ const restore = actions.handle(getUser, (id) => ({ id, name: "test" }));
411
+ const fake = await bus.emit(getUser("1")); // отвечает подделка
412
+
413
+ restore(); // снова отвечает настоящий обработчик
414
+ ```
415
+
416
+ У каждой шины свой реестр. [Модуль](#9-создание-модулей) может привязывать обработчики и снимать их при размонтировании. Подробнее в [Actions](actions.md#привязка-обработчика-позже).
417
+
418
+ ## 9. Создание модулей
419
+
420
+ **Проблема.** Фича владеет на шине несколькими вещами: ответом на `getUser`, подпиской на `greet`, возможно, action с готовым обработчиком. С обычными `bus.on` и `actions.handle` нужно помнить каждую отписку, когда фича уходит. Забудете одну — подписки копятся.
421
+
422
+ **Когда.** Доменная логика живёт на шине вне UI, и фичу нужно подключать и отключать одним вызовом. Пока хватает нескольких `bus.on` верхнего уровня, модули не нужны.
423
+
424
+ **Что делать.** `createModule(name, setup)` описывает фичу. `bus.use(modulePlugin([…]))` подключает её. `app.unmount(module)` отменяет всё, что зарегистрировал `setup`.
425
+
426
+ ```js
427
+ // user.js
428
+ import { createAction } from "nanoeffects/actions";
429
+
430
+ export const getUser = createAction("user/get"); // обработчика пока нет
431
+ ```
432
+
433
+ ```js
434
+ // math.js
435
+ import { createAction } from "nanoeffects/actions";
436
+
437
+ export const double = createAction("math/double", (value) => value * 2); // несёт свой обработчик
438
+ ```
439
+
440
+ ```js
441
+ // greeting-module.js
442
+ import { createModule } from "nanoeffects/actions";
443
+ import { getUser } from "./user";
444
+ import { double } from "./math";
445
+
446
+ // createModule — одна фича; имя стабильно, чтобы hot reload мог заменить модуль
447
+ export const greeting = createModule("greeting", (m) => {
448
+ // m.on — как bus.on, снимается автоматически при размонтировании
449
+ m.on("greet", (name) => console.log(`hello ${name}`));
450
+
451
+ // m.handle — пока модуль подключён, он отвечает на getUser
452
+ m.handle(getUser, (id) => api.user(id));
453
+
454
+ // m.use — зарегистрировать action, у которого уже есть обработчик, на время жизни модуля
455
+ m.use(double);
456
+ });
457
+ ```
458
+
459
+ ```js
460
+ // bus.js
461
+ import { createBus } from "nanoeffects";
462
+ import { modulePlugin } from "nanoeffects/actions";
463
+ import { greeting } from "./greeting-module";
464
+
465
+ export const bus = createBus();
466
+ // modulePlugin — подключает модули к шине
467
+ export const app = bus.use(modulePlugin([greeting]));
468
+ ```
469
+
470
+ ```js
471
+ import { app, bus } from "./bus";
472
+ import { greeting } from "./greeting-module";
473
+
474
+ bus.emit("greet", "Ada"); // hello Ada
475
+ app.unmount(greeting); // подписка на greet и привязка getUser сняты
476
+ ```
477
+
478
+ - **`m.on`**: та же работа, что у `bus.on`, но на время жизни модуля.
479
+ - **`m.handle`**: привязывает обработчик к action, у которого его нет; при размонтировании исчезает.
480
+ - **`m.use`**: подключает action со своим обработчиком (или подмодуль). Если обработчик должен умереть вместе с фичей, лучше `handle`.
481
+
482
+ Модули также делают возможной [hot reload](hmr.md). Полный API — в [Actions](actions.md), более длинный пример — в [`examples/modules.ts`](../../examples/modules.ts).
483
+
484
+ ## 10. Поддержка TypeScript
485
+
486
+ Опишите события один раз, и `on` с `emit` начнут проверяться. Событие без данных — `void`.
487
+
488
+ ```ts
489
+ import { createBus } from "nanoeffects";
490
+
491
+ // Events — имя события → payload для этой шины (только для проверки типов)
492
+ type Events = {
493
+ greet: string;
494
+ moved: { x: number; y: number };
495
+ reset: void;
496
+ };
497
+
498
+ const bus = createBus<Events>();
499
+
500
+ bus.on("moved", ({ x, y }) => console.log(x + y));
501
+ bus.emit("moved", { x: 1, y: 2 });
502
+ bus.emit("reset");
503
+ // bus.emit("moved", { x: "1" }); // ошибка
504
+ // bus.emit("jumped", 1); // ошибка
505
+ ```
506
+
507
+ Без `createBus<Events>` разрешено любое строковое событие, а payload имеет тип `unknown`.
508
+
509
+ ## 11. Работа с ошибками
510
+
511
+ ```js
512
+ import { createBus } from "nanoeffects";
513
+
514
+ const bus = createBus({
515
+ onError: (error) => report(error), // изолировать упавшие обработчики
516
+ onUnhandled: ({ type }) => console.warn(`nobody listens to ${type}`),
517
+ });
518
+ ```
519
+
520
+ Подробнее в [Ошибках](errors.md).
521
+
522
+ ## Что дальше
523
+
524
+ | Нужно | Страница |
525
+ | ----- | -------- |
526
+ | Store или клиент на каждой задаче | [Поля контекста](context.md) |
527
+ | Redux / Zustand / MobX | [State managers](stores.md) |
528
+ | Наблюдать за задачами | [Трассировка](tracing.md) |
529
+ | Гарантии | [Семантика](semantics.md) |
530
+ | Какой импорт | [Точки входа пакета](package.md) |
package/docs/ru/hmr.md ADDED
@@ -0,0 +1,83 @@
1
+ # Hot reload и диагностика при разработке
2
+
3
+ > **[Уровень F](README.md#виды-проектов)**: платформа, SDK или долгоживущее приложение. Сначала прочитайте: [Actions, jobs и модули](actions.md).
4
+
5
+ Страница нужна, если вы подключаете домены через `modulePlugin`.
6
+
7
+ Когда вы правите файл, сборщик загружает его новую версию и запускает заново. Страница и остальной код продолжают работать. Для шины это грозит тремя проблемами:
8
+
9
+ 1. Файл, вызывающий `bus.on(...)`, подписывается второй раз.
10
+ 2. Файл, создающий шину, создаёт новую.
11
+ 3. Задачи старого кода продолжают выполняться.
12
+
13
+ ## Замена модулей по имени
14
+
15
+ Модуль с занятым именем заменяет подключённый. Сначала отключается старый модуль, а с ним его подписки, привязанные обработчики, подмодули и фоновые задачи (их сигналы прерываются). Новой версии файла достаточно подключиться снова: ничего не копится.
16
+
17
+ - **Имена должны быть уникальными.** Имя означает «это новая версия того модуля», поэтому два разных модуля с одним именем заменяют друг друга. В трассировке имя также служит типом задач модуля.
18
+ - **Передавайте `signal` в то, что делает модуль.** Отключение прерывает задачи, но `fetch` или цикл, который игнорирует `ctx.signal`, продолжит работать со старым кодом.
19
+ - **Подмодули** (`m.use(...)`) заменяются по имени внутри родителя. Родитель заменяется вместе со всеми подмодулями.
20
+ - Держите шину и приложение в файле, который не перезагружается (или который сборщик сохраняет между перезагрузками). Тогда перезагрузка не создаст новую шину, с которой другие файлы не связаны.
21
+
22
+ `bus.on` вне модуля возвращает функцию отписки. Вызывайте её в dispose-хуке сборщика или перенесите подписку в модуль.
23
+
24
+ ## Сообщения об утечках через `devPlugin`
25
+
26
+ `bus.use(devPlugin())` предупреждает о том, что легко упустить при разработке:
27
+
28
+ ```js
29
+ import { createBus } from "nanoeffects";
30
+ import { modulePlugin } from "nanoeffects/actions";
31
+ import { devPlugin } from "nanoeffects/dev";
32
+
33
+ const bus = createBus();
34
+ bus.use(modulePlugin([userModule]));
35
+ // devPlugin — предупреждать о накопившихся подписках; логировать подключение, замену и отключение модулей
36
+ // (он находит приложение modulePlugin на шине, в любом порядке этих двух)
37
+ bus.use(devPlugin());
38
+ ```
39
+
40
+ - **Накопившиеся подписки.** Если на одно событие подписано и не отписано больше `maxListeners` подписок (по умолчанию `10`), выходит одно предупреждение с названием события и числом. Счёт следит за функцией, которую возвращает `on`; `bus.off(event, fn)` он не видит.
41
+ - **Модули.** Для приложения `modulePlugin` (или параметра `app`) он логирует `module "user" mounted`, `replaced` (подключён модуль с тем же именем) и `unmounted`. В лог попадает только то, что идёт через `app.mount` и `app.unmount`; подмодули не попадают.
42
+ - **Где создана именованная подписка.** Для подписки с `traceId` он запоминает файл и строку вызова `bus.on`, пока подписка жива. [`graphLogger`](debugging-loops.md#чтение-отчёта) показывает это в отчёте о цикле, и вы сразу открываете обработчик, который замыкает цикл.
43
+ - **Обработчики, которые игнорируют сигнал.** Если обработчик отправляет событие после того, как его задачу прервали (новый вызов `latest`, размонтированный модуль), шина отбрасывает этот emit. Тогда плагин один раз предупреждает, называя обработчик и событие: обработчик не остановился при отмене, обычно потому что `ctx.signal` не дошёл до запроса. [Подробнее](#поиск-обработчика-который-игнорирует-сигнал)
44
+ - **Параметры.** `abandoned: false`, `leaks: false` и `modules: false` отключают диагностику; `maxListeners` задаёт предел; `log` получает сообщения вместо консоли.
45
+ - **Отключение.** `bus.use(devPlugin(...))` возвращает функцию, которая останавливает диагностику и возвращает `bus.on` и `app.mount` на место.
46
+
47
+ Поведение он не меняет: он оборачивает `bus.on`, `app.mount` и `app.unmount` и добавляет сообщения.
48
+
49
+ ### Поиск обработчика, который игнорирует сигнал
50
+
51
+ Политика вроде `latest` отменяет старый вызов, прерывая его `ctx.signal`. Обработчик, который не передал сигнал в запрос, продолжает работать, и его результат приходит позже нового. Политика отбрасывает этот результат, поэтому ничего не выглядит неправильно, пока обработчик не сделает с результатом что-то ещё.
52
+
53
+ ```js
54
+ import { createBus } from "nanoeffects";
55
+ import { latest } from "nanoeffects/policies";
56
+
57
+ const bus = createBus();
58
+
59
+ bus.on(
60
+ "search",
61
+ latest(async (query, ctx) => {
62
+ // сигнал не передан, поэтому старый запрос не отменяется
63
+ const items = await fetch(`/api?q=${query}`).then((response) => response.json());
64
+ ctx.emit("results", items);
65
+ }),
66
+ );
67
+ ```
68
+
69
+ С `devPlugin` второй `search` прерывает первый вызов. Когда первый вызов затем отправляет `results`, шина отбрасывает emit, а плагин один раз предупреждает:
70
+
71
+ ```text
72
+ [nanoeffects] the handler of "search" emitted "results" after its task was aborted, so the emit was dropped. The handler kept running after it was cancelled: pass ctx.signal to the request (fetch(url, { signal: ctx.signal })) or give ctx to an effect (retry(ctx, ...)).
73
+ ```
74
+
75
+ Проверка живёт только в плагине: ни политики, ни шина не несут для неё кода, а в production `nanoeffects/dev` пуст. Плагин видит обработчики, которые после отмены отправляют событие или подписываются через `ctx.on`; обработчик, который этого не делает, он не видит.
76
+
77
+ Линтер находит ту же ошибку до запуска кода, и гораздо больше: обработчик без `ctx`, шаг без сигнала при том, что другой его получает, вызовы `Promise.all`, callback `retry` и `timeout`, паузу, поток, `bus.emit` в обработчике. Страница [Сохранение сигнала в обработчике](pass-the-signal.md) перечисляет все ситуации, что их находит и как исправить, и показывает настройку ESLint, oxlint и Biome.
78
+
79
+ ### Импорт в production
80
+
81
+ `nanoeffects/dev` в production-сборке ничего не делает. `package.json` сопоставляет его с пустой сборкой по условию экспорта `production` (Vite и webpack задают его в режиме production), поэтому `bus.use(devPlugin())` можно оставить без защиты: он ничего не трогает и весит несколько байт.
82
+
83
+ Чтобы убрать даже вызов, оберните его в проверку (`if (import.meta.env.DEV)` в Vite). Приложение, которое не импортирует эту точку входа, её не содержит.