nanoeffects 0.29.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.
- package/README.md +6 -5
- package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
- package/dist/context-B5Z2RpJt.js +1 -0
- package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +44 -3
- package/dist/dev.js +2 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/emit-key-C6FL7Ewn.js +1 -0
- package/dist/guard.d.ts +59 -0
- package/dist/guard.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
- package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/production/dev.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +2 -2
- package/dist/signal.js +1 -1
- package/dist/site-DLvUoXs3.js +1 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +151 -2
- package/dist/trace.js +3 -1
- package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
- package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
- package/docs/README.md +62 -31
- package/docs/actions.md +44 -27
- package/docs/cancel-stale-requests.md +2 -0
- package/docs/chat-example.md +3 -1
- package/docs/chat-widget.md +2 -0
- package/docs/context.md +321 -9
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +283 -0
- package/docs/effects.md +255 -138
- package/docs/errors.md +4 -2
- package/docs/examples.md +60 -0
- package/docs/guide.md +39 -22
- package/docs/hmr.md +35 -1
- package/docs/loop-guard.md +109 -0
- package/docs/migrating-from-effector.md +4 -2
- package/docs/migrating-from-mitt.md +6 -4
- package/docs/migrating-from-rxjs.md +2 -0
- package/docs/migrating-from-saga.md +2 -0
- package/docs/motivation.md +14 -21
- package/docs/observable.md +2 -0
- package/docs/oop.md +3 -1
- package/docs/package.md +4 -1
- package/docs/pass-the-signal.md +281 -0
- package/docs/plugins.md +22 -5
- package/docs/policies.md +73 -46
- package/docs/prevent-double-submit.md +3 -1
- package/docs/query-libraries.md +3 -1
- package/docs/react.md +42 -13
- package/docs/recipes.md +528 -0
- package/docs/ru/README.md +62 -0
- package/docs/ru/actions.md +412 -0
- package/docs/ru/cancel-stale-requests.md +106 -0
- package/docs/ru/chat-example.md +259 -0
- package/docs/ru/chat-widget.md +131 -0
- package/docs/ru/context.md +401 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +283 -0
- package/docs/ru/effects.md +538 -0
- package/docs/ru/errors.md +36 -0
- package/docs/ru/examples.md +60 -0
- package/docs/ru/guide.md +530 -0
- package/docs/ru/hmr.md +83 -0
- package/docs/ru/loop-guard.md +109 -0
- package/docs/ru/migrating-from-effector.md +339 -0
- package/docs/ru/migrating-from-mitt.md +99 -0
- package/docs/ru/migrating-from-rxjs.md +197 -0
- package/docs/ru/migrating-from-saga.md +97 -0
- package/docs/ru/motivation.md +47 -0
- package/docs/ru/observable.md +233 -0
- package/docs/ru/oop.md +431 -0
- package/docs/ru/package.md +42 -0
- package/docs/ru/pass-the-signal.md +281 -0
- package/docs/ru/plugins.md +398 -0
- package/docs/ru/policies.md +201 -0
- package/docs/ru/prevent-double-submit.md +122 -0
- package/docs/ru/query-libraries.md +392 -0
- package/docs/ru/react.md +309 -0
- package/docs/ru/recipes.md +528 -0
- package/docs/ru/semantics.md +19 -0
- package/docs/ru/server.md +204 -0
- package/docs/ru/starter.md +118 -0
- package/docs/ru/stores.md +673 -0
- package/docs/ru/svelte.md +78 -0
- package/docs/ru/tracing.md +466 -0
- package/docs/ru/typescript.md +170 -0
- package/docs/ru/vue.md +92 -0
- package/docs/semantics.md +2 -0
- package/docs/server.md +2 -0
- package/docs/starter.md +2 -0
- package/docs/stores.md +2 -0
- package/docs/svelte.md +3 -1
- package/docs/tracing.md +58 -1
- package/docs/typescript.md +2 -0
- package/docs/vue.md +3 -1
- package/llms-full.txt +2008 -440
- package/llms.txt +11 -8
- package/package.json +22 -5
- package/dist/context-DtUnzC-1.js +0 -1
- package/docs/scope.md +0 -278
- package/docs/stability.md +0 -90
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Чат с сущностями, фичами, модулями и небольшим backend
|
|
2
|
+
|
|
3
|
+
> **[Уровень D](README.md#виды-проектов)**: доменная логика вне UI. Сначала прочитайте: [Actions, jobs и модули](actions.md).
|
|
4
|
+
|
|
5
|
+
Разбор приложения с несколькими фичами, которые реагируют друг на друга: где живёт каждый кусок логики. Пример — чат с комнатами, загружаемой историей, сообщениями, которые могут не отправиться, поиском по всем комнатам и новыми сообщениями, приходящими сами. Небольшой backend на Fastify сбоит по известному расписанию, поэтому каждая проблема на странице действительно происходит.
|
|
6
|
+
|
|
7
|
+
Это пример уровня D. Для API откройте [Actions, jobs и модули](actions.md), а для проекта поменьше — шаблон `domains-ts` из [Проекта из шаблона](starter.md#начало-с-доменами).
|
|
8
|
+
|
|
9
|
+
Код лежит в [`examples/chat`](../../examples/chat). Запустите его на [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat) или на своей машине:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cd examples/chat
|
|
13
|
+
npm install
|
|
14
|
+
npm run dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`npm run dev` запускает backend и Vite вместе. Чтобы отключить сбои, запустите `CHAOS=off npm run dev`.
|
|
18
|
+
|
|
19
|
+
## На этой странице
|
|
20
|
+
|
|
21
|
+
- [Как ведёт себя backend при сбоях](#как-ведёт-себя-backend-при-сбоях)
|
|
22
|
+
- [Как разделены файлы](#как-разделены-файлы)
|
|
23
|
+
- [Загрузка истории без гонки](#загрузка-истории-без-гонки)
|
|
24
|
+
- [Отправка сообщения, которое может не дойти](#отправка-сообщения-которое-может-не-дойти)
|
|
25
|
+
- [Поиск, где одна комната медленная](#поиск-где-одна-комната-медленная)
|
|
26
|
+
- [Ожидание комнаты перед отметкой результата](#ожидание-комнаты-перед-отметкой-результата)
|
|
27
|
+
- [Опрос новых сообщений](#опрос-новых-сообщений)
|
|
28
|
+
- [Где живёт каждое значение](#где-живёт-каждое-значение)
|
|
29
|
+
|
|
30
|
+
## Как ведёт себя backend при сбоях
|
|
31
|
+
|
|
32
|
+
Backend хранит данные в памяти и сбоит по фиксированному расписанию. Повторите каждый сценарий из таблицы:
|
|
33
|
+
|
|
34
|
+
| Что вы делаете | Что делает backend | Раздел |
|
|
35
|
+
| -------------- | ----------------- | ------ |
|
|
36
|
+
| Выбираете комнату, затем другую, прежде чем покажется первая | Комната Random загружает историю за 1,2 с, медленнее остальных. | [Загрузка истории без гонки](#загрузка-истории-без-гонки) |
|
|
37
|
+
| Отправляете сообщения | Каждая третья отправка отвечает 503. | [Отправка сообщения, которое может не дойти](#отправка-сообщения-которое-может-не-дойти) |
|
|
38
|
+
| Отправляете `/fail hello` | Отвечает 400. | [Отправка сообщения, которое может не дойти](#отправка-сообщения-которое-может-не-дойти) |
|
|
39
|
+
| Ищете слово, которое есть в Random | Random отвечает через 2,5 с. | [Поиск, где одна комната медленная](#поиск-где-одна-комната-медленная) |
|
|
40
|
+
| Ждёте | Новое сообщение появляется каждые 4 с. | [Опрос новых сообщений](#опрос-новых-сообщений) |
|
|
41
|
+
|
|
42
|
+
Пока вы это делаете, откройте панель Network в браузере. Запрос, который страница прерывает, показывается там как отменённый, а backend печатает для него `aborted: ...`.
|
|
43
|
+
|
|
44
|
+
## Как разделены файлы
|
|
45
|
+
|
|
46
|
+
Код разложен по слоям, и файл импортирует только из слоёв ниже себя. Компоненты только отправляют события и читают значения; backend они никогда не вызывают.
|
|
47
|
+
|
|
48
|
+
| Путь | Слой | Задача |
|
|
49
|
+
| ---- | ---- | ------ |
|
|
50
|
+
| `src/shared/bus.ts` | shared | Создаёт шину. Не импортирует ни события, ни сущности, ни фичи. |
|
|
51
|
+
| `src/shared/api.ts` | shared | Клиент backend: по одной функции на запрос. |
|
|
52
|
+
| `src/entities/room/` | сущность | Список комнат и выбранная комната. |
|
|
53
|
+
| `src/entities/message/` | сущность | История открытой комнаты, отправка сообщения и приходящее сообщение. |
|
|
54
|
+
| `src/features/show-history/` | фича | Выбранная комната загружает свою историю. |
|
|
55
|
+
| `src/features/search-messages/` | фича | Поиск по всем комнатам и открытие результата. |
|
|
56
|
+
| `src/features/receive-messages/` | фича | Новые сообщения, запрашиваемые с интервалом. |
|
|
57
|
+
| `src/ui/`, `src/app.ts` | верх | Компоненты и файл, который подключает модули. |
|
|
58
|
+
| `server/` | | Backend на Fastify и скрипт, запускающий его вместе с Vite. |
|
|
59
|
+
|
|
60
|
+
**Правило.** Сущность — вещь со своим state и командами (комната, сообщение). Она импортирует `shared` и больше ничего. Фича — сценарий, который связывает вещи: она импортирует `shared` и сущности и никогда другую фичу. Верх импортирует всё, что ниже. Никакие две сущности не импортируют друг друга, и никакие две фичи не импортируют друг друга, а тест в репозитории (`src/tests/architecture.test.ts`) падает, когда это случается.
|
|
61
|
+
|
|
62
|
+
Именно из-за этого правила существует `show-history`. Выбор комнаты должен загружать её историю, но `room` не знает `message`, а `message` не знает `room`. Фича `show-history` — единственное место, которое знает обоих: она слушает `rooms/selected` и запускает job `messages/loadHistory`. То же для приходящего сообщения: `receive-messages` отправляет `incoming/received` — событие, которым владеет и которое слушает сущность `message`.
|
|
63
|
+
|
|
64
|
+
В папке лежат её события (`room.ts`, не импортирующий шину), её работа (`room-handlers.ts`), state, которое она хранит (`room-store.ts`), и модуль (`room-module.ts`), который всё это связывает. Другой слой обращается к сущности через её actions, никогда через её обработчики или store. Все actions лежат в файле событий. Action без обработчика говорит, что что-то произошло, и любая фича может его слушать: `rooms/selected` слышат две. Action с обработчиком — это команда ровно с одним обработчиком, как `search/openResult` и `messages/mark`. Обработчик передаётся в `createAction`, поэтому action работает везде, где его вызывают, и может вернуть результат, а модуль привязывает его через `module.use` на всё время, пока он подключён. Job — тот же action с обработчиком, который ещё и отправляет свои исходы, но его обработчик привязывается в модуле (`module.handle`): job обычно работает под политикой, а тип его исходов точен, только когда обработчик не входит в тип.
|
|
65
|
+
|
|
66
|
+
Слои взяты из [Feature-Sliced Design](https://feature-sliced.design/), сокращённого до трёх, которых хватает приложению такого размера.
|
|
67
|
+
|
|
68
|
+
## Загрузка истории без гонки
|
|
69
|
+
|
|
70
|
+
**Проблема.** Читатель кликает по медленной комнате, а потом по быстрой. Если медленный ответ приходит последним, он показывает не ту комнату, а рукописный флаг загрузки остаётся `true`, когда запрос прерывается.
|
|
71
|
+
|
|
72
|
+
**Когда.** Загрузка стартует от выбора пользователя, который может измениться до её окончания.
|
|
73
|
+
|
|
74
|
+
Job отправляет `pending`, когда стартует его обработчик, и ровно одно из `success`, `fail` и `cancel`, когда он заканчивается. `latest` вокруг обработчика прерывает старую загрузку, когда начинается новая, поэтому доставляется только новый результат, а старая отправляет `cancel`:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// message.js
|
|
78
|
+
import { createJob } from "nanoeffects/actions";
|
|
79
|
+
|
|
80
|
+
// createJob — action с обработчиком, который ещё и отправляет pending, success, fail и cancel
|
|
81
|
+
export const loadHistory = createJob("messages/loadHistory");
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// message-handlers.js
|
|
86
|
+
import { latest } from "nanoeffects/policies";
|
|
87
|
+
|
|
88
|
+
// latest — загрузка для новой комнаты прерывает старую, и её результат никогда не доставляется
|
|
89
|
+
export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Модуль даёт job работу и ведёт state по исходам, так что прерванная загрузка тоже сбрасывает флаг (см. [State работы job](actions.md#state-работы-job)):
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
// message-module.js
|
|
96
|
+
import { createModule } from "nanoeffects/actions";
|
|
97
|
+
import { loadHistory } from "./message";
|
|
98
|
+
import { fetchHistory } from "./message-handlers";
|
|
99
|
+
|
|
100
|
+
export const messageModule = createModule("message", (module) => {
|
|
101
|
+
// handle — пока модуль подключён, он выполняет работу loadHistory
|
|
102
|
+
module.handle(loadHistory, fetchHistory);
|
|
103
|
+
module.on(loadHistory.pending, () => console.log("show the spinner"));
|
|
104
|
+
module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Отправка сообщения, которое может не дойти
|
|
109
|
+
|
|
110
|
+
**Проблема.** Сообщение должно появиться сразу, уйти в порядке отправки, быть повторено после сбоя и показаться неудавшимся, если так и не дошло. Исход `fail` содержит только ошибку, а не вызов, поэтому страница не может определить, какое сообщение упало.
|
|
111
|
+
|
|
112
|
+
**Когда.** У action есть видимый результат, который может не получиться и должен сохранять порядок.
|
|
113
|
+
|
|
114
|
+
`queue` сохраняет порядок, `retry` повторяет упавший вызов, а обработчик выбрасывает ошибку с идентификатором сообщения:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { retry } from "nanoeffects/effects";
|
|
118
|
+
import { queue } from "nanoeffects/policies";
|
|
119
|
+
|
|
120
|
+
class SendFailed extends Error {
|
|
121
|
+
constructor(localId, cause) {
|
|
122
|
+
super(cause.message);
|
|
123
|
+
this.localId = localId;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 400 — это ответ: повторный запрос даст тот же ответ. 503 или потерянное соединение могут пройти.
|
|
128
|
+
const isWorthRetrying = (error) => !(error.status < 500);
|
|
129
|
+
|
|
130
|
+
// retry — повторить упавшую отправку, до 3 попыток с паузой 200 мс, если ответ не был «нет»
|
|
131
|
+
const attempt = retry(
|
|
132
|
+
async (call, { signal }) => {
|
|
133
|
+
const message = await api.send(call.roomId, call.text, { signal });
|
|
134
|
+
return { localId: call.localId, message };
|
|
135
|
+
},
|
|
136
|
+
{ times: 3, delay: 200, shouldRetry: isWorthRetrying },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// queue — сообщения уходят одно за другим, в порядке отправки
|
|
140
|
+
export const deliver = queue(async (call, ctx) => {
|
|
141
|
+
try {
|
|
142
|
+
return await attempt(call, ctx);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new SendFailed(call.localId, error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Модуль добавляет сообщение на `pending`, отмечает его доставленным на `success` и отмечает сообщение, названное в ошибке, как неудавшееся на `fail`. Отправьте `/fail hello`, чтобы увидеть сбой, который не повторяется, и отправьте несколько сообщений, чтобы встретить 503, который повторяется.
|
|
150
|
+
|
|
151
|
+
## Поиск, где одна комната медленная
|
|
152
|
+
|
|
153
|
+
**Проблема.** Поиск опрашивает каждую комнату. Если он ждёт все, а одна комната медленная или сломана, читатель не видит ничего. Если он падает на первой ошибке, комнаты, которые ответили, теряются.
|
|
154
|
+
|
|
155
|
+
**Когда.** Один action рассылает запросы в несколько источников, и частичный ответ лучше, чем никакого.
|
|
156
|
+
|
|
157
|
+
`timeout` ограничивает каждый запрос, а `allSettled` ждёт окончания каждого запроса, удался он или нет. Обработчик хранит найденное и имена комнат, которые не ответили:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { allSettled, timeout } from "nanoeffects/effects";
|
|
161
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
162
|
+
|
|
163
|
+
const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
|
|
164
|
+
|
|
165
|
+
const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
|
|
166
|
+
|
|
167
|
+
// timeout — комната, не ответившая за 1,5 с, падает с ошибкой таймаута
|
|
168
|
+
const askRoom = (room, query) => {
|
|
169
|
+
const request = searchRoom(room, query);
|
|
170
|
+
return (parent) => timeout(parent, 1500, request);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// debounce — дождаться тишины; latest — новый запрос прерывает старый поиск
|
|
174
|
+
export const runSearch = debounce(
|
|
175
|
+
300,
|
|
176
|
+
latest(async (query, ctx) => {
|
|
177
|
+
// allSettled — спросить все комнаты сразу и дождаться каждой, как бы она ни закончила
|
|
178
|
+
const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
|
|
179
|
+
return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`all` провалил бы весь поиск на первой медленной комнате, а здесь это неверное поведение. Про остальные эффекты см. [Эффекты](effects.md).
|
|
185
|
+
|
|
186
|
+
## Ожидание комнаты перед отметкой результата
|
|
187
|
+
|
|
188
|
+
**Проблема.** Читатель открывает результат поиска из другой комнаты. Сообщение нельзя отметить, пока не загрузилась история той комнаты, а обработчик не должен гадать, сколько это займёт.
|
|
189
|
+
|
|
190
|
+
**Когда.** Фича запускает работу в сущности и должна продолжить после её исхода.
|
|
191
|
+
|
|
192
|
+
`take` ждёт следующее событие с таким именем. Запускайте его до `emit`, который вызывает событие, чтобы ничего не пропустить:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
import { createAction } from "nanoeffects/actions";
|
|
196
|
+
import { take } from "nanoeffects/effects";
|
|
197
|
+
|
|
198
|
+
// createAction с обработчиком — команда для сущности message, с обработчиком, который её выполняет
|
|
199
|
+
const markMessage = createAction("messages/mark", (messageId) => console.log("mark", messageId));
|
|
200
|
+
|
|
201
|
+
// открытая комната, запоминается из события rooms/selected
|
|
202
|
+
let selectedRoom = "general";
|
|
203
|
+
|
|
204
|
+
export const goToResult = async (hit, ctx) => {
|
|
205
|
+
if (hit.roomId !== selectedRoom) {
|
|
206
|
+
// take — ждать следующее событие с таким именем; запускается до emit, чтобы ничего не пропустить
|
|
207
|
+
const loaded = take(ctx, "messages/loadHistory/success");
|
|
208
|
+
ctx.emit("rooms/selected", hit.roomId);
|
|
209
|
+
await loaded;
|
|
210
|
+
}
|
|
211
|
+
// ctx.emit с action, у которого есть обработчик — сущность message отмечает сообщение
|
|
212
|
+
ctx.emit(markMessage(hit.id));
|
|
213
|
+
};
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Опрос новых сообщений
|
|
217
|
+
|
|
218
|
+
**Проблема.** Новые сообщения приходят с backend, и страница должна запрашивать их с интервалом. Опрос должен прекращаться, когда фича выключена, а один упавший запрос не должен его останавливать.
|
|
219
|
+
|
|
220
|
+
**Когда.** У фичи есть фоновая работа, которая живёт столько же, сколько сама фича.
|
|
221
|
+
|
|
222
|
+
`poll` вызывает функцию время от времени с интервалом и заканчивается, когда её контекст прерывается. Модуль запускает задачу через `spawn`, а отключение модуля её прерывает:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
import { createModule } from "nanoeffects/actions";
|
|
226
|
+
import { poll } from "nanoeffects/effects";
|
|
227
|
+
|
|
228
|
+
const watchUpdates = async (_, ctx) => {
|
|
229
|
+
// номер последнего увиденного обновления; первый ответ лишь сообщает, где backend
|
|
230
|
+
let since;
|
|
231
|
+
|
|
232
|
+
const check = async () => {
|
|
233
|
+
try {
|
|
234
|
+
const updates = await api.loadUpdates(since, { signal: ctx.signal });
|
|
235
|
+
since = updates.seq;
|
|
236
|
+
for (const message of updates.messages) {
|
|
237
|
+
// ctx.emit — после прерывания ничего не доставляет
|
|
238
|
+
ctx.emit("incoming/received", message);
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// один упавший запрос не должен заканчивать опрос: следующий тик спросит снова
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// poll — вызвать `check` сейчас и затем каждые 3 с; прерывание ctx (отключённый модуль) его заканчивает
|
|
246
|
+
await poll(ctx, check, 3000, { leading: true });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const receiveMessagesModule = createModule("receive-messages", (module) => {
|
|
250
|
+
// spawn — фоновая задача, живущая столько же, сколько этот модуль
|
|
251
|
+
module.spawn(watchUpdates);
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
В примере кнопка вызывает `app.unmount(receiveMessagesModule)` и `app.mount(receiveMessagesModule)`, так что вы можете увидеть, как опрос останавливается (панель Network больше не показывает запросов `updates`) и запускается.
|
|
256
|
+
|
|
257
|
+
## Где живёт каждое значение
|
|
258
|
+
|
|
259
|
+
Каждая сущность и каждая фича, у которых есть state, держат его в store [Zustand](https://zustand.docs.pmnd.rs/), который пишет модуль, а компонент читает через `useStore`. Каждый store перечисляет свои мутации, например `startLoading(roomId)` и `markFailed(localId)`, а модуль вызывает их с данными события: `module.on(loadHistory.pending, startLoading)`. Мутация, которая задаёт простые значения, передаёт `set` объект, как в `set({ searching: true })`. Store сущности message использует middleware `immer`, поэтому мутация, меняющая список, может менять его на месте, как в `state.messages.push(message)`, а store всё равно отдаёт React новый объект. Других записей state нет. Исключение — список комнат: он читается через один `useEventState` в провайдере в корне. `useEventState` — не store: событие, пришедшее до монтирования провайдера, теряется, а второй компонент получает свою копию. См. [Последнее значение события как state](react.md#последнее-значение-события-как-state).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Виджет чата, который слушает только пока открыт
|
|
2
|
+
|
|
3
|
+
> **[Уровень D](README.md#виды-проектов)**: доменная логика вне UI. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
|
|
4
|
+
|
|
5
|
+
Чат с ботом за кнопкой-лаунчером, где закрытый чат ничего не стоит. «Закрыт» значит: нет подписок на шине, опроса, обработчика клавиатуры и запроса в полёте. Открытие включает всё это, закрытие выключает одним вызовом.
|
|
6
|
+
|
|
7
|
+
Код лежит в [`examples/chat-widget`](../../examples/chat-widget): обычный DOM и TypeScript, поддельный бот в браузере, без backend. Запустите его на [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget) или на своей машине:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd examples/chat-widget
|
|
11
|
+
npm install
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Это пример уровня D: логика живёт в одном модуле, а страница его только подключает и отключает. Для приложения с несколькими фичами см. [Чат с сущностями, фичами, модулями и небольшим backend](chat-example.md).
|
|
16
|
+
|
|
17
|
+
## Что происходит при открытии и закрытии
|
|
18
|
+
|
|
19
|
+
| Момент | Что происходит |
|
|
20
|
+
| ------ | -------------- |
|
|
21
|
+
| Открытие | Модуль подключается: подписывается обработчик бота, опрашиваются сообщения оператора, добавляется обработчик `Escape`. Панель рисуется и слушает шину. |
|
|
22
|
+
| Пользователь отправляет текст | Тексты уходят боту по одному, в порядке отправки, с ограничением по времени и повтором. |
|
|
23
|
+
| `Escape` | Модуль отправляет `chat/closeRequested`; лаунчер закрывает чат. |
|
|
24
|
+
| Закрытие | Панель удаляется и отписывается, модуль отключается. Запрос в полёте прерывается, опрос останавливается, обработчик `Escape` исчезает. |
|
|
25
|
+
|
|
26
|
+
## Файлы
|
|
27
|
+
|
|
28
|
+
| Файл | Задача |
|
|
29
|
+
| ---- | ------ |
|
|
30
|
+
| `src/bus.ts` | Шина и `modulePlugin()` без модуля: лаунчер подключает модуль сам. |
|
|
31
|
+
| `src/chat.ts` | Только события: `messageSent`, `messageAdded`, `typingChanged`, `closeRequested`. |
|
|
32
|
+
| `src/chat-handlers.ts` | `askBot` (внутри `timeout` и `retry`) и `watchOperator` (`poll`): обычные `async`-функции, которые ничего не знают о странице. |
|
|
33
|
+
| `src/chat-module.ts` | Список того, что живо, пока чат открыт. |
|
|
34
|
+
| `src/panel.ts` | Вид: показывает строки, отправляет тексты, возвращает одну функцию, которая его удаляет. |
|
|
35
|
+
| `src/main.ts` | Лаунчер: единственный файл, знающий и модуль, и панель; он слушает `closeRequested`, только пока чат открыт. |
|
|
36
|
+
|
|
37
|
+
## Модуль виджета
|
|
38
|
+
|
|
39
|
+
Модуль — список того, что живо, пока чат открыт. При отключении шина отменяет каждую его строку.
|
|
40
|
+
|
|
41
|
+
<!-- docs-check: skip - остальные файлы примера лежат в examples/chat-widget -->
|
|
42
|
+
```js
|
|
43
|
+
// chat-module.js
|
|
44
|
+
import { createModule } from "nanoeffects/actions";
|
|
45
|
+
import { queue } from "nanoeffects/policies";
|
|
46
|
+
import { closeRequested, messageSent } from "./chat";
|
|
47
|
+
import { askBot, watchOperator } from "./chat-handlers";
|
|
48
|
+
|
|
49
|
+
// задача, которая остаётся в ожидании, пока модуль не отключится; тогда её сигнал прерывается
|
|
50
|
+
const untilAborted = (ctx) =>
|
|
51
|
+
new Promise((resolve) => {
|
|
52
|
+
ctx.signal.addEventListener("abort", resolve, { once: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// DOM-обработчик, живущий столько же, сколько модуль: `signal` снимает его при прерывании
|
|
56
|
+
const listenToEscape = async (_, ctx) => {
|
|
57
|
+
const onKey = (event) => {
|
|
58
|
+
if (event.key === "Escape") {
|
|
59
|
+
ctx.emit(closeRequested());
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
document.addEventListener("keydown", onKey, { signal: ctx.signal });
|
|
63
|
+
await untilAborted(ctx);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const chatModule = createModule("chat", (module) => {
|
|
67
|
+
// module.run — запускает askBot как дочернюю задачу модуля, так что отключение её прерывает
|
|
68
|
+
const answer = (text) => module.run(askBot, text);
|
|
69
|
+
// on + queue — отвечать на тексты один за другим, в порядке отправки
|
|
70
|
+
module.on(messageSent, queue(answer));
|
|
71
|
+
// spawn — фоновая задача, живущая столько же, сколько модуль
|
|
72
|
+
module.spawn(watchOperator);
|
|
73
|
+
module.spawn(listenToEscape);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Три детали, которые делают очистку полной:
|
|
78
|
+
|
|
79
|
+
- **`module.run`, а не `askBot` напрямую.** Обработчик из `module.on` запускается как дочерняя задача той, что отправила событие (здесь панели). Отключение прекращает слушать, но не прерывает уже идущий запрос. `module.run` делает запрос дочерним для модуля.
|
|
80
|
+
- **`queue`, а не `latest`.** `queue` сохраняет порядок, когда пользователь быстро отправляет два текста. [`latest`](policies.md) прервал бы первый ответ, что для разговора неверно.
|
|
81
|
+
- **DOM-обработчик с `signal`.** `addEventListener` принимает параметр `signal` и отписывает обработчик при прерывании. До этого задача остаётся в ожидании: завершённая задача повторно не прерывается.
|
|
82
|
+
|
|
83
|
+
## Открытие и закрытие из лаунчера
|
|
84
|
+
|
|
85
|
+
`app.mount` возвращает функцию, которая отключает модуль, `bus.on` — функцию отписки. Лаунчер объединяет их и функцию, удаляющую панель, в один `close`.
|
|
86
|
+
|
|
87
|
+
<!-- docs-check: skip - остальные файлы примера лежат в examples/chat-widget -->
|
|
88
|
+
```js
|
|
89
|
+
// main.js
|
|
90
|
+
import { app, bus } from "./bus";
|
|
91
|
+
import { closeRequested } from "./chat";
|
|
92
|
+
import { chatModule } from "./chat-module";
|
|
93
|
+
import { renderPanel } from "./panel";
|
|
94
|
+
|
|
95
|
+
const button = document.querySelector("#chat-launcher");
|
|
96
|
+
let close = null; // задано, пока чат открыт
|
|
97
|
+
|
|
98
|
+
const open = () => {
|
|
99
|
+
const stopModule = app.mount(chatModule); // стартуют подписки, опрос и Escape
|
|
100
|
+
const stopPanel = renderPanel(document.body);
|
|
101
|
+
// модуль отправляет closeRequested по Escape; пока чат закрыт, этого не делает никто
|
|
102
|
+
const stopEscape = bus.on(closeRequested, () => close?.());
|
|
103
|
+
close = () => {
|
|
104
|
+
stopEscape();
|
|
105
|
+
stopPanel();
|
|
106
|
+
stopModule(); // всё, что запустил модуль, заканчивается, запрос в полёте тоже
|
|
107
|
+
close = null;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
button.addEventListener("click", () => {
|
|
112
|
+
if (close) {
|
|
113
|
+
close();
|
|
114
|
+
} else {
|
|
115
|
+
open();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Пока чат закрыт, у лаунчера нет подписок на шине. Остаётся только DOM-обработчик клика по кнопке.
|
|
121
|
+
|
|
122
|
+
## Проверка очистки
|
|
123
|
+
|
|
124
|
+
1. Откройте чат и отправьте текст. Бот отвечает примерно через секунду. Каждый третий вопрос падает, и повтор спрашивает снова.
|
|
125
|
+
2. Отправьте текст и закройте чат до прихода ответа. Строка не появится, в том числе когда вы снова откроете чат.
|
|
126
|
+
3. Посмотрите на блок «Listeners on the bus»: он показывает `bus.get(event).length` для четырёх событий и обновляется, когда чат открывается или закрывается. Закрыт: у каждого события `0`, и у «all events» тоже. Открыт: у каждого из четырёх по одной подписке, а «all events» — `4`. Так видно, что ничего не осталось подписанным.
|
|
127
|
+
4. Подождите 10 с при закрытом чате. Оператор не опрашивается: строка от оператора не появляется.
|
|
128
|
+
5. Нажмите `Escape` при закрытом чате. Ничего не происходит. Откройте чат и нажмите снова: он закрывается.
|
|
129
|
+
6. Снова откройте чат. Старые строки вернулись, и опрос работает.
|
|
130
|
+
|
|
131
|
+
`src/tests/chat-widget-example.test.ts` проверяет те же пункты без браузера.
|